익스클루시브

Turn Clock <mv-turn-clock>

스탠드업, 회고, 토론, 인터뷰, 패널, 몹 프로그래밍 순번을 위한 공정한 시간 배분 순서 관리자로, 대회용 체스 시계처럼 만들어졌습니다. 모든 참여자가 자기 차례에만 줄어드는 시간 잔고(동일하게 또는 사람별로 지정)를 가지며, 한 번에 정확히 하나의 시계만 작동하고, 차례를 넘기면(큰 Pass 버튼, Space, 숫자 키, 또는 사람 클릭) 내 시계가 멈추고 다음 사람의 시계가 시작됩니다. 차례마다 시간을 추가하는 옵션도 있습니다.

문화적 레퍼런스

퀸스 갬빗, 월터 테비스 (소설, 1983; 스콧 프랭크 & 앨런 스콧의 Netflix 시리즈, 2020) (1983, 도서). 대회 체스에서 각 선수는 자기 차례에만 흐르는 시간 은행을 가지며, 시계를 누르면 자신의 턴이 끝나고 상대의 턴이 시작되므로 시간이 공정하고 눈에 보이게 공유됩니다. 여기서는 회의 참가자마다 그런 시간 은행을 갖습니다. 한 번에 하나의 시계만 흐르고, 턴을 넘기면 다음 사람에게 발언권이 넘어가며, 세션은 공정한 발언 시간 요약으로 끝납니다.

작동 방식

스탠드업, 회고, 토론, 인터뷰, 패널, 몹 프로그래밍 교대를 위한 공정한 시간 배분 턴 관리자로, 대회용 체스 시계처럼 만들어졌습니다. 모든 참가자는 자기 턴 동안에만 줄어드는 시간 은행(균등하게, 또는 사람마다 따로 지정)을 가지며, 정확히 하나의 시계만 흐르고, 턴을 넘기면(큰 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로 번역되었습니다. 번역 문제 신고