feat(desktop): anchored ghost graph with typed lenses — the note pane's third mode (#22) - #56
Conversation
…'s third mode (#22) The graph surface the design pass on #22 recommended: not a whole-vault force-directed hairball, but the open note's world, drawn so layout follows meaning. Deterministic hand-rolled SVG, zero new deps, zero IPC on toggle — the scene is a pure projection of the discovery state the note-open already fetches (explain + similar). Frontend: - ui/src/graph.ts — pure, unit-tested scene builder (ui/src/graph.test.ts, wired into npm test). Three lenses: "all" (authored edges on a category- banded orbit + the top `similar` candidates as a dashed ghost halo), "lineage" (supersedes/derived-from on an older→newer time axis with created dates), "argument" (supports left, refutes right, contradicts on a vertical fault line). - The reading key from the issue: color = edge category (5 CSS vars, light + dark), solid = authored / dashed teal = latent, disc = note / square = resource / dashed hollow ⚠ = dangling (#12's repair surface). Legend strip. - Clicking a ghost opens the existing link palette — committing re-runs discovery, so the ghost solidifies into a typed edge in place; right-click reuses the Similar-card menu. Note/resource nodes ride the existing data-open delegations; the anchor (and Escape) returns to reading. - Sticky graphOpen/graphLens; staggered entrance animation (reduced-motion aware); hover spotlights a node + its edges via CSS only; honest empty states (ghosts-need-embedding caveat mirrors #26's tiers). The note pane render is memoized so toasts/progress never replay the animation. Façade (additive, both adapters inherit): - ExplainView.resources (ResourceLinkView): a note's outbound resource links, so the graph draws all three edge-target kinds instead of silently hiding file links (db::outbound_resource_edges; issue comment on #22). - NeighborView.created (db::note_created): the lineage lens's time axis without re-reading files. - CLI: `b2 explain` prints a Resource links section; --json picks both up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ChHM1jmL1DJVhaSbLii7pv
📝 WalkthroughWalkthroughThis PR adds outbound resource links and note creation dates to explain data, introduces deterministic graph scene generation with all, lineage, and argument lenses, and renders an interactive anchored ghost graph in the desktop UI with themed styling and expanded tests. ChangesGraph visualization
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ExplainAPI
participant AppState
participant buildScene
participant GraphPane
Client->>ExplainAPI: request note explanation
ExplainAPI-->>AppState: return discovery data
AppState->>buildScene: build selected lens
buildScene-->>GraphPane: return positioned graph
GraphPane-->>Client: render interactive graph
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@ui/src/graph.ts`:
- Around line 468-493: Update the axis placement logic in the axis bucket loop
so every contradiction remains within the scene’s bounded fault-line layout,
keeping x anchored around CENTER.x instead of increasing without limit. Preserve
the alternating above/below vertical positioning, and add coverage for layouts
containing at least three and seven contradiction nodes to verify none are
clipped.
- Around line 237-268: Update the edge layout using canonical anchor-to-node
geometry for perpendicular offsets, independent of each edge’s outbound
direction, so bidirectional neighbors receive distinct curves. Revise the
lineage bookkeeping around the left/right placement maps to explicitly preserve
mixed-direction neighbors instead of overwriting one temporal role. Add a
regression test covering inbound and outbound edges to the same target.
In `@ui/src/render.ts`:
- Around line 640-669: The interactive graph currently exposes only
pointer-driven actions and non-focusable SVG nodes. In ui/src/render.ts lines
640-669, update nodeGroupHtml to assign actionable nodes appropriate roles,
accessible names, and keyboard focusability; in ui/src/render.ts lines 807-812,
replace the atomic role="img" presentation or add an equivalent accessible
relationship list; in ui/src/main.ts lines 1496-1511, handle Enter and Space on
focused nodes by invoking the same open, link, and graph-toggle actions used by
pointer clicks.
In `@ui/style.css`:
- Around line 25-31: Update the light-theme category color variables in the
category palette, including --cat-referential through --cat-latent, to use
darker text-safe values with sufficient contrast against --bg for the 10.5px SVG
edge and ghost labels. Ensure the corresponding category colors at the
additional label rules also consume these accessible variables without changing
dark-theme styling.
- Around line 1720-1736: Update the .graph-hint styles to allow long
initialization and reindex hint text to wrap within resized panes by removing or
overriding white-space: nowrap and adding appropriate wrapping behavior.
Preserve the existing positioning and visual styling.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 19f60b1c-ca5a-4d79-a29f-3245e5b03b9a
📒 Files selected for processing (12)
crates/b2-cli/src/main.rscrates/b2-core/src/db.rscrates/b2-core/src/vault.rscrates/b2-core/tests/explain.rsui/package.jsonui/src/graph.test.tsui/src/graph.tsui/src/main.tsui/src/render.tsui/src/state.tsui/src/types.tsui/style.css
| // Parallel-edge bookkeeping: how many edges share a node, and each one's index. | ||
| const perNode = new Map<string, number>(); | ||
| for (const it of items) perNode.set(it.nodeId, (perNode.get(it.nodeId) ?? 0) + 1); | ||
| const seen = new Map<string, number>(); | ||
|
|
||
| return items.map((it) => { | ||
| const node = nodeAt.get(it.nodeId); | ||
| if (!node) throw new Error(`unplaced node ${it.nodeId}`); | ||
| const r = radiusOf.get(it.nodeId) ?? NODE_R.note; | ||
| const [a, b, ra, rb] = it.outbound | ||
| ? [anchor, node, NODE_R.anchor, r] | ||
| : [node, anchor, r, NODE_R.anchor]; | ||
| const seg = trim(a, ra, b, rb); | ||
|
|
||
| // Parallel edges between the same pair bow apart on a perpendicular offset. | ||
| const siblings = perNode.get(it.nodeId) ?? 1; | ||
| const index = seen.get(it.nodeId) ?? 0; | ||
| seen.set(it.nodeId, index + 1); | ||
| let cx: number | null = null; | ||
| let cy: number | null = null; | ||
| let lx = (seg.x1 + seg.x2) / 2; | ||
| let ly = (seg.y1 + seg.y2) / 2; | ||
| if (siblings > 1) { | ||
| const off = (index - (siblings - 1) / 2) * 34; | ||
| const dx = seg.x2 - seg.x1; | ||
| const dy = seg.y2 - seg.y1; | ||
| const len = Math.hypot(dx, dy) || 1; | ||
| cx = lx + (-dy / len) * off; | ||
| cy = ly + (dx / len) * off; | ||
| // Quadratic midpoint: B(0.5) = ¼·p0 + ½·c + ¼·p1. | ||
| lx = 0.25 * seg.x1 + 0.5 * cx + 0.25 * seg.x2; | ||
| ly = 0.25 * seg.y1 + 0.5 * cy + 0.25 * seg.y2; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Handle neighbors connected in both directions.
Parallel-curve offsets are relative to the authored segment direction, so reversing an edge also reverses its normal and can place opposite-direction edges on the same curve. The lineage maps similarly overwrite a node’s left placement when that neighbor also appears on the right, hiding one temporal role.
Use canonical anchor-to-node geometry for offsets and explicitly represent mixed-direction lineage neighbors. Add an inbound/outbound same-target regression test.
Also applies to: 438-451
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ui/src/graph.ts` around lines 237 - 268, Update the edge layout using
canonical anchor-to-node geometry for perpendicular offsets, independent of each
edge’s outbound direction, so bidirectional neighbors receive distinct curves.
Revise the lineage bookkeeping around the left/right placement maps to
explicitly preserve mixed-direction neighbors instead of overwriting one
temporal role. Add a regression test covering inbound and outbound edges to the
same target.
| const side = (verb: string) => (verb === "supports" ? "left" : verb === "refutes" ? "right" : "axis"); | ||
|
|
||
| const nodeAt = new Map<string, Placed>(); | ||
| const bucket = (want: string): string[] => { | ||
| const ids: string[] = []; | ||
| for (const it of kept) { | ||
| if (side(it.verb) === want && !ids.includes(it.nodeId) && !nodeAt.has(it.nodeId)) { | ||
| ids.push(it.nodeId); | ||
| } | ||
| } | ||
| return ids; | ||
| }; | ||
| // A node arguing twice (e.g. supports + contradicts) is placed by its first | ||
| // bucket, left → right → axis, so placement stays deterministic. | ||
| const left = bucket("left"); | ||
| column(195, left.length).forEach((p, i) => nodeAt.set(left[i], p)); | ||
| const right = bucket("right"); | ||
| column(805, right.length).forEach((p, i) => nodeAt.set(right[i], p)); | ||
| const axis = bucket("axis"); | ||
| axis.forEach((id, i) => { | ||
| // The fault line: contradicting peers alternate above/below the claim. | ||
| nodeAt.set(id, { | ||
| x: CENTER.x + Math.floor(i / 2) * 170, | ||
| y: i % 2 === 0 ? 96 : VIEW_H - 96, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Keep every contradiction on a bounded fault-line layout.
After two nodes, x shifts away from CENTER.x; the seventh contradiction reaches x = 1010 and is clipped outside the 1000-wide scene. Use bounded vertical positions around the anchor and test at least three and seven contradiction nodes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ui/src/graph.ts` around lines 468 - 493, Update the axis placement logic in
the axis bucket loop so every contradiction remains within the scene’s bounded
fault-line layout, keeping x anchored around CENTER.x instead of increasing
without limit. Preserve the alternating above/below vertical positioning, and
add coverage for layouts containing at least three and seven contradiction nodes
to verify none are clipped.
| function nodeGroupHtml(n: GraphNode, edges: GraphEdge[], order: number): string { | ||
| const attrs: string[] = [`class="gnode is-${n.kind}"`, `style="--i:${order}"`]; | ||
| if (n.kind === "note" && n.path) attrs.push(`data-open="${escapeHtml(n.path)}"`); | ||
| if (n.kind === "anchor") attrs.push(`data-toggle-graph="1"`); | ||
| if (n.kind === "resource" && n.path) attrs.push(`data-open-resource="${escapeHtml(n.path)}"`); | ||
| if (n.kind === "ghost" && n.path) { | ||
| attrs.push( | ||
| `data-ghost-link="${escapeHtml(n.path)}"`, | ||
| `data-card-path="${escapeHtml(n.path)}"`, | ||
| `data-card-title="${escapeHtml(n.title ?? "")}"`, | ||
| ); | ||
| } | ||
| const r = NODE_R[n.kind]; | ||
| // Text goes on the side of the node facing *away* from the anchor (above for the | ||
| // upper half of the scene), so a label never sits in its own edge's path. | ||
| const above = n.kind !== "anchor" && n.y < VIEW_H / 2 - 20; | ||
| const label = `<text class="gnode-label" x="${px(n.x)}" y="${px( | ||
| above ? n.y - r - 14 : n.y + r + 18, | ||
| )}">${escapeHtml(n.label)}</text>`; | ||
| const sub = n.sub | ||
| ? `<text class="gnode-sub" x="${px(n.x)}" y="${px( | ||
| above ? n.y - r - 29 : n.y + r + 33, | ||
| )}">${escapeHtml(n.sub)}</text>` | ||
| : ""; | ||
| return `<g ${attrs.join(" ")}> | ||
| <title>${escapeHtml(nodeTitle(n))}</title> | ||
| ${edges.map(edgeHtml).join("")} | ||
| ${nodeShapeHtml(n)} | ||
| ${label}${sub} | ||
| </g>`; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the interactive graph keyboard and assistive-technology accessible. The graph is rendered as a static image containing non-focusable SVG groups, while its actions are handled only through pointer clicks.
ui/src/render.ts#L640-L669: give actionable nodes suitable roles, accessible names, and keyboard focus.ui/src/render.ts#L807-L812: replace the atomicrole="img"model or provide an equivalent accessible relationship list.ui/src/main.ts#L1496-L1511: activate the same open/link/toggle actions on Enter and Space.
📍 Affects 2 files
ui/src/render.ts#L640-L669(this comment)ui/src/render.ts#L807-L812ui/src/main.ts#L1496-L1511
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ui/src/render.ts` around lines 640 - 669, The interactive graph currently
exposes only pointer-driven actions and non-focusable SVG nodes. In
ui/src/render.ts lines 640-669, update nodeGroupHtml to assign actionable nodes
appropriate roles, accessible names, and keyboard focusability; in
ui/src/render.ts lines 807-812, replace the atomic role="img" presentation or
add an equivalent accessible relationship list; in ui/src/main.ts lines
1496-1511, handle Enter and Space on focused nodes by invoking the same open,
link, and graph-toggle actions used by pointer clicks.
| --cat-referential: #64748b; | ||
| --cat-expository: #d97706; | ||
| --cat-evidential: #d94f4f; | ||
| --cat-structural: #2f9e63; | ||
| --cat-versioning: #6366f1; | ||
| --cat-other: #8a8880; | ||
| --cat-latent: #0d9488; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use accessible colors for the small SVG labels.
The 10.5px edge and ghost labels inherit light-theme category colors with insufficient contrast against --bg; several are only around 3.0–3.9:1. Darken the light palette or introduce separate text-safe category variables.
Also applies to: 1558-1568, 1617-1619
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ui/style.css` around lines 25 - 31, Update the light-theme category color
variables in the category palette, including --cat-referential through
--cat-latent, to use darker text-safe values with sufficient contrast against
--bg for the 10.5px SVG edge and ghost labels. Ensure the corresponding category
colors at the additional label rules also consume these accessible variables
without changing dark-theme styling.
| .graph-hint { | ||
| position: absolute; | ||
| left: 50%; | ||
| bottom: 18px; | ||
| transform: translateX(-50%); | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| padding: 5px 14px; | ||
| border: 1px solid var(--border); | ||
| border-radius: 999px; | ||
| background: var(--surface); | ||
| color: var(--muted); | ||
| font-size: 12px; | ||
| box-shadow: var(--shadow); | ||
| white-space: nowrap; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Allow graph hints to wrap inside resized panes.
Long initialization and reindex hints are clipped because white-space: nowrap combines with the pane’s hidden overflow.
Proposed fix
.graph-hint {
+ box-sizing: border-box;
+ max-width: calc(100% - 32px);
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
display: flex;
align-items: center;
+ justify-content: center;
gap: 8px;
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 12px;
box-shadow: var(--shadow);
- white-space: nowrap;
+ text-align: center;
+ white-space: normal;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| .graph-hint { | |
| position: absolute; | |
| left: 50%; | |
| bottom: 18px; | |
| transform: translateX(-50%); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 5px 14px; | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| background: var(--surface); | |
| color: var(--muted); | |
| font-size: 12px; | |
| box-shadow: var(--shadow); | |
| white-space: nowrap; | |
| } | |
| .graph-hint { | |
| box-sizing: border-box; | |
| max-width: calc(100% - 32px); | |
| position: absolute; | |
| left: 50%; | |
| bottom: 18px; | |
| transform: translateX(-50%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 5px 14px; | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| background: var(--surface); | |
| color: var(--muted); | |
| font-size: 12px; | |
| box-shadow: var(--shadow); | |
| text-align: center; | |
| white-space: normal; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ui/style.css` around lines 1720 - 1736, Update the .graph-hint styles to
allow long initialization and reindex hint text to wrap within resized panes by
removing or overriding white-space: nowrap and adding appropriate wrapping
behavior. Preserve the existing positioning and visual styling.
Closes #22 — ships the design proposal's recommendation: the anchored ghost graph as the primary graph surface, with the typed-lens selector (default All, plus Lineage and Argument) as its second gear. Not a whole-vault force-directed hairball: one note's world, laid out so layout follows meaning.
What it looks like
A Graph toggle joins the note bar (sibling of
</>and Edit), sticky across notes — you can browse the vault in graph mode, each node click re-anchoring on the opened note. Escape (or clicking the anchor) returns to reading.similarcandidates float as a dashed-teal outer halo, score-tagged. Clicking a ghost opens the link palette; committing re-runs discovery, so the ghost solidifies into a typed colored edge in place — the discover→link loop made visual. Right-click on a ghost reuses the Similar-card menu (Open note / Link…).supersedes/derived-fromon an older→newer time axis, every node dated (newNeighborView.created).contradictssits on a dotted vertical fault line, zone captions framing the map.supportsa claim appears on the argument map.b2 init/ Reindex).How it's built
explain+similar), so entering the graph and switching lenses are instant.ui/src/graph.ts(category mapping, lens filters, orbit/column/fault-line geometry, parallel-edge curves, ghost cap) with 13 unit checks inui/src/graph.test.ts, wired intonpm testbeside the pane-sizing suite.prefers-reduced-motionaware), CSS-only hover that spotlights a node + its incident edges while the rest dims, halo'd verb labels, node labels placed away from the anchor so text never sits in its own edge's path, light + dark palettes for the five category colors. The note-pane render is memoized so toasts/progress ticks never replay the animation or reset reading scroll.Façade additions (small, additive — both adapters inherit)
explainonly exposed note↔note edges, so a graph over it would have hidden file links:ExplainView.resources(ResourceLinkView) — a note's outbound resource links, from the note's side (db::outbound_resource_edges).NeighborView.created(db::note_created) — the lineage time axis without file re-reads.b2 explainprints a Resource links: section;--jsonpicks both fields up automatically. The desktop host needed zero Rust changes — the thin-adapter discipline held.Verification
cargo test -p b2-core— full suite green, including new explain tests (resource links surfaced;createdcarried).cargo clippy --workspace --exclude b2-desktop— clean (one pre-existing warning in untoucheddb.rstrace-gate code).--jsonexplainshow connections / resource links / unresolved correctly.ui:tsc --noEmit+vite buildgreen; all 26 unit checks pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01ChHM1jmL1DJVhaSbLii7pv
Generated by Claude Code
Summary by CodeRabbit
b2 explain, including paths, captions, embedding status, and explanations.