익스클루시브

Lead Board <mv-lead-board>

알게 된 것만이 유일한 진척인 조사 기록입니다. 리서치, 사용자 인터뷰, 장애 조사, 실사, 버그 추적, 저널리즘에 적합합니다.

문화적 레퍼런스

아우터 와일즈, Mobius Digital (2019, 게임). 유일한 진행은 지식입니다. 우주선의 기록은 소문과 발견을 연결된 카드로 남기면서 어떤 단서는 소문으로만 들었고 어떤 단서는 탐사했는지 표시하고, 화살표로 무엇이 무엇을 가리키는지 보여 줍니다. UI에서는 이것이 조사 기록이 되어, 개요와 지도에서 발견을 소문, 탐사 완료, 막다른 길로 연결 관계와 함께 보여 주고, 가장 많은 증거가 가리키는 미탐사 소문의 순위를 매기며, 질문에 답이 나오면 그 갈래를 한쪽으로 치워 둡니다.

작동 방식

진행이란 오직 알게 된 것뿐인 조사 기록입니다: 리서치, 사용자 인터뷰, 장애 조사, 실사, 버그 추적, 저널리즘. 모든 항목은 세 가지 상태(소문: 들었지만 검증되지 않음, 탐사 완료: 증거와 함께 검증됨, 막다른 길) 중 하나에 있는 발견이거나, 열려 있거나 답이 나온 질문이며, 발견들은 “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로 번역되었습니다. 번역 문제 신고