From c276cbab296167661b84dced62dc4e6e61f2e6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Mon, 24 Aug 2026 20:33:38 -0300 Subject: [PATCH] feat(review-tutor): add the Structure graph Add a List | Graph switch inside the Structure view with a deterministic layered SVG of changed files and their evidence-backed connections, routed around intermediate nodes, reusing the list's evidence rows and Open in Diff for the selected node or edge, with an honest size bound and List as the default everywhere. --- packages/review-tutor/src/page-script.ts | 462 ++++++++++++++++++++--- packages/review-tutor/src/page-styles.ts | 34 ++ packages/review-tutor/src/page.ts | 2 +- packages/review-tutor/test/page.test.ts | 442 +++++++++++++++++++++- 4 files changed, 890 insertions(+), 50 deletions(-) diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index fcb5413..66111fa 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -73,7 +73,9 @@ export const pageScript = String.raw` structureInputId = null, structureStatus = "idle", structureRequestSequence = 0, - selectedConnection = null; + structureMode = readStructureMode(), + selectedConnection = null, + graphSelection = null; const learningBadgeGroups = new Map(); const foreignActiveIds = new Set(); let railCollapsed = sessionStorage.getItem("reviewTutorRailCollapsed") === "1"; @@ -379,6 +381,13 @@ export const pageScript = String.raw` function structureStatusLabel(status) { return status === "modified" ? "edited" : status; } + function readStructureMode() { + try { + return sessionStorage.getItem("reviewTutorStructureMode") === "graph" ? "graph" : "list"; + } catch { + return "list"; + } + } function structureKindLabel(kind) { return kind === "reexport" ? "re-export" : kind === "dynamic-import" ? "dynamic" : kind; } @@ -389,7 +398,12 @@ export const pageScript = String.raw` structureInputId = null; structureStatus = "idle"; selectedConnection = null; + graphSelection = null; + element("structure-mode-switch").hidden = true; + element("structure-shared").replaceChildren(); element("structure-content").replaceChildren(); + element("structure-graph").replaceChildren(); + element("structure-graph-evidence")?.remove(); } function clearStructureLanding() { document.querySelector(".diff-row.structure-landing")?.classList.remove("structure-landing"); @@ -437,10 +451,371 @@ export const pageScript = String.raw` row?.scrollIntoView({ block: "center" }); control?.focus({ preventScroll: true }); } + function createConnectionRow(edge, key, options = {}) { + const evidenceId = "connection-evidence-" + key; + const row = document.createElement("button"); + row.type = "button"; + row.className = "connection-row"; + row.dataset.status = edge.status; + row.setAttribute("aria-expanded", String(!!options.expanded)); + row.setAttribute("aria-controls", evidenceId); + row.append( + makeSpan("connection-kind", structureKindLabel(edge.kind)), + makeSpan("connection-target", edge.to), + ); + if (edge.typeOnly) row.append(makeSpan("connection-type", "type")); + row.append(makeSpan("connection-status status-chip status-" + edge.status, structureStatusLabel(edge.status))); + const evidenceList = document.createElement("ul"); + evidenceList.id = evidenceId; + evidenceList.className = "connection-evidence"; + evidenceList.hidden = !options.expanded; + edge.evidence.forEach((evidence, evidenceIndex) => { + const item = document.createElement("li"); + const code = document.createElement("span"); + code.className = "evidence-code"; + code.textContent = evidence.line + " " + evidence.text; + const open = document.createElement("button"); + open.type = "button"; + open.className = "ghost open-in-diff"; + open.textContent = "Open in Diff"; + open.addEventListener("click", () => jumpToEvidence(evidence, edge.status, evidenceIndex, edge.evidence)); + item.append(code, open); + evidenceList.append(item); + }); + row.addEventListener("click", () => { + const expanded = row.getAttribute("aria-expanded") === "true"; + if (options.listSelection) { + if (selectedConnection && selectedConnection !== row) { + selectedConnection.classList.remove("selected"); + selectedConnection.setAttribute("aria-expanded", "false"); + element(selectedConnection.getAttribute("aria-controls")).hidden = true; + } + selectedConnection = row; + row.classList.add("selected"); + } + row.setAttribute("aria-expanded", String(!expanded)); + evidenceList.hidden = expanded; + }); + return { row, evidenceList }; + } + function graphTextOrder(left, right) { + return left < right ? -1 : left > right ? 1 : 0; + } + function graphPathOrder(left, right) { + const leftSlash = left.lastIndexOf("/"); + const rightSlash = right.lastIndexOf("/"); + const directory = graphTextOrder(left.slice(0, Math.max(0, leftSlash)), right.slice(0, Math.max(0, rightSlash))); + return directory || graphTextOrder(left, right); + } + function graphLabel(path) { + return path.length <= 40 ? path : path.slice(0, 19) + "…" + path.slice(-20); + } + function graphNumber(value) { + return String(Math.round(value * 100) / 100); + } + function graphPath(points) { + return points.map((point, index) => (index ? "L" : "M") + graphNumber(point.x) + " " + graphNumber(point.y)).join(" "); + } + function layoutStructureGraph(snapshot) { + const endpoints = new Set(snapshot.edges.flatMap((edge) => [edge.from, edge.to])); + const filesByPath = new Map(snapshot.files.filter((file) => file.analyzed && endpoints.has(file.path)).map((file) => [file.path, file])); + const paths = [...filesByPath.keys()].sort(graphPathOrder); + const edges = snapshot.edges + .filter((edge) => filesByPath.has(edge.from) && filesByPath.has(edge.to)) + .map((edge, index) => ({ ...edge, graphIndex: index })) + .sort((left, right) => graphTextOrder(left.from, right.from) || graphTextOrder(left.to, right.to) || graphTextOrder(left.kind, right.kind) || left.graphIndex - right.graphIndex); + if (!paths.length) return { nodes: [], edges, width: 0, height: 0, coordinates: "" }; + const adjacency = new Map(paths.map((path) => [path, []])); + const retained = []; + function reaches(start, target) { + const pending = [start], seen = new Set(); + while (pending.length) { + const path = pending.pop(); + if (path === target) return true; + if (seen.has(path)) continue; + seen.add(path); + pending.push(...adjacency.get(path)); + } + return false; + } + // Layout edges are considered by path order; rejecting an edge that closes a cycle removes the back-edge whose from path is lexicographically later. + for (const edge of edges) { + if (reaches(edge.to, edge.from)) continue; + adjacency.get(edge.from).push(edge.to); + adjacency.get(edge.from).sort(graphTextOrder); + retained.push(edge); + } + const incoming = new Map(paths.map((path) => [path, 0])); + for (const edge of retained) incoming.set(edge.to, incoming.get(edge.to) + 1); + const layers = new Map(paths.map((path) => [path, 0])); + const pending = paths.filter((path) => incoming.get(path) === 0).sort(graphTextOrder); + while (pending.length) { + const path = pending.shift(); + for (const target of adjacency.get(path)) { + layers.set(target, Math.max(layers.get(target), layers.get(path) + 1)); + incoming.set(target, incoming.get(target) - 1); + if (incoming.get(target) === 0) { + pending.push(target); + pending.sort(graphTextOrder); + } + } + } + const NODE_HEIGHT = 28, COLUMN_GAP = 72, ROW_GAP = 12, CH_WIDTH = 7.2, HORIZONTAL_PADDING = 20, OUTER_PADDING = 12; + const layerPaths = []; + for (const path of paths) { + const layer = layers.get(path); + if (!layerPaths[layer]) layerPaths[layer] = []; + layerPaths[layer].push(path); + } + layerPaths.forEach((items) => items.sort(graphPathOrder)); + const positions = new Map(); + let x = OUTER_PADDING; + for (let layer = 0; layer < layerPaths.length; layer++) { + const items = layerPaths[layer] || []; + const widths = items.map((path) => Math.round((graphLabel(path).length * CH_WIDTH + HORIZONTAL_PADDING) * 10) / 10); + const columnWidth = Math.max(...widths); + items.forEach((path, row) => positions.set(path, { + path, + file: filesByPath.get(path), + label: graphLabel(path), + layer, + x, + y: OUTER_PADDING + row * (NODE_HEIGHT + ROW_GAP), + width: widths[row], + height: NODE_HEIGHT, + })); + x += columnWidth + COLUMN_GAP; + } + const nodes = [...positions.values()].sort((left, right) => left.layer - right.layer || graphPathOrder(left.path, right.path)); + const drawingBottom = Math.max(...nodes.map((node) => node.y + node.height)) + OUTER_PADDING; + const layerBounds = new Map(); + for (const node of nodes) { + const bounds = layerBounds.get(node.layer) || { x: node.x, right: node.x + node.width, bottom: 0 }; + bounds.x = Math.min(bounds.x, node.x); + bounds.right = Math.max(bounds.right, node.x + node.width); + bounds.bottom = Math.max(bounds.bottom, node.y + node.height); + layerBounds.set(node.layer, bounds); + } + const routeKinds = new Map(), portGroups = new Map(), ports = new Map(); + function addPort(key, edge, endpoint) { + const group = portGroups.get(key) || []; + group.push({ edge, endpoint }); + portGroups.set(key, group); + } + for (const edge of edges) { + const from = positions.get(edge.from), to = positions.get(edge.to); + const route = to.layer <= from.layer ? "back" : to.layer - from.layer > 1 ? "long" : "adjacent"; + routeKinds.set(edge, route); + addPort("out:right:" + edge.from, edge, "from"); + addPort("in:" + (route === "back" ? "right:" : "left:") + edge.to, edge, "to"); + } + for (const group of portGroups.values()) + group.forEach(({ edge, endpoint }, index) => { + const port = ports.get(edge) || { from: 0, to: 0 }; + port[endpoint] = (index - (group.length - 1) / 2) * 6; + ports.set(edge, port); + }); + const longEdgeLanes = new Map(), backEdges = []; + const routedEdges = edges.map((edge) => { + const from = positions.get(edge.from), to = positions.get(edge.to), port = ports.get(edge), route = routeKinds.get(edge); + const fromY = from.y + from.height / 2 + port.from, toY = to.y + to.height / 2 + port.to; + if (route === "long") { + const points = [{ x: from.x + from.width, y: fromY }]; + for (let layer = from.layer + 1; layer < to.layer; layer++) { + const bounds = layerBounds.get(layer), lane = longEdgeLanes.get(layer) || 0; + const y = bounds.bottom + ROW_GAP / 2 + lane * ROW_GAP; + longEdgeLanes.set(layer, lane + 1); + points.push({ x: bounds.x, y }, { x: bounds.right, y }); + } + points.push({ x: to.x, y: toY }); + return { ...edge, route, points }; + } + if (route === "back") { + const lane = backEdges.length; + backEdges.push(edge); + const y = drawingBottom + ROW_GAP * (1 + lane); + const fromGutter = layerBounds.get(from.layer).right + COLUMN_GAP / 2; + const toGutter = layerBounds.get(to.layer).right + COLUMN_GAP / 2; + return { ...edge, route, points: [ + { x: from.x + from.width, y: fromY }, + { x: fromGutter, y: fromY }, + { x: fromGutter, y }, + { x: toGutter, y }, + { x: toGutter, y: toY }, + { x: to.x + to.width, y: toY }, + ] }; + } + return { ...edge, route, points: [ + { x: from.x + from.width, y: fromY }, + { x: to.x, y: toY }, + ] }; + }); + const width = Math.max( + ...nodes.map((node) => node.x + node.width), + ...layerBounds.values().map((bounds) => bounds.right + COLUMN_GAP / 2), + ) + OUTER_PADDING; + const routeBottom = Math.max(drawingBottom, ...routedEdges.flatMap((edge) => edge.points.map((point) => point.y))); + const height = routeBottom + OUTER_PADDING; + const coordinates = nodes.map((node) => [node.path, node.layer, node.x, node.y, node.width, node.height].join("@")).join("|"); + return { nodes, edges: routedEdges, width, height, coordinates }; + } + function renderGraphEvidence(edges, expand) { + const graph = element("structure-graph"); + element("structure-graph-evidence")?.remove(); + if (!edges.length) return; + const panel = document.createElement("div"); + panel.id = "structure-graph-evidence"; + panel.className = "connection-list structure-graph-evidence"; + edges.forEach((edge, index) => { + const rendered = createConnectionRow(edge, "graph-" + index, { expanded: expand }); + panel.append(rendered.row, rendered.evidenceList); + }); + graph.after(panel); + } + function clearGraphSelection(restoreFocus = false) { + graphSelection = null; + element("structure-graph")?.querySelectorAll(".structure-graph-edge.graph-selected .structure-graph-line").forEach((path) => + path.setAttribute("marker-end", "url(#structure-arrow-" + path.dataset.marker + ")")); + element("structure-graph")?.querySelectorAll(".graph-selected").forEach((node) => node.classList.remove("graph-selected")); + element("structure-graph-evidence")?.remove(); + if (restoreFocus) element("structure-mode-graph").focus(); + } + function selectGraphItem(type, item, control) { + clearGraphSelection(); + graphSelection = { type, item }; + control.classList.add("graph-selected"); + if (type === "edge") { + control.querySelector(".structure-graph-line").setAttribute("marker-end", "url(#structure-arrow-selected)"); + renderGraphEvidence([item], true); + announce(item.from + " to " + item.to + " connection selected."); + } else { + const outgoing = structureSnapshot.edges.filter((edge) => edge.from === item.path).sort((left, right) => graphTextOrder(left.to, right.to) || graphTextOrder(left.kind, right.kind)); + renderGraphEvidence(outgoing, false); + announce(item.path + ", " + structureStatusLabel(item.file.status) + " selected."); + } + } + function graphControlEvents(control, type, item) { + control.addEventListener("click", () => selectGraphItem(type, item, control)); + control.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + clearGraphSelection(true); + announce("Selection cleared."); + event.preventDefault(); + } else if (event.key === "Enter" || event.key === " ") { + selectGraphItem(type, item, control); + event.preventDefault(); + } + }); + } + function renderStructureGraph(snapshot) { + const container = element("structure-graph"); + container.replaceChildren(); + const layout = layoutStructureGraph(snapshot); + if (layout.nodes.length > 60 || layout.edges.length > 200) { + const note = document.createElement("p"); + note.className = "empty"; + note.textContent = "Graph is too large for this view (" + layout.nodes.length + " files, " + layout.edges.length + " connections). Use the list."; + container.append(note); + return; + } + if (!layout.nodes.length) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = "No connections to draw."; + container.append(empty); + return; + } + const NS = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(NS, "svg"); + svg.classList.add("structure-graph-svg"); + svg.setAttribute("role", "group"); + svg.setAttribute("aria-label", "Structure graph, " + layout.nodes.length + " files, " + layout.edges.length + " connections"); + svg.setAttribute("viewBox", "0 0 " + layout.width + " " + layout.height); + svg.setAttribute("width", String(layout.width)); + svg.setAttribute("height", String(layout.height)); + svg.dataset.layout = layout.coordinates; + const defs = document.createElementNS(NS, "defs"); + for (const status of ["added", "removed", "modified", "unchanged", "selected"]) { + const marker = document.createElementNS(NS, "marker"); + marker.id = "structure-arrow-" + status; + marker.classList.add("structure-graph-marker", "status-" + status); + marker.setAttribute("viewBox", "0 0 6 6"); + marker.setAttribute("refX", "5"); + marker.setAttribute("refY", "3"); + marker.setAttribute("markerWidth", "5"); + marker.setAttribute("markerHeight", "5"); + marker.setAttribute("orient", "auto"); + const arrow = document.createElementNS(NS, "path"); + arrow.setAttribute("d", "M0 0 L6 3 L0 6 Z"); + marker.append(arrow); + defs.append(marker); + } + svg.append(defs); + for (const node of layout.nodes) { + const group = document.createElementNS(NS, "g"); + group.classList.add("structure-graph-node", "status-" + node.file.status); + group.dataset.path = node.path; + group.dataset.layer = String(node.layer); + group.setAttribute("tabindex", "0"); + group.setAttribute("role", "button"); + group.setAttribute("aria-label", node.path + ", " + structureStatusLabel(node.file.status)); + const rect = document.createElementNS(NS, "rect"); + rect.setAttribute("x", String(node.x)); + rect.setAttribute("y", String(node.y)); + rect.setAttribute("width", String(node.width)); + rect.setAttribute("height", String(node.height)); + rect.setAttribute("rx", "5"); + const label = document.createElementNS(NS, "text"); + label.setAttribute("x", String(node.x + 10)); + label.setAttribute("y", String(node.y + 18)); + label.textContent = node.label; + if (node.path.length > 40) { + const title = document.createElementNS(NS, "title"); + title.textContent = node.path; + group.append(title); + } + group.append(rect, label); + graphControlEvents(group, "node", node); + svg.append(group); + } + for (const edge of layout.edges) { + const group = document.createElementNS(NS, "g"); + group.classList.add("structure-graph-edge", "status-" + edge.status); + if (edge.typeOnly) group.classList.add("type-only"); + group.dataset.from = edge.from; + group.dataset.to = edge.to; + group.dataset.status = edge.status; + group.setAttribute("tabindex", "0"); + group.setAttribute("role", "button"); + group.setAttribute("aria-label", edge.from + " → " + edge.to + ", " + structureKindLabel(edge.kind) + ", " + structureStatusLabel(edge.status)); + const d = graphPath(edge.points); + const hit = document.createElementNS(NS, "path"); + hit.classList.add("structure-graph-hit"); + hit.setAttribute("d", d); + hit.setAttribute("style", "fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke"); + const path = document.createElementNS(NS, "path"); + path.classList.add("structure-graph-line"); + path.dataset.marker = edge.status; + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("marker-end", "url(#structure-arrow-" + edge.status + ")"); + group.append(hit, path); + graphControlEvents(group, "edge", edge); + svg.append(group); + } + container.append(svg); + } function renderStructure() { selectedConnection = null; - const container = element("structure-content"); + graphSelection = null; + const shared = element("structure-shared"), container = element("structure-content"), graph = element("structure-graph"), modeSwitch = element("structure-mode-switch"); + shared.replaceChildren(); container.replaceChildren(); + graph.replaceChildren(); + element("structure-graph-evidence")?.remove(); + modeSwitch.hidden = true; + container.hidden = false; + graph.hidden = true; if (!currentSource || structureStatus === "loading") { const empty = document.createElement("p"); empty.className = "empty"; @@ -470,11 +845,17 @@ export const pageScript = String.raw` } if (!structureSnapshot) return; const snapshot = structureSnapshot; + modeSwitch.hidden = false; + for (const mode of ["list", "graph"]) { + const tab = element("structure-mode-" + mode), selected = mode === structureMode; + tab.setAttribute("aria-selected", String(selected)); + tab.tabIndex = selected ? 0 : -1; + } const comparison = document.createElement("p"); comparison.id = "structure-comparison"; comparison.className = "structure-comparison"; comparison.textContent = snapshot.comparison.from + " → " + snapshot.comparison.to; - container.append(comparison); + shared.append(comparison); const disclosureItems = [ ...snapshot.comparison.reasons.map((reason) => ({ reason })), ...snapshot.limits.omitted, @@ -496,7 +877,7 @@ export const pageScript = String.raw` } details.append(summary, reasons); notice.append(sentence, details); - container.append(notice); + shared.append(notice); } if (!snapshot.edges.length) container.append(makeSpan("structure-zero", "No connections among changed files. Unchanged neighbours are outside this view.")); @@ -524,54 +905,17 @@ export const pageScript = String.raw` const list = document.createElement("div"); list.className = "connection-list"; connections.forEach((edge, edgeIndex) => { - const key = fileIndex + "-" + edgeIndex; - const evidenceId = "connection-evidence-" + key; - const row = document.createElement("button"); - row.type = "button"; - row.className = "connection-row"; - row.dataset.status = edge.status; - row.setAttribute("aria-expanded", "false"); - row.setAttribute("aria-controls", evidenceId); - row.append( - makeSpan("connection-kind", structureKindLabel(edge.kind)), - makeSpan("connection-target", edge.to), - ); - if (edge.typeOnly) row.append(makeSpan("connection-type", "type")); - row.append(makeSpan("connection-status status-chip status-" + edge.status, structureStatusLabel(edge.status))); - const evidenceList = document.createElement("ul"); - evidenceList.id = evidenceId; - evidenceList.className = "connection-evidence"; - evidenceList.hidden = true; - edge.evidence.forEach((evidence, evidenceIndex) => { - const item = document.createElement("li"); - const code = document.createElement("span"); - code.className = "evidence-code"; - code.textContent = evidence.line + " " + evidence.text; - const open = document.createElement("button"); - open.type = "button"; - open.className = "ghost open-in-diff"; - open.textContent = "Open in Diff"; - open.addEventListener("click", () => jumpToEvidence(evidence, edge.status, evidenceIndex, edge.evidence)); - item.append(code, open); - evidenceList.append(item); - }); - row.addEventListener("click", () => { - const expanded = row.getAttribute("aria-expanded") === "true"; - if (selectedConnection && selectedConnection !== row) { - selectedConnection.classList.remove("selected"); - selectedConnection.setAttribute("aria-expanded", "false"); - element(selectedConnection.getAttribute("aria-controls")).hidden = true; - } - selectedConnection = row; - row.classList.add("selected"); - row.setAttribute("aria-expanded", String(!expanded)); - evidenceList.hidden = expanded; - }); - list.append(row, evidenceList); + const rendered = createConnectionRow(edge, fileIndex + "-" + edgeIndex, { listSelection: true }); + list.append(rendered.row, rendered.evidenceList); }); if (connections.length) group.append(list); container.append(group); }); + if (structureMode === "graph") { + container.hidden = true; + graph.hidden = false; + renderStructureGraph(snapshot); + } } function ensureStructure() { if (!currentSource) { @@ -609,6 +953,23 @@ export const pageScript = String.raw` announce("Structure analysis failed: " + structureError); }); } + function setStructureMode(mode) { + structureMode = mode === "graph" ? "graph" : "list"; + try { sessionStorage.setItem("reviewTutorStructureMode", structureMode); } catch {} + renderStructure(); + announce(structureMode === "graph" ? "Structure graph mode." : "Structure list mode."); + } + function handleStructureModeKey(event) { + if (!["ArrowLeft", "ArrowRight", "Home", "End", "Enter", " "].includes(event.key)) return; + const tabs = [element("structure-mode-list"), element("structure-mode-graph")]; + if (event.key === "Enter" || event.key === " ") setStructureMode(event.currentTarget.id.endsWith("graph") ? "graph" : "list"); + else { + const target = event.key === "ArrowLeft" || event.key === "Home" ? tabs[0] : tabs[1]; + tabs.forEach((tab) => { tab.tabIndex = tab === target ? 0 : -1; }); + target.focus(); + } + event.preventDefault(); + } function setView(view, focusPanel = false) { activeView = ["diff", "structure", "log"].includes(view) ? view : "diff"; if (activeView !== "diff") hideInlineComposer(); @@ -1779,6 +2140,11 @@ export const pageScript = String.raw` element(id).addEventListener("click", () => setView(id.replace("view-", ""))); element(id).addEventListener("keydown", handleViewKey); } + for (const mode of ["list", "graph"]) { + const tab = element("structure-mode-" + mode); + tab.addEventListener("click", () => setStructureMode(mode)); + tab.addEventListener("keydown", handleStructureModeKey); + } element("history-previous").addEventListener("click", () => renderHistoryEntry(Math.max(0, historyIndex - 1))); element("history-next").addEventListener("click", () => renderHistoryEntry(Math.min(historyEntries.length - 1, historyIndex + 1))); element("question").addEventListener("input", updateActions); diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 58d3440..72e83d0 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -290,6 +290,40 @@ position:fixed;top:calc(var(--topbar-h) + 10px);left:50%;translate:-50% 0;z-inde display:none} .structure-section { contain:layout;min-width:0;padding:14px 14px 100px;overflow-x:hidden} +.structure-mode-switch { +width:max-content;margin:0 0 10px} +#structure-graph { +max-width:100%;overflow-x:auto;overflow-y:hidden} +.structure-graph-svg { +display:block;max-width:none} +.structure-graph-node,.structure-graph-edge { +cursor:pointer} +.structure-graph-node rect { +fill:var(--surface-1);stroke:var(--hairline);stroke-width:1} +.structure-graph-node text { +fill:currentColor;font:12px var(--font-mono)} +.structure-graph-node.status-added,.structure-graph-edge.status-added,.structure-graph-marker.status-added { +color:var(--green)} +.structure-graph-node.status-removed,.structure-graph-edge.status-removed,.structure-graph-marker.status-removed { +color:var(--rose)} +.structure-graph-node.status-modified,.structure-graph-edge.status-modified,.structure-graph-marker.status-modified { +color:var(--amber)} +.structure-graph-node.status-unchanged,.structure-graph-node.status-renamed,.structure-graph-edge.status-unchanged,.structure-graph-marker.status-unchanged { +color:var(--muted)} +.structure-graph-marker.status-selected { +color:var(--ember)} +.structure-graph-edge .structure-graph-line { +stroke:currentColor;stroke-width:1;vector-effect:non-scaling-stroke} +.structure-graph-edge.status-removed .structure-graph-line { +stroke-dasharray:4 3} +.structure-graph-edge.type-only { +opacity:.7} +.structure-graph-marker path { +fill:currentColor} +.structure-graph-node.graph-selected rect,.structure-graph-edge.graph-selected .structure-graph-line { +stroke:var(--ember);stroke-width:2} +.structure-graph-evidence { +margin-top:12px;border-top:1px solid var(--hairline)} .structure-comparison { margin:0 0 10px;color:var(--subtle);font:12px var(--font-mono)} .structure-partial { diff --git a/packages/review-tutor/src/page.ts b/packages/review-tutor/src/page.ts index 0af82c4..5944b8b 100644 --- a/packages/review-tutor/src/page.ts +++ b/packages/review-tutor/src/page.ts @@ -41,7 +41,7 @@ export const pageHtml = `
No code selected

Load a source and enter a question.

-

Load a source to begin reviewing.

+

Load a source to begin reviewing.

`; diff --git a/packages/review-tutor/test/page.test.ts b/packages/review-tutor/test/page.test.ts index 86dec8c..88bd871 100644 --- a/packages/review-tutor/test/page.test.ts +++ b/packages/review-tutor/test/page.test.ts @@ -119,6 +119,8 @@ async function boot(options: { source?: typeof source; storedPageId?: string; storedQuizIds?: string; + storedStructureMode?: string; + throwStructureModeRead?: boolean; stateResponses?: unknown[]; structureResponses?: Array | Response | unknown | Error>; failHeartbeat?: boolean; @@ -129,7 +131,15 @@ async function boot(options: { Object.defineProperty(window, "innerWidth", { value: options.width ?? 1440, writable: true, configurable: true }); if (options.storedPageId !== undefined) window.sessionStorage.setItem("reviewTutorPageId", options.storedPageId); if (options.storedQuizIds !== undefined) window.sessionStorage.setItem("reviewTutorQuizEntryIds", options.storedQuizIds); + if (options.storedStructureMode !== undefined) window.sessionStorage.setItem("reviewTutorStructureMode", options.storedStructureMode); if (options.railCollapsed) window.sessionStorage.setItem("reviewTutorRailCollapsed", "1"); + if (options.throwStructureModeRead) { + const getItem = window.sessionStorage.getItem.bind(window.sessionStorage); + window.sessionStorage.getItem = vi.fn((key: string) => { + if (key === "reviewTutorStructureMode") throw new Error("storage blocked"); + return getItem(key); + }); + } const script = pageHtml.match(/