独家
Turn Clock <mv-turn-clock>
公平计时的轮流发言管理器,适用于站会、回顾会、辩论、面试、圆桌讨论和结对编程轮换,仿照锦标赛国际象棋钟设计:每位参与者拥有一个时间库(均等,或按人自定义),只在自己的回合内流逝,同一时间只有一个时钟在走,交出回合(大号 Pass 按钮、空格键、数字键或点击某人)会停止你的时钟并启动下一个,可选每回合加时。
文化出处
《后翼弃兵》,沃尔特·特维斯(小说,1983 年;Netflix 剧集由斯科特·弗兰克和艾伦·斯科特创作,2020 年)(1983 年,小说)。在锦标赛国际象棋中,每位棋手都有一个只在自己走棋时流逝的时间储备,按下棋钟即结束你的回合并开始对手的计时,因此时间被公平且可见地分配。这里,每位会议参与者都拥有这样一个时间储备:同一时刻只有一个时钟在走,交接即把发言权交给下一个人,会议结束时会给出一份公平发言时长汇总。
工作原理
面向站会、回顾会、辩论、面试、座谈和 mob programming 轮换的公平计时发言管理器,仿照锦标赛国际象棋棋钟打造:每位参与者拥有一个时间储备(均等,或按人自定义),只在自己的回合中消耗,同一时刻只有一个时钟在走;交接回合(大号 Pass 按钮、空格键、数字键或点击某个人)会停止你的计时并启动下一位的计时,可选每回合加时。当前发言者以大尺寸突出显示,并配有逐渐缩短的进度条;名单中每一行都以进度条和时钟显示各自的时间储备;时间用完时从不发出警报:时间会在平静的斜纹图案和 “Overtime” 标签后变为负数(或在设置 on-empty 时自动交接或暂停)。顶部栏跟踪会议相对于计划的进度;顺序可以是轮流、随机或发言最少者优先(回合最少的人总是先发言),可以把人标记为离开,误操作的交接可以撤销并退回时间,rounds 可以自动结束会议。结束时会生成发言时长汇总(每人实际占比与分配占比的对比、回合数、平均回合时长、超时以及平衡分数),可复制为文本或下载为 CSV。时间根据时钟偏移计算,从不依赖累计的 tick(对后台标签页很稳健),启用 persist 后会话在页面重新加载后依然保留,getSnapshot()/restore() 让你同步或恢复会话。
| 分类 | 数据展示 |
|---|---|
| 类型 | Web Component(<mv-turn-clock>) |
| 状态 | 稳定版 |
| 同时安装 | button, kbd |
| Keywords | exclusive, culture, timer, turn-taking, meeting, standup, retro, debate, facilitation, chess-clock, time-bank, speaking-time, fairness, airtime, mob-programming, rotation, countdown, csv-export, keyboard |
When to use
- A daily standup or retro keeps overrunning because a few people take most of the time and nobody wants to interrupt them
- A debate, panel, pitch round or interview loop must give every side the same speaking time, visibly and without arguing
- A mob or pair programming rotation needs a fair driver timer where handing over is one key press
- A facilitator wants to see after the meeting who actually got the floor and export it for the team notes
Avoid when
- There is a single countdown for one task or one presenter and nobody takes turns
- The time limit belongs to the user's session or a security timeout rather than a conversation → use Hatch instead
- People compare their pace against a previous attempt of the same task rather than share a meeting → use Ghost Run instead
安装
node scripts/add.mjs turn-clock --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["turn-clock"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/kbd/kbd.css, core/dom.js, core/element.js, core/focus.js, components/turn-clock/turn-clock.js, components/turn-clock/turn-clock.css。
用法
标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<div id="tc-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#tc-demo { display:grid; gap:1.25rem; align-content:start }
#tc-demo .tc-controls { display:flex; flex-wrap:wrap; align-items:center; gap:.75rem 1.25rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#tc-demo .tc-field { display:inline-flex; align-items:center; gap:.5rem; font-size:.8125rem; color:var(--mv-fg-muted) }
#tc-demo .tc-field .mv-select { width:auto; min-width:10.5rem }
#tc-demo .mv-choice { font-size:.8125rem }
#tc-demo .tc-spacer { flex:1 1 0 }
#tc-demo .tc-log { display:flex; align-items:center; gap:.625rem; min-height:1.5rem; margin:0; color:var(--mv-fg-muted); font:.75rem/1.4 var(--mv-font-mono) }
#tc-demo .tc-sub { display:grid; gap:.625rem }
#tc-demo .tc-sub h4 { margin:0; font-size:.875rem; font-weight:600 }
#tc-demo .tc-sub p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
</style>
<!-- Main: a daily standup, already under way (restored from a snapshot below) -->
<mv-turn-clock id="tc-standup" label="Daily standup · Platform team" bank="2m" planned="13m" rounds="1" hotkeys="global">
<span data-person="amara" data-role="Backend · payments">Amara Okafor</span>
<span data-person="kenji" data-role="Frontend · checkout">Kenji Watanabe</span>
<span data-person="priya" data-role="Product design">Priya Raman</span>
<span data-person="lucas" data-role="QA automation">Lucas Moreau</span>
<span data-person="sofia" data-role="Data engineering">Sofia Almeida</span>
<span data-person="daniel" data-role="Engineering lead" data-bank="3m">Daniel Kim</span>
</mv-turn-clock>
<div class="tc-controls">
<label class="tc-field">Order
<select class="mv-select" id="tc-order" data-size="sm">
<option value="round-robin">Round robin</option>
<option value="random">Random</option>
<option value="least-spoken">Quietest first</option>
</select>
</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tc-inc"> +10 s per turn</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tc-auto"> Auto-pass at zero</label>
<span class="tc-spacer"></span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tc-ff">Skip ahead 45 s</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="tc-replay">Replay demo</button>
</div>
<p class="tc-log"><span class="mv-badge" data-variant="secondary" id="tc-event">mv-turn</span><span id="tc-log">Priya Raman took the floor from Kenji Watanabe.</span></p>
<!-- Two-sided debate: equal banks, a 15-second increment, paused mid-way -->
<div class="tc-sub">
<div>
<h4>Architecture review · two-sided debate</h4>
<p>A true chess clock: 3 minutes per side, 15 seconds back on every hand-over. Shortcuts work while focus is inside it.</p>
</div>
<mv-turn-clock id="tc-debate" label="Proposal: move billing to event sourcing" bank="3m" increment="15s" warn="30s">
<span data-person="for" data-role="For the proposal">Lucía Fernández</span>
<span data-person="against" data-role="Against the proposal">Tomás Novak</span>
</mv-turn-clock>
</div>
<script type="module">
const standup = document.getElementById("tc-standup");
const debate = document.getElementById("tc-debate");
const log = document.getElementById("tc-log");
const badge = document.getElementById("tc-event");
const say = (event, text) => { badge.textContent = event; log.textContent = text; };
const s = (n) => n * 1000;
// A standup that started 4 min 44 s ago: Amara and Kenji are done, Priya is speaking.
function seedStandup() {
const now = Date.now();
standup.restore({
state: "running", current: "priya", elapsed: s(52), savedAt: now, startedAt: now - s(94 + 138 + 52),
people: [
{ id: "amara", bank: s(26), spent: s(94), turns: 1, longest: s(94) },
{ id: "kenji", bank: -s(18), spent: s(138), turns: 1, longest: s(138), overtime: s(18) },
{ id: "priya", bank: s(120) }, { id: "lucas", bank: s(120) }, { id: "sofia", bank: s(120) }, { id: "daniel", bank: s(180) },
],
log: [
{ id: "amara", name: "Amara Okafor", start: 0, duration: s(94) },
{ id: "kenji", name: "Kenji Watanabe", start: s(94), duration: s(138) },
],
});
}
// Debate: Lucía opened (two turns), Tomás holds the floor, the moderator paused.
function seedDebate() {
const now = Date.now();
debate.restore({
state: "paused", current: "against", elapsed: s(41), savedAt: now, startedAt: now - s(260),
people: [
{ id: "for", bank: s(180 - 71 - 58 + 30), spent: s(129), turns: 2, longest: s(71) },
{ id: "against", bank: s(180 - 49 + 15), spent: s(49), turns: 1, longest: s(49) },
],
log: [
{ id: "for", name: "Lucía Fernández", start: 0, duration: s(71) },
{ id: "against", name: "Tomás Novak", start: s(71), duration: s(49) },
{ id: "for", name: "Lucía Fernández", start: s(120), duration: s(58) },
],
});
}
const who = (p) => (p ? p.name : "nobody");
standup.addEventListener("mv-turn", (e) => {
const { from, to, reason } = e.detail;
say("mv-turn", reason === "start" ? `${who(to)} opened the session.` : reason === "undo" ? `Undo: back to ${who(to)}.` : `${who(to)} took the floor from ${who(from)}${reason === "timeout" ? " (time ran out)" : ""}.`);
});
standup.addEventListener("mv-warn", (e) => say("mv-warn", `${e.detail.person.name} has 1 minute left.`));
standup.addEventListener("mv-overtime", (e) => say("mv-overtime", `${e.detail.person.name} is over time. Nothing flashes: the clock just goes negative.`));
standup.addEventListener("mv-finish", (e) => {
const { summary } = e.detail;
say("mv-finish", `Finished in ${Math.round(summary.duration / 1000)} s · airtime balance ${summary.balance === null ? "n/a" : Math.round(summary.balance * 100) + "%"}.`);
});
standup.addEventListener("mv-away", (e) => say("mv-away", `${e.detail.person.name} is ${e.detail.away ? "away" : "back"}.`));
document.getElementById("tc-order").addEventListener("change", (e) => { standup.order = e.target.value; });
document.getElementById("tc-inc").addEventListener("change", (e) => { standup.increment = e.target.checked ? "10s" : "0"; });
document.getElementById("tc-auto").addEventListener("change", (e) => { standup.onEmpty = e.target.checked ? "pass" : "continue"; });
document.getElementById("tc-ff").addEventListener("click", () => {
// Fast-forward the running turn by resuming a snapshot 45 s older.
if (standup.state !== "running" && standup.state !== "paused") return;
const snap = standup.getSnapshot();
snap.elapsed += 45_000;
snap.startedAt -= 45_000;
standup.restore(snap);
});
document.getElementById("tc-replay").addEventListener("click", () => {
standup.reset();
seedStandup();
say("mv-turn", "Priya Raman took the floor from Kenji Watanabe.");
});
customElements.whenDefined("mv-turn-clock").then(() => { seedStandup(); seedDebate(); });
</script>
</div>API
Attributes
| Name | 类型 | Default | Description |
|---|---|---|---|
bank | time ("2m", "90s", "1m30s", ms number) | 2m | Time bank given to every participant who has no data-bank of their own. Changing it mid-session shifts the remaining bank of those people by the difference. |
increment | time | 0 | Time added to a person's bank each time they end a turn by passing (a chess-style increment). Turns shorter than one second (an accidental double pass) earn nothing and don't count as a turn. |
warn | time | 1m | Remaining time under which the speaker's clock turns to the low tone and “1 minute left for …” is announced once (skipped when the turn already starts below it). |
planned | time | Planned meeting length shown in the header meter (“of 15:00 planned”, then “1:12 over plan”). Defaults to the sum of the present people's banks times rounds. | |
order | round-robin | random | least-spoken | round-robin | Who a plain pass goes to. round-robin and random always pick among the people with the fewest turns so far (list order after the current speaker, or a random one that stays shown as “Up next” until the pass); least-spoken picks whoever has spoken the least time. Clicking a person or pressing their digit always overrides it. |
rounds | number | 0 | When set, the session finishes by itself once every present person has had that many turns (the Pass button reads “Pass & finish” on the last turn). 0 = unlimited. |
on-empty | continue | pass | pause | continue | What happens when the speaker's bank reaches zero: continue into calm negative overtime, pass to the next person at the exact moment of zero (the overshoot of a throttled tab is carried over), or pause the session. |
hotkeys | local | global | off | local | Where the keyboard shortcuts listen: while focus is inside the component, anywhere on the page (never while typing in a field, and never for keys aimed at another turn clock), or nowhere. |
label | string | Turn clock | Session name shown in the header; also the accessible name of the region and the CSV file name. |
persist | string | localStorage key suffix: the session is saved on every change and when the page is hidden, and restored on load, so a reload or a crashed tab resumes where it was (running time included). | |
sound | boolean | Opt-in: one soft two-note tone when a bank runs out. Only after the user has interacted with the page, never an alarm loop. | |
data-person / data-role / data-bank / data-avatar / data-away | on child elements | Each child element is a participant: its text is the name, data-person the stable id (defaults to a slug of the name), data-role a subtitle, data-bank a custom time bank, data-avatar an image URL (initials otherwise), data-away starts them out of the rotation. Children are re-read when a framework re-renders them; stats are kept by id. | |
data-state / data-phase | set by the component | On the host: ready | running | paused | finished, and ok | low | over for the current speaker. Rows get data-status (speaking, paused, next, done, idle, away) and data-phase. |
Properties
| Name | 类型 | Description |
|---|---|---|
people | Array<string | { id?, name, role?, bank?, avatar?, away? }> | Set to replace the child-element roster (stats are kept for matching ids). Read it for live person objects { id, name, role, index, allotted, remaining, spent, turns, away }. |
state | "ready" | "running" | "paused" | "finished" | Session state (read-only). |
current / next | person | null | The person holding the turn, and who a plain pass would give it to (the first speaker before start). Read-only. |
elapsed | number (ms) | Meeting time so far, pauses excluded (read-only). |
summary | object | Live airtime summary (final once finished): { label, state, startedAt, endedAt, duration, planned, overPlan, turns, averageTurn, balance (Jain's fairness index of time spent relative to each allotment, 1 = exactly as allotted, null before anyone spoke), people: [{ id, name, role, allotted, spent, share, expected, turns, average, longest, overtime, remaining, away }], log: [{ id, name, start, duration }] }. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (English defaults; placeholders like {name}, {time}, {n}). |
Methods
| Name | Description |
|---|---|
start(who?) | Starts the session with a person (id, index or person object) or the first in order. Returns false if not ready or cancelled. |
pass() | Ends the current turn and gives it to next (starts the session when ready, finishes it when the last round completes). |
passTo(who) | Gives the turn to a specific person, bringing them back if they were away. |
pause() / resume() / toggle() | Freezes or restarts the running clock. |
undo() | Gives the turn back to the previous speaker and charges them the time since the pass (their bank, turn count and increment are restored). Up to 50 passes deep. |
finish({ reason? }) | Ends the session (emits the cancelable mv-finish) and shows the summary. |
reset() | New session: banks refilled, stats and log cleared, back to ready. |
setAway(who, away = true) | Takes someone out of the rotation (a current speaker passes on first) or brings them back. |
adjust(who, time) | Adds time to a bank, or removes it with a negative value ("30s", -15000): a bonus, a penalty, a time-out. |
getSnapshot() / restore(snapshot) | Serializable session state and its restoration (a running snapshot keeps counting the time since it was saved). Use it to sync a facilitator's screen to a shared display or resume a session server-side. |
toText() / toCSV() | The airtime summary as paste-ready plain text, or as CSV (one row per person plus totals). |
Events
| Name | Description |
|---|---|
mv-turn | Cancelable, before the turn changes. detail: { from (person | null at start), to, reason: "start" | "pass" | "pick" | "timeout" | "away" | "undo", duration (ms of the turn that ends) }. preventDefault() keeps the current speaker. |
mv-warn | The speaker crossed the warn threshold. detail: { person, remaining }. |
mv-overtime | The speaker's bank reached zero during this turn. detail: { person, overtime }. |
mv-finish | Cancelable, before the session ends. detail: { summary, reason: "button" | "rounds" | "timeout" | "api" }. |
mv-away | Someone was marked away or back. detail: { person, away }. |
Content structure
| Name | Description |
|---|---|
(content) | One child element per participant (see data-person…). They are hidden and only read as data; the UI is rendered in .mv-turn-clock-ui. |
CSS classes
| Name | Description |
|---|---|
mv-turn-clock-head | Header: -title, -meta, -pill (state), -elapsed, -planned and -meter (meeting vs plan). |
mv-turn-clock-stage | Current speaker: -avatar[data-size=lg], -eyebrow (with the -eq speaking bars), -name, -role, -digits, -over-tag, -sub, -drain (bank bar), -next, -undo, -pause, -pass. |
mv-turn-clock-row / mv-turn-clock-pick / mv-turn-clock-away | Roster row, its main button (-index, -avatar, -person-name, -bar, -time, -status) and the Away toggle. |
mv-turn-clock-summary | After finish: -stats (dl), -table (with -share bars and -share-mark for the allotted share), export buttons. |
mv-turn-clock-foot | Shortcut legend (-keys) and the Finish button. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-turn-clock-accent | var(--mv-accent) | Current speaker, bars and airtime shares. |
--mv-turn-clock-low | var(--mv-warning) | Tone under the warn threshold. |
--mv-turn-clock-over | var(--mv-info) | Overtime tone and striped pattern (deliberately calm, not danger red). |
--mv-turn-clock-avatar-bg / --mv-turn-clock-avatar-fg | hue derived from the id | Override the initials avatar colors. |
Accessibility
The host is a labelled region. The current speaker and their time live in a visually hidden role="timer" element (implicit aria-live off): it is always up to date for screen readers that read it on demand, but never chatters every second. A separate polite live region speaks only on real events: turn changes (“Kenji Watanabe’s turn. 2 minutes left.”), the warn threshold (“1 minute left for Kenji Watanabe.”), overtime (“Kenji Watanabe is over time.”), pause, resume, undo, away and finish. Every control is a real button: the roster is an ordered list with one tab stop (arrow keys, Home and End move between people, Right reaches the Away toggle, Left comes back), the speaker's row has aria-current, and each row is described by a sentence (time left or over, turns, status). Shortcuts are listed in a visible legend and exposed with aria-keyshortcuts: Space or N passes (Space still activates a focused button natively), 1-9 give the turn to that person, P pauses and resumes, Backspace or Ctrl/Cmd+Z undoes the last pass; they never fire while typing in a field, and hotkeys="global" makes them work from anywhere on the page. States never rely on color alone: speaking, paused, up next and away each have a word and a distinct marker, overtime shows a minus sign, a striped pattern and an “Overtime” tag, away people are struck through. Finishing moves focus to the summary title when focus was inside the component; the summary is a real table with a caption, row headers and a sentence per share (“23% of the airtime, 17% allotted”). Reduced motion (OS setting or data-motion="reduce"): bars step each second instead of gliding and the speaking bars stand still. Forced colors: bars get system-color borders, fills use Highlight and overtime a CanvasText hatch. Overtime is never an alarm: no flashing, no red by default, sound is opt-in and plays once. Known limits: the clock runs in one browser; to show the same session on several screens, drive each one from your own server through the API and events. Shortcuts 1-9 reach the first nine people only.