From cdbf8826ff0b837fff3592d4767556d2fabf954e Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Tue, 20 Oct 2026 17:36:25 -0500 Subject: [PATCH 01/10] Rendering Source Hierarchy - Rendering packages as subgraphs in the class graph - Rendering packages as subgraphs and rectangles in the package graph --- AGENTS.md | 37 ++- components/report-view.jsx | 4 +- lib/hierarchy-dot.js | 208 +++++++++++++ lib/report-view.js | 149 ++++++--- public/widgets/graphviz-bridge.js | 14 + tests/e2e/report-pagination.spec.js | 21 +- tests/e2e/static-export-parity.spec.js | 4 +- tests/e2e/user-journeys.spec.js | 32 +- tests/fixtures/junit4-report.json | 250 ++++++++++++--- tests/integration/report-view.test.jsx | 25 ++ tests/unit/hierarchy-dot.test.js | 410 +++++++++++++++++++++++++ tests/unit/report-view.test.js | 321 +++++++++++-------- 12 files changed, 1229 insertions(+), 246 deletions(-) create mode 100644 lib/hierarchy-dot.js create mode 100644 public/widgets/graphviz-bridge.js create mode 100644 tests/unit/hierarchy-dot.test.js diff --git a/AGENTS.md b/AGENTS.md index 283a4c7..4316b32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,16 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). branch (`rf:branch-resolved` event) so the label matches the loaded report (`main` 404 → `master` fallback) - Reports rendered with Mustache.js (bundled template is authoritative) +- Graphs synthesized client-side from the nested hierarchy in + refactor-first.json (`lib/hierarchy-dot.js`, byte-identical to the report + module's Java `NestedDotBuilder`; packages nest as clusters, classes as + ellipses, break-up packages fill pink). The package graph renders its own + compound DOT shape — parent packages once as clusters, childless packages + once as filled rectangles, edges clipped to cluster borders via + invisible anchors + `ltail`/`lhead` — through + `@hpcc-js/wasm-graphviz@1.7.0` (`public/widgets/graphviz-bridge.js`, + the only engine honoring compound cluster edges); the class and cycle + graphs stay on vizdom - Enhanced report tables: sticky headers, pagination (>20 rows), sortable columns, in-table search, CSV export, click/keyboard cell copy with toasts - Repository submission via pre-filled platform issues — no login, apps or tokens @@ -91,7 +101,10 @@ lib/ # shared logic (client + RSC): routes, fetcher, # renderer, search, utils, host, rate-limiter, # cache-manager, error-handler, repo-submission, # report-view, static-params, widget-loader, - # breadcrumbs, + # breadcrumbs, hierarchy-dot (hierarchy-to-DOT + # synthesis mirroring the Java NestedDotBuilder in + # the report module; byte-identical goldens in + # tests/unit/hierarchy-dot.test.js), # table-operations (filter/sort/paginate/CSV/copy + # TABLE_CONFIG + REPORT_TABLES descriptors), # table-enhancer (binds toolbar/sort/pagination/ @@ -101,8 +114,9 @@ public/ # static files copied verbatim into out/: repositories.txt # synced from the repo root (sync-repositories.mjs) assets/ # mustache template, logo templates/ # workflow-sample-*.html fragments - widgets/ # module bridges: vizdom WASM, three-spritetext, - # sentry (runtime CDN imports cannot be bundled) + widgets/ # module bridges: vizdom WASM, wasm-graphviz, + # three-spritetext, sentry (runtime CDN imports + # cannot be bundled) templates/ # user CI samples (user-refactorfirst-*.yml) for the docs ci/process-submissions.sh # shared submission validator for GH/Gl/BB CI .github/workflows/ # test.yml, static.yml, redeploy.yml, @@ -158,12 +172,15 @@ coverage there when introducing new markup patterns. SVGs' embedded black strokes/fills are likewise re-themed to `--graph-line` (`#9fb0c0` dark = `CHART_LEGEND_TEXT.dark`) via `path[stroke]`/`polygon` selectors on `.fullscreen-svg` — red cycle - edges stay red. Edge labels are glyph paths baked by vizdom, so CSS - can't reach them: `withEdgeFontColor` in `lib/report-view.js` rewrites - the DOT with `fontcolor = "#9fb0c0"` per edge when the dark palette - is active (vizdom uppercases the hex in the SVG), and theme switches - re-parse the graphs via `redrawGraphsForTheme` alongside the chart - legend redraw in `bindThemeChartRedraw`. + edges stay red. Graph DOT is synthesized from the `.hierarchy` payloads + (`classMap`/`packageMap`/`largestCycle`, see `lib/hierarchy-dot.js`); + edge labels are fill-only glyph paths baked by vizdom, so CSS can't + reach them: the dark palette bakes `fontcolor = "#9fb0c0"` into every + edge at synthesis time via `graphDotOptions()` in `lib/report-view.js` + (vizdom uppercases the hex in the SVG), and theme switches + re-synthesize + re-render the graphs via `redrawGraphsForTheme` alongside + the chart legend redraw in `bindThemeChartRedraw`. Node/cluster labels + sit on vizdom's white shapes and stay default-colored in both palettes. ## Report Tables (Enhanced) @@ -220,7 +237,7 @@ coverage there when introducing new markup patterns. ## Current Test Count -~584 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). +~733 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and diff --git a/components/report-view.jsx b/components/report-view.jsx index 07c1b5b..1a261bd 100644 --- a/components/report-view.jsx +++ b/components/report-view.jsx @@ -40,11 +40,11 @@ const CLASSIC_WIDGETS = [ // Module bridges (CSP 'script-src self' — vendored files) report readiness // themselves via window.__rfMarkWidgetReady. -const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/three-spritetext-bridge.js']; +const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/graphviz-bridge.js', '/widgets/three-spritetext-bridge.js']; // Widgets that enhanceReport consumes synchronously; all others degrade // gracefully at popup-open time. -const ENHANCE_WIDGETS = ['chart', 'vizdom']; +const ENHANCE_WIDGETS = ['chart', 'vizdom', 'graphviz']; /** * Fetches and renders a repository report with interactive table controls. diff --git a/lib/hierarchy-dot.js b/lib/hierarchy-dot.js new file mode 100644 index 0000000..d231951 --- /dev/null +++ b/lib/hierarchy-dot.js @@ -0,0 +1,208 @@ +// Client-side DOT synthesis from the nested package/class hierarchy in +// refactor-first.json (classMap.hierarchy, packageMap.hierarchy, +// classCycles.largestCycle.hierarchy). Mirrors NestedDotBuilder in the +// RefactorFirst report module (Java) — the two render byte-identical DOT and +// must stay in lockstep; the unit tests here mirror NestedDotBuilderTest +// there. The standalone viewer (report/src/main/resources/viewer/index.html) +// carries an inline copy of this module for self-contained use. +// +// Layout contract (why clusters come first): in DOT a node belongs to the +// cluster where it is first defined, so packages are emitted as labelled +// `subgraph cluster_…` blocks containing their class nodes (ellipses) and +// child packages before any edge is declared. Edges then reference the +// pre-resolved node ids and may freely cross cluster borders. +// +// Color contract: classes/edges marked for removal stay red; packages that +// need to be broken up render pink — clusters via `style=filled fillcolor=pink` +// when the package has no node of its own, package-map rectangle nodes via +// `color=pink style=filled` (a pink node inside its own pink box would hide +// the signal). Every cluster declares an explicit fill (white when unflagged) +// because vizdom propagates a styled cluster's fill to its nested cluster +// rects — the explicit white keeps child packages of a pink cluster on their +// normal fill; white is vizdom's default cluster fill, so nothing changes +// visually for unflagged clusters. Node and cluster label glyphs keep vizdom's +// default black: +// shapes default to white fills (probe: cluster rects render +// fill="white" stroke="black"), so labels stay readable on every palette — +// only edge labels sit on the page background, which is why edgeFontColor +// exists (vizdom bakes fontcolor into fill-only glyph paths at parse time, +// unreachable by CSS; the dark palette re-synthesizes with +// fontcolor = CHART_LEGEND_TEXT.dark). + +/** + * Escapes a value for a DOT double-quoted string: backslashes, quotes and + * line breaks become their DOT escape sequences (mirrors the Java + * escapeDotQuoted in the report module). + * + * @param {*} value - Raw label or name; null/undefined render as empty. + * @returns {string} DOT-quoted-safe text (without the surrounding quotes). + */ +export function escapeDotQuoted(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); +} + +/** + * Synthesizes the nested DOT graph for one hierarchy payload. + * + * @param {object|null} hierarchy - `{packages, edges}` from the report JSON; + * missing, null and empty hierarchies render the bare digraph skeleton. + * @param {object} [options] - Optional rendering tweaks. + * @param {string} [options.edgeFontColor] - Graphviz color baked into every + * edge's fontcolor so vizdom renders edge labels in that color (used by the + * dark palette); omitted/empty keeps vizdom's default black. + * @returns {string} DOT source for a strict digraph with package clusters. + */ +export function hierarchyToDot(hierarchy, options) { + const edgeFontColor = + options && typeof options.edgeFontColor === 'string' ? options.edgeFontColor : ''; + const parts = ['strict digraph G {']; + appendPackages((hierarchy && hierarchy.packages) || [], edgeFontColor, parts); + appendEdges((hierarchy && hierarchy.edges) || [], edgeFontColor, parts); + parts.push('}'); + return parts.join('\n'); +} + +/** + * Synthesizes the package-map graph's compound DOT: packages with children render + * once as labelled clusters, childless packages render once as filled rectangles, + * and edges attach to cluster-represented packages through invisible anchor nodes + * clipped to the cluster borders via {@code ltail}/{@code lhead} under + * {@code compound=true}. Each package name therefore appears exactly once. The + * package graph renders through @hpcc-js/wasm-graphviz — the only engine that + * honors compound cluster edges (vizdom ignores ltail/lhead). + * + * @param {object|null} hierarchy - `{packages, edges}` from the report JSON; + * missing, null and empty hierarchies render the bare compound skeleton. + * @param {object} [options] - Optional rendering tweaks. + * @param {string} [options.edgeFontColor] - Graphviz color baked into every + * edge's fontcolor (used by the dark palette). + * @returns {string} DOT source for a strict compound digraph. + */ +export function packageHierarchyToDot(hierarchy, options) { + const edgeFontColor = + options && typeof options.edgeFontColor === 'string' ? options.edgeFontColor : ''; + const packages = (hierarchy && hierarchy.packages) || []; + const edges = (hierarchy && hierarchy.edges) || []; + + const referencedIds = new Set(); + const byNodeId = new Map(); + for (const edge of edges) { + referencedIds.add(edge.source); + referencedIds.add(edge.target); + } + const index = pkgs => { + for (const pkg of pkgs) { + if (pkg.nodeId) byNodeId.set(pkg.nodeId, pkg); + index(pkg.children || []); + } + }; + index(packages); + + const parts = ['strict digraph G {', 'compound=true;']; + const appendShape = pkg => { + if (pkg.renderAsNode) { + parts.push(`${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=rectangle style=filled fillcolor=${pkg.needsBreakUp ? 'pink' : 'white'}];`); + return; + } + parts.push(`subgraph ${pkg.id} {`); + parts.push(`label="${escapeDotQuoted(pkg.name)}";`); + parts.push(`style=filled fillcolor=${pkg.needsBreakUp ? 'pink' : 'white'};`); + if (pkg.nodeId && referencedIds.has(pkg.nodeId)) { + parts.push(`${pkg.nodeId} [shape=point style=invis];`); + } + for (const child of pkg.children || []) appendShape(child); + parts.push('}'); + }; + for (const pkg of packages) appendShape(pkg); + for (const edge of edges) { + let line = `${edge.source} -> ${edge.target} [ label = "${edge.weight}" weight = "${edge.weight}"`; + if (edge.markedForRemoval) line += ' color = "red"'; + if (edgeFontColor) line += ` fontcolor = "${edgeFontColor}"`; + const source = byNodeId.get(edge.source); + if (source && !source.renderAsNode) line += ` ltail = "${source.id}"`; + const target = byNodeId.get(edge.target); + if (target && !target.renderAsNode) line += ` lhead = "${target.id}"`; + parts.push(`${line} ];`); + } + parts.push('}'); + return parts.join('\n'); +} + +/** + * Appends one package cluster block per entry: label, breakup style, its + * rectangle node (package map), its class nodes, then its child packages. + * + * @param {Array} packages - Package nodes in render order. + * @param {string} edgeFontColor - See hierarchyToDot options. + * @param {string[]} parts - Output accumulator. + * @returns {void} + */ +function appendPackages(packages, edgeFontColor, parts) { + for (const pkg of packages) { + appendPackage(pkg, edgeFontColor, parts); + } +} + +/** + * Appends one package cluster block (see appendPackages). + * + * @param {object} pkg - Package node: name, id, nodeId, renderAsNode, + * needsBreakUp, children, classes. + * @param {string} edgeFontColor - See hierarchyToDot options. + * @param {string[]} parts - Output accumulator. + * @returns {void} + */ +function appendPackage(pkg, edgeFontColor, parts) { + parts.push(`subgraph ${pkg.id} {`); + parts.push(`label="${escapeDotQuoted(pkg.name)}";`); + // Every cluster declares an explicit fill: vizdom propagates a styled + // cluster's fill to its nested cluster rects, so unflagged children of a + // pink break-up cluster must say white explicitly or they would inherit + // the pink. White is vizdom's default cluster fill, so unflagged clusters + // look unchanged. In the package map the pink signal belongs to the + // rectangle node, not its own cluster box. + if (pkg.needsBreakUp && !pkg.renderAsNode) { + parts.push('style=filled fillcolor=pink;'); + } else { + parts.push('style=filled fillcolor=white;'); + } + if (pkg.renderAsNode) { + let line = `${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=rectangle`; + if (pkg.needsBreakUp) line += ' color=pink style=filled'; + parts.push(`${line}];`); + } + for (const cls of pkg.classes || []) { + let line = `${cls.id} [`; + if (cls.label !== cls.id) line += ` label="${escapeDotQuoted(cls.label)}"`; + line += ' shape=ellipse'; + if (cls.markedForRemoval) line += ' color=red style=filled'; + parts.push(`${line}];`); + } + appendPackages(pkg.children || [], edgeFontColor, parts); + parts.push('}'); +} + +/** + * Appends the graph's edges after all clusters, keeping the previous + * emitters' exact attribute format and highlighting removals in red. + * + * @param {Array} edges - Edge DTOs: source, target, weight, + * markedForRemoval. + * @param {string} edgeFontColor - See hierarchyToDot options. + * @param {string[]} parts - Output accumulator. + * @returns {void} + */ +function appendEdges(edges, edgeFontColor, parts) { + for (const edge of edges) { + let line = `${edge.source} -> ${edge.target} [ label = "${edge.weight}" weight = "${edge.weight}"`; + if (edge.markedForRemoval) line += ' color = "red"'; + if (edgeFontColor) line += ` fontcolor = "${edgeFontColor}"`; + parts.push(`${line} ];`); + } +} diff --git a/lib/report-view.js b/lib/report-view.js index 6249beb..3c32698 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -5,21 +5,24 @@ // failure degrades to the static rendered report. import { sourceUrl } from './utils.js'; +import { hierarchyToDot, packageHierarchyToDot } from './hierarchy-dot.js'; // --------------------------------------------------------------------------- // DOT exposure: popup buttons in the Mustache template reference globals. +// Each graph's DOT is synthesized client-side from its hierarchy payload +// (vizdom renders it; graphlib-dot flattens the clusters in the 2D/3D popups). // --------------------------------------------------------------------------- export function exposeGraphDots(data) { - if (data.classMap && data.classMap.dot) { - window.classGraph_dot = data.classMap.dot; + if (data.classMap && data.classMap.hierarchy) { + window.classGraph_dot = hierarchyToDot(data.classMap.hierarchy, graphDotOptions()); } - if (data.packageMap && data.packageMap.dot) { - window.packageGraph_dot = data.packageMap.dot; + if (data.packageMap && data.packageMap.hasEdges && data.packageMap.hierarchy) { + window.packageGraph_dot = packageHierarchyToDot(data.packageMap.hierarchy, graphDotOptions()); } - if (data.classCycles && data.classCycles.largestCycle && data.classCycles.largestCycle.dot) { - window[`${data.classCycles.largestCycle.cycleIdentifier}_dot`] = - data.classCycles.largestCycle.dot; + const cycle = data.classCycles && data.classCycles.largestCycle; + if (cycle && cycle.hierarchy) { + window[`${cycle.cycleIdentifier}_dot`] = hierarchyToDot(cycle.hierarchy, graphDotOptions()); } } @@ -852,33 +855,52 @@ export function initDisharmonyCharts(disharmonies, repoUrl) { // --------------------------------------------------------------------------- /** - * Appends a Graphviz `fontcolor` attribute to every edge attribute list in - * a DOT string so vizdom bakes the label color into the SVG at parse time - * (the default black is unreadable on the dark palette). Edges that already - * carry fontcolor are left untouched; node statements have no `->` in the - * matched segment and are preserved verbatim. + * Synthesis options for the active palette: the dark palette bakes the muted + * edge-label color into the DOT (vizdom renders labels as fill-only glyph + * paths at parse time, beyond CSS reach), the light palette keeps vizdom's + * default black. Node and cluster labels sit on white shapes in both palettes + * and stay default-colored. * - * @param {string} dot - DOT graph source. - * @param {string} color - Graphviz color value for edge labels. - * @returns {string} DOT with fontcolor appended to each labeled edge. + * @returns {object} Options for hierarchyToDot. */ -export function withEdgeFontColor(dot, color) { - if (typeof dot !== 'string' || !color) return dot; - return dot.replace(/(->[^;[\]{}]*?\[)([^\]]*?)(\s*\])/g, (match, open, attrs, close) => { - if (/fontcolor\s*=/.test(attrs)) return match; - return `${open}${attrs} fontcolor = "${color}"${close}`; - }); +export function graphDotOptions() { + return { + edgeFontColor: activeChartTheme() === 'dark' ? CHART_LEGEND_TEXT.dark : '' + }; } -// Rendered graphs keyed by container id, so a theme switch can re-parse the -// dots with the new palette's edge-label color — vizdom bakes fontcolor into -// the SVG at parse time, so recoloring needs a re-render, like the charts. -const wasmGraphDots = new Map(); +// Rendered graph entries keyed by container id, so a theme switch can +// re-synthesize the DOT with the new palette's edge-label color — vizdom and +// wasm-graphviz bake fontcolor into the output at parse time, so recoloring +// needs a re-render, like the charts. The class and cycle graphs render +// through vizdom; the package graph renders through @hpcc-js/wasm-graphviz, +// the only engine that honors the compound ltail/lhead cluster edges, so +// each entry records its engine. +const wasmGraphHierarchies = new Map(); + +// The loaded wasm-graphviz instance, cached against the identity of the +// window.Graphviz widget that produced it (the bridge installs the widget +// once; tests re-stub it, so a stale cache is never reused). +let graphvizCache = null; /** - * Re-renders every live WASM graph so edge labels re-resolve against the - * newly active palette. Entries whose container left the DOM (report - * re-rendered or torn down) are pruned instead of redrawn. + * Loads (or reuses) the wasm-graphviz engine. Returns null when the widget + * is not installed. + * + * @returns {Promise} Loaded Graphviz instance, or null. + */ +async function loadGraphviz() { + if (!window.Graphviz) return null; + if (!graphvizCache || graphvizCache.widget !== window.Graphviz) { + graphvizCache = {widget: window.Graphviz, instance: await window.Graphviz.load()}; + } + return graphvizCache.instance; +} + +/** + * Re-renders every live graph so edge labels re-resolve against the newly + * active palette. Entries whose container left the DOM (report re-rendered + * or torn down) are pruned instead of redrawn. * * @returns {void} */ @@ -886,14 +908,18 @@ function redrawGraphsForTheme() { const vizdom = typeof window !== 'undefined' ? window.Vizdom : null; if (!vizdom || !vizdom.DotParser) return; const parser = new vizdom.DotParser(); - for (const [containerId, dotString] of wasmGraphDots) { + for (const [containerId, entry] of wasmGraphHierarchies) { const el = typeof document !== 'undefined' ? document.getElementById(containerId) : null; if (!el || !el.isConnected) { - wasmGraphDots.delete(containerId); + wasmGraphHierarchies.delete(containerId); continue; } try { - renderGraphInto(parser, el, containerId, dotString); + if (entry.engine === 'graphviz') { + renderPackageGraphInto(el, containerId, entry.hierarchy); + } else { + renderGraphInto(parser, el, containerId, entry.hierarchy); + } } catch (error) { console.warn('WASM graph re-render failed:', error); } @@ -904,11 +930,9 @@ function redrawGraphsForTheme() { * Parses a dot string (recolored for the active palette) into the graph * container's innerHTML and re-wires pan/zoom on the freshly injected SVG. */ -function renderGraphInto(parser, el, containerId, dotString) { - const themedDot = activeChartTheme() === 'dark' - ? withEdgeFontColor(dotString, CHART_LEGEND_TEXT.dark) - : dotString; - const parsed = parser.parse(themedDot).to_directed().layout(); +function renderGraphInto(parser, el, containerId, hierarchy) { + const dotString = hierarchyToDot(hierarchy, graphDotOptions()); + const parsed = parser.parse(dotString).to_directed().layout(); const svg = parsed.to_svg().to_string() .replace(' { + const renderGraph = (containerId, hierarchy) => { + if (!parser) return; const el = document.getElementById(containerId); - if (!el || !dotString) return; - renderGraphInto(parser, el, containerId, dotString); - wasmGraphDots.set(containerId, dotString); + if (!el || !hierarchy) return; + renderGraphInto(parser, el, containerId, hierarchy); + wasmGraphHierarchies.set(containerId, {engine: 'vizdom', hierarchy}); }; if (data.classMap && !data.classMap.dotThresholdExceeded) { - renderGraph('classGraph', data.classMap.dot); + renderGraph('classGraph', data.classMap.hierarchy); } - if (data.packageMap && !data.packageMap.dotThresholdExceeded && data.packageMap.hasEdges) { - renderGraph('packageGraph', data.packageMap.dot); + // The package graph's compound cluster edges (ltail/lhead) only render + // under @hpcc-js/wasm-graphviz — vizdom ignores them — so it degrades + // away silently when that widget has not settled. + if (data.packageMap + && !data.packageMap.dotThresholdExceeded + && data.packageMap.hasEdges + && data.packageMap.hierarchy) { + const el = document.getElementById('packageGraph'); + const graphviz = el ? await loadGraphviz() : null; + if (el && graphviz) { + renderPackageGraphInto(el, 'packageGraph', data.packageMap.hierarchy); + wasmGraphHierarchies.set('packageGraph', { + engine: 'graphviz', + hierarchy: data.packageMap.hierarchy + }); + } } const cycle = data.classCycles && data.classCycles.largestCycle; if (cycle && !cycle.dotThresholdExceeded) { - renderGraph(cycle.cycleIdentifier, cycle.dot); + renderGraph(cycle.cycleIdentifier, cycle.hierarchy); } } catch (error) { console.warn('WASM graph layout unavailable:', error); diff --git a/public/widgets/graphviz-bridge.js b/public/widgets/graphviz-bridge.js new file mode 100644 index 0000000..c5ff288 --- /dev/null +++ b/public/widgets/graphviz-bridge.js @@ -0,0 +1,14 @@ +// Bridge: loads the pinned @hpcc-js/wasm-graphviz module from jsDelivr +// (allowed by script-src; the wasm binary is embedded in the module) and +// exposes it as window.Graphviz for the package graph — the only renderer +// honoring the graph's compound ltail/lhead cluster edges. Bundler +// `import()` rewriting cannot load remote URLs, so this runs as a native +//
'); parsed.length = 0; + graphvizRendered.length = 0; mediaListener(); - expect(parsed).toEqual([packageDot, cycleDot]); + expect(parsed).toEqual([lightDot(cycleHierarchy)]); + expect(graphvizRendered).toEqual([lightPackageDot(packageHierarchy)]); expect(document.getElementById('classGraph').innerHTML).toBe(''); }); @@ -923,46 +931,94 @@ describe('initWasmGraphs', () => { }; return captured; } - afterEach(() => { delete window.Vizdom; delete window.matchMedia; }); + + // Minimal wasm-graphviz stub: records the rendered package DOT and yields a + // fixed SVG with an xml/doctype preamble like the real engine. + function stubGraphviz() { + const rendered = []; + window.Graphviz = { + load: async () => ({ + dot: dotString => { + rendered.push(dotString); + return '\n\n'; + } + }) + }; + return rendered; + } + + afterEach(() => { delete window.Vizdom; delete window.Graphviz; delete window.matchMedia; }); it('resolves gracefully when the WASM loader is unavailable', async () => { document.body.innerHTML = '
'; // No network/WASM in tests: must resolve, not reject - await initWasmGraphs({ classMap: { dot: 'strict digraph G {}', dotThresholdExceeded: false } }); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + }); + + it('renders the package graph through wasm-graphviz while the light palette is active', async () => { + installThemeRadios('light', false); + const graph = document.createElement('div'); + graph.id = 'packageGraph'; + document.body.appendChild(graph); + const vizdomCaptured = stubVizdom(); + const graphvizRendered = stubGraphviz(); + await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); + expect(graphvizRendered).toEqual([lightPackageDot(packageHierarchy)]); + expect(vizdomCaptured).toEqual([], "the package graph must not render through vizdom"); + expect(document.querySelector('#packageGraph svg').classList.contains('fullscreen-svg')).toBe(true); + }); + + it('renders the package graph with dark edge labels while the dark palette is active', async () => { + installThemeRadios('dark', false); + const graph = document.createElement('div'); + graph.id = 'packageGraph'; + document.body.appendChild(graph); + const graphvizRendered = stubGraphviz(); + await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); + expect(graphvizRendered).toEqual([darkPackageDot(packageHierarchy)]); + expect(graphvizRendered[0]).toContain('fontcolor = "#9fb0c0"'); + }); + + it('skips the package graph when the wasm-graphviz widget is unavailable', async () => { + installThemeRadios('light', false); + const graph = document.createElement('div'); + graph.id = 'packageGraph'; + document.body.appendChild(graph); + const vizdomCaptured = stubVizdom(); + await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); + expect(document.getElementById('packageGraph').innerHTML).toBe(''); + expect(vizdomCaptured).toEqual([]); }); it('skips graphs whose dot threshold was exceeded', async () => { document.body.innerHTML = '
'; - await initWasmGraphs({ classMap: { dot: 'x', dotThresholdExceeded: true } }); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: true } }); expect(document.getElementById('classGraph').innerHTML).toBe(''); }); - it('parses the dot unchanged while the light palette is active', async () => { + it('synthesizes the hierarchy DOT while the light palette is active', async () => { installThemeRadios('light', false); const graph = document.createElement('div'); graph.id = 'classGraph'; document.body.appendChild(graph); const captured = stubVizdom(); - const dot = 'strict digraph G {\nA -> B [ label = "6" ];\n}'; - await initWasmGraphs({ classMap: { dot, dotThresholdExceeded: false } }); - expect(captured).toEqual([dot]); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + expect(captured).toEqual([lightDot(classHierarchy)]); }); - it('appends fontcolor = #9fb0c0 to edge labels while the dark palette is active', async () => { + it('synthesizes edge fontcolor = #9fb0c0 while the dark palette is active', async () => { installThemeRadios('dark', false); const graph = document.createElement('div'); graph.id = 'classGraph'; document.body.appendChild(graph); const captured = stubVizdom(); - const dot = 'strict digraph G {\nA -> B [ label = "6" weight = "6" ];\n}'; - await initWasmGraphs({ classMap: { dot, dotThresholdExceeded: false } }); - expect(captured).toEqual([ - 'strict digraph G {\nA -> B [ label = "6" weight = "6" fontcolor = "#9fb0c0" ];\n}' - ]); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + expect(captured).toEqual([darkDot(classHierarchy)]); + expect(captured[0]).toContain('fontcolor = "#9fb0c0"'); expect(graph.innerHTML).toBe(''); }); - it('re-parses graphs with fresh label colors when a theme radio changes', async () => { + it('re-synthesizes graphs with fresh label colors when a theme radio changes', async () => { installThemeRadios('light', false); document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); bindThemeChartRedraw(); @@ -970,8 +1026,7 @@ describe('initWasmGraphs', () => { graph.id = 'classGraph'; document.body.appendChild(graph); const captured = stubVizdom(); - const dot = 'strict digraph G {\nA -> B [ label = "6" ];\n}'; - await initWasmGraphs({ classMap: { dot, dotThresholdExceeded: false } }); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); const lightParses = captured.length; expect(captured.every(d => !d.includes('fontcolor'))).toBe(true); @@ -982,12 +1037,12 @@ describe('initWasmGraphs', () => { expect(captured.length).toBeGreaterThan(lightParses); expect(captured[captured.length - 1]).toContain('fontcolor = "#9fb0c0"'); - // Switching back re-renders with the plain dot again. + // Switching back re-renders the plain light-palette DOT again. const lightBack = document.getElementById('rf-theme-light'); lightBack.checked = true; lightBack.dispatchEvent(new window.Event('change', { bubbles: true })); await new Promise(resolve => setTimeout(resolve, 0)); - expect(captured[captured.length - 1]).toBe(dot); + expect(captured[captured.length - 1]).toBe(lightDot(classHierarchy)); }); it('prunes graphs whose container left the DOM on theme change', async () => { @@ -998,8 +1053,7 @@ describe('initWasmGraphs', () => { graph.id = 'classGraph'; document.body.appendChild(graph); const captured = stubVizdom(); - const dot = 'strict digraph G {\nA -> B [ label = "6" ];\n}'; - await initWasmGraphs({ classMap: { dot, dotThresholdExceeded: false } }); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); const initial = captured.length; // The re-render threw the graph container away: next theme change must @@ -1343,9 +1397,9 @@ describe('stateful DOM preservation across table-state re-renders', () => { { anchorId: 'GOD', title: 'God Classes', chart: { bubbles: [{ x: 1, y: 1, r: 3 }] } }, { anchorId: 'BRAIN', title: 'Brain Methods' } // no chart: not stateful ], - classMap: { dot: 'strict digraph C {}', dotThresholdExceeded: false }, - packageMap: { dot: 'strict digraph P {}', dotThresholdExceeded: false, hasEdges: true }, - classCycles: { largestCycle: { cycleIdentifier: 'cycle_0', dot: 'x', dotThresholdExceeded: false } } + classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false }, + packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true }, + classCycles: { largestCycle: { cycleIdentifier: 'cycle_0', hierarchy: cycleHierarchy, dotThresholdExceeded: false } } }; describe('statefulElementIds', () => { @@ -1357,8 +1411,8 @@ describe('stateful DOM preservation across table-state re-renders', () => { it('skips graphs whose dot threshold was exceeded or which lack edges', () => { expect(statefulElementIds({ - classMap: { dot: 'x', dotThresholdExceeded: true }, - packageMap: { dot: 'x', dotThresholdExceeded: false, hasEdges: false }, + classMap: { hierarchy: classHierarchy, dotThresholdExceeded: true }, + packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: false }, classCycles: { largestCycle: { cycleIdentifier: 'cycle_0', dotThresholdExceeded: true } } })).toEqual([]); }); @@ -1605,18 +1659,21 @@ describe('enhanceReport', () => { `; + // exposeGraphDots synthesizes per active palette; pin light (no radios + // in this DOM, no matchMedia stub) so the expectation is deterministic. + delete window.matchMedia; const created = []; window.Chart = function (ctx, config) { created.push(config); }; const data = { project: { name: 'Demo' }, - classMap: { dot: 'strict digraph C {}', dotThresholdExceeded: true }, + classMap: { hierarchy: classHierarchy, dotThresholdExceeded: true }, disharmonies: [ { anchorId: 'GOD', title: 'God Classes', chart: { bubbles: [{ x: 1, y: 1, r: 3 }] } } ] }; await enhanceReport(document.getElementById('app'), data); - expect(window.classGraph_dot).toBe('strict digraph C {}'); + expect(window.classGraph_dot).toBe(lightDot(classHierarchy)); expect(typeof window.showPopup).toBe('function'); expect(typeof window.hidePopup).toBe('function'); expect(typeof window.createForceGraph).toBe('function'); From 72f5de0bc71647cf9192c2dc542cc246ddb13644 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Thu, 22 Oct 2026 06:03:11 -0500 Subject: [PATCH 02/10] - Reverted nested package graph rendering - All DOT graph images now hyperlink to git provider location --- AGENTS.md | 21 ++- components/report-view.jsx | 4 +- lib/hierarchy-dot.js | 91 +++++------ lib/report-view.js | 120 ++++++-------- public/widgets/graphviz-bridge.js | 14 -- tests/fixtures/junit4-report.json | 9 +- tests/integration/report-view.test.jsx | 43 ++--- tests/unit/hierarchy-dot.test.js | 123 +++++++------- tests/unit/report-view.test.js | 211 +++++++++++++++++-------- 9 files changed, 343 insertions(+), 293 deletions(-) delete mode 100644 public/widgets/graphviz-bridge.js diff --git a/AGENTS.md b/AGENTS.md index 4316b32..17b59ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,13 +36,13 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). - Graphs synthesized client-side from the nested hierarchy in refactor-first.json (`lib/hierarchy-dot.js`, byte-identical to the report module's Java `NestedDotBuilder`; packages nest as clusters, classes as - ellipses, break-up packages fill pink). The package graph renders its own - compound DOT shape — parent packages once as clusters, childless packages - once as filled rectangles, edges clipped to cluster borders via - invisible anchors + `ltail`/`lhead` — through - `@hpcc-js/wasm-graphviz@1.7.0` (`public/widgets/graphviz-bridge.js`, - the only engine honoring compound cluster edges); the class and cycle - graphs stay on vizdom + ellipses, break-up packages fill pink). The package map renders the flat + relationship graph — one ellipse per package with only the weighted + edges, break-up packages in red — through vizdom like the class and + cycle graphs; every graph's nodes are hyperlinked to their source + files/directories via `project.repoUrl` (http(s)-gated, new tab + restored post-render since vizdom drops the target); the full + hierarchy stays in refactor-first.json for LLM consumption - Enhanced report tables: sticky headers, pagination (>20 rows), sortable columns, in-table search, CSV export, click/keyboard cell copy with toasts - Repository submission via pre-filled platform issues — no login, apps or tokens @@ -114,9 +114,8 @@ public/ # static files copied verbatim into out/: repositories.txt # synced from the repo root (sync-repositories.mjs) assets/ # mustache template, logo templates/ # workflow-sample-*.html fragments - widgets/ # module bridges: vizdom WASM, wasm-graphviz, - # three-spritetext, sentry (runtime CDN imports - # cannot be bundled) + widgets/ # module bridges: vizdom WASM, three-spritetext, + # sentry (runtime CDN imports cannot be bundled) templates/ # user CI samples (user-refactorfirst-*.yml) for the docs ci/process-submissions.sh # shared submission validator for GH/Gl/BB CI .github/workflows/ # test.yml, static.yml, redeploy.yml, @@ -237,7 +236,7 @@ coverage there when introducing new markup patterns. ## Current Test Count -~733 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). +~736 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and diff --git a/components/report-view.jsx b/components/report-view.jsx index 1a261bd..07c1b5b 100644 --- a/components/report-view.jsx +++ b/components/report-view.jsx @@ -40,11 +40,11 @@ const CLASSIC_WIDGETS = [ // Module bridges (CSP 'script-src self' — vendored files) report readiness // themselves via window.__rfMarkWidgetReady. -const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/graphviz-bridge.js', '/widgets/three-spritetext-bridge.js']; +const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/three-spritetext-bridge.js']; // Widgets that enhanceReport consumes synchronously; all others degrade // gracefully at popup-open time. -const ENHANCE_WIDGETS = ['chart', 'vizdom', 'graphviz']; +const ENHANCE_WIDGETS = ['chart', 'vizdom']; /** * Fetches and renders a repository report with interactive table controls. diff --git a/lib/hierarchy-dot.js b/lib/hierarchy-dot.js index d231951..06e15fc 100644 --- a/lib/hierarchy-dot.js +++ b/lib/hierarchy-dot.js @@ -58,76 +58,75 @@ export function escapeDotQuoted(value) { * dark palette); omitted/empty keeps vizdom's default black. * @returns {string} DOT source for a strict digraph with package clusters. */ +/** Only http(s) URLs may become links: report payloads are untrusted. */ +function httpUrl(url) { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +/** Returns a node's hyperlink target from its source path, or null when unresolvable. */ +function nodeHyperlinkUrl(path, repoUrl) { + if (!path || path === '') return null; + const url = (repoUrl || '') + path; + return httpUrl(url) ? url : null; +} + export function hierarchyToDot(hierarchy, options) { + const repoUrl = options && options.repoUrl ? options.repoUrl : ''; const edgeFontColor = options && typeof options.edgeFontColor === 'string' ? options.edgeFontColor : ''; const parts = ['strict digraph G {']; - appendPackages((hierarchy && hierarchy.packages) || [], edgeFontColor, parts); + appendPackages((hierarchy && hierarchy.packages) || [], repoUrl, edgeFontColor, parts); appendEdges((hierarchy && hierarchy.edges) || [], edgeFontColor, parts); parts.push('}'); return parts.join('\n'); } /** - * Synthesizes the package-map graph's compound DOT: packages with children render - * once as labelled clusters, childless packages render once as filled rectangles, - * and edges attach to cluster-represented packages through invisible anchor nodes - * clipped to the cluster borders via {@code ltail}/{@code lhead} under - * {@code compound=true}. Each package name therefore appears exactly once. The - * package graph renders through @hpcc-js/wasm-graphviz — the only engine that - * honors compound cluster edges (vizdom ignores ltail/lhead). + * Synthesizes the package-map graph's flat DOT: one `shape=ellipse` node per + * package that participates in at least one relationship (or was selected for + * removal to break cycles), and the edges carry only the relationship weights — + * matching the main-branch package map, rendered by vizdom like the class and + * cycle maps. Break-up packages fill red; removal relationships render red. + * The full hierarchy stays in refactor-first.json for LLM consumption; only + * the relationship information renders. * * @param {object|null} hierarchy - `{packages, edges}` from the report JSON; - * missing, null and empty hierarchies render the bare compound skeleton. + * missing, null and empty hierarchies render the bare skeleton. * @param {object} [options] - Optional rendering tweaks. * @param {string} [options.edgeFontColor] - Graphviz color baked into every * edge's fontcolor (used by the dark palette). - * @returns {string} DOT source for a strict compound digraph. + * @returns {string} DOT source for a strict digraph of ellipse package nodes. */ export function packageHierarchyToDot(hierarchy, options) { + const repoUrl = options && options.repoUrl ? options.repoUrl : ''; const edgeFontColor = options && typeof options.edgeFontColor === 'string' ? options.edgeFontColor : ''; const packages = (hierarchy && hierarchy.packages) || []; const edges = (hierarchy && hierarchy.edges) || []; - const referencedIds = new Set(); - const byNodeId = new Map(); - for (const edge of edges) { - referencedIds.add(edge.source); - referencedIds.add(edge.target); - } - const index = pkgs => { + const parts = ['strict digraph G {']; + const appendNodes = pkgs => { for (const pkg of pkgs) { - if (pkg.nodeId) byNodeId.set(pkg.nodeId, pkg); - index(pkg.children || []); - } - }; - index(packages); - - const parts = ['strict digraph G {', 'compound=true;']; - const appendShape = pkg => { - if (pkg.renderAsNode) { - parts.push(`${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=rectangle style=filled fillcolor=${pkg.needsBreakUp ? 'pink' : 'white'}];`); - return; - } - parts.push(`subgraph ${pkg.id} {`); - parts.push(`label="${escapeDotQuoted(pkg.name)}";`); - parts.push(`style=filled fillcolor=${pkg.needsBreakUp ? 'pink' : 'white'};`); - if (pkg.nodeId && referencedIds.has(pkg.nodeId)) { - parts.push(`${pkg.nodeId} [shape=point style=invis];`); + if (pkg.nodeId) { + let line = `${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=ellipse`; + if (pkg.needsBreakUp) line += ' color=red style=filled'; + const url = nodeHyperlinkUrl(pkg.path, repoUrl); + if (url) line += ` URL="${escapeDotQuoted(url)}" target="_blank"`; + parts.push(`${line}];`); + } + appendNodes(pkg.children || []); } - for (const child of pkg.children || []) appendShape(child); - parts.push('}'); }; - for (const pkg of packages) appendShape(pkg); + appendNodes(packages); for (const edge of edges) { let line = `${edge.source} -> ${edge.target} [ label = "${edge.weight}" weight = "${edge.weight}"`; if (edge.markedForRemoval) line += ' color = "red"'; if (edgeFontColor) line += ` fontcolor = "${edgeFontColor}"`; - const source = byNodeId.get(edge.source); - if (source && !source.renderAsNode) line += ` ltail = "${source.id}"`; - const target = byNodeId.get(edge.target); - if (target && !target.renderAsNode) line += ` lhead = "${target.id}"`; parts.push(`${line} ];`); } parts.push('}'); @@ -143,9 +142,9 @@ export function packageHierarchyToDot(hierarchy, options) { * @param {string[]} parts - Output accumulator. * @returns {void} */ -function appendPackages(packages, edgeFontColor, parts) { +function appendPackages(packages, repoUrl, edgeFontColor, parts) { for (const pkg of packages) { - appendPackage(pkg, edgeFontColor, parts); + appendPackage(pkg, repoUrl, edgeFontColor, parts); } } @@ -158,7 +157,7 @@ function appendPackages(packages, edgeFontColor, parts) { * @param {string[]} parts - Output accumulator. * @returns {void} */ -function appendPackage(pkg, edgeFontColor, parts) { +function appendPackage(pkg, repoUrl, edgeFontColor, parts) { parts.push(`subgraph ${pkg.id} {`); parts.push(`label="${escapeDotQuoted(pkg.name)}";`); // Every cluster declares an explicit fill: vizdom propagates a styled @@ -182,9 +181,11 @@ function appendPackage(pkg, edgeFontColor, parts) { if (cls.label !== cls.id) line += ` label="${escapeDotQuoted(cls.label)}"`; line += ' shape=ellipse'; if (cls.markedForRemoval) line += ' color=red style=filled'; + const url = nodeHyperlinkUrl(cls.path, repoUrl); + if (url) line += ` URL="${escapeDotQuoted(url)}" target="_blank"`; parts.push(`${line}];`); } - appendPackages(pkg.children || [], edgeFontColor, parts); + appendPackages(pkg.children || [], repoUrl, edgeFontColor, parts); parts.push('}'); } diff --git a/lib/report-view.js b/lib/report-view.js index 3c32698..d5e9e1b 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -14,15 +14,21 @@ import { hierarchyToDot, packageHierarchyToDot } from './hierarchy-dot.js'; // --------------------------------------------------------------------------- export function exposeGraphDots(data) { + // Hyperlinked nodes for every graph: the repository URL from the project + // metadata flows into each synthesis; the helpers only emit http(s) links. + const synthesisOptions = { + ...graphDotOptions(), + repoUrl: data.project && data.project.repoUrl ? data.project.repoUrl : '' + }; if (data.classMap && data.classMap.hierarchy) { - window.classGraph_dot = hierarchyToDot(data.classMap.hierarchy, graphDotOptions()); + window.classGraph_dot = hierarchyToDot(data.classMap.hierarchy, synthesisOptions); } if (data.packageMap && data.packageMap.hasEdges && data.packageMap.hierarchy) { - window.packageGraph_dot = packageHierarchyToDot(data.packageMap.hierarchy, graphDotOptions()); + window.packageGraph_dot = packageHierarchyToDot(data.packageMap.hierarchy, synthesisOptions); } const cycle = data.classCycles && data.classCycles.largestCycle; if (cycle && cycle.hierarchy) { - window[`${cycle.cycleIdentifier}_dot`] = hierarchyToDot(cycle.hierarchy, graphDotOptions()); + window[`${cycle.cycleIdentifier}_dot`] = hierarchyToDot(cycle.hierarchy, synthesisOptions); } } @@ -94,7 +100,16 @@ function sugiyamaLayout(graph) { function buildGraphologyGraph(dot) { const graphlibGraph = window.graphlibDot.read(dot); const graphologyGraph = new window.graphology.Graph(); + // graphlib-dot creates a node for every named subgraph: in the class map + // that is one cluster node per package, labelled with the package name. + // Packages are noise in the popups, so drop the cluster nodes while + // keeping every node the clusters contained and the edges between them. + const droppedClusters = new Set(); graphlibGraph.nodes().forEach(node => { + if (node.startsWith('cluster_')) { + droppedClusters.add(node); + return; + } const attrs = graphlibGraph.node(node); graphologyGraph.addNode(node, { label: attrs.label || node, @@ -103,6 +118,7 @@ function buildGraphologyGraph(dot) { }); }); graphlibGraph.edges().forEach(edge => { + if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; const attrs = graphlibGraph.edge(edge); graphologyGraph.addEdge(edge.v, edge.w, { color: attrs.color, @@ -305,7 +321,15 @@ export async function createForceGraph(popupId, containerName, dot) { const graphlibGraph = window.graphlibDot.read(dot); const nodes = []; const links = []; + // Packages are noise in the popups: drop the cluster nodes graphlib-dot + // creates per named subgraph, keeping every node they contained and the + // edges between the kept nodes. + const droppedClusters = new Set(); graphlibGraph.nodes().forEach(node => { + if (node.startsWith('cluster_')) { + droppedClusters.add(node); + return; + } nodes.push({ id: node, label: graphlibGraph.node(node).label || node, @@ -315,6 +339,7 @@ export async function createForceGraph(popupId, containerName, dot) { }); }); graphlibGraph.edges().forEach(edge => { + if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; links.push({ source: edge.v, target: edge.w, @@ -869,34 +894,16 @@ export function graphDotOptions() { }; } -// Rendered graph entries keyed by container id, so a theme switch can -// re-synthesize the DOT with the new palette's edge-label color — vizdom and -// wasm-graphviz bake fontcolor into the output at parse time, so recoloring -// needs a re-render, like the charts. The class and cycle graphs render -// through vizdom; the package graph renders through @hpcc-js/wasm-graphviz, -// the only engine that honors the compound ltail/lhead cluster edges, so -// each entry records its engine. +// Rendered graph hierarchies keyed by container id, so a theme switch can +// re-synthesize the DOT with the new palette's edge-label color — vizdom bakes +// fontcolor into the SVG at parse time, so recoloring needs a re-render, like +// the charts. All three graphs (class, package, cycle) render through vizdom; +// the package map renders its flat ellipse DOT via packageHierarchyToDot. +// Rendered graph entries keyed by container id: `{hierarchy, repoUrl}` so a +// theme switch can re-synthesize the DOT with the new palette's edge-label +// color and the same hyperlinked nodes. const wasmGraphHierarchies = new Map(); -// The loaded wasm-graphviz instance, cached against the identity of the -// window.Graphviz widget that produced it (the bridge installs the widget -// once; tests re-stub it, so a stale cache is never reused). -let graphvizCache = null; - -/** - * Loads (or reuses) the wasm-graphviz engine. Returns null when the widget - * is not installed. - * - * @returns {Promise} Loaded Graphviz instance, or null. - */ -async function loadGraphviz() { - if (!window.Graphviz) return null; - if (!graphvizCache || graphvizCache.widget !== window.Graphviz) { - graphvizCache = {widget: window.Graphviz, instance: await window.Graphviz.load()}; - } - return graphvizCache.instance; -} - /** * Re-renders every live graph so edge labels re-resolve against the newly * active palette. Entries whose container left the DOM (report re-rendered @@ -915,11 +922,7 @@ function redrawGraphsForTheme() { continue; } try { - if (entry.engine === 'graphviz') { - renderPackageGraphInto(el, containerId, entry.hierarchy); - } else { - renderGraphInto(parser, el, containerId, entry.hierarchy); - } + renderGraphInto(parser, el, containerId, entry.hierarchy, entry.repoUrl); } catch (error) { console.warn('WASM graph re-render failed:', error); } @@ -930,28 +933,17 @@ function redrawGraphsForTheme() { * Parses a dot string (recolored for the active palette) into the graph * container's innerHTML and re-wires pan/zoom on the freshly injected SVG. */ -function renderGraphInto(parser, el, containerId, hierarchy) { - const dotString = hierarchyToDot(hierarchy, graphDotOptions()); +function renderGraphInto(parser, el, containerId, hierarchy, repoUrl) { + // the package map renders its flat ellipse DOT; the class and cycle maps + // render their nested cluster DOT; every node with a source path is + // hyperlinked through the repository URL (http(s)-gated) + const synthesis = containerId === 'packageGraph' ? packageHierarchyToDot : hierarchyToDot; + const dotString = synthesis(hierarchy, {...graphDotOptions(), repoUrl: repoUrl || ''}); const parsed = parser.parse(dotString).to_directed().layout(); - const svg = parsed.to_svg().to_string() - .replace(' { if (!parser) return; const el = document.getElementById(containerId); if (!el || !hierarchy) return; - renderGraphInto(parser, el, containerId, hierarchy); - wasmGraphHierarchies.set(containerId, {engine: 'vizdom', hierarchy}); + renderGraphInto(parser, el, containerId, hierarchy, repoUrl); + wasmGraphHierarchies.set(containerId, {hierarchy, repoUrl}); }; if (data.classMap && !data.classMap.dotThresholdExceeded) { renderGraph('classGraph', data.classMap.hierarchy); } - // The package graph's compound cluster edges (ltail/lhead) only render - // under @hpcc-js/wasm-graphviz — vizdom ignores them — so it degrades - // away silently when that widget has not settled. if (data.packageMap && !data.packageMap.dotThresholdExceeded && data.packageMap.hasEdges && data.packageMap.hierarchy) { - const el = document.getElementById('packageGraph'); - const graphviz = el ? await loadGraphviz() : null; - if (el && graphviz) { - renderPackageGraphInto(el, 'packageGraph', data.packageMap.hierarchy); - wasmGraphHierarchies.set('packageGraph', { - engine: 'graphviz', - hierarchy: data.packageMap.hierarchy - }); - } + renderGraph('packageGraph', data.packageMap.hierarchy); } const cycle = data.classCycles && data.classCycles.largestCycle; if (cycle && !cycle.dotThresholdExceeded) { diff --git a/public/widgets/graphviz-bridge.js b/public/widgets/graphviz-bridge.js deleted file mode 100644 index c5ff288..0000000 --- a/public/widgets/graphviz-bridge.js +++ /dev/null @@ -1,14 +0,0 @@ -// Bridge: loads the pinned @hpcc-js/wasm-graphviz module from jsDelivr -// (allowed by script-src; the wasm binary is embedded in the module) and -// exposes it as window.Graphviz for the package graph — the only renderer -// honoring the graph's compound ltail/lhead cluster edges. Bundler -// `import()` rewriting cannot load remote URLs, so this runs as a native -//
'); parsed.length = 0; - graphvizRendered.length = 0; mediaListener(); - expect(parsed).toEqual([lightDot(cycleHierarchy)]); - expect(graphvizRendered).toEqual([lightPackageDot(packageHierarchy)]); + expect(parsed).toEqual([lightPackageDot(packageHierarchy), lightDot(cycleHierarchy)]); expect(document.getElementById('classGraph').innerHTML).toBe(''); }); @@ -924,7 +904,13 @@ describe('initWasmGraphs', () => { return { to_directed() { return this; }, layout() { return this; }, - to_svg() { return { to_string: () => '' }; } + to_svg() { + return { + // mirrors real vizdom: hyperlinked nodes wrap in without a target + to_string: () => + '' + }; + } }; } } @@ -932,22 +918,7 @@ describe('initWasmGraphs', () => { return captured; } - // Minimal wasm-graphviz stub: records the rendered package DOT and yields a - // fixed SVG with an xml/doctype preamble like the real engine. - function stubGraphviz() { - const rendered = []; - window.Graphviz = { - load: async () => ({ - dot: dotString => { - rendered.push(dotString); - return '\n\n'; - } - }) - }; - return rendered; - } - - afterEach(() => { delete window.Vizdom; delete window.Graphviz; delete window.matchMedia; }); + afterEach(() => { delete window.Vizdom; delete window.matchMedia; }); it('resolves gracefully when the WASM loader is unavailable', async () => { document.body.innerHTML = '
'; @@ -955,39 +926,54 @@ describe('initWasmGraphs', () => { await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); }); - it('renders the package graph through wasm-graphviz while the light palette is active', async () => { + it('renders the package graph through vizdom with its flat ellipse DOT', async () => { installThemeRadios('light', false); const graph = document.createElement('div'); graph.id = 'packageGraph'; document.body.appendChild(graph); - const vizdomCaptured = stubVizdom(); - const graphvizRendered = stubGraphviz(); + const captured = stubVizdom(); await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); - expect(graphvizRendered).toEqual([lightPackageDot(packageHierarchy)]); - expect(vizdomCaptured).toEqual([], "the package graph must not render through vizdom"); + expect(captured).toEqual([lightPackageDot(packageHierarchy)]); + expect(captured[0]).toContain('shape=ellipse'); expect(document.querySelector('#packageGraph svg').classList.contains('fullscreen-svg')).toBe(true); }); - it('renders the package graph with dark edge labels while the dark palette is active', async () => { - installThemeRadios('dark', false); - const graph = document.createElement('div'); - graph.id = 'packageGraph'; - document.body.appendChild(graph); - const graphvizRendered = stubGraphviz(); - await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); - expect(graphvizRendered).toEqual([darkPackageDot(packageHierarchy)]); - expect(graphvizRendered[0]).toContain('fontcolor = "#9fb0c0"'); + it('hyperlinks rendered class and package nodes when the report carries a repository URL', async () => { + installThemeRadios('light', false); + const classGraph = document.createElement('div'); + classGraph.id = 'classGraph'; + const packageGraph = document.createElement('div'); + packageGraph.id = 'packageGraph'; + document.body.append(classGraph, packageGraph); + const captured = stubVizdom(); + const withPaths = JSON.parse(JSON.stringify(classHierarchy)); + withPaths.packages[0].classes[0].path = 'src/main/java/c/A.java'; + const packageWithPaths = JSON.parse(JSON.stringify(packageHierarchy)); + packageWithPaths.packages[0].children[0].path = 'src/main/java/p/p1/'; + await initWasmGraphs({ + project: { repoUrl: 'https://github.com/x/y/blob/main/' }, + classMap: { hierarchy: withPaths, dotThresholdExceeded: false }, + packageMap: { hierarchy: packageWithPaths, dotThresholdExceeded: false, hasEdges: true } + }); + const classDot = captured[0]; + expect(classDot).toContain( + 'A [ shape=ellipse URL="https://github.com/x/y/blob/main/src/main/java/c/A.java" target="_blank"]'); + const packageDot = captured[1]; + expect(packageDot).toContain( + 'package_p1 [label="p.p1" shape=ellipse URL="https://github.com/x/y/blob/main/src/main/java/p/p1/" target="_blank"]'); + // and the inline render restores the anchor target vizdom drops + expect(classGraph.innerHTML).toContain('
{ - installThemeRadios('light', false); + it('renders the package graph with dark edge labels while the dark palette is active', async () => { + installThemeRadios('dark', false); const graph = document.createElement('div'); graph.id = 'packageGraph'; document.body.appendChild(graph); - const vizdomCaptured = stubVizdom(); + const captured = stubVizdom(); await initWasmGraphs({ packageMap: { hierarchy: packageHierarchy, dotThresholdExceeded: false, hasEdges: true } }); - expect(document.getElementById('packageGraph').innerHTML).toBe(''); - expect(vizdomCaptured).toEqual([]); + expect(captured).toEqual([darkPackageDot(packageHierarchy)]); + expect(captured[0]).toContain('fontcolor = "#9fb0c0"'); }); it('skips graphs whose dot threshold was exceeded', async () => { @@ -1015,7 +1001,8 @@ describe('initWasmGraphs', () => { await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); expect(captured).toEqual([darkDot(classHierarchy)]); expect(captured[0]).toContain('fontcolor = "#9fb0c0"'); - expect(graph.innerHTML).toBe(''); + expect(graph.innerHTML).toContain(''); }); it('re-synthesizes graphs with fresh label colors when a theme radio changes', async () => { @@ -1093,6 +1080,98 @@ describe('popup helpers', () => { }); }); +describe('popup cluster filtering (packages are noise)', () => { + beforeEach(() => { + document.body.innerHTML = ` + + `; + }); + + afterEach(() => { + delete window.graphlibDot; + delete window.graphology; + delete window.Sigma; + delete window.ForceGraph3D; + delete window.SpriteText; + }); + + // graphlib-dot creates a node for every named subgraph: in the class map + // that is one cluster node per package, labelled with the package name. + // The popups must drop those cluster nodes while keeping every class node + // the clusters contained and the class-to-class edges. + function stubGraphlibWithCluster() { + window.graphlibDot = { + read: () => ({ + nodes: () => ['cluster_abc123', 'Alpha', 'Beta'], + node(id) { + return id === 'cluster_abc123' ? {label: 'org.hjug.cbc'} : {label: id}; + }, + edges: () => [{v: 'Alpha', w: 'Beta'}], + edge: () => ({color: 'red', weight: 2}) + }) + }; + } + + it('drops cluster nodes from the 2D sugiyama popup while keeping contained nodes', () => { + stubGraphlibWithCluster(); + const addedNodes = []; + const addedEdges = []; + window.graphology = { + Graph: class { + addNode(id, attrs) { addedNodes.push({id, attrs}); } + addEdge(v, w, attrs) { addedEdges.push({v, w, attrs}); } + nodes() { return []; } + forEachNeighbor() {} + setNodeAttribute() {} + } + }; + const sigmaGraphs = []; + window.Sigma = function (graph) { + sigmaGraphs.push(graph); + return {on() {}, setSetting() {}, scheduleRefresh() {}}; + }; + + showPopup('popup-classGraph', 'graph-container-classGraph', 'ignored-by-stub'); + + expect(addedNodes.map(n => n.id)).toEqual(['Alpha', 'Beta']); + expect(addedEdges).toEqual([{v: 'Alpha', w: 'Beta', attrs: {color: 'red', size: 1, type: 'arrow'}}]); + expect(sigmaGraphs).toHaveLength(1); + }); + + it('drops cluster nodes from the 3D force-graph popup while keeping contained nodes', async () => { + stubGraphlibWithCluster(); + window.SpriteText = function () { + return {}; + }; + const capturedData = []; + const chain = {}; + for (const method of [ + 'graphData', 'nodeLabel', 'width', 'height', 'nodeThreeObject', 'nodeColor', + 'linkWidth', 'linkDirectionalParticles', 'linkDirectionalParticleWidth', + 'onNodeHover', 'onLinkHover' + ]) { + chain[method] = function (arg) { + if (method === 'graphData') capturedData.push(arg); + return this; + }; + } + window.ForceGraph3D = function () { + return chain; + }; + + await createForceGraph('popup-classGraph', 'graph-container-classGraph', 'ignored-by-stub'); + + expect(capturedData).toHaveLength(1); + expect(capturedData[0].nodes.map(n => n.id)).toEqual(['Alpha', 'Beta']); + expect(capturedData[0].nodes.map(n => n.label)).toEqual(['Alpha', 'Beta']); + expect(capturedData[0].links).toEqual([ + {source: 'Alpha', target: 'Beta', color: 'red', weight: 2} + ]); + }); +}); + describe('showPopup sigma theme settings', () => { beforeEach(() => { document.body.innerHTML = ` From 0a1f5188aa62b79c610a3710239cd7b03b9ec785 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Thu, 22 Oct 2026 20:29:51 -0500 Subject: [PATCH 03/10] Apply suggested CodeRabbit fixes --- lib/report-view.js | 50 +++++++++++----- tests/unit/report-view.test.js | 106 ++++++++++++++++++++++++++++----- 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/lib/report-view.js b/lib/report-view.js index d5e9e1b..6e5984a 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -22,6 +22,16 @@ export function exposeGraphDots(data) { }; if (data.classMap && data.classMap.hierarchy) { window.classGraph_dot = hierarchyToDot(data.classMap.hierarchy, synthesisOptions); + } else if (data.classMap && data.classMap.dot) { + // Older RefactorFirst reports ship only the raw classMap.dot string, + // with no hierarchy to synthesize from: the popup path parses DOT + // directly, so expose the report's own DOT verbatim. + window.classGraph_dot = data.classMap.dot; + } else { + // This report carries no class graph data: drop whatever a previously + // rendered report left on window so its graph can never be reused + // for this report's popup. + delete window.classGraph_dot; } if (data.packageMap && data.packageMap.hasEdges && data.packageMap.hierarchy) { window.packageGraph_dot = packageHierarchyToDot(data.packageMap.hierarchy, synthesisOptions); @@ -100,14 +110,18 @@ function sugiyamaLayout(graph) { function buildGraphologyGraph(dot) { const graphlibGraph = window.graphlibDot.read(dot); const graphologyGraph = new window.graphology.Graph(); - // graphlib-dot creates a node for every named subgraph: in the class map - // that is one cluster node per package, labelled with the package name. - // Packages are noise in the popups, so drop the cluster nodes while - // keeping every node the clusters contained and the edges between them. - const droppedClusters = new Set(); + // graphlib-dot parses subgraphs as compound parents: every named + // subgraph becomes a node with the contained nodes parented to it (and + // empty subgraphs are pruned at parse time), so only actual subgraph + // ids ever have children. Packages are noise in the popups, so drop the + // subgraph nodes — identified by that parsed metadata, not by the ID + // prefix, so an ordinary node named cluster_worker survives with its + // edges — while keeping every node the subgraphs contained and the + // edges between them. + const droppedSubgraphs = new Set(); graphlibGraph.nodes().forEach(node => { - if (node.startsWith('cluster_')) { - droppedClusters.add(node); + if (graphlibGraph.children(node).length > 0) { + droppedSubgraphs.add(node); return; } const attrs = graphlibGraph.node(node); @@ -118,7 +132,7 @@ function buildGraphologyGraph(dot) { }); }); graphlibGraph.edges().forEach(edge => { - if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; + if (droppedSubgraphs.has(edge.v) || droppedSubgraphs.has(edge.w)) return; const attrs = graphlibGraph.edge(edge); graphologyGraph.addEdge(edge.v, edge.w, { color: attrs.color, @@ -321,13 +335,16 @@ export async function createForceGraph(popupId, containerName, dot) { const graphlibGraph = window.graphlibDot.read(dot); const nodes = []; const links = []; - // Packages are noise in the popups: drop the cluster nodes graphlib-dot + // Packages are noise in the popups: drop the subgraph nodes graphlib-dot // creates per named subgraph, keeping every node they contained and the - // edges between the kept nodes. - const droppedClusters = new Set(); + // edges between the kept nodes. Subgraphs are identified by parsed + // metadata — a node is a subgraph iff contained nodes are parented to + // it — not by the ID prefix, so an ordinary node named cluster_worker + // survives with its edges. + const droppedSubgraphs = new Set(); graphlibGraph.nodes().forEach(node => { - if (node.startsWith('cluster_')) { - droppedClusters.add(node); + if (graphlibGraph.children(node).length > 0) { + droppedSubgraphs.add(node); return; } nodes.push({ @@ -339,7 +356,7 @@ export async function createForceGraph(popupId, containerName, dot) { }); }); graphlibGraph.edges().forEach(edge => { - if (droppedClusters.has(edge.v) || droppedClusters.has(edge.w)) return; + if (droppedSubgraphs.has(edge.v) || droppedSubgraphs.has(edge.w)) return; links.push({ source: edge.v, target: edge.w, @@ -941,8 +958,9 @@ function renderGraphInto(parser, el, containerId, hierarchy, repoUrl) { const dotString = synthesis(hierarchy, {...graphDotOptions(), repoUrl: repoUrl || ''}); const parsed = parser.parse(dotString).to_directed().layout(); let svg = parsed.to_svg().to_string(); - // vizdom drops the target attribute on hyperlinked nodes; restore new-tab behavior - svg = svg.replace(' { expect(window.classGraph_dot).toBeUndefined(); }); + it('falls back to the report-provided classMap.dot when no hierarchy exists', () => { + installThemeRadios('light'); + delete window.classGraph_dot; + // Older RefactorFirst reports carry only the raw classMap.dot string + // (no hierarchy to synthesize from); the popups parse it directly. + const legacyDot = 'strict digraph legacy { A; B; A -> B; }'; + exposeGraphDots({ classMap: { dot: legacyDot } }); + expect(window.classGraph_dot).toBe(legacyDot); + }); + + it('prefers the synthesized hierarchy DOT over a legacy classMap.dot', () => { + installThemeRadios('light'); + delete window.classGraph_dot; + exposeGraphDots({ classMap: { hierarchy: classHierarchy, dot: 'strict digraph legacy { L; }' } }); + expect(window.classGraph_dot).toBe(lightDot(classHierarchy)); + }); + + it('clears a previous report’s class graph DOT when the current report has none', () => { + installThemeRadios('light'); + // A report with a class map leaves its synthesized DOT on window... + exposeGraphDots({ classMap: { hierarchy: classHierarchy } }); + expect(window.classGraph_dot).toBe(lightDot(classHierarchy)); + // ...so the next report without class graph data must drop it, or its + // popup would silently render the previous report's graph. + exposeGraphDots({}); + expect(window.classGraph_dot).toBeUndefined(); + }); + it('skips the package map popup DOT when the report has no package edges', () => { installThemeRadios('light'); delete window.packageGraph_dot; @@ -906,9 +934,11 @@ describe('initWasmGraphs', () => { layout() { return this; }, to_svg() { return { - // mirrors real vizdom: hyperlinked nodes wrap in without a target + // mirrors real vizdom: every hyperlinked node wraps in + // without a target — graphs carry several to_string: () => - '' + '' + + '' }; } }; @@ -1032,6 +1062,39 @@ describe('initWasmGraphs', () => { expect(captured[captured.length - 1]).toBe(lightDot(classHierarchy)); }); + it('restores target="_blank" on every encoded anchor, including after a theme redraw', async () => { + installThemeRadios('light', false); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + stubVizdom(); + const withPaths = JSON.parse(JSON.stringify(classHierarchy)); + withPaths.packages[0].classes[0].path = 'src/main/java/c/A.java'; + withPaths.packages[0].classes[1].path = 'src/main/java/c/B.java'; + await initWasmGraphs({ + project: { repoUrl: 'https://github.com/x/y/blob/main/' }, + classMap: { hierarchy: withPaths, dotThresholdExceeded: false } + }); + const assertAllAnchorsTargetBlank = () => { + const anchors = graph.querySelectorAll('a'); + expect(anchors.length).toBe(2); + for (const anchor of anchors) { + expect(anchor.getAttribute('target')).toBe('_blank'); + } + }; + // initial render: both encoded anchors get the target vizdom drops + assertAllAnchorsTargetBlank(); + + // theme redraw re-renders through the same path: both must keep it + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 0)); + assertAllAnchorsTargetBlank(); + }); + it('prunes graphs whose container left the DOM on theme change', async () => { installThemeRadios('light', false); document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); @@ -1097,24 +1160,27 @@ describe('popup cluster filtering (packages are noise)', () => { delete window.SpriteText; }); - // graphlib-dot creates a node for every named subgraph: in the class map - // that is one cluster node per package, labelled with the package name. - // The popups must drop those cluster nodes while keeping every class node - // the clusters contained and the class-to-class edges. + // graphlib-dot parses subgraphs as compound parents: every named subgraph + // becomes a node with the contained nodes parented to it (and empty + // subgraphs are pruned at parse time), so only actual subgraph ids ever + // have children. The popups must drop those subgraph nodes while keeping + // every node they contained and the edges between them — including + // ordinary nodes whose ID merely starts with cluster_ (cluster_worker). function stubGraphlibWithCluster() { window.graphlibDot = { read: () => ({ - nodes: () => ['cluster_abc123', 'Alpha', 'Beta'], + nodes: () => ['cluster_abc123', 'Alpha', 'Beta', 'cluster_worker'], node(id) { return id === 'cluster_abc123' ? {label: 'org.hjug.cbc'} : {label: id}; }, - edges: () => [{v: 'Alpha', w: 'Beta'}], - edge: () => ({color: 'red', weight: 2}) + children: id => (id === 'cluster_abc123' ? ['Alpha', 'Beta'] : []), + edges: () => [{v: 'Alpha', w: 'Beta'}, {v: 'cluster_worker', w: 'Beta'}], + edge: e => (e.v === 'cluster_worker' ? {color: 'blue', weight: 7} : {color: 'red', weight: 2}) }) }; } - it('drops cluster nodes from the 2D sugiyama popup while keeping contained nodes', () => { + it('drops parsed subgraphs from the 2D sugiyama popup while keeping ordinary nodes', () => { stubGraphlibWithCluster(); const addedNodes = []; const addedEdges = []; @@ -1135,12 +1201,17 @@ describe('popup cluster filtering (packages are noise)', () => { showPopup('popup-classGraph', 'graph-container-classGraph', 'ignored-by-stub'); - expect(addedNodes.map(n => n.id)).toEqual(['Alpha', 'Beta']); - expect(addedEdges).toEqual([{v: 'Alpha', w: 'Beta', attrs: {color: 'red', size: 1, type: 'arrow'}}]); + // the actual subgraph is dropped; the cluster_-prefixed ordinary node + // and its edge are kept + expect(addedNodes.map(n => n.id)).toEqual(['Alpha', 'Beta', 'cluster_worker']); + expect(addedEdges).toEqual([ + {v: 'Alpha', w: 'Beta', attrs: {color: 'red', size: 1, type: 'arrow'}}, + {v: 'cluster_worker', w: 'Beta', attrs: {color: 'blue', size: 1, type: 'arrow'}} + ]); expect(sigmaGraphs).toHaveLength(1); }); - it('drops cluster nodes from the 3D force-graph popup while keeping contained nodes', async () => { + it('drops parsed subgraphs from the 3D force-graph popup while keeping ordinary nodes', async () => { stubGraphlibWithCluster(); window.SpriteText = function () { return {}; @@ -1163,11 +1234,14 @@ describe('popup cluster filtering (packages are noise)', () => { await createForceGraph('popup-classGraph', 'graph-container-classGraph', 'ignored-by-stub'); + // the actual subgraph is dropped; the cluster_-prefixed ordinary node + // and its edge are kept expect(capturedData).toHaveLength(1); - expect(capturedData[0].nodes.map(n => n.id)).toEqual(['Alpha', 'Beta']); - expect(capturedData[0].nodes.map(n => n.label)).toEqual(['Alpha', 'Beta']); + expect(capturedData[0].nodes.map(n => n.id)).toEqual(['Alpha', 'Beta', 'cluster_worker']); + expect(capturedData[0].nodes.map(n => n.label)).toEqual(['Alpha', 'Beta', 'cluster_worker']); expect(capturedData[0].links).toEqual([ - {source: 'Alpha', target: 'Beta', color: 'red', weight: 2} + {source: 'Alpha', target: 'Beta', color: 'red', weight: 2}, + {source: 'cluster_worker', target: 'Beta', color: 'blue', weight: 7} ]); }); }); From e0204caf09461a7b9b756afea3bbd7806b090211 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Fri, 23 Oct 2026 07:38:06 -0500 Subject: [PATCH 04/10] Apply suggested CodeRabbit fixes --- lib/hierarchy-dot.js | 87 ++++++++++-- tests/unit/hierarchy-dot.test.js | 225 +++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 14 deletions(-) diff --git a/lib/hierarchy-dot.js b/lib/hierarchy-dot.js index 06e15fc..a355b68 100644 --- a/lib/hierarchy-dot.js +++ b/lib/hierarchy-dot.js @@ -28,6 +28,19 @@ // exists (vizdom bakes fontcolor into fill-only glyph paths at parse time, // unreachable by CSS; the dark palette re-synthesizes with // fontcolor = CHART_LEGEND_TEXT.dark). +// +// Identifier contract: cluster ids, node ids, edge endpoints and weights are +// interpolated UNQUOTED — re-quoting them would break the byte-identical +// lockstep with the Java builder — so every raw identifier is first validated +// against the NestedDotBuilder producer contract: the Java producer +// (HierarchyBuilder + HtmlReport.renderSafeNodeId) only ever emits bare DOT +// tokens — letters, digits and underscores, like `cluster_`, +// `package_` or class ids with `.`, `$`, `<`, `>` mapped away — and int +// weights. Anything else (report payloads are untrusted) is dropped rather +// than emitted: a quote, bracket, brace or space in a raw id could break out +// of the token and inject DOT statements. Labels and URLs are different: +// they render inside quoted strings and keep their own escaping +// (escapeDotQuoted) and http(s) gate. /** * Escapes a value for a DOT double-quoted string: backslashes, quotes and @@ -48,7 +61,34 @@ export function escapeDotQuoted(value) { } /** - * Synthesizes the nested DOT graph for one hierarchy payload. + * A producer-shaped bare DOT identifier: the NestedDotBuilder producer contract + * yields only letters, digits and underscores (Java identifiers with the + * unsafe characters mapped away, plus the `cluster_` / `package_` + * digests), so anything else cannot be interpolated raw without risking DOT + * injection. Unicode letters/digits stay in — Java identifiers allow them and + * the DOT grammar's bare-ID character set covers the UTF-8 bytes. + */ +const DOT_ID = /^[\p{L}\p{Nd}_]+$/u; + +/** + * Whether the value satisfies the producer contract for a raw-interpolated + * DOT identifier (see DOT_ID): a non-empty string of letters, digits and + * underscores. Labels and URLs do NOT go through this gate — they are quoted + * strings and keep their own escaping. + * + * @param {*} value - Candidate id from the (untrusted) report payload. + * @returns {boolean} True when the id may be emitted as a bare DOT token. + */ +function isDotId(value) { + return typeof value === 'string' && DOT_ID.test(value); +} + +/** + * Synthesizes the nested DOT graph for one hierarchy payload. Untrusted-payload + * gate: cluster ids, node ids, edge endpoints and weights are validated against + * the producer contract (isDotId / appendEdges) and out-of-contract entries are + * dropped rather than emitted; labels and URLs keep their own escaping and + * http(s) gate. * * @param {object|null} hierarchy - `{packages, edges}` from the report JSON; * missing, null and empty hierarchies render the bare digraph skeleton. @@ -93,7 +133,10 @@ export function hierarchyToDot(hierarchy, options) { * matching the main-branch package map, rendered by vizdom like the class and * cycle maps. Break-up packages fill red; removal relationships render red. * The full hierarchy stays in refactor-first.json for LLM consumption; only - * the relationship information renders. + * the relationship information renders. Untrusted-payload gate: node ids and + * edges are validated against the producer contract (isDotId / appendEdges) + * and out-of-contract entries are dropped rather than emitted; labels and + * URLs keep their own escaping and http(s) gate. * * @param {object|null} hierarchy - `{packages, edges}` from the report JSON; * missing, null and empty hierarchies render the bare skeleton. @@ -112,23 +155,18 @@ export function packageHierarchyToDot(hierarchy, options) { const parts = ['strict digraph G {']; const appendNodes = pkgs => { for (const pkg of pkgs) { - if (pkg.nodeId) { + if (isDotId(pkg && pkg.nodeId)) { let line = `${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=ellipse`; if (pkg.needsBreakUp) line += ' color=red style=filled'; const url = nodeHyperlinkUrl(pkg.path, repoUrl); if (url) line += ` URL="${escapeDotQuoted(url)}" target="_blank"`; parts.push(`${line}];`); } - appendNodes(pkg.children || []); + appendNodes((pkg && pkg.children) || []); } }; appendNodes(packages); - for (const edge of edges) { - let line = `${edge.source} -> ${edge.target} [ label = "${edge.weight}" weight = "${edge.weight}"`; - if (edge.markedForRemoval) line += ' color = "red"'; - if (edgeFontColor) line += ` fontcolor = "${edgeFontColor}"`; - parts.push(`${line} ];`); - } + appendEdges(edges, edgeFontColor, parts); parts.push('}'); return parts.join('\n'); } @@ -136,6 +174,8 @@ export function packageHierarchyToDot(hierarchy, options) { /** * Appends one package cluster block per entry: label, breakup style, its * rectangle node (package map), its class nodes, then its child packages. + * Packages without a producer-contract cluster id are dropped, subtree + * included (see appendPackage). * * @param {Array} packages - Package nodes in render order. * @param {string} edgeFontColor - See hierarchyToDot options. @@ -149,7 +189,11 @@ function appendPackages(packages, repoUrl, edgeFontColor, parts) { } /** - * Appends one package cluster block (see appendPackages). + * Appends one package cluster block (see appendPackages). Untrusted-payload + * gate: a package without a producer-contract cluster id cannot be named in + * the raw `subgraph` header, so the whole block — children and classes + * included — is dropped; rectangle nodes and class nodes whose ids fail the + * same gate drop their own line only. * * @param {object} pkg - Package node: name, id, nodeId, renderAsNode, * needsBreakUp, children, classes. @@ -158,6 +202,7 @@ function appendPackages(packages, repoUrl, edgeFontColor, parts) { * @returns {void} */ function appendPackage(pkg, repoUrl, edgeFontColor, parts) { + if (!isDotId(pkg && pkg.id)) return; parts.push(`subgraph ${pkg.id} {`); parts.push(`label="${escapeDotQuoted(pkg.name)}";`); // Every cluster declares an explicit fill: vizdom propagates a styled @@ -171,12 +216,13 @@ function appendPackage(pkg, repoUrl, edgeFontColor, parts) { } else { parts.push('style=filled fillcolor=white;'); } - if (pkg.renderAsNode) { + if (pkg.renderAsNode && isDotId(pkg.nodeId)) { let line = `${pkg.nodeId} [label="${escapeDotQuoted(pkg.name)}" shape=rectangle`; if (pkg.needsBreakUp) line += ' color=pink style=filled'; parts.push(`${line}];`); } for (const cls of pkg.classes || []) { + if (!isDotId(cls && cls.id)) continue; let line = `${cls.id} [`; if (cls.label !== cls.id) line += ` label="${escapeDotQuoted(cls.label)}"`; line += ' shape=ellipse'; @@ -190,8 +236,13 @@ function appendPackage(pkg, repoUrl, edgeFontColor, parts) { } /** - * Appends the graph's edges after all clusters, keeping the previous - * emitters' exact attribute format and highlighting removals in red. + * Appends the graph's edges after all nodes, keeping the previous emitters' + * exact attribute format and highlighting removals in red. Shared by both + * graphs (the nested class/cycle maps and the flat package map). Each edge is + * validated against the NestedDotBuilder producer contract before emission: + * both endpoints must be bare DOT ids (isDotId) and the weight a finite + * number — out-of-contract edges are dropped rather than emitted raw, where a + * crafted endpoint or weight could break out of the statement and inject DOT. * * @param {Array} edges - Edge DTOs: source, target, weight, * markedForRemoval. @@ -201,6 +252,14 @@ function appendPackage(pkg, repoUrl, edgeFontColor, parts) { */ function appendEdges(edges, edgeFontColor, parts) { for (const edge of edges) { + if ( + !edge || + !isDotId(edge.source) || + !isDotId(edge.target) || + !Number.isFinite(edge.weight) + ) { + continue; + } let line = `${edge.source} -> ${edge.target} [ label = "${edge.weight}" weight = "${edge.weight}"`; if (edge.markedForRemoval) line += ' color = "red"'; if (edgeFontColor) line += ` fontcolor = "${edgeFontColor}"`; diff --git a/tests/unit/hierarchy-dot.test.js b/tests/unit/hierarchy-dot.test.js index 9642ed8..d61023a 100644 --- a/tests/unit/hierarchy-dot.test.js +++ b/tests/unit/hierarchy-dot.test.js @@ -335,6 +335,231 @@ describe('packageHierarchyToDot (flat ellipse nodes + relationship edges)', () = }); }); +describe('untrusted payload validation (NestedDotBuilder producer contract)', () => { + // Identifiers and weights are interpolated raw — a bare DOT token cannot be + // re-quoted without breaking the byte-identical lockstep with the Java + // builder — so both graphs gate them against the producer contract before + // emission: ids are letters/digits/underscores (`cluster_`, + // `package_`, or class ids with `.`, `$`, `<`, `>` mapped away) and + // weights finite numbers. Out-of-contract entries are dropped, never + // emitted raw; labels and URLs keep their own escaping and http(s) gate. + + it('drops edges whose endpoints are not producer-shaped DOT ids (hierarchyToDot)', () => { + const hierarchy = { + packages: [ + packageNode('(default package)', 'cluster_default', { + classes: [classNode('A', 'A', 'A', '', false), classNode('B', 'B', 'B', '', false)] + }) + ], + edges: [ + { source: 'A -> B [ label = "injected" ]; evil', target: 'B', weight: 1, markedForRemoval: false }, + { source: 'A', target: 'B; evil [ label = "boom"', weight: 1, markedForRemoval: false }, + { source: 'A B', target: 'B', weight: 1, markedForRemoval: false }, + { source: 'A"B', target: 'B', weight: 1, markedForRemoval: false }, + { source: 'A.B', target: 'B', weight: 1, markedForRemoval: false }, + { source: 'A$B', target: 'B', weight: 1, markedForRemoval: false }, + { source: 'cluster_default {', target: 'B', weight: 1, markedForRemoval: false }, + { source: '', target: 'B', weight: 1, markedForRemoval: false }, + { source: null, target: 'B', weight: 1, markedForRemoval: false }, + { source: 9, target: 'B', weight: 1, markedForRemoval: false }, + { source: {}, target: 'B', weight: 1, markedForRemoval: false }, + null, + { source: 'A', target: 'B', weight: 2, markedForRemoval: true } + ] + }; + + expect(hierarchyToDot(hierarchy)).toBe( + 'strict digraph G {\n' + + 'subgraph cluster_default {\n' + + 'label="(default package)";\n' + + 'style=filled fillcolor=white;\n' + + 'A [ shape=ellipse];\n' + + 'B [ shape=ellipse];\n' + + '}\n' + + 'A -> B [ label = "2" weight = "2" color = "red" ];\n' + + '}' + ); + }); + + it('drops edges whose endpoints are not producer-shaped DOT ids (packageHierarchyToDot)', () => { + const hierarchy = { + packages: [ + packageNode('org.hjug', 'cluster_hjug', { nodeId: 'package_hjug' }), + packageNode('org.hjug.dsm', 'cluster_dsm', { nodeId: 'package_dsm' }) + ], + edges: [ + { source: 'package_hjug" -> package_dsm [ label = "evil" ]; ', target: 'package_dsm', weight: 9, markedForRemoval: false }, + { source: 'package_hjug', target: 'package dsm', weight: 9, markedForRemoval: false }, + { source: 'package_hjug', target: 12, weight: 9, markedForRemoval: false }, + { source: 'package_hjug', target: 'package_dsm', weight: 4, markedForRemoval: true } + ] + }; + + expect(packageHierarchyToDot(hierarchy, { edgeFontColor: '#9fb0c0' })).toBe( + 'strict digraph G {\n' + + 'package_hjug [label="org.hjug" shape=ellipse];\n' + + 'package_dsm [label="org.hjug.dsm" shape=ellipse];\n' + + 'package_hjug -> package_dsm [ label = "4" weight = "4" color = "red" fontcolor = "#9fb0c0" ];\n' + + '}' + ); + }); + + it('rejects non-finite weights before emission in both graphs', () => { + const badWeights = [Number.NaN, Infinity, -Infinity, '3', null, undefined]; + for (const weight of badWeights) { + const nested = hierarchyToDot({ + packages: [ + packageNode('(default package)', 'cluster_default', { + classes: [classNode('A', 'A', 'A', '', false), classNode('B', 'B', 'B', '', false)] + }) + ], + edges: [{ source: 'A', target: 'B', weight, markedForRemoval: false }] + }); + expect(nested).not.toContain('->'); + + const flat = packageHierarchyToDot({ + packages: [packageNode('p', 'cluster_p', { nodeId: 'package_p' })], + edges: [{ source: 'package_p', target: 'package_q', weight, markedForRemoval: false }] + }); + expect(flat).not.toContain('->'); + } + + // Finite weights keep rendering, zero included + expect(hierarchyToDot({ + packages: [ + packageNode('(default package)', 'cluster_default', { + classes: [classNode('A', 'A', 'A', '', false), classNode('B', 'B', 'B', '', false)] + }) + ], + edges: [{ source: 'A', target: 'B', weight: 0, markedForRemoval: false }] + })).toContain('A -> B [ label = "0" weight = "0" ];'); + expect(packageHierarchyToDot({ + packages: [packageNode('p', 'cluster_p', { nodeId: 'package_p' })], + edges: [{ source: 'package_p', target: 'package_q', weight: 2.5, markedForRemoval: false }] + })).toContain('package_p -> package_q [ label = "2.5" weight = "2.5" ];'); + }); + + it('drops packages whose cluster id is not a producer-shaped DOT id, subtree and all', () => { + const hierarchy = { + packages: [ + null, + packageNode('evil', 'cluster_evil { injected', { + classes: [classNode('A', 'A', 'A', '', false)], + children: [packageNode('evil.child', 'child cluster"')] + }), + packageNode('numeric', 42), + packageNode('org', 'cluster_org', { classes: [classNode('B', 'B', 'B', '', false)] }) + ], + edges: [{ source: 'A', target: 'B', weight: 1, markedForRemoval: false }] + }; + + // The dropped packages' classes disappear with them; the edge survives on + // its own valid ids (DOT creates implicit nodes for undefined endpoints). + expect(hierarchyToDot(hierarchy)).toBe( + 'strict digraph G {\n' + + 'subgraph cluster_org {\n' + + 'label="org";\n' + + 'style=filled fillcolor=white;\n' + + 'B [ shape=ellipse];\n' + + '}\n' + + 'A -> B [ label = "1" weight = "1" ];\n' + + '}' + ); + }); + + it('drops rectangle package nodes with invalid node ids but keeps their clusters', () => { + const hierarchy = { + packages: [ + packageNode('bad', 'cluster_bad', { renderAsNode: true, nodeId: 'bad node id' }), + packageNode('good', 'cluster_good', { renderAsNode: true, nodeId: 'package_good' }) + ], + edges: [] + }; + + expect(hierarchyToDot(hierarchy)).toBe( + 'strict digraph G {\n' + + 'subgraph cluster_bad {\n' + + 'label="bad";\n' + + 'style=filled fillcolor=white;\n' + + '}\n' + + 'subgraph cluster_good {\n' + + 'label="good";\n' + + 'style=filled fillcolor=white;\n' + + 'package_good [label="good" shape=rectangle];\n' + + '}\n' + + '}' + ); + }); + + it('drops class nodes with invalid ids but keeps valid siblings', () => { + const hierarchy = { + packages: [ + packageNode('org', 'cluster_org', { + classes: [ + null, + classNode('org.Evil', 'Evil" [ shape=box ]', 'Evil', '', false), + classNode('org.Good', 'Good', 'Good', '', false) + ] + }) + ], + edges: [] + }; + + expect(hierarchyToDot(hierarchy)).toBe( + 'strict digraph G {\n' + + 'subgraph cluster_org {\n' + + 'label="org";\n' + + 'style=filled fillcolor=white;\n' + + 'Good [ shape=ellipse];\n' + + '}\n' + + '}' + ); + }); + + it('drops flat package nodes with invalid node ids (packageHierarchyToDot)', () => { + const hierarchy = { + packages: [ + null, + packageNode('bad', 'cluster_bad', { nodeId: 'bad node id' }), + packageNode('good', 'cluster_good', { nodeId: 'package_good', needsBreakUp: true }) + ], + edges: [] + }; + + expect(packageHierarchyToDot(hierarchy)).toBe( + 'strict digraph G {\n' + + 'package_good [label="good" shape=ellipse color=red style=filled];\n' + + '}' + ); + }); + + it('preserves label escaping and the http(s) URL gate alongside id validation', () => { + const hierarchy = { + packages: [ + packageNode('p', 'cluster_p', { + classes: [ + classNode('A', 'A', 'A" [ shape=box ] evil', '', false), + classNode('X', 'X" ] evil [ shape=box', 'X', '', false) + ] + }) + ], + edges: [] + }; + + const dot = hierarchyToDot(hierarchy, { repoUrl: 'javascript:alert(1)/' }); + expect(dot).toBe( + 'strict digraph G {\n' + + 'subgraph cluster_p {\n' + + 'label="p";\n' + + 'style=filled fillcolor=white;\n' + + 'A [ label="A\\" [ shape=box ] evil" shape=ellipse];\n' + + '}\n' + + '}' + ); + expect(dot).not.toContain('URL='); + }); +}); + describe('hierarchyToDot edge font color (dark palette edge labels)', () => { const hierarchy = () => ({ packages: [ From bd067423937b737bca089e7c6b99560e45ebd38a Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Fri, 23 Oct 2026 18:54:10 -0500 Subject: [PATCH 05/10] Apply suggested CodeRabbit fixes and override white fill for package boxes in dark mode --- AGENTS.md | 7 +++- app/globals.css | 22 ++++++++++ lib/report-view.js | 72 +++++++++++++++++++++---------- tests/e2e/dark-mode.spec.js | 40 ++++++++++++++++++ tests/unit/report-view.test.js | 77 ++++++++++++++++++++++++++++++++++ tests/unit/theme-css.test.js | 47 ++++++++++++++++++++- 6 files changed, 239 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17b59ac..0d8ec37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,7 +171,12 @@ coverage there when introducing new markup patterns. SVGs' embedded black strokes/fills are likewise re-themed to `--graph-line` (`#9fb0c0` dark = `CHART_LEGEND_TEXT.dark`) via `path[stroke]`/`polygon` selectors on `.fullscreen-svg` — red cycle - edges stay red. Graph DOT is synthesized from the `.hierarchy` payloads + edges stay red — and the regular package clusters (`g.cluster + rect[fill="white"]`, the packages that do not need to be broken up) + re-drive their fill to `--bg-color` with their label glyph paths via a + rect-sibling selector, so the boxes match the page in dark mode while + pink break-up clusters keep their baked signal. Graph DOT is synthesized + from the `.hierarchy` payloads (`classMap`/`packageMap`/`largestCycle`, see `lib/hierarchy-dot.js`); edge labels are fill-only glyph paths baked by vizdom, so CSS can't reach them: the dark palette bakes `fontcolor = "#9fb0c0"` into every diff --git a/app/globals.css b/app/globals.css index 49b1578..106b9e4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -953,3 +953,25 @@ main#app .fullscreen-svg polygon:not([fill="red"]) { stroke: var(--graph-line); fill: var(--graph-line); } + +/* Package clusters: vizdom bakes the DOT's fillcolor onto each cluster rect + (probe against the vendored 0.1.19: g.cluster > a > rect fill="white" + stroke="black", with the cluster label as a black glyph path following the + rect as a sibling). appendPackage only ever emits fillcolor=white (regular + packages) or fillcolor=pink (break-up candidates), so the white boxes are + the packages that do not need to be broken up: their fill re-drives from + the palette so it matches the page — vizdom's default white on the light + page, the dark --bg-color in dark mode — and the baked black stroke follows + --graph-line like the rest of the graph. The cluster label glyphs ride on + the re-driven fill, so they follow --graph-line too; anchored to the white + rect sibling so pink break-up clusters keep their baked black-on-pink + signal in both palettes. Pure CSS: the DOT (and its byte-identical + lockstep with the Java NestedDotBuilder) keeps fillcolor=white. */ +main#app .fullscreen-svg g.cluster rect[fill="white"] { + fill: var(--bg-color); + stroke: var(--graph-line); +} + +main#app .fullscreen-svg g.cluster rect[fill="white"] ~ path[fill="black"] { + fill: var(--graph-line); +} diff --git a/lib/report-view.js b/lib/report-view.js index 6e5984a..e4bf51b 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -911,14 +911,13 @@ export function graphDotOptions() { }; } -// Rendered graph hierarchies keyed by container id, so a theme switch can -// re-synthesize the DOT with the new palette's edge-label color — vizdom bakes -// fontcolor into the SVG at parse time, so recoloring needs a re-render, like -// the charts. All three graphs (class, package, cycle) render through vizdom; -// the package map renders its flat ellipse DOT via packageHierarchyToDot. -// Rendered graph entries keyed by container id: `{hierarchy, repoUrl}` so a -// theme switch can re-synthesize the DOT with the new palette's edge-label -// color and the same hyperlinked nodes. +// Rendered graph entries keyed by container id: `{hierarchy, dot, repoUrl}`. +// A hierarchy entry re-synthesizes its DOT on every redraw (the dark palette +// bakes a different edge-label color), while a legacy entry — an older +// RefactorFirst report with only the raw classMap.dot and no hierarchy to +// synthesize from — re-parses that DOT verbatim, so the report renders +// consistently on both palettes. Entries whose container left the DOM +// (report re-rendered or torn down) are pruned instead of redrawn. const wasmGraphHierarchies = new Map(); /** @@ -939,7 +938,7 @@ function redrawGraphsForTheme() { continue; } try { - renderGraphInto(parser, el, containerId, entry.hierarchy, entry.repoUrl); + renderGraphInto(parser, el, containerId, entry); } catch (error) { console.warn('WASM graph re-render failed:', error); } @@ -947,15 +946,34 @@ function redrawGraphsForTheme() { } /** - * Parses a dot string (recolored for the active palette) into the graph + * Parses the graph's DOT (recolored for the active palette) into the graph * container's innerHTML and re-wires pan/zoom on the freshly injected SVG. + * The entry carries either a hierarchy to synthesize from — the package map + * renders its flat ellipse DOT, the class and cycle maps their nested + * cluster DOT, and every node with a source path is hyperlinked through the + * repository URL (http(s)-gated) — or the report's own raw DOT from legacy + * reports without a hierarchy, which is parsed verbatim. + * + * @param {object} parser - vizdom DotParser instance. + * @param {Element} el - Graph container element. + * @param {string} containerId - The container's element id. + * @param {object} entry - `{hierarchy, dot, repoUrl}`: a hierarchy to + * synthesize (with repoUrl for hyperlinks) or a raw DOT fallback. + * @returns {void} */ -function renderGraphInto(parser, el, containerId, hierarchy, repoUrl) { - // the package map renders its flat ellipse DOT; the class and cycle maps - // render their nested cluster DOT; every node with a source path is - // hyperlinked through the repository URL (http(s)-gated) - const synthesis = containerId === 'packageGraph' ? packageHierarchyToDot : hierarchyToDot; - const dotString = synthesis(hierarchy, {...graphDotOptions(), repoUrl: repoUrl || ''}); +function renderGraphInto(parser, el, containerId, entry) { + let dotString; + if (entry.hierarchy) { + const synthesis = containerId === 'packageGraph' ? packageHierarchyToDot : hierarchyToDot; + dotString = synthesis(entry.hierarchy, {...graphDotOptions(), repoUrl: entry.repoUrl || ''}); + } else if (entry.dot) { + // Legacy RefactorFirst reports ship only the raw classMap.dot with no + // hierarchy to synthesize from: parse it verbatim — no palette + // fontcolor is baked in, so it renders the same on every theme. + dotString = entry.dot; + } else { + return; + } const parsed = parser.parse(dotString).to_directed().layout(); let svg = parsed.to_svg().to_string(); // vizdom drops the target attribute on hyperlinked nodes; restore new-tab @@ -974,26 +992,34 @@ export async function initWasmGraphs(data) { const parser = vizdom && vizdom.DotParser ? new vizdom.DotParser() : null; const repoUrl = data.project && data.project.repoUrl ? data.project.repoUrl : ''; - const renderGraph = (containerId, hierarchy) => { + const renderGraph = (containerId, graph) => { if (!parser) return; const el = document.getElementById(containerId); - if (!el || !hierarchy) return; - renderGraphInto(parser, el, containerId, hierarchy, repoUrl); - wasmGraphHierarchies.set(containerId, {hierarchy, repoUrl}); + if (!el || !(graph && (graph.hierarchy || graph.dot))) return; + const entry = {...graph, repoUrl}; + renderGraphInto(parser, el, containerId, entry); + wasmGraphHierarchies.set(containerId, entry); }; if (data.classMap && !data.classMap.dotThresholdExceeded) { - renderGraph('classGraph', data.classMap.hierarchy); + // Older RefactorFirst reports ship only the raw classMap.dot + // string with no hierarchy to synthesize from: render that DOT + // verbatim, and retain it so theme redraws render the legacy + // report consistently. + renderGraph('classGraph', { + hierarchy: data.classMap.hierarchy, + dot: data.classMap.hierarchy ? null : data.classMap.dot + }); } if (data.packageMap && !data.packageMap.dotThresholdExceeded && data.packageMap.hasEdges && data.packageMap.hierarchy) { - renderGraph('packageGraph', data.packageMap.hierarchy); + renderGraph('packageGraph', {hierarchy: data.packageMap.hierarchy}); } const cycle = data.classCycles && data.classCycles.largestCycle; if (cycle && !cycle.dotThresholdExceeded) { - renderGraph(cycle.cycleIdentifier, cycle.hierarchy); + renderGraph(cycle.cycleIdentifier, {hierarchy: cycle.hierarchy}); } } catch (error) { console.warn('WASM graph layout unavailable:', error); diff --git a/tests/e2e/dark-mode.spec.js b/tests/e2e/dark-mode.spec.js index 9c5e87a..16f91d8 100644 --- a/tests/e2e/dark-mode.spec.js +++ b/tests/e2e/dark-mode.spec.js @@ -208,6 +208,46 @@ test('graph edge labels re-tint to the dark palette, restore in light', async ({ expect((await labelFills()).some(f => f === '#9fb0c0')).toBe(false); }); +test('package cluster boxes re-theme in dark mode, labels follow the palette', async ({ page }) => { + test.skip(test.info().project.name !== 'chromium', 'wasm-heavy check runs once'); + // Mock GitHub raw content so the report page never hits the network. + await page.route('**/raw.githubusercontent.com/**', route => { + if (route.request().url().endsWith('refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: SAMPLE_REPORT }); + } else { + route.continue(); + } + }); + await page.goto('/refactorfirst/refactorfirst/master/'); + // vizdom renders package clusters as g.cluster rects carrying the DOT's + // fillcolor=white, with the cluster label as a black glyph path. + await page.waitForSelector('#classGraph svg g.cluster rect[fill="white"]', { timeout: 120000 }); + const fillOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).fill, selector); + const strokeOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).stroke, selector); + const CLUSTER_BOX = '#classGraph svg g.cluster rect[fill="white"]'; + const CLUSTER_LABEL = '#classGraph svg g.cluster rect[fill="white"] ~ path[fill="black"]'; + + // Light theme: vizdom's baked white boxes with black strokes and labels. + expect(await fillOf(CLUSTER_BOX)).toBe('rgb(255, 255, 255)'); + expect(await strokeOf(CLUSTER_BOX)).toBe('rgb(0, 0, 0)'); + expect(await fillOf(CLUSTER_LABEL)).toBe('rgb(0, 0, 0)'); + + await page.locator('#rf-theme-dark').check(); + // Dark theme: regular package clusters match the dark page background + // (#10161d = --bg-color) and their strokes/labels follow --graph-line + // (#9fb0c0), pure CSS on the already-rendered SVG. + expect(await fillOf(CLUSTER_BOX)).toBe('rgb(16, 22, 29)'); + expect(await strokeOf(CLUSTER_BOX)).toBe('rgb(159, 176, 192)'); + expect(await fillOf(CLUSTER_LABEL)).toBe('rgb(159, 176, 192)'); + + // Switching back to light restores the baked white/black without a re-parse. + await page.locator('#rf-theme-light').check(); + expect(await fillOf(CLUSTER_BOX)).toBe('rgb(255, 255, 255)'); + expect(await fillOf(CLUSTER_LABEL)).toBe('rgb(0, 0, 0)'); +}); + test('breadcrumbs and the toggle share one vertically centered row on report pages', async ({ page }) => { test.skip(test.info().project.name !== 'chromium', 'geometry check runs once'); // Mock GitHub raw content so the report page never hits the network. diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index 1b2eba3..eb153a5 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -1115,6 +1115,83 @@ describe('initWasmGraphs', () => { await new Promise(resolve => setTimeout(resolve, 0)); expect(captured.length).toBe(initial); }); + + it('renders the legacy classMap.dot through vizdom when no hierarchy exists', async () => { + installThemeRadios('light', false); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + const captured = stubVizdom(); + // Older RefactorFirst reports ship only the raw classMap.dot string with + // no hierarchy to synthesize from: the inline graph renders it verbatim, + // like the popup path does. + const legacyDot = 'strict digraph legacy { A; B; A -> B [ label = "1" weight = "1" ]; }'; + await initWasmGraphs({ classMap: { dot: legacyDot, dotThresholdExceeded: false } }); + expect(captured).toEqual([legacyDot]); + expect(graph.innerHTML).toContain(''); + }); + + it('retains the legacy dot so theme redraws re-render it verbatim', async () => { + installThemeRadios('light', false); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + const captured = stubVizdom(); + const legacyDot = 'strict digraph legacy { A; B; A -> B [ label = "1" weight = "1" ]; }'; + await initWasmGraphs({ classMap: { dot: legacyDot, dotThresholdExceeded: false } }); + expect(captured).toEqual([legacyDot]); + + // The retained entry re-parses the raw DOT on every theme change: there + // is no hierarchy to re-synthesize and no palette fontcolor to bake in, + // so the legacy report renders consistently on both palettes. + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(captured[captured.length - 1]).toBe(legacyDot); + + const lightBack = document.getElementById('rf-theme-light'); + lightBack.checked = true; + lightBack.dispatchEvent(new window.Event('change', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(captured[captured.length - 1]).toBe(legacyDot); + expect(captured.every(dot => dot === legacyDot)).toBe(true); + }); + + it('renders nothing when the classMap carries neither hierarchy nor dot', async () => { + installThemeRadios('light', false); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + const captured = stubVizdom(); + await initWasmGraphs({ classMap: { dotThresholdExceeded: false } }); + expect(captured).toEqual([]); + expect(graph.innerHTML).toBe(''); + }); + + it('keeps synthesizing from the hierarchy when the report also ships a raw dot', async () => { + installThemeRadios('light', false); + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + const captured = stubVizdom(); + // Modern reports may still carry the raw dot next to the hierarchy: the + // synthesis wins, and the retained entry carries no dot to fall back to. + await initWasmGraphs({ + classMap: { hierarchy: classHierarchy, dot: 'strict digraph legacy { L; }', dotThresholdExceeded: false } + }); + expect(captured).toEqual([lightDot(classHierarchy)]); + + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(captured[captured.length - 1]).toBe(darkDot(classHierarchy)); + }); }); diff --git a/tests/unit/theme-css.test.js b/tests/unit/theme-css.test.js index c18efb1..05dcaff 100644 --- a/tests/unit/theme-css.test.js +++ b/tests/unit/theme-css.test.js @@ -233,6 +233,40 @@ describe('vizdom graph edges in dark mode', () => { }); }); +// vizdom renders every package cluster as a g.cluster rect carrying the DOT's +// baked fillcolor (appendPackage emits fillcolor=white for regular packages, +// fillcolor=pink for break-up candidates) with the cluster label drawn as a +// black-filled glyph path following the rect as a sibling (probe against the +// vendored vizdom 0.1.19). The white boxes glare on the dark page, so the +// regular clusters re-drive fill, stroke and label from the palette like the +// edges; the pink break-up signal keeps its baked colors in both palettes. +describe('vizdom package clusters in dark mode', () => { + it('re-drives regular cluster boxes from the palette', () => { + const boxes = rules.get('main#app .fullscreen-svg g.cluster rect[fill="white"]'); + expect(boxes).toBeTruthy(); + // The dark fill mirrors the page background, exactly like vizdom's + // default white on the light page; the baked black stroke follows + // --graph-line so the boxes stay delineated. + expect(boxes).toMatch(/fill:\s*var\(--bg-color\)/); + expect(boxes).toMatch(/stroke:\s*var\(--graph-line\)/); + }); + + it('re-drives cluster labels on the re-themed boxes', () => { + const labels = rules.get( + 'main#app .fullscreen-svg g.cluster rect[fill="white"] ~ path[fill="black"]'); + expect(labels).toBeTruthy(); + expect(labels).toMatch(/fill:\s*var\(--graph-line\)/); + // The sibling anchor is the white rect: pink break-up clusters never + // match, so their labels keep the baked black on the pink fill. + }); + + it('keeps cluster labels readable on the re-themed boxes (SC 1.4.3, both palettes)', () => { + for (const palette of [lightPalette, darkPalette]) { + expect(contrastOf(palette, '--bg-color', '--graph-line')).toBeGreaterThanOrEqual(4.5); + } + }); +}); + describe('popup close button', () => { it('is a red square re-driven from palette variables', () => { // The template ships a bare × button; the red-square chrome must come @@ -456,7 +490,7 @@ describe('menu toggle contrast', () => { }); describe('report CSS selector boundaries', () => { - it('themes only graph edges and arrowheads, preserving red cycles and node glyphs', () => { + it('themes graph edges, arrowheads and regular clusters; red cycles, pink break-up clusters and node glyphs stay baked', () => { const fixture = document.createElement('div'); fixture.innerHTML = `
@@ -466,13 +500,22 @@ describe('report CSS selector boundaries', () => { + + + + + + + + +
`; const selectors = [...rules].filter(([, body]) => /var\(--graph-line\)/.test(body)) .map(([selector]) => selector); expect(selectors.length).toBeGreaterThan(0); expect([...fixture.querySelectorAll(selectors.join(','))].map(el => el.id)) - .toEqual(['edge', 'arrow']); + .toEqual(['edge', 'arrow', 'cluster-box', 'cluster-label']); }); it('applies the unwrapped-table scrolling rule only to report data tables without enhancement', () => { From 2f5732b3e18df88601291e77f3d210a7b8162d94 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Fri, 23 Oct 2026 20:28:59 -0500 Subject: [PATCH 06/10] Now using deep rose for package clusters in dark mode for the clusters that need to be broken up --- AGENTS.md | 17 +++++--- app/globals.css | 45 +++++++++++++++++++-- tests/e2e/dark-mode.spec.js | 42 ++++++++++++++++++++ tests/unit/theme-css.test.js | 76 ++++++++++++++++++++++++++++++++++-- 4 files changed, 168 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d8ec37..854d2f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,11 +171,18 @@ coverage there when introducing new markup patterns. SVGs' embedded black strokes/fills are likewise re-themed to `--graph-line` (`#9fb0c0` dark = `CHART_LEGEND_TEXT.dark`) via `path[stroke]`/`polygon` selectors on `.fullscreen-svg` — red cycle - edges stay red — and the regular package clusters (`g.cluster - rect[fill="white"]`, the packages that do not need to be broken up) - re-drive their fill to `--bg-color` with their label glyph paths via a - rect-sibling selector, so the boxes match the page in dark mode while - pink break-up clusters keep their baked signal. Graph DOT is synthesized + edges stay red — and the package clusters (`g.cluster rect[fill="white"]`, + the packages that do not need to be broken up) re-drive their fill to + `--bg-color` with their label glyph paths via a rect-sibling selector, so + the boxes match the page in dark mode. Break-up clusters + (`g.cluster rect[fill="pink"]`) re-drive through their own + `--graph-breakup-fill` (light: vizdom's baked `#ffc0cb`; dark: a deep rose + `#7f3350` that keeps the `--graph-line` edges crossing the box visible) + and `--graph-breakup-line` ink (light: baked black; dark: light pink + `#f2b8cd` for the boundary + label) — no fill can pass 3:1 against both + the near-black page and the light edges, so the fill prioritizes edge + visibility and the pink ink carries the perceivable boundary. Graph DOT + is synthesized from the `.hierarchy` payloads (`classMap`/`packageMap`/`largestCycle`, see `lib/hierarchy-dot.js`); edge labels are fill-only glyph paths baked by vizdom, so CSS can't diff --git a/app/globals.css b/app/globals.css index 106b9e4..f1ee93d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -35,6 +35,12 @@ /* vizdom WASM graph edges bake black stroke/fill attributes into the SVG; palette-driven so the dark theme can swap them to a readable tint. */ --graph-line: #000000; + /* Break-up package clusters: vizdom bakes fillcolor=pink onto the cluster + rect with the default black stroke/label glyphs. The light values equal + those baked colors, so the re-driving rules below never change the + light theme by a pixel; the dark block swaps them for a tuned surface. */ + --graph-breakup-fill: #ffc0cb; + --graph-breakup-line: #000000; --error-color: #c0392b; --success-color: #1e8449; --error-bg: #fdf6f5; @@ -101,6 +107,14 @@ html:root { /* Hamburger glyph + 1px border on the dark menu surface (6.8:1). */ --menu-toggle-color: #6fb1d9; --graph-line: #9fb0c0; + /* Break-up package clusters: the baked pink is a glaring pastel slab on + the dark page (11.8:1) and swallows the #9fb0c0 edges crossing it + (1.45:1), so the box drops to a deep rose — flagged against the + near-black regular clusters (2.2:1 plus a warm hue shift) while the + edges stay visible on it (3.8:1) — and a light pink ink carries the + boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ + --graph-breakup-fill: #7f3350; + --graph-breakup-line: #f2b8cd; --error-color: #f0928c; --success-color: #5bbd8b; --error-bg: #2b1d1f; @@ -145,6 +159,14 @@ html:root { /* Hamburger glyph + 1px border on the dark menu surface (6.8:1). */ --menu-toggle-color: #6fb1d9; --graph-line: #9fb0c0; + /* Break-up package clusters: the baked pink is a glaring pastel slab on + the dark page (11.8:1) and swallows the #9fb0c0 edges crossing it + (1.45:1), so the box drops to a deep rose — flagged against the + near-black regular clusters (2.2:1 plus a warm hue shift) while the + edges stay visible on it (3.8:1) — and a light pink ink carries the + boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ + --graph-breakup-fill: #7f3350; + --graph-breakup-line: #f2b8cd; --error-color: #f0928c; --success-color: #5bbd8b; --error-bg: #2b1d1f; @@ -964,9 +986,10 @@ main#app .fullscreen-svg polygon:not([fill="red"]) { page, the dark --bg-color in dark mode — and the baked black stroke follows --graph-line like the rest of the graph. The cluster label glyphs ride on the re-driven fill, so they follow --graph-line too; anchored to the white - rect sibling so pink break-up clusters keep their baked black-on-pink - signal in both palettes. Pure CSS: the DOT (and its byte-identical - lockstep with the Java NestedDotBuilder) keeps fillcolor=white. */ + rect sibling so the rule never touches the break-up clusters, which the + rules below theme through their own palette. Pure CSS: the DOT (and its + byte-identical lockstep with the Java NestedDotBuilder) keeps + fillcolor=white/fillcolor=pink. */ main#app .fullscreen-svg g.cluster rect[fill="white"] { fill: var(--bg-color); stroke: var(--graph-line); @@ -975,3 +998,19 @@ main#app .fullscreen-svg g.cluster rect[fill="white"] { main#app .fullscreen-svg g.cluster rect[fill="white"] ~ path[fill="black"] { fill: var(--graph-line); } + +/* Break-up packages (the pink rects) invert that treatment: their problem + signal survives the dark page best as a deep rose box with a light pink + boundary and label, because no single fill can pass 3:1 against both the + near-black page and the light --graph-line edges crossing the box — the + rose fill keeps the edges visible (SC 1.4.11) and the pink ink keeps the + box boundary and its label readable (SC 1.4.3). The light palette values + equal vizdom's baked pink/black, so this never changes light mode. */ +main#app .fullscreen-svg g.cluster rect[fill="pink"] { + fill: var(--graph-breakup-fill); + stroke: var(--graph-breakup-line); +} + +main#app .fullscreen-svg g.cluster rect[fill="pink"] ~ path[fill="black"] { + fill: var(--graph-breakup-line); +} diff --git a/tests/e2e/dark-mode.spec.js b/tests/e2e/dark-mode.spec.js index 16f91d8..bd5e187 100644 --- a/tests/e2e/dark-mode.spec.js +++ b/tests/e2e/dark-mode.spec.js @@ -248,6 +248,48 @@ test('package cluster boxes re-theme in dark mode, labels follow the palette', a expect(await fillOf(CLUSTER_LABEL)).toBe('rgb(0, 0, 0)'); }); +test('break-up package clusters read as flagged in dark mode, edges stay visible', async ({ page }) => { + test.skip(test.info().project.name !== 'chromium', 'wasm-heavy check runs once'); + // Flag one package for break-up so the class map renders a pink cluster; + // the shipped fixture has none. + const flagged = JSON.parse(SAMPLE_REPORT); + flagged.classMap.hierarchy.packages[0].needsBreakUp = true; + await page.route('**/raw.githubusercontent.com/**', route => { + if (route.request().url().endsWith('refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(flagged) }); + } else { + route.continue(); + } + }); + await page.goto('/refactorfirst/refactorfirst/master/'); + // vizdom bakes the DOT's fillcolor=pink onto the break-up cluster rect. + await page.waitForSelector('#classGraph svg g.cluster rect[fill="pink"]', { timeout: 120000 }); + const fillOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).fill, selector); + const strokeOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).stroke, selector); + const BREAKUP_BOX = '#classGraph svg g.cluster rect[fill="pink"]'; + const BREAKUP_LABEL = '#classGraph svg g.cluster rect[fill="pink"] ~ path[fill="black"]'; + + // Light theme: vizdom's baked pink box with black label glyphs. + expect(await fillOf(BREAKUP_BOX)).toBe('rgb(255, 192, 203)'); + expect(await fillOf(BREAKUP_LABEL)).toBe('rgb(0, 0, 0)'); + + await page.locator('#rf-theme-dark').check(); + // Dark theme: the box drops to a deep rose (#7f3350 = --graph-breakup-fill) + // — clearly flagged against the near-black regular clusters without + // swallowing the #9fb0c0 edges crossing it — and the boundary stroke and + // label glyphs follow the light pink ink (#f2b8cd = --graph-breakup-line). + expect(await fillOf(BREAKUP_BOX)).toBe('rgb(127, 51, 80)'); + expect(await strokeOf(BREAKUP_BOX)).toBe('rgb(242, 184, 205)'); + expect(await fillOf(BREAKUP_LABEL)).toBe('rgb(242, 184, 205)'); + + // Switching back to light restores the baked pink/black (pure CSS). + await page.locator('#rf-theme-light').check(); + expect(await fillOf(BREAKUP_BOX)).toBe('rgb(255, 192, 203)'); + expect(await fillOf(BREAKUP_LABEL)).toBe('rgb(0, 0, 0)'); +}); + test('breadcrumbs and the toggle share one vertically centered row on report pages', async ({ page }) => { test.skip(test.info().project.name !== 'chromium', 'geometry check runs once'); // Mock GitHub raw content so the report page never hits the network. diff --git a/tests/unit/theme-css.test.js b/tests/unit/theme-css.test.js index 05dcaff..69ad348 100644 --- a/tests/unit/theme-css.test.js +++ b/tests/unit/theme-css.test.js @@ -239,7 +239,15 @@ describe('vizdom graph edges in dark mode', () => { // black-filled glyph path following the rect as a sibling (probe against the // vendored vizdom 0.1.19). The white boxes glare on the dark page, so the // regular clusters re-drive fill, stroke and label from the palette like the -// edges; the pink break-up signal keeps its baked colors in both palettes. +// edges. The pink break-up boxes need the opposite treatment: baked pink is +// 11.8:1 against the dark page (a glaring pastel slab) but only 1.45:1 against +// the #9fb0c0 edges crossing it, so the dark fill drops to a deep rose that +// keeps the edges visible while a light pink boundary/label ink carries the +// signal. No fill can pass 3:1 against both the near-black page and the light +// edges at once (the optimum balances at ~2.86:1), so the fill prioritizes +// edge visibility and the pink ink provides the perceivable boundary (SC +// 1.4.11). Light values equal vizdom's baked colors, so light mode does not +// change by a pixel. describe('vizdom package clusters in dark mode', () => { it('re-drives regular cluster boxes from the palette', () => { const boxes = rules.get('main#app .fullscreen-svg g.cluster rect[fill="white"]'); @@ -256,8 +264,8 @@ describe('vizdom package clusters in dark mode', () => { 'main#app .fullscreen-svg g.cluster rect[fill="white"] ~ path[fill="black"]'); expect(labels).toBeTruthy(); expect(labels).toMatch(/fill:\s*var\(--graph-line\)/); - // The sibling anchor is the white rect: pink break-up clusters never - // match, so their labels keep the baked black on the pink fill. + // The sibling anchor is the white rect: break-up labels are themed by + // their own rule below, never by this one. }); it('keeps cluster labels readable on the re-themed boxes (SC 1.4.3, both palettes)', () => { @@ -265,6 +273,47 @@ describe('vizdom package clusters in dark mode', () => { expect(contrastOf(palette, '--bg-color', '--graph-line')).toBeGreaterThanOrEqual(4.5); } }); + + it('re-drives break-up cluster boxes and their labels from the palette', () => { + const boxes = rules.get('main#app .fullscreen-svg g.cluster rect[fill="pink"]'); + expect(boxes).toBeTruthy(); + expect(boxes).toMatch(/fill:\s*var\(--graph-breakup-fill\)/); + expect(boxes).toMatch(/stroke:\s*var\(--graph-breakup-line\)/); + const labels = rules.get( + 'main#app .fullscreen-svg g.cluster rect[fill="pink"] ~ path[fill="black"]'); + expect(labels).toBeTruthy(); + expect(labels).toMatch(/fill:\s*var\(--graph-breakup-line\)/); + }); + + it('keeps the light breakup values equal to vizdom\'s baked pink and black', () => { + // The light rules must be a visual no-op: the palette values equal the + // DOT's baked fillcolor=pink and the default black stroke/label glyphs. + expect(lightPalette.get('--graph-breakup-fill')).toBe('#ffc0cb'); + expect(lightPalette.get('--graph-breakup-line')).toBe('#000000'); + }); + + it('tunes the dark breakup surface for the dark page', () => { + expect(darkPalette.get('--graph-breakup-fill')).toBe('#7f3350'); + expect(darkPalette.get('--graph-breakup-line')).toBe('#f2b8cd'); + }); + + it('dark breakup boxes keep the edges visible and read as flagged', () => { + // Edges (--graph-line) cross the breakup boxes and must stay visible + // against the fill (SC 1.4.11, >= 3:1) — baked pink only reached 1.45:1. + expect(contrastOf(darkPalette, '--graph-line', '--graph-breakup-fill')) + .toBeGreaterThanOrEqual(3); + // The pink label ink keeps AA text contrast on the rose fill (SC 1.4.3) + // and the boundary stays perceivable against the page behind the box. + expect(contrastOf(darkPalette, '--graph-breakup-fill', '--graph-breakup-line')) + .toBeGreaterThanOrEqual(4.5); + expect(contrastOf(darkPalette, '--bg-color', '--graph-breakup-line')) + .toBeGreaterThanOrEqual(3); + // The rose fill reads clearly against the near-black regular clusters + // (large-area presence plus a warm hue shift; the site's own dark + // surfaces sit at 1.29:1). The perceivable boundary is the pink ink. + expect(contrastOf(darkPalette, '--bg-color', '--graph-breakup-fill')) + .toBeGreaterThanOrEqual(2); + }); }); describe('popup close button', () => { @@ -490,7 +539,11 @@ describe('menu toggle contrast', () => { }); describe('report CSS selector boundaries', () => { - it('themes graph edges, arrowheads and regular clusters; red cycles, pink break-up clusters and node glyphs stay baked', () => { + // Mirrors the vizdom SVG structure (probe against the vendored 0.1.19): + // edge curves (stroke paths), arrowhead polygons, node ellipses with + // label glyph paths, and package clusters as g.cluster rects whose label + // glyph path follows the rect as a sibling. + const graphSvgFixture = () => { const fixture = document.createElement('div'); fixture.innerHTML = `
@@ -511,6 +564,11 @@ describe('report CSS selector boundaries', () => {
`; + return fixture; + }; + + it('themes graph edges, arrowheads and regular clusters; red cycles and node glyphs stay untouched', () => { + const fixture = graphSvgFixture(); const selectors = [...rules].filter(([, body]) => /var\(--graph-line\)/.test(body)) .map(([selector]) => selector); expect(selectors.length).toBeGreaterThan(0); @@ -518,6 +576,16 @@ describe('report CSS selector boundaries', () => { .toEqual(['edge', 'arrow', 'cluster-box', 'cluster-label']); }); + it('themes break-up clusters only through the breakup rules', () => { + const fixture = graphSvgFixture(); + const selectors = [...rules] + .filter(([, body]) => /var\(--graph-breakup-(?:fill|line)\)/.test(body)) + .map(([selector]) => selector); + expect(selectors.length).toBeGreaterThan(0); + expect([...fixture.querySelectorAll(selectors.join(','))].map(el => el.id)) + .toEqual(['breakup-box', 'breakup-label']); + }); + it('applies the unwrapped-table scrolling rule only to report data tables without enhancement', () => { const fixture = document.createElement('div'); fixture.innerHTML = ` From d2795cc6813d645a6807fb0157d3ce0a8c57472d Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Fri, 23 Oct 2026 22:21:32 -0500 Subject: [PATCH 07/10] Highlighting nodes and edges and what they connect when they are moused over --- AGENTS.md | 12 ++- app/globals.css | 106 +++++++++++++++++++++ lib/report-view.js | 98 ++++++++++++++++++++ tests/e2e/dark-mode.spec.js | 69 ++++++++++++++ tests/unit/report-view.test.js | 163 +++++++++++++++++++++++++++++++++ tests/unit/theme-css.test.js | 129 ++++++++++++++++++++++++++ 6 files changed, 575 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 854d2f5..0c61d83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,16 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). edges, break-up packages in red — through vizdom like the class and cycle graphs; every graph's nodes are hyperlinked to their source files/directories via `project.repoUrl` (http(s)-gated, new tab - restored post-render since vizdom drops the target); the full - hierarchy stays in refactor-first.json for LLM consumption + restored post-render since vizdom drops the target); hovering a vizdom + edge highlights it plus both endpoints, hovering (or keyboard-focusing) + a node highlights its incoming edges + source nodes and outgoing edges + + sink nodes in two directional colors (`bindGraphHoverHighlights` in + `lib/report-view.js` derives the neighbor model from the vizdom + `` texts and toggles `rf-hover-*` classes, styled per palette via + `--graph-hover-*`); highlighted nodes fill with their accent and their + label glyphs re-ink to `--graph-hover-on` (white on the dark light-mode + accents, black on the bright dark-mode accents) to keep AA contrast; the + full hierarchy stays in refactor-first.json for LLM consumption - Enhanced report tables: sticky headers, pagination (>20 rows), sortable columns, in-table search, CSV export, click/keyboard cell copy with toasts - Repository submission via pre-filled platform issues — no login, apps or tokens diff --git a/app/globals.css b/app/globals.css index f1ee93d..a879d76 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,6 +41,16 @@ light theme by a pixel; the dark block swaps them for a tuned surface. */ --graph-breakup-fill: #ffc0cb; --graph-breakup-line: #000000; + /* DOT-graph hover highlights (vizdom SVGs): the hovered edge/node and its + partners take the amber attention accent; a hovered node colors its + incoming (source) and outgoing (sink) neighborhoods green/blue. All + three pass >= 3:1 on the white page and on the pink breakup clusters; + highlighted nodes fill with their accent, and the label ink flips to + --graph-hover-on to stay readable on it (white on these dark accents). */ + --graph-hover-edge: #b45309; + --graph-hover-in: #1e8449; + --graph-hover-out: #2a6f97; + --graph-hover-on: #ffffff; --error-color: #c0392b; --success-color: #1e8449; --error-bg: #fdf6f5; @@ -115,6 +125,15 @@ html:root { boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ --graph-breakup-fill: #7f3350; --graph-breakup-line: #f2b8cd; + /* DOT-graph hover highlights: bright accents that stay distinct from the + slate #9fb0c0 edges (hue + width) and pass >= 3:1 on both cluster + fills (#10161d regular, #7f3350 rose). */ + --graph-hover-edge: #f0b95a; + --graph-hover-in: #7ee2a8; + --graph-hover-out: #8ed0fa; + /* Label ink on the accent-filled highlighted nodes: the bright fills take + black glyphs (the labels bake a black fill in both palettes). */ + --graph-hover-on: #000000; --error-color: #f0928c; --success-color: #5bbd8b; --error-bg: #2b1d1f; @@ -167,6 +186,15 @@ html:root { boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ --graph-breakup-fill: #7f3350; --graph-breakup-line: #f2b8cd; + /* DOT-graph hover highlights: bright accents that stay distinct from the + slate #9fb0c0 edges (hue + width) and pass >= 3:1 on both cluster + fills (#10161d regular, #7f3350 rose). */ + --graph-hover-edge: #f0b95a; + --graph-hover-in: #7ee2a8; + --graph-hover-out: #8ed0fa; + /* Label ink on the accent-filled highlighted nodes: the bright fills take + black glyphs (the labels bake a black fill in both palettes). */ + --graph-hover-on: #000000; --error-color: #f0928c; --success-color: #5bbd8b; --error-bg: #2b1d1f; @@ -1014,3 +1042,81 @@ main#app .fullscreen-svg g.cluster rect[fill="pink"] { main#app .fullscreen-svg g.cluster rect[fill="pink"] ~ path[fill="black"] { fill: var(--graph-breakup-line); } + +/* Hover highlights on the DOT graphs: lib/report-view.js derives the + node/edge neighborhood from the vizdom titles and toggles rf-hover-* + state classes. Hovering an edge flags the edge plus both endpoint nodes + with the amber attention accent; hovering a node flags it with the same + accent while its incoming edges + source nodes go green and its outgoing + edges + sink nodes go blue, so dependency direction reads at a glance. + Highlighted nodes FILL with their accent (their baked white/red/pink fill + yields to the highlight like red removal edges do), and the node labels — + glyph paths with a baked black fill riding on the shape — re-ink to + --graph-hover-on to keep AA contrast on the fill. A node flagged both in + and out is a bidirectional partner of the hovered node and takes the + attention accent over either directional state. */ +main#app .fullscreen-svg g.edge.rf-hover-edge path[stroke] { + stroke: var(--graph-hover-edge); + stroke-width: 2.5px; +} + +main#app .fullscreen-svg g.edge.rf-hover-edge polygon { + stroke: var(--graph-hover-edge); + fill: var(--graph-hover-edge); +} + +main#app .fullscreen-svg g.node.rf-hover-edge ellipse, +main#app .fullscreen-svg g.node.rf-hover-edge rect, +main#app .fullscreen-svg g.node.rf-hover-focus ellipse, +main#app .fullscreen-svg g.node.rf-hover-focus rect { + stroke: var(--graph-hover-edge); + fill: var(--graph-hover-edge); + stroke-width: 2px; +} + +main#app .fullscreen-svg g.edge.rf-hover-in path[stroke] { + stroke: var(--graph-hover-in); + stroke-width: 2.5px; +} + +main#app .fullscreen-svg g.edge.rf-hover-in polygon { + stroke: var(--graph-hover-in); + fill: var(--graph-hover-in); +} + +main#app .fullscreen-svg g.node.rf-hover-in ellipse, +main#app .fullscreen-svg g.node.rf-hover-in rect { + stroke: var(--graph-hover-in); + fill: var(--graph-hover-in); + stroke-width: 2px; +} + +main#app .fullscreen-svg g.edge.rf-hover-out path[stroke] { + stroke: var(--graph-hover-out); + stroke-width: 2.5px; +} + +main#app .fullscreen-svg g.edge.rf-hover-out polygon { + stroke: var(--graph-hover-out); + fill: var(--graph-hover-out); +} + +main#app .fullscreen-svg g.node.rf-hover-out ellipse, +main#app .fullscreen-svg g.node.rf-hover-out rect { + stroke: var(--graph-hover-out); + fill: var(--graph-hover-out); + stroke-width: 2px; +} + +main#app .fullscreen-svg g.node.rf-hover-in.rf-hover-out ellipse, +main#app .fullscreen-svg g.node.rf-hover-in.rf-hover-out rect { + stroke: var(--graph-hover-edge); + fill: var(--graph-hover-edge); +} + +main#app .fullscreen-svg g.node.rf-hover-edge path[fill="black"], +main#app .fullscreen-svg g.node.rf-hover-focus path[fill="black"], +main#app .fullscreen-svg g.node.rf-hover-in path[fill="black"], +main#app .fullscreen-svg g.node.rf-hover-out path[fill="black"] { + fill: var(--graph-hover-on); +} diff --git a/lib/report-view.js b/lib/report-view.js index e4bf51b..c02e69b 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -981,11 +981,109 @@ function renderGraphInto(parser, el, containerId, entry) { svg = svg.replaceAll(' with the DOT node ids ("TestCase" / "TestCase -> +// TestResult"), so the neighbor model derives from the rendered SVG: +// hovering an edge flags the edge plus both endpoint nodes (the attention +// accent), hovering a node flags its incoming edges + source nodes and its +// outgoing edges + sink nodes in two directional states. The rf-hover-* +// state classes are styled from the palette (globals.css); ids are +// producer-contract word characters, and an edge title that fails to parse +// simply binds no highlight. Node anchors are real tab stops, so keyboard +// focus re-uses the node hover state (SC 2.1.1 parity for the pointer-only +// affordance); edges carry no tab stop — the highlight is a pointer +// courtesy, the relationships stay available in the report tables. +// --------------------------------------------------------------------------- + +/** The state classes the hover model toggles on g.node/g.edge groups. */ +const GRAPH_HOVER_STATES = ['rf-hover-edge', 'rf-hover-in', 'rf-hover-out', 'rf-hover-focus']; + +/** + * Binds the hover/focus highlight model to the freshly injected graph SVG. + * The node/edge neighbor map is rebuilt per render — theme redraws + * re-inject the whole SVG, so the listeners must rebind with it — and every + * mouseover/focusin recomputes the full state idempotently from scratch, so + * moving between an edge's curve and arrowhead or between neighboring nodes + * can never leave stale classes behind. + * + * @param {Element} container - Graph container holding the injected SVG. + * @returns {void} + */ +function bindGraphHoverHighlights(container) { + const svg = container ? container.querySelector('svg') : null; + if (!svg) return; + + const nodeIds = new Map(); // g.node group -> DOT node id + const nodesById = new Map(); // DOT node id -> g.node group + for (const node of svg.querySelectorAll('g.node')) { + const title = node.querySelector('title'); + const id = title ? title.textContent.trim() : ''; + if (id) { + nodeIds.set(node, id); + nodesById.set(id, node); + } + } + const edges = []; // {group, source, target} from "Source -> Target" + for (const group of svg.querySelectorAll('g.edge')) { + const title = group.querySelector('title'); + const parts = title ? title.textContent.split(' -> ') : []; + if (parts.length === 2) edges.push({group, source: parts[0], target: parts[1]}); + } + + const flagged = new Set(); + const mark = (element, state) => { + element.classList.add(state); + flagged.add(element); + }; + const markNode = (id, state) => { + const node = nodesById.get(id); + if (node) mark(node, state); + }; + const clear = () => { + for (const element of flagged) element.classList.remove(...GRAPH_HOVER_STATES); + flagged.clear(); + }; + const applyFor = target => { + clear(); + const edgeGroup = target.closest ? target.closest('g.edge') : null; + if (edgeGroup) { + const edge = edges.find(entry => entry.group === edgeGroup); + if (edge) { + mark(edgeGroup, 'rf-hover-edge'); + markNode(edge.source, 'rf-hover-edge'); + markNode(edge.target, 'rf-hover-edge'); + } + return; + } + const nodeGroup = target.closest ? target.closest('g.node') : null; + if (!nodeGroup || !nodeIds.has(nodeGroup)) return; + const id = nodeIds.get(nodeGroup); + mark(nodeGroup, 'rf-hover-focus'); + for (const edge of edges) { + if (edge.target === id) { + mark(edge.group, 'rf-hover-in'); + markNode(edge.source, 'rf-hover-in'); + } else if (edge.source === id) { + mark(edge.group, 'rf-hover-out'); + markNode(edge.target, 'rf-hover-out'); + } + } + }; + + svg.addEventListener('mouseover', event => applyFor(event.target)); + svg.addEventListener('mouseleave', clear); + // Keyboard parity: the hyperlinked node anchors are focusable tab stops. + svg.addEventListener('focusin', event => applyFor(event.target)); + svg.addEventListener('focusout', clear); +} + export async function initWasmGraphs(data) { try { const vizdom = window.Vizdom; diff --git a/tests/e2e/dark-mode.spec.js b/tests/e2e/dark-mode.spec.js index bd5e187..5aed389 100644 --- a/tests/e2e/dark-mode.spec.js +++ b/tests/e2e/dark-mode.spec.js @@ -290,6 +290,75 @@ test('break-up package clusters read as flagged in dark mode, edges stay visible expect(await fillOf(BREAKUP_LABEL)).toBe('rgb(0, 0, 0)'); }); +test('hovering graph edges and nodes highlights them per palette', async ({ page }) => { + test.skip(test.info().project.name !== 'chromium', 'wasm-heavy check runs once'); + // Mock GitHub raw content so the report page never hits the network. + await page.route('**/raw.githubusercontent.com/**', route => { + if (route.request().url().endsWith('refactor-first.json')) { + route.fulfill({ status: 200, contentType: 'application/json', body: SAMPLE_REPORT }); + } else { + route.continue(); + } + }); + await page.goto('/refactorfirst/refactorfirst/master/'); + // vizdom renders edges as g.edge groups (curve path + arrowhead polygon). + await page.waitForSelector('#classGraph svg g.edge path', { timeout: 120000 }); + const strokeOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).stroke, selector); + const fillOf = selector => page.evaluate( + sel => getComputedStyle(document.querySelector(sel)).fill, selector); + const count = selector => page.evaluate( + sel => document.querySelectorAll(sel).length, selector); + + // Hovering an edge highlights and fills the edge plus both endpoint nodes + // (amber accent, labels re-inked white on the fill). + await page.hover('#classGraph svg g.edge path'); + expect(await count('#classGraph svg g.edge.rf-hover-edge')).toBe(1); + expect(await count('#classGraph svg g.node.rf-hover-edge')).toBe(2); + expect(await strokeOf('#classGraph svg g.edge.rf-hover-edge path')) + .toBe('rgb(180, 83, 9)'); // #b45309 light amber + expect(await fillOf('#classGraph svg g.node.rf-hover-edge ellipse')) + .toBe('rgb(180, 83, 9)'); + expect(await fillOf('#classGraph svg g.node.rf-hover-edge path')) + .toBe('rgb(255, 255, 255)'); // #ffffff light on-accent label ink + + // Hovering a node colors incoming (green) and outgoing (blue) separately; + // TestCase has both directions in the fixture (TestCase -> TestResult and + // TestResult -> TestCase), so both states apply. + const TEST_CASE_NODE = '#classGraph svg g.node:has-text("TestCase") ellipse'; + await page.hover(TEST_CASE_NODE); + expect(await count('#classGraph svg g.edge.rf-hover-in')).toBeGreaterThan(0); + expect(await count('#classGraph svg g.edge.rf-hover-out')).toBeGreaterThan(0); + expect(await strokeOf('#classGraph svg g.edge.rf-hover-in path')) + .toBe('rgb(30, 132, 73)'); // #1e8449 light green + expect(await strokeOf('#classGraph svg g.edge.rf-hover-out path')) + .toBe('rgb(42, 111, 151)'); // #2a6f97 light blue + // The hovered node fills amber; the bidirectional partner (TestResult, + // flagged both in and out) fills amber too, while the out-only sink + // (BaseTestRunner) fills blue. + expect(await fillOf('#classGraph svg g.node.rf-hover-focus ellipse')) + .toBe('rgb(180, 83, 9)'); + expect(await fillOf('#classGraph svg g.node.rf-hover-in.rf-hover-out ellipse')) + .toBe('rgb(180, 83, 9)'); + expect(await fillOf('#classGraph svg g.node.rf-hover-out:not(.rf-hover-in) ellipse')) + .toBe('rgb(42, 111, 151)'); + + // The dark palette swaps to its own bright accents with black label ink. + await page.locator('#rf-theme-dark').check(); + await page.hover('#classGraph svg g.edge path'); + expect(await strokeOf('#classGraph svg g.edge.rf-hover-edge path')) + .toBe('rgb(240, 185, 90)'); // #f0b95a dark amber + expect(await fillOf('#classGraph svg g.node.rf-hover-edge ellipse')) + .toBe('rgb(240, 185, 90)'); + expect(await fillOf('#classGraph svg g.node.rf-hover-edge path')) + .toBe('rgb(0, 0, 0)'); // #000000 dark on-accent label ink + await page.hover(TEST_CASE_NODE); + expect(await strokeOf('#classGraph svg g.edge.rf-hover-in path')) + .toBe('rgb(126, 226, 168)'); // #7ee2a8 dark green + expect(await strokeOf('#classGraph svg g.edge.rf-hover-out path')) + .toBe('rgb(142, 208, 250)'); // #8ed0fa dark blue +}); + test('breadcrumbs and the toggle share one vertically centered row on report pages', async ({ page }) => { test.skip(test.info().project.name !== 'chromium', 'geometry check runs once'); // Mock GitHub raw content so the report page never hits the network. diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index eb153a5..8818f6b 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -1220,6 +1220,169 @@ describe('popup helpers', () => { }); }); +// DOT-graph hover highlights (vizdom SVGs): mousing over an edge emphasizes +// the edge and both endpoint nodes; mousing over a node colors the incoming +// edges + source nodes and the outgoing edges + sink nodes differently. The +// neighbor model derives from the vizdom texts (node ids and +// "Source -> Target" edge titles); lib/report-view.js toggles rf-hover-* +// state classes, and the palette rules in globals.css do the restyling. +describe('graph hover highlights', () => { + // Mirrors the vizdom 0.1.19 SVG structure: g.edge/g.node groups carrying + // <title> with the DOT node ids, curves as stroke paths, arrowheads as + // polygons, node shapes as ellipses inside <a> wrappers. + const HOVER_SVG = '' + + '<svg width="1">' + + '<g class="graph" id="graph-0"><title>G' + + '' + + 'c' + + '' + + 'A -> B' + + '' + + '' + + 'C -> A' + + '' + + '' + + 'B -> A' + + '' + + '' + + 'unparsable' + + '' + + 'A' + + '' + + '' + + 'B' + + '' + + '' + + 'C' + + '' + + '
'; + + function stubHoverVizdom() { + window.Vizdom = { + DotParser: class { + parse() { + return { + to_directed() { return this; }, + layout() { return this; }, + to_svg() { return { to_string: () => HOVER_SVG }; } + }; + } + } + }; + } + + function hoverOver(element) { + element.dispatchEvent(new window.MouseEvent('mouseover', {bubbles: true})); + } + + function flagged(selector, state) { + return [...document.querySelectorAll(selector)] + .filter(el => el.classList.contains(state)).map(el => el.id); + } + + beforeEach(() => { + installThemeRadios('light', false); + const graph = document.createElement('div'); + graph.id = 'classGraph'; + document.body.appendChild(graph); + stubHoverVizdom(); + }); + + afterEach(() => { delete window.Vizdom; delete window.matchMedia; }); + + it('highlights the hovered edge and both endpoint nodes', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + hoverOver(document.getElementById('curve-0')); + expect(flagged('g.edge', 'rf-hover-edge')).toEqual(['edge-0']); + expect(flagged('g.node', 'rf-hover-edge')).toEqual(['node-A', 'node-B']); + // Nothing else is flagged, and no directional state leaks in. + expect(flagged('g.edge', 'rf-hover-in')).toEqual([]); + expect(flagged('g.edge', 'rf-hover-out')).toEqual([]); + expect(document.getElementById('node-C').getAttribute('class')).toBe('node'); + }); + + it('keeps the edge highlighted while the pointer moves within its shapes', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + hoverOver(document.getElementById('curve-0')); + hoverOver(document.getElementById('arrow-0')); + expect(flagged('g.edge', 'rf-hover-edge')).toEqual(['edge-0']); + expect(flagged('g.node', 'rf-hover-edge')).toEqual(['node-A', 'node-B']); + }); + + it('colors a hovered node\'s incoming and outgoing neighborhoods differently', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + hoverOver(document.getElementById('shape-A')); + // The hovered node carries the focus ring. + expect(flagged('g.node', 'rf-hover-focus')).toEqual(['node-A']); + // Incoming: C -> A and B -> A, with their source nodes, in green state. + expect(flagged('g.edge', 'rf-hover-in')).toEqual(['edge-1', 'edge-2']); + expect(flagged('g.node', 'rf-hover-in')).toEqual(['node-B', 'node-C']); + // Outgoing: A -> B with its sink node, in blue state. + expect(flagged('g.edge', 'rf-hover-out')).toEqual(['edge-0']); + expect(flagged('g.node', 'rf-hover-out')).toEqual(['node-B']); + // B is both a source (of B -> A) and a sink (of A -> B): a bidirectional + // partner flagged with both states. + expect(document.getElementById('node-B').classList.contains('rf-hover-in')).toBe(true); + expect(document.getElementById('node-B').classList.contains('rf-hover-out')).toBe(true); + // The edge whose title carries no " -> " never binds a highlight. + expect(document.getElementById('edge-bad').getAttribute('class')).toBe('edge'); + }); + + it('clears every state when the pointer moves onto the graph background', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + hoverOver(document.getElementById('shape-A')); + hoverOver(document.getElementById('graph-bg')); + expect(flagged('g.edge', 'rf-hover-edge').length + + flagged('g.edge', 'rf-hover-in').length + + flagged('g.edge', 'rf-hover-out').length).toBe(0); + expect(flagged('g.node', 'rf-hover-edge').length + + flagged('g.node', 'rf-hover-in').length + + flagged('g.node', 'rf-hover-out').length + + flagged('g.node', 'rf-hover-focus').length).toBe(0); + }); + + it('clears every state when the pointer leaves the svg', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + hoverOver(document.getElementById('curve-0')); + document.querySelector('#classGraph svg') + .dispatchEvent(new window.MouseEvent('mouseleave')); + expect(flagged('g.edge', 'rf-hover-edge')).toEqual([]); + expect(flagged('g.node', 'rf-hover-edge')).toEqual([]); + }); + + it('re-uses the node hover state on keyboard focus and clears on blur', async () => { + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + // Node anchors are real tab stops; focusing one flags the same state as + // hovering (SC 2.1.1 parity for pointer-only affordances). + document.getElementById('anchor-A') + .dispatchEvent(new window.FocusEvent('focusin', {bubbles: true})); + expect(flagged('g.node', 'rf-hover-focus')).toEqual(['node-A']); + expect(flagged('g.edge', 'rf-hover-in')).toEqual(['edge-1', 'edge-2']); + expect(flagged('g.edge', 'rf-hover-out')).toEqual(['edge-0']); + + document.getElementById('anchor-A') + .dispatchEvent(new window.FocusEvent('focusout', {bubbles: true})); + expect(flagged('g.node', 'rf-hover-focus')).toEqual([]); + expect(flagged('g.edge', 'rf-hover-in')).toEqual([]); + expect(flagged('g.edge', 'rf-hover-out')).toEqual([]); + }); + + it('re-binds the highlights after a theme redraw re-injects the svg', async () => { + document.documentElement.removeAttribute('data-rf-theme-redraw-bound'); + bindThemeChartRedraw(); + await initWasmGraphs({ classMap: { hierarchy: classHierarchy, dotThresholdExceeded: false } }); + const dark = document.getElementById('rf-theme-dark'); + dark.checked = true; + dark.dispatchEvent(new window.Event('change', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 0)); + + // The freshly injected svg carries fresh listeners. + hoverOver(document.querySelector('#classGraph svg #curve-1')); + expect(flagged('g.edge', 'rf-hover-edge')).toEqual(['edge-1']); + expect(flagged('g.node', 'rf-hover-edge')).toEqual(['node-A', 'node-C']); + }); +}); + describe('popup cluster filtering (packages are noise)', () => { beforeEach(() => { document.body.innerHTML = ` diff --git a/tests/unit/theme-css.test.js b/tests/unit/theme-css.test.js index 69ad348..d9296de 100644 --- a/tests/unit/theme-css.test.js +++ b/tests/unit/theme-css.test.js @@ -316,6 +316,99 @@ describe('vizdom package clusters in dark mode', () => { }); }); +// DOT-graph hover highlights (vizdom SVGs): lib/report-view.js toggles the +// rf-hover-* state classes on mouseover/focus — the hovered edge plus both +// endpoint nodes take the amber attention accent, a hovered node takes the +// focus ring while its incoming edges + source nodes go green and its +// outgoing edges + sink nodes go blue. The accents re-stroke the baked +// shapes, so they must stay perceivable on every graph surface of their +// palette (SC 1.4.11) and stand apart from the slate --graph-line edges. +describe('graph hover highlight accents', () => { + it('declares per-palette amber/green/blue highlight accents', () => { + expect(lightPalette.get('--graph-hover-edge')).toBe('#b45309'); + expect(lightPalette.get('--graph-hover-in')).toBe('#1e8449'); + expect(lightPalette.get('--graph-hover-out')).toBe('#2a6f97'); + expect(darkPalette.get('--graph-hover-edge')).toBe('#f0b95a'); + expect(darkPalette.get('--graph-hover-in')).toBe('#7ee2a8'); + expect(darkPalette.get('--graph-hover-out')).toBe('#8ed0fa'); + // The on-accent label ink for highlighted nodes: white on the dark + // light-mode accent fills, black on the bright dark-mode accent fills. + expect(lightPalette.get('--graph-hover-on')).toBe('#ffffff'); + expect(darkPalette.get('--graph-hover-on')).toBe('#000000'); + }); + + it('fills and re-strokes hovered edges and their endpoint nodes with the amber accent', () => { + const curve = rules.get('main#app .fullscreen-svg g.edge.rf-hover-edge path[stroke]'); + expect(curve).toBeTruthy(); + expect(curve).toMatch(/stroke:\s*var\(--graph-hover-edge\)/); + expect(curve).toMatch(/stroke-width:\s*2\.5px/); + const arrow = rules.get('main#app .fullscreen-svg g.edge.rf-hover-edge polygon'); + expect(arrow).toMatch(/stroke:\s*var\(--graph-hover-edge\)/); + expect(arrow).toMatch(/fill:\s*var\(--graph-hover-edge\)/); + // Highlighted nodes fill with the accent, not just ring it. + const ring = rules.get('main#app .fullscreen-svg g.node.rf-hover-edge ellipse'); + expect(ring).toMatch(/stroke:\s*var\(--graph-hover-edge\)/); + expect(ring).toMatch(/fill:\s*var\(--graph-hover-edge\)/); + expect(rules.get('main#app .fullscreen-svg g.node.rf-hover-edge rect')) + .toMatch(/fill:\s*var\(--graph-hover-edge\)/); + }); + + it('fills and rings the hovered node and colors incoming vs outgoing differently', () => { + const focus = rules.get('main#app .fullscreen-svg g.node.rf-hover-focus ellipse'); + expect(focus).toMatch(/stroke:\s*var\(--graph-hover-edge\)/); + expect(focus).toMatch(/fill:\s*var\(--graph-hover-edge\)/); + for (const state of ['in', 'out']) { + const accent = `--graph-hover-${state}`; + const curve = rules.get(`main#app .fullscreen-svg g.edge.rf-hover-${state} path[stroke]`); + expect(curve).toMatch(new RegExp(`stroke:\\s*var\\(${accent}\\)`)); + const arrow = rules.get(`main#app .fullscreen-svg g.edge.rf-hover-${state} polygon`); + expect(arrow).toMatch(new RegExp(`stroke:\\s*var\\(${accent}\\)`)); + expect(arrow).toMatch(new RegExp(`fill:\\s*var\\(${accent}\\)`)); + const fill = rules.get(`main#app .fullscreen-svg g.node.rf-hover-${state} ellipse`); + expect(fill).toMatch(new RegExp(`stroke:\\s*var\\(${accent}\\)`)); + expect(fill).toMatch(new RegExp(`fill:\\s*var\\(${accent}\\)`)); + expect(rules.get(`main#app .fullscreen-svg g.node.rf-hover-${state} rect`)) + .toMatch(new RegExp(`fill:\\s*var\\(${accent}\\)`)); + } + // A node that is both a source and a sink of the hovered node is a + // bidirectional partner: it fills with the attention accent over the + // two directional states. + const both = rules.get('main#app .fullscreen-svg g.node.rf-hover-in.rf-hover-out ellipse'); + expect(both).toMatch(/fill:\s*var\(--graph-hover-edge\)/); + expect(both).toMatch(/stroke:\s*var\(--graph-hover-edge\)/); + }); + + it('re-inks the labels of highlighted nodes to stay readable on the fill', () => { + // Node labels are glyph paths with a baked black fill sitting on the + // shape; once the shape fills with an accent the ink must flip to the + // palette's on-accent color (SC 1.4.3). + for (const state of ['edge', 'focus', 'in', 'out']) { + const label = rules.get( + `main#app .fullscreen-svg g.node.rf-hover-${state} path[fill="black"]`); + expect(label).toBeTruthy(); + expect(label).toMatch(/fill:\s*var\(--graph-hover-on\)/); + } + }); + + it('keeps every accent perceivable on every graph surface of its palette (SC 1.4.11)', () => { + for (const palette of [lightPalette, darkPalette]) { + for (const accent of ['--graph-hover-edge', '--graph-hover-in', '--graph-hover-out']) { + for (const surface of ['--bg-color', '--graph-breakup-fill']) { + expect(contrastOf(palette, surface, accent)).toBeGreaterThanOrEqual(3); + } + } + } + }); + + it('keeps node labels readable on every accent fill (SC 1.4.3, both palettes)', () => { + for (const palette of [lightPalette, darkPalette]) { + for (const accent of ['--graph-hover-edge', '--graph-hover-in', '--graph-hover-out']) { + expect(contrastOf(palette, accent, '--graph-hover-on')).toBeGreaterThanOrEqual(4.5); + } + } + }); +}); + describe('popup close button', () => { it('is a red square re-driven from palette variables', () => { // The template ships a bare × button; the red-square chrome must come @@ -586,6 +679,42 @@ describe('report CSS selector boundaries', () => { .toEqual(['breakup-box', 'breakup-label']); }); + it('hover highlight rules stay inert until an element carries a hover state', () => { + const fixture = document.createElement('div'); + fixture.innerHTML = ` +
+ + + + + + + + + + +
`; + const selectors = [...rules] + .filter(([, body]) => /var\(--graph-hover-(?:edge|in|out|on)\)/.test(body)) + .map(([selector]) => selector); + expect(selectors.length).toBeGreaterThan(0); + // Nothing matches while no element is flagged… + expect([...fixture.querySelectorAll(selectors.join(','))]).toEqual([]); + // …including red removal edges and plain shapes once the states are set. + fixture.querySelector('#ge-hover').classList.add('rf-hover-edge'); + fixture.querySelector('#ge-in').classList.add('rf-hover-in'); + fixture.querySelector('#ge-out').classList.add('rf-hover-out'); + fixture.querySelector('#gn-hover').classList.add('rf-hover-edge'); + fixture.querySelector('#gn-focus').classList.add('rf-hover-focus'); + fixture.querySelector('#gn-in').classList.add('rf-hover-in'); + fixture.querySelector('#gn-out').classList.add('rf-hover-out'); + fixture.querySelector('#gn-both').classList.add('rf-hover-in', 'rf-hover-out'); + expect([...fixture.querySelectorAll(selectors.join(','))].map(el => el.id)) + .toEqual(['p-hover', 'a-hover', 'p-in', 'a-in', 'p-out', 'a-out', + 'e-hover', 'l-hover', 'e-focus', 'r-focus', 'l-focus', + 'e-in', 'l-in', 'e-out', 'l-out', 'e-both', 'l-both']); + }); + it('applies the unwrapped-table scrolling rule only to report data tables without enhancement', () => { const fixture = document.createElement('div'); fixture.innerHTML = ` From 9a8dd16e404e464f3bcfa5589d5f6d7bbcdbfc2a Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Sun, 25 Oct 2026 14:16:52 -0500 Subject: [PATCH 08/10] Highlighting nodes and edges and what they connect when they are moused over in the 2D and 3D popups --- AGENTS.md | 24 +- lib/hierarchy-dot.js | 18 +- lib/report-view.js | 207 ++++++++++---- tests/integration/report-view.test.jsx | 12 +- tests/unit/report-view.test.js | 370 ++++++++++++++++++++++--- 5 files changed, 529 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c61d83..250a76d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,19 @@ in `out/` is served by any static host (GitHub Pages, GitLab Pages, Bitbucket). `--graph-hover-*`); highlighted nodes fill with their accent and their label glyphs re-ink to `--graph-hover-on` (white on the dark light-mode accents, black on the bright dark-mode accents) to keep AA contrast; the - full hierarchy stays in refactor-first.json for LLM consumption + 2D/3D popups carry the same directional model (`bindSigmaHoverHighlight` + for the sigma 2D popup — node hover only, sigma 2.4 has no edge events — + and the hover block in `createForceGraph` for the 3D force graph, which + also highlights hovered links), resolving accents from the JS mirror + `GRAPH_HOVER_ACCENTS` in `lib/report-view.js`: the canvas-drawn 2D popup + follows the active palette like the SVGs, while the 3D popup's WebGL + canvas is always dark and therefore always uses the dark values. The 3D + popup's hover highlighting is always on (per-hover sprite recoloring + touches only nodes whose state changed, keeping it O(changed)), and + while nothing is highlighted the DOT graph's own colors show — each + link's DOT color (red removals, white fallback) and each node's DOT + color on its sprite label; the full + hierarchy stays in refactor-first.json for LLM consumption - Enhanced report tables: sticky headers, pagination (>20 rows), sortable columns, in-table search, CSV export, click/keyboard cell copy with toasts - Repository submission via pre-filled platform issues — no login, apps or tokens @@ -198,8 +210,12 @@ coverage there when introducing new markup patterns. edge at synthesis time via `graphDotOptions()` in `lib/report-view.js` (vizdom uppercases the hex in the SVG), and theme switches re-synthesize + re-render the graphs via `redrawGraphsForTheme` alongside - the chart legend redraw in `bindThemeChartRedraw`. Node/cluster labels - sit on vizdom's white shapes and stay default-colored in both palettes. + the chart legend redraw in `bindThemeChartRedraw`. Node labels sit on + vizdom's white shapes and keep the baked black ink in the non-hover + state (hovered or keyboard-focused node labels re-ink to + `--graph-hover-on`), while cluster labels are recolored by CSS along + with their rects (regular clusters' label glyphs follow `--graph-line`, + break-up clusters' labels take `--graph-breakup-line`). ## Report Tables (Enhanced) @@ -256,7 +272,7 @@ coverage there when introducing new markup patterns. ## Current Test Count -~736 unit/integration + ~229 E2E (~202 across three browsers + 4 basePath leg + 27 dark mode). +~787 unit/integration + ~232 E2E (~202 across three browsers + 4 basePath leg + 30 dark mode). WCAG 2.2 AA / HTML5 guards live in tests/unit/html5-attributes.test.js, tests/unit/report-template-wcag.test.js, tests/unit/css-a11y.test.js and diff --git a/lib/hierarchy-dot.js b/lib/hierarchy-dot.js index a355b68..61f2bbd 100644 --- a/lib/hierarchy-dot.js +++ b/lib/hierarchy-dot.js @@ -20,14 +20,16 @@ // because vizdom propagates a styled cluster's fill to its nested cluster // rects — the explicit white keeps child packages of a pink cluster on their // normal fill; white is vizdom's default cluster fill, so nothing changes -// visually for unflagged clusters. Node and cluster label glyphs keep vizdom's -// default black: -// shapes default to white fills (probe: cluster rects render -// fill="white" stroke="black"), so labels stay readable on every palette — -// only edge labels sit on the page background, which is why edgeFontColor -// exists (vizdom bakes fontcolor into fill-only glyph paths at parse time, -// unreachable by CSS; the dark palette re-synthesizes with -// fontcolor = CHART_LEGEND_TEXT.dark). +// visually for unflagged clusters. Label colors: shapes default to white +// fills (probe: cluster rects render fill="white" stroke="black"), so node +// labels stay readable on every palette without synthesis-time color. +// Non-hovered node labels keep the baked black ink; hovered or focused +// node labels re-ink to the CSS --graph-hover-on; regular and break-up +// cluster labels are recolored by CSS along with their rects +// (--graph-line / --graph-breakup-line). Only edge labels sit on the page +// background, which is why edgeFontColor exists (vizdom bakes fontcolor +// into fill-only glyph paths at parse time, unreachable by CSS; the dark +// palette re-synthesizes with fontcolor = CHART_LEGEND_TEXT.dark). // // Identifier contract: cluster ids, node ids, edge endpoints and weights are // interpolated UNQUOTED — re-quoting them would break the byte-identical diff --git a/lib/report-view.js b/lib/report-view.js index c02e69b..4dfc9d4 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -158,6 +158,21 @@ const SIGMA_HOVER_BG = Object.freeze({ dark: '#1a2330' }); +// Hover accents for the canvas/WebGL popup graphs, mirroring the +// --graph-hover-edge/-in/-out custom properties of each palette in +// app/globals.css (the values that style the vizdom SVGs' rf-hover-* +// classes; tests/unit/report-view.test.js guards the two sides against +// drift). CSS cannot reach canvas or WebGL, so the popups resolve accents +// in JS: the 2D sigma popup follows the active palette like the inline +// SVGs (its canvas sits on the themed popup surface), while the 3D +// force-graph popup always paints its own dark WebGL background and +// therefore always uses the dark values. When nothing is highlighted, both +// popups keep the colors the DOT graph itself specifies. +export const GRAPH_HOVER_ACCENTS = Object.freeze({ + light: Object.freeze({edge: '#b45309', in: '#1e8449', out: '#2a6f97'}), + dark: Object.freeze({edge: '#f0b95a', in: '#7ee2a8', out: '#8ed0fa'}) +}); + /** * Dark-palette counterpart of sigma.js' default hover renderer (upstream * drawHover, MIT): identical geometry — a rounded label box trailing a drop @@ -255,23 +270,39 @@ export function showPopup(popupId, containerName, dot) { } /** - * Highlights a hovered sigma popup node's surroundings: the edges directly - * connected to it and the nodes on their opposite ends. The hovered node - * itself already gets sigma's built-in hover rendering. "Accent only" — the - * rest of the graph stays visible and untouched: neighbor nodes are flagged - * `highlighted` so sigma re-renders them above the labels, and incident - * edges thicken. Rebuilt on every popup open, the highlight state lives - * only as long as the sigma instance. + * Highlights a hovered sigma popup node's neighborhood with the same + * directional model as the vizdom SVGs: the hovered node fills with the + * amber attention accent, its incoming edges and their source nodes fill + * green, and its outgoing edges and their sink nodes fill blue — a node + * holding both states is a bidirectional partner and takes the amber + * accent. The accents resolve from the active palette at popup-open time + * (canvas-drawn, so CSS cannot reach them; popups are rebuilt on every + * open, and the theme radios sit under the modal overlay while one is + * open). "Accent only": the rest of the graph stays untouched, flagged + * nodes re-render with sigma's highlighted style so their labels show, and + * flagged edges thicken. Sigma 2.4.0's event model has no edge hover + * events (edge interactivity arrived in sigma 3), so the 2D popup binds + * node hover only; the 3D popup carries the edge-hover half. * * @param {object} sigma - Live Sigma instance (needs on/setSetting/ * scheduleRefresh). - * @param {object} graph - Graphology graph backing the instance. + * @param {object} graph - Graphology graph backing the instance (uses its + * edges(node) and extremities(edge) APIs when present). * @returns {void} */ export function bindSigmaHoverHighlight(sigma, graph) { if (!sigma || typeof sigma.on !== 'function' || typeof sigma.setSetting !== 'function' || !graph) return; - const highlightedNodes = new Set(); - const highlightedEdges = new Set(); + const accents = GRAPH_HOVER_ACCENTS[activeChartTheme()] || GRAPH_HOVER_ACCENTS.light; + const nodeStates = new Map(); // node -> 'focus' | 'in' | 'out' | 'both' + const edgeStates = new Map(); // edge -> 'in' | 'out' + + const clear = () => { + nodeStates.clear(); + edgeStates.clear(); + }; + const refresh = () => { + if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh(); + }; sigma.on('enterNode', payload => { const node = payload && payload.node; @@ -279,26 +310,46 @@ export function bindSigmaHoverHighlight(sigma, graph) { // Clear-then-populate so hopping straight from one node to another // (leaveNode fires first, but re-entering stays correct either way) // never leaves stale members behind. - highlightedNodes.clear(); - highlightedEdges.clear(); - graph.forEachNeighbor(node, neighbor => highlightedNodes.add(neighbor)); + clear(); + nodeStates.set(node, 'focus'); if (typeof graph.edges === 'function') { - graph.edges(node).forEach(edge => highlightedEdges.add(edge)); + graph.edges(node).forEach(edge => { + const extremities = typeof graph.extremities === 'function' + ? graph.extremities(edge) : []; + const [source, target] = extremities; + if (target === node) { + edgeStates.set(edge, 'in'); + if (source !== node) { + nodeStates.set(source, nodeStates.get(source) === 'out' ? 'both' : 'in'); + } + } else if (source === node) { + edgeStates.set(edge, 'out'); + if (target !== node) { + nodeStates.set(target, nodeStates.get(target) === 'in' ? 'both' : 'out'); + } + } + }); } // Scheduled, not synchronous: sigma coalesces per animation frame, so // the leave/enter pair of one pointer movement re-renders only once. - if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh(); + refresh(); }); sigma.on('leaveNode', () => { - if (!highlightedNodes.size && !highlightedEdges.size) return; - highlightedNodes.clear(); - highlightedEdges.clear(); - if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh(); + if (!nodeStates.size && !edgeStates.size) return; + clear(); + refresh(); + }); + sigma.setSetting('nodeReducer', (node, data) => { + const state = nodeStates.get(node); + if (!state) return data; + const accent = state === 'in' ? accents.in : state === 'out' ? accents.out : accents.edge; + return {...data, color: accent, highlighted: true}; + }); + sigma.setSetting('edgeReducer', (edge, data) => { + const state = edgeStates.get(edge); + if (!state) return data; + return {...data, color: accents[state], size: 3}; }); - sigma.setSetting('nodeReducer', (node, data) => - (highlightedNodes.has(node) ? {...data, highlighted: true} : data)); - sigma.setSetting('edgeReducer', (edge, data) => - (highlightedEdges.has(edge) ? {...data, size: 3} : data)); } export function hidePopup() { @@ -368,6 +419,11 @@ export async function createForceGraph(popupId, containerName, dot) { gData.links.forEach(link => { const a = gData.nodes.find(node => node.id === link.source); const b = gData.nodes.find(node => node.id === link.target); + // Resolved endpoint objects ride on each link so the hover handlers + // below never depend on force-graph's ingestion-time rewiring of + // link.source/link.target from ids into node references. + link.sourceNode = a; + link.targetNode = b; a.neighbors.push(b); b.neighbors.push(a); a.links.push(link); @@ -382,51 +438,96 @@ export async function createForceGraph(popupId, containerName, dot) { if (gData.links.length + gData.nodes.length < 4000) { const SpriteText = await loadSpriteText(); - const highlightNodes = new Set(); - const highlightLinks = new Set(); - let hoverNode = null; + // The same directional hover model as the vizdom SVGs, always on: + // hovering a node fills it amber while its incoming links and + // sources go green and its outgoing links and sinks go blue (a + // both-directions partner takes amber); hovering a link fills it and + // both endpoints amber. The accents are always the dark palette + // values — the WebGL canvas paints its own dark background in every + // theme — and while nothing is highlighted, the DOT graph's own + // colors show: each link's DOT color (red removals, white fallback) + // and each node's DOT color on its sprite label. + const accents = GRAPH_HOVER_ACCENTS.dark; + const nodeStates = new Map(); // node -> 'focus' | 'edge' | 'in' | 'out' | 'both' + const linkStates = new Map(); // link -> 'edge' | 'in' | 'out' + const spriteByNode = new Map(); + + const nodeAccent = node => { + const state = nodeStates.get(node); + if (!state) return node.color || 'white'; + return state === 'in' ? accents.in : state === 'out' ? accents.out : accents.edge; + }; + const clearStates = () => { + nodeStates.clear(); + linkStates.clear(); + }; + // Sprites recolor only for nodes whose highlight state changed since + // the previous hover — the previous ∪ current flag set — so the + // always-on hover stays O(changed) instead of O(all sprites). + let touchedNodes = new Set(); + const syncSprites = () => { + const dirty = new Set([...touchedNodes, ...nodeStates.keys()]); + for (const node of dirty) { + const sprite = spriteByNode.get(node); + if (sprite) sprite.color = nodeAccent(node); + } + touchedNodes = new Set(nodeStates.keys()); + }; const updateHighlight = () => { + syncSprites(); graph - .nodeColor(graph.nodeColor()) + .linkColor(graph.linkColor()) .linkWidth(graph.linkWidth()) .linkDirectionalParticles(graph.linkDirectionalParticles()); }; graph .nodeThreeObject(node => { - const sprite = new SpriteText(node.label); - sprite.material.depthWrite = false; - sprite.color = node.color; - sprite.textHeight = 4; + let sprite = spriteByNode.get(node); + if (!sprite) { + sprite = new SpriteText(node.label); + sprite.material.depthWrite = false; + sprite.textHeight = 4; + spriteByNode.set(node, sprite); + } + sprite.color = nodeAccent(node); return sprite; }) - .nodeColor(node => - highlightNodes.has(node) - ? (node === hoverNode ? 'rgb(255,0,0,1)' : 'rgba(255,160,0,0.8)') - : 'rgba(0,255,255,0.6)') - .linkWidth(link => (highlightLinks.has(link) ? 4 : 1)) - .linkDirectionalParticles(link => (highlightLinks.has(link) ? 4 : 0)) + .linkColor(link => (linkStates.has(link) ? accents[linkStates.get(link)] : link.color || 'white')) + .linkWidth(link => (linkStates.has(link) ? 4 : 1)) + .linkDirectionalParticles(link => (linkStates.has(link) ? 4 : 0)) .linkDirectionalParticleWidth(4) .onNodeHover(node => { - if ((!node && !highlightNodes.size) || (node && hoverNode === node)) return; - highlightNodes.clear(); - highlightLinks.clear(); + if ((!node && !nodeStates.size) || (node && nodeStates.get(node) === 'focus')) return; + clearStates(); if (node) { - highlightNodes.add(node); - node.neighbors.forEach(neighbor => highlightNodes.add(neighbor)); - node.links.forEach(link => highlightLinks.add(link)); + nodeStates.set(node, 'focus'); + node.links.forEach(link => { + if (link.targetNode === node) { + linkStates.set(link, 'in'); + if (link.sourceNode !== node) { + nodeStates.set(link.sourceNode, + nodeStates.get(link.sourceNode) === 'out' ? 'both' : 'in'); + } + } else if (link.sourceNode === node) { + linkStates.set(link, 'out'); + if (link.targetNode !== node) { + nodeStates.set(link.targetNode, + nodeStates.get(link.targetNode) === 'in' ? 'both' : 'out'); + } + } + }); } - hoverNode = node || null; updateHighlight(); }) .onLinkHover(link => { - highlightNodes.clear(); - highlightLinks.clear(); + linkStates.clear(); + nodeStates.clear(); if (link) { - highlightLinks.add(link); - highlightNodes.add(link.source); - highlightNodes.add(link.target); + linkStates.set(link, 'edge'); + nodeStates.set(link.sourceNode, 'edge'); + nodeStates.set(link.targetNode, 'edge'); } updateHighlight(); }); @@ -900,8 +1001,12 @@ export function initDisharmonyCharts(disharmonies, repoUrl) { * Synthesis options for the active palette: the dark palette bakes the muted * edge-label color into the DOT (vizdom renders labels as fill-only glyph * paths at parse time, beyond CSS reach), the light palette keeps vizdom's - * default black. Node and cluster labels sit on white shapes in both palettes - * and stay default-colored. + * default black. Only edge labels need that synthesis-time color: node + * labels sit on white shapes in both palettes and keep the baked black + * ink in the non-hover state (hovered or focused node labels re-ink to + * the CSS --graph-hover-on), while cluster labels are recolored by CSS + * along with their rects (--graph-line on regular clusters, + * --graph-breakup-line on break-up clusters). * * @returns {object} Options for hierarchyToDot. */ diff --git a/tests/integration/report-view.test.jsx b/tests/integration/report-view.test.jsx index a673001..a3bec24 100644 --- a/tests/integration/report-view.test.jsx +++ b/tests/integration/report-view.test.jsx @@ -612,7 +612,7 @@ describe('enhanced report tables: sorting', () => { const cycleCounts = tableRows(utils, 'class-relationships') .map(row => Number(row.children[2].textContent.trim())); expect([...cycleCounts].sort((a, b) => a - b)).toEqual(cycleCounts); - }); + }, SLOW_TEST_MS); }); describe('enhanced report tables: search and filter', () => { @@ -634,7 +634,7 @@ describe('enhanced report tables: search and filter', () => { for (const row of tableRows(utils, 'class-relationships')) { expect(row.textContent.toLowerCase()).toContain('assert'); } - }); + }, SLOW_TEST_MS); test('the clear button restores the full dataset', async () => { respondJsonFor(sampleJson); @@ -652,7 +652,7 @@ describe('enhanced report tables: search and filter', () => { await waitForPage(utils, 'class-relationships', 'Page 1 of 3'); expect(tableRows(utils, 'class-relationships').length).toBe(20); expect(input().value).toBe(''); - }); + }, SLOW_TEST_MS); test('the search input keeps focus and its value across re-renders', async () => { respondJsonFor(sampleJson); @@ -669,7 +669,7 @@ describe('enhanced report tables: search and filter', () => { const refocused = utils.container.querySelector('[data-rf-search="class-relationships"]'); expect(document.activeElement).toBe(refocused); expect(refocused.value).toBe('junit'); - }); + }, SLOW_TEST_MS); }); describe('enhanced report tables: stateful widgets survive interactions', () => { @@ -714,7 +714,7 @@ describe('enhanced report tables: stateful widgets survive interactions', () => expect(utils.container.querySelector('canvas#chart_GOD')).toBe(liveCanvas); expect(utils.container.querySelector('#classGraph')).toBe(liveGraph); expect(utils.container.querySelector('#classGraph svg')).not.toBeNull(); - }); + }, SLOW_TEST_MS); test('popup buttons keep working after a table interaction re-render', async () => { respondJsonFor(sampleJson); @@ -730,7 +730,7 @@ describe('enhanced report tables: stateful widgets survive interactions', () => fireEvent.click(popupButton); expect(document.getElementById('overlay').style.display).toBe('block'); hideAllPopups(); - }); + }, SLOW_TEST_MS); test('a payload change destroys the previous charts before the loading markup and re-creates them', async () => { // Every Chart.js construction/teardown, in order, so the test can pin diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index 8818f6b..bab2104 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { JSDOM } from 'jsdom'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; import { exposeGraphDots, initBubbleChart, @@ -15,6 +18,7 @@ import { resolveLegendTextColor, bindThemeChartRedraw, bindSigmaHoverHighlight, + GRAPH_HOVER_ACCENTS, destroyBubbleCharts, statefulElementIds, stashStatefulDom, @@ -1459,7 +1463,7 @@ describe('popup cluster filtering (packages are noise)', () => { const capturedData = []; const chain = {}; for (const method of [ - 'graphData', 'nodeLabel', 'width', 'height', 'nodeThreeObject', 'nodeColor', + 'graphData', 'nodeLabel', 'width', 'height', 'nodeThreeObject', 'linkColor', 'linkWidth', 'linkDirectionalParticles', 'linkDirectionalParticleWidth', 'onNodeHover', 'onLinkHover' ]) { @@ -1479,13 +1483,257 @@ describe('popup cluster filtering (packages are noise)', () => { expect(capturedData).toHaveLength(1); expect(capturedData[0].nodes.map(n => n.id)).toEqual(['Alpha', 'Beta', 'cluster_worker']); expect(capturedData[0].nodes.map(n => n.label)).toEqual(['Alpha', 'Beta', 'cluster_worker']); + const [alpha, beta, worker] = capturedData[0].nodes; + // Each link carries its resolved endpoint objects so the hover handlers + // never depend on force-graph's ingestion-time source/target rewiring. expect(capturedData[0].links).toEqual([ - {source: 'Alpha', target: 'Beta', color: 'red', weight: 2}, - {source: 'cluster_worker', target: 'Beta', color: 'blue', weight: 7} + {source: 'Alpha', target: 'Beta', color: 'red', weight: 2, sourceNode: alpha, targetNode: beta}, + {source: 'cluster_worker', target: 'Beta', color: 'blue', weight: 7, sourceNode: worker, targetNode: beta} ]); }); }); +// The 3D force-graph popup carries the same directional hover model as the +// vizdom SVGs (hovered node amber, incoming green, outgoing blue, hovered +// link amber with both endpoints) — always on, its WebGL canvas always +// dark, the accents always the dark palette values. When nothing is +// highlighted, the DOT graph's own colors show: red removal links, white +// plain links, and the nodes' DOT colors on the sprite labels. Per-hover +// sprite recoloring touches only nodes whose state changed. +describe('createForceGraph hover highlights', () => { + // Directed A->B, B->A (bidirectional pair), C->A: hovering A exercises + // every state, including the both-in-and-out partner. A->B carries the + // DOT color of a removal relationship (red); the others carry no DOT + // color and fall back to white, like the real report DOT. + function stubDirectedGraphlib() { + window.graphlibDot = { + read: () => ({ + nodes: () => ['A', 'B', 'C'], + node: id => ({label: id}), + children: () => [], + edges: () => [{v: 'A', w: 'B'}, {v: 'B', w: 'A'}, {v: 'C', w: 'A'}], + edge: e => (e.v === 'A' && e.w === 'B' ? {color: 'red', weight: 1} : {weight: 1}) + }) + }; + } + + function stubForceGraph() { + const captured = { accessors: {}, chain: {} }; + for (const method of [ + 'graphData', 'nodeLabel', 'width', 'height', 'nodeThreeObject', 'linkColor', + 'linkWidth', 'linkDirectionalParticles', 'linkDirectionalParticleWidth', + 'onNodeHover', 'onLinkHover' + ]) { + // force-graph accessors are dual getter/setters: calling with an + // argument sets it (returning the chain), calling without returns the + // current accessor — updateHighlight() relies on both forms. + captured.chain[method] = function (arg) { + if (arguments.length > 0) { + captured.accessors[method] = arg; + return this; + } + return captured.accessors[method]; + }; + } + return captured; + } + + beforeEach(() => { + document.body.innerHTML = ` + + `; + stubDirectedGraphlib(); + window.SpriteText = function (label) { + this.label = label; + this.material = {}; + }; + }); + + afterEach(() => { + delete window.graphlibDot; + delete window.ForceGraph3D; + delete window.SpriteText; + }); + + async function buildGraph() { + const captured = stubForceGraph(); + window.ForceGraph3D = function () { + return captured.chain; + }; + await createForceGraph('popup-classGraph', 'graph-container-classGraph', 'ignored-by-stub'); + return { captured, data: captured.accessors.graphData }; + } + + it('ships no palette toggle and rests on the DOT-specified colors', async () => { + const { captured, data } = await buildGraph(); + // The toggle era is over: the enhanced palette is the only palette, so + // no checkbox ships. + expect(document.querySelector('#popup-classGraph .rf-enhanced-toggle')).toBeNull(); + // Hover highlighting is armed at popup build… + expect(typeof captured.accessors.linkColor).toBe('function'); + expect(typeof captured.accessors.linkWidth).toBe('function'); + expect(typeof captured.accessors.linkDirectionalParticles).toBe('function'); + expect(typeof captured.accessors.onNodeHover).toBe('function'); + expect(typeof captured.accessors.onLinkHover).toBe('function'); + // …and the resting colors are the ones the DOT graph specifies: red + // removal links, white uncolored links, thin, without particles. + const [ab, ba] = data.links; + expect(captured.accessors.linkColor(ab)).toBe('red'); + expect(captured.accessors.linkColor(ba)).toBe('white'); + expect(captured.accessors.linkWidth(ab)).toBe(1); + expect(captured.accessors.linkDirectionalParticles(ab)).toBe(0); + // The sprite labels render with their DOT colors. + const sprite = captured.accessors.nodeThreeObject({ label: 'A', color: 'white' }); + expect(sprite.label).toBe('A'); + expect(sprite.color).toBe('white'); + }); + + it('caches each node\'s sprite so hover recoloring mutates the live object', async () => { + const { captured, data } = await buildGraph(); + const [a] = data.nodes; + const spriteA = captured.accessors.nodeThreeObject(a); + expect(captured.accessors.nodeThreeObject(a)).toBe(spriteA); + expect(spriteA.label).toBe('A'); + expect(spriteA.color).toBe('white'); // DOT resting color until a hover flags it + }); + + it('splits a hovered node\'s directions with the dark palette accents', async () => { + const { captured, data } = await buildGraph(); + const [a, b, c] = data.nodes; + const [ab, ba, ca] = data.links; + const spriteOf = node => captured.accessors.nodeThreeObject(node); + const spriteA = spriteOf(a); + const spriteB = spriteOf(b); + const spriteC = spriteOf(c); + + captured.accessors.onNodeHover(a); + // The hovered node takes the dark amber attention accent… + expect(spriteA.color).toBe('#f0b95a'); + // …B is both an incoming source (of B->A) and an outgoing sink (of + // A->B): the bidirectional partner, amber over either direction… + expect(spriteB.color).toBe('#f0b95a'); + // …and C, only an incoming source, goes dark green. + expect(spriteC.color).toBe('#7ee2a8'); + // Links follow: incoming dark green, outgoing dark blue, both thickened + // with flowing particles — the accent overrides the DOT red while + // hovered. + expect(captured.accessors.linkColor(ba)).toBe('#7ee2a8'); + expect(captured.accessors.linkColor(ca)).toBe('#7ee2a8'); + expect(captured.accessors.linkColor(ab)).toBe('#8ed0fa'); + expect(captured.accessors.linkWidth(ab)).toBe(4); + expect(captured.accessors.linkDirectionalParticles(ab)).toBe(4); + }); + + it('restores the DOT-specified link colors when the hover clears', async () => { + const { captured, data } = await buildGraph(); + const [a] = data.nodes; + const [ab] = data.links; + // Resting: the DOT colors — the red removal link, over the DOT-white + // plain links pinned by the sibling test. + expect(captured.accessors.linkColor(ab)).toBe('red'); + + const spriteA = captured.accessors.nodeThreeObject(a); + captured.accessors.onNodeHover(a); + // Flagged: the accent overrides the DOT red while hovered… + expect(spriteA.color).toBe('#f0b95a'); + expect(captured.accessors.linkColor(ab)).toBe('#8ed0fa'); + // …and clearing the hover restores the DOT colors and the sprite's own. + captured.accessors.onNodeHover(null); + expect(spriteA.color).toBe('white'); + expect(captured.accessors.linkColor(ab)).toBe('red'); + }); + + it('flags a hovered link and both endpoints with the amber attention accent', async () => { + const { captured, data } = await buildGraph(); + const [a, b] = data.nodes; + const link = data.links[0]; // A -> B + const spriteA = captured.accessors.nodeThreeObject(a); + const spriteB = captured.accessors.nodeThreeObject(b); + + captured.accessors.onLinkHover(link); + expect(spriteA.color).toBe('#f0b95a'); + expect(spriteB.color).toBe('#f0b95a'); + expect(captured.accessors.linkColor(link)).toBe('#f0b95a'); + expect(captured.accessors.linkWidth(link)).toBe(4); + expect(captured.accessors.linkDirectionalParticles(link)).toBe(4); + }); + + it('recolors only the sprites whose highlight state changed', async () => { + // A counting sprite + an unconnected node pin that a hover never touches + // sprites outside the (previous ∪ current) highlight set — the always-on + // hover stays O(changed) instead of O(all sprites) per pointer move. + window.SpriteText = function (label) { + this.label = label; + this.material = {}; + this.setColorCount = 0; + this._color = null; + }; + Object.defineProperty(window.SpriteText.prototype, 'color', { + get() { return this._color; }, + set(value) { + this.setColorCount += 1; + this._color = value; + } + }); + // One extra node no edge touches. + stubDirectedGraphlib(); + const innerRead = window.graphlibDot.read; + window.graphlibDot.read = () => { + const parsed = innerRead(); + return { + ...parsed, + nodes: () => [...parsed.nodes(), 'D'], + node: id => ({label: id}) + }; + }; + const { captured, data } = await buildGraph(); + const [a, , , d] = data.nodes; + const spriteA = captured.accessors.nodeThreeObject(a); + const spriteD = captured.accessors.nodeThreeObject(d); + expect(spriteA.setColorCount).toBe(1); // creation coloring only + expect(spriteD.setColorCount).toBe(1); + + captured.accessors.onNodeHover(a); + captured.accessors.onNodeHover(null); + captured.accessors.onNodeHover(a); + // The flagged node's sprite recolors on every state change… + expect(spriteA.setColorCount).toBe(4); + expect(spriteA.color).toBe('#f0b95a'); + // …while the unflagged node's sprite is never touched again. + expect(spriteD.setColorCount).toBe(1); + expect(spriteD.color).toBe('white'); + }); +}); + +// The popups are canvas/WebGL drawn, so CSS cannot reach them: the accents +// live in JS (GRAPH_HOVER_ACCENTS) and must never drift from the +// --graph-hover-* palette declarations in app/globals.css that style the +// vizdom SVGs. +describe('popup hover accents mirror the CSS palette', () => { + const css = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../../app/globals.css'), + 'utf-8' + ); + + it('GRAPH_HOVER_ACCENTS match the --graph-hover-* declarations per palette', () => { + // File order: the light :root block, then the two byte-identical dark + // blocks — the theme-css guard pins the dark pair together, so each + // role must resolve to [light, dark, dark]. + for (const [role, values] of [ + ['edge', ['#b45309', '#f0b95a', '#f0b95a']], + ['in', ['#1e8449', '#7ee2a8', '#7ee2a8']], + ['out', ['#2a6f97', '#8ed0fa', '#8ed0fa']] + ]) { + expect([...css.matchAll(new RegExp(`--graph-hover-${role}:\\s*(#[0-9a-f]{6})`, 'g'))] + .map(match => match[1])).toEqual(values); + } + expect(GRAPH_HOVER_ACCENTS.light).toEqual({edge: '#b45309', in: '#1e8449', out: '#2a6f97'}); + expect(GRAPH_HOVER_ACCENTS.dark).toEqual({edge: '#f0b95a', in: '#7ee2a8', out: '#8ed0fa'}); + }); +}); + describe('showPopup sigma theme settings', () => { beforeEach(() => { document.body.innerHTML = ` @@ -1629,7 +1877,7 @@ describe('showPopup sigma theme settings', () => { expect(constructed[0].settings).toBeUndefined(); }); - it('binds neighbor and edge hover highlighting onto the sigma instance', () => { + it('binds the directional hover highlighting onto the sigma instance', () => { installRadiosAndPopup('light'); const constructed = stubSigmaStack(); showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}'); @@ -1642,16 +1890,21 @@ describe('showPopup sigma theme settings', () => { }); describe('bindSigmaHoverHighlight', () => { - // A stub graphology graph: edges eAB (A-B), eAC (A-C), eBD (B-D). D is a - // non-neighbor of A; eBD is not incident to A. - function stubHoverGraph() { + // A stub directed graphology graph: A->B, B->A (a bidirectional pair), + // A->C and B->D (unrelated to A). Hovering A flags eBA + node B green + // (incoming), eAB + node B / eAC + node C blue (outgoing); node B holds + // both states and takes the amber attention accent — mirroring the vizdom + // rf-hover-* model. The 2D popup is canvas-drawn, so the accents resolve + // from the active palette like CHART_LEGEND_TEXT. + function stubHoverGraph(overrides = {}) { return { - forEachNeighbor(node, callback) { - ({ A: ['B', 'C'], B: ['A', 'D'], C: ['A'], D: ['B'] })[node].forEach(callback); - }, edges(node) { - return ({ A: ['eAB', 'eAC'], B: ['eAB', 'eBD'], C: ['eAC'], D: ['eBD'] })[node]; - } + return ({A: ['eAB', 'eBA', 'eAC'], B: ['eAB', 'eBA', 'eBD'], C: ['eAC'], D: ['eBD']})[node] || []; + }, + extremities(edge) { + return ({eAB: ['A', 'B'], eBA: ['B', 'A'], eAC: ['A', 'C'], eBD: ['B', 'D']})[edge]; + }, + ...overrides }; } @@ -1668,59 +1921,93 @@ describe('bindSigmaHoverHighlight', () => { return instance; } - it('flags neighbor nodes and thickens the incident edges of the hovered node', () => { + it('splits incoming and outgoing neighborhoods around the hovered node (light accents)', () => { + installThemeRadios('light'); const instance = stubSigmaInstance(); bindSigmaHoverHighlight(instance, stubHoverGraph()); instance.fire('enterNode', { node: 'A' }); expect(instance.scheduledRefreshes).toBe(1); - const neighbor = { label: 'B', size: 5 }; + // The hovered node fills with the amber attention accent… + expect(instance.installed.nodeReducer('A', { label: 'A', size: 5 })) + .toEqual({ label: 'A', size: 5, color: '#b45309', highlighted: true }); + // …B is both an incoming source and an outgoing sink: amber… + expect(instance.installed.nodeReducer('B', { label: 'B', size: 5 }).color).toBe('#b45309'); + // …C is only an outgoing sink: blue… + expect(instance.installed.nodeReducer('C', { label: 'C', size: 5 }).color).toBe('#2a6f97'); + // …and untouched nodes keep their display data. const stranger = { label: 'D', size: 5 }; - const hovered = { label: 'A', size: 5 }; - // Nodes across the hovered node's edges are re-rendered above labels… - expect(instance.installed.nodeReducer('B', neighbor)).toEqual({ label: 'B', size: 5, highlighted: true }); - // …while the hovered node itself (already emphasized by sigma's hover - // layer) and everything else keep their display data untouched. expect(instance.installed.nodeReducer('D', stranger)).toBe(stranger); - expect(instance.installed.nodeReducer('A', hovered)).toBe(hovered); - const incident = { size: 1, color: 'black' }; + // Incoming edges fill green and thicken, outgoing edges fill blue… + expect(instance.installed.edgeReducer('eBA', { size: 1, color: 'black' })) + .toEqual({ size: 3, color: '#1e8449' }); + expect(instance.installed.edgeReducer('eAB', { size: 1, color: 'black' })) + .toEqual({ size: 3, color: '#2a6f97' }); + expect(instance.installed.edgeReducer('eAC', { size: 1, color: 'black' })) + .toEqual({ size: 3, color: '#2a6f97' }); + // …while unrelated edges keep their display data. const unrelated = { size: 1, color: 'black' }; - // Edges directly connected to the hovered node thicken… - expect(instance.installed.edgeReducer('eAB', incident)).toEqual({ size: 3, color: 'black' }); - expect(instance.installed.edgeReducer('eAC', incident)).toEqual({ size: 3, color: 'black' }); - // …while unrelated edges keep their display data untouched. expect(instance.installed.edgeReducer('eBD', unrelated)).toBe(unrelated); }); + it('resolves the dark accents while the dark palette is active', () => { + installThemeRadios('dark'); + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('enterNode', { node: 'A' }); + expect(instance.installed.nodeReducer('A', { label: 'A', size: 5 }).color).toBe('#f0b95a'); + expect(instance.installed.nodeReducer('C', { label: 'C', size: 5 }).color).toBe('#8ed0fa'); + expect(instance.installed.edgeReducer('eBA', { size: 1 }).color).toBe('#7ee2a8'); + expect(instance.installed.edgeReducer('eAC', { size: 1 }).color).toBe('#8ed0fa'); + }); + + it('counts a self-loop as incoming without derailing the focus state', () => { + installThemeRadios('light'); + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph({ + edges: node => (node === 'A' ? ['eAA'] : []), + extremities: () => ['A', 'A'] + })); + instance.fire('enterNode', { node: 'A' }); + expect(instance.installed.nodeReducer('A', { label: 'A', size: 5 }).color).toBe('#b45309'); + expect(instance.installed.edgeReducer('eAA', { size: 1 }).color).toBe('#1e8449'); + }); + it('clears the highlighting when the node is left', () => { + installThemeRadios('light'); const instance = stubSigmaInstance(); bindSigmaHoverHighlight(instance, stubHoverGraph()); instance.fire('enterNode', { node: 'A' }); instance.fire('leaveNode', {}); expect(instance.scheduledRefreshes).toBe(2); - const data = { label: 'B', size: 5 }; - expect(instance.installed.nodeReducer('B', data)).toBe(data); - expect(instance.installed.edgeReducer('eAB', data)).toBe(data); + const nodeData = { label: 'B', size: 5 }; + const edgeData = { size: 1, color: 'black' }; + expect(instance.installed.nodeReducer('B', nodeData)).toBe(nodeData); + expect(instance.installed.edgeReducer('eAB', edgeData)).toBe(edgeData); }); it('re-populates cleanly when hopping straight from one node to another', () => { + installThemeRadios('light'); const instance = stubSigmaInstance(); bindSigmaHoverHighlight(instance, stubHoverGraph()); instance.fire('enterNode', { node: 'A' }); instance.fire('enterNode', { node: 'B' }); - // Highlighting follows B: neighbors A and D, incident edges eAB and eBD. - const gone = { label: 'C', size: 5 }; - expect(instance.installed.nodeReducer('C', gone)).toBe(gone); - expect(instance.installed.nodeReducer('D', { size: 5 })).toEqual({ size: 5, highlighted: true }); + // Highlighting follows B: eAB is incoming (A is its green source), eBA + // is outgoing (A is its blue sink — and holds both states again), and + // eBD is outgoing with its blue sink D. C drops out entirely. + expect(instance.installed.nodeReducer('A', { label: 'A', size: 5 }).color).toBe('#b45309'); + expect(instance.installed.nodeReducer('C', { label: 'C', size: 5 }).color).toBeUndefined(); + expect(instance.installed.edgeReducer('eAB', { size: 1 }).color).toBe('#1e8449'); + expect(instance.installed.edgeReducer('eBD', { size: 1 }).color).toBe('#2a6f97'); const dropped = { size: 1, color: 'black' }; expect(instance.installed.edgeReducer('eAC', dropped)).toBe(dropped); - expect(instance.installed.edgeReducer('eBD', { size: 1, color: 'black' })).toEqual({ size: 3, color: 'black' }); }); it('ignores leave events when nothing is highlighted', () => { + installThemeRadios('light'); const instance = stubSigmaInstance(); bindSigmaHoverHighlight(instance, stubHoverGraph()); instance.fire('leaveNode', {}); @@ -1728,6 +2015,7 @@ describe('bindSigmaHoverHighlight', () => { }); it('ignores enter payloads without a node', () => { + installThemeRadios('light'); const instance = stubSigmaInstance(); bindSigmaHoverHighlight(instance, stubHoverGraph()); instance.fire('enterNode', {}); @@ -1736,6 +2024,22 @@ describe('bindSigmaHoverHighlight', () => { expect(instance.installed.nodeReducer('B', data)).toBe(data); }); + it('tolerates graphs without the edges or extremities APIs', () => { + installThemeRadios('light'); + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph({ edges: undefined })); + instance.fire('enterNode', { node: 'A' }); + // The focus node still fills; nothing throws. + expect(instance.installed.nodeReducer('A', { label: 'A', size: 5 }).color).toBe('#b45309'); + + const bare = stubSigmaInstance(); + bindSigmaHoverHighlight(bare, stubHoverGraph({ extremities: undefined })); + bare.fire('enterNode', { node: 'A' }); + expect(bare.installed.nodeReducer('A', { label: 'A', size: 5 }).color).toBe('#b45309'); + const edgeData = { size: 1, color: 'black' }; + expect(bare.installed.edgeReducer('eAB', edgeData)).toBe(edgeData); + }); + it('does not throw for a missing sigma, event-less sigma or missing graph', () => { expect(() => bindSigmaHoverHighlight(null, stubHoverGraph())).not.toThrow(); expect(() => bindSigmaHoverHighlight({ scheduleRefresh() {} }, stubHoverGraph())).not.toThrow(); From 6fbb9a35f586ddbce931770df29d2d14bcc30e46 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Sun, 25 Oct 2026 21:34:44 -0500 Subject: [PATCH 09/10] fixing e2e tests --- app/globals.css | 1122 --------------------------------- plans/nextjs-conversion.md | 8 +- tests/e2e/performance.spec.js | 16 +- 3 files changed, 16 insertions(+), 1130 deletions(-) delete mode 100644 app/globals.css diff --git a/app/globals.css b/app/globals.css deleted file mode 100644 index a879d76..0000000 --- a/app/globals.css +++ /dev/null @@ -1,1122 +0,0 @@ -/* Main stylesheet: CSS custom properties, base layout, footer. */ - -/* ---- Color palettes (dark mode: plans/css-only-dark-mode.md) ---- */ -/* The theme is selected purely in CSS: the three radios in - * components/theme-toggle.jsx are the single source of truth. - * - explicit "dark" radio -> :root:has(#rf-theme-dark:checked) below; - * - "system" radio + dark OS -> the prefers-color-scheme block below; - * - anything else -> this light :root palette. - * :has() blocks (1,2,0) beat mvp.css's later :root (0,1,0); the light mvp - * overrides therefore need html:root (0,1,1) to survive the load order. */ - -:root { - color-scheme: light; - --brand-color: #2a6f97; - /* Darker accent: white text on it passes WCAG 2.2 AA contrast (1.4.3) and - it serves as the visible focus indicator (2.4.11, >= 3:1 against the - white page background). */ - --brand-accent: #1f5273; - /* --brand-color is an accent *background* (white text sits on it, in both - palettes). Accent *text* (links, wordmark) needs its own role: #2a6f97 - as text fails AA on the dark page background. */ - --link-color: #2a6f97; - --on-accent-color: #ffffff; - --text-color: #1f2d3d; - --muted-color: #5c6b7a; - --bg-color: #ffffff; - --surface-color: #f5f8fa; - --card-bg: #ffffff; - --border-color: #dde4ea; - --input-border: #c6d2dc; - /* Hamburger glyph + 1px border on the menu surface: mvp.css paints every - button in --color-bg (the page background), which is invisible here, so - the toggle re-drives from this accent (7.8:1 on --surface-color). */ - --menu-toggle-color: #1f5273; - /* vizdom WASM graph edges bake black stroke/fill attributes into the SVG; - palette-driven so the dark theme can swap them to a readable tint. */ - --graph-line: #000000; - /* Break-up package clusters: vizdom bakes fillcolor=pink onto the cluster - rect with the default black stroke/label glyphs. The light values equal - those baked colors, so the re-driving rules below never change the - light theme by a pixel; the dark block swaps them for a tuned surface. */ - --graph-breakup-fill: #ffc0cb; - --graph-breakup-line: #000000; - /* DOT-graph hover highlights (vizdom SVGs): the hovered edge/node and its - partners take the amber attention accent; a hovered node colors its - incoming (source) and outgoing (sink) neighborhoods green/blue. All - three pass >= 3:1 on the white page and on the pink breakup clusters; - highlighted nodes fill with their accent, and the label ink flips to - --graph-hover-on to stay readable on it (white on these dark accents). */ - --graph-hover-edge: #b45309; - --graph-hover-in: #1e8449; - --graph-hover-out: #2a6f97; - --graph-hover-on: #ffffff; - --error-color: #c0392b; - --success-color: #1e8449; - --error-bg: #fdf6f5; - --error-border: #f0c6c0; - --btn-hover-bg: #e8f0f6; - --btn-disabled-bg: #f3f4f6; - --btn-disabled-text: #6b7280; - --popup-border: #cccccc; - /* Close-button red: white × on it passes AA (>= 4.5:1) and the square - clears 3:1 against the white popup surface (SC 1.4.11). */ - --popup-close-bg: #c0392b; - --popup-close-fg: #ffffff; - --toast-bg: #1f2933; - --toast-text: #ffffff; - --menu-max-height: 140px; - --menu-height: 48px; - --radius: 6px; - --shadow: 0 1px 4px rgba(0, 0, 0, 0.12); - /* mvp.css caps header/footer/main at --width-content (1080px by default). - Report pages and listings should fill the view: content uses 95% of the - viewport width instead. */ - --width-content: min(3840px, 95vw); -} - -/* mvp.css re-declares its variables on :root AFTER this bundle loads, so - same-specificity overrides lose the cascade. html:root (0,1,1) wins over - mvp's :root (0,1,0) and themes every mvp-styled element (inputs, quotes, - table headers, zebra rows, scrollbars) via mvp's own variable plumbing. */ -html:root { - --color-bg: var(--bg-color); - --color-text: var(--text-color); - --color-bg-secondary: var(--surface-color); - --color-text-secondary: var(--muted-color); - --color-link: var(--link-color); - --color-secondary: var(--brand-accent); - --color-secondary-accent: rgba(42, 111, 151, 0.08); - --color-accent: rgba(42, 111, 151, 0.08); - --color-shadow: rgba(0, 0, 0, 0.12); - --color-table: var(--brand-color); - --color-scrollbar: var(--input-border); -} - -/* Dark palette. The two blocks below MUST stay identical: one applies the - explicit dark radio at any OS setting, the other applies the system radio - only when the OS prefers dark. tests/unit/theme-css.test.js guards the - duplication byte-for-byte. */ -:root:has(#rf-theme-dark:checked) { - color-scheme: dark; - --brand-color: #2a6f97; - /* Focus indicator / hover accent on the dark page: >= 3:1 against both - --bg-color (3.8:1) and --surface-color (3.3:1), and 4.8:1 against white, - so the light-mode guards keep passing under their last-occurrence - variable resolution (tests/unit/css-a11y.test.js). */ - --brand-accent: #3178a6; - --link-color: #6fb1d9; - --on-accent-color: #ffffff; - --text-color: #e6edf3; - --muted-color: #9fb0c0; - --bg-color: #10161d; - --surface-color: #1a2330; - --card-bg: #1a2330; - --border-color: #2c3a4d; - --input-border: #5b7396; - /* Hamburger glyph + 1px border on the dark menu surface (6.8:1). */ - --menu-toggle-color: #6fb1d9; - --graph-line: #9fb0c0; - /* Break-up package clusters: the baked pink is a glaring pastel slab on - the dark page (11.8:1) and swallows the #9fb0c0 edges crossing it - (1.45:1), so the box drops to a deep rose — flagged against the - near-black regular clusters (2.2:1 plus a warm hue shift) while the - edges stay visible on it (3.8:1) — and a light pink ink carries the - boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ - --graph-breakup-fill: #7f3350; - --graph-breakup-line: #f2b8cd; - /* DOT-graph hover highlights: bright accents that stay distinct from the - slate #9fb0c0 edges (hue + width) and pass >= 3:1 on both cluster - fills (#10161d regular, #7f3350 rose). */ - --graph-hover-edge: #f0b95a; - --graph-hover-in: #7ee2a8; - --graph-hover-out: #8ed0fa; - /* Label ink on the accent-filled highlighted nodes: the bright fills take - black glyphs (the labels bake a black fill in both palettes). */ - --graph-hover-on: #000000; - --error-color: #f0928c; - --success-color: #5bbd8b; - --error-bg: #2b1d1f; - --error-border: #7a4a48; - --btn-hover-bg: #243244; - --btn-disabled-bg: #232a35; - --btn-disabled-text: #8a97a8; - --popup-border: #3a4b61; - /* Brighter red for the dark surface: keeps the white × at AA (4.6:1) - and the square at 3.4:1 against --card-bg (SC 1.4.11). */ - --popup-close-bg: #cf4436; - --popup-close-fg: #ffffff; - --toast-bg: #1f2933; - --toast-text: #ffffff; - --color-bg: var(--bg-color); - --color-text: var(--text-color); - --color-bg-secondary: var(--surface-color); - --color-text-secondary: var(--muted-color); - --color-link: var(--link-color); - --color-secondary: var(--link-color); - --color-secondary-accent: rgba(111, 177, 217, 0.12); - --color-accent: rgba(111, 177, 217, 0.1); - --color-shadow: rgba(0, 0, 0, 0.33); - --color-table: var(--link-color); - --color-scrollbar: #566d8f; -} - -@media (prefers-color-scheme: dark) { - :root:has(#rf-theme-system:checked) { - color-scheme: dark; - --brand-color: #2a6f97; - --brand-accent: #3178a6; - --link-color: #6fb1d9; - --on-accent-color: #ffffff; - --text-color: #e6edf3; - --muted-color: #9fb0c0; - --bg-color: #10161d; - --surface-color: #1a2330; - --card-bg: #1a2330; - --border-color: #2c3a4d; - --input-border: #5b7396; - /* Hamburger glyph + 1px border on the dark menu surface (6.8:1). */ - --menu-toggle-color: #6fb1d9; - --graph-line: #9fb0c0; - /* Break-up package clusters: the baked pink is a glaring pastel slab on - the dark page (11.8:1) and swallows the #9fb0c0 edges crossing it - (1.45:1), so the box drops to a deep rose — flagged against the - near-black regular clusters (2.2:1 plus a warm hue shift) while the - edges stay visible on it (3.8:1) — and a light pink ink carries the - boundary and label (5.0:1 on the fill, 10.8:1 on the page). */ - --graph-breakup-fill: #7f3350; - --graph-breakup-line: #f2b8cd; - /* DOT-graph hover highlights: bright accents that stay distinct from the - slate #9fb0c0 edges (hue + width) and pass >= 3:1 on both cluster - fills (#10161d regular, #7f3350 rose). */ - --graph-hover-edge: #f0b95a; - --graph-hover-in: #7ee2a8; - --graph-hover-out: #8ed0fa; - /* Label ink on the accent-filled highlighted nodes: the bright fills take - black glyphs (the labels bake a black fill in both palettes). */ - --graph-hover-on: #000000; - --error-color: #f0928c; - --success-color: #5bbd8b; - --error-bg: #2b1d1f; - --error-border: #7a4a48; - --btn-hover-bg: #243244; - --btn-disabled-bg: #232a35; - --btn-disabled-text: #8a97a8; - --popup-border: #3a4b61; - /* Brighter red for the dark surface: keeps the white × at AA (4.6:1) - and the square at 3.4:1 against --card-bg (SC 1.4.11). */ - --popup-close-bg: #cf4436; - --popup-close-fg: #ffffff; - --toast-bg: #1f2933; - --toast-text: #ffffff; - --color-bg: var(--bg-color); - --color-text: var(--text-color); - --color-bg-secondary: var(--surface-color); - --color-text-secondary: var(--muted-color); - --color-link: var(--link-color); - --color-secondary: var(--link-color); - --color-secondary-accent: rgba(111, 177, 217, 0.12); - --color-accent: rgba(111, 177, 217, 0.1); - --color-shadow: rgba(0, 0, 0, 0.33); - --color-table: var(--link-color); - --color-scrollbar: #566d8f; - } -} - -body { - margin: 0; - color: var(--text-color); - background: var(--bg-color); -} - -.skip-link { - position: absolute; - left: -9999px; - top: 0; - background: var(--brand-color); - color: var(--on-accent-color); - padding: 0.5rem 1rem; - z-index: 100; -} - -.skip-link:focus { - left: 0; -} - -/* mvp.css targets `footer, header, main` with the same specificity and is - loaded after the bundle CSS, so the id-qualified selector is the stable - way to win the cascade. Report pages should fill 95% of the view. */ -main#app { - max-width: min(3840px, 95vw); - margin: 0 auto; - padding: 1rem 1.5rem 3rem; - min-height: 60vh; -} - -/* Skip-link target: suppress the outline only for programmatic (script/ - click) focus; keyboard-focusable users keep the visible indicator (WCAG - 2.4.7 / 2.4.11). */ -main:focus:not(:focus-visible) { - outline: none; -} - -/* mvp.css makes every `section` a wrapping flex row with no gap and center - justification (`section { display: flex; flex-wrap: wrap; justify-content: - var(--justify-important) }`). It targets the mvp `section > aside` card - pattern, but our pages use sections as plain prose containers — so headings, - paragraphs, lists and code blocks were laid out side by side as flex items - and smashed together. mvp.css is loaded after this bundle, so the reset must - win on specificity: `main section` (0-0-2) beats the bare `section` - selector (0-0-1) regardless of cascade order. */ -main section { - display: block; -} - -/* Prose pages: roughly 75ch measure, centered in the wide main column. */ -.content-page { - max-width: 48rem; - margin: 0 auto; -} - -/* Landing sections (CTA, featured repos) that keep centered text now that - sections no longer flex-center their children. */ -.section-center { - text-align: center; -} - -.hero { - text-align: center; - padding: 2.5rem 1rem; -} - -.hero h1 { - color: var(--link-color); - margin-bottom: 0.25rem; -} - -.loading { - text-align: center; - color: var(--muted-color); - padding: 3rem 0; -} - -.site-footer { - text-align: center; - padding: 1.5rem 1rem; - color: var(--muted-color); - font-size: 0.85rem; - border-top: 1px solid var(--surface-color); -} - -a { - color: var(--link-color); -} - -code { - background: var(--surface-color); - padding: 0.1em 0.35em; - border-radius: 4px; -} - -pre { - background: var(--surface-color); - padding: 1rem; - border-radius: var(--radius); - overflow-x: auto; -} - -:focus-visible { - outline: 2px solid var(--brand-accent); - outline-offset: 2px; -} -/* Component styles: top menu, search, repo grid, pagination, forms, errors. */ - -/* ---- Top navigation (max 140px, mobile hamburger) ---- */ -#top-menu { - background: var(--surface-color); - border-bottom: 1px solid var(--border-color); - /* mvp.css adds generous header padding; reset it so max-height bounds the - whole rendered box (padding contributes outside max-height). */ - padding: 0; - max-height: var(--menu-max-height); -} - -.menu-bar { - display: flex; - align-items: center; - gap: 0.4rem; - flex-wrap: nowrap; - max-width: 1200px; - margin: 0 auto; - padding: 0.3rem 0.8rem; - min-height: 48px; - overflow: visible; -} - -.brand { - display: flex; - align-items: center; - gap: 0.4rem; - font-weight: bold; - color: var(--link-color); - text-decoration: none; - white-space: nowrap; - font-size: 0.9rem; -} - -.menu-search { - position: relative; - /* Grow into the space freed by the tighter link spacing. */ - flex: 1 0 240px; - /* Space between the last menu link and the search bar matches the - left-to-right spacing measured between menu links: the .menu-bar flex - gap plus this small margin reproduce it (verified at 1280px via E2E). */ - margin-left: 0.1rem; -} - -.menu-search input { - width: 100%; - padding: 0.35rem 0.6rem; - border: 1px solid var(--input-border); - border-radius: var(--radius); -} - -.menu-toggle { - display: none; - background: none; - /* mvp.css paints every button in var(--color-bg) — the page background — - which is invisible on the menu surface; re-drive glyph and border from - the palette so the control stays perceivable in both themes. */ - color: var(--menu-toggle-color); - border: 1px solid var(--menu-toggle-color); - border-radius: var(--radius); - font-size: 1.1rem; - padding: 0.25rem 0.6rem; - cursor: pointer; -} - -.menu-links { - display: flex; - align-items: center; - gap: 0.1rem; - flex-wrap: nowrap; - flex-shrink: 0; /* never squeeze the links — the bar scrolls/wraps instead */ - list-style: none; - margin: 0; - padding: 0; - overflow: hidden; -} - -/* mvp.css gives every li a horizontal margin — zero it so the menu items - sit at the same tight rhythm as the search bar to their right. */ -.menu-links li { - margin-left: 0; - margin-right: 0; -} - -.menu-links a { - display: inline-block; - padding: 0.15rem 0.2rem; - border-radius: var(--radius); - text-decoration: none; - color: var(--text-color); - font-size: 0.8rem; -} - -.menu-links a:hover, -.menu-links a:focus-visible { - background: var(--brand-color); - color: var(--on-accent-color); -} - -/* ---- Breadcrumbs (user/report trail under the top menu) ---- */ -.breadcrumbs { - /* Lives on the .theme-bar row (rendered by app/layout.jsx), sharing the - line with the color theme toggle: the row owns the menu-column - alignment formula (max-width: var(--width-content) + 0.8rem horizontal - padding, see .theme-bar above), so the trail must add no box-model - offsets of its own — flex-grow fills the row's left side, keeping the - first crumb's glyph at the menu bar's left content edge while the - toggle pins to the right edge, both vertically centered. */ - flex: 1 1 auto; - min-width: 0; - /* mvp.css gives bare nav a 7rem bottom margin, bold text and a spaced flex - row — reset all three so the trail sits inline like a sub-bar. */ - margin: 0; - font-weight: normal; - display: block; - font-size: 0.85rem; -} - -.breadcrumbs ol { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0 0.35rem; - list-style: none; - margin: 0; - padding: 0.1rem 0; -} - -/* Path-style separator, pure CSS so it stays decorative for screen readers - (the ordered list already conveys the hierarchy). */ -.breadcrumbs li + li::before { - content: "/"; - margin-right: 0.35rem; - color: var(--muted-color); -} - -.breadcrumbs a, -.breadcrumbs [aria-current] { - display: inline-block; - /* No left padding: the first crumb's glyph edge must sit at the menu - column left edge (the flex-gap + separator space the rest). */ - padding: 0.2rem 0; -} - -.breadcrumbs [aria-current] { - color: var(--muted-color); -} - -/* ---- Search results ---- */ -.search-results { - position: absolute; - left: 0; - right: 0; - top: 100%; - z-index: 50; - margin: 0.15rem 0 0; - padding: 0; - list-style: none; - background: var(--card-bg); - border: 1px solid var(--input-border); - border-radius: var(--radius); - box-shadow: var(--shadow); - max-height: 16rem; - overflow-y: auto; -} - -.search-results li { - padding: 0.5rem 0.75rem; - cursor: pointer; -} - -.search-results li.active, -.search-results li:hover { - background: var(--brand-color); - color: var(--on-accent-color); -} - -.hero-search { - position: relative; - /* Exactly half the rendered width of the top menu (#top-menu is capped by - mvp.css at --width-content = 1080px, full width below that) and centered - under it. */ - width: min(540px, 50vw); - margin: 1rem auto; - text-align: left; -} - -.hero-search input { - width: 100%; - box-sizing: border-box; - padding: 0.35rem 0.6rem; - border: 1px solid var(--input-border); - border-radius: var(--radius); -} - -/* ---- Repository grid ---- */ -.repo-grid, -.featured-repos ul { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem; - list-style: none; - padding: 0; - max-width: 720px; - margin-left: auto; - margin-right: auto; -} - -.repo-card { - display: block; - padding: 0.85rem 1rem; - border: 1px solid var(--border-color); - border-radius: var(--radius); - text-decoration: none; - color: var(--text-color); - background: var(--card-bg); - box-shadow: var(--shadow); -} - -.repo-card:hover, -.repo-card:focus-visible { - border-color: var(--brand-color); - color: var(--link-color); -} - -/* ---- Pagination ---- */ -.pagination { - display: flex; - gap: 0.4rem; - justify-content: center; - margin-top: 1.5rem; -} - -.pagination .page { - padding: 0.35rem 0.65rem; - border: 1px solid var(--input-border); - border-radius: var(--radius); - text-decoration: none; -} - -.pagination .page.current { - background: var(--brand-color); - border-color: var(--brand-color); - color: var(--on-accent-color); -} - -/* ---- User listing (/user) ---- */ -.repo-listing h1, -.repo-listing .listing-subtitle { - text-align: center; -} - -.listing-subtitle { - margin-top: 0.25rem; - color: var(--muted-color); -} - -/* ---- Forms / add-repo ---- */ -.user-info { - display: flex; - align-items: center; - gap: 0.6rem; - margin-bottom: 1rem; -} - -.user-avatar { - border-radius: 50%; -} - -#repo-form { - display: grid; - gap: 0.5rem; - max-width: 420px; - margin: 0 auto; -} - -.form-status.error { - color: var(--error-color); -} - -.form-status.success { - color: var(--success-color); -} - -.cta { - display: inline-block; - background: var(--brand-color); - color: var(--on-accent-color); - padding: 0.6rem 1.2rem; - border-radius: var(--radius); - border: none; - font-size: 1rem; - text-decoration: none; - cursor: pointer; -} - -.cta:hover { - background: var(--brand-accent); - color: var(--on-accent-color); -} - -/* ---- Error pages ---- */ -.error-page { - max-width: 640px; - margin: 3rem auto; - text-align: center; - padding: 2rem; - border: 1px solid var(--error-border); - border-radius: var(--radius); - background: var(--error-bg); -} - -.error-page h1 { - color: var(--error-color); -} - -.error-code { - color: var(--muted-color); - font-size: 0.85rem; -} - -.error-actions { - display: flex; - gap: 1rem; - justify-content: center; - margin-top: 1rem; -} - -.error-actions .retry { - background: var(--brand-color); - color: var(--on-accent-color); - border: none; - border-radius: var(--radius); - padding: 0.5rem 1rem; - cursor: pointer; -} - -/* ---- Responsive ---- */ -/* The header (brand + search + 9 menu links at 0.8rem) needs roughly 900px. - * Below that widths links would clip because .menu-links uses nowrap + - * overflow:hidden without a visible toggle, so switch to the collapsible - * menu early — before any navigation item becomes unreachable. */ -@media (max-width: 900px) { - #top-menu { - max-height: none; - } - - .menu-bar { - flex-wrap: wrap; - overflow: visible; - } - - .menu-toggle { - display: block; - margin-left: auto; - order: 2; - } - - .menu-search { - /* On mobile the search sits on its own row between toggle and links. */ - order: 3; - flex-basis: 100%; - max-width: none; - margin-left: 0; - margin-top: 0.5rem; - } - - .menu-links { - display: none; - flex-basis: 100%; - flex-direction: column; - align-items: stretch; - padding-bottom: 0.5rem; - order: 4; - } - - .menu-links.open { - display: flex; - } - - .repo-grid, - .featured-repos ul { - grid-template-columns: 1fr; - } -} - -/* Toast notifications (copy feedback on report tables). Rendered by - components/toast-notification.jsx as a persistent aria-live region. */ -.rf-toast-region { - position: fixed; - left: 50%; - bottom: 1.25rem; - transform: translateX(-50%); - z-index: 2000; - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: center; - pointer-events: none; - max-width: min(90vw, 40rem); -} - -.rf-toast { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.6rem 0.9rem; - border-radius: var(--radius); - background: var(--toast-bg); - color: var(--toast-text); - box-shadow: var(--shadow); - pointer-events: auto; -} - -.rf-toast-message { - overflow-wrap: anywhere; -} - -.rf-toast-dismiss { - background: none; - border: none; - color: var(--toast-text); - font-size: 1.1rem; - line-height: 1; - cursor: pointer; - padding: 0.15rem 0.45rem; -} - -.rf-toast-dismiss:focus-visible { - outline: 2px solid var(--brand-color); - outline-offset: 2px; -} - -/* ---- Color theme toggle (three radio modes, pure-CSS switching) ---- */ -/* Row below the site header (rendered by app/layout.jsx): the breadcrumb - trail (when the route has one) on the left and the toggle on the right, - vertically centered on one line. The alignment formula .breadcrumbs used - to carry lives here now: --width-content column, 0.8rem horizontal - padding, border-box — keeping the first crumb's glyph at the menu bar's - left content edge and the toggle at its right content edge at every - viewport, the two edges mirroring each other. #top-menu is capped by - mvp.css at --width-content, so the row and the menu bar can never drift - apart. Crumb routes grow the trail (flex: 1 1 auto); crumb-less routes - leave only the toggle, pinned right by flex-end. */ -.theme-bar { - display: flex; - align-items: center; - justify-content: flex-end; - max-width: var(--width-content); - box-sizing: border-box; - margin: 0 auto; - padding: 0.4rem 0.8rem 0.45rem; -} - -/* The radios in components/theme-toggle.jsx are the theme's single source of - truth (see the palette blocks at the top of this file). Each input is - visually replaced by its icon pill (appearance: none, absolute, fills the - 28px label) but stays keyboard-focusable — the global :focus-visible rule - draws the indicator — and the checked pill doubles as the selected state, - so no JS is involved in switching. The control renders as a surface chip - so it stands out from the page background. */ -.theme-toggle { - display: flex; - align-items: center; - gap: 0.15rem; - flex: 0 0 auto; - padding: 0.15rem; - border: 1px solid var(--input-border); - border-radius: var(--radius); - background: var(--surface-color); -} - -.theme-option { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; /* 28px >= 24px minimum target size (SC 2.5.8) */ - height: 1.75rem; - border-radius: 5px; -} - -.theme-option:hover { - background: var(--btn-hover-bg); -} - -.theme-option-input { - appearance: none; - position: absolute; - inset: 0; - margin: 0; - border-radius: 5px; - cursor: pointer; -} - -.theme-option-input:checked { - background: var(--brand-color); -} - -.theme-option-icon { - /* Relative so the glyph paints above the checked input's pill. */ - position: relative; - width: 1.15rem; - height: 1.15rem; - color: var(--text-color); - pointer-events: none; -} - -.theme-option-input:checked + .theme-option-icon { - color: var(--on-accent-color); -} - -/* ---- Report template theme overrides ---- */ -/* The bundled mustache template (public/assets/refactor-first-report.mustache) - hardcodes light values for a few report widgets; it stays byte-identical, - so these main#app rules (1,1,1) beat its injected