익스클루시브
Deliberation <mv-deliberation>
비공개 투표와 눈에 보이는 수렴을 갖춘 라운드 기반 그룹 의사 결정입니다. 추정(플래닝 포커), 진행 여부 결정, 채용 디브리핑, 디자인 크리틱, 우선순위 지정, 회고 액션 투표에 적합합니다.
문화적 레퍼런스
12인의 성난 사람들, 시드니 루멧(레지널드 로즈의 1954년 TV 드라마 원작)(1957년, 영화). 배심원단은 닫힌 문 뒤에서 거듭 투표하고, 한 배심원의 의심이 묵살되지 않고 경청되면서 라운드를 거칠 때마다 표결이 11 대 1에서 만장일치 평결로 바뀌어 갑니다. UI에서는 팀이 비밀리에 투표하고, 함께 공개하고, 그룹과 동떨어진 투표자의 의견을 먼저 들은 뒤 다시 투표하며, 합의 규칙이 충족될 때까지 라운드마다 분산이 좁혀지는 것을 지켜보고, 평결은 그 이유와 함께 기록됩니다.
작동 방식
비공개 투표와 눈에 보이는 수렴 과정을 갖춘 라운드 기반 그룹 의사 결정: 추정(플래닝 포커), 진행 여부 결정, 채용 디브리핑, 디자인 크리틱, 우선순위 결정 또는 회고 액션 투표. 각 참가자는 척도(피보나치, 티셔츠 사이즈, 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 |
| Keywords | exclusive, 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/marvelousMarvelous 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 | 유형 | Default | Description |
|---|---|---|---|
people | string | Participants as comma-separated names ("Priya Nair, Aiko Tanaka"); ids are slugs of the names. Use the people property for ids, short names and avatars. | |
scale | fibonacci | tshirt | go | hire | confidence | "1-10" | list | fibonacci | Voting 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. |
measure | numeric | ordinal | nominal | How 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. | |
me | person id | The 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. | |
facilitator | boolean | Shows the facilitator controls: Reveal votes, Start round N, and the decision form (value + rationale) once the rule allows it. | |
anonymous | boolean | Rounds 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. | |
rule | unanimous | supermajority | majority | facilitator | supermajority | Consensus rule checked at each reveal. facilitator: never met automatically, the facilitator makes the call after any reveal. |
threshold | ratio ("2/3", "0.75", "80%") | 2/3 | Share of counted votes a supermajority needs (abstentions, “?” and missing votes are not counted). |
tolerance | number (steps) | 0 | Votes within this many steps of the candidate agree with it (1 = neighbors on the scale, e.g. 5 and 8). Ignored for nominal. |
outlier | number (steps) | 2 | Distance 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. |
timer | time ("60s", "2m", ms) | Per-round countdown shown in the header (role="timer"). At zero, mv-timeout fires (cancelable) and the round is revealed. | |
auto-reveal | boolean | Reveals as soon as every participant has voted (after a short beat). | |
abstain | "true" | "false" | true | "false" removes the Abstain option from the ballot. |
max-rounds | number | After this many rounds without consensus the facilitator may decide anyway (“Round limit reached”). | |
question | string | The question being decided, shown as the title and used as the group's accessible name. | |
unit | string | Unit appended to numeric values in sentences, stats and the decision ("pts", "days"). | |
locale | BCP 47 tag | en-US | Locale for lists (“Aiko and Mateo”), numbers and the decision date. |
label | string | Accessible name of the group when question is not set (default “Group decision”). | |
data-phase / data-revealing | set by the component | data-phase="voting | revealed | decided"; data-revealing while mv-reveal waitUntil promises are pending. |
Properties
| Name | 유형 | Description |
|---|---|---|
people | Array<{ id?, name, short?, avatar?, role? }> | Participants. short is used in sentences (default: first word of name); avatar is an image URL (initials otherwise). |
options | Array<{ 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. |
rounds | Array<{ 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 } | null | The recorded decision. Set it to hydrate a past decision; set null to reopen the discussion. |
round / phase / votes / stats | read-only | Current 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 } }. |
strings | Partial<Record<string, string>> | Overrides for every visible text, sentence and announcement ({placeholders}); English defaults. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-vote | Cancelable, 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-reveal | Cancelable, 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-revealed | The cards have flipped. detail: { round, votes, stats, summary (the announced sentences), source }. |
mv-reveal-error | A waitUntil promise rejected. detail: { round, error }. |
mv-round | Cancelable, before a new round starts. detail: { round (the new number), previous (stats of the round just discussed), source }. |
mv-decision | Cancelable, before the decision is recorded. detail: { value, label, note, rule, met, round, agree, counted, stats, source }. |
mv-timeout | Cancelable: the round timer reached zero. preventDefault() keeps the round open (the timer is hidden). |
CSS classes
| Name | Description |
|---|---|
mv-deliberation-head / -kicker / -question / -rule / -timer | Header: round and phase, question, rule and anonymity chips, countdown with its ring (data-urgent in the last 10 s). |
mv-deliberation-seats / -seat / -card | The 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 / -next | Vote count, progress bar and facilitator buttons (mv-button classes). |
mv-deliberation-ballot / -option / -option-face | The 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 / -insights | Revealed 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 / -axis | Convergence 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 / -record | Decision form (value chips, rationale textarea) and the recorded decision card. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-deliberation-accent | var(--mv-accent) | Ballot selection, consensus candidate, median marker. |
--mv-deliberation-outlier | var(--mv-warning) | Tint of the voters far from the group (always paired with a dashed outline, a flag and words). |
--mv-deliberation-agree | var(--mv-success) | Consensus reached: agreeing cards, chart dot, decision card. |
--mv-deliberation-card-back | var(--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.