From 869b8ab887d7a3c89fb581d9cd160fe1a0dafb43 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 16 Jun 2026 19:42:34 -0700 Subject: [PATCH] feat(graph): read node contents/diagram in place + zoom-decoupled legibility (PR-3/PR-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the core "zoom does double duty" UX problem: zoom was both spatial navigation AND the readability control, so reading a label/contents meant zooming all the way in. PR-3 — expand-in-place peek: a ⛶ icon on every node card opens a readable Card/Contents/Diagram panel anchored to the node on the canvas (reuses the NodeInspector with a new `compact` variant + NodeContentRenderer/NodeSubgraphPreview). It tracks its node every frame via rAF + d3.zoomTransform (same technique as the inline-rename box), clamps to the viewport, closes on Esc / graph-change / when its node leaves the simulation. PR-4 — legibility floor: the primary label (title) is wrapped in a counter-scaled .node-title-group so its on-screen size never drops below ~12px within the working zoom band (kept readable when zoomed out, not only when zoomed in); the title also appears across the band, not just past one threshold. The in-card description now truncates honestly with an ellipsis instead of silently vanishing. Perf-safe: the counter-scale pass is skipped in dense-graph data-simplify mode; new code reads the live d3 transform (the currentTransform state has no .k). Verified: tests/diagnostics/node-expand-legibility.spec.ts (PR-3 anchored peek + PR-4 on-screen floor), existing node-inspector diagnostic still green, smoke 5/5, typecheck + lint + build clean. Adversarial review pass applied (honest-preview + title-opacity consistency across build/poll paths, zoom-pass gating, panel auto-close on delete). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 231 ++++++++++++++++-- packages/web/src/components/NodeInspector.tsx | 24 +- packages/web/src/index.css | 1 + .../node-expand-legibility.spec.ts | 159 ++++++++++++ 4 files changed, 392 insertions(+), 23 deletions(-) create mode 100644 tests/diagnostics/node-expand-legibility.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index e2995288..c7a37c9c 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -37,6 +37,7 @@ import { UpdateGraphModal } from './UpdateGraphModal'; import { DeleteGraphModal } from './DeleteGraphModal'; import { ConnectWorkItemModal } from './ConnectWorkItemModal'; import { WorkItemDetailsModal } from './WorkItemDetailsModal'; +import { NodeInspector } from './NodeInspector'; import { WorkItem, WorkItemEdge } from '../types/graph'; import { RelationshipType, RELATIONSHIP_OPTIONS, getRelationshipConfig } from '../constants/workItemConstants'; @@ -85,6 +86,33 @@ const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number return (scale - (threshold - fadeRange)) / (fadeRange * 2); }; +// PR-4 legibility floor: zoom does double duty in this app — it's both spatial +// navigation AND the readability control (text is rendered at a fixed font size, +// so the parent `g` zoom scales it down with everything else). Past a certain +// zoom-out the primary label is sub-readable. Rather than forcing the user to +// zoom IN to read a label, counter-scale the title/type so their ON-SCREEN size +// never drops below a readable floor within the working band. Beyond the band +// the existing LOD opacity cull still hides them for overview/perf. +const TITLE_BASE_PX = 14; // matches .node-title-text font-size +const TYPE_BASE_PX = 13; // matches .node-type-text font-size +const LEGIBLE_FLOOR_PX = 12; // smallest on-screen size we keep text at +const LEGIBLE_MAX_BOOST = 1.9; // cap the counter-scale so text never balloons +// Title appears (and gets the floor) from this zoom — lower than the old +// title-visible threshold so the label is readable across the working band, not +// only when zoomed all the way in. Perf-safe: dense graphs hide node text via +// the data-simplify CSS below SIMPLIFY_SCALE (0.45) regardless of opacity. +const TITLE_VISIBLE_SCALE = 0.4; +const legibilityScale = (basePx: number, k: number) => { + if (!k || k <= 0) return 1; + return Math.min(LEGIBLE_MAX_BOOST, Math.max(1, LEGIBLE_FLOOR_PX / (basePx * k))); +}; +// A scale about a stored vertical anchor (data-cy) so multi-line title blocks +// grow/shrink as a unit (font AND line-spacing together → never overlap). +const legibilityTransform = (cy: number, basePx: number, k: number) => { + const s = legibilityScale(basePx, k); + return `translate(0,${cy}) scale(${s}) translate(0,${-cy})`; +}; + interface NodeMenuState { node: WorkItem | null; position: { x: number; y: number }; @@ -131,6 +159,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // The inline-rename overlay tracks its node live (drag/tick/zoom) via rAF, // because its position derives from currentTransform which only updates on zoom. const inlineEditRef = useRef(null); + // PR-3 expand-in-place: a readable peek panel anchored to a node on the canvas + // (Card/Contents/Diagram at full size, independent of zoom). Like the rename + // box, it tracks its node every frame via rAF. + const [expandedNode, setExpandedNode] = useState(null); + const expandPanelRef = useRef(null); + const expandedNodeIdRef = useRef(null); + expandedNodeIdRef.current = expandedNode?.id ?? null; + const toggleExpandedNode = useCallback((n: WorkItem) => { + setExpandedNode((prev) => (prev?.id === n.id ? null : n)); + }, []); + const toggleExpandedNodeRef = useRef(toggleExpandedNode); + toggleExpandedNodeRef.current = toggleExpandedNode; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); const navigate = useNavigate(); @@ -1148,7 +1188,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('opacity', getSmoothedOpacity(scale, LOD_THRESHOLDS.FAR)); (g.selectAll('.node-title-text') as any) .style('visibility', 'visible') - .style('opacity', getSmoothedOpacity(scale, LOD_THRESHOLDS.MEDIUM)); + .style('opacity', getSmoothedOpacity(scale, TITLE_VISIBLE_SCALE)); + (g.selectAll('.node-title-group') as any) + .attr('transform', function(this: any) { + const cy = +(this.getAttribute('data-cy')) || 0; + return legibilityTransform(cy, TITLE_BASE_PX, scale); + }); (g.selectAll('.node-description-text') as any) .style('visibility', 'visible') .style('opacity', getSmoothedOpacity(scale, LOD_THRESHOLDS.CLOSE)); @@ -1716,7 +1761,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i if (!updatedNode) return; // Update title text elements (must mirror the creation path exactly, - // or cards shift layout on every poll) + // or cards shift layout on every poll — incl. the legibility-floor group) + nodeGroup.selectAll('.node-title-group').remove(); nodeGroup.selectAll('.node-title-text').remove(); const maxCharsPerLine = getNodeDimensions(updatedNode).maxCharsPerLine; const maxLines = 3; @@ -1744,14 +1790,21 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const dimensions = getNodeDimensions(updatedNode); const titleBarHeight = 28; const startY = -dimensions.height / 2 + titleBarHeight + 18; + const liveK = svgRef.current ? d3.zoomTransform(svgRef.current).k : 1; + const titleCy = startY + ((lines.length - 1) * 16) / 2; + const titleGroup = nodeGroup.append('g') + .attr('class', 'node-title-group') + .attr('data-cy', titleCy) + .attr('transform', legibilityTransform(titleCy, TITLE_BASE_PX, liveK)); lines.forEach((line, index) => { - nodeGroup.append('text') + titleGroup.append('text') .attr('class', 'node-title-text') .attr('x', 0) .attr('y', startY + (index * 16)) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') .text(line) + .style('opacity', getSmoothedOpacity(liveK, TITLE_VISIBLE_SCALE)) .style('font-size', '14px') .style('font-weight', '600') .style('fill', () => { @@ -1768,14 +1821,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return config.label.toUpperCase(); }); - // Update description + // Update description — mirror the build path's honest preview exactly + // (width-responsive truncation + ellipsis), or the card text mutates on + // the next data poll. nodeGroup.select('.node-description-text') .text(() => { if (!updatedNode.description) return ''; - const maxDescChars = 25; - return updatedNode.description.length > maxDescChars - ? updatedNode.description.substring(0, maxDescChars) + '...' - : updatedNode.description; + const maxLength = Math.floor(getNodeDimensions(updatedNode).width / 6.5); + const oneLine = updatedNode.description.replace(/\s+/g, ' ').trim(); + return oneLine.length > maxLength + ? oneLine.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…' + : oneLine; }); }); @@ -2780,6 +2836,57 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i setIsConnecting(true); }); + // Expand-in-place icon (title bar, inboard of the + icon): opens a readable + // peek panel anchored to the node (Card/Contents/Diagram at full size) so you + // can read a node's contents/diagram WITHOUT zooming in (PR-3). + const expandIcons = nodeElements.append('g') + .attr('class', 'node-expand-icon') + .attr('transform', (d: WorkItem) => { + const x = getNodeDimensions(d).width / 2 - iconSize / 2 - 12 - iconSize - 8; + const y = -getNodeDimensions(d).height / 2 + 2 + titleBarHeight / 2; + return `translate(${x}, ${y}) scale(${1 / (currentTransform?.k || 1)})`; + }) + .style('cursor', 'pointer') + .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.85 : 0) + .style('pointer-events', 'all'); + expandIcons.append('rect') + .attr('class', 'expand-bg') + .attr('x', -iconSize / 2) + .attr('y', -iconSize / 2) + .attr('width', iconSize) + .attr('height', iconSize) + .attr('rx', 3) + .attr('fill', 'rgba(0, 0, 0, 0.7)') + .attr('stroke', 'rgba(255, 255, 255, 0.8)') + .attr('stroke-width', 1); + expandIcons.append('text') + .attr('class', 'expand-glyph') + .attr('x', 0) + .attr('y', 0) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .style('font-size', `${iconSize * 0.95}px`) + .style('font-weight', 'bold') + .style('fill', '#ffffff') + .style('pointer-events', 'none') + .text('⛶'); + expandIcons + .on('mouseenter', function() { + d3.select(this).select('.expand-bg').transition().duration(150) + .attr('fill', 'rgba(16, 185, 129, 0.85)') + .attr('stroke', '#10b981'); + }) + .on('mouseleave', function() { + d3.select(this).select('.expand-bg').transition().duration(150) + .attr('fill', 'rgba(0, 0, 0, 0.7)') + .attr('stroke', 'rgba(255, 255, 255, 0.8)'); + }) + .on('click', (event: MouseEvent, d: WorkItem) => { + event.stopPropagation(); + event.preventDefault(); + toggleExpandedNodeRef.current(d); + }); + // Sheet-symbol affordances: a "descend" glyph (bottom-right) + a child // count line, only for nodes that drill into a sub-graph. const sheetNodes = nodeElements.filter((d: WorkItem) => !!d.subgraphId); @@ -2898,17 +3005,26 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i lines.push(d.title.substring(0, maxCharsPerLine - 3) + '...'); } - // Create text elements for each line + // Create text elements for each line, inside a group that the legibility + // floor counter-scales as a unit (font + line-spacing together → readable + // when zoomed out, no overlap). data-cy = the title block's vertical center + // so the scale grows about the title, not the node origin. (PR-4) const startY = -dimensions.height / 2 + titleBarHeight + 18; + const liveK = svgRef.current ? d3.zoomTransform(svgRef.current).k : 1; + const titleCy = startY + ((lines.length - 1) * 16) / 2; + const titleGroup = nodeGroup.append('g') + .attr('class', 'node-title-group') + .attr('data-cy', titleCy) + .attr('transform', legibilityTransform(titleCy, TITLE_BASE_PX, liveK)); lines.forEach((line, index) => { - nodeGroup.append('text') + titleGroup.append('text') .attr('class', 'node-title-text') .attr('x', 0) .attr('y', startY + (index * 16)) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') .text(line) - .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.MEDIUM ? 1 : 0) + .style('opacity', getSmoothedOpacity(liveK, TITLE_VISIBLE_SCALE)) .style('font-size', '14px') .style('font-weight', '600') .style('fill', () => { @@ -2933,9 +3049,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('dominant-baseline', 'middle') .text((d: WorkItem) => { if (!d.description) return ''; + // Honest preview: truncate with an ellipsis instead of silently hiding + // long descriptions (the old behavior made content vanish with no cue). + // The ⛶ expand icon / inspector show the full contents (PR-3/PR-4). const maxLength = Math.floor(getNodeDimensions(d).width / 6.5); - // Hide description if too long instead of truncating - return d.description.length > maxLength ? '' : d.description; + const oneLine = d.description.replace(/\s+/g, ' ').trim(); + return oneLine.length > maxLength + ? oneLine.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…' + : oneLine; }) .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) .style('font-size', '11px') @@ -3794,7 +3915,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const scale = currentTransform.k; const classList = d3.select(this as any).attr('class'); if (classList?.includes('node-type-text')) return getSmoothedOpacity(scale, LOD_THRESHOLDS.FAR); - if (classList?.includes('node-title-text')) return getSmoothedOpacity(scale, LOD_THRESHOLDS.MEDIUM); + if (classList?.includes('node-title-text')) return getSmoothedOpacity(scale, TITLE_VISIBLE_SCALE); if (classList?.includes('node-description-text')) return getSmoothedOpacity(scale, LOD_THRESHOLDS.CLOSE); } } @@ -3890,8 +4011,21 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // Update text opacities with smooth transitions g.selectAll('.node-type-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.FAR)); + // Title appears across the working band (not only when zoomed all the way + // in) and is counter-scaled to a legibility floor so it stays readable. The + // group transform is updated live here (the only path that runs on zoom). g.selectAll('.node-title-text' as any) - .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.MEDIUM)); + .style('opacity', getSmoothedOpacity(TITLE_VISIBLE_SCALE)); + // Counter-scale the title block to the legibility floor. Skipped on dense + // graphs in data-simplify mode (titles hidden via CSS → no visible effect, + // so don't walk 1000+ groups every pan frame). + if (!simplifiedRef.current) { + g.selectAll('.node-title-group' as any) + .attr('transform', function(this: any) { + const cy = +(this.getAttribute('data-cy')) || 0; + return legibilityTransform(cy, TITLE_BASE_PX, scale); + }); + } g.selectAll('.node-description-text' as any) .style('opacity', getSmoothedOpacity(LOD_THRESHOLDS.CLOSE)); g.selectAll('.edge-label' as any) @@ -4266,6 +4400,53 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return () => clearTimeout(timer); }, [hasNodes, currentGraphId, fitViewToNodes]); + // PR-3: keep the expand-in-place panel glued to its node through drags, ticks + // and pan/zoom (same rAF technique as the rename box). The panel sits beside + // the node, flips/clamps to stay fully on screen. + const expandedNodeId = expandedNode?.id ?? null; + useEffect(() => { + if (!expandedNodeId) return undefined; + let raf = 0; + const sync = () => { + const el = expandPanelRef.current; + const svgEl = svgRef.current; + if (el && svgEl) { + const simNodes = simulationRef.current?.nodes() as any[] | undefined; + const n = simNodes?.find((m: any) => m.id === expandedNodeId); + // The node was deleted / its graph left → close the peek instead of + // leaving a frozen, stale panel (and stop spinning this rAF loop). + if (simNodes && !n) { setExpandedNode(null); return; } + if (n) { + const rect = svgEl.getBoundingClientRect(); + const t = d3.zoomTransform(svgEl); + const nodeScreenX = rect.left + (n.x ?? 0) * t.k + t.x; + const nodeScreenY = rect.top + (n.y ?? 0) * t.k + t.y; + const pw = el.offsetWidth || 320; + const ph = el.offsetHeight || 260; + let left = nodeScreenX + 28; + if (left + pw > window.innerWidth - 8) left = nodeScreenX - 28 - pw; + let top = nodeScreenY - ph / 2; + left = Math.min(Math.max(8, left), Math.max(8, window.innerWidth - pw - 8)); + top = Math.min(Math.max(8, top), Math.max(8, window.innerHeight - ph - 8)); + el.style.left = `${left}px`; + el.style.top = `${top}px`; + } + } + raf = requestAnimationFrame(sync); + }; + raf = requestAnimationFrame(sync); + return () => cancelAnimationFrame(raf); + }, [expandedNodeId]); + + // Esc closes the expand panel; switching graphs dismisses it (its node is gone). + useEffect(() => { + if (!expandedNodeId) return undefined; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setExpandedNode(null); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [expandedNodeId]); + useEffect(() => { setExpandedNode(null); }, [currentGraphId]); + // Expose reset function to parent component useEffect(() => { if (onResetLayout) { @@ -4803,6 +4984,26 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i ); })()} + {/* PR-3: expand-in-place peek — a readable Card/Contents/Diagram panel + anchored to its node on the canvas, on top of everything. Position is + driven each frame by the rAF sync effect above. */} + {expandedNode && createPortal( +
+ setExpandedNode(null)} + /> +
, + document.body + )} + {/* Node Context Menu */} {nodeMenu.visible && nodeMenu.node && createPortal(
void; + /** Tighter type + smaller content for the anchored on-canvas peek (PR-3). */ + compact?: boolean; + /** Override the root data-testid so an anchored instance is distinguishable + * from the docked one when both are mounted. */ + rootTestId?: string; } /** - * Docked inspector: shows the selected node's Card (summary), Contents (its - * description rendered as readable markdown/code), or Diagram (its sub-graph), - * each at full legible size regardless of canvas zoom. The mode is an explicit, - * per-node toggle — not a side effect of zooming in. + * Inspector: shows the selected node's Card (summary), Contents (its description + * rendered as readable markdown/code), or Diagram (its sub-graph), each at full + * legible size regardless of canvas zoom. The mode is an explicit, per-node + * toggle — not a side effect of zooming in. Used both docked (Workspace) and + * anchored on-canvas (the expand-in-place peek), the latter via `compact`. */ -export function NodeInspector({ node, onClose }: NodeInspectorProps) { +export function NodeInspector({ node, onClose, compact = false, rootTestId = 'node-inspector' }: NodeInspectorProps) { const { descendInto } = useGraph(); const hasSubgraph = !!node?.subgraphId; const [modeByNode, setModeByNode] = useState>({}); @@ -34,8 +40,10 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) { return (
{/* Header */}
@@ -83,7 +91,7 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) { {mode === 'contents' && (
Loading…
}> - +
)} diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 8f668025..58d08c2a 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -856,6 +856,7 @@ input[type="date"]:focus { .graph-container[data-simplify="true"] svg .node-edit-icon, .graph-container[data-simplify="true"] svg .node-relationship-icon, .graph-container[data-simplify="true"] svg .node-descend-icon, +.graph-container[data-simplify="true"] svg .node-expand-icon, .graph-container[data-simplify="true"] svg .status-icon-svg, .graph-container[data-simplify="true"] svg .status-label-text, .graph-container[data-simplify="true"] svg .status-percentage-text, diff --git a/tests/diagnostics/node-expand-legibility.spec.ts b/tests/diagnostics/node-expand-legibility.spec.ts new file mode 100644 index 00000000..0cae3874 --- /dev/null +++ b/tests/diagnostics/node-expand-legibility.spec.ts @@ -0,0 +1,159 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * PR-3 (expand-in-place) + PR-4 (zoom-decoupled legibility floor). + * + * PR-3: the ⛶ expand icon on a node card opens a readable peek panel anchored to + * the node (Card/Contents/Diagram at full size), independent of canvas zoom; it + * stays glued through zoom and closes on Esc. + * + * PR-4: the primary label (title) is counter-scaled so its ON-SCREEN size never + * drops below a readable floor when zoomed out — reading no longer requires + * zooming all the way in. The in-card description preview truncates honestly + * (ellipsis) instead of silently vanishing. + * + * Needs the hierarchy demo seeded (System Overview / overview-graph-shared). + */ +const OVERVIEW_ID = 'overview-graph-shared'; +const LEGIBLE_FLOOR_PX = 12; + +async function openOverview(page: Page) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, OVERVIEW_ID); + await page.reload(); + await page.waitForTimeout(6000); +} + +// Live camera zoom (k) from the main group's d3 transform. +async function readK(page: Page): Promise { + return page.evaluate(() => { + const g = document.querySelector('.graph-container svg .main-graph-group') as SVGGElement | null; + const t = g?.getAttribute('transform') || ''; + const m = t.match(/scale\(([-\d.]+)/); + return m ? parseFloat(m[1]) : 1; + }); +} + +// Real (trusted) wheel zoom over the svg center. Positive deltaY = zoom out. +async function wheelZoom(page: Page, deltaY: number, steps: number) { + const c = await page.evaluate(() => { + const svg = document.querySelector('.graph-container svg') as SVGSVGElement | null; + const r = (svg || document.body).getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }); + await page.mouse.move(c.x, c.y); + for (let i = 0; i < steps; i++) { await page.mouse.wheel(0, deltaY); await page.waitForTimeout(120); } + await page.waitForTimeout(500); +} + +// Zoom out (real wheel) until k drops into the counter-scale band but stays +// above the cull, so a counter-scaled title is on screen to measure. +async function zoomOutInto(page: Page, lo: number, hi: number) { + for (let i = 0; i < 20; i++) { + const k = await readK(page); + if (k <= hi) break; + await wheelZoom(page, 200, 1); + } + return readK(page); +} + +test.describe('node expand-in-place + legibility floor @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('PR-3: ⛶ expand icon opens an anchored Card/Contents/Diagram peek', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Zoom in a touch so per-node icons are past their LOD opacity gate, then + // fire the expand icon's real click handler on a sheet node (has a sub-graph + // → Diagram is meaningful, and overview sheet nodes carry a description). + await wheelZoom(page, -240, 3); + const opened = await page.evaluate(() => { + const nodes = [...document.querySelectorAll('.graph-container svg .node')]; + const sheet = nodes.find((n) => (n as any).__data__?.subgraphId) || nodes[0]; + const icon = sheet?.querySelector('.node-expand-icon'); + if (!icon) return false; + icon.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); + return true; + }); + expect(opened, 'found a node + its expand icon').toBe(true); + + const panel = page.locator('[data-testid="node-expand-panel"]'); + await expect(panel, 'expand panel opens anchored on canvas').toBeVisible({ timeout: 8000 }); + + // It fits within the viewport (anchored + clamped, never off-screen). + const box = await panel.boundingBox(); + expect(box, 'panel has a box').not.toBeNull(); + expect(box!.x, 'panel left on screen').toBeGreaterThanOrEqual(-1); + expect(box!.y, 'panel top on screen').toBeGreaterThanOrEqual(-1); + expect(box!.x + box!.width, 'panel right on screen').toBeLessThanOrEqual(1440 + 1); + expect(box!.y + box!.height, 'panel bottom on screen').toBeLessThanOrEqual(900 + 1); + + // Contents (default for a node with a description) renders readable markdown. + await expect(panel.locator('[data-testid="node-content-rendered"]'), 'Contents renders in the peek').toBeVisible({ timeout: 8000 }); + + // Diagram → static sub-graph preview renders inside the peek. + await panel.getByRole('button', { name: 'Diagram' }).click(); + await expect(panel.locator('[data-testid="subgraph-preview"]'), 'Diagram renders in the peek').toBeVisible({ timeout: 15000 }); + + // Card → summary rows. + await panel.getByRole('button', { name: 'Card' }).click(); + await expect(panel.getByText('Type', { exact: true }), 'Card shows the summary').toBeVisible({ timeout: 5000 }); + + // Stays glued (still on screen) through a zoom-out, i.e. legible regardless + // of canvas zoom — then Esc dismisses it. + await wheelZoom(page, 240, 3); + await expect(panel, 'peek stays anchored through zoom').toBeVisible(); + await page.keyboard.press('Escape'); + await expect(panel, 'Esc closes the peek').toBeHidden({ timeout: 5000 }); + + // eslint-disable-next-line no-console + console.log('[expand] ok — anchored Card/Contents/Diagram peek verified'); + }); + + test('PR-4: title stays above the on-screen legibility floor when zoomed out', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Zoom OUT into the band where the native (un-counter-scaled) title would be + // sub-readable (k < ~0.857) but the label is still on screen. + const k = await zoomOutInto(page, 0.45, 0.7); + // eslint-disable-next-line no-console + console.log('[legibility] zoomed to k=' + k.toFixed(3)); + expect(k, 'reached the counter-scale band (k < 0.857)').toBeLessThan(0.857); + + const probe = await page.evaluate(() => { + const texts = [...document.querySelectorAll('.graph-container svg .node-title-text')] as SVGTextElement[]; + // A visible title (opacity > 0, has a box). + const visible = texts + .map((t) => ({ t, r: t.getBoundingClientRect(), op: parseFloat(getComputedStyle(t).opacity || '1') })) + .filter((x) => x.op > 0.05 && x.r.width > 1 && x.r.height > 1) + .sort((a, b) => b.r.height - a.r.height)[0]; + if (!visible) return { found: false } as any; + const group = visible.t.closest('.node-title-group') as SVGGElement | null; + const transform = group?.getAttribute('transform') || ''; + const m = transform.match(/scale\(([\d.]+)\)/); + const groupScale = m ? parseFloat(m[1]) : null; + return { found: true, screenHeight: visible.r.height, inGroup: !!group, groupScale }; + }); + + expect(probe.found, 'a title is visible when zoomed out (label readable across the band)').toBe(true); + expect(probe.inGroup, 'title is wrapped in the legibility group').toBe(true); + // The counter-scale keeps the on-screen size at/above the floor (allow a + // px of glyph/cap-height tolerance). Without PR-4 this collapses with zoom + // (e.g. 14px * 0.5 = 7px). + expect(probe.screenHeight, `title on-screen height >= floor (${LEGIBLE_FLOOR_PX}px)`).toBeGreaterThanOrEqual(LEGIBLE_FLOOR_PX - 3); + // Zoomed into the band, the counter-scale should be actively boosting (> 1). + expect(probe.groupScale, 'legibility counter-scale is engaged when zoomed out').toBeGreaterThan(1); + + // eslint-disable-next-line no-console + console.log('[legibility] title screenHeight=' + Math.round(probe.screenHeight) + 'px, groupScale=' + probe.groupScale); + }); +});