익스클루시브

Scope Tally <mv-scope-tally>

원래 요청과 비교해 범위가 얼마나 늘었는지 보여 줍니다. 고객 프로젝트와 고정 견적, 제품 사양서, 스프린트 약속, RFP, 계약서에 적합합니다.

문화적 레퍼런스

어부와 그의 아내, 그림 형제 (1812년, 동화). 소원이 이루어질 때마다 곧바로 더 큰 소원으로 이어집니다. 오두막, 다음에는 성, 왕국, 그리고 그 이상으로. 돌아올 때마다 바닷물은 더 어두워지고, 결국 모든 것을 잃습니다. UI에서는 수락된 각 요청이 원래 요청 위에 쌓이는 눈에 보이는 계단이 되고, 각 계단은 이전보다 한 단계 더 짙으며, 범위가 걷잡을 수 없게 되기 전에 예산과 일정에 대한 대화를 이끄는 검토 지점이 있습니다.

작동 방식

원래 요청 대비 범위 증가를 눈에 보이게 합니다. 클라이언트 프로젝트와 고정 견적, 제품 명세, 스프린트 약속, RFP, 계약에 쓰입니다. 기준선(추정치가 있는 항목)과 변경 타임라인(추가, 변경, 제거된 항목으로, 각각 요청자, 시점, 추정치 영향, 수락됨·보류·2단계로 이동·거절됨 중 하나의 상태, 청구 가능 여부를 가짐)을 작성된 두 목록으로, 또는 baseline과 changes 속성으로 전달합니다. 말로 된 집계가 카드 머리에 표시되고(“Original 12 days → Now 21 days · +75% across 7 accepted changes”, 이어서 보류 중인 영향과 청구 가능 일수 및 요율 기준 금액), 그 아래에 같은 축척으로 그린 두 레일이 있습니다. 하나는 원래 요청으로, 나중에 제거된 부분은 빗금으로 표시됩니다. 다른 하나는 현재 범위로, 수락된 증가마다 계단이 되어 이전보다 한 단계 더 짙어지고, 보류 중인 요청은 끝부분에 빗금으로 표시되며, 검토 지점 점선(+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
Keywordsexclusive, 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/marvelous

Marvelous 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 &amp; 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유형DefaultDescription
headingstringScopeTitle of the card (project, quote or sprint name). Also heads the exported summary.
subheadingstringSecondary line under the title (client, quote number, signature date).
heading-levelnumber (2-6)3Level of the heading element.
unit"day" | "hour" | "week" | "month" | any noun ("point") | 3-letter currency code ("USD")dayUnit 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-pluralstringPlural of a custom unit when adding “s” is wrong (unit="person-day" unit-plural="person-days").
ratenumberPrice per unit. When set, billable accepted changes also show their amount (“8.5 days billable ($7,225)”) and the summary adds the rate.
currencyISO 4217 codeUSDCurrency of rate amounts.
thresholdscomma-separated percents25,50Review 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-seenISO date or date-timeChanges 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-keystringPersists the last-seen moment and dismissed nudges per browser in localStorage (key mv-scope-tally:<storage-key>), synced across tabs.
view"timeline" | "compare"timelineVisible 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.
readonlybooleanHides decision and undo buttons, e.g. when the tally is shared with the client. Pending requests still show their impact.
localeBCP 47 tagen-USLocale for numbers, percents, dates and lists.
data-scope-baseline / data-scope-changesauthored listsOn 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-overset by the componentOn 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
baselineArray<{ id, label, estimate }>The original ask. Setting it re-renders.
changesArray<{ 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.
thresholdnumber | nullHighest review point reached, in percent (read-only).
seenDate | nullMoment used for the “since you last looked” marker (read-only).
stringsPartial<Record<string, string>>Overrides for every visible text, announcement and summary line ({placeholders} kept). English defaults.

Methods

NameDescription
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

NameDescription
mv-scope-decisionCancelable, 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-thresholdThe 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-exportCancelable, before Copy summary writes to the clipboard. detail: { text, format: "text" }. preventDefault() to open your own mail composer with detail.text instead.
mv-scope-seenMark as seen was used. detail: { lastSeen }.
mv-scope-viewThe user switched views. detail: { view }.

CSS classes

NameDescription
mv-scope-tally-cardThe card (section labelled by the heading): -head, -overview, -nudge, -body.
mv-scope-tally-tally / -sublineTally in words (-fig, -fig-label, -fig-value, -growth with data-dir, -across) and the pending / billable line.
mv-scope-tally-stripDecorative 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-nudgeReview-point note (role=note) with -nudge-title and Copy summary / Dismiss.
mv-scope-tally-tabs / -tab / -panelView switcher (tablist) and its panels.
mv-scope-tally-steps / -stepTimeline <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-tableCompare to original table: tbody[data-group=added|changed|removed|unchanged], tfoot totals, -compare-note.

CSS variables

NameDefaultDescription
--mv-scope-tally-stepvar(--mv-accent)Hue of accepted steps; each step mixes more of it than the previous one.
--mv-scope-tally-reviewvar(--mv-warning)Review-point lines, the nudge and the growth pill once a point is passed.
--mv-scope-tally-pendingvar(--mv-warning)Hatching and ink of pending requests.
--mv-scope-tally-originalmuted neutralFill 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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고