diff --git a/.gitignore b/.gitignore index 5ee4ab9..618ef1d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,12 @@ evaluate/test_repos/ # Draft/duplicate files docs/assets/marketing-diagram* + +# Audit/design working documents +accessibility-audit.md +design-critique.md +design-handoff.md +design-system-audit.md +cross-audit-synthesis.md +research-synthesis.md +Quality-Audit-Report.docx diff --git a/code-review-graph-vscode/src/views/graphWebview.ts b/code-review-graph-vscode/src/views/graphWebview.ts index 5e67b36..039d102 100644 --- a/code-review-graph-vscode/src/views/graphWebview.ts +++ b/code-review-graph-vscode/src/views/graphWebview.ts @@ -409,25 +409,48 @@ export class GraphWebviewPanel { cursor: pointer; user-select: none; border: 1px solid transparent; - opacity: 0.45; + opacity: 0.55; transition: opacity 0.15s, border-color 0.15s; } .edge-pill.active { opacity: 1; border-color: currentColor; } + .edge-pill:focus-visible { outline: 2px solid var(--btn-bg, #89b4fa); outline-offset: 2px; } .edge-pill .pill-dot { width: 8px; height: 8px; border-radius: 50%; } + /* Edge filter popover */ + #edge-popover { + display: none; + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: var(--toolbar-bg); + border: 1px solid var(--toolbar-border); + border-radius: 6px; + padding: 8px 12px; + z-index: 100; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + min-width: 160px; + } + #edge-popover.visible { display: flex; flex-wrap: wrap; gap: 6px; } + #edge-filter-wrap { position: relative; } + /* Depth slider */ #depth-slider { width: 80px; accent-color: var(--btn-bg); cursor: pointer; } + #depth-slider:disabled { + opacity: 0.4; + cursor: not-allowed; + } #depth-value { font-size: 11px; font-family: var(--font-mono); @@ -455,11 +478,14 @@ export class GraphWebviewPanel { /* Node count badge */ #node-count { + position: absolute; + bottom: 8px; + right: 12px; font-size: 11px; color: var(--fg); opacity: 0.6; - margin-left: auto; white-space: nowrap; + z-index: 5; } /* ------------------------------------------------------------------ */ @@ -516,12 +542,12 @@ export class GraphWebviewPanel { #tooltip .tooltip-params { font-family: var(--font-mono); font-size: 11px; - color: #a6e3a1; + color: var(--vscode-debugTokenExpression-string, #a6e3a1); } #tooltip .tooltip-return { font-family: var(--font-mono); font-size: 11px; - color: #89b4fa; + color: var(--vscode-debugTokenExpression-number, #89b4fa); } /* ------------------------------------------------------------------ */ @@ -534,6 +560,9 @@ export class GraphWebviewPanel { .pulse-ring { animation: pulse 0.6s ease-out; } + + path.node-shape:focus { outline: none; } + path.node-shape:focus-visible { stroke: var(--btn-bg, #89b4fa) !important; stroke-width: 3 !important; } @@ -541,21 +570,23 @@ export class GraphWebviewPanel {
- +
- -
- Edges - Calls - Imports - Inherits - Implements - Tested - Contains - Depends + +
+ +
+ Calls + Imports + Inherits + Implements + Tested + Contains + Depends +
@@ -563,7 +594,7 @@ export class GraphWebviewPanel {
Depth - + All
@@ -576,16 +607,20 @@ export class GraphWebviewPanel {
- - -
-
+
+ + + +
-
+ diff --git a/code-review-graph-vscode/src/webview/graph.ts b/code-review-graph-vscode/src/webview/graph.ts index 0e788eb..6d214b0 100644 --- a/code-review-graph-vscode/src/webview/graph.ts +++ b/code-review-graph-vscode/src/webview/graph.ts @@ -70,28 +70,44 @@ interface SimLink extends d3.SimulationLinkDatum { // --------------------------------------------------------------------------- const NODE_RADIUS: Record = { - File: 14, + File: 18, Class: 12, - Function: 10, - Test: 10, - Type: 10, + Function: 6, + Test: 6, + Type: 5, }; const NODE_COLOR: Record = { - File: "#cba6f7", - Class: "#f9e2af", - Function: "#a6e3a1", - Test: "#89b4fa", - Type: "#fab387", + File: "#58a6ff", + Class: "#f0883e", + Function: "#3fb950", + Test: "#d2a8ff", + Type: "#8b949e", +}; + +const NODE_SHAPE: Record = { + File: d3.symbolCircle, + Class: d3.symbolSquare, + Function: d3.symbolTriangle, + Test: d3.symbolDiamond, + Type: d3.symbolCross, +}; + +const NODE_AREA: Record = { + File: 616, + Class: 452, + Function: 314, + Test: 314, + Type: 314, }; const EDGE_COLOR: Record = { - CALLS: "#a6e3a1", - IMPORTS_FROM: "#89b4fa", - INHERITS: "#cba6f7", + CALLS: "#3fb950", + IMPORTS_FROM: "#f0883e", + INHERITS: "#d2a8ff", IMPLEMENTS: "#f9e2af", TESTED_BY: "#f38ba8", - CONTAINS: "#585b70", + CONTAINS: "rgba(139,148,158,0.15)", DEPENDS_ON: "#fab387", }; @@ -128,7 +144,7 @@ let labelGroup: d3.Selection; let zoomBehavior: d3.ZoomBehavior; let linkSelection: d3.Selection; -let nodeSelection: d3.Selection; +let nodeSelection: d3.Selection; let labelSelection: d3.Selection; let currentTheme: "dark" | "light" = "dark"; @@ -185,7 +201,7 @@ function createSvg(): void { // Initialize empty selections linkSelection = linkGroup.selectAll("line"); - nodeSelection = nodeGroup.selectAll("circle"); + nodeSelection = nodeGroup.selectAll("path.node-shape"); labelSelection = labelGroup.selectAll("text"); // Zoom + pan @@ -243,7 +259,26 @@ function setData(nodes: GraphNode[], edges: GraphEdge[]): void { depthValue.textContent = "All"; } + // Show/hide empty state + const emptyState = document.getElementById("empty-state"); + const graphArea = document.getElementById("graph-area"); + if (nodes.length === 0) { + if (emptyState) emptyState.style.display = "block"; + if (graphArea) { + // Hide the SVG but keep the container + const svgHide = graphArea.querySelector("svg"); + if (svgHide) svgHide.style.display = "none"; + } + updateDepthSliderState(); + return; + } + if (emptyState) emptyState.style.display = "none"; + const svgEl = graphArea?.querySelector("svg"); + if (svgEl) svgEl.style.display = ""; + buildGraph(); + + updateDepthSliderState(); } // --------------------------------------------------------------------------- @@ -370,10 +405,11 @@ function buildGraph(): void { // --- Nodes --- nodeSelection = nodeGroup - .selectAll("circle") + .selectAll("path.node-shape") .data(nodes, (d) => d.qualifiedName) - .join("circle") - .attr("r", (d) => NODE_RADIUS[d.kind] ?? 10) + .join("path") + .attr("class", "node-shape") + .attr("d", (d) => d3.symbol().type(NODE_SHAPE[d.kind] ?? d3.symbolCircle).size(NODE_AREA[d.kind] ?? 314)()!) .attr("fill", (d) => NODE_COLOR[d.kind] ?? "#cdd6f4") .attr("stroke", "none") .attr("stroke-width", 2) @@ -408,7 +444,7 @@ function buildGraph(): void { }) .call( d3 - .drag() + .drag() .on("start", (event, d) => { if (!event.active) simulation?.alphaTarget(0.3).restart(); d.fx = d.x; @@ -423,7 +459,61 @@ function buildGraph(): void { d.fx = null; d.fy = null; }) - ); + ) + .attr("tabindex", 0) + .attr("role", "button") + .attr("aria-label", (d) => `${d.kind}: ${d.name}`) + .on("keydown", (event: KeyboardEvent, d: SimNode) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectNode(d); + vscodeApi.postMessage({ + command: "nodeClicked", + qualifiedName: d.qualifiedName, + filePath: d.filePath, + lineStart: d.lineStart ?? 1, + }); + } else if (event.key === "Escape") { + event.preventDefault(); + selectedNode = null; + unhighlightAll(); + nodeSelection.attr("stroke", "none"); + } else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) { + event.preventDefault(); + const visibleNodes = nodeSelection.data(); + let best: SimNode | null = null; + let bestDist = Infinity; + for (const n of visibleNodes) { + if (n.qualifiedName === d.qualifiedName || n.x == null || n.y == null || d.x == null || d.y == null) continue; + const dx = n.x - d.x; + const dy = n.y - d.y; + const dist = Math.sqrt(dx * dx + dy * dy); + let ok = false; + if (event.key === "ArrowRight" && dx > 0 && Math.abs(dy) < Math.abs(dx)) ok = true; + if (event.key === "ArrowLeft" && dx < 0 && Math.abs(dy) < Math.abs(dx)) ok = true; + if (event.key === "ArrowDown" && dy > 0 && Math.abs(dx) < Math.abs(dy)) ok = true; + if (event.key === "ArrowUp" && dy < 0 && Math.abs(dx) < Math.abs(dy)) ok = true; + if (ok && dist < bestDist) { + best = n; + bestDist = dist; + } + } + if (best) { + const target = nodeGroup.selectAll("path.node-shape") + .filter((n) => n.qualifiedName === best!.qualifiedName) + .node(); + if (target) (target as HTMLElement).focus(); + } + } + }) + .on("focus", (_event: FocusEvent, d: SimNode) => { + showTooltip(d); + highlightConnected(d); + }) + .on("blur", () => { + hideTooltip(); + unhighlightAll(); + }); // Highlight search matches const searchInput = document.getElementById("search-input") as HTMLInputElement | null; @@ -433,19 +523,19 @@ function buildGraph(): void { const matches = d.name.toLowerCase().includes(query) || d.qualifiedName.toLowerCase().includes(query); - return matches ? "#f5e0dc" : "none"; + return matches ? "#e6edf3" : "none"; }); } // Highlight selected node if (selectedNode) { nodeSelection.attr("stroke", (d) => { - if (d.qualifiedName === selectedNode!.qualifiedName) return "#f5e0dc"; + if (d.qualifiedName === selectedNode!.qualifiedName) return "#e6edf3"; if (query.length > 0) { const matches = d.name.toLowerCase().includes(query) || d.qualifiedName.toLowerCase().includes(query); - return matches ? "#f5e0dc" : "none"; + return matches ? "#e6edf3" : "none"; } return "none"; }); @@ -487,7 +577,7 @@ function buildGraph(): void { .attr("x2", (d) => (d.target as SimNode).x!) .attr("y2", (d) => (d.target as SimNode).y!); - nodeSelection.attr("cx", (d) => d.x!).attr("cy", (d) => d.y!); + nodeSelection.attr("transform", (d) => `translate(${d.x},${d.y})`); labelSelection.attr("x", (d) => d.x!).attr("y", (d) => d.y!); }); @@ -506,8 +596,22 @@ function buildGraph(): void { function selectNode(node: SimNode): void { selectedNode = node; nodeSelection.attr("stroke", (d) => - d.qualifiedName === node.qualifiedName ? "#f5e0dc" : "none" + d.qualifiedName === node.qualifiedName ? "#e6edf3" : "none" ); + updateDepthSliderState(); +} + +function updateDepthSliderState(): void { + const slider = document.getElementById("depth-slider") as HTMLInputElement | null; + const depthValue = document.getElementById("depth-value"); + if (slider) { + if (selectedNode) { + slider.disabled = false; + } else { + slider.disabled = true; + if (depthValue) depthValue.textContent = "N/A"; + } + } } function highlightConnected(node: SimNode): void { @@ -613,7 +717,7 @@ function highlightNodeByName(qualifiedName: string): void { .attr("cy", node.y ?? 0) .attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4) .attr("fill", "none") - .attr("stroke", "#f5e0dc") + .attr("stroke", "#e6edf3") .attr("stroke-width", 3) .attr("class", "pulse-ring"); @@ -636,7 +740,7 @@ function highlightNodeByName(qualifiedName: string): void { .attr("cy", node.y ?? 0) .attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4) .attr("fill", "none") - .attr("stroke", "#f5e0dc") + .attr("stroke", "#e6edf3") .attr("stroke-width", 3); ring2 @@ -734,19 +838,40 @@ function bindToolbarEvents(): void { for (const kind of ALL_EDGE_KINDS) { const pill = document.getElementById(`edge-${kind}`); if (pill) { - pill.addEventListener("click", () => { + const toggle = () => { if (visibleEdgeKinds.has(kind)) { visibleEdgeKinds.delete(kind); pill.classList.remove("active"); + pill.setAttribute("aria-pressed", "false"); } else { visibleEdgeKinds.add(kind); pill.classList.add("active"); + pill.setAttribute("aria-pressed", "true"); } buildGraph(); + }; + pill.addEventListener("click", toggle); + pill.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); } }); } } + // Edge filter popover toggle + const edgeFilterBtn = document.getElementById("btn-edge-filter"); + const edgePopover = document.getElementById("edge-popover"); + if (edgeFilterBtn && edgePopover) { + edgeFilterBtn.addEventListener("click", (e) => { + e.stopPropagation(); + edgePopover.classList.toggle("visible"); + }); + document.addEventListener("click", (e) => { + if (!edgePopover.contains(e.target as Node) && e.target !== edgeFilterBtn) { + edgePopover.classList.remove("visible"); + } + }); + } + // Depth slider const depthSlider = document.getElementById("depth-slider") as HTMLInputElement | null; if (depthSlider) { diff --git a/code_review_graph/visualization.py b/code_review_graph/visualization.py index 95f592b..289b4d6 100644 --- a/code_review_graph/visualization.py +++ b/code_review_graph/visualization.py @@ -208,24 +208,24 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path: } #legend h3 { font-size: 11px; font-weight: 700; margin-bottom: 6px; - color: #8b949e; text-transform: uppercase; letter-spacing: 1px; + color: #9eaab6; text-transform: uppercase; letter-spacing: 1px; } .legend-section { margin-bottom: 10px; } .legend-section:last-child { margin-bottom: 0; } .legend-item { display: flex; align-items: center; gap: 10px; padding: 2px 0; cursor: default; } .legend-item[data-edge-kind] { cursor: pointer; user-select: none; } .legend-item[data-edge-kind].dimmed { opacity: 0.3; } - .legend-circle { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } + .legend-item svg { flex-shrink: 0; } .legend-line { width: 24px; height: 0; flex-shrink: 0; border-top-width: 2px; } .l-calls { border-top: 2px solid #3fb950; } .l-imports { border-top: 2px dashed #f0883e; } .l-inherits { border-top: 2.5px dotted #d2a8ff; } - .l-contains { border-top: 1.5px solid rgba(139,148,158,0.3); } + .l-contains { border-top: 1px solid rgba(139,148,158,0.3); } #stats-bar { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(13,17,23,0.95); border-top: 1px solid #21262d; padding: 8px 24px; display: flex; gap: 32px; justify-content: center; - font-size: 12px; color: #8b949e; backdrop-filter: blur(12px); + font-size: 12px; color: #9eaab6; backdrop-filter: blur(12px); } .stat-item { display: flex; gap: 6px; align-items: center; } .stat-value { color: #e6edf3; font-weight: 600; } @@ -247,7 +247,7 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path: text-transform: uppercase; letter-spacing: 0.5px; } .tt-row { margin-top: 4px; } - .tt-label { color: #8b949e; } + .tt-label { color: #9eaab6; } .tt-file { color: #58a6ff; font-size: 11px; } #controls { position: absolute; top: 16px; right: 16px; @@ -271,7 +271,7 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path: outline: none; backdrop-filter: blur(12px); } #search:focus { border-color: #58a6ff; } - #search::placeholder { color: #484f58; } + #search::placeholder { color: #6e7681; } #search-results { position: absolute; top: 52px; right: 16px; background: rgba(22,27,34,0.97); border: 1px solid #30363d; @@ -287,28 +287,31 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path: .sr-item:last-child { border-bottom: none; } .sr-kind { font-size: 9px; padding: 2px 6px; border-radius: 8px; text-transform: uppercase; font-weight: 700; } #detail-panel { - position: absolute; top: 16px; right: 16px; + position: absolute; top: 16px; left: 16px; width: 320px; max-height: calc(100vh - 80px); background: rgba(22,27,34,0.97); border: 1px solid #30363d; border-radius: 10px; padding: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); - backdrop-filter: blur(12px); z-index: 20; + backdrop-filter: blur(12px); z-index: 15; overflow-y: auto; display: none; font-size: 12px; } #detail-panel.visible { display: block; } #detail-panel h2 { font-size: 16px; color: #e6edf3; margin-bottom: 4px; word-break: break-all; } #detail-panel .dp-close { position: absolute; top: 12px; right: 14px; - cursor: pointer; color: #8b949e; font-size: 18px; line-height: 1; - border: none; background: none; + cursor: pointer; color: #8b949e; font-size: 14px; line-height: 1; + border: 1px solid #30363d; background: rgba(22,27,34,0.95); + border-radius: 6px; width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + transition: all 0.15s; } - #detail-panel .dp-close:hover { color: #e6edf3; } + #detail-panel .dp-close:hover { color: #e6edf3; border-color: #8b949e; background: #30363d; } .dp-section { margin-top: 14px; } - .dp-section h4 { color: #8b949e; font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; } + .dp-section h4 { color: #9eaab6; font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 6px; } .dp-list { list-style: none; } .dp-list li { padding: 3px 0; color: #c9d1d9; cursor: pointer; } .dp-list li:hover { color: #58a6ff; text-decoration: underline; } - .dp-meta { color: #8b949e; } + .dp-meta { color: #9eaab6; } .dp-meta span { color: #e6edf3; font-weight: 600; } #filter-panel { position: absolute; bottom: 50px; left: 16px; @@ -319,31 +322,99 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path: } #filter-panel h3 { font-size: 11px; font-weight: 700; margin-bottom: 8px; - color: #8b949e; text-transform: uppercase; letter-spacing: 1px; + color: #9eaab6; text-transform: uppercase; letter-spacing: 1px; } .filter-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; user-select: none; } .filter-item input { accent-color: #58a6ff; cursor: pointer; } + :focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; } + .filter-item input:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; } + .dp-close:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; } + .filter-item:focus-within { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; } + #help-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + display: flex; align-items: center; justify-content: center; z-index: 100; + backdrop-filter: blur(4px); + } + #help-overlay.hidden { display: none; } + .help-content { + position: relative; background: #161b22; border: 1px solid #30363d; + border-radius: 12px; padding: 28px 32px; max-width: 420px; width: 90%; + box-shadow: 0 16px 48px rgba(0,0,0,0.5); + } + .help-content h2 { font-size: 16px; color: #e6edf3; margin-bottom: 16px; } + .help-content .help-close { position: absolute; top: 12px; right: 14px; } + .help-content table { width: 100%; border-collapse: collapse; } + .help-content td { padding: 6px 8px; color: #c9d1d9; font-size: 13px; border-bottom: 1px solid #21262d; } + .help-content td:first-child { white-space: nowrap; width: 1%; } + kbd { + display: inline-block; background: #21262d; border: 1px solid #30363d; + border-radius: 5px; padding: 2px 7px; font-size: 11px; font-family: inherit; + color: #e6edf3; box-shadow: inset 0 -1px 0 #0d1117; line-height: 1.6; + } + .help-dismiss { + margin-top: 16px; display: block; text-align: center; + color: #8b949e; font-size: 12px; cursor: pointer; + } + .help-dismiss:hover { color: #e6edf3; } + #loading-overlay { + position: fixed; inset: 0; display: flex; align-items: center; + justify-content: center; z-index: 50; background: rgba(13,17,23,0.85); + backdrop-filter: blur(6px); transition: opacity 0.4s ease; + } + #loading-overlay.hidden { opacity: 0; pointer-events: none; } + .loading-spinner { + width: 36px; height: 36px; border: 3px solid #30363d; + border-top-color: #58a6ff; border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + .loading-text { color: #9eaab6; font-size: 13px; margin-top: 14px; text-align: center; } + #empty-state { + position: fixed; inset: 0; display: none; align-items: center; + justify-content: center; z-index: 50; flex-direction: column; gap: 12px; + } + #empty-state.visible { display: flex; } + .empty-icon { font-size: 48px; opacity: 0.4; } + .empty-title { color: #e6edf3; font-size: 18px; font-weight: 600; } + .empty-desc { color: #9eaab6; font-size: 13px; max-width: 320px; text-align: center; line-height: 1.6; } marker { overflow: visible; } + g.node-g:focus { outline: none; } + g.node-g:focus-visible .node-shape { stroke: #58a6ff !important; stroke-width: 3 !important; } + g.node-g:focus-visible .glow-ring { stroke: #58a6ff !important; opacity: 0.6 !important; } + button.legend-edge { background: none; border: none; color: #c9d1d9; font-size: 12px; font-family: inherit; } + button.legend-edge:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 4px; } + .sr-item.sr-active { background: #30363d; } + .skip-link { + position: absolute; top: -40px; left: 16px; z-index: 100; + background: #1f6feb; color: #fff; padding: 8px 16px; + border-radius: 0 0 8px 8px; text-decoration: none; font-weight: 600; + transition: top 0.2s; + } + .skip-link:focus { top: 0; } - +

Filter by Kind

@@ -353,23 +424,63 @@ def generate_html(store: GraphStore, output_path: str | Path) -> Path:
- + - + +
-
-
+
+
-
- + + +
+
+
+
Laying out graph…
+
+
+
+
🔍
+
No nodes to display
+
The graph is empty. Run code-review-graph build to index your codebase, then regenerate the visualization.
+
+ diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 9c7872d..45e7a8b 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -218,6 +218,18 @@ def test_export_includes_communities(store_with_data): assert isinstance(data["communities"], list) +def test_generate_html_includes_all_edge_types(store_with_data, tmp_path): + """Generated HTML should define colors and legend entries for all 7 edge types.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + for edge_kind in ["CALLS", "IMPORTS_FROM", "INHERITS", "CONTAINS", + "IMPLEMENTS", "TESTED_BY", "DEPENDS_ON"]: + assert edge_kind in content, f"Edge type {edge_kind} missing from HTML" + + def test_generate_html_includes_interactive_features(store_with_data, tmp_path): """Generated HTML should include new interactive features.""" from code_review_graph.visualization import generate_html @@ -235,3 +247,71 @@ def test_generate_html_includes_interactive_features(store_with_data, tmp_path): assert "filter-panel" in content # Search results dropdown assert "search-results" in content + # Accessibility: skip link + assert "skip-link" in content + # Accessibility: live region + assert 'aria-live="polite"' in content + # Node shapes mapping + assert "KIND_SHAPE" in content + + +def test_generate_html_includes_node_shapes(store_with_data, tmp_path): + """Generated HTML should use d3.symbol() for distinct node shapes.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + assert "d3.symbol()" in content or "symbolCircle" in content + assert "symbolSquare" in content + assert "symbolTriangle" in content + assert "symbolDiamond" in content + assert "symbolCross" in content + + +def test_generate_html_includes_help_overlay(store_with_data, tmp_path): + """Generated HTML should include a help overlay for onboarding.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + assert "help-overlay" in content + assert "btn-help" in content + assert "Click a file" in content + + +def test_generate_html_includes_aria_attributes(store_with_data, tmp_path): + """Generated HTML should include key ARIA attributes for accessibility.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + assert 'role="tooltip"' in content + assert 'role="dialog"' in content + assert 'role="listbox"' in content + assert 'aria-pressed="false"' in content # community button + assert 'aria-modal="false"' in content # detail panel + + +def test_generate_html_includes_loading_and_empty_state(store_with_data, tmp_path): + """Generated HTML should include loading overlay and empty state markup.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + assert "loading-overlay" in content + assert "empty-state" in content + assert "No nodes to display" in content + + +def test_generate_html_includes_focus_visible(store_with_data, tmp_path): + """Generated HTML should include :focus-visible styles.""" + from code_review_graph.visualization import generate_html + + output_path = tmp_path / "graph.html" + generate_html(store_with_data, output_path) + content = output_path.read_text() + assert ":focus-visible" in content