Exclusifbêta
Lead Board <mv-lead-board>
Un journal d’enquête où la seule progression, c’est ce que vous savez : recherche, entretiens utilisateurs, analyses d’incidents, due diligence, chasse aux bugs, journalisme. Chaque élément est une piste dans l’un de trois états : rumeur (entendue, non vérifiée), explorée (vérifiée, avec ses preuves) ou impasse, ou bien une question, ouverte ou résolue, et les pistes se relient entre elles par « points to », « contradicts » et « answers ». Deux vues du même journal : une vue Outline (la source de vérité accessible : les questions d’abord, puis une section par groupe, une piste imbriquée sous celle qui y mène, chaque relation écrite en toutes lettres, par exemple « Points to: Billing retries », avec la source et l’auteur de l’entrée) et une vue Map (des cartes réparties en bandes par groupe, reliées par des flèches, disposées automatiquement pour que toute la carte tienne dans sa zone d’affichage, déplaçables, avec panoramique et zoom au pointeur, au pincement, avec Ctrl + molette ou au clavier). « What’s unexplored? » classe les rumeurs vers lesquelles pointent le plus d’autres pistes actives, avec la raison (« Pointed to by 3 leads, 1 of them explored »), et les met en évidence dans les deux vues. Répondre à une question met de côté la branche qui ne menait qu’à elle : chaque piste en amont qui ne mène pas aussi à une question ouverte est estompée et signalée. Un panneau de détail présente les preuves, la provenance et les relations de la piste sélectionnée sous forme de liens de navigation, et permet à l’équipe de changer son état, de répondre à une question, d’ajouter ou de retirer des liens ; un formulaire de saisie consigne les nouvelles pistes. Les états ont toujours un glyphe distinct et un mot (anneau pointillé, disque coché, anneau barré, losange), jamais la couleur seule. Les données viennent du balisage (éléments [data-lead] avec data-state, data-cluster, data-points-to…) ou de la propriété leads ; mv-lead-state, mv-lead-add et mv-lead-link sont annulables pour que l’application puisse enregistrer ou refuser chaque changement.
| Catégorie | Affichage de données |
|---|---|
| Type | Web Component (<mv-lead-board>) |
| Statut | bêta |
| Installe aussi | button, input, select |
| Keywords | exclusive, 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
Installation
node scripts/add.mjs lead-board --out ./src/marvelousAgent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["lead-board"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Fichiers copiés (dépendances comprises) : 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.
Utilisation
Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables 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="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>Référence culturelle
Outer Wilds, Mobius Digital (2019, jeu vidéo). La seule progression, c’est la connaissance : le journal de bord du vaisseau consigne rumeurs et découvertes sous forme de cartes reliées, en distinguant les pistes seulement entendues de celles qui ont été explorées, avec des flèches qui montrent ce qui mène à quoi. Dans l’interface, cela devient un journal d’enquête dont le plan et la carte présentent les découvertes comme rumeurs, pistes explorées ou impasses avec leurs liens, classent les rumeurs inexplorées vers lesquelles pointent le plus d’indices, et mettent une branche de côté une fois sa question résolue.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
view | outline | map | outline | Which 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. |
selected | string | Id 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. | |
unexplored | boolean | “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. | |
readonly | boolean | Hides the composer, state controls, link editor and unlink buttons. Selecting, reading and arranging the map still work. | |
author | string | Name recorded as “Logged by” on leads added from the composer or addLead() (when the lead doesn’t set by). | |
locale | string | en-US | Locale 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. |
label | string | Investigation log | Accessible name of the region. |
heading-level | number | 3 | Heading level (2-6) of the outline sections and of the detail title. |
data-view / data-unexplored / data-readonly / data-has-selection | set by the component | Reflect the current view and modes on the host, for styling. |
Properties
| Name | Type | Description |
|---|---|---|
leads | Array<{ 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. |
promising | Array<{ 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. |
strings | Partial<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
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-lead-select | A lead was selected. detail: { id, lead, view }. |
mv-lead-state | Cancelable, before a state change. detail: { id, state, previous, lead }. preventDefault() keeps the previous state. |
mv-lead-add | Cancelable, 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-link | Cancelable, before a relation is added or removed. detail: { from, to, type, action: "add" | "remove" }. |
mv-lead-move | A 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
| Name | Description |
|---|---|
mv-lead-board-bar / -tally / -promising / -composer | Toolbar (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 / -desc | Outline: 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 / -legend | Map: 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-detail | Detail 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 / -rank | State 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
| Name | Default | Description |
|---|---|---|
--mv-lead-board-rumor | var(--mv-warning) | Tone of rumors, of the What’s unexplored? mode and of rank badges. |
--mv-lead-board-explored | var(--mv-success) | Tone of explored findings. |
--mv-lead-board-dead-end | var(--mv-fg-subtle) | Tone of dead ends. |
--mv-lead-board-question | var(--mv-accent) | Tone of open questions and of “answers” arrows. |
--mv-lead-board-answered | var(--mv-success) | Tone of answered questions and of the set-aside note. |
--mv-lead-board-contradicts | var(--mv-danger) | Tone of “contradicts” arrows (also dashed and labeled). |
--mv-lead-board-map-height | 34rem | Height of the map view. |
--mv-lead-board-detail-width | 20rem | Width 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.