独家

Deliberation <mv-deliberation>

按轮次进行的群体决策,投票隐藏、收敛可见:估算(规划扑克)、是否上线决策、招聘复盘、设计评审、优先级排序或回顾会行动项投票。

文化出处

十二怒汉,西德尼·吕美特(改编自雷金纳德·罗斯 1954 年的电视剧本)(1957 年,电影)。陪审团在密室里一轮又一轮地投票,一位陪审员的疑虑被认真听取而不是被压下,票数从 11 比 1 一步步走向全票一致的裁决。在 UI 中,团队匿名投票、同时揭晓,先听取与多数意见相差最远的投票者,再次投票,看着分歧一轮轮缩小,直到满足共识规则,裁决连同理由一起被记录下来。

工作原理

基于轮次的群体决策,投票隐藏、收敛可见:估算(计划扑克)、go/no-go 决策、招聘复盘、设计评审、优先级排序或回顾会行动项投票。每位参与者在一个量表上私下投票(Fibonacci、T 恤尺码、go/no-go、录用、信心、数值范围或你自己的列表,可选 “?” 和 Abstain);桌面上用背面朝上的卡片显示谁已投票,但绝不显示投了什么。揭晓时所有卡片同时翻开,随后分析本轮结果:分布(每位投票者显示为一个标签)、中位数、范围、一致度、共识候选项,并以纯文本点名偏离群体的投票者(“Aiko and Mateo voted far from the group (13 pts and 1 pt): hear them first”),存在两派分歧时则显示两派。小组讨论后再次投票;收敛图把每一轮绘制在同一量表上,可以看到范围逐渐收窄;一旦满足规则(全体一致、绝对多数、简单多数或由主持人决定,可选轮次上限),主持人就记录决定及其理由。匿名轮次按数值排列卡片,与姓名脱钩,事后也保持匿名;可选的计时器会在时间到时自动揭晓。数据由应用掌握:它通过自己的后端用 vote()、markVoted()(服务器对数值保密)和 reveal(votes) 同步投票,每一步都会经过可取消的 mv-vote、mv-reveal(带 waitUntil)、mv-round 和 mv-decision 事件。

分类数据展示
类型Web Component(<mv-deliberation>)
状态稳定版
同时安装button, textarea
Keywordsexclusive, culture, voting, vote, consensus, planning-poker, estimation, story-points, go-no-go, decision, decision-log, hiring, debrief, retro, team, collaboration, facilitation, outliers, convergence, chart, realtime, anonymous

When to use

  • A team estimates backlog items together and needs independent votes before anyone anchors on the loudest number
  • A release, launch or incident review needs an explicit go/no-go where every dissent is surfaced and heard
  • A hiring debrief or design critique should collect private ratings first, then discuss the outliers and re-vote
  • A decision must be logged with its rule, tally and rationale once a group actually reaches consensus

Avoid when

  • People each contribute something different toward one shared goal rather than voting on one question → use Stone Soup instead
  • A single person rates a product or answer as form input → use Rating instead
  • A one-shot poll or survey with many questions and no discussion rounds: a form or poll tool is simpler

安装

node scripts/add.mjs deliberation --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["deliberation"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, components/textarea/textarea.css, components/textarea/char-count.js, core/motion.js, components/deliberation/deliberation.js, components/deliberation/deliberation.css。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="dl-demo" style="display:grid;gap:1.75rem;width:min(100%,68rem);margin-inline:auto">
  <style>
    #dl-demo .dl-bar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1.5rem }
    #dl-demo .dl-bar h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
    #dl-demo .dl-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #dl-demo .dl-controls { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem 1.25rem }
    #dl-demo .dl-controls .mv-choice { font-size:.8125rem }
    #dl-demo .dl-hint { margin:-.75rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    #dl-demo .dl-sep { height:1px; background:var(--mv-border) }
  </style>

  <div class="dl-bar">
    <div>
      <h3>Sprint 42 planning</h3>
      <p>You are facilitating. Round 1 was far apart; round 2 is under way.</p>
    </div>
    <div class="dl-controls">
      <label class="mv-choice">
        <input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="dl-anon">
        <span class="mv-choice-text"><span class="mv-choice-title">Anonymous</span></span>
      </label>
      <label class="mv-choice">
        <input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="dl-timer">
        <span class="mv-choice-text"><span class="mv-choice-title">60 s timer</span></span>
      </label>
      <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="dl-restart">Restart</button>
    </div>
  </div>

  <mv-deliberation id="dl-estimate" scale="fibonacci" unit="pts" rule="supermajority" me="priya" facilitator
    question="Offline sync for the mobile app (MOB-412): how many points?"></mv-deliberation>
  <p class="dl-hint">Pick your card, then Reveal votes: every card flips at once · Start the next round and watch the convergence chart narrow</p>

  <div class="dl-sep"></div>

  <div class="dl-bar">
    <div>
      <h3>Release go/no-go</h3>
      <p>Read-only room display · anonymous votes · unanimity required</p>
    </div>
  </div>
  <mv-deliberation id="dl-go" scale="go" rule="unanimous"
    question="Roll Checkout v3 out to 100% of traffic on Thursday?"></mv-deliberation>

  <script type="module">
    const est = document.getElementById("dl-estimate");
    const go = document.getElementById("dl-go");
    const $ = (id) => document.getElementById(id);

    const team = [
      { id: "priya", name: "Priya Nair" },
      { id: "aiko", name: "Aiko Tanaka" },
      { id: "mateo", name: "Mateo Rossi" },
      { id: "lars", name: "Lars Eriksen" },
      { id: "amara", name: "Amara Okafor" },
      { id: "diego", name: "Diego Fernández" },
    ];
    const hour = 36e5;
    const start = Date.now() - hour / 2;
    const seed = () => [
      { votes: { priya: 5, aiko: 13, mateo: 1, lars: 5, amara: 8, diego: 3 }, revealed: true, startedAt: start, revealedAt: start + 4 * 6e4 },
      { votes: { aiko: 8, lars: 5, amara: 8, diego: 5 }, startedAt: Date.now() - 45e3 },
    ];
    // Teammates' next votes, round by round (the viewer votes for themself).
    const plans = {
      2: { mateo: 5 },
      3: { aiko: 8, mateo: 5, lars: 5, amara: 5, diego: 5 },
    };
    const later = { aiko: 5, mateo: 5, lars: 5, amara: 5, diego: 8 };
    let timers = [];
    const schedule = (round) => {
      timers.forEach(clearTimeout);
      timers = [];
      const plan = plans[round] ?? later;
      Object.entries(plan).forEach(([id, value], i) => {
        timers.push(setTimeout(() => { if (est.isConnected && est.round === round) est.vote(id, value); }, 900 + i * 650 + Math.random() * 400));
      });
    };

    est.people = team;
    est.rounds = seed();
    await customElements.whenDefined("mv-deliberation");
    schedule(2);
    est.addEventListener("mv-round", (e) => schedule(e.detail.round));
    $("dl-anon").addEventListener("change", (e) => { est.anonymous = e.target.checked; });
    $("dl-timer").addEventListener("change", (e) => { est.timer = e.target.checked ? "60s" : null; });
    $("dl-restart").addEventListener("click", () => {
      est.decision = null;
      est.rounds = seed();
      schedule(2);
    });

    go.people = [
      { id: "noor", name: "Noor Haddad" },
      { id: "felix", name: "Felix Wagner" },
      { id: "sade", name: "Sade Adeyemi" },
      { id: "kenji", name: "Kenji Watanabe" },
      { id: "lucia", name: "Lucía Morales" },
      { id: "oren", name: "Oren Levi" },
    ];
    const day = Date.now() - 2 * hour;
    go.anonymous = true;
    go.rounds = [
      { votes: { noor: "go", felix: "no-go", sade: "conditional", kenji: "go", lucia: "go", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day },
      { votes: { noor: "go", felix: "conditional", sade: "conditional", kenji: "go", lucia: "conditional", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day + 9e5 },
      { votes: { noor: "conditional", felix: "conditional", sade: "conditional", kenji: "conditional", lucia: "conditional", oren: "conditional" }, revealed: true, anonymous: true, startedAt: day + 18e5 },
    ];
    go.decision = {
      value: "conditional",
      note: "Ship behind the checkout-v3 kill switch. Payments on-call confirms a rollback in under 5 minutes before the ramp goes past 25%.",
      rule: "unanimous",
      round: 3,
      agree: 6,
      counted: 6,
      at: day + 21e5,
    };
  </script>
</div>

API

Attributes

Name类型DefaultDescription
peoplestringParticipants as comma-separated names ("Priya Nair, Aiko Tanaka"); ids are slugs of the names. Use the people property for ids, short names and avatars.
scalefibonacci | tshirt | go | hire | confidence | "1-10" | listfibonacciVoting scale. Presets: fibonacci (0 1 2 3 5 8 13 21 ?), tshirt (XS…XXL), go (No-go, Go with conditions, Go), hire (Strong no hire … Strong hire), confidence (1-5 with hints). A range "1-10", or a list "1, 2, 3, 5, 8, ?" / "a:Option A, b:Option B" (value:label). "?" is neutral: shown, never counted.
measurenumeric | ordinal | nominalHow distances are read. Auto: numeric when every value is a number, otherwise ordinal (steps along the scale). nominal (unordered choices, e.g. which feature first) drops median, range and splits; outliers become lone votes against a majority.
meperson idThe viewer. Shows the private ballot (a radio group) for this person, their own card face up with a “You” tag. Without it the component is a read-only display.
facilitatorbooleanShows the facilitator controls: Reveal votes, Start round N, and the decision form (value + rationale) once the rule allows it.
anonymousbooleanRounds revealed while it is on are anonymous: cards are laid out by value with no names, chips carry no initials, sentences give counts instead of names. The flag is stored per round, so turning it off later never unmasks a past round.
ruleunanimous | supermajority | majority | facilitatorsupermajorityConsensus rule checked at each reveal. facilitator: never met automatically, the facilitator makes the call after any reveal.
thresholdratio ("2/3", "0.75", "80%")2/3Share of counted votes a supermajority needs (abstentions, “?” and missing votes are not counted).
tolerancenumber (steps)0Votes within this many steps of the candidate agree with it (1 = neighbors on the scale, e.g. 5 and 8). Ignored for nominal.
outliernumber (steps)2Distance from the median, in scale steps, from which a minority vote is named as far from the group. Also the gap that makes two camps a split.
timertime ("60s", "2m", ms)Per-round countdown shown in the header (role="timer"). At zero, mv-timeout fires (cancelable) and the round is revealed.
auto-revealbooleanReveals as soon as every participant has voted (after a short beat).
abstain"true" | "false"true"false" removes the Abstain option from the ballot.
max-roundsnumberAfter this many rounds without consensus the facilitator may decide anyway (“Round limit reached”).
questionstringThe question being decided, shown as the title and used as the group's accessible name.
unitstringUnit appended to numeric values in sentences, stats and the decision ("pts", "days").
localeBCP 47 tagen-USLocale for lists (“Aiko and Mateo”), numbers and the decision date.
labelstringAccessible name of the group when question is not set (default “Group decision”).
data-phase / data-revealingset by the componentdata-phase="voting | revealed | decided"; data-revealing while mv-reveal waitUntil promises are pending.

Properties

Name类型Description
peopleArray<{ id?, name, short?, avatar?, role? }>Participants. short is used in sentences (default: first word of name); avatar is an image URL (initials otherwise).
optionsArray<{ value, label?, short?, hint?, neutral? }>Custom scale; overrides the scale attribute. short is used on cards and axes, hint under the ballot card, neutral options are shown but not counted.
roundsArray<{ votes: { [id]: value }, voted?: id[], revealed?, anonymous?, startedAt?, revealedAt? }>Hydrate or read the whole history (silent: no events). The last round is the current one; voted lists people whose value is still secret. Reading returns copies with a round number.
decision{ value, label, note, rule, met, round, agree, counted, at } | nullThe recorded decision. Set it to hydrate a past decision; set null to reopen the discussion.
round / phase / votes / statsread-onlyCurrent round number; "voting" | "revealed" | "decided"; current votes { id: value } (null for secret ones); analysis of the latest revealed round: { total, voted, counted, abstained, unsure, missing, distribution, median, mean, min, max, spread, candidate, agreement, outliers: [{ personId, value, distance }], split, consensus: { rule, met, value, agree, counted, needed } }.
stringsPartial<Record<string, string>>Overrides for every visible text, sentence and announcement ({placeholders}); English defaults.

Methods

NameDescription
vote(personId, value)Records a vote from your realtime feed: a scale value, "abstain", or null to withdraw. Emits the cancelable mv-vote (source "api"). Returns false outside the voting phase, for unknown people or values.
markVoted(personId)The person voted but the server keeps the value secret: the card turns face down with no value in the DOM. Supply values at reveal time.
reveal(votes?)Reveals the round (any number of votes). votes { id: value } fills secret values. Emits mv-reveal first, flips every card, then mv-revealed. Returns Promise<boolean>.
nextRound()Starts the next round with hidden votes; emits the cancelable mv-round.
decide(value, note)Records the decision for the current revealed round (any non-neutral scale value, whatever the rule: detail.met says whether it was met). Emits the cancelable mv-decision.
reset()Clears the history and the decision: one empty round.
analyzeRound(votes, config) (module export)The pure analysis used internally (Map of id → value, options, people, measure, rule, threshold, tolerance, outlierSteps), for server-side checks or tests. ABSTAIN is exported too.

Events

NameDescription
mv-voteCancelable, before a vote is stored. detail: { personId, value (null = withdrawn), previous, round, source: "ui" | "api" }. Send ui votes to your backend here; preventDefault() rejects it (the ballot reverts).
mv-revealCancelable, before the reveal. detail: { round, source: "ui" | "api" | "timer" | "auto", voted, total, missing, waitUntil(promise) }. A waitUntil promise may resolve to { id: value } to disclose secret votes; a rejection cancels the reveal (announced, mv-reveal-error).
mv-revealedThe cards have flipped. detail: { round, votes, stats, summary (the announced sentences), source }.
mv-reveal-errorA waitUntil promise rejected. detail: { round, error }.
mv-roundCancelable, before a new round starts. detail: { round (the new number), previous (stats of the round just discussed), source }.
mv-decisionCancelable, before the decision is recorded. detail: { value, label, note, rule, met, round, agree, counted, stats, source }.
mv-timeoutCancelable: the round timer reached zero. preventDefault() keeps the round open (the timer is hidden).

CSS classes

NameDescription
mv-deliberation-head / -kicker / -question / -rule / -timerHeader: round and phase, question, rule and anonymity chips, countdown with its ring (data-urgent in the last 10 s).
mv-deliberation-seats / -seat / -cardThe table. Each seat has data-state="waiting | voted | revealed | abstained | missing", data-me, data-own (own value shown), data-outlier, data-agree (with the met consensus), data-anonymous.
mv-deliberation-toolbar / -progress / -reveal / -nextVote count, progress bar and facilitator buttons (mv-button classes).
mv-deliberation-ballot / -option / -option-faceThe viewer's private ballot: a fieldset of native radios styled as cards; data-locked after the reveal.
mv-deliberation-results / -stats / -dist / -col / -chip / -median / -insightsRevealed round: stats, distribution columns (data-candidate, data-window), voter chips (data-outlier), median marker and insight sentences (li[data-kind="count | consensus | pending | facilitator | outliers | split"]).
mv-deliberation-history / -chart / -row / -track / -range / -dot / -med / -axisConvergence chart: one row per round (data-current, data-pending for the round being voted), range bar, value dots sized by share, median marker, agreement share.
mv-deliberation-decide / -recordDecision form (value chips, rationale textarea) and the recorded decision card.

CSS variables

NameDefaultDescription
--mv-deliberation-accentvar(--mv-accent)Ballot selection, consensus candidate, median marker.
--mv-deliberation-outliervar(--mv-warning)Tint of the voters far from the group (always paired with a dashed outline, a flag and words).
--mv-deliberation-agreevar(--mv-success)Consensus reached: agreeing cards, chart dot, decision card.
--mv-deliberation-card-backvar(--mv-accent)Tint of the face-down card back pattern.

Accessibility

The component is a labelled group (the question). Seats are a list: each item reads the name and a status (“Thinking…”, “Voted”, “Your vote: 5 pts”, then the revealed value, “far from group”, “Abstained” or “No vote”); the card graphics are aria-hidden, and hidden values never exist in the DOM before the reveal (except the viewer's own). The ballot is a native fieldset with a legend (“Your vote · Round 2”) and radios, so arrow keys, Tab and screen readers work as in any form; Abstain is a radio too and Withdraw is a real button. Reveal, Start round and Record decision are native buttons (aria-busy while revealing); after a reveal from the button, focus moves to Start round, after a new round to the ballot, after a decision to the decision card. Results are given three ways: visible sentences (counts, median and range, consensus, outliers named with their values, or the two camps of a split), a visually hidden table per round (voter, vote, note; value and count in anonymous rounds) and a visually hidden table of all rounds for the convergence chart, whose drawing is aria-hidden. A polite live region announces “Everyone has voted.”, the full summary at each reveal, each new round, the 10-second warning and the decision; a failed reveal is announced assertively. The timer is role="timer" with a spoken aria-label and is not live. Nothing relies on color alone: outliers have a dashed outline, a flag and the words “Far from group”, consensus has a check and a sentence, the median has a marker, anonymous rounds say so. Reduced motion (prefers-reduced-motion or data-motion="reduce"): cards swap faces instantly, no lift and no breathing on waiting seats. Forced colors: cards, tracks and chips use system colors, the selected option and the consensus use Highlight. While revealing, the Reveal button keeps focus with aria-busy and aria-disabled (never disabled), and focus moves to Start round as soon as the votes are shown.

本页面由 AI 翻译。报告翻译问题