Skip to content

Instantly share code, notes, and snippets.

@bholmesdev
Created June 17, 2026 21:43
Show Gist options
  • Select an option

  • Save bholmesdev/c2ac306376ea794aff3e6d1b77bfad46 to your computer and use it in GitHub Desktop.

Select an option

Save bholmesdev/c2ac306376ea794aff3e6d1b77bfad46 to your computer and use it in GitHub Desktop.
PR walkthrough for bholmesdev/hubble.md#72
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fix tag placeholder visibility</title>
<style>:root {
--warp-bg: #121212;
--warp-panel: #1e1e1d;
--warp-panel-2: #292929;
--warp-border: #404040;
--warp-text: #faf9f6;
--warp-muted: #b4b4b2;
--warp-dim: #868584;
--warp-accent: #a43787;
--warp-green: #34895c;
--warp-blue: #2e5d9e;
--warp-purple: #754dac;
--warp-yellow: #c0872a;
--warp-font-sans: 'Matter', 'DM Sans', system-ui, sans-serif;
--warp-font-mono: 'Matter Mono', 'Roboto Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--warp-bg); color: var(--warp-text); font-family: var(--warp-font-sans); }
a { color: var(--warp-text); text-decoration-color: var(--warp-accent); text-underline-offset: 3px; }
button, input { font: inherit; }
.d3-walkthrough-shell { min-height: 100vh; display: grid; grid-template-rows: auto 1fr; }
.d3-walkthrough-header { display: grid; gap: 10px; padding: 28px 32px 20px; border-bottom: 1px solid var(--warp-border); background: linear-gradient(180deg, #1e1e1d, #121212); }
.d3-kicker { color: var(--warp-accent); font-family: var(--warp-font-mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; }
.d3-walkthrough-header h1 { margin: 0; max-width: 1080px; font-size: clamp(34px, 5vw, 72px); line-height: 0.95; letter-spacing: -0.04em; }
.d3-meta-row { display: flex; flex-wrap: wrap; gap: 8px; color: var(--warp-muted); font-family: var(--warp-font-mono); font-size: 12px; }
.d3-summary { max-width: 920px; margin: 0; color: var(--warp-muted); font-size: 17px; line-height: 1.45; }
.d3-canvas-layout { min-height: 0; display: grid; grid-template-columns: 310px minmax(560px, 1fr) 390px; gap: 0; }
.d3-control-panel, .d3-detail-panel { min-height: 0; overflow: auto; background: var(--warp-panel); border-right: 1px solid var(--warp-border); padding: 18px; }
.d3-detail-panel { border-right: 0; border-left: 1px solid var(--warp-border); }
.d3-panel-title { margin: 0 0 12px; font-size: 12px; color: var(--warp-muted); font-family: var(--warp-font-mono); letter-spacing: 0.08em; text-transform: uppercase; }
.d3-control-stack { display: grid; gap: 10px; margin-bottom: 18px; }
.d3-control-button, .d3-graph-toggle { border: 1px solid var(--warp-border); background: var(--warp-panel-2); color: var(--warp-text); border-radius: 10px; padding: 10px 12px; cursor: pointer; text-align: left; }
.d3-control-button:hover, .d3-graph-toggle:hover, .d3-control-button:focus, .d3-graph-toggle:focus { border-color: var(--warp-accent); outline: none; }
.d3-graph-toggle[aria-pressed="true"] { border-color: var(--graph-color, var(--warp-accent)); box-shadow: inset 3px 0 0 var(--graph-color, var(--warp-accent)); }
.d3-tour-card { border: 1px solid var(--warp-border); background: #121212; border-radius: 12px; padding: 12px; margin-bottom: 14px; }
.d3-tour-step-label { color: var(--warp-accent); font-family: var(--warp-font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.d3-tour-title { margin: 6px 0; font-size: 18px; line-height: 1.15; }
.d3-tour-body { color: var(--warp-muted); line-height: 1.4; margin: 0; }
.d3-search { width: 100%; border: 1px solid var(--warp-border); background: #121212; color: var(--warp-text); border-radius: 10px; padding: 10px 12px; }
.d3-search:focus { border-color: var(--warp-accent); outline: none; }
.d3-help { color: var(--warp-dim); font-family: var(--warp-font-mono); font-size: 11px; line-height: 1.5; }
.d3-canvas-stage { min-height: 0; position: relative; overflow: hidden; background: radial-gradient(circle at 20% 20%, #a4378722, transparent 28%), radial-gradient(circle at 80% 70%, #2e5d9e22, transparent 26%), #121212; }
#pr-walkthrough-canvas { width: 100%; height: 100%; min-height: 700px; display: block; }
.d3-canvas-error { position: absolute; inset: 18px; display: none; place-items: center; border: 1px solid var(--warp-border); background: #1e1e1df2; color: var(--warp-text); padding: 24px; z-index: 2; }
body.d3-canvas-error .d3-canvas-error { display: grid; }
.d3-graph-title { fill: #faf9f6; opacity: 0.36; font-family: var(--warp-font-mono); font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; }
.d3-edge path { fill: none; stroke: var(--edge-color, #868584); stroke-width: 2; stroke-opacity: 0.68; }
.d3-edge-arrow path { fill: var(--edge-color, #868584); }
.d3-edge text { fill: #cccbc8; font-family: var(--warp-font-mono); font-size: 11px; paint-order: stroke; stroke: #121212; stroke-width: 4px; stroke-linejoin: round; }
.d3-node { cursor: pointer; }
.d3-node rect { fill: #1e1e1d; stroke: var(--node-color, var(--warp-accent)); stroke-width: 2; filter: drop-shadow(0 10px 24px #00000066); }
.d3-node.is-selected rect { stroke: var(--warp-accent); stroke-width: 4; }
.d3-node.is-tour-node rect { stroke: var(--warp-accent); stroke-width: 4; filter: drop-shadow(0 0 18px #a4378788); }
.d3-node.is-dimmed, .d3-edge.is-dimmed { opacity: 0.18; }
.d3-node-title { fill: #faf9f6; font-family: var(--warp-font-sans); font-size: 15px; font-weight: 700; pointer-events: none; }
.d3-node-kind { fill: #b4b4b2; font-family: var(--warp-font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; pointer-events: none; }
.d3-node-summary { fill: #cccbc8; font-family: var(--warp-font-sans); font-size: 12px; pointer-events: none; }
.d3-detail-title { margin: 0 0 6px; font-size: 24px; line-height: 1.1; }
.d3-detail-kind { color: var(--warp-accent); font-family: var(--warp-font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; }
.d3-detail-summary { color: var(--warp-muted); line-height: 1.45; }
.d3-detail-section { margin-top: 18px; }
.d3-detail-section h3 { margin: 0 0 8px; color: var(--warp-muted); font-family: var(--warp-font-mono); font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; }
.d3-detail-list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.d3-detail-list li { border: 1px solid var(--warp-border); background: #121212; border-radius: 10px; padding: 10px; color: var(--warp-muted); line-height: 1.35; }
.d3-file-link { display: block; overflow-wrap: anywhere; color: var(--warp-text); font-family: var(--warp-font-mono); font-size: 12px; }
.d3-comment-author { display: block; color: var(--warp-accent); font-family: var(--warp-font-mono); font-size: 11px; margin-bottom: 4px; }
.d3-empty { color: var(--warp-dim); }
@media (max-width: 1180px) { .d3-canvas-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(680px, 1fr) auto; } .d3-control-panel, .d3-detail-panel { border: 0; border-bottom: 1px solid var(--warp-border); max-height: 360px; } }</style>
</head>
<body>
<main class="d3-walkthrough-shell">
<header class="d3-walkthrough-header">
<div class="d3-kicker">Warp PR walkthrough</div>
<h1>Fix tag placeholder visibility</h1>
<div class="d3-meta-row"><span>main ← oz/fix-tag-empty-placeholder-71</span><a href="https://github.com/bholmesdev/hubble.md/pull/72" target="_blank" rel="noreferrer">Open PR</a></div>
<p class="d3-summary">One-line fix in the file properties panel: the tags editor now hides its &quot;Empty&quot; placeholder once at least one tag exists, while still showing it for an empty tag property. Tiny PR (1 file, 1 line).</p>
</header>
<section class="d3-canvas-layout">
<aside class="d3-control-panel" aria-label="Canvas controls">
<p class="d3-panel-title">View</p>
<div class="d3-control-stack"><button class="d3-graph-toggle" type="button" data-graph-id="system-overview" aria-pressed="false" style="--graph-color: #c0872a">System overview</button>
<button class="d3-graph-toggle" type="button" data-graph-id="data-flow" aria-pressed="false" style="--graph-color: #34895c">Data flow graph</button>
<button class="d3-graph-toggle" type="button" data-graph-id="code-dependency" aria-pressed="false" style="--graph-color: #2e5d9e">Code dependency graph</button>
<button class="d3-graph-toggle" type="button" data-graph-id="user-action" aria-pressed="false" style="--graph-color: #754dac">User action graph</button></div>
<p class="d3-panel-title">Tour</p>
<div class="d3-tour-card" aria-live="polite">
<div class="d3-tour-step-label">Step 0 / 0</div>
<h2 class="d3-tour-title">View tour</h2>
<p class="d3-tour-body">Use Next tour step to start.</p>
</div>
<div class="d3-control-stack">
<button class="d3-control-button" type="button" data-d3-action="tour-prev">Previous tour step</button>
<button class="d3-control-button" type="button" data-d3-action="tour-next">Next tour step</button>
<button class="d3-control-button" type="button" data-d3-action="tour-restart">Restart tour</button>
</div>
<p class="d3-panel-title">Canvas</p>
<div class="d3-control-stack">
<button class="d3-control-button" type="button" data-d3-action="fit">Fit to view</button>
<button class="d3-control-button" type="button" data-d3-action="reset">Reset zoom</button>
</div>
<label class="d3-panel-title" for="d3-node-search">Search active graph</label>
<input id="d3-node-search" class="d3-search" type="search" placeholder="Search nodes, files, comments" />
<p class="d3-help">Keyboard: n/→ next, p/← previous, 1 overview, 2 data, 3 code, 4 user, + zoom in, - zoom out, 0 reset, f fit, / search, Esc clear.</p>
</aside>
<section class="d3-canvas-stage">
<svg id="pr-walkthrough-canvas" role="img" aria-label="Interactive PR walkthrough graph"></svg>
<div class="d3-canvas-error" role="alert"></div>
</section>
<aside id="pr-walkthrough-details" class="d3-detail-panel" aria-label="Selected point details"></aside>
</section>
<script>window.PR_WALKTHROUGH_D3_DATA = {"meta": {"title": "Fix tag placeholder visibility", "prUrl": "https://github.com/bholmesdev/hubble.md/pull/72", "baseRef": "main", "headRef": "oz/fix-tag-empty-placeholder-71", "summary": "One-line fix in the file properties panel: the tags editor now hides its \"Empty\" placeholder once at least one tag exists, while still showing it for an empty tag property. Tiny PR (1 file, 1 line)."}, "graphs": [{"id": "system-overview", "label": "System overview", "color": "#c0872a", "summary": "How the file properties / front-matter editing subsystem is structured, independent of this PR.", "nodes": [{"id": "editor-state-owner", "title": "EditorView front-matter state", "kind": "state owner", "x": -430, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "EditorView is the editing surface for a note. It owns the front-matter as React state (a FrontMatterState union of none/valid/invalid), derived from the document markdown. When properties change it serializes them back into markdown and schedules a debounced autosave, so the properties panel and the document body stay one source of truth.", "details": ["frontMatterState lives in EditorView via useState and is rebuilt from markdown with frontMatterStateFromMarkdown.", "onChange from the panel serializes properties and recombines them with the body before saving."], "files": [], "comments": [], "links": []}, {"id": "properties-panel", "title": "FilePropertiesPanel & PropertyRow", "kind": "container", "x": -40, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "FilePropertiesPanel renders each front-matter property as a PropertyRow: a type selector, an editable name, and a value editor. It manages draft rows (newly added, not yet named), auto-focus, and cleanup of empty rows when focus leaves. It is the layout and lifecycle layer between the state owner and the individual value editors.", "details": ["PropertyRow wires name/type controls and delegates the value cell to PropertyValue.", "Empty draft rows are removed on blur via removeEmptyIfLeavingRow / isEmptyDraftProperty."], "files": [], "comments": [], "links": []}, {"id": "value-editors", "title": "Typed value editors", "kind": "components", "x": 350, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "PropertyValue switches on the property type and renders the matching editor: ScalarValue for text/number, DateValue for dates, a checkbox, an unsupported view, and TagsValue for tag lists. Each editor owns its own local draft input state and reports committed values upward, which keeps per-type input behavior (parsing, placeholders, chips) isolated from the panel.", "details": ["TagsValue keeps a draft string, splits on commas, and emits a string[] of tags.", "Placeholder text is a per-editor presentation concern owned inside each value component."], "files": [], "comments": [], "links": []}], "edges": [], "tour": [{"nodeId": "editor-state-owner", "title": "Who owns the data", "body": "Start here: EditorView holds front-matter as state and is responsible for parsing it from and serializing it back to markdown."}, {"nodeId": "properties-panel", "title": "How rows are laid out", "body": "FilePropertiesPanel turns each property into a row and decides which value editor to show, but defers the actual input behavior downward."}, {"nodeId": "value-editors", "title": "Where input behavior lives", "body": "Each typed editor (including the tags editor) owns its own draft state and presentation, which is the layer relevant to property-value UX."}]}, {"id": "data-flow", "label": "Data flow graph", "color": "#34895c", "summary": "How a note's front matter becomes tag values, how the placeholder is decided, and how edits are saved back.", "nodes": [{"id": "markdown-source", "title": "Note markdown", "kind": "input", "x": -460, "y": -30, "summary": "The note's raw markdown / YAML front matter.", "details": ["EditorView receives initialMarkdown and reparses it when it changes.", "No PR-changed specs were found and no existing PR review comments were posted for PR #72."], "files": [], "comments": [], "links": [{"label": "PR #72", "url": "https://github.com/bholmesdev/hubble.md/pull/72"}]}, {"id": "parse-state", "title": "Parsed front-matter state", "kind": "transform", "x": -150, "y": -30, "summary": "parseMarkdownFrontMatter + frontMatterStateFromMarkdown produce a typed properties list.", "details": ["A 'tags' property carries value as string[].", "This array's length is exactly what the fix keys the placeholder off of."], "files": [], "comments": [], "links": []}, {"id": "tags-render", "title": "TagsValue placeholder decision", "kind": "render", "x": 170, "y": -30, "summary": "TagsValue renders tag chips plus a draft input whose placeholder is now values.length === 0 ? \"Empty\" : \"\".", "details": ["Before: placeholder was always \"Empty\", so it showed even next to existing chips.", "After: the placeholder is blank once any tag is present (the single changed line)."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Changed placeholder expression in TagsValue."}], "comments": [], "links": []}, {"id": "persist", "title": "Serialize & autosave", "kind": "output", "x": 470, "y": -30, "summary": "Adding a tag calls onChange up to EditorView, which serializes front matter and schedules an autosave.", "details": ["addDraft emits the new string[] via onChange.", "This path is unchanged by the PR; the fix is presentation-only."], "files": [], "comments": [], "links": []}], "edges": [{"source": "markdown-source", "target": "parse-state", "label": "parsed into"}, {"source": "parse-state", "target": "tags-render", "label": "tag values flow to"}, {"source": "tags-render", "target": "persist", "label": "edits serialized to"}], "tour": [{"nodeId": "markdown-source", "title": "Start at the source", "body": "Front matter begins as markdown. This view follows it to the placeholder decision the PR changes."}, {"nodeId": "parse-state", "title": "Into typed state", "body": "Parsing yields a tags property whose value is a string[]; its length is the only input the fix needs."}, {"nodeId": "tags-render", "title": "The changed line", "body": "TagsValue now shows \"Empty\" only when there are zero tags. This is the entire behavioral change."}, {"nodeId": "persist", "title": "Edits still save normally", "body": "Confirm the save path is untouched: the change only affects placeholder text, not how tags are persisted."}]}, {"id": "code-dependency", "label": "Code dependency graph", "color": "#2e5d9e", "summary": "Which modules depend on each other around the changed TagsValue component.", "nodes": [{"id": "editorview", "title": "EditorView.tsx", "kind": "entry point", "x": -430, "y": -60, "summary": "Top-level editor surface; renders FilePropertiesPanel and owns front-matter state.", "details": ["Entry point for the properties UI; not modified by this PR."], "files": [], "comments": [], "links": []}, {"id": "panel", "title": "FilePropertiesPanel.tsx", "kind": "module", "x": -110, "y": -60, "summary": "PropertyRow → PropertyValue dispatch; the only file changed by the PR.", "details": ["Routes 'tags' properties to TagsValue via PropertyValue."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087c", "note": "Sole changed file in PR #72."}], "comments": [], "links": []}, {"id": "tagsvalue", "title": "TagsValue", "kind": "leaf component", "x": 210, "y": 70, "summary": "Leaf editor that renders chips + draft input; holds the one changed line.", "details": ["The placeholder expression change is fully contained here."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Changed line."}], "comments": [], "links": []}, {"id": "editor-pkg", "title": "@hubble.md/editor front matter", "kind": "leaf dependency", "x": 210, "y": -150, "summary": "parseMarkdownFrontMatter / serializeFrontMatter used to read and write properties.", "details": ["Stable dependency; not touched by the PR."], "files": [], "comments": [], "links": []}], "edges": [{"source": "editorview", "target": "panel", "label": "renders"}, {"source": "panel", "target": "tagsvalue", "label": "delegates tags to"}, {"source": "panel", "target": "editor-pkg", "label": "imports parse/serialize"}], "tour": [{"nodeId": "editorview", "title": "Entry point", "body": "EditorView is where the properties UI is mounted; it is the caller, not the change site."}, {"nodeId": "panel", "title": "The changed file", "body": "FilePropertiesPanel.tsx is the only file in the diff. It dispatches tag properties to TagsValue."}, {"nodeId": "tagsvalue", "title": "Leaf with the change", "body": "Drill into TagsValue: the entire edit is one line here, so the blast radius is a single leaf component."}]}, {"id": "user-action", "label": "User action graph", "color": "#754dac", "summary": "What the user does in the tags property and what they now see.", "nodes": [{"id": "open-props", "title": "Open a note's properties", "kind": "surface", "x": -430, "y": -30, "summary": "User views the properties panel of a note that has a tags property.", "details": ["An empty tags property shows the \"Empty\" placeholder, which is intended."], "files": [], "comments": [], "links": []}, {"id": "add-tag", "title": "Add the first tag", "kind": "action", "x": -110, "y": -30, "summary": "User types a tag and presses Enter or comma; addDraft appends it to the tag list.", "details": ["addDraft splits on commas, trims, and emits the new string[]."], "files": [], "comments": [], "links": []}, {"id": "placeholder-feedback", "title": "Placeholder hides", "kind": "feedback", "x": 210, "y": -30, "summary": "With ≥1 tag the draft input placeholder is now blank instead of the stray \"Empty\" text.", "details": ["Before the fix, \"Empty\" showed next to existing chips, which looked wrong (see issue #71).", "Empty tag properties still show \"Empty\" so the field remains discoverable."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Placeholder now depends on values.length."}], "comments": [], "links": [{"label": "Bug screenshot (issue #71)", "url": "assets/issue-71-bug.png"}, {"label": "Issue #71", "url": "https://github.com/bholmesdev/hubble.md/issues/71"}]}], "edges": [{"source": "open-props", "target": "add-tag", "label": "user adds tag"}, {"source": "add-tag", "target": "placeholder-feedback", "label": "input re-renders"}], "tour": [{"nodeId": "open-props", "title": "Where the user starts", "body": "The user is in a note's properties panel; an empty tags field correctly invites input with \"Empty\"."}, {"nodeId": "add-tag", "title": "The triggering action", "body": "Adding the first tag is the moment the old behavior looked wrong."}, {"nodeId": "placeholder-feedback", "title": "What changed for the user", "body": "After the fix, the stray \"Empty\" disappears once a tag exists. Open the screenshot to see the original bug."}]}]};</script>
<script id="pr-walkthrough-data" type="application/json">{"meta": {"title": "Fix tag placeholder visibility", "prUrl": "https://github.com/bholmesdev/hubble.md/pull/72", "baseRef": "main", "headRef": "oz/fix-tag-empty-placeholder-71", "summary": "One-line fix in the file properties panel: the tags editor now hides its \"Empty\" placeholder once at least one tag exists, while still showing it for an empty tag property. Tiny PR (1 file, 1 line)."}, "graphs": [{"id": "system-overview", "label": "System overview", "color": "#c0872a", "summary": "How the file properties / front-matter editing subsystem is structured, independent of this PR.", "nodes": [{"id": "editor-state-owner", "title": "EditorView front-matter state", "kind": "state owner", "x": -430, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "EditorView is the editing surface for a note. It owns the front-matter as React state (a FrontMatterState union of none/valid/invalid), derived from the document markdown. When properties change it serializes them back into markdown and schedules a debounced autosave, so the properties panel and the document body stay one source of truth.", "details": ["frontMatterState lives in EditorView via useState and is rebuilt from markdown with frontMatterStateFromMarkdown.", "onChange from the panel serializes properties and recombines them with the body before saving."], "files": [], "comments": [], "links": []}, {"id": "properties-panel", "title": "FilePropertiesPanel & PropertyRow", "kind": "container", "x": -40, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "FilePropertiesPanel renders each front-matter property as a PropertyRow: a type selector, an editable name, and a value editor. It manages draft rows (newly added, not yet named), auto-focus, and cleanup of empty rows when focus leaves. It is the layout and lifecycle layer between the state owner and the individual value editors.", "details": ["PropertyRow wires name/type controls and delegates the value cell to PropertyValue.", "Empty draft rows are removed on blur via removeEmptyIfLeavingRow / isEmptyDraftProperty."], "files": [], "comments": [], "links": []}, {"id": "value-editors", "title": "Typed value editors", "kind": "components", "x": 350, "y": -40, "width": 360, "height": 230, "summaryLines": 7, "summary": "PropertyValue switches on the property type and renders the matching editor: ScalarValue for text/number, DateValue for dates, a checkbox, an unsupported view, and TagsValue for tag lists. Each editor owns its own local draft input state and reports committed values upward, which keeps per-type input behavior (parsing, placeholders, chips) isolated from the panel.", "details": ["TagsValue keeps a draft string, splits on commas, and emits a string[] of tags.", "Placeholder text is a per-editor presentation concern owned inside each value component."], "files": [], "comments": [], "links": []}], "edges": [], "tour": [{"nodeId": "editor-state-owner", "title": "Who owns the data", "body": "Start here: EditorView holds front-matter as state and is responsible for parsing it from and serializing it back to markdown."}, {"nodeId": "properties-panel", "title": "How rows are laid out", "body": "FilePropertiesPanel turns each property into a row and decides which value editor to show, but defers the actual input behavior downward."}, {"nodeId": "value-editors", "title": "Where input behavior lives", "body": "Each typed editor (including the tags editor) owns its own draft state and presentation, which is the layer relevant to property-value UX."}]}, {"id": "data-flow", "label": "Data flow graph", "color": "#34895c", "summary": "How a note's front matter becomes tag values, how the placeholder is decided, and how edits are saved back.", "nodes": [{"id": "markdown-source", "title": "Note markdown", "kind": "input", "x": -460, "y": -30, "summary": "The note's raw markdown / YAML front matter.", "details": ["EditorView receives initialMarkdown and reparses it when it changes.", "No PR-changed specs were found and no existing PR review comments were posted for PR #72."], "files": [], "comments": [], "links": [{"label": "PR #72", "url": "https://github.com/bholmesdev/hubble.md/pull/72"}]}, {"id": "parse-state", "title": "Parsed front-matter state", "kind": "transform", "x": -150, "y": -30, "summary": "parseMarkdownFrontMatter + frontMatterStateFromMarkdown produce a typed properties list.", "details": ["A 'tags' property carries value as string[].", "This array's length is exactly what the fix keys the placeholder off of."], "files": [], "comments": [], "links": []}, {"id": "tags-render", "title": "TagsValue placeholder decision", "kind": "render", "x": 170, "y": -30, "summary": "TagsValue renders tag chips plus a draft input whose placeholder is now values.length === 0 ? \"Empty\" : \"\".", "details": ["Before: placeholder was always \"Empty\", so it showed even next to existing chips.", "After: the placeholder is blank once any tag is present (the single changed line)."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Changed placeholder expression in TagsValue."}], "comments": [], "links": []}, {"id": "persist", "title": "Serialize & autosave", "kind": "output", "x": 470, "y": -30, "summary": "Adding a tag calls onChange up to EditorView, which serializes front matter and schedules an autosave.", "details": ["addDraft emits the new string[] via onChange.", "This path is unchanged by the PR; the fix is presentation-only."], "files": [], "comments": [], "links": []}], "edges": [{"source": "markdown-source", "target": "parse-state", "label": "parsed into"}, {"source": "parse-state", "target": "tags-render", "label": "tag values flow to"}, {"source": "tags-render", "target": "persist", "label": "edits serialized to"}], "tour": [{"nodeId": "markdown-source", "title": "Start at the source", "body": "Front matter begins as markdown. This view follows it to the placeholder decision the PR changes."}, {"nodeId": "parse-state", "title": "Into typed state", "body": "Parsing yields a tags property whose value is a string[]; its length is the only input the fix needs."}, {"nodeId": "tags-render", "title": "The changed line", "body": "TagsValue now shows \"Empty\" only when there are zero tags. This is the entire behavioral change."}, {"nodeId": "persist", "title": "Edits still save normally", "body": "Confirm the save path is untouched: the change only affects placeholder text, not how tags are persisted."}]}, {"id": "code-dependency", "label": "Code dependency graph", "color": "#2e5d9e", "summary": "Which modules depend on each other around the changed TagsValue component.", "nodes": [{"id": "editorview", "title": "EditorView.tsx", "kind": "entry point", "x": -430, "y": -60, "summary": "Top-level editor surface; renders FilePropertiesPanel and owns front-matter state.", "details": ["Entry point for the properties UI; not modified by this PR."], "files": [], "comments": [], "links": []}, {"id": "panel", "title": "FilePropertiesPanel.tsx", "kind": "module", "x": -110, "y": -60, "summary": "PropertyRow → PropertyValue dispatch; the only file changed by the PR.", "details": ["Routes 'tags' properties to TagsValue via PropertyValue."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087c", "note": "Sole changed file in PR #72."}], "comments": [], "links": []}, {"id": "tagsvalue", "title": "TagsValue", "kind": "leaf component", "x": 210, "y": 70, "summary": "Leaf editor that renders chips + draft input; holds the one changed line.", "details": ["The placeholder expression change is fully contained here."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Changed line."}], "comments": [], "links": []}, {"id": "editor-pkg", "title": "@hubble.md/editor front matter", "kind": "leaf dependency", "x": 210, "y": -150, "summary": "parseMarkdownFrontMatter / serializeFrontMatter used to read and write properties.", "details": ["Stable dependency; not touched by the PR."], "files": [], "comments": [], "links": []}], "edges": [{"source": "editorview", "target": "panel", "label": "renders"}, {"source": "panel", "target": "tagsvalue", "label": "delegates tags to"}, {"source": "panel", "target": "editor-pkg", "label": "imports parse/serialize"}], "tour": [{"nodeId": "editorview", "title": "Entry point", "body": "EditorView is where the properties UI is mounted; it is the caller, not the change site."}, {"nodeId": "panel", "title": "The changed file", "body": "FilePropertiesPanel.tsx is the only file in the diff. It dispatches tag properties to TagsValue."}, {"nodeId": "tagsvalue", "title": "Leaf with the change", "body": "Drill into TagsValue: the entire edit is one line here, so the blast radius is a single leaf component."}]}, {"id": "user-action", "label": "User action graph", "color": "#754dac", "summary": "What the user does in the tags property and what they now see.", "nodes": [{"id": "open-props", "title": "Open a note's properties", "kind": "surface", "x": -430, "y": -30, "summary": "User views the properties panel of a note that has a tags property.", "details": ["An empty tags property shows the \"Empty\" placeholder, which is intended."], "files": [], "comments": [], "links": []}, {"id": "add-tag", "title": "Add the first tag", "kind": "action", "x": -110, "y": -30, "summary": "User types a tag and presses Enter or comma; addDraft appends it to the tag list.", "details": ["addDraft splits on commas, trims, and emits the new string[]."], "files": [], "comments": [], "links": []}, {"id": "placeholder-feedback", "title": "Placeholder hides", "kind": "feedback", "x": 210, "y": -30, "summary": "With ≥1 tag the draft input placeholder is now blank instead of the stray \"Empty\" text.", "details": ["Before the fix, \"Empty\" showed next to existing chips, which looked wrong (see issue #71).", "Empty tag properties still show \"Empty\" so the field remains discoverable."], "files": [{"path": "packages/ui/src/editor/FilePropertiesPanel.tsx:602", "url": "https://github.com/bholmesdev/hubble.md/pull/72/files#diff-63010c442cf709e64bb649bc9d037732b2d4b1e7a23328fef89ba1be8b8f087cR602", "note": "Placeholder now depends on values.length."}], "comments": [], "links": [{"label": "Bug screenshot (issue #71)", "url": "assets/issue-71-bug.png"}, {"label": "Issue #71", "url": "https://github.com/bholmesdev/hubble.md/issues/71"}]}], "edges": [{"source": "open-props", "target": "add-tag", "label": "user adds tag"}, {"source": "add-tag", "target": "placeholder-feedback", "label": "input re-renders"}], "tour": [{"nodeId": "open-props", "title": "Where the user starts", "body": "The user is in a note's properties panel; an empty tags field correctly invites input with \"Empty\"."}, {"nodeId": "add-tag", "title": "The triggering action", "body": "Adding the first tag is the moment the old behavior looked wrong."}, {"nodeId": "placeholder-feedback", "title": "What changed for the user", "body": "After the fix, the stray \"Empty\" disappears once a tag exists. Open the screenshot to see the original bug."}]}]}</script>
<script>
(() => {
const D3_CDN_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js';
let attemptedLoad = false;
let activeGraphId = null;
let selectedNodeId = null;
let tourIndex = 0;
const REQUIRED_GRAPH_IDS = ['system-overview', 'data-flow', 'code-dependency', 'user-action'];
const DEFAULT_NODE_WIDTH = 220;
const DEFAULT_NODE_HEIGHT = 116;
const OVERVIEW_NODE_WIDTH = 360;
const OVERVIEW_NODE_HEIGHT = 220;
let zoomBehavior = null;
let svgSelection = null;
let viewportSelection = null;
let currentData = null;
function setError(error) {
console.warn('D3 canvas render unavailable.', error || 'unknown error');
document.body.classList.add('d3-canvas-error');
const errorNode = document.querySelector('.d3-canvas-error');
if (errorNode) errorNode.textContent = `D3 canvas failed to render: ${error?.message || error || 'unknown error'}`;
}
function readInlineData() {
if (window.PR_WALKTHROUGH_D3_DATA) return window.PR_WALKTHROUGH_D3_DATA;
const script = document.getElementById('pr-walkthrough-data');
if (!script) throw new Error('Missing pr-walkthrough-data script tag');
return JSON.parse(script.textContent || '{}');
}
function activeGraph() {
return (currentData.graphs || []).find((graph) => graph.id === activeGraphId) || (currentData.graphs || [])[0];
}
function nodeMap(graph) { return new Map((graph.nodes || []).map((node) => [node.id, node])); }
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
}
function listItems(items, render) {
if (!items || items.length === 0) return '<p class="d3-empty">None attached.</p>';
return `<ul class="d3-detail-list">${items.map(render).join('')}</ul>`;
}
function renderTour(graph) {
const step = (graph.tour || [])[tourIndex];
const label = document.querySelector('.d3-tour-step-label');
const title = document.querySelector('.d3-tour-title');
const body = document.querySelector('.d3-tour-body');
const card = document.querySelector('.d3-tour-card');
if (card) card.dataset.tourIndex = String(tourIndex);
if (label) label.textContent = `Step ${Math.min(tourIndex + 1, (graph.tour || []).length)} / ${(graph.tour || []).length || 0}`;
if (title) title.textContent = step?.title || graph.label || 'View tour';
if (body) body.textContent = step?.body || graph.summary || '';
}
function renderDetails(node, graph) {
const panel = document.getElementById('pr-walkthrough-details');
if (!panel) return;
const step = (graph.tour || [])[tourIndex];
if (!node) {
panel.innerHTML = '<p class="d3-panel-title">Selected point</p><p class="d3-empty">Select a node or use the tour controls.</p>';
return;
}
const details = Array.isArray(node.details) ? node.details : [];
const files = Array.isArray(node.files) ? node.files : [];
const comments = Array.isArray(node.comments) ? node.comments : [];
const links = Array.isArray(node.links) ? node.links : [];
panel.innerHTML = `
<span class="d3-detail-kind">${escapeHtml(graph.label)} · ${escapeHtml(node.kind || 'point of interest')}</span>
<h2 class="d3-detail-title">${escapeHtml(node.title)}</h2>
<p class="d3-detail-summary">${escapeHtml(node.summary || '')}</p>
${step?.nodeId === node.id ? `<section class="d3-detail-section"><h3>Tour context</h3><ul class="d3-detail-list"><li>${escapeHtml(step.body || '')}</li></ul></section>` : ''}
<section class="d3-detail-section"><h3>Explanation</h3>${details.length ? `<ul class="d3-detail-list">${details.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : '<p class="d3-empty">No additional detail provided.</p>'}</section>
<section class="d3-detail-section"><h3>Changed files</h3>${listItems(files, (file) => `<li><a class="d3-file-link" href="${escapeHtml(file.url || '#')}" target="_blank" rel="noreferrer">${escapeHtml(file.path || file.label || 'file')}</a>${file.note ? `<p>${escapeHtml(file.note)}</p>` : ''}</li>`)}</section>
<section class="d3-detail-section"><h3>Existing review discussion</h3>${listItems(comments, (comment) => `<li><span class="d3-comment-author">${escapeHtml(comment.author || 'reviewer')}</span>${escapeHtml(comment.body || '')}${comment.url ? `<br><a href="${escapeHtml(comment.url)}" target="_blank" rel="noreferrer">Open comment</a>` : ''}</li>`)}</section>
<section class="d3-detail-section"><h3>Links</h3>${listItems(links, (link) => `<li><a href="${escapeHtml(link.url || '#')}" target="_blank" rel="noreferrer">${escapeHtml(link.label || link.url || 'link')}</a></li>`)}</section>
`;
}
function wrapText(selection, width, maxLines = 3) {
selection.each(function wrapEach() {
const text = window.d3.select(this);
const datum = text.datum();
const resolvedWidth = typeof width === 'function' ? Number(width(datum)) : Number(width);
const resolvedMaxLines = typeof maxLines === 'function' ? Number(maxLines(datum)) : Number(maxLines);
const words = text.text().split(new RegExp('\\s+')).filter(Boolean);
const lineHeight = 15;
const y = Number(text.attr('y') || 0);
text.text('');
let line = [];
let lineNumber = 0;
let tspan = text.append('tspan').attr('x', text.attr('x')).attr('y', y);
for (const word of words) {
line.push(word);
tspan.text(line.join(' '));
if (tspan.node().getComputedTextLength() > resolvedWidth && line.length > 1) {
line.pop();
tspan.text(line.join(' '));
line = [word];
lineNumber += 1;
if (lineNumber >= resolvedMaxLines) { tspan.text(`${tspan.text()}…`); break; }
tspan = text.append('tspan').attr('x', text.attr('x')).attr('y', y + lineNumber * lineHeight).text(word);
}
}
});
}
function nodeWidth(node, graph) {
return Number(node.width || (graph?.id === 'system-overview' ? OVERVIEW_NODE_WIDTH : DEFAULT_NODE_WIDTH));
}
function nodeHeight(node, graph) {
return Number(node.height || (graph?.id === 'system-overview' ? OVERVIEW_NODE_HEIGHT : DEFAULT_NODE_HEIGHT));
}
function nodeBoundaryPoint(from, to, padding, graph) {
const dx = to.x - from.x;
const dy = to.y - from.y;
if (dx === 0 && dy === 0) return { x: from.x, y: from.y };
const scale = 1 / Math.max(Math.abs(dx) / (nodeWidth(from, graph) / 2 + padding), Math.abs(dy) / (nodeHeight(from, graph) / 2 + padding));
return { x: from.x + dx * scale, y: from.y + dy * scale };
}
function pathForEdge(edge, nodes, graph) {
const source = nodes.get(edge.source);
const target = nodes.get(edge.target);
if (!source || !target) return '';
const start = nodeBoundaryPoint(source, target, 10, graph);
const end = nodeBoundaryPoint(target, source, 18, graph);
const dx = end.x - start.x;
const control = Math.max(80, Math.abs(dx) * 0.42);
return `M ${start.x} ${start.y} C ${start.x + control} ${start.y}, ${end.x - control} ${end.y}, ${end.x} ${end.y}`;
}
function applyFilters(graph) {
const query = (document.querySelector('.d3-search')?.value || '').trim().toLowerCase();
const tourNodeId = (graph.tour || [])[tourIndex]?.nodeId;
const matches = (node) => {
if (!query) return true;
const haystack = [node.title, node.kind, node.summary, ...(node.details || []), ...(node.files || []).map((file) => file.path || file.label || ''), ...(node.comments || []).map((comment) => `${comment.author || ''} ${comment.body || ''}`)].join(' ').toLowerCase();
return haystack.includes(query);
};
const visible = new Set((graph.nodes || []).filter(matches).map((node) => node.id));
window.d3.selectAll('.d3-node')
.classed('is-dimmed', (node) => !visible.has(node.id))
.classed('is-selected', (node) => node.id === selectedNodeId)
.classed('is-tour-node', (node) => node.id === tourNodeId);
window.d3.selectAll('.d3-edge').classed('is-dimmed', (edge) => !visible.has(edge.source) || !visible.has(edge.target));
}
function fitToView() {
if (!svgSelection || !viewportSelection || !zoomBehavior) return;
const svg = svgSelection.node();
const bounds = viewportSelection.node().getBBox();
const fullWidth = svg.clientWidth || 1000;
const fullHeight = svg.clientHeight || 700;
const width = Math.max(bounds.width, 1);
const height = Math.max(bounds.height, 1);
const scale = Math.min(1.25, 0.86 / Math.max(width / fullWidth, height / fullHeight));
const translate = [fullWidth / 2 - scale * (bounds.x + width / 2), fullHeight / 2 - scale * (bounds.y + height / 2)];
svgSelection.transition().duration(300).call(zoomBehavior.transform, window.d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}
function focusNode(node) {
if (!node || !svgSelection || !zoomBehavior) return;
const svg = svgSelection.node();
const scale = Math.max(0.85, window.d3.zoomTransform(svg).k || 1);
const translate = [(svg.clientWidth || 1000) / 2 - node.x * scale, (svg.clientHeight || 700) / 2 - node.y * scale];
svgSelection.transition().duration(260).call(zoomBehavior.transform, window.d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale));
}
function resetZoom() { if (svgSelection && zoomBehavior) svgSelection.transition().duration(220).call(zoomBehavior.transform, window.d3.zoomIdentity); }
function zoomBy(factor) { if (svgSelection && zoomBehavior) svgSelection.transition().duration(140).call(zoomBehavior.scaleBy, factor); }
function selectTourStep(index, options = {}) {
const graph = activeGraph();
const tour = graph.tour || [];
if (!tour.length) return;
tourIndex = Math.max(0, Math.min(index, tour.length - 1));
const nodes = nodeMap(graph);
const node = nodes.get(tour[tourIndex].nodeId) || (graph.nodes || [])[0];
selectedNodeId = node?.id || null;
renderTour(graph);
renderDetails(node, graph);
applyFilters(graph);
if (!options.noFocus) focusNode(node);
}
function nextTourStep() { selectTourStep(tourIndex + 1); }
function previousTourStep() { selectTourStep(tourIndex - 1); }
function restartTour() { selectTourStep(0); }
function renderActiveGraph(options = {}) {
const graph = activeGraph();
if (!graph) throw new Error('No active graph');
const svg = window.d3.select('#pr-walkthrough-canvas');
if (svg.empty()) throw new Error('Missing #pr-walkthrough-canvas');
svg.selectAll('*').remove();
svgSelection = svg;
const nodesById = nodeMap(graph);
const defs = svg.append('defs');
defs.append('marker').attr('id', `d3-arrowhead-${graph.id}`).attr('class', 'd3-edge-arrow').attr('viewBox', '0 -6 12 12').attr('refX', 11).attr('refY', 0).attr('markerWidth', 9).attr('markerHeight', 9).attr('orient', 'auto').attr('markerUnits', 'strokeWidth').style('--edge-color', graph.color || '#868584').append('path').attr('d', 'M0,-6L12,0L0,6Z');
const root = svg.append('g').attr('class', 'd3-zoom-root');
viewportSelection = root.append('g').attr('class', 'd3-viewport');
viewportSelection.append('text').attr('class', 'd3-graph-title').attr('x', -420).attr('y', -300).attr('fill', graph.color || '#a43787').text(graph.label || graph.id);
const edgeLayer = viewportSelection.append('g').attr('class', 'd3-edges');
const nodeLayer = viewportSelection.append('g').attr('class', 'd3-nodes');
const edges = edgeLayer.selectAll('.d3-edge').data(graph.edges || []).join('g').attr('class', 'd3-edge').attr('data-edge-id', (edge, index) => edge.id || `${edge.source}-${edge.target}-${index}`).style('--edge-color', graph.color || '#868584');
edges.append('path').attr('d', (edge) => pathForEdge(edge, nodesById, graph)).attr('marker-end', `url(#d3-arrowhead-${graph.id})`);
edges.append('text').append('textPath').attr('href', function href(_, index) { const path = window.d3.select(edges.nodes()[index]).select('path'); const id = `d3-edge-path-${graph.id}-${index}`; path.attr('id', id); return `#${id}`; }).attr('startOffset', '50%').attr('text-anchor', 'middle').text((edge) => edge.label || '');
const nodes = nodeLayer.selectAll('.d3-node').data(graph.nodes || []).join('g').attr('class', (node) => `d3-node${graph.id === 'system-overview' ? ' is-overview-card' : ''}`).attr('data-node-id', (node) => node.id).attr('tabindex', 0).attr('role', 'button').attr('aria-label', (node) => node.title).attr('transform', (node) => `translate(${node.x || 0}, ${node.y || 0})`).style('--node-color', graph.color || '#a43787').on('click keydown', (event, node) => {
if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return;
selectedNodeId = node.id;
const tourPosition = (graph.tour || []).findIndex((step) => step.nodeId === node.id);
if (tourPosition >= 0) tourIndex = tourPosition;
renderTour(graph);
renderDetails(node, graph);
applyFilters(graph);
});
nodes.append('rect').attr('x', (node) => -nodeWidth(node, graph) / 2).attr('y', (node) => -nodeHeight(node, graph) / 2).attr('width', (node) => nodeWidth(node, graph)).attr('height', (node) => nodeHeight(node, graph)).attr('rx', 12);
nodes.append('text').attr('class', 'd3-node-kind').attr('x', (node) => -nodeWidth(node, graph) / 2 + 20).attr('y', (node) => -nodeHeight(node, graph) / 2 + 27).text((node) => node.kind || 'point');
nodes.append('text').attr('class', 'd3-node-title').attr('x', (node) => -nodeWidth(node, graph) / 2 + 20).attr('y', (node) => -nodeHeight(node, graph) / 2 + 53).text((node) => node.title || node.id).call(wrapText, (node) => nodeWidth(node, graph) - 40, 2);
nodes.append('text').attr('class', 'd3-node-summary').attr('x', (node) => -nodeWidth(node, graph) / 2 + 20).attr('y', (node) => -nodeHeight(node, graph) / 2 + 96).text((node) => node.summary || '').call(wrapText, (node) => nodeWidth(node, graph) - 40, (node) => Number(node.summaryLines || (graph.id === 'system-overview' ? 7 : 2)));
zoomBehavior = window.d3.zoom().scaleExtent([0.18, 3.5]).on('zoom', (event) => root.attr('transform', event.transform));
svg.call(zoomBehavior);
document.querySelectorAll('.d3-graph-toggle').forEach((button) => button.setAttribute('aria-pressed', button.dataset.graphId === graph.id ? 'true' : 'false'));
selectTourStep(Math.min(tourIndex, Math.max((graph.tour || []).length - 1, 0)), { noFocus: true });
if (!options.skipFit) window.setTimeout(fitToView, 40);
}
function switchGraph(graphId) {
activeGraphId = graphId;
selectedNodeId = null;
tourIndex = 0;
const search = document.querySelector('.d3-search');
if (search) search.value = '';
renderActiveGraph();
}
function setupControls() {
document.querySelector('[data-d3-action="fit"]')?.addEventListener('click', fitToView);
document.querySelector('[data-d3-action="reset"]')?.addEventListener('click', resetZoom);
document.querySelector('[data-d3-action="tour-prev"]')?.addEventListener('click', previousTourStep);
document.querySelector('[data-d3-action="tour-next"]')?.addEventListener('click', nextTourStep);
document.querySelector('[data-d3-action="tour-restart"]')?.addEventListener('click', restartTour);
document.querySelectorAll('.d3-graph-toggle').forEach((button) => button.addEventListener('click', () => switchGraph(button.dataset.graphId)));
document.querySelector('.d3-search')?.addEventListener('input', () => applyFilters(activeGraph()));
document.addEventListener('keydown', (event) => {
if (event.target?.matches?.('input, textarea')) { if (event.key === 'Escape') event.target.blur(); else return; }
if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'n') nextTourStep();
else if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'p') previousTourStep();
else if (event.key === '1') switchGraph('system-overview');
else if (event.key === '2') switchGraph('data-flow');
else if (event.key === '3') switchGraph('code-dependency');
else if (event.key === '4') switchGraph('user-action');
else if (event.key === '+' || event.key === '=') zoomBy(1.2);
else if (event.key === '-') zoomBy(0.82);
else if (event.key === '0') resetZoom();
else if (event.key.toLowerCase() === 'f') fitToView();
else if (event.key === '/') { event.preventDefault(); document.querySelector('.d3-search')?.focus(); }
else if (event.key === 'Escape') { selectedNodeId = null; const search = document.querySelector('.d3-search'); if (search) search.value = ''; renderDetails(null, activeGraph()); applyFilters(activeGraph()); }
});
}
function renderD3Canvas() {
if (!window.d3) { setError('D3 library was not loaded'); return; }
currentData = readInlineData();
const graphIds = new Set((currentData.graphs || []).map((graph) => graph.id));
const missing = REQUIRED_GRAPH_IDS.filter((id) => !graphIds.has(id));
if (missing.length) throw new Error(`Missing required graphs: ${missing.join(', ')}`);
activeGraphId = activeGraphId || (currentData.graphs || [])[0]?.id;
setupControls();
renderActiveGraph();
document.body.classList.add('d3-canvas-ready');
document.body.classList.remove('d3-canvas-error');
}
function loadD3Runtime() {
if (attemptedLoad) return;
attemptedLoad = true;
if (window.d3) { try { renderD3Canvas(); } catch (error) { setError(error); } return; }
const script = document.createElement('script');
script.src = D3_CDN_URL;
script.async = true;
script.onload = () => { try { renderD3Canvas(); } catch (error) { setError(error); } };
script.onerror = () => setError(`Failed to load pinned D3 CDN script: ${D3_CDN_URL}`);
document.head.appendChild(script);
}
window.prWalkthroughD3Render = renderD3Canvas;
window.prWalkthroughD3SwitchGraph = switchGraph;
window.prWalkthroughD3NextTourStep = nextTourStep;
window.prWalkthroughD3PreviousTourStep = previousTourStep;
window.prWalkthroughD3FitToView = fitToView;
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadD3Runtime, { once: true });
else loadD3Runtime();
})();
</script>
</main>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment