エクスクルーシブ

Turn Clock <mv-turn-clock>

スタンドアップ、レトロ、ディベート、面接、パネルディスカッション、モブプログラミングの交代のための、時間を公平に配分する順番管理ツールで、トーナメント用のチェスクロックのように作られています。参加者はそれぞれ持ち時間(全員同じ、または個別に設定)を持ち、それは自分の番の間だけ減ります。同時に動く時計は常に 1 つだけで、順番を渡す(大きな Pass ボタン、スペースキー、数字キー、または人物のクリック)と自分の時計が止まり次の時計が動き出します。1 手ごとの加算時間も任意で設定できます。

文化的リファレンス

クイーンズ・ギャンビット、ウォルター・テヴィス(小説、1983年。Netflix のドラマ版はスコット・フランク & アラン・スコット、2020年)(1983年、書籍)。トーナメントのチェスでは各プレイヤーが自分の手番の間だけ減る持ち時間を持ち、時計を押すと自分の手番が終わって相手の時計が動き出すため、時間は公平かつ目に見える形で分け合われます。ここでは会議の参加者全員がそうした持ち時間を持ちます:一度に動く時計は 1 つだけで、ターンを渡すと次の人に発言権が移り、セッションは発言時間の公平さを示すサマリーで締めくくられます。

仕組み

スタンドアップ、振り返り、討論、面接、パネル、モブプログラミングのローテーション向けの公平なターン管理で、トーナメントのチェスクロックのように作られています。各参加者は自分のターンの間だけ減る持ち時間(均等、または個別に設定)を持ち、同時に動く時計は常に 1 つだけです。ターンを渡すと(大きな Pass ボタン、Space キー、数字キー、または人をクリック)自分の時計が止まって次の人の時計が動き出し、ターンごとの加算時間も設定できます。現在の発言者は減っていくバーとともに大きく表示され、名簿の各行は自分の持ち時間をバーと時計で示します。時間切れでもアラームは鳴りません:落ち着いたストライプ模様と「Overtime」タグの後ろで時間がマイナスに進みます(on-empty を指定すると、自動で次に渡すか一時停止します)。ヘッダーは計画に対する会議の進行を追跡します。順番はラウンドロビン、ランダム、または発言の少ない人優先(ターン数が最も少ない人が常に先)にでき、不在の人をマークでき、誤ったパスは時間を戻して取り消せ、rounds でセッションを自動終了させることもできます。終了すると発言時間のサマリー(割り当てに対する各人の発言比率、ターン数、平均ターン時間、超過時間、バランススコア)が作成され、テキストとしてコピーするか CSV でダウンロードできます。時間はティックの数え上げではなく時計のオフセットから算出され(バックグラウンドタブでも正確です)、persist を指定すればセッションはリロード後も残り、getSnapshot()/restore() で同期や再開ができます。

カテゴリーデータ表示
タイプWeb Component(<mv-turn-clock>)
ステータス安定版
同時にインストールされるものbutton, kbd
Keywordsexclusive, 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タイプDefaultDescription
banktime ("2m", "90s", "1m30s", ms number)2mTime 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.
incrementtime0Time 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.
warntime1mRemaining 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).
plannedtimePlanned 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.
orderround-robin | random | least-spokenround-robinWho 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.
roundsnumber0When 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-emptycontinue | pass | pausecontinueWhat 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.
hotkeyslocal | global | offlocalWhere 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.
labelstringTurn clockSession name shown in the header; also the accessible name of the region and the CSV file name.
persiststringlocalStorage 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).
soundbooleanOpt-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-awayon child elementsEach 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-phaseset by the componentOn 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
peopleArray<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 / nextperson | nullThe person holding the turn, and who a plain pass would give it to (the first speaker before start). Read-only.
elapsednumber (ms)Meeting time so far, pauses excluded (read-only).
summaryobjectLive 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 }] }.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (English defaults; placeholders like {name}, {time}, {n}).

Methods

NameDescription
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

NameDescription
mv-turnCancelable, 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-warnThe speaker crossed the warn threshold. detail: { person, remaining }.
mv-overtimeThe speaker's bank reached zero during this turn. detail: { person, overtime }.
mv-finishCancelable, before the session ends. detail: { summary, reason: "button" | "rounds" | "timeout" | "api" }.
mv-awaySomeone was marked away or back. detail: { person, away }.

Content structure

NameDescription
(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

NameDescription
mv-turn-clock-headHeader: -title, -meta, -pill (state), -elapsed, -planned and -meter (meeting vs plan).
mv-turn-clock-stageCurrent 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-awayRoster row, its main button (-index, -avatar, -person-name, -bar, -time, -status) and the Away toggle.
mv-turn-clock-summaryAfter finish: -stats (dl), -table (with -share bars and -share-mark for the allotted share), export buttons.
mv-turn-clock-footShortcut legend (-keys) and the Finish button.

CSS variables

NameDefaultDescription
--mv-turn-clock-accentvar(--mv-accent)Current speaker, bars and airtime shares.
--mv-turn-clock-lowvar(--mv-warning)Tone under the warn threshold.
--mv-turn-clock-overvar(--mv-info)Overtime tone and striped pattern (deliberately calm, not danger red).
--mv-turn-clock-avatar-bg / --mv-turn-clock-avatar-fghue derived from the idOverride 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.

このページは AI で翻訳されています。翻訳の問題を報告