Exclusivebeta

Lead Board <mv-lead-board>

An investigation log where the only progress is what you know: research, user interviews, incident investigations, due diligence, bug hunts, journalism. Every item is a finding in one of three states: rumor (heard about, not verified), explored (verified, with its evidence) or dead end, or a question, open or answered, and findings link to each other with “points to”, “contradicts” and “answers”. Two views of the same log: an Outline (the accessible source of truth: questions first, then one section per cluster, a lead nested under the lead that points to it, every relation spelled out in words such as “Points to: Billing retries”, with source and who logged it) and a Map (cards in cluster bands with drawn arrows, auto-laid out so the whole map fits its viewport, draggable, pan and zoom with pointer, pinch, ctrl + wheel or keyboard). “What’s unexplored?” ranks the rumors most other live leads point to, with the reason (“Pointed to by 3 leads, 1 of them explored”), and spotlights them in both views. Answering a question sets aside the branch that only led to it: every lead upstream of it that does not also lead to an open question is dimmed and labeled. A detail panel shows the selected lead’s evidence, provenance and relations as jump links, and lets the team change its state, answer a question, add or remove links; a composer logs new leads. States always come with a distinct glyph and a word (dashed ring, check disc, slashed ring, diamond), never color alone. Data comes from markup ([data-lead] items with data-state, data-cluster, data-points-to…) or the leads property; mv-lead-state, mv-lead-add and mv-lead-link are cancelable so the app can persist or veto every change.

CategoryData display
TypeWeb Component (<mv-lead-board>)
Statusbeta
Also installsbutton, 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

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["lead-board"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.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, components/button/button.css, components/input/input.css, components/select/select.css.

Usage

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

<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="App 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>

Cultural reference

Outer Wilds, Mobius Digital (2019, game). The only progression is knowledge: the ship’s log records rumors and discoveries as linked cards, marking which leads were only heard about and which were explored, with arrows showing what points to what. In the UI this becomes an investigation log whose outline and map show findings as rumors, explored or dead ends with their links, rank the unexplored rumors that the most evidence points to, and set aside a branch once its question is answered.

API

Attributes

NameTypeDefaultDescription
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

NameTypeDescription
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.