Created
June 17, 2026 21:43
-
-
Save bholmesdev/c2ac306376ea794aff3e6d1b77bfad46 to your computer and use it in GitHub Desktop.
PR walkthrough for bholmesdev/hubble.md#72
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 "Empty" 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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