익스클루시브
Fork <mv-fork>
입력값으로 움직이는 모든 화면(대출 및 가격 계산기, 플랜 비교, 설정 미리 보기, 문구 변형)을 위한 가정 시나리오 타임라인입니다. Fork는 화면을 처음에는 동일한 여러 타임라인으로 나란히 나누고, 각 타임라인이 자체 입력값을 가지며, compute(state) 함수(동기 또는 비동기)나 render(state, body) 콜백으로 각 타임라인의 결과를 다시 계산합니다.
문화적 레퍼런스
슬라이딩 도어즈, 피터 호윗(1998, 영화). 주인공이 기차를 타느냐 놓치느냐의 순간에 이야기가 둘로 갈라지고, 이후 두 타임라인을 나란히 따라가며 교차 편집하여 관객이 아주 작은 차이가 어떤 결과를 낳는지 비교할 수 있게 합니다. UI에서는 입력값으로 구동되는 모든 뷰를 특정 순간에 똑같이 시작하는 병렬 타임라인으로 나눌 수 있고, 입력과 결과의 모든 차이가 나란히 표시되며, 최종적으로 하나의 타임라인을 실제 타임라인으로 유지합니다.
작동 방식
입력값으로 구동되는 모든 뷰(대출 및 가격 계산기, 플랜 비교, 설정 미리보기, 문구 변형)를 위한 가정 시나리오 타임라인: Fork는 뷰를 똑같이 시작하는 나란한 타임라인으로 나누고, 각 타임라인은 자체 입력값을 가지며, compute(state) 함수(동기 또는 비동기) 또는 render(state, body) 콜백을 통해 모든 타임라인의 출력을 다시 계산합니다. 기준 타임라인과 다른 모든 것은 제자리에 표시됩니다. 변경된 필드에는 색조가 들어간 후광과 음성 설명(“Timeline A has 6.1%”)이 붙고, 숫자 출력에는 부호와 톤을 반영한 차이 배지(data-better="lower"일 때 빨간색으로 “+$1,128 / mo”, 날짜는 “15 yr earlier”)가 붙으며, 각 푸터는 변경된 입력값을 문장으로 나열합니다(“Rate 6.1% → 5.4%”). 구분선은 분기가 일어난 지점을 기록하고(“Forked at 2:14 PM from 30-year fixed, 6.1%”), 호로 이루어진 작은 트리는 어느 타임라인이 어디서 나왔는지 보여 줍니다(최대 4개, 6개까지 설정 가능). 패널은 다시 분기하거나, 교체하거나(가장 왼쪽이 기준이 되거나, 각 패널을 부모와 비교), 폐기하거나, 유지할 수 있습니다. 취소 가능한 mv-keep은 선택한 상태를 메인 뷰에 다시 병합하며, 메인 뷰는 항상 원래 DOM이므로 참조와 폼 제출이 그대로 유지됩니다. 뷰는 data-bind(입력, 셀렉트, 라디오, 체크박스, 범위, value를 가진 커스텀 요소)와 data-out(통화, 백분율, 숫자, 축약 또는 날짜 형식의 출력)으로 선언적으로 바인딩됩니다. 복제본에는 고유한 id와 라디오 name이 부여되고 폼에서 분리됩니다. 분기는 애니메이션으로 표현되며(뷰가 둘로 잘려 벌어지고, 새 패널이 이음매에서 펼쳐지며, 유지된 패널은 클립 리빌로 확장), 동작 줄이기 설정에서는 즉시 적용되고, 좁은 컨테이너에서는 세로로 쌓입니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-fork>) |
| 상태 | 안정 |
| 함께 설치되는 항목 | badge, button |
| Keywords | exclusive, culture, what-if, scenario, compare, comparison, side-by-side, calculator, simulator, diff, delta, branch, timeline, split, pricing, mortgage, flip, cancelable, accessibility |
When to use
- A loan, pricing or savings calculator should let users compare two scenarios side by side without losing their current inputs
- A plan, seat or configuration picker needs a what-if comparison where only the differences matter
- Users tweak several inputs and need to see exactly which ones changed and how much each output moved
- A settings or copy preview should let users branch, compare, then keep one version as the new default
Avoid when
- The goal is to generate and explore mutations of an element's CSS properties → use Variant Tree instead
- The comparison is between two aligned images, before and after → use Image Compare instead
- Users should undo or scrub through their past edits of one form rather than compare parallel versions → use Rewind instead
설치
node scripts/add.mjs fork --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["fork"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/badge/badge.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/fork/fork.js, components/fork/fork.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="fk-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#fk-demo { display:grid; gap:1.5rem }
#fk-demo .fk-presets { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .625rem }
#fk-demo .fk-presets > span { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500; margin-inline-end:.25rem }
#fk-demo .fk-view { display:grid; gap:1.25rem; container-type:inline-size }
#fk-demo .fk-inputs { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1rem }
@container (max-width: 22rem) { #fk-demo .fk-inputs { grid-template-columns:minmax(0,1fr) } }
#fk-demo .fk-field { display:grid; gap:.375rem; align-content:start; font-size:.8125rem }
#fk-demo .fk-field > span:first-child { display:flex; justify-content:space-between; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#fk-demo .fk-field output { color:var(--mv-fg); font-variant-numeric:tabular-nums }
#fk-demo .fk-wide { grid-column:1/-1 }
#fk-demo .fk-range { padding-block:.5rem .25rem }
#fk-demo .fk-hero { display:flex; flex-wrap:wrap; align-items:baseline; gap:.25rem .5rem; margin:0; padding-top:1.125rem; border-top:1px solid var(--mv-border) }
#fk-demo .fk-hero dt { flex-basis:100%; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#fk-demo .fk-hero dd { margin:0; display:flex; align-items:center; font-size:1.875rem; font-weight:650; letter-spacing:-.025em; font-variant-numeric:tabular-nums; line-height:1.1 }
#fk-demo .fk-hero small { color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500 }
#fk-demo .fk-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.625rem; margin:0 }
@container (max-width: 24rem) { #fk-demo .fk-stats { grid-template-columns:minmax(0,1fr) } }
#fk-demo .fk-stats div { display:grid; gap:.25rem; align-content:start; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#fk-demo .fk-stats dt { color:var(--mv-fg-muted); font-size:.6875rem }
#fk-demo .fk-stats dd { margin:0; display:flex; flex-wrap:wrap; align-items:center; row-gap:.25rem; font-size:.9375rem; font-weight:600; font-variant-numeric:tabular-nums }
#fk-demo .fk-stats dd .mv-fork-delta { margin-inline-start:0; flex-basis:auto }
#fk-demo .fk-stats dd [data-out] { margin-inline-end:.5rem }
#fk-demo .fk-split { display:grid; gap:.5rem }
#fk-demo .fk-bar { display:flex; height:.5rem; border-radius:999px; overflow:hidden; background:var(--mv-bg-muted) }
#fk-demo .fk-bar i { display:block; height:100%; transition:flex-grow var(--mv-duration-normal) var(--mv-ease-out) }
#fk-demo .fk-bar .p { background:var(--mv-fg-muted) }
#fk-demo .fk-bar .i { background:color-mix(in oklab, var(--mv-warning) 80%, var(--mv-fg) 10%) }
#fk-demo .fk-legend { display:flex; flex-wrap:wrap; justify-content:space-between; gap:.25rem 1rem; color:var(--mv-fg-muted); font-size:.75rem }
#fk-demo .fk-legend b { color:var(--mv-fg); font-weight:600; font-variant-numeric:tabular-nums }
#fk-demo .fk-legend span::before { content:""; display:inline-block; width:.5rem; height:.5rem; margin-inline-end:.375rem; border-radius:2px; background:var(--c) }
#fk-demo .fk-second { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,27rem); gap:1.5rem 3rem; align-items:start; padding-top:1.5rem; border-top:1px dashed var(--mv-border) }
#fk-demo .fk-second h4 { margin:0 0 .375rem; font-size:.9375rem }
#fk-demo .fk-second p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.55 }
#fk-demo .fk-seats { display:grid; gap:1rem }
#fk-demo .fk-plans { display:flex; gap:.375rem; margin:0; padding:0; border:0 }
#fk-demo .fk-plans legend { margin-bottom:.375rem; padding:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#fk-demo .fk-plans label { flex:1; display:grid; place-items:center; padding:.4375rem .5rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); font-size:.8125rem; cursor:pointer }
#fk-demo .fk-plans label:has(:checked) { border-color:var(--mv-fg); background:var(--mv-bg-muted); font-weight:600 }
#fk-demo .fk-plans label:has(:focus-visible) { box-shadow:var(--mv-focus-ring) }
#fk-demo .fk-plans input { position:absolute; opacity:0; pointer-events:none }
#fk-demo .fk-total { display:flex; flex-wrap:wrap; align-items:center; gap:.25rem; margin:0; font-size:1.375rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#fk-demo .fk-total small { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500; letter-spacing:0; margin-inline-start:.25rem }
#fk-demo .fk-log { list-style:none; margin:.75rem 0 0; padding:0; display:grid; gap:.375rem; font-size:.75rem; color:var(--mv-fg-muted) }
#fk-demo .fk-log b { color:var(--mv-fg); font-weight:600 }
@media (max-width: 48rem) { #fk-demo .fk-second { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="fk-presets" role="group" aria-label="Scenario presets">
<span>Try a what-if</span>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-preset="rate">Rates drop to 5.4%</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-preset="down">Put 30% down</button>
<button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-preset="extra">Pay $400 extra a month</button>
<label class="mv-choice" data-control="start" style="margin-inline-start:auto">
<input type="checkbox" role="switch" class="mv-switch" id="fk-parent">
<span class="mv-choice-text"><span class="mv-choice-title">Compare each with its parent</span></span>
</label>
</div>
<mv-fork id="fk-mortgage">
<div class="fk-view">
<div class="fk-inputs">
<label class="fk-field">
<span>Home price</span>
<input class="mv-input" type="number" data-bind="price" data-format="currency" min="50000" step="5000" value="685000" inputmode="numeric">
</label>
<label class="fk-field">
<span>Loan term</span>
<select class="mv-select" data-bind="term" data-summary>
<option value="30" selected>30-year fixed</option>
<option value="20">20-year fixed</option>
<option value="15">15-year fixed</option>
</select>
</label>
<label class="fk-field">
<span>Interest rate</span>
<input class="mv-input" type="number" data-bind="rate" data-format="percent" data-summary min="1" max="15" step="0.05" value="6.1" inputmode="decimal">
</label>
<label class="fk-field">
<span>Extra each month</span>
<input class="mv-input" type="number" data-bind="extra" data-format="currency" min="0" step="50" value="0" inputmode="numeric">
</label>
<label class="fk-field fk-wide">
<span>Down payment <output data-out="downLabel">20% · $137,000</output></span>
<input class="mv-slider fk-range" type="range" data-bind="down" data-format="percent" min="5" max="40" step="1" value="20">
</label>
</div>
<dl class="fk-hero">
<dt>Monthly principal & interest</dt>
<dd><span data-out="monthly" data-format="currency" data-better="lower" data-unit="/ mo" data-label="Monthly payment">$3,321</span></dd>
</dl>
<dl class="fk-stats">
<div><dt>Total interest</dt><dd><span data-out="interest" data-format="currency" data-better="lower">$647,538</span></dd></div>
<div><dt>Paid off</dt><dd><span data-out="payoff" data-format="date" data-better="lower">Oct 2056</span></dd></div>
<div><dt>Loan amount</dt><dd><span data-out="loan" data-format="currency">$548,000</span></dd></div>
</dl>
<div class="fk-split" aria-hidden="true">
<div class="fk-bar"><i class="p" style="flex-grow:548"></i><i class="i" style="flex-grow:647"></i></div>
<div class="fk-legend"><span style="--c:var(--mv-fg-muted)">Principal <b class="fk-lp">46%</b></span><span style="--c:var(--mv-warning)">Interest <b class="fk-li">54%</b> of all you pay</span></div>
</div>
</div>
</mv-fork>
<div class="fk-second">
<div>
<h4>Stacked, starting from one timeline</h4>
<p>Narrow containers stack the timelines. Fork the seat plan, change the plan or the billing cycle on one side, then keep the timeline you prefer: its values merge back into the main view.</p>
<ol class="fk-log" id="fk-log" aria-label="Event log"><li>Events will appear here.</li></ol>
</div>
<mv-fork id="fk-seats" max="3" style="--mv-fork-min:20rem">
<div class="fk-seats">
<label class="fk-field">
<span>Seats</span>
<input class="mv-input" type="number" data-bind="seats" data-summary min="1" max="500" value="24" inputmode="numeric">
</label>
<fieldset class="fk-plans">
<legend>Plan</legend>
<label><input type="radio" name="fk-plan" value="team" data-bind="plan" data-summary checked>Team</label>
<label><input type="radio" name="fk-plan" value="business" data-bind="plan">Business</label>
<label><input type="radio" name="fk-plan" value="enterprise" data-bind="plan">Enterprise</label>
</fieldset>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" data-bind="annual" data-label="Billing" data-on="Annual" data-off="Monthly" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Annual billing</span><span class="mv-choice-description">Two months free</span></span>
</label>
<p class="fk-total"><span data-out="yearly" data-format="currency" data-better="lower" data-unit="/ yr" data-label="Yearly cost">$4,320</span><small>per year</small></p>
</div>
</mv-fork>
</div>
<script type="module">
await customElements.whenDefined("mv-fork");
const fk = document.getElementById("fk-mortgage");
const seats = document.getElementById("fk-seats");
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
// Amortization with an optional extra payment each month (first payment: Nov 2026).
fk.compute = ({ price = 0, down = 20, term = "30", rate = 6, extra = 0 }) => {
const loan = Math.max(0, price * (1 - down / 100));
const n = Number(term) * 12;
const r = rate / 100 / 12;
const monthly = r ? (loan * r) / (1 - (1 + r) ** -n) : loan / n;
let balance = loan, interest = 0, months = 0;
while (balance > 0.005 && months < n) {
const i = balance * r;
interest += i;
balance -= Math.min(balance, monthly + extra - i);
months++;
}
return {
loan, monthly, interest,
payoff: new Date(2026, 9 + months, 1),
downLabel: `${down}% · ${usd.format(price * down / 100)}`,
};
};
fk.render = (state, body, { outputs }) => {
const p = outputs.loan ?? 0, i = outputs.interest ?? 0, total = p + i || 1;
const [bp, bi] = body.querySelectorAll(".fk-bar i");
bp.style.flexGrow = String(p); bi.style.flexGrow = String(i);
body.querySelector(".fk-lp").textContent = `${Math.round((p / total) * 100)}%`;
body.querySelector(".fk-li").textContent = `${Math.round((i / total) * 100)}%`;
};
const presets = { rate: { rate: 5.4 }, down: { down: 30 }, extra: { extra: 400 } };
document.querySelectorAll("#fk-demo [data-preset]").forEach((b) => b.addEventListener("click", () => {
fk.fork(fk.active, presets[b.dataset.preset]);
}));
document.getElementById("fk-parent").addEventListener("change", (e) => { fk.compare = e.target.checked ? "parent" : "first"; });
// Opens already split: 30-year at 6.1% vs 15-year at 5.4%.
if (!fk.forked) fk.fork("A", { term: "15", rate: 5.4 });
const prices = { team: 15, business: 29, enterprise: 49 };
seats.compute = ({ seats: n = 1, plan = "team", annual = true }) => ({
yearly: (n || 0) * prices[plan] * (annual ? 10 : 12),
});
const log = document.getElementById("fk-log");
const note = (html) => {
const li = document.createElement("li");
li.append(...html);
if (log.firstElementChild && !log.firstElementChild.querySelector("b")) log.firstElementChild.remove();
log.prepend(li);
while (log.children.length > 4) log.lastElementChild.remove();
};
const b = (t) => Object.assign(document.createElement("b"), { textContent: t });
seats.addEventListener("mv-fork", (e) => note([b("mv-fork"), ` Timeline ${e.detail.timeline} from ${e.detail.from}`]));
seats.addEventListener("mv-keep", (e) => note([b("mv-keep"), ` Timeline ${e.detail.timeline} kept (${e.detail.state.seats} seats, ${e.detail.state.plan})`]));
seats.addEventListener("mv-diff", (e) => {
const o = e.detail.outputs[0];
note([b("mv-diff"), ` ${e.detail.timeline}: ${o ? `${o.label} ${o.deltaText}` : `identical to ${e.detail.reference}`}`]);
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
max | number (2..6) | 4 | Maximum number of timelines, the original included. Fork buttons are disabled at the limit and the reason is given in their tooltip and announced. |
compare | "first" | "parent" | "first" | Reference for markers, badges and diffs: the leftmost timeline (swap to change it) or each timeline's parent (the timeline it was forked from). |
locale | BCP 47 tag | "en-US" | Locale of built-in number, currency, date and time formatting. |
currency | ISO 4217 code | "USD" | Currency of data-format="currency" values (overridable per element with data-currency). |
bar | "auto" | "none" | "auto" | "none" hides the built-in bar (hint, tree, Swap and Fork): drive it with your own data-fork-action buttons or the methods. |
quiet | boolean | false | Stops the debounced spoken summary of diffs after edits (fork, keep, discard and swap are still announced). |
data-bind | state key | On an input, select, textarea, radio group, checkbox or custom element with a value property inside the view: the state key it reads and writes. Numbers are parsed for number and range inputs (or data-type="number"); checkboxes give booleans. Several controls may share a key (a range and a number field stay in sync). | |
data-out | output key | On any element: replaced with the formatted value of that key from compute(). Numeric and date outputs get a delta badge when they differ from the reference. | |
data-format | "currency" | "percent" | "number" | "integer" | "compact" | "date" | On data-bind or data-out elements. percent takes percentage points (6.1 → 6.1%); date shows month and year and its delta in years and months. data-digits sets the fraction digits. | |
data-better | "lower" | "higher" | On a data-out: which direction is good, so the delta badge turns success or danger (neutral otherwise). | |
data-unit | string | Appended to delta badges and diff sentences ("/ mo" → “+$104 / mo”). | |
data-label / data-on / data-off / data-text | string | Names used in diffs: field or output label (default: its <label>, legend, aria-label or the <dt> before it), checkbox texts, radio option text. | |
data-summary | boolean | On data-bind controls: the values quoted in “Forked at … from …” (default: the first two bound fields). | |
data-fork-field | boolean | Marks the container that gets the changed halo (default: the control's label, .mv-field or fieldset, else its parent). | |
data-fork-action | "fork" | "keep" | "discard" | "swap" | "focus" | On any button inside the element: runs that action for the timeline it sits in (or data-timeline / the active one). | |
data-forked / data-count / data-layout | read-only | Set on the host: forked state, number of timelines, "split" or "stacked". |
Properties
| Name | 유형 | Description |
|---|---|---|
compute | (state, { timeline, label, parent }) => object | Promise<object> | Returns the outputs of one timeline, keyed like data-out. Async results show aria-busy and stale answers are dropped. |
render | (state, body, info) => void | Called for every timeline after its outputs are written, for custom visuals (charts, previews). info has id, outputs, reference, compareTo, refOutputs, refState and diff. Must be idempotent. |
format | (value, { key, kind, format, timeline }) => string | undefined | Overrides built-in formatting for inputs, outputs and deltas (return undefined to keep the default). |
describe | (state, id) => string | Summary of a state for the split divider (default: the data-summary fields). |
strings | object | Partial override of every visible text and announcement (English defaults). |
state | object | State of Timeline A, the main view. Setting it writes A's inputs and recomputes (can be set before the element is defined). |
timelines | Array<{ id, label, parent, state, outputs, forkedAt, reference, compareTo, active, diff, element }> | Snapshot of every timeline in visual order (read-only). |
forked / active | boolean / string | Whether more than one timeline exists; id of the timeline that has (or last had) focus. |
Methods
| Name | Description |
|---|---|
fork(from?, patch?) | Splits a timeline (default: the active one) into a new one that starts identical, with an optional state patch (scenario presets). Returns the new id ("B"…) or null when canceled or at max. |
keep(id) | Merges that timeline's state into Timeline A and closes the others. Returns false if mv-keep was canceled. |
discard(id) | Closes a timeline without merging (A cannot be discarded; its children are re-parented). |
swap(a?, b?) | Swaps two panes (default: the active one and the reference); the leftmost becomes the reference. |
set(id, patch) | Merges values into a timeline's state, writes its inputs and recomputes. |
focusTimeline(id) | Moves focus to the same field in that timeline, or to its region. |
recompute() | Recomputes every timeline (after external data used by compute changed). |
Events
| Name | Description |
|---|---|
mv-fork | Before a timeline is created. detail: { from, timeline, state }. Cancelable: preventDefault() vetoes the fork. |
mv-keep | Before a timeline is kept. detail: { timeline, state, previous (A's state), discarded (ids) }. Cancelable: preventDefault() keeps everything as is (confirm first, save to the server…). |
mv-diff | A timeline's differences with its reference changed. detail: { timeline, reference, inputs: [{ key, label, from, to, fromText, toText }], outputs: [{ key, label, from, to, fromText, toText, delta, deltaText, direction, tone }], text }. |
mv-change | A bound control was edited. detail: { timeline, key, value, state }. |
mv-discard | A timeline was discarded. detail: { timeline, state }. |
mv-swap | Two timelines were swapped. detail: { a, b, reference }. |
Content structure
| Name | Description |
|---|---|
(content) | The view: your fields (data-bind) and outputs (data-out). It stays your DOM as Timeline A; forks are clones. Use a <template> child instead when the view contains custom elements that build their own DOM, so each timeline is stamped from pristine markup. Listeners you attach to the original controls are not copied: react to mv-change or compute instead. |
CSS classes
| Name | Description |
|---|---|
mv-fork-bar / -hint / -tree / -node / -arcs / -count / -swap / -main | Top bar: hint and Fork button when unforked; mini tree, count, Swap and Fork once forked. |
mv-fork-origin | Split divider “Forked at … from …” (role=note). |
mv-fork-stage / mv-fork-pane | Grid of timelines. Pane attributes: data-timeline (A-F), data-reference, data-active, data-seam ("inline" | "block"), aria-busy while computing. |
mv-fork-head / -dot / -title / -sub / -tag / -tool | Pane header: letter, name, provenance, change count badge, Fork and Discard tools. |
mv-fork-body / mv-fork-foot / mv-fork-diff / mv-fork-keep | The view, then the footer with the differences in words and “Keep this timeline”. |
mv-fork-delta | Delta badge inserted after a data-out: data-tone="good | bad | neutral", data-sign="up | down". |
[data-fork-changed] | On changed controls, their container (halo) and changed outputs. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-fork-tone-a … --mv-fork-tone-f | slate, accent, teal, amber, rose, blue | Identity color of each timeline (letter, node, arc, halo, active border). |
--mv-fork-good / --mv-fork-bad | var(--mv-success) / var(--mv-danger) | Delta badge tones for data-better. |
--mv-fork-min | 17rem | Minimum pane width: fewer columns (down to one, stacked) when the container is narrower. |
--mv-fork-gap | var(--mv-space-5) | Gap between panes, where the seam is drawn. |
--mv-fork-radius | var(--mv-radius-xl) | Pane corner radius. |
Accessibility
Once forked, each timeline is a region named “Timeline A”, “Timeline B”… and described by its provenance (“From A · 2:14 PM”); before that the view is a plain container with no extra landmarks. Differences never rely on color: every changed control gets an aria-describedby text (“Changed in this timeline, Timeline A has 6.1%”), each delta badge carries a visually hidden “compared with Timeline A”, the footer lists changed inputs as “Rate 5.4%, was 6.1% in Timeline A”, and delta arrows plus signs (+ / −) back the success/danger tones. A polite live region announces forks, keeps, discards and swaps, and after edits a debounced summary of the output changes (“Timeline B compared with Timeline A: Monthly payment $4,448, $1,128 / mo higher; …”), silenced with quiet. Keyboard: the mini tree is a toolbar with roving arrows whose nodes move focus into their timeline; Alt+Shift+Arrow keys jump from a field to the same field in the previous or next timeline; Fork, Keep this timeline and Discard are real buttons with explicit labels, and disabled Fork buttons explain the limit. Focus is preserved: swapping keeps it on the same element (moveBefore when available), and keeping or discarding a pane that held focus moves it to the same field in the surviving timeline, else to its region. Clones get unique ids, relinked label/for and ARIA references, renamed radio groups and are detached from forms so only the original submits. Reduced motion (prefers-reduced-motion or data-motion="reduce"): every split, expand and swap is instant. Forced colors: panes, badges and diff chips use system colors, halos become dashed Highlight outlines and the active timeline a Highlight outline.