独家

Lead Board <mv-lead-board>

调查日志,唯一的进展就是你所知道的内容:研究、用户访谈、事故调查、尽职调查、bug 排查、新闻调查。

文化出处

星际拓荒,Mobius Digital(2019 年,游戏)。唯一的进度就是知识:飞船日志把传闻和发现记录为相互关联的卡片,标明哪些线索只是听说过、哪些已经探索过,并用箭头表示彼此的指向关系。在 UI 中,这成为一份调查日志:其大纲和地图将发现显示为传闻、已探索或死胡同并附带关联,对证据指向最多的未探索传闻进行排序,并在某个问题得到解答后将该分支搁置。

工作原理

一份调查日志,其中唯一的进展就是你所知道的东西:研究、用户访谈、事故调查、尽职调查、排查 bug、新闻调查。每个条目要么是处于三种状态之一的发现:传闻(听说过,未核实)、已探索(已核实,附证据)或死胡同;要么是一个问题,未解答或已解答。发现之间通过 “points to”、“contradicts” 和 “answers” 相互关联。同一份日志有两种视图:大纲(Outline,无障碍的唯一事实来源:问题在前,然后每个聚类一个小节,线索嵌套在指向它的线索之下,每种关系都用文字写明,例如 “Points to: Billing retries”,并附来源和记录人)和地图(Map,卡片位于各聚类的条带中,以绘制的箭头相连,自动布局以使整张地图适应视口,可拖动,并可用指针、双指捏合、ctrl + 滚轮或键盘平移和缩放)。“What’s unexplored?” 会对其他有效线索指向最多的传闻进行排序并给出理由(“Pointed to by 3 leads, 1 of them explored”),并在两个视图中突出显示它们。解答一个问题会把只通往该问题的分支搁置:其上游所有不同时通往某个未解答问题的线索都会变暗并加上标注。详情面板以跳转链接显示所选线索的证据、出处和关系,让团队可以更改其状态、解答问题、添加或移除关联;编辑器用于记录新线索。状态始终配有独特的图形符号和一个词(虚线圆环、对勾圆盘、斜线圆环、菱形),从不只靠颜色区分。数据来自标记(带 data-state、data-cluster、data-points-to… 的 [data-lead] 条目)或 leads 属性;mv-lead-state、mv-lead-add 和 mv-lead-link 均可取消,以便应用持久化或否决每一次更改。

分类数据展示
类型Web Component(<mv-lead-board>)
状态稳定版
同时安装button, input, select
Keywordsexclusive, culture, investigation, research, evidence, findings, rumor, leads, hypotheses, incident, post-mortem, bug-hunt, user-research, due-diligence, journalism, knowledge-graph, graph, outline, map, pan-zoom, drag

When to use

  • An incident or bug hunt collects unverified reports and verified facts, and the team must see which leads still need checking
  • A research or discovery team logs interview findings and hunches and wants to know which hunch the evidence points to most
  • Due diligence, audits or investigative reporting must keep every claim's source and author and show what contradicts what
  • A long investigation spans days and people, and answered questions should stop drawing attention to the leads behind them

Avoid when

  • The items are tasks moving through workflow stages rather than findings whose truth is being established → use Kanban instead
  • Several sources report the same timestamped events and the job is reconciling who says what happened when → use Accounts instead
  • Text claims must each show whether a citation backs them, inline in the prose → use Unsourced instead

安装

node scripts/add.mjs lead-board --out ./src/marvelous

使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["lead-board"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, components/select/select.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/lead-board/lead-board.js, components/lead-board/lead-board.css。

用法

标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:

<div id="lb-demo" style="width:min(100%,78rem);margin-inline:auto">
  <style>
    #lb-demo { display:grid; gap:2.5rem }
    #lb-demo .lb-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.75rem 1.5rem; flex-wrap:wrap; margin:0 0 .875rem }
    #lb-demo .lb-kicker { display:flex; align-items:center; gap:.5rem; margin:0 0 .375rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums }
    #lb-demo .lb-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #lb-demo .lb-sub { margin:.25rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; max-width:64ch }
    #lb-demo .lb-side { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap }
    #lb-demo .lb-side .mv-choice { font-size:.8125rem }
    #lb-demo .lb-log { margin:.625rem 0 0; min-height:1.25rem; color:var(--mv-fg-muted); font-size:.75rem }
    #lb-demo .lb-log b { color:var(--mv-fg); font-weight:600 }
    #lb-demo #lb-incident { --mv-lead-board-map-height: 38rem }
  </style>

  <!-- Incident investigation, map view, declared in markup -->
  <section aria-labelledby="lb-t1">
    <div class="lb-head">
      <div>
        <p class="lb-kicker"><span class="mv-badge" data-variant="danger" data-shape="pill">SEV-2</span> INC-3107 · Opened Sep 19, 2026 · Day 5</p>
        <h3 id="lb-t1">Some customers are charged twice at checkout</h3>
        <p class="lb-sub">Dashed cards are rumors nobody has checked yet. Select a card to light up what it points to, drag cards around, or turn on What’s unexplored? to see which rumor the evidence converges on.</p>
      </div>
      <div class="lb-side">
        <label class="mv-choice" data-control="end">
          <input type="checkbox" role="switch" class="mv-switch" id="lb-readonly">
          <span class="mv-choice-text"><span class="mv-choice-title">Read-only</span></span>
        </label>
        <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="lb-teammate">Simulate a teammate’s lead</button>
      </div>
    </div>

    <mv-lead-board id="lb-incident" view="map" author="Maya Okafor" label="INC-3107 investigation log">
      <ul>
        <li data-lead="why" data-kind="question" data-by="Amara Nwosu" data-at="2026-09-19" data-note="Top priority for the incident review on Sep 25.">Why are some customers charged twice?</li>
        <li data-lead="who" data-kind="question" data-by="Amara Nwosu" data-at="2026-09-19">Which customers are affected?</li>
        <li data-lead="release" data-state="answered" data-by="Kenji Watanabe" data-at="2026-09-19">Did the Sep 16 release cause it?</li>

        <li data-lead="tickets" data-state="explored" data-cluster="Customer reports" data-source="Help desk view: Double charge" data-by="Amara Nwosu" data-at="2026-09-19" data-points-to="double-tap, who">42 tickets report a double charge since Sep 17</li>
        <li data-lead="eu-cards" data-state="rumor" data-cluster="Customer reports" data-source="Support standup, Sep 19" data-by="Amara Nwosu" data-at="2026-09-19" data-points-to="three-ds, who">Agents say it is mostly EU cards</li>
        <li data-lead="double-tap" data-state="rumor" data-cluster="Customer reports" data-source="Mobile store review, Sep 18" data-by="Leo Martins" data-at="2026-09-20">Pay button can be tapped twice on slow networks</li>

        <li data-lead="timeouts" data-state="explored" data-cluster="Billing service" data-source="Latency dashboard, provider calls p99" data-by="Kenji Watanabe" data-at="2026-09-20" data-points-to="retries">Provider calls time out at 10 s since Sep 17</li>
        <li data-lead="retries" data-state="explored" data-cluster="Billing service" data-source="billing-api retry policy, charge.ts" data-by="Kenji Watanabe" data-at="2026-09-20" data-points-to="idempotency">Billing retries a charge after a timeout</li>
        <li data-lead="web-share" data-state="explored" data-cluster="Billing service" data-source="Payments export, Sep 17-21" data-by="Leo Martins" data-at="2026-09-21" data-contradicts="double-tap">17 of 42 double charges came from web checkout</li>
        <li data-lead="no-key" data-state="rumor" data-cluster="Billing service" data-source="Overheard in #billing" data-by="Sofia Marino" data-at="2026-09-21" data-points-to="idempotency">Retries may go out without an idempotency key</li>

        <li data-lead="three-ds" data-state="rumor" data-cluster="Payment provider" data-source="Provider support email, Sep 20" data-by="Sofia Marino" data-at="2026-09-20" data-points-to="idempotency">3-D Secure retries fail silently on some EU banks</li>
        <li data-lead="idempotency" data-state="rumor" data-cluster="Payment provider" data-source="Provider community forum thread" data-by="Sofia Marino" data-at="2026-09-21" data-answers="why">Provider ignores idempotency keys on 3-D Secure retries</li>

        <li data-lead="sales-said" data-state="dead-end" data-cluster="Release" data-source="#sales thread, Sep 18" data-by="Amara Nwosu" data-at="2026-09-18" data-points-to="release">Sales says the new checkout shipped Sep 16</li>
        <li data-lead="diff" data-state="explored" data-cluster="Release" data-source="Release diff v4.18.0 to v4.19.0" data-by="Kenji Watanabe" data-at="2026-09-19" data-contradicts="sales-said" data-answers="release">The Sep 16 release has no billing changes</li>
      </ul>
    </mv-lead-board>
    <p class="lb-log" id="lb-log1" aria-live="off">Keyboard: arrow keys move between cards, Shift + arrows move one, + and − zoom, 0 fits.</p>
  </section>

  <!-- User research, outline view, data from the leads property -->
  <section aria-labelledby="lb-t2">
    <div class="lb-head">
      <div>
        <p class="lb-kicker"><span class="mv-badge" data-variant="secondary" data-shape="pill">Discovery</span> Trial activation study · Sep 8-22, 2026</p>
        <h3 id="lb-t2">Why do trial teams stall in week 2?</h3>
        <p class="lb-sub">Six interviews, two dashboards and a pricing experiment. The pricing question is answered, so the leads behind it step back; What’s unexplored? ranks the hunches the evidence keeps pointing to.</p>
      </div>
      <div class="lb-side">
        <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="lb-answer">Mark the top lead explored</button>
      </div>
    </div>
    <mv-lead-board id="lb-research" unexplored selected="a-import" author="Hana Kobayashi" label="Trial activation research log"></mv-lead-board>
    <p class="lb-log" id="lb-log2" aria-live="off"></p>
  </section>

  <script type="module">
    const incident = document.getElementById("lb-incident");
    const research = document.getElementById("lb-research");
    const log1 = document.getElementById("lb-log1");
    const log2 = document.getElementById("lb-log2");
    const say = (el, [label, text = ""]) => { const b = document.createElement("b"); b.textContent = label; el.replaceChildren(b, text); };

    research.leads = [
      { id: "q-stall", kind: "question", title: "Why do trial teams stall in week 2?", by: "Hana Kobayashi", at: "2026-09-08" },
      { id: "q-price", kind: "question", state: "answered", title: "Is pricing a blocker for small teams?", by: "Chloe Martin", at: "2026-09-08" },

      { id: "i-invite", state: "explored", cluster: "Interviews", title: "4 of 6 admins never invited a second teammate", source: "Interview notes, sessions 1-6", by: "Hana Kobayashi", at: "2026-09-12", pointsTo: ["a-solo", "q-stall"] },
      { id: "i-import", state: "rumor", cluster: "Interviews", title: "Admins say the CSV import “broke” their data", source: "Session 3, operations lead", by: "Hana Kobayashi", at: "2026-09-10", pointsTo: ["a-import"] },
      { id: "i-champion", state: "rumor", cluster: "Interviews", title: "Trials stall when the champion goes on vacation", source: "Session 5, aside after the call", by: "Hana Kobayashi", at: "2026-09-15", pointsTo: ["q-stall"] },

      { id: "a-solo", state: "explored", cluster: "Product analytics", title: "Single-seat workspaces churn 3× more in week 2", source: "Retention dashboard, August cohort", by: "Omar Haddad", at: "2026-09-16", pointsTo: ["a-template"] },
      { id: "a-template", state: "rumor", cluster: "Product analytics", title: "Teams that skip templates never build a second board", source: "Hunch from the August funnel review", by: "Omar Haddad", at: "2026-09-16", pointsTo: ["q-stall"] },
      { id: "a-support", state: "explored", cluster: "Product analytics", title: "Support logged 23 import tickets in September", source: "Help desk tag: import", sourceHref: "#", by: "Omar Haddad", at: "2026-09-18", pointsTo: ["a-import"] },
      { id: "a-import", state: "rumor", cluster: "Product analytics", title: "Imports over 5,000 rows time out without an error", source: "Support team, not reproduced yet", by: "Omar Haddad", at: "2026-09-18", note: "If true, the team’s first real data never lands and the trial looks empty in week 2.", pointsTo: ["q-stall"] },

      { id: "s-import", state: "rumor", cluster: "Sales calls", title: "Two prospects asked whether import takes Excel files", source: "Sales call notes, Sep 11", by: "Chloe Martin", at: "2026-09-11", pointsTo: ["a-import"] },
      { id: "s-price", state: "dead-end", cluster: "Sales calls", title: "Small teams say the per-seat price is too high", source: "Recorded sales calls, August", by: "Chloe Martin", at: "2026-09-09", pointsTo: ["q-price"] },
      { id: "s-discount", state: "explored", cluster: "Sales calls", title: "Trials with a 50% discount stall at the same rate", source: "Pricing experiment #12", by: "Chloe Martin", at: "2026-09-19", contradicts: ["s-price"], answers: ["q-price"] },
    ];

    // Teammate activity: a new explored finding lands on the incident map.
    let added = false;
    document.getElementById("lb-teammate").addEventListener("click", (e) => {
      if (added) return;
      const id = incident.addLead({
        id: "captures", state: "explored", cluster: "Payment provider", by: "Kenji Watanabe",
        title: "Provider shows two captures for one authorization", source: "Provider ticket #55120",
        links: [{ to: "idempotency", type: "points-to" }],
      }, { select: false });
      if (id) { added = true; e.currentTarget.disabled = true; }
    });
    document.getElementById("lb-readonly").addEventListener("change", (e) => { incident.readonly = e.target.checked; });

    incident.addEventListener("mv-lead-add", (e) => say(log1, ["Logged: ", `${e.detail.lead.title} (${e.detail.lead.by})`]));
    incident.addEventListener("mv-lead-state", (e) => say(log1, ["State: ", `${e.detail.lead.title} → ${e.detail.state}`]));
    incident.addEventListener("mv-lead-link", (e) => say(log1, [`Link ${e.detail.action === "add" ? "added" : "removed"}: `, `${e.detail.from} ${e.detail.type} ${e.detail.to}`]));
    incident.addEventListener("mv-lead-move", (e) => say(log1, ["Moved: ", `${e.detail.id} to ${e.detail.x}, ${e.detail.y}`]));

    research.addEventListener("mv-lead-state", (e) => say(log2, ["State: ", `${e.detail.lead.title} → ${e.detail.state}`]));
    research.addEventListener("mv-lead-select", (e) => say(log2, ["Selected: ", e.detail.lead.title]));
    document.getElementById("lb-answer").addEventListener("click", () => {
      const top = research.promising[0];
      if (top) research.setState(top.id, "explored");
      else say(log2, ["Nothing left: ", "every rumor has been checked or set aside."]);
    });
  </script>
</div>

API

Attributes

Name类型DefaultDescription
viewoutline | mapoutlineWhich view is shown. Both render the same log; the outline is the accessible source of truth and the map mirrors it. The built-in Outline / Map switch sets it.
selectedstringId of the selected lead: highlighted in both views (its links and neighbors lit on the map, the rest faded) and shown in the detail panel. On the map, the detail column only opens while a lead is selected.
unexploredboolean“What’s unexplored?” mode: a Most promising leads strip ranks unexplored rumors (not set aside) by how many live leads point to them (dead ends don’t count; ties go to pointers that are explored, then to rumors leading to an open question); rumors get rank badges, arrows into rumors are lit, everything else steps back. The toolbar toggle sets it.
readonlybooleanHides the composer, state controls, link editor and unlink buttons. Selecting, reading and arranging the map still work.
authorstringName recorded as “Logged by” on leads added from the composer or addLead() (when the lead doesn’t set by).
localestringen-USLocale for dates (“Sep 19”, with the year when it isn’t the current one). Date-only values (2026-09-19) are read as calendar dates, never shifted by time zone.
labelstringInvestigation logAccessible name of the region.
heading-levelnumber3Heading level (2-6) of the outline sections and of the detail title.
data-view / data-unexplored / data-readonly / data-has-selectionset by the componentReflect the current view and modes on the host, for styling.

Properties

Name类型Description
leadsArray<{ id?, title, kind?: "finding" | "question", state?: "rumor" | "explored" | "dead-end" | "open" | "answered", cluster?, note?, source?, sourceHref?, by?, at?, links?: Array<{ to, type: "points-to" | "contradicts" | "answers" }>, pointsTo?, contradicts?, answers?, x?, y? }>The log as data (takes precedence over markup). pointsTo / contradicts / answers accept an array or a comma-separated list of ids as shorthand for links. state "question", "open" or "answered" implies kind question. x / y pin a card on the map. Reading it returns the current log, including edits and manual map positions (as x / y).
summary{ rumors, explored, deadEnds, questions, answered, setAside }Counts (read-only). setAside is the number of leads dimmed by answered questions.
promisingArray<{ id, title, rank, score, verified, reason }>Unexplored rumors in ranked order (read-only): score = live leads pointing to it, verified = how many of them are explored, reason = the sentence shown in the UI.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (English defaults), including state and relation names (rumor, explored, dead-end, open, answered, points-to, pointed-by, contradicts, contradicted-by, answers, answered-by, proposed-by), plurals written "one|many" with {n}.

Methods

NameDescription
select(id, { focus? })Selects a lead (null clears) and optionally focuses it in the current view. Emits mv-lead-select. Returns false for an unknown id.
setState(id, state)Changes a finding’s state (rumor | explored | dead-end) or a question’s (open | answered). Emits the cancelable mv-lead-state; returns false if vetoed or invalid.
answer(questionId, { by? })Marks a question answered, first linking the finding given as by with “answers” if needed. Its resolved branch is set aside.
addLead(lead, { select? })Adds a lead (same shape as leads items; by defaults to author, at to today). Emits the cancelable mv-lead-add; returns the new id or null.
link(from, to, type) / unlink(from, to, type)Adds or removes a relation (points-to | contradicts | answers; answers must target a question). Emits the cancelable mv-lead-link; returns false if vetoed or invalid.
arrange()Re-runs the automatic map layout (manual positions are dropped) and fits the map; cards glide to their new place.
fit() / zoom(factor, { to? })Fits the whole map in its viewport, or zooms around the viewport center (scale 0.3 to 2).

Events

NameDescription
mv-lead-selectA lead was selected. detail: { id, lead, view }.
mv-lead-stateCancelable, before a state change. detail: { id, state, previous, lead }. preventDefault() keeps the previous state.
mv-lead-addCancelable, before a lead is added. detail: { lead, reason: "form" | "api" }. detail.lead may be modified (e.g. a server id) before it is added; preventDefault() drops it.
mv-lead-linkCancelable, before a relation is added or removed. detail: { from, to, type, action: "add" | "remove" }.
mv-lead-moveA card was dragged or moved with Shift + arrow keys on the map. detail: { id, x, y } (world pixels, snapped to an 8 px grid). Persist it and pass it back as x / y.

CSS classes

NameDescription
mv-lead-board-bar / -tally / -promising / -composerToolbar (views, What’s unexplored?, Log a lead), counting legend, ranked strip, and the new-lead form.
mv-lead-board-outline / -group / -list / -node / -row / -item / -descOutline: one section per group, nested lists, a row per lead whose button (-item) holds the state tag and title and whose -desc spells out rank, relations (-rel with data-rel), source and author. -node carries data-state, data-kind, data-resolved, data-rank, data-selected.
mv-lead-board-map / -viewport / -world / -band / -edge / -edge-label / -card / -map-tools / -legendMap: pannable viewport, transformed world, cluster bands, SVG arrows (data-type, data-hot, data-cold, data-resolved), cards (data-state, data-kind, data-rank, aria-current, data-lifted while dragged), zoom tools and the arrow legend.
mv-lead-board-detailDetail panel (data-state, data-kind): kicker, title, note, -facts (evidence, logged by), -detail-rel groups of jump links, -states radiogroup, -linker.
mv-lead-board-tag / -glyph / -rankState pill (glyph + word, dashed for rumors), the state glyphs (.g-fill, .g-ink, .g-line, .g-dash) and the rank badge (data-top for #1).

CSS variables

NameDefaultDescription
--mv-lead-board-rumorvar(--mv-warning)Tone of rumors, of the What’s unexplored? mode and of rank badges.
--mv-lead-board-exploredvar(--mv-success)Tone of explored findings.
--mv-lead-board-dead-endvar(--mv-fg-subtle)Tone of dead ends.
--mv-lead-board-questionvar(--mv-accent)Tone of open questions and of “answers” arrows.
--mv-lead-board-answeredvar(--mv-success)Tone of answered questions and of the set-aside note.
--mv-lead-board-contradictsvar(--mv-danger)Tone of “contradicts” arrows (also dashed and labeled).
--mv-lead-board-map-height34remHeight of the map view.
--mv-lead-board-detail-width20remWidth of the detail column (it moves under the view below a 52rem-wide container).

Accessibility

The outline is the primary view and the source of truth; the map mirrors it and never holds information the outline lacks. The component is a labeled region. Outline sections have real headings (heading-level); each lead is a <button> in a nested list, reached with a single Tab stop (roving tabindex: Up / Down, Home / End, typeahead on titles), whose name starts with its state in words (“Rumor: Provider ignores idempotency keys…”) and whose aria-describedby text spells out its rank in unexplored mode, every relation (“Points to: Billing retries. Contradicted by: 17 of 42 double charges came from web checkout”), its source (“Heard from” for rumors, “Evidence” for findings) and who logged it when, and whether an answered question set it aside. The selected lead has aria-current. Map cards are buttons with the same name and the same description (they reference the outline text); one Tab stop, arrow keys move spatially to the nearest card in that direction, Shift + arrow keys move the focused card (the keyboard alternative to dragging, reported by mv-lead-move), + / − zoom, 0 fits, and the viewport itself is focusable so arrow keys pan it; focus is always scrolled into view. Arrows, band labels and edge labels are aria-hidden because relations are already in text. The Outline / Map switch is a radiogroup, What’s unexplored? a toggle button (aria-pressed), Log a lead a disclosure (aria-expanded) opening a labeled form; the state control in the detail panel is a radiogroup (arrow keys change the state), relations there are jump links that select and focus the related lead, unlink buttons have explicit names. Escape clears the selection and returns focus to the lead, or closes the composer. View changes, state changes, answered questions (with how many leads were set aside), new leads, links and the top unexplored lead are announced in a polite live region. States never rely on color: each has its own glyph (dashed ring, check disc, slashed ring, diamond, check diamond) and word, rumors have dashed borders, dead ends are struck through and labeled, set-aside leads carry text as well as dimming. Forced colors keep arrows, glyphs and the selection visible (CanvasText, Highlight). Reduced motion (OS or data-motion="reduce"): cards jump to a new layout instead of gliding and the camera moves without easing.

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