エクスクルーシブ
Balance Sheet <mv-balance-sheet>
2列で構成された意思決定のバランスシート(長所と短所、自作か購入か、採用するかどうか、ベンダー選定、引っ越し)。各ポイントが向かい合う台帳の形でレイアウトされます。
文化的リファレンス
ロビンソン・クルーソー、ダニエル・デフォー(1719年、書籍)。島に取り残されたクルーソーは、自分の境遇を「借方と貸方のように」2列に書き出し、ひとつひとつの悪い点に良い点を向かい合わせて、絶望するのではなく冷静に状況を判断しました。UI はこれを意思決定のための台帳にします:ポイントは2列で比較され、各論点には同じ行の反論で答えることができ、反論はその論点を部分的に相殺します。答えのない懸念は本当の未解決の問題として目立ち、天秤のビームと平易な判定の1行が、バランスがどちらに傾くかを示します。
仕組み
2列で構成された意思決定のバランスシート(長所と短所、自作か購入か、採用するかどうか、ベンダー選定、引っ越し)。各ポイントが向かい合う台帳の形でレイアウトされます。各ポイントは編集可能な短い1行で、5つの目盛りとして描かれたネイティブの range で1から5の重みを設定でき(キーボード、ドラッグ、クリック)、重みを調整する確信度(certain、likely、unsure)も任意で指定できます。どのポイントにも、「Answer with…」メニューから反対側の列のポイント(ペアになっていないポイントを選ぶか、新しく書く)で答えることができます:ペアは同じ行に移動し、ガターをまたぐアルファベット付きのコネクターで結ばれ、両方のポイントが部分的に相殺されます(offset 属性、デフォルトは小さいほうのスコアの50%)。そのため、答えの出たノイズは合計から外れ、まだ未解決のものがより正確な重みを持ちます。誰も答えていない懸念は本当の未解決の問題として強調表示され、「Not yet answered」の区切りの下にまとめられ、平易な判定の1行(「Leaning yes · 17.6 vs 11.9 · 3 unanswered concerns」)でカウントされます。そのカウントをクリックすると、未解決の懸念から次の懸念へ移動します。支点の上のビームは、重いほうへスプリングで傾きます(モーション軽減時は静的な角度)。複数の選択肢をタブごとに1枚のシートで比較でき、各タブには正味の値が表示されます。さらに Compare タブでは、それらを発散型の正味バーと「Best balance」マーク付きのテーブルでランク付けします。キャンセル可能な mv-export を通じて Markdown(クリップボード)と JSON(ダウンロード)をエクスポートでき、localStorage に保存することもできます。フォーム関連付け(form-associated)にも対応しています:JSON の値には各選択肢の結果が含まれ、required と require-answers(すべての懸念に回答済み)が有効性を決定します。mv-change はすべての編集を通知し、mv-verdict は傾きが反対側に変わったときに発火し、mv-remove と mv-pair はキャンセル可能です。
| カテゴリー | フォーム |
|---|---|
| タイプ | Web Component(<mv-balance-sheet>) |
| ステータス | 安定版 |
| 同時にインストールされるもの | button |
| Keywords | exclusive, culture, decision, pros-and-cons, decision-matrix, trade-off, weighted, comparison, vendor-selection, build-vs-buy, risk, verdict, ledger, scale, balance, form, export, markdown, json, adr |
When to use
- A team must decide yes or no on a proposal (a 4-day week, a rewrite, a hire) and wants the reasoning written down and weighed
- Several vendors, tools or offers must be compared on strengths and risks before one is picked
- Every concern raised in a review should be explicitly answered or left visibly open before a decision is submitted
- A decision record needs to be exported to a doc, ticket or ADR as Markdown or stored as structured JSON
Avoid when
- The user is picking one of a few ready-made options (plans, shipping) and needs quick help, not a written argument → use Lifelines instead
- Options should be compared on computed numbers from the same inputs (price, rate, term) rather than on arguments → use Fork instead
- The goal is to find a product in a large catalog by answering questions → use Narrow Down instead
インストール
node scripts/add.mjs balance-sheet --out ./src/marvelousMarvelous UI の MCP サーバーを使う AI エージェント:install_components({ slugs: ["balance-sheet"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/form.js, core/motion.js, core/position.js, components/balance-sheet/balance-sheet.js, components/balance-sheet/balance-sheet.css。
使い方
基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="bs-demo" style="width:min(100%,74rem);margin-inline:auto">
<style>
#bs-demo { display:grid; gap:1.5rem; align-content:start }
#bs-demo .bs-layout { display:grid; grid-template-columns:minmax(0,1fr) 15.5rem; gap:1.25rem; align-items:start }
#bs-demo .bs-intro { display:flex; align-items:flex-end; justify-content:space-between; gap:.5rem 1rem; flex-wrap:wrap; margin-bottom:.75rem }
#bs-demo .bs-intro h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#bs-demo .bs-intro p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#bs-demo .bs-side { position:sticky; top:1rem; display:grid; gap:.875rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#bs-demo .bs-side h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
#bs-demo .bs-side p { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
#bs-demo .bs-side .mv-choice { font-size:.8125rem }
#bs-demo .bs-sep { height:1px; background:var(--mv-border) }
#bs-demo .bs-log { display:grid; gap:.3125rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#bs-demo .bs-log li { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#bs-demo .bs-log b { color:var(--mv-fg); font-weight:500 }
#bs-demo .bs-form { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; margin-top:.75rem; color:var(--mv-fg-muted); font-size:.75rem }
#bs-demo .bs-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:56rem) {
#bs-demo .bs-layout { grid-template-columns:minmax(0,1fr) }
#bs-demo .bs-side { position:static }
}
</style>
<div class="bs-layout">
<section aria-labelledby="bs-week-title">
<div class="bs-intro">
<div>
<h3 id="bs-week-title">Leadership offsite · People & Ops</h3>
<p>Decision due Friday, October 16 · Product team of 14 across Lisbon, Toronto and Singapore</p>
</div>
<span class="mv-badge" data-variant="outline" id="bs-verdict-badge">Leaning yes</span>
</div>
<form id="bs-week-form">
<mv-balance-sheet id="bs-week" name="four-day-week" confidence require-answers
question="Move the product team to a 4-day week?">
<ul>
<li data-side="for" data-weight="5" data-id="retention">Two engineers cited burnout in exit interviews this year; replacing each costs about $48,000</li>
<li data-side="for" data-weight="5" data-confidence="likely" data-id="pilot">Lisbon pilot (Q2): shipped output flat, sick days down 30%</li>
<li data-side="for" data-weight="3" data-id="stagger">Staggered days off (Mon or Fri) keep five-day coverage</li>
<li data-side="for" data-weight="4" data-confidence="likely" data-id="hiring">4-day roles draw roughly 3× more applicants in our markets</li>
<li data-side="for" data-weight="4" data-id="meetings">Forces us to cut recurring meetings (11 h per person per week today)</li>
<li data-side="for" data-weight="3" data-confidence="unsure" data-id="raise">Cheaper than the 8% raise the team asked for in the spring</li>
<li data-side="against" data-weight="3" data-confidence="likely" data-pair="pilot" data-id="cadence">Release cadence could slip during the transition quarter</li>
<li data-side="against" data-weight="4" data-pair="stagger" data-id="support">Customer support needs full coverage on Fridays</li>
<li data-side="against" data-weight="4" data-id="sla">Two enterprise contracts promise a 5-business-day response SLA</li>
<li data-side="against" data-weight="2" data-id="payroll">Payroll and time-off policies must be rewritten in 3 countries</li>
<li data-side="against" data-weight="3" data-confidence="likely" data-id="reverse">Hard to walk back once it is announced</li>
</ul>
</mv-balance-sheet>
<div class="bs-form">
<span id="bs-form-status" role="status">Every concern must be answered before this can be submitted.</span>
<button class="mv-button" data-size="sm" type="submit">Submit decision</button>
</div>
</form>
</section>
<aside class="bs-side" aria-label="Demo controls">
<h4>Options</h4>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="bs-conf" checked> Confidence per point</label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="bs-ro"> Read-only record</label>
<div class="bs-sep"></div>
<h4>Try it</h4>
<p>Answer “Hard to walk back” with a new point, or drag a weight: the beam tilts and the verdict line follows.</p>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="bs-answer">Answer the SLA concern</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" id="bs-reset">Reset the sheet</button>
<div class="bs-sep"></div>
<h4>Events</h4>
<ol class="bs-log" id="bs-log"><li>-</li></ol>
</aside>
</div>
<!-- Variant: several options in tabs, compared in a summary table -->
<section aria-labelledby="bs-vendor-title">
<div class="bs-intro">
<div>
<h3 id="bs-vendor-title">Vendor review · Platform team</h3>
<p>Three candidates for error monitoring, weighed option by option. Budget: $1,800 / month.</p>
</div>
</div>
<mv-balance-sheet id="bs-vendor" selected="compare" for-label="Strengths" against-label="Risks" for-verdict="in favor" against-verdict="against"
question="Which error-monitoring vendor should we adopt?">
<ul data-option="Halcyra Metrics" data-option-id="halcyra">
<li data-side="for" data-weight="5" data-id="l1">Source maps and release tracking work out of the box</li>
<li data-side="for" data-weight="4" data-id="l2">EU data residency (Frankfurt) covers our GDPR review</li>
<li data-side="for" data-weight="3" data-id="l3">Annual plan at $1,450 / month for 20 seats</li>
<li data-side="against" data-weight="3" data-pair="l3" data-id="l4">Event overage billed at $0.00028 per event</li>
<li data-side="against" data-weight="2" data-id="l5">No on-prem option if a customer demands one</li>
</ul>
<ul data-option="Tessera Cloud" data-option-id="tessera">
<li data-side="for" data-weight="4" data-id="t1">Already used by the mobile team; one contract</li>
<li data-side="for" data-weight="3" data-id="t2">Best session replay of the three</li>
<li data-side="against" data-weight="5" data-id="t3">US-only hosting blocks two EU customers</li>
<li data-side="against" data-weight="4" data-pair="t1" data-id="t4">Seat price rises to $95 at renewal</li>
<li data-side="against" data-weight="3" data-id="t5">SAML only on the Enterprise tier</li>
</ul>
<ul data-option="Self-hosted stack" data-option-id="self">
<li data-side="for" data-weight="4" data-id="s1">No per-event cost, data never leaves our cloud</li>
<li data-side="for" data-weight="2" data-id="s2">Full control over retention (400 days)</li>
<li data-side="against" data-weight="5" data-pair="s1" data-id="s3">About 0.5 FTE of on-call and upgrades</li>
<li data-side="against" data-weight="4" data-id="s4">Alerting and dashboards to build ourselves</li>
</ul>
</mv-balance-sheet>
</section>
<p class="bs-hint">Open a vendor tab to edit its sheet · the Compare tab ranks the options by net balance · Copy Markdown exports the whole review</p>
<script type="module">
await customElements.whenDefined("mv-balance-sheet");
const $ = (id) => document.getElementById(id);
const sheet = $("bs-week");
const log = $("bs-log");
const badge = $("bs-verdict-badge");
const write = (html) => {
if (log.firstElementChild?.textContent === "-") log.replaceChildren();
const li = document.createElement("li");
li.append(...html);
log.prepend(li);
while (log.children.length > 6) log.lastElementChild.remove();
};
const b = (t) => { const el = document.createElement("b"); el.textContent = t; return el; };
const paintBadge = () => {
const r = sheet.verdict;
badge.textContent = r.verdict.split(" · ")[0];
badge.dataset.variant = r.lean === "for" ? "success" : r.lean === "against" ? "danger" : "secondary";
};
paintBadge();
sheet.addEventListener("mv-change", (e) => {
const { reason, results } = e.detail;
write([b("mv-change "), `${reason} · net ${results[0].net > 0 ? "+" : ""}${results[0].net}`]);
paintBadge();
});
sheet.addEventListener("mv-verdict", (e) => {
write([b("mv-verdict "), `${e.detail.previous} → ${e.detail.lean}`]);
paintBadge();
});
sheet.addEventListener("mv-export", (e) => write([b("mv-export "), e.detail.format]));
$("bs-conf").addEventListener("change", (e) => { sheet.confidence = e.target.checked; paintBadge(); });
$("bs-ro").addEventListener("change", (e) => { sheet.readonly = e.target.checked; });
$("bs-answer").addEventListener("click", () => {
const id = sheet.addItem("for", "Keep a rotating Friday responder for enterprise tickets", { weight: 4, answers: "sla" });
if (id) $("bs-answer").disabled = true;
});
const initial = sheet.value;
$("bs-reset").addEventListener("click", () => { sheet.value = initial; $("bs-answer").disabled = false; paintBadge(); write([b("reset")]); });
const form = $("bs-week-form");
const status = $("bs-form-status");
form.addEventListener("submit", (e) => {
e.preventDefault();
const data = JSON.parse(new FormData(form).get("four-day-week"));
status.textContent = `Submitted: ${data.results[0].verdict}.`;
});
form.addEventListener("invalid", () => { status.textContent = sheet.validationMessage; }, true);
$("bs-vendor").addEventListener("mv-verdict", (e) => write([b("mv-verdict "), `${e.detail.label}: ${e.detail.lean}`]));
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
question | string | The decision being weighed, shown as the sheet's heading and used for the export file name. Also stored in the value. | |
for-label / against-label | string | For / Against | Column headings and pan labels (e.g. Pros / Cons, Strengths / Risks, Build / Buy). |
for-verdict / against-verdict | string | yes / no | Words used in the verdict line: “Leaning {word}”, “Clearly {word}”. |
confidence | boolean | Shows a confidence select on every point (Certain ×1, Likely ×0.75, Unsure ×0.5); a point's score is weight × confidence. Unsure pips are hatched, likely pips lighter. | |
offset | number (0..1) | 0.5 | Share of the smaller score that a pair removes from both of its points. 0 = pairing is purely a link; 1 = the smaller point is fully cancelled. Pairing alone never flips the lean; it sharpens the margin. |
tilt | number (deg) | 10 | Maximum beam angle, reached when one side outweighs the other by about 2 to 1. |
multiple | boolean | Shows the option tabs and the Add option button even with a single option. Tabs appear on their own as soon as there are two options. | |
selected | option id | "compare" | Option shown on first render (or the Compare view). Changing the attribute switches tabs. | |
readonly | boolean | Decision record mode: nothing can be edited, added, paired or removed; pairs and verdict stay visible and exports still work. | |
persist | string | localStorage key suffix (mv-balance-sheet:{persist}). The sheet is saved on every change and restored on load before the markup (a value property set by the app wins). clearSaved() forgets it. | |
name / required / disabled | form | Form association: the submitted value is JSON ({ question, options, results }). required = at least one point. | |
require-answers | boolean | The field is invalid while any concern is unanswered (“Answer every concern before submitting (3 open).”), anchored on the first open concern's Answer button. | |
hide-export | boolean | Hides the Copy Markdown / Export JSON buttons (the methods still work). | |
heading-level | 1..6 | 3 | aria-level of the question heading; column headings use the next level. |
locale | BCP 47 | en-US | Number formatting of totals and nets. |
data-side / data-weight / data-confidence / data-id / data-pair | markup | Initial points as <li data-side="for|against"> inside a <ul> child (or directly as children), read once then replaced. data-pair (alias data-answers) names the id of the point it faces. Wrap each option's points in <ul data-option="Label" data-option-id="id">. A <script type="application/json"> child with a value is also accepted. | |
data-lean / data-readonly | set by the component | On the host: for | against | even for the option on screen (empty in the Compare view), and data-readonly while editing is locked. |
Properties
| Name | タイプ | Description |
|---|---|---|
value | { question, options: [{ id, label, items: [{ id, side, text, weight, confidence, pair }] }] } | The whole sheet. Accepts the same shape, { items } for a single option, an array of options or a JSON string; pairs are made symmetric and one-to-one. Setting it emits no event. |
results | Array<{ id, label, for, against, net, ratio, lean, strength, pairs, unanswered: { for, against }, scores, count, verdict }> | Weighed result of every option (read-only). lean: for | against | even; strength: clear | leaning | even | empty; pairs carry their letter and offset; scores are the effective score of each point. |
verdict | result | Result of the option on screen, with its verdict sentence (read-only). |
selectedOption | string | Id of the option on screen, or "compare" (read-only; use select()). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (forLabel, againstLabel, forVerdict, againstVerdict, addFor, addAgainst, answer, answerLabel, pairedLabel, unanswered, verdictLeaning, verdictClear, verdictEven, concernsOne, concernsOther, concernsNone, divider, menuTitle, menuNew, menuUnpair, compare, best, copyMarkdown, exportJSON, announceFlip, requireAnswers…). English defaults. |
question / forLabel / againstLabel / confidence / offset / tilt / readonly / persist … | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
addItem(side, text, { weight?, confidence?, answers?, option?, id? }) | Adds a point (weight 3 by default) and returns its id; answers pairs it with an existing point of the other side. Emits mv-change (reason "add"). |
removeItem(id, { option? }) | Removes a point (cancelable mv-remove). Returns false if refused. |
pair(a, b, { option? }) / unpair(id, { option? }) | Pairs two points of opposite sides (replacing their previous pairs) or breaks a pair. Cancelable through mv-pair. |
addOption(label?) / removeOption(id) / select(id | "compare") | Manages option sheets and switches tabs. removeOption keeps at least one option and emits a cancelable mv-remove with kind "option". |
toMarkdown() / toJSON() | The decision as Markdown (question, verdict, both columns with weights, answers and unanswered concerns, compare table) or as { question, options, results }. |
copyMarkdown() / downloadJSON() | Same as the footer buttons: clipboard (file fallback) and .json download named after the question. Return Promise<boolean>. |
clearSaved() | Forgets the persisted copy. |
Events
| Name | Description |
|---|---|
mv-change | After every edit. detail: { reason: "add" | "remove" | "edit" | "weight" | "confidence" | "pair" | "unpair" | "option-add" | "option-remove" | "option-rename", value, results (summaries), item, option }. Text edits are reported on commit (blur), not per keystroke. |
mv-verdict | The lean of an option flipped side (for, against or even) after a user action. detail: { option, label, lean, previous, strength, for, against, net, unanswered, verdict }. |
mv-remove | Cancelable, before a point or an option is removed. detail: { kind: "item" | "option", item?, option, label? }. |
mv-pair | Cancelable, before two points are paired or unpaired. detail: { action: "pair" | "unpair", for, against, option }. |
mv-export | Cancelable, before Copy Markdown or Export JSON. detail: { format: "markdown" | "json", text, filename }. preventDefault() to send the text somewhere else (a ticket, an ADR, your API). |
Content structure
| Name | Description |
|---|---|
(content) | Initial data only (<ul>/<li data-side> or a JSON <script>); it is read once and replaced by the sheet. |
CSS classes
| Name | Description |
|---|---|
mv-balance-sheet-head / -question / -scale / -verdict | Head: question heading, the decorative scale (-beam, -arm, -hang[data-side], -cord, -pan, -pan-total, -fulcrum, -pivot, --_tilt) and the verdict line (-lean[data-lean][data-strength], -score, -open). |
mv-balance-sheet-grid / -col / -list / -gutter / -link / -divider | Ledger: three columns sharing rows through subgrid; -link (with -link-badge and -link-offset) joins each pair across the gutter; -divider heads the unanswered rows. Stacks under 38rem of container width. |
mv-balance-sheet-item | A point: data-side, data-paired or data-open, data-hot (hovered or focused with its partner), data-confidence. Parts: -text (textarea), -remove, -weight (-pips, -range), -conf, -tag, -pair (data-letter). |
mv-balance-sheet-add / -answering | Add field under each column and the “Answering …” chip shown while a new point is written as an answer. |
mv-balance-sheet-tabs / -tab / -tab-net / -compare / -table | Option tabs with their net badge, and the Compare view table (-net-bar, -best, -mini-beam). |
mv-balance-sheet-menu | The “Answer with…” menu (Popover API, role="menu"). |
mv-balance-sheet-foot / -note / -export | Footer with the scoring note and export buttons. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-balance-sheet-for | var(--mv-success) | Color of the first column, its pan and pips. |
--mv-balance-sheet-against | var(--mv-danger) | Color of the second column, its pan and pips. |
--mv-balance-sheet-open | var(--mv-warning) | Unanswered concerns: edge, tag and verdict count. |
--mv-balance-sheet-link | var(--mv-accent) | Pair connectors, letters and the answering chip. |
--mv-balance-sheet-gutter | 3.5rem | Width of the gutter where pairs meet. |
Accessibility
Each column is a real list (role="list") labelled by its heading (role="heading" with a configurable aria-level, one level below the question), and the heading reads its weighted total (“For, weighted total 17.6”). Every point is made of native controls: a textarea named “Point, For” (Enter moves to the next point, Backspace on an empty point removes it), a Remove button naming the point, a native range labelled “Weight” with aria-valuetext (“4 of 5, major”) driven by arrow keys, Home and End, and a native select for confidence. Pairing never needs dragging: the “Answer” button (aria-haspopup="menu", aria-expanded) names what it answers and opens a menu of the other column's unpaired points plus “Write a new point” and “Unpair”; arrow keys, Home and End move inside it, Escape or Tab closes it and returns focus to the button. A paired button reads “Answered by “…”, pair B”. Writing a new answer shows a visible “Answering …” chip and renames the add field accordingly; Escape cancels it. The beam, pans, gutter connectors and divider are aria-hidden: everything they show is also in text (column totals, the pair letter on both points, the “Unanswered” tag, the verdict line). The verdict line is plain text, and its concern count is a button that moves focus to the next unanswered concern. A polite live region announces additions, removals and pairings; an assertive one announces only when the verdict flips side (“Verdict changed. Leaning no · 12 vs 14.5 · 2 unanswered concerns.”). Tabs follow the tabs pattern (roving tabindex, arrow keys, automatic activation, tabpanel labelled by its tab, each tab named with its verdict); the Compare view is a real table with a caption, column and row headers. Hover or focus on a point highlights its partner. Colors always come with signs and words (+ / −, “Unanswered”, letters, numbers). Reduced motion (OS or data-motion="reduce"): the beam sits at its angle without a spring, rows do not glide when pairs form, the focus pulse becomes a static outline. Forced colors: pips, beam and connectors use CanvasText, unanswered concerns get a dashed border.