From 16e13ef05a172d9056f89d186423a27848faa466 Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 14:40:55 +0200 Subject: [PATCH 1/7] docs: design responsive graph viewport --- ...-07-27-responsive-graph-viewport-design.md | 218 ++++++++++++++++++ ...6-07-21-playground-visualization-design.md | 9 +- .../2026-07-27-graph-theme-css-design.md | 2 + 3 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md diff --git a/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md b/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md new file mode 100644 index 00000000..9073f6bb --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md @@ -0,0 +1,218 @@ +# 2026-07-27 Responsive Graph Viewport Design + +## Status + +Approved for implementation on 2026-07-27. + +This specification follows the shipped +[Playground Visualization MVP](archived/2026-07-21-playground-visualization-design.md) +and [Graph Theme CSS](archived/2026-07-27-graph-theme-css-design.md) designs. + +## Problem + +The graph's semantic styling is present, but the rendered composition is still +hard to use. React Flow fits every layout into the available canvas without a +readability floor. In the default test workspace, Entity mode renders 37 nodes +at approximately 17.6% zoom, reducing a 180px node to about 32px on screen. +Labels are unreadable even though the complete graph technically fits. + +The fixed 202px by 152px MiniMap occupies a large share of the normal desktop +canvas, and the bottom-left controls can cover nodes. Domain and Lineage modes +remain readable but place meaningful content beneath these overlays. + +The graph also fails to benefit from larger displays. The workbench is capped +at 120rem, leaving 320px unused on each side of a 2560px viewport, and resizing +the graph panel does not recompute the initial viewport. A graph fitted in a +small panel therefore remains unnecessarily small after the panel or browser +grows. + +## Goal + +Make every graph mode readable and visually balanced across light and dark +themes, while allowing larger displays and widened panels to reveal more graph +content. Dense graphs should prefer readable nodes with panning over a +whole-graph thumbnail. + +## Non-goals + +- Changing compiler graph data or browser protocol contracts. +- Adding graph filtering, clustering, search, drill-down, or saved layouts. +- Replacing ELK or React Flow. +- Persisting zoom or pan state between sessions. +- Redesigning the surrounding editor, explorer, or diagnostics panels. + +## Chosen approach + +Use a responsive readable viewport rather than unconditional whole-graph fit. +Keep the existing ELK layout and React Flow interaction model, but make the +initial viewport mode-aware, size-aware, and bounded by a legibility floor. +Pair that behavioral change with smaller overlays, explicit overlay clearance, +and a compact toolbar. + +This approach was selected over: + +- a CSS-only cleanup, which cannot prevent dense graphs from shrinking below + readable size; and +- overview/detail navigation or clustering, which would add new product + behavior beyond a focused graph cleanup. + +## Viewport behavior + +### Initial fit + +After a graph layout completes, fit the graph once with explicit padding and a +minimum readable zoom. The fit may use up to 100% zoom but must not enlarge +normal nodes beyond their authored size. + +- Domain and Lineage modes should show the complete graph when it fits at a + readable scale. +- Entity and Projection modes should stop shrinking at the readability floor. + Content outside the canvas remains reachable through pan and zoom. +- The readability floor should be chosen from rendered screenshot evidence, + not from the current React Flow component minimum of 0.1. The implementation + target is approximately 0.55–0.65, with the exact value finalized during + visual verification. +- Fit padding must reserve space for any visible controls and MiniMap so the + initial composition does not place nodes underneath them. + +The React Flow component's interactive minimum zoom may remain lower than the +automatic fit floor. Users can deliberately zoom farther out for orientation, +but the application must not begin in an unreadable state. + +### Resize response + +Observe the actual graph canvas rather than relying only on browser viewport +breakpoints. When the canvas meaningfully changes size because the browser, +editor split, graph split, or bottom panel is resized: + +- recompute the fitted viewport if the user has not manually panned or zoomed; +- use newly available width and height to show more content; +- debounce resize-driven fitting so dragging a separator remains smooth; and +- do not reset a viewport that the user has intentionally navigated. + +Changing graph mode or receiving a new graph layout resets the interaction +marker and establishes a new initial fit. Incidental React re-renders must not +reset the viewport. + +### Fit control + +The explicit React Flow "Fit View" control should apply the same readable fit +policy as the initial view. It should not return dense modes to the current +whole-graph thumbnail. Users can still zoom out manually when they want a +complete overview. + +## Large-screen behavior + +The playground is an IDE-style workbench and should use the available browser +width. Remove the fixed 120rem workbench cap while retaining a full-width +layout and existing resizable-panel constraints. The graph continues to use +the visualization panel's actual dimensions, so widening that panel on an +ultrawide display directly increases visible graph context. + +Large screens must not merely add empty margins or stretch node cards. Nodes +retain their authored dimensions; the larger canvas displays more nodes and +edges at a readable scale. + +## Overlay and toolbar composition + +### MiniMap + +- Reduce the MiniMap from its 202px by 152px default footprint. +- Size it responsively within a bounded range so it remains useful on large + canvases without dominating smaller ones. +- Hide it when the graph is small enough to fit readably without navigation + context, or when the canvas cannot accommodate it without obscuring content. +- Retain `aria-hidden="true"` because it duplicates the main graph. + +### Controls + +- Present the React Flow controls as a compact cluster. +- Position them in reserved canvas space rather than over the node layout. +- Preserve accessible names, keyboard focus, zoom, fit, and interactivity + actions. +- Keep light and dark theme contrast consistent with the graph surfaces. + +### Toolbar + +- Keep all four mode buttons and both export actions. +- Reduce padding and visual weight so the toolbar gives more space to the + canvas. +- Allow sensible wrapping or grouping when the panel is narrow instead of + squeezing labels or overflowing. +- Preserve clear pressed, hover, disabled, and keyboard-focus states. + +## Visual treatment + +Retain the semantic node system from the Graph Theme CSS design. Refine it only +where screenshot verification shows weak hierarchy: + +- keep node text readable at the automatic zoom floor; +- preserve restrained kind accents and dashed version nodes; +- improve edge visibility without letting edges overpower node labels; +- ensure selected and keyboard-focused nodes remain distinct in both themes; +- use canvas dots, overlay surfaces, and borders from graph-specific theme + tokens; and +- keep SVG export aligned with the active theme. + +Layout spacing may be tuned by mode when it improves scanability, but node +dimensions and semantic relationships remain unchanged. + +## Accessibility + +- Automatic fitting must not reduce text below the selected readability floor. +- Panning and zooming remain keyboard and pointer accessible. +- Controls keep visible focus indicators and accessible labels. +- Color remains supplementary to node shape, badge, border, and line style. +- Reduced-motion users should not receive animated resize fitting. + +## Architecture decision scope + +No ADR change is required. The shipped +[Playground Visualization MVP](archived/2026-07-21-playground-visualization-design.md) +already assigns rendering and viewport interaction to React Flow and layout to +ELK. This design adjusts presentation and viewport policy within that existing +boundary. + +## Testing strategy + +### Unit and component tests + +- Verify the readable fit options used for dense and sparse modes. +- Verify mode or graph changes permit a new initial fit. +- Verify manual viewport interaction prevents resize-driven reset. +- Verify the explicit fit control uses the readable fit policy. +- Verify MiniMap visibility rules and overlay classes or properties. + +### Playwright integration tests + +Exercise Domain, Entity, Projection, and Lineage modes at: + +- a constrained graph panel on a standard desktop viewport; +- the default desktop panel size; +- a widened graph panel; and +- a 2560px-wide ultrawide viewport. + +Assert that: + +- dense modes do not start below the readability floor; +- representative node labels remain visible; +- visible controls and the MiniMap do not intersect graph nodes after fitting; +- widening the panel or viewport increases visible graph context; +- the workbench uses the available ultrawide viewport; and +- light and dark themes retain usable contrast. + +### Screenshot review + +Capture before-and-after screenshots for every graph mode in explicit light +and dark themes. Include a constrained dense graph and an ultrawide dense graph +in the after set. Review composition, label readability, edge clarity, overlay +placement, toolbar density, theme parity, and use of available space. + +## Verification + +- Run focused web unit and Playwright tests while iterating. +- Run the complete web test suite and production build. +- Inspect the final screenshot matrix at constrained, default, widened, and + ultrawide sizes. +- Before committing implementation, run all four repository gates from `cli/` + as required by `AGENTS.md`. diff --git a/docs/superpowers/specs/archived/2026-07-21-playground-visualization-design.md b/docs/superpowers/specs/archived/2026-07-21-playground-visualization-design.md index 1e839fc1..b1d485e9 100644 --- a/docs/superpowers/specs/archived/2026-07-21-playground-visualization-design.md +++ b/docs/superpowers/specs/archived/2026-07-21-playground-visualization-design.md @@ -7,13 +7,16 @@ Shipped on 2026-07-21. Execution is broken into reviewable tasks in the [Playground Visualization implementation plan](../../plans/archived/2026-07-21-playground-visualization.md). +Responsive viewport and overlay follow-up work is defined in the +[Responsive Graph Viewport design](../2026-07-27-responsive-graph-viewport-design.md). + This specification defines Phase 4 of the -[Modelable Playground Architecture](../../playground-design.md). It builds on +[Modelable Playground Architecture](../../../playground-design.md). It builds on the shipped workspace persistence and browser-native language services, and adds compiler-owned semantic graph export, ELK-based layout, React Flow rendering, and bidirectional editor–graph navigation. -The repository [roadmap](../../../ROADMAP.md) makes the remaining Playground +The repository [roadmap](../../../../ROADMAP.md) makes the remaining Playground phases the immediate product priority, records Phases 1–4 as the active visualization and analysis work, and names Scalable registration as the next non-Playground priority. @@ -87,7 +90,7 @@ This approach was selected over: ## Architecture decision scope No ADR change is required. The -[Playground Architecture](../../playground-design.md#10-visualization-architecture) +[Playground Architecture](../../../playground-design.md#10-visualization-architecture) already assigns graph DTOs to the Python compiler, layout to ELK.js, and rendering to React. This specification narrows Phase 4 delivery and makes its protocol, DTO, layout, and rendering contracts executable. diff --git a/docs/superpowers/specs/archived/2026-07-27-graph-theme-css-design.md b/docs/superpowers/specs/archived/2026-07-27-graph-theme-css-design.md index 5d5547bd..f7073917 100644 --- a/docs/superpowers/specs/archived/2026-07-27-graph-theme-css-design.md +++ b/docs/superpowers/specs/archived/2026-07-27-graph-theme-css-design.md @@ -2,6 +2,8 @@ **Implementation Plan:** [Graph Theme CSS Implementation Plan](../../plans/archived/2026-07-27-graph-theme-css.md) +**Follow-up:** [Responsive Graph Viewport Design](../2026-07-27-responsive-graph-viewport-design.md) + ## Problem The playground redesign removed the internal graph-node styles while retaining From bea12272430430f1bcc6dfea9db6c3a4f2dc942f Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 14:49:18 +0200 Subject: [PATCH 2/7] docs: plan responsive graph viewport --- .../2026-07-27-responsive-graph-viewport.md | 960 ++++++++++++++++++ ...-07-27-responsive-graph-viewport-design.md | 2 + 2 files changed, 962 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-responsive-graph-viewport.md diff --git a/docs/superpowers/plans/2026-07-27-responsive-graph-viewport.md b/docs/superpowers/plans/2026-07-27-responsive-graph-viewport.md new file mode 100644 index 00000000..238bab86 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-responsive-graph-viewport.md @@ -0,0 +1,960 @@ +# Responsive Graph Viewport Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep graph nodes readable on constrained panels while using widened and ultrawide canvases to reveal more graph context without overlay collisions. + +**Design:** [Responsive Graph Viewport Design](../specs/2026-07-27-responsive-graph-viewport-design.md) + +**Architecture:** Add a pure viewport policy module and a focused React hook that applies that policy through React Flow's `fitView`, `Controls.fitViewOptions`, and canvas `ResizeObserver`. `GraphPanel` composes the hook with the existing export ref, while CSS handles responsive toolbar and overlay presentation and Playwright verifies real rendered geometry. + +**Tech Stack:** React 19, TypeScript 7, React Flow 12, ELK.js, Vitest, Testing Library, Playwright, CSS + +## Global Constraints + +- Automatic fitting must not reduce graph zoom below `0.6`. +- Automatic fitting must not enlarge normal graph nodes above `1`. +- Deliberate user zoom may still reach the existing interactive minimum of `0.1`. +- Domain and Lineage show the complete graph when it fits at the readable scale; Entity and Projection prefer readable nodes with panning. +- Resize-driven fitting is debounced and stops after deliberate user pan or zoom. +- Changing graph mode or graph layout permits a new initial fit. +- Nodes retain their authored dimensions on larger displays. +- The playground workbench uses the full available browser width instead of the existing `120rem` cap. +- MiniMap and controls must not obscure nodes in the initial fitted view. +- All graph modes must be screenshot-checked in explicit light and dark themes. +- No compiler graph DTO, protocol, persistence, ELK dependency, or React Flow dependency changes. +- Before every commit, run the four `AGENTS.md` commands from `cli/`. + +--- + +### Task 1: Define the readable viewport policy + +**Files:** +- Create: `web/src/visualization/graph-viewport.ts` +- Create: `web/src/visualization/graph-viewport.test.ts` + +**Interfaces:** +- Consumes: `BrowserGraphMode` from `web/src/protocol.ts` and `FitViewOptions` from `@xyflow/react`. +- Produces: `READABLE_FIT_MIN_ZOOM`, `INTERACTIVE_MIN_ZOOM`, `AUTO_FIT_MAX_ZOOM`, `RESIZE_FIT_DEBOUNCE_MS`, `CanvasSize`, `readableFitOptions(showMiniMap)`, and `shouldShowMiniMap(size, nodeCount)`. + +- [ ] **Step 1: Write the failing viewport-policy tests** + +```ts +import { describe, expect, test } from 'vitest'; + +import { + AUTO_FIT_MAX_ZOOM, + INTERACTIVE_MIN_ZOOM, + READABLE_FIT_MIN_ZOOM, + readableFitOptions, + shouldShowMiniMap, +} from './graph-viewport'; + +describe('readableFitOptions', () => { + test('keeps automatic fitting readable without restricting deliberate overview zoom', () => { + expect(READABLE_FIT_MIN_ZOOM).toBe(0.6); + expect(INTERACTIVE_MIN_ZOOM).toBe(0.1); + expect(AUTO_FIT_MAX_ZOOM).toBe(1); + expect(readableFitOptions(false)).toMatchObject({ + minZoom: 0.6, + maxZoom: 1, + }); + }); + + test('reserves additional right-side space when the minimap is visible', () => { + const withoutMiniMap = readableFitOptions(false); + const withMiniMap = readableFitOptions(true); + expect(withMiniMap.padding).not.toEqual(withoutMiniMap.padding); + }); +}); + +describe('shouldShowMiniMap', () => { + test('shows navigation context only for a dense graph on a roomy canvas', () => { + expect(shouldShowMiniMap({ width: 900, height: 600 }, 37)).toBe(true); + expect(shouldShowMiniMap({ width: 500, height: 600 }, 37)).toBe(false); + expect(shouldShowMiniMap({ width: 900, height: 300 }, 37)).toBe(false); + expect(shouldShowMiniMap({ width: 900, height: 600 }, 7)).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd web && npm test -- --run src/visualization/graph-viewport.test.ts` + +Expected: FAIL because `graph-viewport.ts` does not exist. + +- [ ] **Step 3: Implement the viewport policy** + +```ts +import type { FitViewOptions } from '@xyflow/react'; + +export const READABLE_FIT_MIN_ZOOM = 0.6; +export const AUTO_FIT_MAX_ZOOM = 1; +export const INTERACTIVE_MIN_ZOOM = 0.1; +export const RESIZE_FIT_DEBOUNCE_MS = 160; + +const MINIMAP_MIN_CANVAS_WIDTH = 640; +const MINIMAP_MIN_CANVAS_HEIGHT = 360; +const MINIMAP_MIN_NODE_COUNT = 10; + +export interface CanvasSize { + width: number; + height: number; +} + +export function shouldShowMiniMap( + size: CanvasSize, + nodeCount: number, +): boolean { + return ( + size.width >= MINIMAP_MIN_CANVAS_WIDTH && + size.height >= MINIMAP_MIN_CANVAS_HEIGHT && + nodeCount >= MINIMAP_MIN_NODE_COUNT + ); +} + +export function readableFitOptions(showMiniMap: boolean): FitViewOptions { + return { + minZoom: READABLE_FIT_MIN_ZOOM, + maxZoom: AUTO_FIT_MAX_ZOOM, + padding: { + top: '24px', + right: showMiniMap ? '164px' : '24px', + bottom: '64px', + left: '64px', + }, + }; +} +``` + +- [ ] **Step 4: Run the viewport-policy tests** + +Run: `cd web && npm test -- --run src/visualization/graph-viewport.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Run TypeScript checking** + +Run: `cd web && npm run check` + +Expected: PASS with no type errors from React Flow padding or exported policy types. + +- [ ] **Step 6: Run the mandatory repository gates** + +Run from `cli/`, in order: + +```bash +uv run ruff format . +uv run ruff check . +uv run python ../.github/scripts/check_mypy_baseline.py --baseline mypy-baseline.txt -- uv run mypy src/modelable --no-error-summary --show-error-codes +uv run pytest --tb=short +``` + +Expected: all four commands pass cleanly. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/visualization/graph-viewport.ts web/src/visualization/graph-viewport.test.ts +git commit -m "test: define readable graph viewport policy" +``` + +--- + +### Task 2: Apply resize-aware fitting without overriding user navigation + +**Files:** +- Create: `web/src/visualization/useReadableGraphViewport.ts` +- Create: `web/src/visualization/useReadableGraphViewport.test.tsx` + +**Interfaces:** +- Consumes: a shared `RefObject`, the current `GraphNode[]`, `fitView()` from `useReactFlow`, and the Task 1 viewport policy. +- Produces: + +```ts +export interface ReadableGraphViewport { + fitViewOptions: FitViewOptions; + showMiniMap: boolean; + onMoveStart: OnMoveStart; + onFitView(): void; +} + +export function useReadableGraphViewport( + containerRef: RefObject, + nodes: GraphNode[], +): ReadableGraphViewport; +``` + +- [ ] **Step 1: Write failing hook tests with controlled browser primitives** + +Mock `useReactFlow()` to return `fitView`, install a test `ResizeObserver`, and +stub `requestAnimationFrame` so fits execute deterministically: + +```tsx +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +const fitView = vi.fn(async () => true); +let animationFrame: FrameRequestCallback | null = null; +let resizeCallback: ResizeObserverCallback | null = null; + +vi.mock('@xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + useReactFlow: () => ({ fitView }), +})); + +function graphNode(id: string): GraphNode { + return { + id, + position: { x: 0, y: 0 }, + data: { + label: id, + kind: 'entity', + metadata: {}, + sourceRange: null, + direction: 'DOWN', + }, + }; +} + +function setCanvasRect( + element: HTMLDivElement, + width: number, + height: number, +): void { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: width, + bottom: height, + left: 0, + width, + height, + toJSON: () => ({}), + }); +} + +function runAnimationFrame(): void { + const callback = animationFrame; + animationFrame = null; + callback?.(0); +} + +function resizeCanvas( + target: HTMLDivElement, + width: number, + height: number, +): void { + setCanvasRect(target, width, height); + resizeCallback?.( + [ + { + target, + contentRect: target.getBoundingClientRect(), + } as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + fitView.mockClear(); + animationFrame = null; + resizeCallback = null; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + animationFrame = callback; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => { + animationFrame = null; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +test('fits a completed layout and refits a resized untouched canvas', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + const { result } = renderHook(() => + useReadableGraphViewport(containerRef, [graphNode('one')]), + ); + + await act(async () => runAnimationFrame()); + expect(fitView).toHaveBeenLastCalledWith(result.current.fitViewOptions); + + act(() => resizeCanvas(containerRef.current!, 900, 600)); + await act(async () => + vi.advanceTimersByTimeAsync(RESIZE_FIT_DEBOUNCE_MS), + ); + expect(fitView).toHaveBeenCalledTimes(2); +}); + +test('does not refit resize after pointer navigation begins', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + const { result } = renderHook(() => + useReadableGraphViewport(containerRef, [graphNode('one')]), + ); + await act(async () => runAnimationFrame()); + + act(() => result.current.onMoveStart(new MouseEvent('mousedown'), { + x: 0, + y: 0, + zoom: 1, + })); + act(() => resizeCanvas(containerRef.current!, 1000, 700)); + await act(async () => + vi.advanceTimersByTimeAsync(RESIZE_FIT_DEBOUNCE_MS), + ); + + expect(fitView).toHaveBeenCalledTimes(1); +}); + +test('a new node array establishes a new initial fit', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + const first = [graphNode('one')]; + const { rerender } = renderHook( + ({ nodes }) => useReadableGraphViewport(containerRef, nodes), + { initialProps: { nodes: first } }, + ); + await act(async () => runAnimationFrame()); + rerender({ nodes: [graphNode('two')] }); + await act(async () => runAnimationFrame()); + expect(fitView).toHaveBeenCalledTimes(2); +}); +``` + +- [ ] **Step 2: Run the hook tests to verify they fail** + +Run: `cd web && npm test -- --run src/visualization/useReadableGraphViewport.test.tsx` + +Expected: FAIL because `useReadableGraphViewport.ts` does not exist. + +- [ ] **Step 3: Implement the hook** + +Implement the hook with these state transitions: + +```ts +const { fitView } = useReactFlow(); +const [canvasSize, setCanvasSize] = useState({ + width: 0, + height: 0, +}); +const userNavigatedRef = useRef(false); +const previousSizeRef = useRef(null); +const showMiniMap = shouldShowMiniMap(canvasSize, nodes.length); +const fitViewOptions = useMemo( + () => readableFitOptions(showMiniMap), + [showMiniMap], +); + +const fitViewOptionsRef = useRef(fitViewOptions); +fitViewOptionsRef.current = fitViewOptions; + +const measureFitOptions = useCallback((): FitViewOptions => { + const bounds = containerRef.current?.getBoundingClientRect(); + if (bounds === undefined) return fitViewOptionsRef.current; + const size = { width: bounds.width, height: bounds.height }; + const next = readableFitOptions(shouldShowMiniMap(size, nodes.length)); + setCanvasSize(size); + fitViewOptionsRef.current = next; + return next; +}, [containerRef, nodes.length]); + +const fitReadableView = useCallback(() => { + void fitView(measureFitOptions()); +}, [fitView, measureFitOptions]); + +useEffect(() => { + if (nodes.length === 0) return; + userNavigatedRef.current = false; + const frame = requestAnimationFrame(fitReadableView); + return () => cancelAnimationFrame(frame); +}, [fitReadableView, nodes]); +``` + +The `nodes` dependency intentionally represents a completed new layout. A +canvas-size change updates `fitViewOptionsRef` but does not clear +`userNavigatedRef`. + +Add a `ResizeObserver` effect that calls `measureFitOptions`, compares the +observed width and height with `previousSizeRef`, and schedules +`fitReadableView` after `RESIZE_FIT_DEBOUNCE_MS` only when nodes exist, +dimensions actually changed, and `userNavigatedRef.current === false`. On the +observer's first notification, record the size without scheduling a duplicate +fit. Clear the timeout and disconnect the observer during cleanup. + +Return an `onMoveStart` callback that sets `userNavigatedRef.current = true` +only when React Flow supplies a non-null pointer or touch event. Programmatic +fits use a null event and must not disable future responsive fitting. Return +`onFitView` as a callback that sets `userNavigatedRef.current = false`, so the +explicit fit control restores responsive fitting after the user resets the +view. + +- [ ] **Step 4: Run the hook and policy tests** + +Run: + +```bash +cd web +npm test -- --run src/visualization/useReadableGraphViewport.test.tsx src/visualization/graph-viewport.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run TypeScript checking** + +Run: `cd web && npm run check` + +Expected: PASS. + +- [ ] **Step 6: Run the mandatory repository gates** + +Run the four commands in `Global Constraints` from `cli/`. + +Expected: all four commands pass cleanly. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/visualization/useReadableGraphViewport.ts web/src/visualization/useReadableGraphViewport.test.tsx +git commit -m "feat: add responsive graph viewport fitting" +``` + +--- + +### Task 3: Integrate readable fitting, responsive MiniMap, and export ref ownership + +**Files:** +- Modify: `web/src/visualization/GraphPanel.tsx:1-145` +- Modify: `web/src/visualization/useGraphExport.ts:1-111` +- Create: `web/src/visualization/GraphPanel.test.tsx` + +**Interfaces:** +- Consumes: `useReadableGraphViewport(containerRef, nodes)` from Task 2. +- Produces: one shared graph canvas ref, readable initial and control fits, + user-interaction tracking, and conditional MiniMap rendering. +- Changes `useGraphExport` to: + +```ts +export function useGraphExport( + containerRef: RefObject, +): { + exportSvg(): void; + exportPng(): void; +}; +``` + +- [ ] **Step 1: Write failing GraphPanel composition tests** + +Mock `useGraphLayout`, `useGraphSync`, `useGraphExport`, +`useReadableGraphViewport`, and the lightweight React Flow surface. Assert the +policy is connected to both the canvas and built-in controls: + +```tsx +test('shares readable fit options with React Flow and its fit control', () => { + render(); + + expect(screen.getByTestId('react-flow')).toHaveAttribute('data-min-zoom', '0.1'); + expect(screen.getByTestId('react-flow')).toHaveAttribute( + 'data-fit-min-zoom', + '0.6', + ); + expect(screen.getByTestId('controls')).toHaveAttribute( + 'data-fit-min-zoom', + '0.6', + ); +}); + +test('renders the minimap only when the responsive policy enables it', () => { + mockViewport.showMiniMap = false; + const { rerender } = render(); + expect(screen.queryByTestId('minimap')).toBeNull(); + + mockViewport.showMiniMap = true; + rerender(); + expect(screen.getByTestId('minimap')).toBeTruthy(); +}); +``` + +The React Flow mock must render its `children`, expose `fitViewOptions.minZoom`, +`minZoom`, and `onMoveStart`, and use distinct `data-testid` values for +`Controls` and `MiniMap`. + +- [ ] **Step 2: Run the GraphPanel tests to verify they fail** + +Run: `cd web && npm test -- --run src/visualization/GraphPanel.test.tsx` + +Expected: FAIL because `GraphPanel` still uses unconditional `fitView()` and +always renders the MiniMap. + +- [ ] **Step 3: Give GraphPanel one shared container ref** + +In `GraphPanelInner`, create the ref and pass it to both hooks: + +```ts +const containerRef = useRef(null); +const { exportSvg, exportPng } = useGraphExport(containerRef); +const { fitViewOptions, showMiniMap, onMoveStart, onFitView } = + useReadableGraphViewport(containerRef, nodes); +``` + +Update `useGraphExport` to consume the supplied ref and remove its internal +`useRef`. Keep export behavior and filenames unchanged. + +- [ ] **Step 4: Replace unconditional fitting with the shared policy** + +Remove the existing `useReactFlow`/`useEffect` fit block. Configure React Flow +and its children: + +```tsx + onNodeClick(node as GraphNode)} + onMoveStart={onMoveStart} + fitView + fitViewOptions={fitViewOptions} + minZoom={INTERACTIVE_MIN_ZOOM} + maxZoom={2} + proOptions={{ hideAttribution: true }} +> + + {showMiniMap ? ( + +``` + +Group the existing toolbar buttons without changing their labels or accessible +toolbar role: + +```tsx +
+
+ {([ + ['domain', 'Domain'], + ['entity', 'Entity'], + ['projection', 'Projection'], + ['lineage', 'Lineage'], + ] as const).map(([candidate, label]) => ( + + ))} +
+
+ + +
+
+``` + +Remove `.graph-panel__toolbar-spacer`; the two groups own toolbar distribution. + +- [ ] **Step 5: Run the focused tests and type check** + +Run: + +```bash +cd web +npm test -- --run src/visualization/GraphPanel.test.tsx src/visualization/useReadableGraphViewport.test.tsx src/visualization/graph-viewport.test.ts +npm run check +``` + +Expected: all tests and TypeScript checking pass. + +- [ ] **Step 6: Run the mandatory repository gates** + +Run the four commands in `Global Constraints` from `cli/`. + +Expected: all four commands pass cleanly. + +- [ ] **Step 7: Commit** + +```bash +git add web/src/visualization/GraphPanel.tsx web/src/visualization/useGraphExport.ts web/src/visualization/GraphPanel.test.tsx +git commit -m "feat: keep dense graph views readable" +``` + +--- + +### Task 4: Polish responsive canvas, overlays, toolbar, and ultrawide layout + +**Files:** +- Modify: `web/src/style.css:213-221` +- Modify: `web/src/style.css:612-795` +- Modify: `web/src/style.css:1419-1495` +- Modify: `web/tests/playground.spec.ts` + +**Interfaces:** +- Consumes: `.graph-panel__minimap`, `.graph-panel__canvas`, existing React + Flow classes, and the readable viewport behavior from Tasks 1–3. +- Produces: full-width workbench CSS, compact graph toolbar, bounded MiniMap, + compact horizontal controls, and end-to-end geometry assertions. + +- [ ] **Step 1: Add failing Playwright checks for readable dense modes** + +Add helpers: + +```ts +function graphZoom(page: Page): Promise { + return page.locator('.graph-panel .react-flow__viewport').evaluate((element) => { + const transform = (element as HTMLElement).style.transform; + const match = transform.match(/scale\(([\d.]+)\)/); + if (match?.[1] === undefined) throw new Error(`Missing graph scale: ${transform}`); + return Number(match[1]); + }); +} + +function rectanglesOverlap( + left: { x: number; y: number; width: number; height: number }, + right: { x: number; y: number; width: number; height: number }, +): boolean { + return ( + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y + ); +} +``` + +Add a test that opens Entity and Projection modes at 1280×720, waits for each +layout, and asserts `await graphZoom(page) >= 0.6`. Collect visible node, +controls, and optional MiniMap bounding boxes and assert no initial +intersection. + +- [ ] **Step 2: Add failing large-screen and responsive-MiniMap checks** + +At 2560×1440, assert: + +```ts +const workbenchWidth = await page.locator('.workbench').evaluate( + (element) => element.getBoundingClientRect().width, +); +expect(workbenchWidth).toBeGreaterThanOrEqual(2500); +``` + +Record canvas width and visible node count, widen the visualization panel by +dragging its left separator, and assert the canvas grows. Verify the graph +still starts at or above `0.6`. + +At a graph canvas narrower than `640px`, assert the MiniMap is absent. On a +roomy dense canvas, assert it is present and no larger than `144px` by `104px`. + +- [ ] **Step 3: Run the new Playwright tests to verify they fail** + +Run: + +```bash +cd web +npx playwright test tests/playground.spec.ts --project=chromium --grep "graph.*readable|ultrawide|responsive minimap" +``` + +Expected: FAIL because Entity and Projection start below `0.6`, the workbench +is capped at 1920px, and the default MiniMap is 202px by 152px. + +- [ ] **Step 4: Implement the responsive graph CSS** + +Replace the workbench width cap: + +```css +.workbench { + width: 100%; + max-width: none; +} +``` + +Refine the graph toolbar and canvas: + +```css +.graph-panel .tab-strip { + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.3rem 0.4rem; +} + +.graph-panel { + container-type: inline-size; +} + +.graph-panel__modes, +.graph-panel__exports { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.graph-panel__modes { + flex: 1 1 auto; +} + +.graph-panel__exports { + margin-left: auto; +} + +.graph-panel .tab, +.graph-panel__export-btn { + min-height: 1.75rem; + padding: 0.2rem 0.45rem; + white-space: nowrap; +} + +.graph-panel__canvas { + isolation: isolate; +} + +.graph-panel .react-flow__controls { + display: flex; + overflow: hidden; + flex-direction: row; + border: 1px solid var(--graph-border); + border-radius: 0.45rem; + box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--text) 12%, transparent); +} + +.graph-panel .react-flow__controls-button { + width: 1.75rem; + height: 1.75rem; + border-bottom: 0; + border-right: 1px solid var(--border); +} + +.graph-panel .react-flow__controls-button:last-child { + border-right: 0; +} + +.graph-panel__minimap { + overflow: hidden; + border-radius: 0.5rem; + box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--text) 12%, transparent); +} + +@container (max-width: 34rem) { + .graph-panel__exports { + width: 100%; + margin-left: 0; + } + + .graph-panel__export-btn { + flex: 1; + } +} +``` + +The container query tracks the resizable graph panel instead of the browser +viewport. Keep visible focus indicators and the existing graph theme tokens. + +- [ ] **Step 5: Tune graph spacing only where screenshots show collisions** + +If the initial screenshot pass shows unnecessarily tight paths, adjust +`buildElkGraph()` with explicit per-mode spacing helpers covered in +`layout-model.test.ts`: + +```ts +export function layoutSpacing(mode: BrowserGraphMode): { + nodeNode: string; + betweenLayers: string; +} { + return mode === 'entity' + ? { nodeNode: '36', betweenLayers: '72' } + : { nodeNode: '32', betweenLayers: '68' }; +} +``` + +Do not change node dimensions or semantic edge relationships. Skip this edit +when the viewport and overlay changes already produce clear spacing. + +- [ ] **Step 6: Run focused web verification** + +Run: + +```bash +cd web +npm test -- --run src/visualization +npm run check +npx playwright test tests/playground.spec.ts --project=chromium --grep "graph" +``` + +Expected: all graph unit, component, and Playwright tests pass. + +- [ ] **Step 7: Run the mandatory repository gates** + +Run the four commands in `Global Constraints` from `cli/`. + +Expected: all four commands pass cleanly. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/style.css web/tests/playground.spec.ts web/src/visualization/layout-model.ts web/src/visualization/layout-model.test.ts +git commit -m "style: polish responsive graph composition" +``` + +Omit unchanged layout-model files from `git add` when Step 5 was unnecessary. + +--- + +### Task 5: Capture the final visual matrix and run release verification + +**Files:** +- Modify only files that require evidence-driven visual tuning from Task 4. +- Capture ignored artifacts under: `web/output/playwright/graph-cleanup/` + +**Interfaces:** +- Consumes: the complete responsive graph implementation. +- Produces: before/after screenshot evidence at constrained, default, and + ultrawide sizes and a fully verified branch. + +- [ ] **Step 1: Build and start the production preview** + +Run: + +```bash +cd web +npm run build +npm run preview +``` + +Expected: production build succeeds and preview listens on +`http://127.0.0.1:4173/modelable/playground/`. + +- [ ] **Step 2: Capture explicit light-theme screenshots** + +Using the Playwright CLI, set `localStorage['modelable:theme'] = 'light'`, open +the graph tab, and capture Domain, Entity, Projection, and Lineage at 1280×720: + +```text +web/output/playwright/graph-cleanup/domain-light.png +web/output/playwright/graph-cleanup/entity-light.png +web/output/playwright/graph-cleanup/projection-light.png +web/output/playwright/graph-cleanup/lineage-light.png +``` + +- [ ] **Step 3: Capture explicit dark-theme screenshots** + +Switch to the explicit dark theme and capture the same four modes: + +```text +web/output/playwright/graph-cleanup/domain-dark.png +web/output/playwright/graph-cleanup/entity-dark.png +web/output/playwright/graph-cleanup/projection-dark.png +web/output/playwright/graph-cleanup/lineage-dark.png +``` + +- [ ] **Step 4: Capture constrained and ultrawide dense-graph screenshots** + +Capture Entity mode with a constrained graph panel and at 2560×1440: + +```text +web/output/playwright/graph-cleanup/entity-constrained.png +web/output/playwright/graph-cleanup/entity-ultrawide.png +``` + +Verify visually that labels remain readable, extra ultrawide space reveals +more context, controls and MiniMap do not cover nodes, and toolbar actions do +not overflow. + +- [ ] **Step 5: Make evidence-driven visual corrections** + +Limit corrections to the approved design: viewport padding, MiniMap bounds, +toolbar density, overlay placement, graph-specific theme tokens, edges, and +mode spacing. Re-run the focused unit and Playwright tests after every +correction and recapture any affected screenshots. + +- [ ] **Step 6: Run complete web verification** + +Run: + +```bash +cd web +npm test +npm run check +npm run build +npx playwright test --project=chromium +``` + +Expected: all commands pass. + +- [ ] **Step 7: Run the mandatory repository gates** + +Run from `cli/`, in order: + +```bash +uv run ruff format . +uv run ruff check . +uv run python ../.github/scripts/check_mypy_baseline.py --baseline mypy-baseline.txt -- uv run mypy src/modelable --no-error-summary --show-error-codes +uv run pytest --tb=short +``` + +Expected: all four commands pass cleanly. + +- [ ] **Step 8: Confirm branch scope and commit any final tuning** + +Run: + +```bash +git status --short +git diff --check +git diff --stat +``` + +If Step 5 changed tracked files, commit exactly those files: + +```bash +git add web/src web/tests/playground.spec.ts +git commit -m "style: finish graph visual cleanup" +``` + +Do not add `web/output/playwright/`; screenshots remain local verification +artifacts for the user. diff --git a/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md b/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md index 9073f6bb..da1e1d1a 100644 --- a/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md +++ b/docs/superpowers/specs/2026-07-27-responsive-graph-viewport-design.md @@ -4,6 +4,8 @@ Approved for implementation on 2026-07-27. +**Implementation Plan:** [Responsive Graph Viewport Implementation Plan](../plans/2026-07-27-responsive-graph-viewport.md) + This specification follows the shipped [Playground Visualization MVP](archived/2026-07-21-playground-visualization-design.md) and [Graph Theme CSS](archived/2026-07-27-graph-theme-css-design.md) designs. From c666851006917a34874c15d36b6aa232c0ebcdca Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 14:54:07 +0200 Subject: [PATCH 3/7] test: define readable graph viewport policy --- web/src/visualization/graph-viewport.test.ts | 36 ++++++++++++++++++ web/src/visualization/graph-viewport.ts | 39 ++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 web/src/visualization/graph-viewport.test.ts create mode 100644 web/src/visualization/graph-viewport.ts diff --git a/web/src/visualization/graph-viewport.test.ts b/web/src/visualization/graph-viewport.test.ts new file mode 100644 index 00000000..dc083094 --- /dev/null +++ b/web/src/visualization/graph-viewport.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; + +import { + AUTO_FIT_MAX_ZOOM, + INTERACTIVE_MIN_ZOOM, + READABLE_FIT_MIN_ZOOM, + readableFitOptions, + shouldShowMiniMap, +} from './graph-viewport'; + +describe('readableFitOptions', () => { + test('keeps automatic fitting readable without restricting deliberate overview zoom', () => { + expect(READABLE_FIT_MIN_ZOOM).toBe(0.6); + expect(INTERACTIVE_MIN_ZOOM).toBe(0.1); + expect(AUTO_FIT_MAX_ZOOM).toBe(1); + expect(readableFitOptions(false)).toMatchObject({ + minZoom: 0.6, + maxZoom: 1, + }); + }); + + test('reserves additional right-side space when the minimap is visible', () => { + const withoutMiniMap = readableFitOptions(false); + const withMiniMap = readableFitOptions(true); + expect(withMiniMap.padding).not.toEqual(withoutMiniMap.padding); + }); +}); + +describe('shouldShowMiniMap', () => { + test('shows navigation context only for a dense graph on a roomy canvas', () => { + expect(shouldShowMiniMap({ width: 900, height: 600 }, 37)).toBe(true); + expect(shouldShowMiniMap({ width: 500, height: 600 }, 37)).toBe(false); + expect(shouldShowMiniMap({ width: 900, height: 300 }, 37)).toBe(false); + expect(shouldShowMiniMap({ width: 900, height: 600 }, 7)).toBe(false); + }); +}); diff --git a/web/src/visualization/graph-viewport.ts b/web/src/visualization/graph-viewport.ts new file mode 100644 index 00000000..9b953b92 --- /dev/null +++ b/web/src/visualization/graph-viewport.ts @@ -0,0 +1,39 @@ +import type { FitViewOptions } from '@xyflow/react'; + +export const READABLE_FIT_MIN_ZOOM = 0.6; +export const AUTO_FIT_MAX_ZOOM = 1; +export const INTERACTIVE_MIN_ZOOM = 0.1; +export const RESIZE_FIT_DEBOUNCE_MS = 160; + +const MINIMAP_MIN_CANVAS_WIDTH = 640; +const MINIMAP_MIN_CANVAS_HEIGHT = 360; +const MINIMAP_MIN_NODE_COUNT = 10; + +export interface CanvasSize { + width: number; + height: number; +} + +export function shouldShowMiniMap( + size: CanvasSize, + nodeCount: number, +): boolean { + return ( + size.width >= MINIMAP_MIN_CANVAS_WIDTH && + size.height >= MINIMAP_MIN_CANVAS_HEIGHT && + nodeCount >= MINIMAP_MIN_NODE_COUNT + ); +} + +export function readableFitOptions(showMiniMap: boolean): FitViewOptions { + return { + minZoom: READABLE_FIT_MIN_ZOOM, + maxZoom: AUTO_FIT_MAX_ZOOM, + padding: { + top: '24px', + right: showMiniMap ? '164px' : '24px', + bottom: '64px', + left: '64px', + }, + }; +} From 7c5320ad99f735e62710bbc2fe693124f2fbfdcd Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 14:56:40 +0200 Subject: [PATCH 4/7] feat: add responsive graph viewport fitting --- .../useReadableGraphViewport.test.tsx | 200 ++++++++++++++++++ .../visualization/useReadableGraphViewport.ts | 140 ++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 web/src/visualization/useReadableGraphViewport.test.tsx create mode 100644 web/src/visualization/useReadableGraphViewport.ts diff --git a/web/src/visualization/useReadableGraphViewport.test.tsx b/web/src/visualization/useReadableGraphViewport.test.tsx new file mode 100644 index 00000000..76b08a62 --- /dev/null +++ b/web/src/visualization/useReadableGraphViewport.test.tsx @@ -0,0 +1,200 @@ +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import type { GraphNode } from './graph-types'; +import { RESIZE_FIT_DEBOUNCE_MS } from './graph-viewport'; +import { useReadableGraphViewport } from './useReadableGraphViewport'; + +const mocks = vi.hoisted(() => ({ + fitView: vi.fn(async () => true), +})); + +vi.mock('@xyflow/react', async (importOriginal) => ({ + ...(await importOriginal()), + useReactFlow: () => ({ fitView: mocks.fitView }), +})); + +let animationFrames: FrameRequestCallback[] = []; +let resizeCallback: ResizeObserverCallback | null = null; + +function graphNode(id: string): GraphNode { + return { + id, + position: { x: 0, y: 0 }, + data: { + label: id, + kind: 'entity', + metadata: {}, + sourceRange: null, + direction: 'DOWN', + }, + }; +} + +function setCanvasRect( + element: HTMLDivElement, + width: number, + height: number, +): void { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: width, + bottom: height, + left: 0, + width, + height, + toJSON: () => ({}), + }); +} + +function runAnimationFrames(): void { + const callbacks = animationFrames; + animationFrames = []; + for (const callback of callbacks) callback(0); +} + +function resizeCanvas( + target: HTMLDivElement, + width: number, + height: number, +): void { + setCanvasRect(target, width, height); + resizeCallback?.( + [ + { + target, + contentRect: target.getBoundingClientRect(), + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + mocks.fitView.mockClear(); + animationFrames = []; + resizeCallback = null; + vi.stubGlobal( + 'requestAnimationFrame', + (callback: FrameRequestCallback): number => { + animationFrames.push(callback); + return animationFrames.length; + }, + ); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + animationFrames[id - 1] = () => undefined; + }); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test('fits a completed layout and refits a resized untouched canvas', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + + const { result } = renderHook(() => + useReadableGraphViewport(containerRef, [graphNode('one')]), + ); + await act(async () => runAnimationFrames()); + + expect(mocks.fitView).toHaveBeenLastCalledWith(result.current.fitViewOptions); + expect(result.current.showMiniMap).toBe(false); + + act(() => resizeCanvas(containerRef.current!, 900, 600)); + await act(async () => + vi.advanceTimersByTimeAsync(RESIZE_FIT_DEBOUNCE_MS), + ); + + expect(mocks.fitView).toHaveBeenCalledTimes(2); + expect(result.current.showMiniMap).toBe(false); +}); + +test('does not refit resize after pointer navigation begins', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + + const { result } = renderHook(() => + useReadableGraphViewport(containerRef, [graphNode('one')]), + ); + await act(async () => runAnimationFrames()); + + act(() => + result.current.onMoveStart(new MouseEvent('mousedown'), { + x: 0, + y: 0, + zoom: 1, + }), + ); + act(() => resizeCanvas(containerRef.current!, 1000, 700)); + await act(async () => + vi.advanceTimersByTimeAsync(RESIZE_FIT_DEBOUNCE_MS), + ); + + expect(mocks.fitView).toHaveBeenCalledTimes(1); +}); + +test('a new node array establishes a new initial fit', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + const first = [graphNode('one')]; + + const { rerender } = renderHook( + ({ nodes }) => useReadableGraphViewport(containerRef, nodes), + { initialProps: { nodes: first } }, + ); + await act(async () => runAnimationFrames()); + rerender({ nodes: [graphNode('two')] }); + await act(async () => runAnimationFrames()); + + expect(mocks.fitView).toHaveBeenCalledTimes(2); +}); + +test('fit control restores responsive fitting after manual navigation', async () => { + const containerRef = createRef(); + containerRef.current = document.createElement('div'); + setCanvasRect(containerRef.current, 700, 500); + + const { result } = renderHook(() => + useReadableGraphViewport(containerRef, [graphNode('one')]), + ); + await act(async () => runAnimationFrames()); + act(() => + result.current.onMoveStart(new MouseEvent('mousedown'), { + x: 0, + y: 0, + zoom: 1, + }), + ); + + act(() => result.current.onFitView()); + act(() => resizeCanvas(containerRef.current!, 1000, 700)); + await act(async () => + vi.advanceTimersByTimeAsync(RESIZE_FIT_DEBOUNCE_MS), + ); + + expect(mocks.fitView).toHaveBeenCalledTimes(2); +}); diff --git a/web/src/visualization/useReadableGraphViewport.ts b/web/src/visualization/useReadableGraphViewport.ts new file mode 100644 index 00000000..8c1abc55 --- /dev/null +++ b/web/src/visualization/useReadableGraphViewport.ts @@ -0,0 +1,140 @@ +import { + useReactFlow, + type FitViewOptions, + type OnMoveStart, +} from '@xyflow/react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from 'react'; + +import type { GraphNode } from './graph-types'; +import { + RESIZE_FIT_DEBOUNCE_MS, + readableFitOptions, + shouldShowMiniMap, + type CanvasSize, +} from './graph-viewport'; + +export interface ReadableGraphViewport { + fitViewOptions: FitViewOptions; + showMiniMap: boolean; + onMoveStart: OnMoveStart; + onFitView(): void; +} + +function sameSize(left: CanvasSize, right: CanvasSize): boolean { + return left.width === right.width && left.height === right.height; +} + +export function useReadableGraphViewport( + containerRef: RefObject, + nodes: GraphNode[], +): ReadableGraphViewport { + const { fitView } = useReactFlow(); + const [canvasSize, setCanvasSize] = useState({ + width: 0, + height: 0, + }); + const userNavigatedRef = useRef(false); + const previousSizeRef = useRef(null); + const resizeTimerRef = useRef | null>(null); + const showMiniMap = shouldShowMiniMap(canvasSize, nodes.length); + const fitViewOptions = useMemo( + () => readableFitOptions(showMiniMap), + [showMiniMap], + ); + const fitViewOptionsRef = useRef(fitViewOptions); + fitViewOptionsRef.current = fitViewOptions; + + const optionsForSize = useCallback( + (size: CanvasSize): FitViewOptions => { + setCanvasSize((previous) => (sameSize(previous, size) ? previous : size)); + const next = readableFitOptions(shouldShowMiniMap(size, nodes.length)); + fitViewOptionsRef.current = next; + return next; + }, + [nodes.length], + ); + + const fitReadableView = useCallback(() => { + const bounds = containerRef.current?.getBoundingClientRect(); + const options = + bounds === undefined + ? fitViewOptionsRef.current + : optionsForSize({ width: bounds.width, height: bounds.height }); + if (bounds !== undefined) { + previousSizeRef.current = { + width: bounds.width, + height: bounds.height, + }; + } + void fitView(options); + }, [containerRef, fitView, optionsForSize]); + + useEffect(() => { + if (nodes.length === 0) return; + userNavigatedRef.current = false; + const frame = requestAnimationFrame(fitReadableView); + return () => cancelAnimationFrame(frame); + }, [fitReadableView, nodes]); + + useEffect(() => { + const container = containerRef.current; + if (container === null) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry === undefined) return; + const nextSize = { + width: entry.contentRect.width, + height: entry.contentRect.height, + }; + const previousSize = previousSizeRef.current; + previousSizeRef.current = nextSize; + optionsForSize(nextSize); + + if ( + previousSize === null || + sameSize(previousSize, nextSize) || + nodes.length === 0 || + userNavigatedRef.current + ) { + return; + } + + if (resizeTimerRef.current !== null) { + clearTimeout(resizeTimerRef.current); + } + resizeTimerRef.current = setTimeout(() => { + resizeTimerRef.current = null; + void fitView(fitViewOptionsRef.current); + }, RESIZE_FIT_DEBOUNCE_MS); + }); + observer.observe(container); + + return () => { + observer.disconnect(); + if (resizeTimerRef.current !== null) { + clearTimeout(resizeTimerRef.current); + resizeTimerRef.current = null; + } + }; + }, [containerRef, fitView, nodes.length, optionsForSize]); + + const onMoveStart = useCallback((event) => { + if (event !== null) { + userNavigatedRef.current = true; + } + }, []); + + const onFitView = useCallback(() => { + userNavigatedRef.current = false; + }, []); + + return { fitViewOptions, showMiniMap, onMoveStart, onFitView }; +} From 78c4d1a4820a024be69010eefaae4ea0504e84b7 Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 14:59:38 +0200 Subject: [PATCH 5/7] feat: keep dense graph views readable --- web/src/visualization/GraphPanel.test.tsx | 162 ++++++++++++++++++++++ web/src/visualization/GraphPanel.tsx | 113 +++++++-------- web/src/visualization/useGraphExport.ts | 10 +- 3 files changed, 220 insertions(+), 65 deletions(-) create mode 100644 web/src/visualization/GraphPanel.test.tsx diff --git a/web/src/visualization/GraphPanel.test.tsx b/web/src/visualization/GraphPanel.test.tsx new file mode 100644 index 00000000..70558bf4 --- /dev/null +++ b/web/src/visualization/GraphPanel.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import type { BrowserGraphResult } from '../protocol'; +import type { GraphNode } from './graph-types'; +import { GraphPanel, type GraphPanelProps } from './GraphPanel'; + +const mocks = vi.hoisted(() => ({ + useGraphExport: vi.fn(() => ({ + exportSvg: vi.fn(), + exportPng: vi.fn(), + })), + viewport: { + fitViewOptions: { + minZoom: 0.6, + maxZoom: 1, + padding: 0.1, + }, + showMiniMap: false, + onMoveStart: vi.fn(), + onFitView: vi.fn(), + }, +})); + +const node: GraphNode = { + id: 'domain:sales', + type: 'domain', + position: { x: 0, y: 0 }, + data: { + label: 'sales', + kind: 'domain', + metadata: {}, + sourceRange: null, + direction: 'RIGHT', + }, +}; + +vi.mock('@xyflow/react', () => ({ + Background: () =>
, + BackgroundVariant: { Dots: 'dots' }, + Controls: ({ + fitViewOptions, + onFitView, + }: { + fitViewOptions?: { minZoom?: number }; + onFitView?: () => void; + }) => ( + + ), + MiniMap: () =>
, + ReactFlow: ({ + children, + fitViewOptions, + minZoom, + onMoveStart, + }: { + children: React.ReactNode; + fitViewOptions?: { minZoom?: number }; + minZoom?: number; + onMoveStart?: (event: MouseEvent, viewport: unknown) => void; + }) => ( +
+ onMoveStart?.(event.nativeEvent, { x: 0, y: 0, zoom: 1 }) + } + > + {children} +
+ ), + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => children, + useReactFlow: () => ({ fitView: vi.fn(async () => true) }), +})); + +vi.mock('./useGraphExport', () => ({ + useGraphExport: mocks.useGraphExport, +})); + +vi.mock('./useGraphLayout', () => ({ + useGraphLayout: () => ({ + nodes: [node], + edges: [], + loading: false, + error: null, + }), +})); + +vi.mock('./useGraphSync', () => ({ + useGraphSync: () => ({ + selectedNodeId: null, + onNodeClick: vi.fn(), + }), +})); + +vi.mock('./useReadableGraphViewport', () => ({ + useReadableGraphViewport: () => mocks.viewport, +})); + +const graphResult: BrowserGraphResult = { + workspace_revision: 1, + mode: 'domain', + graph: { + schema_version: 1, + nodes: [], + edges: [], + }, +}; + +const props: GraphPanelProps = { + graphResult, + mode: 'domain', + onModeChange: vi.fn(), +}; + +beforeEach(() => { + mocks.useGraphExport.mockClear(); + mocks.viewport.showMiniMap = false; +}); + +afterEach(cleanup); + +test('shares readable fit options with React Flow and its fit control', () => { + render(); + + expect(screen.getByTestId('react-flow').getAttribute('data-min-zoom')).toBe( + '0.1', + ); + expect( + screen.getByTestId('react-flow').getAttribute('data-fit-min-zoom'), + ).toBe('0.6'); + expect(screen.getByTestId('controls').getAttribute('data-fit-min-zoom')).toBe( + '0.6', + ); +}); + +test('renders the minimap only when the responsive policy enables it', () => { + const { rerender } = render(); + expect(screen.queryByTestId('minimap')).toBeNull(); + + mocks.viewport.showMiniMap = true; + rerender(); + expect(screen.getByTestId('minimap')).toBeTruthy(); +}); + +test('shares the graph canvas ref with export behavior', () => { + render(); + + expect(mocks.useGraphExport).toHaveBeenCalledOnce(); + expect(mocks.useGraphExport).toHaveBeenCalledWith( + expect.objectContaining({ current: expect.any(HTMLDivElement) }), + ); +}); diff --git a/web/src/visualization/GraphPanel.tsx b/web/src/visualization/GraphPanel.tsx index ddcf54de..98d91539 100644 --- a/web/src/visualization/GraphPanel.tsx +++ b/web/src/visualization/GraphPanel.tsx @@ -5,15 +5,16 @@ import { Background, BackgroundVariant, ReactFlowProvider, - useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { useEffect } from 'react'; +import { useRef } from 'react'; import type { BrowserGraphResult, BrowserGraphMode } from '../protocol'; +import { INTERACTIVE_MIN_ZOOM } from './graph-viewport'; import type { GraphNode } from './graph-types'; import { useGraphExport } from './useGraphExport'; import { useGraphLayout } from './useGraphLayout'; +import { useReadableGraphViewport } from './useReadableGraphViewport'; import { useGraphSync } from './useGraphSync'; import { edgeTypes, nodeTypes } from './registry'; @@ -36,10 +37,13 @@ function GraphPanelInner({ cursorLine = null, onRevealRange, }: GraphPanelProps) { - const { containerRef, exportSvg, exportPng } = useGraphExport(); + const containerRef = useRef(null); + const { exportSvg, exportPng } = useGraphExport(containerRef); const { nodes, edges, loading, error: layoutError } = useGraphLayout( graphResult, ); + const { fitViewOptions, showMiniMap, onMoveStart, onFitView } = + useReadableGraphViewport(containerRef, nodes); const failure = error ?? layoutError; const { selectedNodeId, onNodeClick } = useGraphSync( nodes, @@ -55,63 +59,44 @@ function GraphPanelInner({ })) : nodes; - // Nodes arrive after the first render, so the `fitView` prop alone only ever - // fits an empty canvas. Refit once each new layout has been painted. - const { fitView } = useReactFlow(); - useEffect(() => { - if (nodes.length === 0) return; - const frame = requestAnimationFrame(() => { - void fitView(); - }); - return () => cancelAnimationFrame(frame); - }, [fitView, nodes]); - return (
- - - - - - - +
+ {( + [ + ['domain', 'Domain'], + ['entity', 'Entity'], + ['projection', 'Projection'], + ['lineage', 'Lineage'], + ] as const + ).map(([candidate, label]) => ( + + ))} +
+
+ + +
{failure !== null && ( @@ -138,13 +123,21 @@ function GraphPanelInner({ nodeTypes={nodeTypes} edgeTypes={edgeTypes} onNodeClick={(_event, node) => onNodeClick(node as GraphNode)} + onMoveStart={onMoveStart} fitView - minZoom={0.1} + fitViewOptions={fitViewOptions} + minZoom={INTERACTIVE_MIN_ZOOM} maxZoom={2} proOptions={{ hideAttribution: true }} > - -
diff --git a/web/src/visualization/useGraphExport.ts b/web/src/visualization/useGraphExport.ts index 49295389..09e57770 100644 --- a/web/src/visualization/useGraphExport.ts +++ b/web/src/visualization/useGraphExport.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, type RefObject } from 'react'; function collectStyles(): string { const sheets = Array.from(document.styleSheets); @@ -73,9 +73,9 @@ function downloadBlob(blob: Blob, filename: string): void { URL.revokeObjectURL(url); } -export function useGraphExport() { - const containerRef = useRef(null); - +export function useGraphExport( + containerRef: RefObject, +) { const exportSvg = useCallback(() => { const container = containerRef.current; if (container === null) return; @@ -122,5 +122,5 @@ export function useGraphExport() { img.src = url; }, []); - return { containerRef, exportSvg, exportPng }; + return { exportSvg, exportPng }; } From cfcb016b5b2df0468301971056b1324d4b11951b Mon Sep 17 00:00:00 2001 From: ktjn Date: Mon, 27 Jul 2026 15:04:06 +0200 Subject: [PATCH 6/7] style: polish responsive graph composition --- web/src/style.css | 80 +++++++++++++++++++- web/src/visualization/GraphPanel.tsx | 12 ++- web/tests/playground.spec.ts | 107 +++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 7 deletions(-) diff --git a/web/src/style.css b/web/src/style.css index 1f2dda34..6ac31d14 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -213,7 +213,8 @@ button.view-tab--active:hover:not(:disabled) { .workbench { display: flex; flex-direction: column; - width: min(100%, 120rem); + width: 100%; + max-width: none; height: 100dvh; margin: 0 auto; border-inline: 1px solid var(--border); @@ -614,12 +615,14 @@ button.view-tab--active:hover:not(:disabled) { display: flex; flex-direction: column; overflow: hidden; + container-type: inline-size; } .graph-panel__canvas { flex: 1; min-height: 0; position: relative; + isolation: isolate; } .graph-panel__error { @@ -637,12 +640,38 @@ button.view-tab--active:hover:not(:disabled) { font-size: 0.85rem; } -.graph-panel__toolbar-spacer { - flex: 1; +.graph-panel .tab-strip { + align-items: center; + flex-wrap: nowrap; + gap: 0.25rem; + padding: 0.3rem 0.4rem; +} + +.graph-panel__modes, +.graph-panel__exports { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.graph-panel__modes { + flex: 1 1 auto; + min-width: 0; +} + +.graph-panel__exports { + flex: 0 0 auto; + margin-left: auto; +} + +.graph-panel .tab, +.graph-panel__export-btn { + min-height: 1.75rem; + padding: 0.2rem 0.45rem; + white-space: nowrap; } .graph-panel__export-btn { - padding: 0.25rem 0.5rem; font-size: 0.75rem; } @@ -781,11 +810,37 @@ button.view-tab--active:hover:not(:disabled) { background: var(--hover); } +.graph-panel .react-flow__controls { + overflow: hidden; + border: 1px solid var(--graph-border); + border-radius: 0.45rem; + box-shadow: 0 0.25rem 0.75rem + color-mix(in srgb, var(--text) 12%, transparent); +} + +.graph-panel .react-flow__controls-button { + width: 1.75rem; + height: 1.75rem; + border-right: 1px solid var(--border); + border-bottom: 0; +} + +.graph-panel .react-flow__controls-button:last-child { + border-right: 0; +} + .graph-panel .react-flow__minimap { border: 1px solid var(--graph-border); background: var(--graph-surface-muted); } +.graph-panel__minimap { + overflow: hidden; + border-radius: 0.5rem; + box-shadow: 0 0.25rem 0.75rem + color-mix(in srgb, var(--text) 12%, transparent); +} + .graph-panel .react-flow__minimap-node { fill: var(--graph-minimap-node); } @@ -794,6 +849,23 @@ button.view-tab--active:hover:not(:disabled) { fill: var(--graph-minimap-mask); } +@container (max-width: 34rem) { + .graph-panel .tab-strip, + .graph-panel__modes, + .graph-panel__exports { + gap: 0.125rem; + } + + .graph-panel .tab, + .graph-panel__export-btn { + padding-inline: 0.325rem; + } + + .graph-panel__export-prefix { + display: none; + } +} + /* Tab strip (shared by graph & bottom panel) */ .tab-strip { diff --git a/web/src/visualization/GraphPanel.tsx b/web/src/visualization/GraphPanel.tsx index 98d91539..b286ee0d 100644 --- a/web/src/visualization/GraphPanel.tsx +++ b/web/src/visualization/GraphPanel.tsx @@ -86,15 +86,17 @@ function GraphPanelInner({ className="graph-panel__export-btn" onClick={exportSvg} disabled={nodes.length === 0} + aria-label="Export SVG" > - Export SVG + Export SVG
@@ -130,7 +132,11 @@ function GraphPanelInner({ maxZoom={2} proOptions={{ hideAttribution: true }} > - + {showMiniMap ? (