エクスクルーシブ
Scope Tally <mv-scope-tally>
当初の依頼と比べたスコープの拡大を可視化します。クライアント案件や固定見積もり、製品仕様、スプリントのコミットメント、RFP、契約書が対象です。
文化的リファレンス
漁師とその妻、グリム兄弟(1812年、寓話)。願いが叶うたびに、すぐにもっと大きな願いが生まれます。小屋、次は城、王国、さらにその先へ。戻るたびに海の色は暗くなり、ついにはすべてを失います。UI では、受け入れた各リクエストが元の依頼の上に積み上がる目に見える段となり、段ごとに前より一段濃い色になります。スコープが暴走する前に、予算とスケジュールについて話し合うきっかけとなるレビューポイントも設けられています。
仕組み
元の依頼に対するスコープの増加を可視化します。クライアント案件や固定見積もり、製品仕様、スプリントのコミットメント、RFP、契約向けです。ベースライン(見積もり付きの項目)と変更のタイムライン(追加・変更・削除された項目。それぞれに依頼者、日時、見積もりへの影響、accepted、pending、moved to phase 2、declined のいずれかのステータス、請求対象かどうかを持つ)を、HTML に記述した 2 つのリストとして、または baseline と changes プロパティで渡します。カードの冒頭には言葉による集計(「Original 12 days → Now 21 days · +75% across 7 accepted changes」、続いて保留中の影響と、指定レートでの金額付きの請求対象日数)があり、その下に同じ縮尺で描かれた 2 本のレールがあります。1 本は元の依頼で、後で削除されたものはハッチングで示されます。もう 1 本は現在のスコープで、受け入れた増加はそれぞれ段となり、段ごとに前より一段濃くなります。保留中のリクエストは末尾にハッチングで示され、破線のレビューポイント線(+25%、+50%、設定可能)が横切り、超えると実線に変わります。タイムラインは段の順序付きリストで、日付、種類、コスト、依頼者、累計、そして各レビューポイントを超えた段を示します。保留中のリクエストはその場で判断でき(Accept as change order、Move to phase 2、Decline、Undo 付き)、判断後の合計をプレビューするキャンセル可能な mv-scope-decision を通ります。各リクエストには受け入れた場合の結果も示されます(「If accepted: 24 days · +100% · Would pass +100%」)。「New since you last looked」の区切り線(last-seen、または storage-key と Mark as seen ボタンによるブラウザごとの記録)が前回の訪問以降の変更を分け、各レビューポイントでは予算とスケジュールの見直しを中立的に促し、アプリ向けに mv-scope-threshold を発火します。Compare to original は追加・変更・削除・変更なしの項目を合計付きの表で一覧にし、Copy summary はクライアントへのメールにそのまま使えるプレーンテキストを生成します(キャンセル可能な mv-scope-export)。単位は日、時間、週、ポイント、または通貨コードを指定でき、variant="summary" はポートフォリオのダッシュボード向けに集計とレールだけを残します。
| カテゴリー | データ表示 |
|---|---|
| タイプ | Web Component(<mv-scope-tally>) |
| ステータス | 安定版 |
| 同時にインストールされるもの | button |
| Keywords | exclusive, culture, scope, scope-creep, change-order, quote, estimate, baseline, budget, freelance, agency, sprint, commitment, rfp, contract, timeline, compare, renegotiation, project-management |
When to use
- A freelancer or agency must show a client how a fixed quote grew, request by request, before asking for a change order
- A sprint or release commitment keeps absorbing unplanned work and the team needs the growth and its sources in plain numbers
- New requests on a spec, RFP or contract must be accepted, moved to a later phase or declined with their cost visible
- A project dashboard should flag which engagements passed +25% or +50% of their original estimate
Avoid when
- Tasks need to move through workflow stages rather than be measured against an original commitment → use Kanban instead
- The wording of a requirement changed as it was relayed, not its size or cost → use Drift Check instead
- A single request must be argued for and against before deciding, with weighted pros and cons → use Balance Sheet instead
インストール
node scripts/add.mjs scope-tally --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["scope-tally"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/scope-tally/scope-tally.js, components/scope-tally/scope-tally.css。
使い方
基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="st-demo" style="width:min(100%,72rem);margin-inline:auto">
<style>
#st-demo .st-layout { display:grid; grid-template-columns:minmax(0,1fr) 19rem; gap:1.25rem; align-items:start }
#st-demo .st-side { display:grid; gap:1rem; position:sticky; top:1rem }
#st-demo .st-panel { display:grid; gap:.75rem; padding:1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-xs); font-size:.8125rem }
#st-demo .st-panel h4 { margin:0; font-size:.8125rem; font-weight:600 }
#st-demo .st-panel p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#st-demo .st-k { color:var(--mv-fg-muted); font-size:.6875rem; letter-spacing:.04em; text-transform:uppercase; font-weight:600 }
#st-demo .st-log { list-style:none; margin:0; padding:0; display:grid; gap:.375rem; font-size:.75rem }
#st-demo .st-log li { display:grid; gap:.125rem; padding:.375rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle) }
#st-demo .st-log code { font:500 .6875rem/1.2 var(--mv-font-mono); color:var(--mv-fg) }
#st-demo .st-log span { color:var(--mv-fg-muted) }
#st-demo .st-log .st-empty { background:none; padding:0; color:var(--mv-fg-subtle) }
#st-demo .st-minis { display:grid; gap:1rem }
@media (max-width:60rem) {
#st-demo .st-layout { grid-template-columns:minmax(0,1fr) }
#st-demo .st-side { position:static; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)) }
}
</style>
<div class="st-layout">
<mv-scope-tally id="st-main"
heading="Customer portal rebuild"
subheading="Harbor & Pine Outfitters · Fixed quote Q-2026-114, signed Jul 28"
unit="day" rate="850" thresholds="25,50,100" last-seen="2026-09-12">
<ol data-scope-baseline>
<li data-id="discovery" data-estimate="1">Discovery workshop and sitemap</li>
<li data-id="design" data-estimate="3">Visual design: 5 page templates</li>
<li data-id="cms" data-estimate="2">CMS setup and content model</li>
<li data-id="catalog" data-estimate="3">Product catalog with filters</li>
<li data-id="account" data-estimate="2">Customer accounts and order history</li>
<li data-id="launch" data-estimate="1">QA, launch and handover</li>
</ol>
<ol data-scope-changes>
<li data-id="newsletter" data-kind="add" data-delta="0.5" data-by="Maya Chen" data-role="Client" data-date="2026-08-04" data-status="accepted" data-billable>Newsletter signup synced to the CRM</li>
<li data-id="templates" data-kind="change" data-target="design" data-delta="1.5" data-by="Maya Chen" data-role="Client" data-date="2026-08-12" data-status="accepted" data-billable>Two more page templates: Stores and Careers</li>
<li data-id="sso" data-kind="add" data-delta="2" data-by="Daniel Okafor" data-role="Client IT" data-date="2026-08-19" data-status="accepted" data-billable>Staff sign-in through the company directory (SSO)</li>
<li data-id="sitemap" data-kind="remove" data-target="discovery" data-by="Maya Chen" data-role="Client" data-date="2026-08-27" data-status="accepted">Discovery workshop dropped<span data-note>The client supplied an approved sitemap from their agency of record.</span></li>
<li data-id="chat" data-kind="add" data-delta="4" data-by="Leo Martins" data-role="Client" data-date="2026-08-30" data-status="declined">Custom live chat widget<span data-note>Replaced by their existing help desk embed, at no cost.</span></li>
<li data-id="locator" data-kind="add" data-delta="2" data-by="Maya Chen" data-role="Client" data-date="2026-09-03" data-status="accepted" data-billable>Store locator with map and opening hours</li>
<li data-id="filters" data-kind="change" data-target="catalog" data-delta="1.5" data-by="Aiko Tanaka" data-role="Studio lead" data-date="2026-09-08" data-status="accepted" data-billable="false">Catalog filters by size, color and in-store stock<span data-note>Underestimated in the quote; absorbed by the studio.</span></li>
<li data-id="i18n" data-kind="add" data-delta="3" data-by="Priya Raman" data-role="Client marketing" data-date="2026-09-10" data-status="deferred">French and Spanish versions of the site</li>
<li data-id="giftcards" data-kind="add" data-delta="2.5" data-by="Leo Martins" data-role="Client" data-date="2026-09-15" data-status="accepted" data-billable>Gift cards at checkout</li>
<li data-id="loyalty" data-kind="add" data-delta="3" data-by="Leo Martins" data-role="Client" data-date="2026-09-18" data-status="pending">Loyalty points in the account area<span data-note>Needs the points API from their POS vendor, expected in October.</span></li>
<li data-id="a11y" data-kind="add" data-delta="1" data-by="Samira Haddad" data-role="Studio QA" data-date="2026-09-22" data-status="pending" data-billable="false">Fixes from the accessibility audit (contrast, focus order)</li>
</ol>
</mv-scope-tally>
<aside class="st-side" aria-label="Demo controls">
<div class="st-panel">
<h4>Try it</h4>
<p>Decide the pending requests, hover a step to find it on the rails, or switch to Compare to original.</p>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="st-request">Client sends a new request</button>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="st-readonly">
<span class="mv-choice-text"><span class="mv-choice-title">Client view</span><span class="mv-choice-description">Read-only, no decision buttons</span></span>
</label>
</div>
<div class="st-panel">
<span class="st-k">Events</span>
<ol class="st-log" id="st-log"><li class="st-empty">Decide a request to see mv-scope-decision</li></ol>
</div>
<div class="st-minis">
<mv-scope-tally id="st-sprint" variant="summary" heading="Sprint 42 commitment" subheading="Payments team · Sep 15 to Sep 26" unit="point" thresholds="15,30"></mv-scope-tally>
<mv-scope-tally id="st-brand" variant="summary" heading="Brand guidelines" subheading="Fernhollow Labs · Fixed fee" unit="USD" thresholds="25,50"></mv-scope-tally>
</div>
</aside>
</div>
<script type="module">
await customElements.whenDefined("mv-scope-tally");
const $ = (id) => document.getElementById(id);
const main = $("st-main"), log = $("st-log");
$("st-sprint").baseline = [
{ id: "refunds", label: "Partial refunds", estimate: 8 },
{ id: "payouts", label: "Scheduled payouts", estimate: 13 },
{ id: "report", label: "Legacy settlement report", estimate: 5 },
{ id: "webhooks", label: "Webhook retries", estimate: 8 },
];
$("st-sprint").changes = [
{ id: "hotfix", label: "Hotfix: refund rounding", delta: 3, by: "On-call", date: "2026-09-16", status: "accepted" },
{ id: "wallet", label: "Wallet payments on web", delta: 8, by: "Product", date: "2026-09-17", status: "accepted" },
{ id: "drop", label: "Legacy report moved out", kind: "remove", target: "report", date: "2026-09-18", status: "accepted" },
{ id: "alerts", label: "Chargeback alerts", delta: 5, by: "Risk", date: "2026-09-22", status: "pending" },
];
$("st-brand").baseline = [
{ id: "logo", label: "Logo refresh", estimate: 4500 },
{ id: "guide", label: "Guidelines document", estimate: 5000 },
{ id: "templates", label: "Social templates", estimate: 2500 },
];
$("st-brand").changes = [
{ id: "motion", label: "Motion logo", delta: 1800, by: "Client", date: "2026-09-02", status: "accepted", billable: true },
{ id: "print", label: "Print stationery", delta: 950, by: "Client", date: "2026-09-11", status: "accepted", billable: true },
{ id: "signage", label: "Store signage kit", delta: 2400, by: "Client", date: "2026-09-19", status: "pending" },
];
const push = (name, text) => {
log.querySelector(".st-empty")?.remove();
const li = document.createElement("li");
const code = document.createElement("code");
code.textContent = name;
const span = document.createElement("span");
span.textContent = text;
li.append(code, span);
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
};
const pct = (g) => `${g >= 0 ? "+" : ""}${Math.round(g * 100)}%`;
main.addEventListener("mv-scope-decision", (e) => {
const { decision, change, after } = e.detail;
push("mv-scope-decision", `${decision} · ${change.label} → ${after?.now ?? main.totals.now} days`);
});
main.addEventListener("mv-scope-threshold", (e) => {
const { threshold, direction, growth } = e.detail;
push("mv-scope-threshold", `${direction} · +${threshold}% review point (${pct(growth)})`);
});
main.addEventListener("mv-scope-export", (e) => push("mv-scope-export", `${e.detail.text.split("\n").length} lines of summary`));
const requests = [
{ label: "Export order history to CSV", delta: 1, by: "Maya Chen", role: "Client" },
{ label: "Wishlist shared by email", delta: 1.5, by: "Leo Martins", role: "Client" },
{ label: "Staff-only price overrides", delta: 2, by: "Daniel Okafor", role: "Client IT" },
];
let next = 0;
$("st-request").addEventListener("click", () => {
const r = requests[next++ % requests.length];
main.addChange({ ...r, id: `req-${next}`, date: new Date(), status: "pending" });
});
$("st-readonly").addEventListener("change", (e) => { main.readonly = e.target.checked; });
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
heading | string | Scope | Title of the card (project, quote or sprint name). Also heads the exported summary. |
subheading | string | Secondary line under the title (client, quote number, signature date). | |
heading-level | number (2-6) | 3 | Level of the heading element. |
unit | "day" | "hour" | "week" | "month" | any noun ("point") | 3-letter currency code ("USD") | day | Unit of every estimate. Time units are formatted with Intl (“1 day”, “2.5 days”), other nouns get a plural (see unit-plural), a currency code formats estimates as money. |
unit-plural | string | Plural of a custom unit when adding “s” is wrong (unit="person-day" unit-plural="person-days"). | |
rate | number | Price per unit. When set, billable accepted changes also show their amount (“8.5 days billable ($7,225)”) and the summary adds the rate. | |
currency | ISO 4217 code | USD | Currency of rate amounts. |
thresholds | comma-separated percents | 25,50 | Review points above the original estimate. Drawn as lines across the rails, reported on the step that passed them, and each one reached shows the renegotiation nudge and fires mv-scope-threshold. |
last-seen | ISO date or date-time | Changes dated after this moment get a “New” tag and the “New since you last looked” divider. Overridden by Mark as seen / markSeen() and by the stored value when storage-key is set. | |
storage-key | string | Persists the last-seen moment and dismissed nudges per browser in localStorage (key mv-scope-tally:<storage-key>), synced across tabs. | |
view | "timeline" | "compare" | timeline | Visible view. Reflects the tab chosen by the user. |
variant | "summary" | Compact card with only the tally, the rails and the nudge (no views, no copy button), for dashboards listing several projects. | |
readonly | boolean | Hides decision and undo buttons, e.g. when the tally is shared with the client. Pending requests still show their impact. | |
locale | BCP 47 tag | en-US | Locale for numbers, percents, dates and lists. |
data-scope-baseline / data-scope-changes | authored lists | On two child lists: each <li> of the baseline carries data-id and data-estimate; each change carries data-id, data-kind (add | change | remove), data-delta (signed; a removal without delta removes the target’s whole estimate), data-target (id of the item changed or removed), data-by, data-role, data-date, data-status (accepted | pending | deferred | declined) and data-billable (present = billable, "false" = absorbed). The text is the label; an inner [data-note] element becomes the note. Read once, then replaced by the rendered card. | |
data-tier / data-over | set by the component | On the host: number of review points passed, and present once at least one is passed (the --_tier variable deepens the growth pill). |
Properties
| Name | タイプ | Description |
|---|---|---|
baseline | Array<{ id, label, estimate }> | The original ask. Setting it re-renders. |
changes | Array<{ id, label, kind?, target?, delta?, by?, role?, date?, status?, billable?, note? }> | Every change to the ask. kind defaults to "add" (or "change" when target is set), status to "pending"; dates sort the timeline. Reading it returns each change with its computed effect. |
totals | { baseline, now, delta, growth, projected, pending, pendingDelta, accepted, billable, billableAmount, threshold } | Current figures (read-only). growth is a ratio (0.75 = +75%), projected includes every pending request, billableAmount needs rate. |
threshold | number | null | Highest review point reached, in percent (read-only). |
seen | Date | null | Moment used for the “since you last looked” marker (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text, announcement and summary line ({placeholders} kept). English defaults. |
Methods
| Name | Description |
|---|---|
decide(id, decision) | Applies "accept" | "defer" | "decline" | "reopen" from code; emits mv-scope-decision with source "api" (cancelable). Returns false if cancelled, unknown or unchanged. |
addChange(change) | Appends a change (usually a new pending request), animates it in and announces it. Returns its id. |
removeChange(id) | Removes a change without an event. |
markSeen(at?) | Marks everything up to now (or at) as seen, persists it with storage-key and emits mv-scope-seen. |
summary() | Plain-text summary for a client email: original and current estimate, growth, billable change orders at your rate, pending impact, then accepted, pending, phase 2 and declined changes with date, cost and requester. |
copySummary() | Emits the cancelable mv-scope-export, then copies the summary. Resolves true when copied. |
Events
| Name | Description |
|---|---|
mv-scope-decision | Cancelable, before a decision is applied. detail: { id, decision: "accept" | "defer" | "decline" | "reopen", source: "user" | "api", previous: { status, billable }, change, totals (before), after: { baseline, now, growth, projected, threshold } }; undo sets detail.undo. preventDefault() keeps the change as it was (e.g. to confirm on the server first, then call decide()). |
mv-scope-threshold | The highest review point reached changed after a decision or a data update. detail: { threshold, level, direction: "up" | "down", growth, baseline, now }. Not fired for the initial state: read threshold instead. |
mv-scope-export | Cancelable, before Copy summary writes to the clipboard. detail: { text, format: "text" }. preventDefault() to open your own mail composer with detail.text instead. |
mv-scope-seen | Mark as seen was used. detail: { lastSeen }. |
mv-scope-view | The user switched views. detail: { view }. |
CSS classes
| Name | Description |
|---|---|
mv-scope-tally-card | The card (section labelled by the heading): -head, -overview, -nudge, -body. |
mv-scope-tally-tally / -subline | Tally in words (-fig, -fig-label, -fig-value, -growth with data-dir, -across) and the pending / billable line. |
mv-scope-tally-strip | Decorative rails (aria-hidden): -rail[data-rail=original|now], -track, -seg[data-seg=original|removed|kept|step|pending] (data-id, --_d depth, data-active when linked), -marks / -mark[data-reached], -legend. |
mv-scope-tally-nudge | Review-point note (role=note) with -nudge-title and Copy summary / Dismiss. |
mv-scope-tally-tabs / -tab / -panel | View switcher (tablist) and its panels. |
mv-scope-tally-steps / -step | Timeline <ol>; each step has data-id, data-status, data-kind, data-sign, data-new, and holds -node, -since (first new step), -step-top, -delta, -step-label, -step-by, -step-note, -step-foot (-status, -running, -cross), -decide, -undo. |
mv-scope-tally-table | Compare to original table: tbody[data-group=added|changed|removed|unchanged], tfoot totals, -compare-note. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-scope-tally-step | var(--mv-accent) | Hue of accepted steps; each step mixes more of it than the previous one. |
--mv-scope-tally-review | var(--mv-warning) | Review-point lines, the nudge and the growth pill once a point is passed. |
--mv-scope-tally-pending | var(--mv-warning) | Hatching and ink of pending requests. |
--mv-scope-tally-original | muted neutral | Fill of the original ask on both rails. |
Accessibility
The card is a <section> labelled by its heading. Everything the rails show is also written as text: the tally paragraph reads “Original: 12 days · Now: 21 days · +75% across 7 accepted changes”, the subline gives pending impact and billable days, and the rails themselves are aria-hidden (their segments are a pointer-only shortcut that scrolls to and focuses the matching step). The timeline is an ordered list in date order; each step states its date (<time>), kind (Added, Changed, Removed), cost, requester, status in words with an icon (Accepted · Change order, Awaiting decision, Moved to phase 2, Declined), the running total and any review point it passed, so nothing relies on color or hatching. The “New since you last looked” divider is real text inside the first new step, and every later new step carries a “New” tag. Decisions are native buttons grouped per request (role=group, “Decision for Loyalty points…”); after a decision focus moves to that step’s Undo button, after an undo back to Accept, and a polite status region announces the result with the new total (“Gift cards at checkout: Accepted. Total 21 days, +75% compared with the original.”), new requests and review points passed. The view switcher is a tablist with roving tabindex (arrow keys, Home, End) and automatic activation; the compare view is a real table with a caption, column and row headers, row groups and a totals footer, dashes read as “none”. The nudge is a role=note with a labelled Dismiss button. Reduced motion (OS or data-motion="reduce"): no segment growth, no flash, no slide-in. Forced colors: rails, marks and chips switch to system colors with borders and dashed outlines.