独家

Fork <mv-fork>

为任何由输入驱动的视图提供假设时间线(贷款和价格计算器、方案对比、配置预览、文案变体):Fork 把视图拆分为并排的时间线,起初完全相同,各自拥有独立输入,并通过你的 compute(state) 函数(同步或异步)或 render(state, body) 回调重新计算每条时间线的输出。

文化出处

《双面情人》,彼得·休伊特(1998 年,电影)。故事在女主角赶上或错过一班地铁的瞬间一分为二,随后并行讲述两条时间线,在两者之间来回切换,让观众比较一个微小的差别会带来怎样的结果。在界面中,任何由输入驱动的视图都可以在某一刻拆分为起点相同的平行时间线,输入和结果的每一处差异都并排标出,最后保留其中一条作为真实的时间线。

工作原理

为任何由输入驱动的视图提供“如果……会怎样”的平行时间线(贷款和价格计算器、方案对比、配置预览、文案变体):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 和单选框名称,并与表单脱离。拆分带有动画(视图被一分为二并向两侧拉开,新窗格从接缝处展开,保留的窗格以裁剪显现的方式展开),在减少动态效果模式下瞬间完成,并在窄容器中垂直堆叠。

分类数据展示
类型Web Component(<mv-fork>)
状态稳定版
同时安装badge, button
Keywordsexclusive, 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/marvelous

使用 Marvelous 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 &amp; 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类型DefaultDescription
maxnumber (2..6)4Maximum 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).
localeBCP 47 tag"en-US"Locale of built-in number, currency, date and time formatting.
currencyISO 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.
quietbooleanfalseStops the debounced spoken summary of diffs after edits (fork, keep, discard and swap are still announced).
data-bindstate keyOn 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-outoutput keyOn 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-unitstringAppended to delta badges and diff sentences ("/ mo" → “+$104 / mo”).
data-label / data-on / data-off / data-textstringNames used in diffs: field or output label (default: its <label>, legend, aria-label or the <dt> before it), checkbox texts, radio option text.
data-summarybooleanOn data-bind controls: the values quoted in “Forked at … from …” (default: the first two bound fields).
data-fork-fieldbooleanMarks 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-layoutread-onlySet 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) => voidCalled 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 | undefinedOverrides built-in formatting for inputs, outputs and deltas (return undefined to keep the default).
describe(state, id) => stringSummary of a state for the split divider (default: the data-summary fields).
stringsobjectPartial override of every visible text and announcement (English defaults).
stateobjectState of Timeline A, the main view. Setting it writes A's inputs and recomputes (can be set before the element is defined).
timelinesArray<{ id, label, parent, state, outputs, forkedAt, reference, compareTo, active, diff, element }>Snapshot of every timeline in visual order (read-only).
forked / activeboolean / stringWhether more than one timeline exists; id of the timeline that has (or last had) focus.

Methods

NameDescription
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

NameDescription
mv-forkBefore a timeline is created. detail: { from, timeline, state }. Cancelable: preventDefault() vetoes the fork.
mv-keepBefore 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-diffA 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-changeA bound control was edited. detail: { timeline, key, value, state }.
mv-discardA timeline was discarded. detail: { timeline, state }.
mv-swapTwo timelines were swapped. detail: { a, b, reference }.

Content structure

NameDescription
(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

NameDescription
mv-fork-bar / -hint / -tree / -node / -arcs / -count / -swap / -mainTop bar: hint and Fork button when unforked; mini tree, count, Swap and Fork once forked.
mv-fork-originSplit divider “Forked at … from …” (role=note).
mv-fork-stage / mv-fork-paneGrid 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 / -toolPane header: letter, name, provenance, change count badge, Fork and Discard tools.
mv-fork-body / mv-fork-foot / mv-fork-diff / mv-fork-keepThe view, then the footer with the differences in words and “Keep this timeline”.
mv-fork-deltaDelta 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

NameDefaultDescription
--mv-fork-tone-a … --mv-fork-tone-fslate, accent, teal, amber, rose, blueIdentity color of each timeline (letter, node, arc, halo, active border).
--mv-fork-good / --mv-fork-badvar(--mv-success) / var(--mv-danger)Delta badge tones for data-better.
--mv-fork-min17remMinimum pane width: fewer columns (down to one, stacked) when the container is narrower.
--mv-fork-gapvar(--mv-space-5)Gap between panes, where the seam is drawn.
--mv-fork-radiusvar(--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.

本页面由 AI 翻译。报告翻译问题