From 0e2c412e6c1e0dbd7780aaa49ea89a4a927c67c0 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 19:16:52 -0700 Subject: [PATCH 01/24] Local-VLM visual review + large-scale performance sweep (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add local-VLM visual review + large-scale perf sweep to the test pipeline Two new report-only, opt-in suites that exercise realistic user experiences from multiple perspectives and at scale, plus the plumbing to run them against local GPU vision models without leaking hostnames into the repo. Large-scale perf sweep (tests/perf/scale-sweep.spec.ts, `perf-scale` project, `npm run test:perf:scale`): - Seeds real graphs (50→2000+ nodes) through the GraphQL API with grid positions, varied status/type/priority, and a connected edge backbone (canonical Edge nodes), batched. - Loads each at one or more quality tiers and records window.__graphPerf: load ms, settle ms (alpha<=0.02), avg/p95 tick, fps, dropped frames, layout drift, plus graph-scoped query p95. - generate-perf-report.mjs renders a table + inline SVG charts of how each metric scales (budgets drawn for reference). Report-only; only asserts a seeded graph renders. Cleans up each graph (edges→nodes→graph). Local VLM visual review (tests/e2e/visual-vlm.spec.ts, `vlm` project, `npm run test:vlm`): - Protocol-agnostic client (tests/helpers/vlm.ts): auto-detects OpenAI-compatible vs Ollama-native per endpoint, round-robins across all configured GPUs, bounded concurrency, lenient JSON-verdict parsing. - Judges captured states (empty graph, populated desktop+mobile, and any scale-sweep frames) from four personas: visual defects, new-user clarity, accessibility, living-graph aliveness. - generate-vlm-report.mjs renders a screenshot+verdict gallery. Report-only; asserts the model answered, not its subjective verdict. Skips entirely when VLM_ENDPOINTS is unset, so CI (which can't reach local GPUs) stays green. Hostname privacy: real endpoints live ONLY in `.env.test.local` (gitignored), auto-loaded by tests/helpers/testEnv.ts. The committed `.env.test.example` documents variable NAMES with placeholder hosts only. No hostnames/IPs/keys anywhere in the repo or docs. Wiring: dedicated Playwright projects keep these out of the fast smoke/perf gates; no CI job invokes them. Validated end-to-end locally (scale harness against the dev stack; VLM client against a mock OpenAI-compatible server). Docs: docs/testing/local-vlm-and-scale.md + SYSTEMS.md gates table. Co-Authored-By: Claude Opus 4.8 (1M context) * Harden VLM + scale-sweep against real local hardware Iterated after running both suites against the actual GPU boxes (gb10-02 = Qwen2.5-VL-32B on GPU, rtx4090 = Qwen2.5-VL-7B on CPU). VLM client: - Per-endpoint model resolution (VLM_MODEL=auto): boxes serving DIFFERENT models now work under one config; each verdict records which model/endpoint judged it + latency, shown in the report. - max_tokens 700->400 to keep calls fast on CPU servers. Scale sweep — make the metrics trustworthy: - Add interactionFps: real rendered frames/sec (requestAnimationFrame) measured while dragging the graph. Reliable at every size with no app instrumentation; it's the headline scaling signal (e.g. 200n=16.6fps, 500n=10.2fps observed). - Seed a FRESH graph per (size, quality): the v1 -1/settle=NONE gaps were the 2nd quality loading the 1st run's already-settled pinned positions, so the sim never ticked and window.__graphPerf never published. - Sustained node drag keeps the sim hot so __graphPerf (best-effort tick/ drift/settle bonus) publishes when it can; take the worst under-load tick. - visual-vlm: cap the slow 1920px scale-frame ingestion to the single largest; raise timeout to 900s (the all-frames version timed out at 10m). Reports: perf report leads with Interaction FPS; VLM cards show model@endpoint·latency. Docs updated (reliable vs best-effort metrics). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .env.test.example | 42 ++++ docs/SYSTEMS.md | 2 + docs/testing/local-vlm-and-scale.md | 113 ++++++++++ package.json | 4 + playwright.config.ts | 29 ++- tests/e2e/visual-vlm.spec.ts | 118 ++++++++++ tests/generate-perf-report.mjs | 111 ++++++++++ tests/generate-vlm-report.mjs | 82 +++++++ tests/helpers/seedGraph.ts | 151 +++++++++++++ tests/helpers/testEnv.ts | 42 ++++ tests/helpers/vlm.ts | 331 ++++++++++++++++++++++++++++ tests/perf/scale-sweep.spec.ts | 224 +++++++++++++++++++ 12 files changed, 1246 insertions(+), 3 deletions(-) create mode 100644 .env.test.example create mode 100644 docs/testing/local-vlm-and-scale.md create mode 100644 tests/e2e/visual-vlm.spec.ts create mode 100644 tests/generate-perf-report.mjs create mode 100644 tests/generate-vlm-report.mjs create mode 100644 tests/helpers/seedGraph.ts create mode 100644 tests/helpers/testEnv.ts create mode 100644 tests/helpers/vlm.ts create mode 100644 tests/perf/scale-sweep.spec.ts diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 00000000..a8d29355 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,42 @@ +# GraphDone test-pipeline configuration — LOCAL ONLY. +# +# Copy this file to `.env.test.local` (which is gitignored) and fill in your +# own values. NEVER put real hostnames, IPs, or keys in this committed example +# or anywhere else in the repo — the local VLM boxes (GPU workstations) must +# stay out of version control. The test harness auto-loads `.env.test.local`. +# +# cp .env.test.example .env.test.local # then edit .env.test.local + +# --- Local Vision-Language-Model (VLM) endpoints --------------------------- +# Comma-separated base URLs of your local VLM server(s). Requests are +# round-robined across them so visual evaluation is spread over every GPU. +# Leave blank to skip all VLM-driven suites (they no-op cleanly in CI). +# Example shape (use your OWN hosts in .env.test.local, never here): +# VLM_ENDPOINTS=http://:,http://: +VLM_ENDPOINTS= + +# Model id/tag to request (e.g. a llava / qwen2-vl / llama-3.2-vision build). +VLM_MODEL= + +# Optional bearer key for OpenAI-compatible servers that require one. +VLM_API_KEY= + +# Wire protocol: auto (default) | openai | ollama. +# auto — probe each endpoint: /v1/models => OpenAI-compatible, else Ollama. +# openai — POST /v1/chat/completions (vLLM, LM Studio, llama.cpp, Ollama compat) +# ollama — POST /api/chat with an images[] array +VLM_PROTOCOL=auto + +# Max concurrent VLM requests across all endpoints (default 3). +VLM_MAX_CONCURRENCY=3 + +# Per-request timeout in ms — VLMs can be slow on large images (default 120000). +VLM_TIMEOUT_MS=120000 + +# --- Large-scale performance sweep ----------------------------------------- +# Node counts to sweep, comma-separated. Leave blank to use the built-in +# default (small in CI, large locally). Example: 50,200,500,1000,2000 +SCALE_SWEEP_SIZES= + +# Quality tiers to sweep per size (subset of LOW,MEDIUM,HIGH,ULTRA). +SCALE_SWEEP_QUALITIES=HIGH,ULTRA diff --git a/docs/SYSTEMS.md b/docs/SYSTEMS.md index 42cdc71e..129e5774 100644 --- a/docs/SYSTEMS.md +++ b/docs/SYSTEMS.md @@ -19,6 +19,8 @@ | Lint | `npm run lint` | 0 errors (warnings allowed) | | Build | `npm run build` | Production build succeeds | | Showcase report | `TEST_URL=http://localhost:3127 npm run report:showcase` | Records .webm video + screenshots of every mode at all 5 resolutions → `test-artifacts/showcase/index.html` (also an every-PR CI artifact). | +| Large-scale perf sweep | `TEST_URL=http://localhost:3127 npm run test:perf:scale` | Seeds graphs of increasing size (50→2000+ nodes) and records `window.__graphPerf` (settle, tick, fps, drift, query p95) across size × quality → `test-artifacts/scale-sweep/index.html`. Report-only; sizes/qualities via `.env.test.local`. See [docs/testing/local-vlm-and-scale.md](./testing/local-vlm-and-scale.md). | +| Local VLM visual review | `TEST_URL=http://localhost:3127 npm run test:vlm` | A locally-hosted vision model judges captured states from 4 perspectives (visual defects, new-user clarity, accessibility, living-graph aliveness) → `test-artifacts/vlm/index.html`. **Skips unless `VLM_ENDPOINTS` is set in the gitignored `.env.test.local`** (CI can't reach local GPUs). Report-only. | **Why THE GATE exists:** a real incident — orphaned `Edge` records made the edges query 500 and the UI showed "Error" with zero edges, while every unit diff --git a/docs/testing/local-vlm-and-scale.md b/docs/testing/local-vlm-and-scale.md new file mode 100644 index 00000000..320a5c04 --- /dev/null +++ b/docs/testing/local-vlm-and-scale.md @@ -0,0 +1,113 @@ +# Local VLM visual review & large-scale performance sweeps + +Two heavier, report-only suites that exercise GraphDone from realistic user +perspectives and at scale. Both are **opt-in and run locally** (or on a +self-hosted runner) because the vision models live on your own GPU boxes — +their hostnames must never enter the repo. + +## TL;DR + +```bash +cp .env.test.example .env.test.local # gitignored — put your real values here +# edit .env.test.local: VLM_ENDPOINTS, VLM_MODEL, (optional) sweep sizes + +./start dev # or have the stack running on :3127 + +TEST_URL=http://localhost:3127 npm run test:perf:scale # → test-artifacts/scale-sweep/index.html +TEST_URL=http://localhost:3127 npm run test:vlm # → test-artifacts/vlm/index.html +``` + +If `VLM_ENDPOINTS` is unset, `test:vlm` **skips cleanly** — so CI and other +developers are never blocked by hardware they don't have. + +## Keeping hostnames out of the repo + +- **Never** commit hostnames, IPs, or keys. The GPU boxes (e.g. an RTX 4090 + workstation and Grace-Blackwell nodes) are referenced only by env vars. +- `.env.test.local` is gitignored (see `.gitignore`). It is the *only* place + your real endpoints live. +- `.env.test.example` is committed and documents the variable **names** with + placeholder hosts (`http://:`). Copy it to `.env.test.local` + and fill in the rest. +- The harness auto-loads `.env.test.local` via `tests/helpers/testEnv.ts`. + +```bash +# .env.test.local (NOT committed) +VLM_ENDPOINTS=http://:,http://:,http://: +VLM_MODEL= +VLM_PROTOCOL=auto # auto | openai | ollama +VLM_MAX_CONCURRENCY=3 +``` + +Multiple endpoints are **round-robined**, so visual evaluation spreads across +every GPU you list. + +## VLM protocol support + +`tests/helpers/vlm.ts` is protocol-agnostic and auto-detects per endpoint: + +| Protocol | Detected via | Request | +|----------|--------------|---------| +| OpenAI-compatible | `GET /v1/models` | `POST /v1/chat/completions` with an `image_url` data URI (vLLM, LM Studio, llama.cpp server, Ollama's `/v1` shim) | +| Ollama native | `GET /api/tags` | `POST /api/chat` with a base64 `images[]` array | + +Force one with `VLM_PROTOCOL=openai` or `ollama`. Each model call asks for a +strict JSON verdict `{pass, score, issues[], summary}`, parsed leniently. + +### Personas + +Each captured screenshot is judged from several perspectives (see `PERSONAS` +in `tests/helpers/vlm.ts`): + +- **Visual defects** — overlapping/cut-off nodes, unreadable labels, broken + layout, missing edges, error chrome. +- **New-user clarity** — is the screen legible and inviting to a newcomer? +- **Accessibility** — contrast, text size, color-only signals, target size. +- **Living-graph aliveness** — do glow/breathe/flow status cues read clearly? + +Report-only: a **FLAG** is the model's subjective concern, surfaced for a human +to look at — it never fails the build. The suite *does* assert the model +answered, so a broken client is still caught. + +## Large-scale perf sweep + +`tests/perf/scale-sweep.spec.ts` seeds real graphs (via the GraphQL API, the +same path a human/AI uses) of increasing size, loads each at one or more +quality tiers, and records the in-app `window.__graphPerf` readings plus load +time, settle time and query latency. + +```bash +# .env.test.local +SCALE_SWEEP_SIZES=50,200,500,1000,2000 # blank => small in CI, large locally +SCALE_SWEEP_QUALITIES=HIGH,ULTRA +``` + +Metrics per (size, quality): + +- **Reliable (measured directly from the browser, captured at every size):** + rendered node/edge counts, initial load ms, graph-scoped query p95, and + **interaction FPS** — real rendered frames/sec while a node is dragged + (counted via `requestAnimationFrame`, so it needs no app instrumentation and + reflects how janky the graph feels under interaction at scale). +- **Best-effort bonus (from the app's `window.__graphPerf`, which only + publishes ~every 2s while the sim ticks):** settle ms (to `alpha ≤ 0.02`), + avg/p95 sim tick ms, layout drift (`rmsFromSavedPx`). These can be blank for + graphs that settle instantly — `interactionFps` is the headline signal. + +A FRESH graph is seeded per (size, quality) so each measurement starts from an +unsettled layout (otherwise the second quality loads the first run's settled, +pinned positions and the sim never ticks). Output: +`test-artifacts/scale-sweep/index.html` — a table plus inline SVG charts of how +each metric scales, with the `@perf` budgets drawn for reference. + +Report-only; the only hard assertion is that a seeded graph actually renders. +Each seeded graph is deleted afterward (edges first, then nodes, then graph). + +## CI + +GitHub-hosted runners can't reach your local GPUs, so neither suite gates +merges there. To gate on them, register a **self-hosted runner** on a machine +that can reach the endpoints, give it the `.env.test.local`, and add a workflow +job (manual-dispatch or nightly) that runs `npm run test:perf:scale` / +`npm run test:vlm`. The scale sweep alone (no VLM) is safe to run on any runner +with the dev stack and a small `SCALE_SWEEP_SIZES`. diff --git a/package.json b/package.json index 3c442d84..b3182854 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,10 @@ "test:smoke": "playwright test tests/e2e/user-smoke.spec.ts --reporter=line", "report:showcase": "playwright test --project=showcase && node tests/generate-showcase-report.mjs", "test:perf": "playwright test --project=perf --reporter=line", + "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", + "report:perf": "node tests/generate-perf-report.mjs", + "test:vlm": "playwright test --project=vlm --reporter=line && node tests/generate-vlm-report.mjs", + "report:vlm": "node tests/generate-vlm-report.mjs", "perf:bundle": "node tests/perf/check-bundle-size.mjs" }, "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 3a9f10e5..d0d1716b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,9 +38,10 @@ export default defineConfig({ projects: [ { name: 'GraphDone-Core/dev-neo4j/chromium', - // The showcase tour runs in its own capture-heavy project below; keep it - // out of the default (fast) project so the smoke gate stays quick. - testIgnore: /showcase\.spec\.ts/, + // The showcase tour and the local-VLM visual eval run in their own + // capture-heavy projects below; keep them out of the default (fast) + // project so the smoke gate stays quick. + testIgnore: [/showcase\.spec\.ts/, /visual-vlm\.spec\.ts/], use: { ...devices['Desktop Chrome'] }, }, @@ -65,9 +66,31 @@ export default defineConfig({ { name: 'perf', testDir: './tests/perf', + // The large-scale sweep is heavy and report-only; it has its own project + // so `test:perf` (the budget gate) stays fast. + testIgnore: /scale-sweep\.spec\.ts/, use: { ...devices['Desktop Chrome'] }, }, + /* Large-scale graph creation + performance metric sweep. Seeds graphs of + * increasing size and records window.__graphPerf across them. Heavy + + * report-only; run via `npm run test:perf:scale`. */ + { + name: 'perf-scale', + testDir: './tests/perf', + testMatch: /scale-sweep\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + + /* Local-VLM visual evaluation across personas. Skips unless VLM_ENDPOINTS + * is set in .env.test.local. Run via `npm run test:vlm`. */ + { + name: 'vlm', + testDir: './tests/e2e', + testMatch: /visual-vlm\.spec\.ts/, + use: { ...devices['Desktop Chrome'], screenshot: 'on' }, + }, + // Commented out until browsers installed with system dependencies // { // name: 'GraphDone-Core/dev-neo4j/firefox', diff --git a/tests/e2e/visual-vlm.spec.ts b/tests/e2e/visual-vlm.spec.ts new file mode 100644 index 00000000..654e4ea0 --- /dev/null +++ b/tests/e2e/visual-vlm.spec.ts @@ -0,0 +1,118 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { seedLargeGraph, deleteGraphDeep } from '../helpers/seedGraph'; +import '../helpers/testEnv'; +import { isVlmAvailable, evaluateBatch, PERSONAS, personaByKey } from '../helpers/vlm'; + +/** + * Local-VLM visual evaluation. Captures key user-facing states, then asks a + * locally-hosted vision model to judge each one from four perspectives + * (visual defects, new-user clarity, accessibility, living-graph aliveness). + * + * Report-only: it never fails on a model's subjective verdict — it writes + * test-artifacts/vlm/results.json for `npm run report:vlm` and prints a + * summary. It only asserts the VLM actually answered (so a broken client is + * still caught). Skips entirely when no VLM endpoint is configured/reachable + * (VLM_ENDPOINTS in .env.test.local), so CI stays green. + */ + +const SHOT_DIR = path.resolve(process.cwd(), 'test-artifacts/vlm/shots'); +const OUT = path.resolve(process.cwd(), 'test-artifacts/vlm/results.json'); +const SCALE_DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); + +interface Capture { file: string; context: string; personas: string[] } + +async function shot(page: Page, name: string): Promise { + fs.mkdirSync(SHOT_DIR, { recursive: true }); + const file = path.join(SHOT_DIR, `${name}.png`); + await page.screenshot({ path: file, fullPage: false }).catch(() => {}); + return file; +} + +test('VLM visual evaluation across personas @vlm', async ({ page }) => { + test.setTimeout(900_000); + const available = await isVlmAvailable(); + test.skip(!available, 'No reachable VLM endpoint (set VLM_ENDPOINTS in .env.test.local)'); + + const allPersonas = PERSONAS.map((p) => p.key); + const captures: Capture[] = []; + const cleanup: string[] = []; + + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // 1. Empty graph — first-run invitation (new-user + visual defects). + const empty = await page.evaluate(async () => { + const token = localStorage.getItem('authToken') ?? ''; + const post = (query: string, variables?: unknown) => + fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }).then((r) => r.json()); + const me = await post('{ me { id } }'); + const g = await post(`mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, { i: [{ name: `VLM Empty ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me.data.me.id, isShared: true }] }); + return g.data.createGraphs.graphs[0].id as string; + }); + cleanup.push(empty); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.evaluate((id) => localStorage.setItem('currentGraphId', id), empty); + await page.reload(); + await page.waitForTimeout(5000); + captures.push({ file: await shot(page, 'empty-graph-desktop'), context: 'the first-run empty-state of a brand-new project graph in GraphDone, a graph-based task manager', personas: ['visual-defects', 'new-user', 'accessibility'] }); + + // 2. Populated graph at ULTRA quality — full living-graph experience. + const seeded = await seedLargeGraph(page, { size: 60, namePrefix: 'VLM' }); + cleanup.push(seeded.graphId); + await page.evaluate((id) => { localStorage.setItem('currentGraphId', id); localStorage.setItem('graphdone.quality.override', 'ULTRA'); }, seeded.graphId); + await page.reload(); + await page.waitForTimeout(8000); // let it settle + effects run + captures.push({ file: await shot(page, 'populated-desktop'), context: 'a populated project graph (~60 work items) with dependency edges; nodes glow by priority and animate by status (in-progress breathes, blocked aches, complete settles)', personas: allPersonas }); + + // 3. Same graph on a phone viewport — accessibility + new-user on mobile. + await page.setViewportSize({ width: 393, height: 852 }); + await page.reload(); + await page.waitForTimeout(6000); + captures.push({ file: await shot(page, 'populated-mobile'), context: 'the same project graph viewed on a phone-sized screen (393x852)', personas: ['visual-defects', 'new-user', 'accessibility'] }); + + // 4. Bonus: judge the SINGLE largest scale-sweep frame for density/legibility + // (those frames are 1920px and slow on the model; one is enough signal). + if (fs.existsSync(SCALE_DIR)) { + const largest = fs.readdirSync(SCALE_DIR) + .filter((f) => f.endsWith('.png')) + .map((f) => ({ f, size: parseInt(f, 10) || 0 })) + .sort((a, b) => b.size - a.size)[0]; + if (largest) { + captures.push({ file: path.join(SCALE_DIR, largest.f), context: `a large graph rendered at scale (${largest.size} nodes) — judge whether it stays legible at this density`, personas: ['visual-defects'] }); + } + } + + // Build and run the persona jobs. + const jobs = captures.flatMap((c) => + c.personas + .map((pk) => personaByKey(pk)) + .filter((p): p is NonNullable => Boolean(p)) + .map((persona) => ({ imagePath: c.file, persona, context: c.context, meta: { capture: path.basename(c.file) } })) + ); + + let results: Awaited> = []; + try { + results = await evaluateBatch(jobs); + } finally { + for (const id of cleanup) await deleteGraphDeep(page, id); + } + + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)); + + const fails = results.filter((r) => !r.verdict.pass); + // eslint-disable-next-line no-console + console.log(`[vlm] ${results.length} evaluations, ${results.length - fails.length} pass, ${fails.length} flagged:`); + for (const f of fails) { + // eslint-disable-next-line no-console + console.log(` ⚠️ [${f.persona}] ${f.meta?.capture}: ${f.verdict.summary || f.verdict.issues.join('; ')}`); + } + + // Report-only: we assert the VLM produced answers, not what it concluded. + expect(results.length, 'VLM returned evaluations').toBeGreaterThan(0); + const answered = results.filter((r) => !r.verdict.issues.some((i) => i.startsWith('VLM request failed') || i.startsWith('No reachable'))); + expect(answered.length, 'at least some VLM calls succeeded').toBeGreaterThan(0); +}); diff --git a/tests/generate-perf-report.mjs b/tests/generate-perf-report.mjs new file mode 100644 index 00000000..8cd78d82 --- /dev/null +++ b/tests/generate-perf-report.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Renders the large-scale perf sweep into a single self-contained page: + * test-artifacts/scale-sweep/index.html + * + * Input: test-artifacts/scale-sweep/n-.json (from scale-sweep.spec.ts) + * Output: an HTML table of every metric plus inline SVG line charts (no deps, + * no external assets) showing how settle time, tick cost, FPS, drift and query + * latency scale with graph size, per quality tier. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); +const OUT = path.join(DIR, 'index.html'); + +if (!fs.existsSync(DIR)) { + console.error(`No sweep results at ${DIR} — run "npm run test:perf:scale" first.`); + process.exit(1); +} + +const rows = fs + .readdirSync(DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8'))) + .sort((a, b) => a.size - b.size || String(a.quality).localeCompare(b.quality)); + +if (rows.length === 0) { + console.error('No JSON sweep results found.'); + process.exit(1); +} + +const qualities = [...new Set(rows.map((r) => r.quality))]; +const sizes = [...new Set(rows.map((r) => r.size))].sort((a, b) => a - b); +const COLORS = ['#34d399', '#60a5fa', '#f472b6', '#fbbf24', '#a78bfa']; + +const num = (v) => (typeof v === 'number' && v >= 0 ? v : null); + +function lineChart(title, key, { unit = '', budget = null } = {}) { + const W = 560, H = 260, PADL = 56, PADB = 36, PADT = 28, PADR = 16; + const series = qualities.map((q) => ({ + q, + pts: sizes.map((s) => { + const row = rows.find((r) => r.size === s && r.quality === q); + return { x: s, y: row ? num(row[key]) : null }; + }).filter((p) => p.y !== null), + })).filter((s) => s.pts.length); + const allY = series.flatMap((s) => s.pts.map((p) => p.y)).concat(budget != null ? [budget] : []); + if (allY.length === 0) return ''; + const maxY = Math.max(...allY) * 1.1 || 1; + const maxX = Math.max(...sizes); + const minX = Math.min(...sizes); + const sx = (x) => PADL + ((x - minX) / (maxX - minX || 1)) * (W - PADL - PADR); + const sy = (y) => H - PADB - (y / maxY) * (H - PADT - PADB); + + const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const y = sy(maxY * f); + return `${Math.round(maxY * f)}`; + }).join(''); + const xticks = sizes.map((s) => `${s}`).join(''); + const budgetLine = budget != null ? `budget ${budget}${unit}` : ''; + const lines = series.map((s, i) => { + const c = COLORS[qualities.indexOf(s.q) % COLORS.length]; + const d = s.pts.map((p, j) => `${j === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); + const dots = s.pts.map((p) => `${s.q} @ ${p.x}n: ${p.y}${unit}`).join(''); + return `${dots}`; + }).join(''); + const legend = series.map((s, i) => { + const c = COLORS[qualities.indexOf(s.q) % COLORS.length]; + return `● ${s.q}`; + }).join('  '); + return `

${title}

${legend}
${grid}${xticks}${budgetLine}${lines}graph size (nodes)
`; +} + +const HEADERS = [ + ['size', 'nodes'], ['quality', 'quality'], ['renderedNodes', 'rendered n'], ['renderedEdges', 'rendered e'], + ['loadMs', 'load ms'], ['interactionFps', 'drag fps'], ['settleMs', 'settle ms'], ['finalAlpha', 'alpha'], + ['avgTickMs', 'tick ms'], ['p95TickMs', 'tick p95'], ['rmsFromSavedPx', 'drift px'], + ['queryP95Ms', 'query p95'], +]; +const tableRows = rows.map((r) => `${HEADERS.map(([k]) => `${r[k] === null ? '—' : r[k]}`).join('')}`).join(''); + +const html = `GraphDone — Large-Scale Perf Sweep + +

GraphDone — Large-Scale Graph Performance Sweep

+

${rows.length} runs · sizes ${sizes.join(', ')} · qualities ${qualities.join(', ')} · generated ${new Date().toISOString()}

+
+${lineChart('Interaction FPS vs size (drag)', 'interactionFps', { unit: '' })} +${lineChart('Initial load vs size', 'loadMs', { unit: 'ms' })} +${lineChart('Avg simulation tick vs size', 'avgTickMs', { unit: 'ms', budget: 8 })} +${lineChart('Settle time vs size', 'settleMs', { unit: 'ms' })} +${lineChart('Layout drift vs size', 'rmsFromSavedPx', { unit: 'px', budget: 25 })} +${lineChart('Query p95 latency vs size', 'queryP95Ms', { unit: 'ms', budget: 800 })} +
+

All metrics

+${HEADERS.map(([, h]) => ``).join('')}${tableRows}
${h}
+

Report-only. Budgets shown (red dashed) mirror the @perf gate; this sweep characterises how they scale, it does not enforce them.

+`; + +fs.writeFileSync(OUT, html); +console.log(`✅ Perf sweep report: ${OUT} (${rows.length} runs)`); diff --git a/tests/generate-vlm-report.mjs b/tests/generate-vlm-report.mjs new file mode 100644 index 00000000..819a63a0 --- /dev/null +++ b/tests/generate-vlm-report.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Renders local-VLM visual evaluations into one self-contained gallery: + * test-artifacts/vlm/index.html + * + * Input: test-artifacts/vlm/results.json (from visual-vlm.spec.ts) + * Output: each captured screenshot with a card per persona verdict + * (pass/flag badge, 0-1 score, summary, issues). No deps, no external assets. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const VLM_DIR = path.resolve(process.cwd(), 'test-artifacts/vlm'); +const RESULTS = path.join(VLM_DIR, 'results.json'); +const OUT = path.join(VLM_DIR, 'index.html'); + +if (!fs.existsSync(RESULTS)) { + console.error(`No VLM results at ${RESULTS} — run "npm run test:vlm" (with VLM_ENDPOINTS set) first.`); + process.exit(1); +} + +const { generatedAt, results } = JSON.parse(fs.readFileSync(RESULTS, 'utf8')); +const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +// Group verdicts by the screenshot they judged. +const byCapture = new Map(); +for (const r of results) { + const key = r.imagePath; + if (!byCapture.has(key)) byCapture.set(key, { imagePath: r.imagePath, context: r.context, verdicts: [] }); + byCapture.get(key).verdicts.push(r); +} + +const total = results.length; +const passed = results.filter((r) => r.verdict.pass).length; +const avgScore = total ? (results.reduce((a, r) => a + (r.verdict.score || 0), 0) / total).toFixed(2) : '—'; + +const sections = [...byCapture.values()].map((cap) => { + const rel = path.relative(VLM_DIR, cap.imagePath).split(path.sep).join('/'); + const cards = cap.verdicts.map((r) => { + const v = r.verdict; + const cls = v.pass ? 'pass' : 'flag'; + const issues = v.issues?.length ? `
    ${v.issues.map((i) => `
  • ${esc(i)}
  • `).join('')}
` : ''; + const model = (v.model || '').replace(/\.gguf$/, '').slice(0, 28); + const host = (v.endpoint || '').replace(/^https?:\/\//, ''); + const foot = (v.endpoint || v.latencyMs) ? `
${esc(model)} @ ${esc(host)}${v.latencyMs ? ` · ${(v.latencyMs / 1000).toFixed(1)}s` : ''}
` : ''; + return `
+
${v.pass ? 'PASS' : 'FLAG'} + ${esc(r.persona)}score ${Number(v.score ?? 0).toFixed(2)}
+

${esc(v.summary)}

${issues}${foot}
`; + }).join(''); + return `
+
${esc(path.basename(cap.imagePath))}
${esc(path.basename(cap.imagePath))}

${esc(cap.context)}

+
${cards}
+
`; +}).join(''); + +const html = `GraphDone — Local VLM Visual Review + +

GraphDone — Local VLM Visual Review

+
${passed}/${total} persona checks passed · avg score ${avgScore}
+generated ${esc(generatedAt)} · evaluated by a local vision model
+${sections} +

Report-only. "FLAG" is the model's subjective concern from one perspective, not a hard failure — use it to spot real UX/rendering regressions worth a human look.

+`; + +fs.writeFileSync(OUT, html); +console.log(`✅ VLM review report: ${OUT} (${total} evaluations, ${passed} pass)`); diff --git a/tests/helpers/seedGraph.ts b/tests/helpers/seedGraph.ts new file mode 100644 index 00000000..2635822e --- /dev/null +++ b/tests/helpers/seedGraph.ts @@ -0,0 +1,151 @@ +import { Page } from '@playwright/test'; + +/** + * Seeds realistically-shaped graphs of arbitrary size through the real GraphQL + * API (the same path a human or AI uses), so the perf sweep measures the true + * stack — Neo4j + Apollo + the web force simulation — not a synthetic shortcut. + * + * Nodes are spread on a grid (real positions, not all stacked at the origin), + * statuses/types/priorities are varied so living-graph effects and priority + * glow actually exercise, and edges form a connected backbone plus extra links + * to hit a target edge:node ratio. Edges are created as Edge nodes (the + * canonical model the web renders). Everything batches to stay within request + * limits, and cleanup deletes edges before nodes (orphan edges break the whole + * edges query). + */ + +export interface SeededGraph { + graphId: string; + nodeIds: string[]; + edgeCount: number; +} + +async function gql(page: Page, query: string, variables?: unknown): Promise { + return page.evaluate( + async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + const body = await res.json(); + if (body.errors) throw new Error(body.errors[0]?.message ?? 'GraphQL error'); + return body.data; + }, + { query, variables } + ); +} + +const STATUSES = ['PROPOSED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED'] as const; +const TYPES = ['TASK', 'BUG', 'FEATURE', 'MILESTONE', 'OUTCOME'] as const; +const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'RELATES_TO'] as const; + +function chunk(arr: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); + return out; +} + +export interface SeedOptions { + size: number; + /** edges ≈ edgeFactor * size (default 1.4). */ + edgeFactor?: number; + /** grid spacing in px (default 130). */ + spacing?: number; + namePrefix?: string; +} + +export async function seedLargeGraph(page: Page, opts: SeedOptions): Promise { + const { size, edgeFactor = 1.4, spacing = 130, namePrefix = 'Scale' } = opts; + const me = await gql(page, '{ me { id } }'); + const userId = me.me.id; + + const g = await gql( + page, + `mutation($input: [GraphCreateInput!]!) { createGraphs(input: $input) { graphs { id } } }`, + { input: [{ name: `${namePrefix} ${size}n ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] } + ); + const graphId = g.createGraphs.graphs[0].id as string; + + // Grid layout centered on the origin so the sim starts from a real arrangement. + const cols = Math.ceil(Math.sqrt(size)); + const half = (cols * spacing) / 2; + const nodeInputs = Array.from({ length: size }, (_, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + // Deterministic pseudo-variety without Math.random (kept reproducible). + const status = STATUSES[i % STATUSES.length]; + const type = TYPES[(i * 7) % TYPES.length]; + const priority = ((i * 37) % 100) / 100; + return { + type, + title: `${type} ${i}`, + status, + priority, + positionX: col * spacing - half, + positionY: row * spacing - half, + positionZ: 0, + owner: { connect: { where: { node: { id: userId } } } }, + graph: { connect: { where: { node: { id: graphId } } } }, + }; + }); + + const nodeIds: string[] = []; + for (const batch of chunk(nodeInputs, 100)) { + const res = await gql( + page, + `mutation($input: [WorkItemCreateInput!]!) { createWorkItems(input: $input) { workItems { id } } }`, + { input: batch } + ); + for (const w of res.createWorkItems.workItems) nodeIds.push(w.id); + } + + // Backbone chain guarantees connectivity; extra forward links add realism. + const targetEdges = Math.round(size * edgeFactor); + const edgeInputs: Array> = []; + const link = (a: string, b: string, t: string) => + edgeInputs.push({ + type: t, + weight: 0.5 + ((edgeInputs.length % 5) / 10), + source: { connect: { where: { node: { id: a } } } }, + target: { connect: { where: { node: { id: b } } } }, + }); + for (let i = 0; i + 1 < nodeIds.length; i++) link(nodeIds[i], nodeIds[i + 1], 'DEPENDS_ON'); + let extra = targetEdges - edgeInputs.length; + for (let i = 0; i < nodeIds.length && extra > 0; i++) { + const jump = 2 + ((i * 5) % Math.max(2, Math.floor(nodeIds.length / 4))); + const j = i + jump; + if (j < nodeIds.length) { + link(nodeIds[i], nodeIds[j], EDGE_TYPES[i % EDGE_TYPES.length]); + extra--; + } + } + + let edgeCount = 0; + for (const batch of chunk(edgeInputs, 100)) { + const res = await gql( + page, + `mutation($input: [EdgeCreateInput!]!) { createEdges(input: $input) { edges { id } } }`, + { input: batch } + ); + edgeCount += res.createEdges.edges.length; + } + + return { graphId, nodeIds, edgeCount }; +} + +export async function deleteGraphDeep(page: Page, graphId: string): Promise { + // Edges first (orphan edges break the edges query), then nodes, then graph. + await gql( + page, + `mutation($id: ID!) { deleteEdges(where: { source: { graph: { id: $id } } }) { nodesDeleted } }`, + { id: graphId } + ).catch(() => {}); + await gql( + page, + `mutation($id: ID!) { deleteWorkItems(where: { graph: { id: $id } }) { nodesDeleted } }`, + { id: graphId } + ).catch(() => {}); + await gql(page, `mutation($id: ID!) { deleteGraphs(where: { id: $id }) { nodesDeleted } }`, { id: graphId }).catch(() => {}); +} diff --git a/tests/helpers/testEnv.ts b/tests/helpers/testEnv.ts new file mode 100644 index 00000000..f29c8c80 --- /dev/null +++ b/tests/helpers/testEnv.ts @@ -0,0 +1,42 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import dotenv from 'dotenv'; + +/** + * Loads local-only test configuration from `.env.test.local` (gitignored) into + * process.env, without ever baking secrets or hostnames into the repo. Import + * this for its side effect at the top of any spec/generator that needs the VLM + * endpoints or sweep config: + * + * import '../helpers/testEnv'; + * + * Safe to import everywhere — it's a no-op when the file is absent (e.g. CI), + * so VLM-driven suites skip cleanly. Existing process.env values win, so you + * can still override per-run on the command line. + */ +const localEnvPath = path.resolve(process.cwd(), '.env.test.local'); +if (fs.existsSync(localEnvPath)) { + dotenv.config({ path: localEnvPath }); +} + +/** Comma/whitespace separated env list -> trimmed non-empty string[]. */ +export function envList(name: string): string[] { + return (process.env[name] ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** Parse a comma-separated list of positive integers (sweep sizes). */ +export function envIntList(name: string): number[] { + return envList(name) + .map((s) => Number.parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); +} + +export function envNumber(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const n = Number(raw); + return Number.isFinite(n) ? n : fallback; +} diff --git a/tests/helpers/vlm.ts b/tests/helpers/vlm.ts new file mode 100644 index 00000000..aeb4db24 --- /dev/null +++ b/tests/helpers/vlm.ts @@ -0,0 +1,331 @@ +import * as fs from 'fs'; +import './testEnv'; +import { envList, envNumber } from './testEnv'; + +/** + * Protocol-agnostic client for LOCAL Vision-Language-Model servers. + * + * The actual endpoints (GPU workstations) live only in `.env.test.local` + * (gitignored) as VLM_ENDPOINTS — never in the repo. Requests are round-robined + * across every configured endpoint so visual evaluation spreads over all GPUs. + * + * Two wire protocols are supported and auto-detected per endpoint: + * - OpenAI-compatible: POST /v1/chat/completions with image_url data URIs + * (vLLM, LM Studio, llama.cpp server, Ollama's /v1 compat shim) + * - Ollama native: POST /api/chat with a base64 images[] array + * + * Everything degrades gracefully: when VLM_ENDPOINTS is unset or no endpoint is + * reachable, isVlmAvailable() is false and suites skip — so CI stays green. + */ + +export type VlmProtocol = 'openai' | 'ollama'; + +export interface VlmVerdict { + pass: boolean; + score: number; // 0..1 + issues: string[]; + summary: string; + raw?: string; // raw model text, for the report when parsing is imperfect + endpoint?: string; // which box judged this (honesty in the report) + model?: string; + latencyMs?: number; +} + +export interface Persona { + key: string; + label: string; + /** Framing for the model — who it is and what it cares about. */ + system: string; + /** What "pass" means, appended to every prompt for this persona. */ + rubric: string; +} + +/** + * The evaluation perspectives. Each judges a rendered screenshot from a + * distinct point of view, so one capture yields several independent reads. + */ +export const PERSONAS: Persona[] = [ + { + key: 'visual-defects', + label: 'Visual defects', + system: + 'You are a meticulous UI rendering QA inspector for a graph-visualization web app. ' + + 'You judge ONLY what is visible in the screenshot — objective rendering correctness.', + rubric: + 'Fail if you see: nodes overlapping so labels are unreadable, nodes/text cut off at the edges, ' + + 'a broken or empty layout where content is expected, edges that clearly do not connect nodes, ' + + 'obvious visual glitches, or any error message / "Error" badge / blank red state. ' + + 'Pass if the graph (or its empty-state invitation) renders cleanly and legibly.', + }, + { + key: 'new-user', + label: 'New-user clarity', + system: + 'You are a first-time user who has never seen this product. You are evaluating whether the ' + + 'screen is clear, inviting, and self-explanatory.', + rubric: + 'Fail if you would feel lost or could not tell what to do next, or the screen looks intimidating ' + + 'or cluttered to a newcomer. Pass if the purpose is clear and there is an obvious next action.', + }, + { + key: 'accessibility', + label: 'Accessibility', + system: + 'You are an accessibility reviewer judging a rendered screenshot for visual a11y.', + rubric: + 'Fail if text contrast looks too low to read, text is too small, information is conveyed by color ' + + 'alone, or interactive targets look too small to tap. Pass if it appears broadly legible and usable.', + }, + { + key: 'living-graph', + label: 'Living-graph aliveness', + system: + 'You evaluate whether a graph visualization feels "alive" and communicates work status. Nodes may ' + + 'glow by priority, pulse/breathe when in progress, look settled when complete, or ache when blocked; ' + + 'edges may show energy flow.', + rubric: + 'Fail if the graph looks completely static/flat with no visual hierarchy or status cues, or if the ' + + 'effects look chaotic/noisy rather than purposeful. Pass if status and priority read clearly and the ' + + 'scene feels alive but legible. (Judge the single frame; do not penalize lack of motion in a still.)', + }, +]; + +export const personaByKey = (key: string): Persona | undefined => + PERSONAS.find((p) => p.key === key); + +const TIMEOUT_MS = envNumber('VLM_TIMEOUT_MS', 120_000); +const MAX_CONCURRENCY = Math.max(1, envNumber('VLM_MAX_CONCURRENCY', 3)); + +let rrCounter = 0; +const protocolCache = new Map(); +const modelCache = new Map(); + +export function vlmEndpoints(): string[] { + return envList('VLM_ENDPOINTS').map((e) => e.replace(/\/+$/, '')); +} + +export function vlmModel(): string { + return (process.env.VLM_MODEL ?? '').trim(); +} + +/** + * Resolve the model id for an endpoint. With a single shared model set + * VLM_MODEL; with multiple boxes serving DIFFERENT models, set VLM_MODEL=auto + * (or leave blank) and each endpoint's own loaded model is used. llama.cpp + * serves one model and ignores the field, but sending the right id keeps logs + * honest and works with multi-model servers too. + */ +async function resolveModel(base: string, protocol: VlmProtocol): Promise { + const configured = vlmModel(); + if (configured && configured.toLowerCase() !== 'auto') return configured; + if (modelCache.has(base)) return modelCache.get(base)!; + let id = 'default'; + try { + if (protocol === 'openai') { + const r = await fetchWithTimeout(`${base}/v1/models`, { headers: authHeaders() }, 5000); + const d = await r.json(); + id = d?.data?.[0]?.id ?? d?.models?.[0]?.name ?? 'default'; + } else { + const r = await fetchWithTimeout(`${base}/api/tags`, {}, 5000); + const d = await r.json(); + id = d?.models?.[0]?.name ?? d?.models?.[0]?.model ?? 'default'; + } + } catch { /* keep default */ } + modelCache.set(base, id); + return id; +} + +export function isVlmConfigured(): boolean { + return vlmEndpoints().length > 0 && vlmModel().length > 0; +} + +function authHeaders(): Record { + const key = (process.env.VLM_API_KEY ?? '').trim(); + return key ? { Authorization: `Bearer ${key}` } : {}; +} + +async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = TIMEOUT_MS): Promise { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(t); + } +} + +/** Detect (and cache) the wire protocol for a single endpoint. */ +async function detectProtocol(base: string): Promise { + const forced = (process.env.VLM_PROTOCOL ?? 'auto').trim().toLowerCase(); + if (forced === 'openai' || forced === 'ollama') return forced; + if (protocolCache.has(base)) return protocolCache.get(base)!; + // OpenAI-compatible servers expose /v1/models. + try { + const r = await fetchWithTimeout(`${base}/v1/models`, { headers: authHeaders() }, 5000); + if (r.ok) { protocolCache.set(base, 'openai'); return 'openai'; } + } catch { /* try next */ } + // Ollama exposes /api/tags. + try { + const r = await fetchWithTimeout(`${base}/api/tags`, {}, 5000); + if (r.ok) { protocolCache.set(base, 'ollama'); return 'ollama'; } + } catch { /* unreachable */ } + return null; +} + +/** Endpoints that are configured AND currently reachable, with their protocol. */ +export async function reachableEndpoints(): Promise> { + const out: Array<{ base: string; protocol: VlmProtocol }> = []; + await Promise.all( + vlmEndpoints().map(async (base) => { + const protocol = await detectProtocol(base); + if (protocol) out.push({ base, protocol }); + }) + ); + return out; +} + +let availabilityCache: boolean | null = null; +/** True only if VLM is configured and at least one endpoint responds. */ +export async function isVlmAvailable(): Promise { + if (!isVlmConfigured()) return false; + if (availabilityCache !== null) return availabilityCache; + availabilityCache = (await reachableEndpoints()).length > 0; + return availabilityCache; +} + +function extractVerdict(text: string): VlmVerdict { + // Models wrap JSON in prose or code fences; grab the first balanced object. + let parsed: Record | null = null; + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fence ? fence[1] : text; + const start = candidate.indexOf('{'); + const end = candidate.lastIndexOf('}'); + if (start !== -1 && end > start) { + try { parsed = JSON.parse(candidate.slice(start, end + 1)); } catch { /* fall through */ } + } + if (!parsed) { + return { pass: false, score: 0, issues: ['Could not parse a JSON verdict from the model'], summary: text.slice(0, 300), raw: text }; + } + const issuesRaw = parsed.issues; + const issues = Array.isArray(issuesRaw) ? issuesRaw.map((i) => String(i)) : issuesRaw ? [String(issuesRaw)] : []; + let score = Number(parsed.score); + if (!Number.isFinite(score)) score = parsed.pass ? 1 : 0; + if (score > 1) score = score / 100; // tolerate 0-100 scales + return { + pass: Boolean(parsed.pass), + score: Math.max(0, Math.min(1, score)), + issues, + summary: String(parsed.summary ?? '').slice(0, 600), + raw: text, + }; +} + +const PROMPT_TAIL = + 'Respond with ONLY a JSON object, no prose, of exactly this shape: ' + + '{"pass": boolean, "score": number between 0 and 1, "issues": string[], "summary": string}. ' + + 'Keep issues short and specific. Be fair: this is a still frame.'; + +function buildPrompt(persona: Persona, context: string): string { + return `Context: this screenshot shows ${context}.\n\n${persona.rubric}\n\n${PROMPT_TAIL}`; +} + +async function callOpenAI(base: string, model: string, system: string, prompt: string, b64: string): Promise { + const r = await fetchWithTimeout(`${base}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + model, + temperature: 0, + max_tokens: 400, + messages: [ + { role: 'system', content: system }, + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: `data:image/png;base64,${b64}` } }, + ], + }, + ], + }), + }); + if (!r.ok) throw new Error(`OpenAI VLM ${base} HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`); + const data = await r.json(); + return data?.choices?.[0]?.message?.content ?? ''; +} + +async function callOllama(base: string, model: string, system: string, prompt: string, b64: string): Promise { + const r = await fetchWithTimeout(`${base}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + stream: false, + options: { temperature: 0 }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: prompt, images: [b64] }, + ], + }), + }); + if (!r.ok) throw new Error(`Ollama VLM ${base} HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`); + const data = await r.json(); + return data?.message?.content ?? ''; +} + +/** + * Evaluate one screenshot from one persona's perspective. Round-robins across + * reachable endpoints. Never throws — failures come back as a non-pass verdict + * so the report is always complete. + */ +export async function evaluateImage( + imagePath: string, + persona: Persona, + context: string, + endpoints?: Array<{ base: string; protocol: VlmProtocol }> +): Promise { + const eps = endpoints ?? (await reachableEndpoints()); + if (eps.length === 0) { + return { pass: false, score: 0, issues: ['No reachable VLM endpoint'], summary: '' }; + } + const { base, protocol } = eps[rrCounter++ % eps.length]; + const prompt = buildPrompt(persona, context); + const started = Date.now(); + try { + const model = await resolveModel(base, protocol); + const b64 = fs.readFileSync(imagePath).toString('base64'); + const text = + protocol === 'openai' + ? await callOpenAI(base, model, persona.system, prompt, b64) + : await callOllama(base, model, persona.system, prompt, b64); + const v = extractVerdict(text); + return { ...v, endpoint: base, model, latencyMs: Date.now() - started }; + } catch (err) { + return { + pass: false, + score: 0, + issues: [`VLM request failed: ${err instanceof Error ? err.message : String(err)}`], + summary: '', + endpoint: base, + latencyMs: Date.now() - started, + }; + } +} + +/** Run a batch of {imagePath, persona, context} jobs with bounded concurrency. */ +export async function evaluateBatch( + jobs: Array<{ imagePath: string; persona: Persona; context: string; meta?: Record }> +): Promise }>> { + const eps = await reachableEndpoints(); + const results: Array<{ persona: string; context: string; imagePath: string; verdict: VlmVerdict; meta?: Record }> = []; + let idx = 0; + async function worker() { + while (idx < jobs.length) { + const job = jobs[idx++]; + const verdict = await evaluateImage(job.imagePath, job.persona, job.context, eps); + results.push({ persona: job.persona.key, context: job.context, imagePath: job.imagePath, verdict, meta: job.meta }); + } + } + await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENCY, jobs.length) }, worker)); + return results; +} diff --git a/tests/perf/scale-sweep.spec.ts b/tests/perf/scale-sweep.spec.ts new file mode 100644 index 00000000..2ad753e9 --- /dev/null +++ b/tests/perf/scale-sweep.spec.ts @@ -0,0 +1,224 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { seedLargeGraph, deleteGraphDeep } from '../helpers/seedGraph'; +import '../helpers/testEnv'; +import { envIntList, envList } from '../helpers/testEnv'; + +/** + * Large-scale graph creation + performance metric sweep. Seeds real graphs of + * increasing size through the GraphQL API, loads each in the browser at one or + * more quality tiers, and records the in-app PerfMeter/DriftMeter readings + * (window.__graphPerf) plus settle time, load time, and query latency. + * + * Report-only: writes one JSON per (size, quality) under + * test-artifacts/scale-sweep/, which `npm run report:perf` renders into a table + * + charts. It does NOT fail on thresholds — the goal is a metric sweep, not a + * gate (the @perf budgets spec is the gate). The only hard assertion is that a + * seeded graph actually renders, so a silent breakage still surfaces. + * + * Sizes/qualities come from env (.env.test.local) so you can push it hard + * locally; CI uses a small set just to keep the harness honest. + */ + +const SIZES = (() => { + const fromEnv = envIntList('SCALE_SWEEP_SIZES'); + if (fromEnv.length) return fromEnv; + return process.env.CI ? [40, 120] : [50, 200, 500, 1000, 2000]; +})(); + +const QUALITIES = (() => { + const fromEnv = envList('SCALE_SWEEP_QUALITIES').map((q) => q.toUpperCase()); + const valid = fromEnv.filter((q) => ['LOW', 'MEDIUM', 'HIGH', 'ULTRA'].includes(q)); + if (valid.length) return valid; + return process.env.CI ? ['HIGH'] : ['HIGH', 'ULTRA']; +})(); + +const OUT_DIR = path.resolve(process.cwd(), 'test-artifacts/scale-sweep'); +const SETTLE_BUDGET_MS = 30_000; +const REST_ALPHA = 0.02; + +interface SweepResult { + size: number; + quality: string; + seededNodes: number; + seededEdges: number; + renderedNodes: number; + renderedEdges: number; + loadMs: number; // time from reload to first node painted + interactionFps: number; // RELIABLE: rendered frames/sec while dragging the graph + settleMs: number | null; // time to reach REST_ALPHA (null = never settled within budget) + finalAlpha: number; + avgTickMs: number; + p95TickMs: number; + fps: number; + droppedFrames: number; + rmsFromSavedPx: number; + maxStepPx: number; + queryP95Ms: number; + timestampISO: string; +} + +async function measure(page: Page, graphId: string, size: number, quality: string): Promise { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.evaluate( + ({ gid, q }) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', q); + }, + { gid: graphId, q: quality } + ); + + const t0 = Date.now(); + await page.reload(); + // Load time = first node painted. + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 60_000 }).catch(() => {}); + const loadMs = Date.now() - t0; + + // Seeded nodes carry saved grid positions, so the app pins them and the force + // sim sits idle — PerfMeter (window.__graphPerf, published only every ~2s + // WHILE ticking) then never reports. We hold a node and drag it continuously + // for a few seconds: d3 keeps alphaTarget>0 while dragging, so the sim ticks + // the whole time and the meter publishes real UNDER-INTERACTION samples (tick + // cost / fps at this scale — a realistic "dragging a big graph" metric). We + // keep the best (lowest-tick) live sample, then release and time the settle. + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + + // Reliable interaction FPS: count real rendered frames (requestAnimationFrame) + // over a fixed wall-clock window while dragging. This needs no app + // instrumentation, so it works at every size — when the main thread is busy + // ticking a huge sim, rAF visibly drops, which is exactly the scaling signal. + let lastNonNull: any = null; + const samples: any[] = []; + let interactionFps = -1; + if (box) { + await page.evaluate(() => { + (window as any).__fc = 0; + const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; + (window as any).__rafId = requestAnimationFrame(loop); + }); + await page.mouse.move(box.x, box.y).catch(() => {}); + await page.mouse.down().catch(() => {}); + const dragStart = Date.now(); + let a = 0; + while (Date.now() - dragStart < 6000) { + a += 0.6; + await page.mouse.move(box.x + Math.cos(a) * 70, box.y + Math.sin(a) * 55).catch(() => {}); + await page.waitForTimeout(120); + const cur = await page.evaluate(() => (window as any).__graphPerf ?? null); + if (cur) { lastNonNull = cur; samples.push(cur); } + } + await page.mouse.up().catch(() => {}); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const secs = (Date.now() - dragStart) / 1000; + interactionFps = Math.round((frames / secs) * 10) / 10; + } + + // Now measure how long it takes to come to rest after the perturbation. + let settleMs: number | null = null; + const settleStart = Date.now(); + while (Date.now() - settleStart < SETTLE_BUDGET_MS) { + const cur = await page.evaluate(() => (window as any).__graphPerf ?? null); + if (cur) { + lastNonNull = cur; + if (typeof cur.alpha === 'number' && cur.alpha <= REST_ALPHA) { + settleMs = Date.now() - settleStart; + break; + } + } + await page.waitForTimeout(300); + } + // Prefer the worst (max) tick seen under interaction — that's the real cost at + // scale; a single settled sample understates it. + const underLoad = samples.length + ? samples.reduce((w, s) => ((s.avgTickMs ?? 0) > (w.avgTickMs ?? 0) ? s : w)) + : null; + const last = underLoad ?? (await page.evaluate(() => (window as any).__graphPerf ?? null)) ?? lastNonNull ?? {}; + + const renderedNodes = await page.locator('.graph-container svg .node').count(); + const renderedEdges = await page.locator('.graph-container svg .edge').count(); + + // Query latency a human/AI would feel: a graph-scoped workItems fetch. + const queryP95Ms = await page.evaluate(async (gid) => { + const token = localStorage.getItem('authToken') ?? ''; + const times: number[] = []; + for (let i = 0; i < 10; i++) { + const s = performance.now(); + await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + query: `query($w: WorkItemWhere) { workItems(where: $w, options: { limit: 5000 }) { id status type priority } }`, + variables: { w: { graph: { id: gid } } }, + }), + }).then((r) => r.json()); + times.push(performance.now() - s); + } + times.sort((a, b) => a - b); + return Math.round(times[Math.floor(times.length * 0.95)] ?? times[times.length - 1]); + }, graphId); + + const spatial = last?.spatial ?? {}; + fs.mkdirSync(OUT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(OUT_DIR, `${size}n-${quality}.png`), fullPage: false }).catch(() => {}); + + return { + size, + quality, + seededNodes: size, + seededEdges: 0, // filled by caller + renderedNodes, + renderedEdges, + loadMs, + interactionFps, + settleMs, + finalAlpha: typeof last?.alpha === 'number' ? last.alpha : -1, + avgTickMs: last?.avgTickMs ?? -1, + p95TickMs: last?.p95TickMs ?? -1, + fps: last?.fps ?? -1, + droppedFrames: last?.droppedFrames ?? -1, + rmsFromSavedPx: spatial.rmsFromSavedPx ?? -1, + maxStepPx: spatial.maxStepPx ?? -1, + queryP95Ms, + timestampISO: new Date(t0).toISOString(), + }; +} + +test.describe('large-scale graph perf sweep @scale', () => { + test.describe.configure({ mode: 'serial', timeout: 600_000 }); + + for (const size of SIZES) { + test(`sweep ${size} nodes`, async ({ page }) => { + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // A FRESH graph per (size, quality): otherwise the second quality loads + // the first run's already-settled positions, the sim never ticks, and + // PerfMeter never publishes (the -1 / settle=NONE gaps in v1). + for (const quality of QUALITIES) { + const seeded = await seedLargeGraph(page, { size }); + try { + const result = await measure(page, seeded.graphId, size, quality); + result.seededEdges = seeded.edgeCount; + fs.mkdirSync(OUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUT_DIR, `${size}n-${quality}.json`), JSON.stringify(result, null, 2)); + // eslint-disable-next-line no-console + console.log( + `[scale] ${size}n/${quality}: rendered ${result.renderedNodes}n/${result.renderedEdges}e ` + + `load=${result.loadMs}ms dragFps=${result.interactionFps} settle=${result.settleMs ?? 'NONE'}ms ` + + `tick=${result.avgTickMs}ms qP95=${result.queryP95Ms}ms` + ); + expect(result.renderedNodes, `graph of ${size} nodes must render some nodes`).toBeGreaterThan(0); + } finally { + await deleteGraphDeep(page, seeded.graphId); + } + } + }); + } +}); From 5dbe765eb16944dd93441ca9a7a84fef199d0b84 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 20:26:59 -0700 Subject: [PATCH 02/24] Self-heal the dev DB around heavy test suites (clean systems) (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large scale-sweep / VLM runs seed real graphs; an interrupted run (timeout, Ctrl-C) skips its per-test cleanup and leaves orphan WorkItems + drifted seed positions behind. That pollution made THE GATE's grow-flow flake (force-click landing on an overlapping node) until a manual re-seed. Now the heavy suites self-heal: - All test-seeded graphs are tagged with a sentinel name prefix ("[E2E]") so they're unmistakably identifiable (never matches a real/seed graph). - tests/helpers/dbHealing.ts sweepTestData() (Cypher over bolt, batched, fully graceful if Neo4j is down) removes: sentinel/legacy test-named graphs + their WorkItems/Edge nodes, orphan WorkItems (no BELONGS_TO), and orphan Edge nodes (missing source/target — the data-integrity incident class). It never touches seed/demo graphs. - scale-sweep.spec.ts and visual-vlm.spec.ts call it in beforeAll (heal leftovers from a prior killed run) and afterAll (clean up this run even if a per-run delete was skipped). Verified: injected a test graph + orphan node + orphan edge → sweep removed exactly those, seed data (44 items) untouched; a scale run leaves 0 test graphs / 0 orphans; THE GATE stays 5/5 green. Co-authored-by: Claude Opus 4.8 (1M context) --- tests/e2e/visual-vlm.spec.ts | 12 +++- tests/helpers/dbHealing.ts | 106 +++++++++++++++++++++++++++++++++ tests/helpers/seedGraph.ts | 3 +- tests/perf/scale-sweep.spec.ts | 7 +++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/helpers/dbHealing.ts diff --git a/tests/e2e/visual-vlm.spec.ts b/tests/e2e/visual-vlm.spec.ts index 654e4ea0..e5ca9673 100644 --- a/tests/e2e/visual-vlm.spec.ts +++ b/tests/e2e/visual-vlm.spec.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { login, TEST_USERS } from '../helpers/auth'; import { seedLargeGraph, deleteGraphDeep } from '../helpers/seedGraph'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; import '../helpers/testEnv'; import { isVlmAvailable, evaluateBatch, PERSONAS, personaByKey } from '../helpers/vlm'; @@ -31,6 +32,11 @@ async function shot(page: Page, name: string): Promise { return file; } +// Self-heal: clear leftover test graphs + orphans before and after, so an +// interrupted run never leaves the dev DB dirty (which can break THE GATE). +test.beforeAll(async () => { await sweepTestData('vlm:before'); }); +test.afterAll(async () => { await sweepTestData('vlm:after'); }); + test('VLM visual evaluation across personas @vlm', async ({ page }) => { test.setTimeout(900_000); const available = await isVlmAvailable(); @@ -44,14 +50,14 @@ test('VLM visual evaluation across personas @vlm', async ({ page }) => { await page.waitForTimeout(1500); // 1. Empty graph — first-run invitation (new-user + visual defects). - const empty = await page.evaluate(async () => { + const empty = await page.evaluate(async (pfx) => { const token = localStorage.getItem('authToken') ?? ''; const post = (query: string, variables?: unknown) => fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }).then((r) => r.json()); const me = await post('{ me { id } }'); - const g = await post(`mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, { i: [{ name: `VLM Empty ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me.data.me.id, isShared: true }] }); + const g = await post(`mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, { i: [{ name: `${pfx} VLM Empty ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: me.data.me.id, isShared: true }] }); return g.data.createGraphs.graphs[0].id as string; - }); + }, TEST_GRAPH_PREFIX); cleanup.push(empty); await page.setViewportSize({ width: 1440, height: 900 }); await page.evaluate((id) => localStorage.setItem('currentGraphId', id), empty); diff --git a/tests/helpers/dbHealing.ts b/tests/helpers/dbHealing.ts new file mode 100644 index 00000000..eb286cfa --- /dev/null +++ b/tests/helpers/dbHealing.ts @@ -0,0 +1,106 @@ +import neo4j, { Driver } from 'neo4j-driver'; + +/** + * Self-healing for the dev Neo4j so test runs never leave the database dirty — + * even when a run is killed mid-flight (timeout, Ctrl-C) and its per-test + * cleanup never executes. + * + * Heavy suites (scale-sweep, visual-vlm) call sweepTestData() in beforeAll + * (heal leftovers from a previous interrupted run) AND afterAll (clean up this + * run). It removes: + * - graphs whose name carries the test sentinel (or a legacy test prefix), + * with their WorkItems and Edge nodes, + * - orphan WorkItems (no BELONGS_TO) — what a half-finished delete leaves, + * - orphan Edge nodes (missing a source or target) — these 500 the edges + * query, the original data-integrity incident. + * + * It NEVER touches seed/demo graphs (Welcome, Cycle 2, Aquarium, …) — only + * sentinel/test-named graphs and true orphans. Fully graceful: if Neo4j is + * unreachable it logs and returns zeros rather than failing the run. + */ + +/** Every test-seeded graph name starts with this so the sweep can find them + * unambiguously without ever matching a real graph. */ +export const TEST_GRAPH_PREFIX = '[E2E]'; + +// Legacy/explicit test-name patterns (graphs created before the sentinel, or by +// ad-hoc probes). Anchored so they can't match real graphs. +const LEGACY_TEST_NAME_REGEX = + '^(\\[E2E\\]|Scale |VLM |Clone|Parity|PathP|NodeAttach|Contract|CloneFix|CloneProbe|Pop|TP |Empty Smoke|Living E2E|ParityV|Smoke ).*'; + +const URI = process.env.NEO4J_URI || 'bolt://localhost:7687'; +const USER = process.env.NEO4J_USER || 'neo4j'; +const PASS = process.env.NEO4J_PASSWORD || 'graphdone_password'; + +export interface SweepResult { + testGraphs: number; + testGraphNodes: number; + orphanNodes: number; + orphanEdges: number; + ok: boolean; +} + +async function deleteInBatches(session: any, matchDelete: string): Promise { + // matchDelete must be a query of shape: MATCH ... WITH x LIMIT 5000 DETACH DELETE x RETURN count(x) AS c + let total = 0; + for (;;) { + const r = await session.run(matchDelete); + const c = r.records[0]?.get('c')?.toNumber?.() ?? 0; + total += c; + if (c === 0) break; + } + return total; +} + +export async function sweepTestData(label = ''): Promise { + const result: SweepResult = { testGraphs: 0, testGraphNodes: 0, orphanNodes: 0, orphanEdges: 0, ok: false }; + let driver: Driver | undefined; + try { + driver = neo4j.driver(URI, neo4j.auth.basic(USER, PASS)); + await driver.verifyConnectivity(); + const session = driver.session(); + try { + // 1) WorkItems + Edge nodes that belong to test-named graphs. + result.testGraphNodes = await deleteInBatches( + session, + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' + MATCH (g)<-[:BELONGS_TO]-(w:WorkItem) + OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) + WITH w, e LIMIT 5000 DETACH DELETE e, w RETURN count(w) AS c` + ); + // 2) The test-named graphs themselves. + const g = await session.run( + `MATCH (g:Graph) WHERE g.name =~ '${LEGACY_TEST_NAME_REGEX}' DETACH DELETE g RETURN count(g) AS c` + ); + result.testGraphs = g.records[0]?.get('c')?.toNumber?.() ?? 0; + // 3) Orphan WorkItems (belong to no graph) — what a killed delete leaves. + result.orphanNodes = await deleteInBatches( + session, + `MATCH (w:WorkItem) WHERE NOT (w)-[:BELONGS_TO]->(:Graph) WITH w LIMIT 5000 DETACH DELETE w RETURN count(w) AS c` + ); + // 4) Orphan Edge nodes (missing a source or target) — these break the + // edges query for everyone. + result.orphanEdges = await deleteInBatches( + session, + `MATCH (e:Edge) WHERE NOT (e)-[:EDGE_SOURCE]->(:WorkItem) OR NOT (e)-[:EDGE_TARGET]->(:WorkItem) WITH e LIMIT 5000 DETACH DELETE e RETURN count(e) AS c` + ); + result.ok = true; + const touched = result.testGraphs + result.testGraphNodes + result.orphanNodes + result.orphanEdges; + if (touched > 0) { + // eslint-disable-next-line no-console + console.log( + `[db-heal${label ? ' ' + label : ''}] swept ${result.testGraphs} test graphs, ${result.testGraphNodes} their nodes, ${result.orphanNodes} orphan nodes, ${result.orphanEdges} orphan edges` + ); + } + } finally { + await session.close(); + } + } catch (err) { + // Graceful: never fail the test run because healing couldn't connect. + // eslint-disable-next-line no-console + console.warn(`[db-heal] skipped (${err instanceof Error ? err.message.split('\n')[0] : String(err)})`); + } finally { + await driver?.close(); + } + return result; +} diff --git a/tests/helpers/seedGraph.ts b/tests/helpers/seedGraph.ts index 2635822e..b8d267d2 100644 --- a/tests/helpers/seedGraph.ts +++ b/tests/helpers/seedGraph.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import { TEST_GRAPH_PREFIX } from './dbHealing'; /** * Seeds realistically-shaped graphs of arbitrary size through the real GraphQL @@ -64,7 +65,7 @@ export async function seedLargeGraph(page: Page, opts: SeedOptions): Promise { test.describe.configure({ mode: 'serial', timeout: 600_000 }); + // Self-heal: clear leftover test graphs + orphans from any prior interrupted + // run before starting, and clean up this run afterward even if a per-run + // delete was skipped (e.g. a killed run). + test.beforeAll(async () => { await sweepTestData('scale:before'); }); + test.afterAll(async () => { await sweepTestData('scale:after'); }); + for (const size of SIZES) { test(`sweep ${size} nodes`, async ({ page }) => { await login(page, TEST_USERS.ADMIN); From 3ade1627b465ee4e3d51d0bf1094aa8abebb7a8c Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 22:15:37 -0700 Subject: [PATCH 03/24] Add graph-geometry diagnostic (baseline for the node/edge/label overhaul) (#52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before changing the layout system, make its problems measurable + visible. This report-only diagnostic (npm run test:geometry, 'geometry' project) seeds a controlled scenario — a CLOSE node pair and a FAR node pair sharing the same wide edge label — and measures real rendered geometry from the DOM: - edge attachment: distance from each edge endpoint to the node CENTER (0px today => edges attach to centers, not borders) and how far the endpoint sits inside the card, - label fit: label box width vs the clear span between the two cards, and whether the label box overlaps either card, - minimum length: actual edge length vs what the label needs. Baseline captured on the live stack (1440x900): close pair: centerLen=200 clearSpan=30 labelW=104 -> overflow +74px, overlapsCard=true, endpoints 0px from both centers far pair: centerLen=470 clearSpan=300 labelW=104 -> fits, no overlap Confirms: edges are center-attached, and a short edge can't fit its label so the label overflows onto the cards. Output: test-artifacts/geometry/ {report.json, scenario-full.png, close-pair-centered.png}. Seeds with the [E2E] sentinel + self-heals (sweepTestData before/after), so it leaves the dev DB clean. Re-run after a fix to verify overflow/overlap -> 0 and endpoints move to the border. Co-authored-by: Claude Opus 4.8 (1M context) --- package.json | 1 + playwright.config.ts | 9 + tests/diagnostics/graph-geometry.spec.ts | 230 +++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 tests/diagnostics/graph-geometry.spec.ts diff --git a/package.json b/package.json index b3182854..f363cb8b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", "report:perf": "node tests/generate-perf-report.mjs", "test:vlm": "playwright test --project=vlm --reporter=line && node tests/generate-vlm-report.mjs", + "test:geometry": "playwright test --project=geometry --reporter=line", "report:vlm": "node tests/generate-vlm-report.mjs", "perf:bundle": "node tests/perf/check-bundle-size.mjs" }, diff --git a/playwright.config.ts b/playwright.config.ts index d0d1716b..bfb7c281 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -91,6 +91,15 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], screenshot: 'on' }, }, + /* Graph-geometry diagnostics: measures node/edge/label geometry from the + * rendered DOM (edge attachment, label fit, overlaps) to SEE layout issues + * before/after a fix. Report-only. Run via `npm run test:geometry`. */ + { + name: 'geometry', + testDir: './tests/diagnostics', + use: { ...devices['Desktop Chrome'], screenshot: 'on' }, + }, + // Commented out until browsers installed with system dependencies // { // name: 'GraphDone-Core/dev-neo4j/firefox', diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts new file mode 100644 index 00000000..14e6562b --- /dev/null +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Graph-geometry diagnostic — measures node/edge/label geometry from the REAL + * rendered DOM so layout problems are visible quantitatively + visually before + * (and after) any fix. Report-only; it never changes app behaviour. + * + * It checks the three things called out for the layout overhaul: + * 1. EDGE ATTACHMENT — do edge line endpoints sit at the node CENTER (buried + * under the card) or on the node BORDER? Measured as the gap between the + * edge endpoint and the node center (≈0 today = center-attached) and how + * far the endpoint is INSIDE the card. + * 2. LABEL FIT — is the edge long enough for its label? i.e. is the label's + * rendered box wider than the clear span between the two cards, and does + * it OVERLAP either card? + * 3. MIN LENGTH — does the actual edge length respect a label-width minimum? + * + * Output: test-artifacts/geometry/{report.json, *.png}. The screenshots feed + * the eye (and the VLM suite). A FRESH controlled scenario is seeded so the + * problems are reproducible regardless of demo data; healing cleans it up. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/geometry'); + +interface EdgeGeom { + type: string; + centerLen: number; // node-center to node-center distance (what the sim uses) + endpointAtSourceCenterPx: number; // |edge (x1,y1) - source center| (≈0 => center-attached) + endpointAtTargetCenterPx: number; + sourceInsetPx: number; // how far the edge endpoint is INSIDE the source card border, along the edge + targetInsetPx: number; + labelW: number; + labelH: number; + clearSpanPx: number; // straight-line gap between the two card borders along the edge + labelOverflowPx: number; // labelW - clearSpan (>0 => label can't fit between cards) + labelOverlapsCard: boolean; // label box intersects either node card rect +} + +async function readGeometry(page: Page): Promise<{ edges: EdgeGeom[] }> { + return page.evaluate(() => { + const rectFor = (nodeG: Element) => { + const bg = nodeG.querySelector('.node-bg') as Element | null; + const r = (bg ?? nodeG).getBoundingClientRect(); + return { cx: r.x + r.width / 2, cy: r.y + r.height / 2, w: r.width, h: r.height }; + }; + // node id -> screen rect/center + const nodeById: Record = {}; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const id = (n as any).__data__?.id; + if (id) nodeById[id] = rectFor(n); + }); + + const boxesOverlap = (a: any, b: any) => + Math.abs(a.cx - b.cx) * 2 < a.w + b.w && Math.abs(a.cy - b.cy) * 2 < a.h + b.h; + + // straight gap between two axis-aligned card borders along the connecting line + const clearSpan = (s: any, t: any) => { + const dx = t.cx - s.cx, dy = t.cy - s.cy; + const len = Math.hypot(dx, dy) || 1; + const ux = Math.abs(dx) / len, uy = Math.abs(dy) / len; + const proj = (n: any) => (n.w / 2) * ux + (n.h / 2) * uy; + return Math.max(0, len - proj(s) - proj(t)); + }; + + const edges: any[] = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d?.source || !d?.target) return; + const sId = typeof d.source === 'object' ? d.source.id : d.source; + const tId = typeof d.target === 'object' ? d.target.id : d.target; + const s = nodeById[sId], t = nodeById[tId]; + if (!s || !t) return; + + // The rendered edge endpoints (screen coords) + const le = e as SVGLineElement; + const r = le.getBoundingClientRect(); + // endpoints from attributes mapped to screen via the line's CTM is messy; + // instead compare the edge's own bbox extremes to the node centers. + const x1 = le.x1.baseVal.value, y1 = le.y1.baseVal.value, x2 = le.x2.baseVal.value, y2 = le.y2.baseVal.value; + // map svg-userspace endpoint to screen using the element CTM + const m = le.getScreenCTM(); + const p1 = m ? new DOMPoint(x1, y1).matrixTransform(m) : { x: r.left, y: r.top }; + const p2 = m ? new DOMPoint(x2, y2).matrixTransform(m) : { x: r.right, y: r.bottom }; + + const len = Math.hypot(t.cx - s.cx, t.cy - s.cy); + const ux = (t.cx - s.cx) / (len || 1), uy = (t.cy - s.cy) / (len || 1); + const proj = (n: any) => (n.w / 2) * Math.abs(ux) + (n.h / 2) * Math.abs(uy); + + // label box for this edge + const labelG = document.querySelector(`.edge-label-group`); + let labelW = 0, labelH = 0, labelBox: any = null; + const allLabels = [...document.querySelectorAll('.graph-container svg .edge-label-group')]; + // match label to edge by id when possible + const lg = allLabels.find((g) => (g as any).__data__?.id === d.id) as Element | undefined; + if (lg) { + const lr = lg.getBoundingClientRect(); + labelW = lr.width; labelH = lr.height; + labelBox = { cx: lr.x + lr.width / 2, cy: lr.y + lr.height / 2, w: lr.width, h: lr.height }; + } + + edges.push({ + type: d.type, + centerLen: Math.round(len), + endpointAtSourceCenterPx: Math.round(Math.hypot(p1.x - s.cx, p1.y - s.cy)), + endpointAtTargetCenterPx: Math.round(Math.hypot(p2.x - t.cx, p2.y - t.cy)), + sourceInsetPx: Math.round(proj(s)), + targetInsetPx: Math.round(proj(t)), + labelW: Math.round(labelW), + labelH: Math.round(labelH), + clearSpanPx: Math.round(clearSpan(s, t)), + labelOverflowPx: Math.round(labelW - clearSpan(s, t)), + labelOverlapsCard: labelBox ? (boxesOverlap(labelBox, s) || boxesOverlap(labelBox, t)) : false, + }); + }); + return { edges }; + }); +} + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }); + return res.json(); + }, { query, variables }); +} + +test.describe('graph geometry diagnostic @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('geometry:before'); }); + test.afterAll(async () => { await sweepTestData('geometry:after'); }); + + test('measure edge attachment, label fit and overlaps', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed a controlled scenario: two PINNED pairs sharing the widest edge + // label. One pair is close (label can't fit), one is far (control). + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Geometry ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + // positions are pinned (snapshot-authoritative) so the edges stay the + // length we choose. Close pair: 200px apart (cards ~170 wide => ~30px clear, + // far short of a "Depends On"/"Is Part Of" label). Far pair: 470px apart. + const nodeDefs = [ + { key: 'closeA', x: -100, y: -120 }, { key: 'closeB', x: 100, y: -120 }, + { key: 'farA', x: -235, y: 140 }, { key: 'farB', x: 235, y: 140 }, + ]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + const mkEdge = (a: string, b: string, type: string) => ({ type, weight: 0.6, source: { connect: { where: { node: { id: ids[a] } } } }, target: { connect: { where: { node: { id: ids[b] } } } } }); + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [mkEdge('closeA', 'closeB', 'DEPENDS_ON'), mkEdge('farA', 'farB', 'DEPENDS_ON')] }); + + // Open the scenario graph. + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); // load + settle (pinned nodes barely move) + + await page.screenshot({ path: path.join(OUT, 'scenario-full.png') }); + + // Center the view on the close pair (graph midpoint (0,-120)) so the label + // overflow is clearly visible, not tucked under the toolbar. + await page.evaluate(() => (window as any).miniMapNavigate?.(0, -120)); + await page.waitForTimeout(1000); + await page.screenshot({ path: path.join(OUT, 'close-pair-centered.png') }); + + const geom = await readGeometry(page); + + // Clipped close-up around the CLOSE pair so the label overflow is obvious, + // using the measured on-screen rects (independent of the app's framing). + const closeRects = await page.evaluate(() => { + const out: Array<{ x: number; y: number; w: number; h: number }> = []; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const t = (n as any).__data__?.title; + if (t === 'closeA' || t === 'closeB') { + const r = ((n.querySelector('.node-bg') as Element) ?? n).getBoundingClientRect(); + out.push({ x: r.x, y: r.y, w: r.width, h: r.height }); + } + }); + return out; + }); + if (closeRects.length === 2) { + const margin = 90; + const minX = Math.max(0, Math.min(...closeRects.map((r) => r.x)) - margin); + const minY = Math.max(0, Math.min(...closeRects.map((r) => r.y)) - margin); + const maxX = Math.min(1440, Math.max(...closeRects.map((r) => r.x + r.w)) + margin); + const maxY = Math.min(900, Math.max(...closeRects.map((r) => r.y + r.h)) + margin); + await page.screenshot({ path: path.join(OUT, 'close-pair.png'), clip: { x: minX, y: minY, width: Math.max(50, maxX - minX), height: Math.max(50, maxY - minY) } }).catch(() => {}); + } + + const report = { + generatedAt: new Date().toISOString(), + viewport: '1440x900', + edges: geom.edges, + summary: { + edgeCount: geom.edges.length, + centerAttached: geom.edges.filter((e) => e.endpointAtSourceCenterPx <= 3 && e.endpointAtTargetCenterPx <= 3).length, + labelsOverflowing: geom.edges.filter((e) => e.labelOverflowPx > 0).length, + labelsOverlappingCards: geom.edges.filter((e) => e.labelOverlapsCard).length, + }, + }; + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + + // eslint-disable-next-line no-console + console.log('[geometry] ' + JSON.stringify(report.summary)); + for (const e of geom.edges) { + // eslint-disable-next-line no-console + console.log(`[geometry] ${e.type}: centerLen=${e.centerLen} clearSpan=${e.clearSpanPx} labelW=${e.labelW} overflow=${e.labelOverflowPx} overlapsCard=${e.labelOverlapsCard} endpoint@srcCenter=${e.endpointAtSourceCenterPx}px endpoint@tgtCenter=${e.endpointAtTargetCenterPx}px`); + } + + // Diagnostic, not a gate: just assert we measured something real. + expect(geom.edges.length, 'measured at least one edge').toBeGreaterThan(0); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +}); From 2f1673e3bc578e01b13af1dca2b5ee85794f0895 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 22:36:54 -0700 Subject: [PATCH 04/24] Edges attach to node borders + edge-label width is a hard minimum edge length (#53) Two long-standing graph-layout problems, measured with the geometry diagnostic before changing anything (baseline: edges 0px from node centers; a 200px edge couldn't fit its 104px "Depends On" label -> +74px overflow onto the cards). 1. BORDER ATTACHMENT (dynamic anchor). New pure, unit-tested edgeGeometry.ts: rectBorderPoint() returns where the center line crosses a card's border; edgeBorderEndpoints() gives border-to-border endpoints. updateEdgePositions now draws the line + hitbox border-to-border and puts the arrow on the TARGET border. The anchor slides around the border as nodes move around each other -> always the shortest border-to-border connection. 2. LABEL WIDTH = MINIMUM EDGE LENGTH. minEdgeLength() = halfDiag(src) + halfDiag(tgt) + labelW + pad guarantees the label fits in the border gap at ANY angle. forceLink.distance is floored to it (rest length), and a new position-based 'minEdge' constraint force (like forceCollide, per-edge, respecting pinned nodes) makes it a HARD floor, not just a spring preference. Verified on the live stack via the diagnostic: border attachment: endpoint-to-center 0px -> 85px (on the border) for every edge. unpinned cluster (hub + 5 spokes, before the force): 1 edge overflowing, 2 labels overlapping their cards, minClearSpan 72 < labelW 106. unpinned cluster (after): 0 overflowing, 0 overlapping, minClearSpan 116 >= 107. No regressions: web units 113, perf budgets 3/3 (settle/tick/drift within budget), THE GATE 5/5, living-graph 3/3. (Pinned/user-placed nodes are deliberately left where the user put them; the floor governs the auto-layout. Drag-time clamping is a possible follow-up.) Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 82 +++++++++++++++---- .../src/lib/__tests__/edgeGeometry.test.ts | 75 +++++++++++++++++ packages/web/src/lib/edgeGeometry.ts | 77 +++++++++++++++++ tests/diagnostics/graph-geometry.spec.ts | 38 +++++++++ 4 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 packages/web/src/lib/__tests__/edgeGeometry.test.ts create mode 100644 packages/web/src/lib/edgeGeometry.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 23ac750a..766ae0f7 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -46,6 +46,7 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; +import { edgeBorderEndpoints, minEdgeLength } from '../lib/edgeGeometry'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -1969,13 +1970,47 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // physicsConfig.ts — see that file (and the debug console's drift metrics) // to reason about / tune why nodes settle and drift the way they do. const phys = DEFAULT_PHYSICS; + + // Hard minimum-edge-length constraint: connected nodes may never sit closer + // than their edge label needs to display (d._minLen, cached by the link + // distance accessor = halfDiag(src)+halfDiag(tgt)+labelW+pad). Position-based + // like forceCollide, so it's a real floor, not just a spring preference. It + // respects pinned nodes (fx set): a free node yields, two pinned nodes hold. + const minEdgeForce = () => { + for (const e of validatedEdges as any[]) { + const s = e.source, t = e.target; + if (!s || !t || typeof s.x !== 'number' || typeof t.x !== 'number') continue; + const min = e._minLen || 0; + if (min <= 0) continue; + let dx = t.x - s.x, dy = t.y - s.y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { dx = 1; dy = 0; dist = 1; } // arbitrary separation dir + if (dist >= min) continue; + const corr = ((min - dist) / dist) * 0.5; // ease toward the floor + const ox = dx * corr, oy = dy * corr; + const sFixed = s.fx != null, tFixed = t.fx != null; + if (sFixed && tFixed) continue; + if (sFixed) { t.x += ox * 2; t.y += oy * 2; } + else if (tFixed) { s.x -= ox * 2; s.y -= oy * 2; } + else { s.x -= ox; s.y -= oy; t.x += ox; t.y += oy; } + } + }; + simulation .force('link', d3.forceLink(validatedEdges) .id((d: any) => d.id) .distance((d: any) => { - const currentDistance = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y); + const currentDistance = Math.hypot((d.target.x || 0) - (d.source.x || 0), (d.target.y || 0) - (d.source.y || 0)); const maxDistance = linkMaxDistance(width, height, phys); - return currentDistance > maxDistance ? maxDistance : linkDistance(width, height, phys); + const preferred = currentDistance > maxDistance ? maxDistance : linkDistance(width, height, phys); + // Floor: never pull connected nodes closer than their edge label + // needs to display — the label width sets a minimum edge length so + // it always fits in the border-to-border gap (edgeGeometry.minEdgeLength). + const label = getRelationshipConfig(d.type as RelationshipType)?.label || ''; + const labelW = label.length * 6.2 + 28; // 10px/600 text + icon + padding + const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW); + d._minLen = minLen; // cached for the hard min-edge constraint below + return Math.max(preferred, minLen); }) .strength((d: any) => { const currentDistance = Math.hypot(d.target.x - d.source.x, d.target.y - d.source.y); @@ -1995,6 +2030,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap .strength(phys.collision.strength) .iterations(phys.collision.iterations) ) + .force('minEdge', minEdgeForce) .force('hierarchy', d3.forceLink() .id((d: any) => d.id) .links(createHierarchicalLinks(nodes)) @@ -3370,27 +3406,37 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap let labelAvoidCounter = 0; const updateEdgePositions = () => { + // Border-to-border anchors: the edge starts/ends where the center line + // crosses each card's border, not at the buried center. Computed once per + // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor + // slides around the border as the nodes move — shortest border path. + linkElements.each(function (d: any) { + d._ep = edgeBorderEndpoints( + { x: d.source.x || 0, y: d.source.y || 0 }, getNodeDimensions(d.source), + { x: d.target.x || 0, y: d.target.y || 0 }, getNodeDimensions(d.target) + ); + }); + // Update visible edge positions linkElements - .attr('x1', (d: any) => d.source.x) - .attr('y1', (d: any) => d.source.y) - .attr('x2', (d: any) => d.target.x) - .attr('y2', (d: any) => d.target.y); - - // Update clickable edge positions + .attr('x1', (d: any) => d._ep.x1) + .attr('y1', (d: any) => d._ep.y1) + .attr('x2', (d: any) => d._ep.x2) + .attr('y2', (d: any) => d._ep.y2); + + // Update clickable edge positions clickableEdges - .attr('x1', (d: any) => d.source.x) - .attr('y1', (d: any) => d.source.y) - .attr('x2', (d: any) => d.target.x) - .attr('y2', (d: any) => d.target.y); - - // Update arrow positions + .attr('x1', (d: any) => d._ep.x1) + .attr('y1', (d: any) => d._ep.y1) + .attr('x2', (d: any) => d._ep.x2) + .attr('y2', (d: any) => d._ep.y2); + + // Arrow sits at the TARGET border, pointing into the node. arrowElements .attr('transform', (d: any) => { - const midX = (d.source.x + d.target.x) / 2; - const midY = (d.source.y + d.target.y) / 2; - const angle = Math.atan2(d.target.y - d.source.y, d.target.x - d.source.x) * 180 / Math.PI; - return `translate(${midX},${midY}) rotate(${angle})`; + const ep = d._ep; + const angle = Math.atan2(ep.y2 - ep.y1, ep.x2 - ep.x1) * 180 / Math.PI; + return `translate(${ep.x2},${ep.y2}) rotate(${angle})`; }); // Edge labels: auto-centered in the clear span between the two node diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts new file mode 100644 index 00000000..d0cda9c8 --- /dev/null +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength } from '../edgeGeometry'; + +describe('rectBorderPoint', () => { + const dims = { width: 100, height: 60 }; // hw=50, hh=30 + + it('hits the vertical border for a horizontal ray', () => { + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 100, y: 0 })).toEqual({ x: 50, y: 0 }); + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: -100, y: 0 })).toEqual({ x: -50, y: 0 }); + }); + + it('hits the horizontal border for a vertical ray', () => { + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 0, y: 100 })).toEqual({ x: 0, y: 30 }); + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 0, y: -100 })).toEqual({ x: 0, y: -30 }); + }); + + it('hits the nearer border on a diagonal', () => { + // toward (100,100): sx=50/100=0.5, sy=30/100=0.3 -> s=0.3 -> (30,30) + expect(rectBorderPoint({ x: 0, y: 0 }, dims, { x: 100, y: 100 })).toEqual({ x: 30, y: 30 }); + }); + + it('respects the center offset', () => { + expect(rectBorderPoint({ x: 200, y: 100 }, dims, { x: 400, y: 100 })).toEqual({ x: 250, y: 100 }); + }); + + it('returns the center when toward equals center', () => { + expect(rectBorderPoint({ x: 5, y: 5 }, dims, { x: 5, y: 5 })).toEqual({ x: 5, y: 5 }); + }); + + it('always lands on the border (distance from center = exactly one half-extent)', () => { + for (const angle of [0.1, 0.7, 1.2, 2.5, -1.9, 3.0]) { + const p = rectBorderPoint({ x: 0, y: 0 }, dims, { x: Math.cos(angle) * 1000, y: Math.sin(angle) * 1000 }); + const onVertical = Math.abs(Math.abs(p.x) - 50) < 1e-9; + const onHorizontal = Math.abs(Math.abs(p.y) - 30) < 1e-9; + expect(onVertical || onHorizontal, `point ${JSON.stringify(p)} should be on a border`).toBe(true); + // and within the box on the other axis + expect(Math.abs(p.x)).toBeLessThanOrEqual(50 + 1e-9); + expect(Math.abs(p.y)).toBeLessThanOrEqual(30 + 1e-9); + } + }); +}); + +describe('edgeBorderEndpoints', () => { + it('connects the facing borders of two horizontally-separated cards', () => { + const e = edgeBorderEndpoints({ x: 0, y: 0 }, { width: 100, height: 60 }, { x: 300, y: 0 }, { width: 100, height: 60 }); + expect(e).toEqual({ x1: 50, y1: 0, x2: 250, y2: 0 }); + }); + + it('the drawn segment is shorter than center-to-center (it starts at borders)', () => { + const s = { x: 0, y: 0 }, t = { x: 300, y: 0 }; + const e = edgeBorderEndpoints(s, { width: 100, height: 60 }, t, { width: 100, height: 60 }); + const drawn = Math.hypot(e.x2 - e.x1, e.y2 - e.y1); + const center = Math.hypot(t.x - s.x, t.y - s.y); + expect(drawn).toBeLessThan(center); + expect(drawn).toBe(200); // 300 - 50 - 50 + }); +}); + +describe('minEdgeLength', () => { + it('is zero when there is no label', () => { + expect(minEdgeLength({ width: 170, height: 105 }, { width: 170, height: 105 }, 0)).toBe(0); + }); + + it('guarantees the label fits in the border gap at any angle', () => { + const a = { width: 170, height: 105 }; + const b = { width: 160, height: 100 }; + const labelW = 104; + const min = minEdgeLength(a, b, labelW, 16); + expect(min).toBeCloseTo(halfDiagonal(a) + halfDiagonal(b) + 104 + 16, 6); + // At the worst angle (toward a corner) the projections sum to the two half + // diagonals; the remaining gap must still cover the label. + const gap = min - halfDiagonal(a) - halfDiagonal(b); + expect(gap).toBeGreaterThanOrEqual(labelW); + }); +}); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts new file mode 100644 index 00000000..6fe4adaa --- /dev/null +++ b/packages/web/src/lib/edgeGeometry.ts @@ -0,0 +1,77 @@ +/** + * Edge geometry: where an edge meets a node, and how far apart connected nodes + * must sit for their label to fit. Pure functions, no D3 — unit-testable and + * shared by the renderer (border attachment) and the force sim (min length). + * + * Model: a node card is an axis-aligned box centered at (x,y). An edge is the + * straight line between two node centers; it should *draw* from border to + * border (the point where that line crosses each card), and the two nodes + * should never sit so close that the edge's label can't fit in the gap. + */ + +export interface Pt { x: number; y: number; } +export interface Dims { width: number; height: number; } + +/** + * The point on a node card's border along the ray from its center toward + * `toward`. As the two nodes move around each other this point slides around + * the border, always giving the shortest border-to-border connection. + * + * If the cards overlap (the other center is inside this card) the scaled point + * lands outside the segment; we clamp to the border so the result is always on + * the card edge, never past `toward`. + */ +export function rectBorderPoint(center: Pt, dims: Dims, toward: Pt): Pt { + const dx = toward.x - center.x; + const dy = toward.y - center.y; + if (dx === 0 && dy === 0) return { x: center.x, y: center.y }; + const hw = dims.width / 2; + const hh = dims.height / 2; + const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity; + const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity; + // Smaller scale = the first border (vertical vs horizontal) the ray hits. + const s = Math.min(sx, sy); + return { x: center.x + dx * s, y: center.y + dy * s }; +} + +export interface EdgeEndpoints { x1: number; y1: number; x2: number; y2: number; } + +/** + * Border-to-border endpoints for an edge: from the source card's border (facing + * the target) to the target card's border (facing the source). + */ +export function edgeBorderEndpoints( + source: Pt, + sourceDims: Dims, + target: Pt, + targetDims: Dims +): EdgeEndpoints { + const p1 = rectBorderPoint(source, sourceDims, target); + const p2 = rectBorderPoint(target, targetDims, source); + return { x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }; +} + +/** Half the box diagonal — the largest distance from center to border (a + * corner), i.e. the worst-case projection of the card onto any edge angle. */ +export function halfDiagonal(dims: Dims): number { + return Math.hypot(dims.width, dims.height) / 2; +} + +/** + * Minimum CENTER-to-CENTER distance so the edge label always fits in the + * border-to-border gap, at ANY angle. The visible gap = centerLen − proj(src) − + * proj(tgt); projection peaks at the half-diagonal (edge toward a corner), so + * requiring centerLen ≥ halfDiag(src) + halfDiag(tgt) + labelWidth + pad + * guarantees gap ≥ labelWidth regardless of how the nodes are oriented. + * + * Returns 0 for a zero-width label (no constraint). + */ +export function minEdgeLength( + sourceDims: Dims, + targetDims: Dims, + labelWidth: number, + pad = 16 +): number { + if (!(labelWidth > 0)) return 0; + return halfDiagonal(sourceDims) + halfDiagonal(targetDims) + labelWidth + pad; +} diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts index 14e6562b..5ed44bd3 100644 --- a/tests/diagnostics/graph-geometry.spec.ts +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -226,5 +226,43 @@ test.describe('graph geometry diagnostic @geometry', () => { await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + + // ── Scenario 2: an UNPINNED cluster the sim lays out (positionX/Y=0 => + // unplaced). This exercises the physics floor: every auto-laid edge should + // settle long enough for its label (clearSpan >= labelW). Pinned scenario + // above can't test this (user-placed nodes aren't moved by the sim). + const g2 = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} GeometryFlow ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const flowId = g2.data.createGraphs.graphs[0].id; + const flowNodes = ['hub', 's1', 's2', 's3', 's4', 's5']; + const c2 = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: flowNodes.map((t) => ({ type: 'TASK', title: t, status: 'IN_PROGRESS', priority: 0.5, positionX: 0, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: flowId } } } } })) }); + const fids: Record = {}; + for (const w of c2.data.createWorkItems.workItems) fids[w.title] = w.id; + const fEdge = (a: string, b: string, type: string) => ({ type, weight: 0.6, source: { connect: { where: { node: { id: fids[a] } } } }, target: { connect: { where: { node: { id: fids[b] } } } } }); + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: ['s1', 's2', 's3', 's4', 's5'].map((s, idx) => fEdge('hub', s, ['DEPENDS_ON', 'IS_PART_OF', 'RELATES_TO', 'BLOCKS', 'DEPENDS_ON'][idx])) }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, flowId); + await page.reload(); + await page.waitForTimeout(9000); // unplaced nodes flow + settle + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(800); + await page.screenshot({ path: path.join(OUT, 'flow-cluster.png') }); + const flow = await readGeometry(page); + const flowSummary = { + edgeCount: flow.edges.length, + labelsOverflowing: flow.edges.filter((e) => e.labelOverflowPx > 0).length, + labelsOverlappingCards: flow.edges.filter((e) => e.labelOverlapsCard).length, + minClearSpan: Math.min(...flow.edges.map((e) => e.clearSpanPx)), + maxLabelW: Math.max(...flow.edges.map((e) => e.labelW)), + }; + fs.writeFileSync(path.join(OUT, 'report-flow.json'), JSON.stringify({ summary: flowSummary, edges: flow.edges }, null, 2)); + // eslint-disable-next-line no-console + console.log('[geometry:flow] ' + JSON.stringify(flowSummary)); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: flowId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: flowId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: flowId }); }); }); From 22800a19793928fb1bcad88e69507609db99428f Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 22:55:49 -0700 Subject: [PATCH 05/24] Drag-time clamp: a node can't be dragged closer than its edge-label minimum (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the border-attachment / min-edge-length work. The minEdge force governs the AUTO-layout, but a user could still drag two nodes on top of each other (the dragged node is pinned to the cursor, so the force can't move it). Now the node drag handler clamps the dragged node's target so it stays at least the edge-label minimum (edge._minLen) away from any connected neighbor that isn't moving with it (cluster-co-moving free neighbors keep their distance, so they're excluded). New pure, unit-tested helper clampToMinNeighbors() projects the target out of each neighbor's min-radius circle (a few passes resolve multiple neighbors). Verified end-to-end (geometry diagnostic): dragging one node right onto a connected anchor stops at centerDist=306 = minLen=306 — the "IS PART OF" label still displays between them. web units 118 (5 new clamp tests), THE GATE 5/5, perf budgets 3/3. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 32 ++++++--- .../src/lib/__tests__/edgeGeometry.test.ts | 37 +++++++++- packages/web/src/lib/edgeGeometry.ts | 32 +++++++++ tests/diagnostics/graph-geometry.spec.ts | 71 +++++++++++++++++++ 4 files changed, 161 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 766ae0f7..68795774 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -46,7 +46,7 @@ import { mergeSimulationNodes, mergeSimulationEdges } from '../lib/graphDataMerg import { edgeLabelPlacement, clearSegment, slideTFromPointer, chooseLabelT } from '../lib/edgeLabelLayout'; import { PerfMeter, DriftMeter } from '../lib/perfMeter'; import { DEFAULT_PHYSICS, collisionRadius, linkDistance, linkMaxDistance, linkStrength } from '../lib/physicsConfig'; -import { edgeBorderEndpoints, minEdgeLength } from '../lib/edgeGeometry'; +import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry'; import { spawnCelebration } from '../lib/celebration'; import { buildNeighborhood } from '../lib/graphAdjacency'; import { UndoStack } from '../lib/undoStack'; @@ -2227,6 +2227,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const connectedNode = edge.source.id === d.id ? edge.target : edge.source; return { node: connectedNode, + edge, // keep the edge so the drag clamp can read its _minLen wasFixed: connectedNode.fx !== null || connectedNode.fy !== null }; }); @@ -2245,12 +2246,23 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Threshold for switching from cluster movement to edge stretching const stretchThreshold = 80; // pixels - - if (dragDistance < stretchThreshold) { + const clustering = dragDistance < stretchThreshold; + + // Drag-time hard clamp: the dragged node may not get closer than the + // edge-label minimum to a connected neighbor that ISN'T moving with it + // (cluster-co-moving free neighbors keep their distance automatically, + // so they're excluded). This is the interactive twin of the minEdge + // force, which only governs the auto-layout. + const clampNeighbors = (d._connectedNodes || []) + .filter((c: any) => !(clustering && !c.wasFixed)) + .map((c: any) => ({ x: c.node.x || 0, y: c.node.y || 0, minLen: c.edge?._minLen || 0 })); + const tgt = clampToMinNeighbors({ x: event.x, y: event.y }, clampNeighbors); + + if (clustering) { // Cluster movement - move connected nodes together - const deltaX = event.x - d.x; - const deltaY = event.y - d.y; - + const deltaX = tgt.x - d.x; + const deltaY = tgt.y - d.y; + d._connectedNodes.forEach(({ node, wasFixed }: { node: any, wasFixed: boolean }) => { if (!wasFixed) { // Only move if not already fixed by user previously node.fx = (node.fx || node.x) + deltaX; @@ -2268,10 +2280,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap } }); } - - // Move the dragged node - d.fx = event.x; - d.fy = event.y; + + // Move the dragged node to the clamped target + d.fx = tgt.x; + d.fy = tgt.y; d.x = d.fx; d.y = d.fy; }) diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts index d0cda9c8..e080b683 100644 --- a/packages/web/src/lib/__tests__/edgeGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength } from '../edgeGeometry'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors } from '../edgeGeometry'; describe('rectBorderPoint', () => { const dims = { width: 100, height: 60 }; // hw=50, hh=30 @@ -73,3 +73,38 @@ describe('minEdgeLength', () => { expect(gap).toBeGreaterThanOrEqual(labelW); }); }); + +describe('clampToMinNeighbors (drag-time min edge length)', () => { + it('leaves a target that is already far enough alone', () => { + const p = clampToMinNeighbors({ x: 300, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(p).toEqual({ x: 300, y: 0 }); + }); + + it('pushes a too-close target out to exactly the min radius', () => { + const p = clampToMinNeighbors({ x: 50, y: 0 }, [{ x: 0, y: 0, minLen: 200 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(200, 6); + expect(p.y).toBeCloseTo(0, 6); // stays on the same ray + expect(p.x).toBeCloseTo(200, 6); + }); + + it('pushes out in a stable direction when the target sits on the neighbor', () => { + const p = clampToMinNeighbors({ x: 0, y: 0 }, [{ x: 0, y: 0, minLen: 120 }]); + expect(Math.hypot(p.x, p.y)).toBeCloseTo(120, 6); + }); + + it('respects multiple neighbors (target ends up outside every min radius)', () => { + const neighbors = [ + { x: 0, y: 0, minLen: 150 }, + { x: 100, y: 0, minLen: 150 }, + ]; + const p = clampToMinNeighbors({ x: 50, y: 10 }, neighbors, 8); + for (const n of neighbors) { + expect(Math.hypot(p.x - n.x, p.y - n.y)).toBeGreaterThanOrEqual(150 - 1e-6); + } + }); + + it('ignores neighbors with no minimum', () => { + const p = clampToMinNeighbors({ x: 5, y: 5 }, [{ x: 0, y: 0, minLen: 0 }]); + expect(p).toEqual({ x: 5, y: 5 }); + }); +}); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts index 6fe4adaa..1c9485ee 100644 --- a/packages/web/src/lib/edgeGeometry.ts +++ b/packages/web/src/lib/edgeGeometry.ts @@ -57,6 +57,38 @@ export function halfDiagonal(dims: Dims): number { return Math.hypot(dims.width, dims.height) / 2; } +export interface MinNeighbor { x: number; y: number; minLen: number; } + +/** + * Clamp a dragged node's target so it never sits closer than `minLen` to any + * neighbor — drag-time enforcement of the minimum-edge-length rule. Projects + * the target out of each neighbor's min-radius circle; a few passes resolve + * multiple neighbors approximately (the cursor simply can't push past the + * nearest constraint). Pure — the caller supplies neighbor positions + mins. + */ +export function clampToMinNeighbors(target: Pt, neighbors: MinNeighbor[], iterations = 4): Pt { + let x = target.x; + let y = target.y; + for (let i = 0; i < iterations; i++) { + let moved = false; + for (const n of neighbors) { + if (!(n.minLen > 0)) continue; + let dx = x - n.x; + let dy = y - n.y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { dx = 1; dy = 0; dist = 1; } // arbitrary push-out direction + if (dist < n.minLen) { + const s = n.minLen / dist; + x = n.x + dx * s; + y = n.y + dy * s; + moved = true; + } + } + if (!moved) break; + } + return { x, y }; +} + /** * Minimum CENTER-to-CENTER distance so the edge label always fits in the * border-to-border gap, at ANY angle. The visible gap = centerLen − proj(src) − diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts index 5ed44bd3..d4673229 100644 --- a/tests/diagnostics/graph-geometry.spec.ts +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -265,4 +265,75 @@ test.describe('graph geometry diagnostic @geometry', () => { await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: flowId }); await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: flowId }); }); + + test('drag-time clamp: a node cannot be dragged closer than the label minimum', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} DragClamp ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + // Two placed nodes, far apart, joined by a wide-label edge. + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: [ + { type: 'TASK', title: 'anchor', status: 'IN_PROGRESS', priority: 0.5, positionX: -260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + { type: 'TASK', title: 'dragme', status: 'IN_PROGRESS', priority: 0.5, positionX: 260, positionY: 0, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } }, + ] }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'IS_PART_OF', weight: 0.6, source: { connect: { where: { node: { id: ids.dragme } } } }, target: { connect: { where: { node: { id: ids.anchor } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(6000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const centerOf = (title: string) => page.evaluate((t) => { + const n = [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + if (!n) return null; + const r = (n.querySelector('.node-bg') as Element).getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, title); + + const anchor = await centerOf('anchor'); + const drag = await centerOf('dragme'); + expect(anchor && drag, 'both nodes on screen').toBeTruthy(); + + // Drag "dragme" right onto "anchor" (and past it) — the clamp must stop it. + await page.mouse.move(drag!.x, drag!.y); + await page.mouse.down(); + const steps = 24; + for (let i = 1; i <= steps; i++) { + await page.mouse.move(drag!.x + (anchor!.x - drag!.x) * (i / steps), drag!.y + (anchor!.y - drag!.y) * (i / steps), { steps: 1 }); + await page.waitForTimeout(15); + } + await page.mouse.up(); + await page.waitForTimeout(1500); + await page.screenshot({ path: path.join(OUT, 'drag-clamp.png') }); + + // Final graph-space center distance + the edge's enforced minimum. + const result = await page.evaluate(() => { + const node = (t: string) => [...document.querySelectorAll('.graph-container svg .node')].find((el: any) => el.__data__?.title === t) as any; + const a = node('anchor')?.__data__, b = node('dragme')?.__data__; + const edge = [...document.querySelectorAll('.graph-container svg .edge')].map((e: any) => e.__data__).find((d: any) => d && d._minLen); + return { dist: a && b ? Math.hypot(a.x - b.x, a.y - b.y) : -1, minLen: edge?._minLen ?? -1 }; + }); + // eslint-disable-next-line no-console + console.log(`[geometry:drag] after dragging onto the anchor: centerDist=${Math.round(result.dist)} minLen=${Math.round(result.minLen)}`); + + expect(result.minLen, 'edge has a computed minimum length').toBeGreaterThan(0); + // The clamp must keep them apart — allow a small tolerance for the iterative + // projection + a tick of settling. + expect(result.dist, 'dragged node was held at the label minimum, not on top of the anchor').toBeGreaterThanOrEqual(result.minLen - 25); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); }); From 51945a5ed2bea1d15516216319b32e0a28d244d9 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 23:50:02 -0700 Subject: [PATCH 06/24] Fix CodeCaptcha refresh button not vertically centered in math mode (#55) The "Protected by ALTCHA"-style challenge centered the refresh button in the panel correctly, but in MATH mode the "What is:" label sat on top of the number and pushed the number ~14px below the panel center, so the lone refresh button (panel-centered) appeared above the number. (With the speaker button present the two-button column brackets the center, so it read as centered.) Float the "What is:" label at the top and center the NUMBER as the primary element so it sits at the panel center, aligned with the refresh button. Measured (geometry diagnostic, /signup): math refresh-vs-number offset -14px -> 0px. New report-only diagnostic tests/diagnostics/captcha-layout.spec.ts. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/components/CodeCaptcha.tsx | 8 ++- tests/diagnostics/captcha-layout.spec.ts | 74 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/diagnostics/captcha-layout.spec.ts diff --git a/packages/web/src/components/CodeCaptcha.tsx b/packages/web/src/components/CodeCaptcha.tsx index 861adc87..b02dd7f8 100644 --- a/packages/web/src/components/CodeCaptcha.tsx +++ b/packages/web/src/components/CodeCaptcha.tsx @@ -418,8 +418,12 @@ export function CodeCaptcha({ {/* Math Problem or Canvas Code Image */}
{currentStyle === 'math' ? ( -
-

What is:

+ // The "What is:" label floats at the top so the NUMBER is the + // vertically-centered element — otherwise the label pushed the + // number below the panel center and the lone refresh button + // (panel-centered) sat above it. +
+ What is:

{mathProblem} = ?

diff --git a/tests/diagnostics/captcha-layout.spec.ts b/tests/diagnostics/captcha-layout.spec.ts new file mode 100644 index 00000000..4c67b41c --- /dev/null +++ b/tests/diagnostics/captcha-layout.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * CodeCaptcha (ALTCHA-style) layout diagnostic. Measures whether the refresh + * ("try different style") button is vertically centered against the challenge + * content, in BOTH the math state (no speaker button) and the text/complex + * state (speaker button present). Report-only — no app behavior changed. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/captcha'); + +async function measure(page: Page) { + return page.evaluate(() => { + const refresh = document.querySelector('button[title="Try different style"]') as HTMLElement | null; + const speaker = document.querySelector('button[title="Listen to code"]') as HTMLElement | null; + // the challenge box (the bordered panel) and the math number / canvas + const num = [...document.querySelectorAll('p')].find((p) => /=\s*\?/.test(p.textContent || '')) as HTMLElement | null; + const canvas = document.querySelector('canvas') as HTMLElement | null; + const content = (num ?? canvas) as HTMLElement | null; + const box = refresh?.closest('.rounded-lg') as HTMLElement | null; + const c = (el: HTMLElement | null) => { if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), cy: Math.round(r.top + r.height / 2), h: Math.round(r.height) }; }; + return { + style: num ? 'math' : (canvas ? 'image' : 'unknown'), + hasSpeaker: !!speaker, + refresh: c(refresh), + content: c(content), + panel: c(box), + }; + }); +} + +test.describe('captcha layout diagnostic @geometry', () => { + test('refresh button vertical centering (math vs speaker state)', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/signup'); + await page.waitForTimeout(1500); + await page.locator('button[title="Try different style"]').first().waitFor({ timeout: 15000 }); + + const results: any[] = []; + // State 1: as first shown (math, no speaker) + let m = await measure(page); + await page.screenshot({ path: path.join(OUT, `state-${m.style}-speaker${m.hasSpeaker}.png`), clip: m.panel ? { x: 0, y: Math.max(0, m.panel.top - 20), width: 1280, height: m.panel.h + 40 } : undefined }).catch(() => {}); + results.push(m); + + // Click refresh until we land on an image/text style (speaker appears), to + // compare. Bounded so we don't loop forever if RNG keeps picking math. + for (let i = 0; i < 12 && !(results.find((r) => r.hasSpeaker)); i++) { + await page.locator('button[title="Try different style"]').click(); + await page.waitForTimeout(500); + const cur = await measure(page); + if (cur.style !== results[results.length - 1].style || cur.hasSpeaker !== results[results.length - 1].hasSpeaker) { + await page.screenshot({ path: path.join(OUT, `state-${cur.style}-speaker${cur.hasSpeaker}.png`), clip: cur.panel ? { x: 0, y: Math.max(0, cur.panel.top - 20), width: 1280, height: cur.panel.h + 40 } : undefined }).catch(() => {}); + results.push(cur); + } + } + + const report = results.map((r) => ({ + style: r.style, + hasSpeaker: r.hasSpeaker, + refreshCy: r.refresh?.cy, + contentCy: r.content?.cy, + panelCy: r.panel?.cy, + refreshVsContent: r.refresh && r.content ? r.refresh.cy - r.content.cy : null, + refreshVsPanel: r.refresh && r.panel ? r.refresh.cy - r.panel.cy : null, + })); + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + // eslint-disable-next-line no-console + for (const r of report) console.log('[captcha] ' + JSON.stringify(r)); + + expect(report.length, 'measured at least the math state').toBeGreaterThan(0); + }); +}); From a86b3741be08d4a63765e5fcdc5262554deb19bd Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 00:09:55 -0700 Subject: [PATCH 07/24] Tighten min edge length to label width + small margin (no oversized buffer) (#56) minEdgeLength used the half-DIAGONAL of each card (~100px) as the node projection, leaving a far larger gap than the edge label needs. Use the per-angle border reach instead (borderReach, mirrors rectBorderPoint) so the border-to-border gap = labelWidth + a small 14px margin, at any angle. Pass the edge direction through from the link-distance accessor (the minEdge force and drag clamp reuse the cached _minLen). Also make the label-width estimate slightly generous so the gap never under-shoots the rendered label. Verified (geometry diagnostic): drag-clamp centerDist 306->274; flow cluster minClearSpan 120 >= maxLabelW 106 with 0 overflowing / 0 overlapping. web units 19 (new borderReach tests), THE GATE 5/5, perf 3/3. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 10 ++++- .../src/lib/__tests__/edgeGeometry.test.ts | 40 +++++++++++++++---- packages/web/src/lib/edgeGeometry.ts | 40 ++++++++++++++----- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 68795774..f235dc5a 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -2007,8 +2007,14 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // needs to display — the label width sets a minimum edge length so // it always fits in the border-to-border gap (edgeGeometry.minEdgeLength). const label = getRelationshipConfig(d.type as RelationshipType)?.label || ''; - const labelW = label.length * 6.2 + 28; // 10px/600 text + icon + padding - const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW); + // Slightly generous estimate of the rendered label box (10px/600 text + // + icon + padding) so the gap never UNDER-shoots the real label. + const labelW = label.length * 7 + 34; + // Pass the edge direction so the minimum is just the per-angle border + // reach + label + a small margin (not an oversized half-diagonal buffer). + const dx = (d.target.x || 0) - (d.source.x || 0); + const dy = (d.target.y || 0) - (d.source.y || 0); + const minLen = minEdgeLength(getNodeDimensions(d.source), getNodeDimensions(d.target), labelW, dx, dy); d._minLen = minLen; // cached for the hard min-edge constraint below return Math.max(preferred, minLen); }) diff --git a/packages/web/src/lib/__tests__/edgeGeometry.test.ts b/packages/web/src/lib/__tests__/edgeGeometry.test.ts index e080b683..9c4bfdad 100644 --- a/packages/web/src/lib/__tests__/edgeGeometry.test.ts +++ b/packages/web/src/lib/__tests__/edgeGeometry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors } from '../edgeGeometry'; +import { rectBorderPoint, edgeBorderEndpoints, halfDiagonal, minEdgeLength, clampToMinNeighbors, borderReach, LABEL_MARGIN } from '../edgeGeometry'; describe('rectBorderPoint', () => { const dims = { width: 100, height: 60 }; // hw=50, hh=30 @@ -56,21 +56,45 @@ describe('edgeBorderEndpoints', () => { }); }); +describe('borderReach', () => { + const dims = { width: 170, height: 106 }; // hw=85, hh=53 + it('is the half-width for a horizontal edge', () => { + expect(borderReach(dims, 1, 0)).toBeCloseTo(85, 6); + }); + it('is the half-height for a vertical edge', () => { + expect(borderReach(dims, 0, 1)).toBeCloseTo(53, 6); + }); + it('never exceeds the half-diagonal (corner is the worst case)', () => { + for (const a of [0.2, 0.9, 1.4, 2.2]) { + expect(borderReach(dims, Math.cos(a), Math.sin(a))).toBeLessThanOrEqual(halfDiagonal(dims) + 1e-9); + } + }); +}); + describe('minEdgeLength', () => { it('is zero when there is no label', () => { expect(minEdgeLength({ width: 170, height: 105 }, { width: 170, height: 105 }, 0)).toBe(0); }); - it('guarantees the label fits in the border gap at any angle', () => { + it('leaves only a SMALL margin around the label, not an oversized buffer', () => { const a = { width: 170, height: 105 }; const b = { width: 160, height: 100 }; const labelW = 104; - const min = minEdgeLength(a, b, labelW, 16); - expect(min).toBeCloseTo(halfDiagonal(a) + halfDiagonal(b) + 104 + 16, 6); - // At the worst angle (toward a corner) the projections sum to the two half - // diagonals; the remaining gap must still cover the label. - const gap = min - halfDiagonal(a) - halfDiagonal(b); - expect(gap).toBeGreaterThanOrEqual(labelW); + // horizontal edge: the border-to-border gap should equal labelW + margin exactly. + const min = minEdgeLength(a, b, labelW, 1, 0); + const gap = min - borderReach(a, 1, 0) - borderReach(b, 1, 0); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); + // and it must be far tighter than the old half-diagonal buffer. + expect(min).toBeLessThan(halfDiagonal(a) + halfDiagonal(b) + labelW); + }); + + it('keeps the gap == label + margin at a vertical angle too', () => { + const a = { width: 170, height: 105 }; + const b = { width: 170, height: 105 }; + const labelW = 90; + const min = minEdgeLength(a, b, labelW, 0, 1); + const gap = min - borderReach(a, 0, 1) - borderReach(b, 0, 1); + expect(gap).toBeCloseTo(labelW + LABEL_MARGIN, 6); }); }); diff --git a/packages/web/src/lib/edgeGeometry.ts b/packages/web/src/lib/edgeGeometry.ts index 1c9485ee..0d6f6548 100644 --- a/packages/web/src/lib/edgeGeometry.ts +++ b/packages/web/src/lib/edgeGeometry.ts @@ -57,6 +57,25 @@ export function halfDiagonal(dims: Dims): number { return Math.hypot(dims.width, dims.height) / 2; } +/** A small margin kept around an edge label (px). */ +export const LABEL_MARGIN = 14; + +/** + * Distance from a card's center to where the edge crosses its border, along + * direction (dx,dy) — i.e. how much of the edge the card actually covers at + * this angle (NOT the worst-case corner). With direction unknown (0,0), falls + * back to the half short-side. Mirrors rectBorderPoint's reach. + */ +export function borderReach(dims: Dims, dx: number, dy: number): number { + const hw = dims.width / 2; + const hh = dims.height / 2; + if (dx === 0 && dy === 0) return Math.min(hw, hh); + const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity; + const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity; + const s = Math.min(sx, sy); + return Math.hypot(dx * s, dy * s); +} + export interface MinNeighbor { x: number; y: number; minLen: number; } /** @@ -90,20 +109,23 @@ export function clampToMinNeighbors(target: Pt, neighbors: MinNeighbor[], iterat } /** - * Minimum CENTER-to-CENTER distance so the edge label always fits in the - * border-to-border gap, at ANY angle. The visible gap = centerLen − proj(src) − - * proj(tgt); projection peaks at the half-diagonal (edge toward a corner), so - * requiring centerLen ≥ halfDiag(src) + halfDiag(tgt) + labelWidth + pad - * guarantees gap ≥ labelWidth regardless of how the nodes are oriented. - * - * Returns 0 for a zero-width label (no constraint). + * Minimum CENTER-to-CENTER distance so the edge label fits in the + * border-to-border gap with just a small margin — NOT an oversized buffer. + * The visible gap = centerLen − reach(src) − reach(tgt); using the per-angle + * border reach (pass the edge direction dx,dy) makes the gap = labelWidth + + * margin exactly. Returns 0 for a zero-width label (no constraint). */ export function minEdgeLength( sourceDims: Dims, targetDims: Dims, labelWidth: number, - pad = 16 + dx = 0, + dy = 0, + margin = LABEL_MARGIN ): number { if (!(labelWidth > 0)) return 0; - return halfDiagonal(sourceDims) + halfDiagonal(targetDims) + labelWidth + pad; + // Center distance whose BORDER-TO-BORDER gap is exactly labelWidth + a small + // margin. We use the per-angle border reach (not the half-diagonal), so the + // visible edge is just long enough for the label — no excessive buffer. + return borderReach(sourceDims, dx, dy) + borderReach(targetDims, dx, dy) + labelWidth + margin; } From 6666acd469409a89774bd9e3b14bd7d604bab587 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 00:45:36 -0700 Subject: [PATCH 08/24] Fix relationship type/flip live update + orthogonal Welcome graph; add interaction audit (#57) Basic graph interactions were silently broken. Test-first, each failure shown before its fix: - Relationship TYPE change didn't update the edge label, and flipping an edge's DIRECTION left a stale duplicate. Root cause: the reinitialization gatekeeper effect keyed only on node props + edge COUNT, so an edge whose type changed (same count) or whose direction flipped (delete+create => same count, new id) never triggered a rebuild. Fix: track an edge signature (id + type + direction) and force a reinit when it changes; add the signature to the effect's dependency array so the change is observed. - The Welcome onboarding graph violated the layout rules new users are meant to learn: nodes at the unpinned origin, diagonal edges, and spacing tighter than the edge label. Re-authored the template onto an orthogonal grid (every edge horizontal or vertical, spaced for the label + small margin, every node placed/pinned, no edge routed through a non-endpoint node). Verification: - New tests/diagnostics/interaction-audit.spec.ts drives the real UI: type change -> label "Blocks" immediately (no reload); flip -> exactly one edge, direction swapped. Shown failing first, green after the fix. - New onboarding-template.test.ts asserts the Welcome template is orthogonal, spaced, connected, fully placed, and routes no edge through a node. - THE GATE 5/5, web unit 122/122, server onboarding 5/5. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/services/onboarding-template.test.ts | 94 ++++++++++++ packages/server/src/services/onboarding.ts | 46 +++--- .../InteractiveGraphVisualization.tsx | 33 ++++- tests/diagnostics/interaction-audit.spec.ts | 138 ++++++++++++++++++ 4 files changed, 283 insertions(+), 28 deletions(-) create mode 100644 packages/server/src/services/onboarding-template.test.ts create mode 100644 tests/diagnostics/interaction-audit.spec.ts diff --git a/packages/server/src/services/onboarding-template.test.ts b/packages/server/src/services/onboarding-template.test.ts new file mode 100644 index 00000000..9c3e1a73 --- /dev/null +++ b/packages/server/src/services/onboarding-template.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { WELCOME_NODES, WELCOME_EDGES } from './onboarding.js'; + +/** + * The Welcome graph is the first thing every new user sees, so it must model + * the layout rules the app enforces everywhere else: + * - every node is "placed" (pinned) so the seed positions are authoritative + * and don't drift (a node at exactly (0,0) is treated as unplaced/unpinned); + * - simple template relationships are ORTHOGONAL — every edge is purely + * horizontal or vertical (the user's stated preference for simple graphs); + * - connected nodes sit far enough apart for the edge label to fit with only + * a small margin (the min-edge-length rule), not crammed together. + */ + +// Card footprint (≈ getNodeDimensions) + the min-edge math used by the renderer. +const CARD_W = 170; +const CARD_H = 106; +const LABEL_W = 90; // a typical relationship label ("Depends On") width +const LABEL_MARGIN = 14; + +// Per-axis half-extent the card covers along a horizontal / vertical edge. +const reachH = CARD_W / 2; // 85 +const reachV = CARD_H / 2; // 53 + +describe('Welcome onboarding template', () => { + it('places every node (no node pinned at the unplaced origin)', () => { + for (const n of WELCOME_NODES) { + const atOrigin = n.positionX === 0 && n.positionY === 0; + expect(atOrigin, `"${n.title}" sits at (0,0) and would be unpinned/drift`).toBe(false); + } + }); + + it('draws every edge orthogonally (horizontal or vertical)', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const orthogonal = dx < 1 || dy < 1; + expect( + orthogonal, + `edge ${e.sourceIndex}->${e.targetIndex} ("${s.title}"->"${t.title}") is diagonal: dx=${dx} dy=${dy}` + ).toBe(true); + } + }); + + it('spaces connected nodes so the edge label fits with a small margin', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const dx = Math.abs(s.positionX - t.positionX); + const dy = Math.abs(s.positionY - t.positionY); + const horizontal = dy < 1; + const centerDist = horizontal ? dx : dy; + const reach = horizontal ? reachH : reachV; + const gap = centerDist - 2 * reach; + expect( + gap, + `edge ${e.sourceIndex}->${e.targetIndex} gap ${gap.toFixed(0)}px < label ${LABEL_W}+${LABEL_MARGIN}` + ).toBeGreaterThanOrEqual(LABEL_W + LABEL_MARGIN); + } + }); + + it('keeps the graph connected (every node touched by an edge)', () => { + const touched = new Set(); + for (const e of WELCOME_EDGES) { + touched.add(e.sourceIndex); + touched.add(e.targetIndex); + } + for (let i = 0; i < WELCOME_NODES.length; i++) { + expect(touched.has(i), `"${WELCOME_NODES[i].title}" has no edges`).toBe(true); + } + }); + + it('routes no edge straight through a non-endpoint node', () => { + for (const e of WELCOME_EDGES) { + const s = WELCOME_NODES[e.sourceIndex]; + const t = WELCOME_NODES[e.targetIndex]; + const horizontal = Math.abs(s.positionY - t.positionY) < 1; + for (let i = 0; i < WELCOME_NODES.length; i++) { + if (i === e.sourceIndex || i === e.targetIndex) continue; + const n = WELCOME_NODES[i]; + const onLine = horizontal + ? Math.abs(n.positionY - s.positionY) < 1 && + n.positionX > Math.min(s.positionX, t.positionX) && + n.positionX < Math.max(s.positionX, t.positionX) + : Math.abs(n.positionX - s.positionX) < 1 && + n.positionY > Math.min(s.positionY, t.positionY) && + n.positionY < Math.max(s.positionY, t.positionY); + expect(onLine, `edge ${e.sourceIndex}->${e.targetIndex} passes through "${n.title}"`).toBe(false); + } + } + }); +}); diff --git a/packages/server/src/services/onboarding.ts b/packages/server/src/services/onboarding.ts index 2cf843ed..99ddbec2 100644 --- a/packages/server/src/services/onboarding.ts +++ b/packages/server/src/services/onboarding.ts @@ -25,7 +25,7 @@ export async function sharedWelcomeGraphExists(driver: Driver): Promise } } -interface OnboardingNode { +export interface OnboardingNode { title: string; description: string; type: string; @@ -35,13 +35,13 @@ interface OnboardingNode { positionZ: number; } -interface OnboardingEdge { +export interface OnboardingEdge { sourceIndex: number; targetIndex: number; type: string; } -const WELCOME_NODES: OnboardingNode[] = [ +export const WELCOME_NODES: OnboardingNode[] = [ { title: 'Welcome to GraphDone!', description: `# Welcome to GraphDone! 🎉 @@ -62,8 +62,8 @@ GraphDone is a graph-native project management system that reimagines how work f This is your workspace - feel free to edit, delete, or reorganize these tutorial nodes as you learn!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 0, + positionX: -180, + positionY: -240, positionZ: 0 }, { @@ -80,8 +80,8 @@ Each work item has: Try creating a work item right now!`, type: 'TASK', status: 'NOT_STARTED', - positionX: -200, - positionY: 150, + positionX: -180, + positionY: 0, positionZ: 0 }, { @@ -102,8 +102,8 @@ To create a dependency: Dependencies determine priority - the more dependents a node has, the higher its priority becomes.`, type: 'TASK', status: 'NOT_STARTED', - positionX: 200, - positionY: 150, + positionX: 180, + positionY: 0, positionZ: 0 }, { @@ -125,8 +125,8 @@ Dependencies determine priority - the more dependents a node has, the higher its The graph is alive - it reorganizes as you add dependencies and complete work!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: 0, - positionY: 300, + positionX: 180, + positionY: 240, positionZ: 0 }, { @@ -144,8 +144,8 @@ Switch between views using the mode buttons at the top center of the screen. Each view presents the same underlying graph data in different ways - choose what works best for your current task!`, type: 'DOCUMENTATION', status: 'COMPLETED', - positionX: -200, - positionY: -150, + positionX: -180, + positionY: 240, positionZ: 0 }, { @@ -170,21 +170,19 @@ Remember: In GraphDone, work flows through natural dependencies, not artificial You can always come back to this Welcome graph for reference, or delete it when you're ready.`, type: 'MILESTONE', status: 'NOT_STARTED', - positionX: 200, - positionY: -150, + positionX: 180, + positionY: 480, positionZ: 0 } ]; -const WELCOME_EDGES: OnboardingEdge[] = [ - { sourceIndex: 1, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 2, targetIndex: 0, type: 'DEPENDS_ON' }, - { sourceIndex: 3, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 4, targetIndex: 0, type: 'RELATES_TO' }, - { sourceIndex: 5, targetIndex: 1, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 2, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' }, - { sourceIndex: 5, targetIndex: 4, type: 'DEPENDS_ON' } +export const WELCOME_EDGES: OnboardingEdge[] = [ + { sourceIndex: 1, targetIndex: 0, type: 'RELATES_TO' }, + { sourceIndex: 2, targetIndex: 1, type: 'DEPENDS_ON' }, + { sourceIndex: 3, targetIndex: 2, type: 'DEPENDS_ON' }, + { sourceIndex: 4, targetIndex: 1, type: 'RELATES_TO' }, + { sourceIndex: 4, targetIndex: 3, type: 'RELATES_TO' }, + { sourceIndex: 5, targetIndex: 3, type: 'DEPENDS_ON' } ]; export async function createSharedWelcomeGraph(driver: Driver): Promise { diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index f235dc5a..93bd79b6 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3924,6 +3924,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Track previous node count to detect transition from empty to non-empty const prevNodeCountRef = useRef(0); + // Track the previous edge signature (id + type + direction) so a relationship + // TYPE change or a direction FLIP — which keep the edge COUNT the same — still + // forces a rebuild. Without this the edge label/arrow keep the stale value. + const prevEdgeSigRef = useRef(''); // Comprehensive reinitialization effect - ONLY when actually needed useEffect(() => { @@ -3940,6 +3944,19 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const isNowPopulated = nodes.length > 0; const transitioningFromEmpty = wasEmpty && isNowPopulated; + // Detect a relationship TYPE change or direction FLIP. Both keep the edge + // count the same, so length-based checks miss them; compare an id+type+ + // direction signature against the last render and force a rebuild on change. + const edgeSig = (validatedEdges as any[]) + .map((e) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }) + .sort() + .join(','); + const edgesChanged = prevEdgeSigRef.current !== '' && prevEdgeSigRef.current !== edgeSig; + // Only reinitialize if this is truly necessary const shouldReinit = !svgRef.current || @@ -3947,8 +3964,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap nodes.length === 0 || !d3.select(svgRef.current).select('.main-graph-group').node() || reinitTrigger > 0 || - transitioningFromEmpty; // Force reinit when adding first node to empty graph - + transitioningFromEmpty || // Force reinit when adding first node to empty graph + edgesChanged; // relationship type changed or direction flipped + if (shouldReinit) { console.log('[Graph Debug] Full reinitialization required'); initializeVisualization(); @@ -3962,8 +3980,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap updateVisualizationData(); } - // Update previous node count for next comparison + // Update previous node count + edge signature for next comparison prevNodeCountRef.current = nodes.length; + prevEdgeSigRef.current = edgeSig; const handleResize = () => { if (!containerRef.current || !svgRef.current || !simulationRef.current) return; @@ -3995,7 +4014,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap loading, // Re-init when loading completes edgesLoading, // Re-init when edges loading completes // Track node property changes for selective updates (only titles, descriptions, types) - nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(',') + nodes.map(n => `${n.id}:${n.title}:${n.description}:${n.type}:${n.status}`).join(','), + // Track edge type/direction changes so a relationship edit or flip rebuilds + validatedEdges.map((e: any) => { + const sId = typeof e.source === 'object' ? e.source?.id : e.source; + const tId = typeof e.target === 'object' ? e.target?.id : e.target; + return `${e.id}:${e.type}:${sId}>${tId}`; + }).join(',') ]); // Manual reinitialization function (expose globally for debugging) diff --git a/tests/diagnostics/interaction-audit.spec.ts b/tests/diagnostics/interaction-audit.spec.ts new file mode 100644 index 00000000..461b7a71 --- /dev/null +++ b/tests/diagnostics/interaction-audit.spec.ts @@ -0,0 +1,138 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Basic-interaction audit — walks the everyday graph interactions a user does + * and asserts the CORRECT outcome from the real rendered DOM. It exists to make + * "basic" regressions impossible to miss: each check is named after the user + * action it guards, and a failure prints exactly what went wrong. + * + * Covered here (the relationship-editing basics that were visibly broken): + * - changing an edge's relationship TYPE updates its label immediately, with + * no reload (the label used to stay on the old type); + * - flipping an edge's DIRECTION leaves EXACTLY ONE edge for the pair (it + * used to leave a stale duplicate because the delete+recreate kept the same + * edge count and the DOM was never reconciled). + * + * Output: test-artifacts/interaction-audit/report.json + screenshots. Seeds a + * controlled [E2E] graph; healing cleans up even if a run is killed. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/interaction-audit'); + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ query, variables }), + }); + return res.json(); + }, { query, variables }); +} + +async function readEdges(page: Page) { + return page.evaluate(() => { + const edges: Array<{ id: string; rtype: string; label: string; sId: string; tId: string }> = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d) return; + const sId = typeof d.source === 'object' ? d.source?.id : d.source; + const tId = typeof d.target === 'object' ? d.target?.id : d.target; + edges.push({ id: d.id, rtype: (e as Element).getAttribute('data-rtype') ?? d.type, label: '', sId, tId }); + }); + // labels live in a sibling group, keyed by the same datum id + const labelById: Record = {}; + document.querySelectorAll('.graph-container svg .edge-label-group').forEach((g) => { + const d = (g as any).__data__; + const txt = (g.querySelector('.edge-label') as Element | null)?.textContent ?? ''; + if (d?.id) labelById[d.id] = txt; + }); + for (const e of edges) e.label = labelById[e.id] ?? ''; + return edges; + }); +} + +test.describe('interaction audit @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('audit:before'); }); + test.afterAll(async () => { await sweepTestData('audit:after'); }); + + test('relationship type change + flip direction', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed one PINNED horizontal edge so the label is on-screen and clickable. + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Audit ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + const nodeDefs = [{ key: 'A', x: -190, y: 0 }, { key: 'B', x: 190, y: 0 }]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [{ type: 'DEPENDS_ON', weight: 0.6, source: { connect: { where: { node: { id: ids.A } } } }, target: { connect: { where: { node: { id: ids.B } } } } }] }); + + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); + await page.evaluate(() => (window as any).miniMapNavigate?.(0, 0)); + await page.waitForTimeout(1000); + + const results: Record = {}; + + // ── Baseline ───────────────────────────────────────────────────────── + let edges = await readEdges(page); + results.baseline = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + await page.screenshot({ path: path.join(OUT, '1-baseline.png') }); + expect(edges.length, 'one edge rendered at baseline').toBe(1); + expect(edges[0].label, 'baseline label is the DEPENDS_ON label').toBe('Depends On'); + + // ── Interaction 1: change relationship type → label updates immediately ─ + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.locator('button', { hasText: /^Blocks$/ }).first().click(); + await page.waitForTimeout(2500); // mutation + refetch + re-render (no reload) + await page.screenshot({ path: path.join(OUT, '2-after-type-change.png') }); + edges = await readEdges(page); + results.afterTypeChange = { edgeCount: edges.length, label: edges[0]?.label, rtype: edges[0]?.rtype }; + expect(edges.length, 'still exactly one edge after type change').toBe(1); + expect(edges[0].label, 'label updates to Blocks immediately, no reload').toBe('Blocks'); + + // ── Interaction 2: flip direction → exactly one edge remains ──────────── + const beforeFlip = edges[0]; + await page.locator('.graph-container svg .edge-label-group').first().click({ force: true }); + await page.waitForTimeout(800); + await page.getByRole('button', { name: /Flip Direction/ }).click(); + await page.waitForTimeout(3000); // delete + create + refetch + re-render + await page.screenshot({ path: path.join(OUT, '3-after-flip.png') }); + edges = await readEdges(page); + results.afterFlip = { + edgeCount: edges.length, + label: edges[0]?.label, + directionSwapped: edges[0] ? (edges[0].sId === beforeFlip.tId && edges[0].tId === beforeFlip.sId) : false, + }; + expect(edges.length, 'flip leaves EXACTLY ONE edge (no duplicate)').toBe(1); + expect(edges[0].label, 'flipped edge keeps its Blocks label').toBe('Blocks'); + + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(results, null, 2)); + // eslint-disable-next-line no-console + console.log('[interaction-audit] ' + JSON.stringify(results)); + + // Cleanup (edges before work items — orphan edges break the edges query). + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +}); From 780f71dcd353ff76cca49d6af016a1aafa0fb9c8 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 08:49:46 -0700 Subject: [PATCH 09/24] Integrate insecure-connection (HTTP) warning as a clean top strip, not a floating overlay (#58) The HTTP warning was a fixed corner badge (`fixed top-4 right-4`) that floated over the UI and constantly overlapped page content. Replace it with a single, slim, dismissible warning strip in a standard location. - New `InsecureConnectionBanner` (replaces the floating `TlsStatusIndicator` and the unused `TlsSecurityBanner`): a full-width strip shown ONLY over HTTP, dismissible for the session. Two placements: - in-app: in-flow at the very top of the shell, so it reserves its own space and pushes the app down instead of overlapping it; - auth pages (no app chrome): a pinned top strip, portaled to so a transformed ancestor can't offset it. - Layout shell is now a flex column (h-screen) with the banner as the first row and the app filling the rest; app page roots use h-full instead of h-screen so they shrink to the banner. With no banner (HTTPS) this is identical to before. Verified (over HTTP): banner at top:0, slim (33px), app starts exactly at its bottom (no overlap), no page scroll introduced, dismiss reclaims the space. New tests/diagnostics/insecure-connection-banner.spec.ts asserts all of this; THE GATE 5/5; web typecheck clean. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/components/Layout.tsx | 17 +-- .../web/src/components/TlsStatusIndicator.tsx | 133 ++++++++++-------- packages/web/src/pages/Admin.tsx | 2 +- packages/web/src/pages/Agents.tsx | 2 +- packages/web/src/pages/Analytics.tsx | 2 +- packages/web/src/pages/Backend.tsx | 2 +- packages/web/src/pages/ForgotPassword.tsx | 6 +- packages/web/src/pages/Ontology.tsx | 2 +- packages/web/src/pages/ResetPassword.tsx | 4 +- packages/web/src/pages/Settings.tsx | 2 +- packages/web/src/pages/Signin.tsx | 6 +- packages/web/src/pages/Signup.tsx | 6 +- packages/web/src/pages/Workspace.tsx | 2 +- .../insecure-connection-banner.spec.ts | 78 ++++++++++ 14 files changed, 179 insertions(+), 85 deletions(-) create mode 100644 tests/diagnostics/insecure-connection-banner.spec.ts diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index d0d6f30a..76757289 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { GraphSelector } from './GraphSelector'; import { useAuth } from '../contexts/AuthContext'; import { McpHealthIndicator } from './McpHealthIndicator'; import FloatingConsole from './FloatingConsole'; -import { TlsStatusIndicator, TlsSecurityBanner } from './TlsStatusIndicator'; +import { InsecureConnectionBanner } from './TlsStatusIndicator'; import { APP_VERSION } from '../utils/version'; interface LayoutProps { @@ -31,12 +31,16 @@ export function Layout({ children }: LayoutProps) { ]; return ( -
+ {/* Insecure-connection warning — an in-flow strip at the very top, so it + reserves its own space and never overlaps the app (only over HTTP). */} + + {/* Static gradient background - optimized for all browsers */}
@@ -58,7 +62,7 @@ export function Layout({ children }: LayoutProps) {
-
+
{/* Sidebar */}
-
+
{children}
@@ -305,9 +309,6 @@ export function Layout({ children }: LayoutProps) { onToggle={() => setShowFloatingConsole(!showFloatingConsole)} onClose={() => setShowFloatingConsole(false)} /> - - {/* TLS/SSL Status Indicator */} -
); } \ No newline at end of file diff --git a/packages/web/src/components/TlsStatusIndicator.tsx b/packages/web/src/components/TlsStatusIndicator.tsx index 95819919..f8cae834 100644 --- a/packages/web/src/components/TlsStatusIndicator.tsx +++ b/packages/web/src/components/TlsStatusIndicator.tsx @@ -1,72 +1,87 @@ import React from 'react'; -import { Shield, ShieldOff, AlertTriangle } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { ShieldOff, AlertTriangle, X } from 'lucide-react'; -interface TlsStatusIndicatorProps { - className?: string; -} +const DISMISS_KEY = 'tlsBannerDismissed'; -export function TlsStatusIndicator({ className = '' }: TlsStatusIndicatorProps) { - // Detect if we're running over HTTPS +function readConnection() { const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + const isLocalhost = + window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + return { isSecure, isLocalhost }; +} - // Don't show anything if we're on HTTPS (secure) - if (isSecure) { - return null; - } +interface InsecureConnectionBannerProps { + /** Render as a fixed full-width strip pinned to the very top (for pages that + * have no app chrome to sit under, e.g. the auth screens). Default is an + * in-flow strip that pushes the content below it down. */ + fixed?: boolean; + /** Called when the user dismisses the strip, so a parent can drop any layout + * offset it added to make room for the (fixed) banner. */ + onDismiss?: () => void; + className?: string; +} - return ( -
-
- {isLocalhost ? ( - <> - - Development Mode (HTTP) - - ) : ( - <> - - Insecure Connection (HTTP) - - )} -
-
- ); +/** Whether the current connection should trigger an insecure-connection warning + * (i.e. not HTTPS). Lets a layout reserve space for the fixed banner. */ +export function isInsecureConnection(): boolean { + return typeof window !== 'undefined' && window.location.protocol !== 'https:'; } -// For authenticated users, show a more prominent security status -export function TlsSecurityBanner({ className = '' }: TlsStatusIndicatorProps) { - const isSecure = window.location.protocol === 'https:'; - const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +/** + * A slim, dismissible warning strip shown ONLY when the connection is not + * encrypted (HTTP). It lives in the document flow (or pinned to the top edge + * for chrome-less pages) instead of floating in a corner, so it never overlaps + * the rest of the UI. Dismissal is remembered for the browser session. + */ +export function InsecureConnectionBanner({ fixed = false, onDismiss, className = '' }: InsecureConnectionBannerProps) { + const { isSecure, isLocalhost } = readConnection(); + const [dismissed, setDismissed] = React.useState( + () => typeof sessionStorage !== 'undefined' && sessionStorage.getItem(DISMISS_KEY) === '1' + ); - if (isSecure) { - return ( -
- - Secure Connection -
- ); - } + // Nothing to warn about on HTTPS, or once the user has dismissed it. + if (isSecure || dismissed) return null; - if (isLocalhost) { - return ( -
- - Development Mode -
- ); - } + const dismiss = () => { + try { + sessionStorage.setItem(DISMISS_KEY, '1'); + } catch { + /* storage may be unavailable; dismissing for this mount is enough */ + } + setDismissed(true); + onDismiss?.(); + }; - return ( -
- - Insecure Connection + const tone = isLocalhost + ? 'bg-yellow-500/15 border-yellow-600/40 text-yellow-200' + : 'bg-red-500/15 border-red-600/40 text-red-200'; + const position = fixed ? 'fixed top-0 inset-x-0 z-[60]' : 'w-full'; + + const strip = ( +
+ {isLocalhost ? : } + + {isLocalhost + ? 'Development mode — this connection is not encrypted (HTTP).' + : 'Insecure connection — this site is served over HTTP, not HTTPS.'} + +
); -} \ No newline at end of file + + // When pinned, portal to so a transformed/blur ancestor (route + // transitions, backdrop-filter) can't turn `fixed` into a clipped/offset box. + return fixed && typeof document !== 'undefined' ? createPortal(strip, document.body) : strip; +} diff --git a/packages/web/src/pages/Admin.tsx b/packages/web/src/pages/Admin.tsx index cc175531..10123181 100644 --- a/packages/web/src/pages/Admin.tsx +++ b/packages/web/src/pages/Admin.tsx @@ -15,7 +15,7 @@ export function Admin() { // Redirect if not ADMIN if (currentUser?.role !== 'ADMIN') { return ( -
+

Access Denied

diff --git a/packages/web/src/pages/Agents.tsx b/packages/web/src/pages/Agents.tsx index 18e36293..2e778512 100644 --- a/packages/web/src/pages/Agents.tsx +++ b/packages/web/src/pages/Agents.tsx @@ -109,7 +109,7 @@ export function Agents() { const totalActiveCount = activeAgents.length + activeMcpServers.length; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Analytics.tsx b/packages/web/src/pages/Analytics.tsx index 87019b38..8f0ccb61 100644 --- a/packages/web/src/pages/Analytics.tsx +++ b/packages/web/src/pages/Analytics.tsx @@ -47,7 +47,7 @@ export function Analytics() { ]; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Backend.tsx b/packages/web/src/pages/Backend.tsx index af9382f2..c71c18cc 100644 --- a/packages/web/src/pages/Backend.tsx +++ b/packages/web/src/pages/Backend.tsx @@ -460,7 +460,7 @@ export function Backend() { }; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index 5e4bc16e..2261dd68 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { isValidEmail } from '../utils/validation'; @@ -246,8 +246,8 @@ export function ForgotPassword() {
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } diff --git a/packages/web/src/pages/Ontology.tsx b/packages/web/src/pages/Ontology.tsx index 4970bc15..1ef4f714 100644 --- a/packages/web/src/pages/Ontology.tsx +++ b/packages/web/src/pages/Ontology.tsx @@ -115,7 +115,7 @@ export function Ontology() { }; return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index 2f5b88a5..1648a3e3 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Lock, Eye, EyeOff, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { validatePassword, getPasswordStrength } from '../utils/validation'; @@ -255,7 +255,7 @@ export function ResetPassword() {
- +
); } diff --git a/packages/web/src/pages/Settings.tsx b/packages/web/src/pages/Settings.tsx index b449c769..680787e3 100644 --- a/packages/web/src/pages/Settings.tsx +++ b/packages/web/src/pages/Settings.tsx @@ -11,7 +11,7 @@ export function Settings() { const { tier, override, setOverride } = useAdaptiveQuality(); return ( -
+
{/* Header */}
diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index fb007209..b3c2cdb9 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail } from '../utils/validation'; @@ -992,8 +992,8 @@ export function Signin() {
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } \ No newline at end of file diff --git a/packages/web/src/pages/Signup.tsx b/packages/web/src/pages/Signup.tsx index f0f0f1f9..0fcaaa04 100644 --- a/packages/web/src/pages/Signup.tsx +++ b/packages/web/src/pages/Signup.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, CheckCircle, XCircle, Github, Mail, Info, Shield } from 'lucide-react'; -import { TlsStatusIndicator } from '../components/TlsStatusIndicator'; +import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail, getPasswordStrength } from '../utils/validation'; import { CodeCaptcha } from '../components/CodeCaptcha'; @@ -729,8 +729,8 @@ export function Signup() { )}
- {/* TLS/SSL Status Indicator */} - + {/* Insecure-connection warning (top strip, only over HTTP) */} +
); } \ No newline at end of file diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 606feda2..31007e2c 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -61,7 +61,7 @@ export function Workspace() { const actualEdgeCount = edgesData?.edges?.length || 0; return ( -
+
{/* Header with Graph Context */}
{/* Responsive Layout Container */} diff --git a/tests/diagnostics/insecure-connection-banner.spec.ts b/tests/diagnostics/insecure-connection-banner.spec.ts new file mode 100644 index 00000000..d3a16b4b --- /dev/null +++ b/tests/diagnostics/insecure-connection-banner.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * The insecure-connection (HTTP) warning must integrate cleanly: a slim strip + * at the very top that reserves its own space (never overlaps the app), and is + * dismissible. Runs over the dev HTTP origin, so the banner is expected. + * Report-only screenshots + hard assertions on placement. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/tls-banner'); +const SEL = '[data-testid="insecure-connection-banner"]'; + +async function measure(page: Page) { + return page.evaluate((sel) => { + const b = document.querySelector(sel) as HTMLElement | null; + if (!b) return { present: false } as const; + const r = b.getBoundingClientRect(); + // The first app chrome under the banner: the sidebar or the main content. + const main = document.querySelector('main') as HTMLElement | null; + const sidebar = document.querySelector('nav')?.closest('div') as HTMLElement | null; + const topOfApp = Math.min( + main ? main.getBoundingClientRect().top : Infinity, + sidebar ? sidebar.getBoundingClientRect().top : Infinity + ); + return { + present: true, + top: Math.round(r.top), + bottom: Math.round(r.bottom), + height: Math.round(r.height), + width: Math.round(r.width), + topOfApp: Math.round(topOfApp), + scrollY: Math.round(window.scrollY), + }; + }, SEL); +} + +test.describe('insecure-connection banner @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('renders as a top strip, reserves space (no overlap), dismissible', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + + // Auth page (chrome-less): a pinned strip at the very top. + await page.goto('/signin'); + await page.waitForTimeout(1200); + const auth = await measure(page); + await page.screenshot({ path: path.join(OUT, 'auth-signin.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(auth.present, 'banner shown over HTTP on auth page').toBe(true); + if (auth.present) { + expect(auth.top, 'auth banner pinned to the very top').toBeLessThanOrEqual(1); + expect(auth.height, 'auth banner is a slim strip').toBeLessThan(60); + } + + // In-app: an in-flow strip that pushes the app below it (no overlap, no scroll). + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(3000); + const app = await measure(page); + await page.screenshot({ path: path.join(OUT, 'app-top.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + // eslint-disable-next-line no-console + console.log('[tls-banner] ' + JSON.stringify(app)); + expect(app.present, 'banner shown in-app over HTTP').toBe(true); + if (app.present) { + expect(app.top, 'in-app banner sits at the top').toBeLessThanOrEqual(1); + expect(app.height, 'in-app banner is a slim strip').toBeLessThan(60); + expect(app.scrollY, 'banner must not introduce a page scroll').toBeLessThanOrEqual(1); + expect(app.topOfApp, 'app chrome starts at/below the banner (no overlap)').toBeGreaterThanOrEqual(app.bottom - 1); + } + + // Dismiss reclaims the space. + await page.locator(`${SEL} button[aria-label="Dismiss insecure-connection warning"]`).click(); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(OUT, 'app-after-dismiss.png'), clip: { x: 0, y: 0, width: 1440, height: 220 } }); + expect(await page.locator(SEL).count(), 'banner gone after dismiss').toBe(0); + }); +}); From 583ea3f6ccc0400f8dcbb6ad9c0d18c4d8e413ac Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 10:09:18 -0700 Subject: [PATCH 10/24] Add WorkItem->Graph drill-in relationship (hierarchical graphs, PR1) (#59) First slice of Altium-style "graphs of graphs": a WorkItem can be a sheet symbol that drills into a sub-graph. - neo4j-schema.ts: WorkItem.subgraph (DRILLS_INTO -> Graph) + denormalized subgraphId scalar; inverse Graph.drillSources. Additive, no data migration. - queries.ts: fetch subgraphId + subgraph{ id name nodeCount edgeCount type } in GET_WORK_ITEMS / GET_WORK_ITEM_BY_ID. - types/graph.ts: WorkItem gains subgraphId? + subgraph? summary. Verified: GraphQL exposes the fields and returns null for existing nodes; THE GATE 5/5; web + server typecheck clean. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/server/src/schema/neo4j-schema.ts | 9 ++++++++- packages/web/src/lib/queries.ts | 16 ++++++++++++++++ packages/web/src/types/graph.ts | 9 +++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/server/src/schema/neo4j-schema.ts b/packages/server/src/schema/neo4j-schema.ts index 9fd47e16..f05642ce 100644 --- a/packages/server/src/schema/neo4j-schema.ts +++ b/packages/server/src/schema/neo4j-schema.ts @@ -313,6 +313,8 @@ export const typeDefs = gql` workItems: [WorkItem!]! @relationship(type: "BELONGS_TO", direction: IN) subgraphs: [Graph!]! @relationship(type: "PARENT_OF", direction: OUT) parentGraph: Graph @relationship(type: "PARENT_OF", direction: IN) + # WorkItems (sheet symbols) that drill into this graph + drillSources: [WorkItem!]! @relationship(type: "DRILLS_INTO", direction: IN) } # WorkItem entity - represents work items in the graph @@ -333,12 +335,17 @@ export const typeDefs = gql` dueDate: DateTime tags: [String!] metadata: String # JSON as string - + # If set, this node is a "sheet symbol" that drills into another graph + # (Altium-style hierarchy). Denormalized id for cheap client-side branching. + subgraphId: String + createdAt: DateTime! @timestamp(operations: [CREATE]) updatedAt: DateTime! @timestamp # Relationships owner: User @relationship(type: "OWNS", direction: IN) + # The sub-graph this node drills into (sheet symbol -> sub-sheet) + subgraph: Graph @relationship(type: "DRILLS_INTO", direction: OUT) assignedTo: User @relationship(type: "ASSIGNED_TO", direction: IN) graph: Graph @relationship(type: "BELONGS_TO", direction: OUT) dependencies: [WorkItem!]! @relationship(type: "DEPENDS_ON", direction: OUT) diff --git a/packages/web/src/lib/queries.ts b/packages/web/src/lib/queries.ts index ebc686f1..93216c87 100644 --- a/packages/web/src/lib/queries.ts +++ b/packages/web/src/lib/queries.ts @@ -18,6 +18,14 @@ export const GET_WORK_ITEMS = gql` dueDate tags metadata + subgraphId + subgraph { + id + name + nodeCount + edgeCount + type + } owner { id name @@ -103,6 +111,14 @@ export const GET_WORK_ITEM_BY_ID = gql` dueDate tags metadata + subgraphId + subgraph { + id + name + nodeCount + edgeCount + type + } owner { id name diff --git a/packages/web/src/types/graph.ts b/packages/web/src/types/graph.ts index 2e3c002a..0198e276 100644 --- a/packages/web/src/types/graph.ts +++ b/packages/web/src/types/graph.ts @@ -100,6 +100,15 @@ export interface WorkItem { userId?: string; dependencies?: WorkItem[]; dependents?: WorkItem[]; + // Altium-style hierarchy: if set, this node drills into another graph + subgraphId?: string; + subgraph?: { + id: string; + name: string; + nodeCount?: number; + edgeCount?: number; + type?: string; + }; } // Import and re-export RelationshipType from central constants file From cfec582fdf688589788ebdc18914f04282790dfa Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 10:40:18 -0700 Subject: [PATCH 11/24] Hierarchical graphs PR2+3: guest-visible demo hierarchy + drill-in navigation (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Seed guest-visible hierarchical "graphs of graphs" demo (PR2) Adds an Altium-style hierarchy demo, visible to the guest account, that showcases high-performance / dynamic LOD via drill-in (the hierarchy is the LOD strategy — never render all ~2900 nodes at once). - services/hierarchyDemo.ts + scripts/create-hierarchy-demo.ts (npm run create-hierarchy-demo): idempotent, NON-destructive. Builds a "System Overview" graph of 16 sheet-symbol WorkItems, each with subgraphId + DRILLS_INTO a sub-graph; inter-sheet wires kept endpoint-local to the overview. 16 sub-graphs (one ~1000-node "Compute Core" perf showcase), totaling 2911 work items / 4073 edges. All createdBy:'system' isShared:true (guest-visible, read-only). Edge nodes carry both EDGE_SOURCE+EDGE_TARGET. - GraphContext.tsx: fresh-load default graph now picked by identity (Welcome, then System Overview) instead of array position — shared/system demo graphs no longer hijack the default view. (The seed surfaced this latent fragility.) Verified: 17 system/shared graphs, overview sheets resolve subgraph counts, big sub-graph = 1000 nodes, idempotent re-run skips; THE GATE 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) * Drill-in/ascend navigation for hierarchical graphs (PR3) Makes the "graphs of graphs" hierarchy navigable like Altium schematics. - GraphContext: descendInto(subgraphId) / ascendTo(graphId) / getBreadcrumb() (breadcrumb derived from the graph's path ancestors + itself). Added to the context type. - InteractiveGraphVisualization: a plain click on a sheet-symbol node (one with subgraphId) descends into its sub-graph, via a ref so the D3-bound handler isn't re-created. Grow/connect, drag, and edit/relationship icons are unaffected (handled earlier / stopPropagation). - Workspace: a breadcrumb bar (Up button + clickable ancestors) shown when inside a sub-graph. Verified by tests/diagnostics/hierarchy-navigation.spec.ts: System Overview (16 sheets) -> click descends into the 1000-node Compute Core sub-graph -> breadcrumb -> Up returns to the overview. Web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/server/package.json | 3 +- .../src/scripts/create-hierarchy-demo.ts | 30 +++ packages/server/src/services/hierarchyDemo.ts | 242 ++++++++++++++++++ .../InteractiveGraphVisualization.tsx | 12 +- packages/web/src/contexts/GraphContext.tsx | 32 ++- packages/web/src/pages/Workspace.tsx | 41 ++- packages/web/src/types/graph.ts | 4 + .../diagnostics/hierarchy-navigation.spec.ts | 91 +++++++ 8 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/scripts/create-hierarchy-demo.ts create mode 100644 packages/server/src/services/hierarchyDemo.ts create mode 100644 tests/diagnostics/hierarchy-navigation.spec.ts diff --git a/packages/server/package.json b/packages/server/package.json index 27ebdce3..67600201 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -15,7 +15,8 @@ "clean": "rm -rf dist coverage", "db:seed": "tsx src/scripts/seed.ts", "create-admin": "tsx src/scripts/create-admin.ts", - "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts" + "create-welcome-graphs": "tsx src/scripts/create-welcome-graphs.ts", + "create-hierarchy-demo": "tsx src/scripts/create-hierarchy-demo.ts" }, "dependencies": { "@apollo/server": "^4.9.0", diff --git a/packages/server/src/scripts/create-hierarchy-demo.ts b/packages/server/src/scripts/create-hierarchy-demo.ts new file mode 100644 index 00000000..4b76e971 --- /dev/null +++ b/packages/server/src/scripts/create-hierarchy-demo.ts @@ -0,0 +1,30 @@ +import { driver } from '../db.js'; +import { createHierarchyDemo, hierarchyDemoExists } from '../services/hierarchyDemo.js'; + +async function ensureHierarchyDemo() { + console.log('🏗️ Ensuring hierarchical "graphs of graphs" demo exists...\n'); + try { + if (await hierarchyDemoExists(driver)) { + console.log('⏭️ Hierarchy demo already exists - skipping creation\n'); + } else { + const r = await createHierarchyDemo(driver); + console.log('\n========================================'); + console.log(' Hierarchy Demo Created'); + console.log('========================================'); + console.log(`• Graphs: ${r.graphs} (1 overview + sub-graphs)`); + console.log(`• Work items: ${r.nodes}`); + console.log(`• Edges: ${r.edges}`); + console.log('• Shared: Yes (guest + all users, read-only)'); + console.log('• Open "System Overview" and click a node to drill in'); + console.log('========================================\n'); + } + } catch (error: any) { + console.error('❌ Failed to create hierarchy demo:', error); + await driver.close(); + process.exit(1); + } + await driver.close(); + process.exit(0); +} + +ensureHierarchyDemo(); diff --git a/packages/server/src/services/hierarchyDemo.ts b/packages/server/src/services/hierarchyDemo.ts new file mode 100644 index 00000000..53f58c54 --- /dev/null +++ b/packages/server/src/services/hierarchyDemo.ts @@ -0,0 +1,242 @@ +import { Driver } from 'neo4j-driver'; + +/** + * Guest-visible demo of an Altium-style HIERARCHY of graphs ("graphs of + * graphs"). A top OVERVIEW graph holds sheet-symbol nodes; each drills into a + * sub-graph (via the WorkItem.subgraph / DRILLS_INTO relationship). The + * hierarchy is the level-of-detail strategy — any single view renders one graph + * (a few dozen sheets at the overview, up to ~1000 in the perf showcase + * sub-graph), never all ~2600 nodes at once. + * + * Everything is createdBy:'system' + isShared:true so the GUEST account sees it. + * Idempotent and NON-destructive: it only creates if the overview graph is + * absent (unlike scripts/seed.ts which wipes the DB). Edges are canonical Edge + * nodes with both EDGE_SOURCE and EDGE_TARGET (no orphan edges). + */ + +export const OVERVIEW_GRAPH_ID = 'overview-graph-shared'; + +const NODE_TYPES = ['TASK', 'FEATURE', 'BUG', 'MILESTONE', 'OUTCOME', 'IDEA']; +const STATUSES = ['NOT_STARTED', 'PROPOSED', 'PLANNED', 'IN_PROGRESS', 'BLOCKED', 'COMPLETED']; +const EDGE_TYPES = ['DEPENDS_ON', 'BLOCKS', 'ENABLES', 'RELATES_TO']; + +// Subsystems = sub-graphs. One large "Compute Core" (~1000 nodes) is the +// high-performance / LOD showcase; the rest are varied mid-size graphs. +interface Subsystem { key: string; name: string; size: number; } +const SUBSYSTEMS: Subsystem[] = [ + { key: 'compute', name: 'Compute Core', size: 1000 }, + { key: 'power', name: 'Power Management', size: 90 }, + { key: 'clocking', name: 'Clocking & PLL', size: 110 }, + { key: 'memctl', name: 'Memory Controller', size: 150 }, + { key: 'ddrphy', name: 'DDR PHY', size: 130 }, + { key: 'pcie', name: 'PCIe Root Complex', size: 160 }, + { key: 'usb', name: 'USB Subsystem', size: 120 }, + { key: 'ethernet', name: 'Ethernet MAC', size: 140 }, + { key: 'display', name: 'Display Pipeline', size: 170 }, + { key: 'audio', name: 'Audio Codec', size: 90 }, + { key: 'security', name: 'Security Enclave', size: 100 }, + { key: 'thermal', name: 'Thermal & Sensors', size: 110 }, + { key: 'ioexp', name: 'I/O Expander', size: 95 }, + { key: 'firmware', name: 'Firmware & Boot', size: 130 }, + { key: 'telemetry', name: 'Telemetry & Logging', size: 120 }, + { key: 'fabric', name: 'Interconnect Fabric', size: 180 }, +]; + +interface NodeRow { id: string; type: string; title: string; description: string; status: string; priority: number; x: number; y: number; } +interface EdgeRow { id: string; s: string; t: string; type: string; weight: number; } + +/** Grid positions centered on the origin so nodes load pinned in a real layout. */ +function gridPositions(n: number, spacing: number): Array<{ x: number; y: number }> { + const cols = Math.ceil(Math.sqrt(n)); + const half = (cols * spacing) / 2; + return Array.from({ length: n }, (_, i) => ({ + x: (i % cols) * spacing - half, + y: Math.floor(i / cols) * spacing - half, + })); +} + +/** A connected sub-graph: backbone chain + deterministic forward links (~1.4x). */ +function buildSubgraph(graphId: string, size: number): { nodes: NodeRow[]; edges: EdgeRow[] } { + const pos = gridPositions(size, 140); + const nodes: NodeRow[] = Array.from({ length: size }, (_, i) => { + const type = NODE_TYPES[(i * 7) % NODE_TYPES.length]; + return { + id: `${graphId}-n${i}`, + type, + title: `${type} ${i}`, + description: '', + status: STATUSES[i % STATUSES.length], + priority: ((i * 37) % 100) / 100, + x: pos[i].x, + y: pos[i].y, + }; + }); + + const edges: EdgeRow[] = []; + const link = (a: string, b: string, t: string) => + edges.push({ id: `${graphId}-e${edges.length}`, s: a, t: b, type: t, weight: 0.5 + (edges.length % 5) / 10 }); + for (let i = 0; i + 1 < size; i++) link(nodes[i].id, nodes[i + 1].id, 'DEPENDS_ON'); + let extra = Math.round(size * 1.4) - edges.length; + for (let i = 0; i < size && extra > 0; i++) { + const jump = 2 + ((i * 5) % Math.max(2, Math.floor(size / 4))); + const j = i + jump; + if (j < size) { link(nodes[i].id, nodes[j].id, EDGE_TYPES[i % EDGE_TYPES.length]); extra--; } + } + return { nodes, edges }; +} + +function chunk(arr: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); + return out; +} + +export async function hierarchyDemoExists(driver: Driver): Promise { + const session = driver.session(); + try { + const r = await session.run(`MATCH (g:Graph {id: $id}) RETURN count(g) > 0 AS exists`, { id: OVERVIEW_GRAPH_ID }); + return r.records[0]?.get('exists') ?? false; + } finally { + await session.close(); + } +} + +async function createGraphNode(session: any, params: { + id: string; name: string; description: string; type: string; depth: number; path: string[]; parentGraphId: string | null; nodeCount: number; edgeCount: number; tags: string[]; +}) { + await session.run( + `CREATE (g:Graph { + id: $id, name: $name, description: $description, type: $type, status: 'ACTIVE', + teamId: null, createdBy: 'system', tags: $tags, defaultRole: 'VIEWER', + parentGraphId: $parentGraphId, depth: $depth, path: $path, isShared: true, + nodeCount: $nodeCount, edgeCount: $edgeCount, contributorCount: 0, + lastActivity: datetime(), settings: '{}', + permissions: '{"public":"read","authenticated":"read"}', + shareSettings: '{"public":true,"readOnly":true}', + createdAt: datetime(), updatedAt: datetime() + })`, + params + ); +} + +async function insertNodesAndEdges(session: any, graphId: string, nodes: NodeRow[], edges: EdgeRow[]) { + for (const batch of chunk(nodes, 500)) { + await session.run( + `MATCH (g:Graph {id: $graphId}) + UNWIND $nodes AS n + CREATE (w:WorkItem { + id: n.id, type: n.type, title: n.title, description: n.description, status: n.status, + positionX: toFloat(n.x), positionY: toFloat(n.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: toFloat(n.priority), priorityComp: 0.0, + tags: [], metadata: '{}', createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g)`, + { graphId, nodes: batch } + ); + } + for (const batch of chunk(edges, 700)) { + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: batch } + ); + } +} + +export async function createHierarchyDemo(driver: Driver): Promise<{ graphs: number; nodes: number; edges: number }> { + const session = driver.session(); + let totalNodes = 0; + let totalEdges = 0; + try { + console.log('🏗️ Building hierarchical "graphs of graphs" demo...'); + + // 1) Build + populate each sub-graph. + for (const sub of SUBSYSTEMS) { + const subId = `subgraph-${sub.key}-shared`; + const { nodes, edges } = buildSubgraph(subId, sub.size); + await createGraphNode(session, { + id: subId, + name: sub.name, + description: `${sub.name} — a sub-sheet of the System Overview (${sub.size} work items).`, + type: 'SUBGRAPH', + depth: 1, + path: [OVERVIEW_GRAPH_ID], + parentGraphId: OVERVIEW_GRAPH_ID, + nodeCount: nodes.length, + edgeCount: edges.length, + tags: ['demo', 'subgraph', sub.key], + }); + await insertNodesAndEdges(session, subId, nodes, edges); + totalNodes += nodes.length; + totalEdges += edges.length; + console.log(` • ${sub.name}: ${nodes.length} nodes / ${edges.length} edges`); + } + + // 2) Overview graph: one sheet symbol per subsystem. + const sheetPos = gridPositions(SUBSYSTEMS.length, 320); + const sheets = SUBSYSTEMS.map((sub, i) => ({ + id: `${OVERVIEW_GRAPH_ID}-sheet-${sub.key}`, + subgraphId: `subgraph-${sub.key}-shared`, + title: sub.name, + description: `Drill in to open the ${sub.name} sub-graph (${sub.size} work items).`, + x: sheetPos[i].x, + y: sheetPos[i].y, + })); + // Inter-sheet "wires": backbone chain + a few cross links (both endpoints in overview). + const sheetEdges: EdgeRow[] = []; + const wire = (a: string, b: string, t: string) => + sheetEdges.push({ id: `${OVERVIEW_GRAPH_ID}-w${sheetEdges.length}`, s: a, t: b, type: t, weight: 0.8 }); + for (let i = 0; i + 1 < sheets.length; i++) wire(sheets[i].id, sheets[i + 1].id, 'DEPENDS_ON'); + for (let i = 0; i + 3 < sheets.length; i += 3) wire(sheets[i].id, sheets[i + 3].id, 'RELATES_TO'); + + await createGraphNode(session, { + id: OVERVIEW_GRAPH_ID, + name: 'System Overview', + description: 'Top-level overview — each node is a sub-sheet. Click a node to drill into its sub-graph (Altium-style hierarchy).', + type: 'PROJECT', + depth: 0, + path: [], + parentGraphId: null, + nodeCount: sheets.length, + edgeCount: sheetEdges.length, + tags: ['demo', 'overview', 'hierarchy'], + }); + + // Sheet WorkItems with subgraphId + DRILLS_INTO to their sub-graph. + await session.run( + `MATCH (g:Graph {id: $overviewId}) + UNWIND $sheets AS sh + MATCH (sub:Graph {id: sh.subgraphId}) + CREATE (w:WorkItem { + id: sh.id, type: 'OUTCOME', title: sh.title, description: sh.description, status: 'IN_PROGRESS', + positionX: toFloat(sh.x), positionY: toFloat(sh.y), positionZ: 0.0, + radius: 1.0, theta: 0.0, phi: 0.0, priority: 0.8, priorityComp: 0.0, + tags: ['sheet'], metadata: '{}', subgraphId: sh.subgraphId, + createdAt: datetime(), updatedAt: datetime() + }) + CREATE (w)-[:BELONGS_TO]->(g) + CREATE (w)-[:DRILLS_INTO]->(sub) + CREATE (g)-[:PARENT_OF]->(sub)`, + { overviewId: OVERVIEW_GRAPH_ID, sheets } + ); + // Inter-sheet wires. + await session.run( + `UNWIND $edges AS ed + MATCH (s:WorkItem {id: ed.s}), (t:WorkItem {id: ed.t}) + CREATE (e:Edge { id: ed.id, type: ed.type, weight: toFloat(ed.weight), metadata: '{}', createdAt: datetime() }) + CREATE (e)-[:EDGE_SOURCE]->(s) + CREATE (e)-[:EDGE_TARGET]->(t)`, + { edges: sheetEdges } + ); + totalNodes += sheets.length; + totalEdges += sheetEdges.length; + + console.log(`✅ Hierarchy demo: ${SUBSYSTEMS.length + 1} graphs, ${totalNodes} work items, ${totalEdges} edges`); + return { graphs: SUBSYSTEMS.length + 1, nodes: totalNodes, edges: totalEdges }; + } finally { + await session.close(); + } +} diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 93bd79b6..23836d12 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -90,7 +90,11 @@ interface InteractiveGraphVisualizationProps { export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, descendInto } = useGraph(); + // descendInto from context isn't memoized; hold the latest in a ref so the + // D3-bound node click handler can call it without re-binding every render. + const descendIntoRef = useRef(descendInto); + descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); const navigate = useNavigate(); @@ -1036,6 +1040,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(false); setConnectionSource(null); + } else if (node.subgraphId) { + // Altium-style sheet symbol: a plain click descends into its sub-graph. + // (Grow/connect is handled above; drag is suppressed by mousedownNodeRef; + // edit/relationship icons stopPropagation, so this only fires on a plain + // click of a sheet node.) Called via ref to avoid re-binding the handler. + descendIntoRef.current(node.subgraphId); } else { // Handle node selection with 2-item ring buffer setSelectedNodes(prev => { diff --git a/packages/web/src/contexts/GraphContext.tsx b/packages/web/src/contexts/GraphContext.tsx index 13c0e350..38541803 100644 --- a/packages/web/src/contexts/GraphContext.tsx +++ b/packages/web/src/contexts/GraphContext.tsx @@ -154,10 +154,17 @@ export function GraphProvider({ children }: GraphProviderProps) { graphToSelect = parsedGraphs.find((g: any) => g.id === storedGraphId); } - // Auto-select graph: either previously selected or first available + // Auto-select graph: previously selected, else a sensible default. + // Pick by identity (Welcome tutorial first, then the System Overview), + // never by array position — merge order isn't stable and shared/system + // demo graphs must not hijack the fresh-load graph. if (parsedGraphs.length > 0) { if (!currentGraph || !parsedGraphs.find((g: any) => g.id === currentGraph.id)) { - const selectedGraph = graphToSelect || parsedGraphs[0]; + const preferredDefault = + parsedGraphs.find((g: any) => g.name === 'Welcome') || + parsedGraphs.find((g: any) => g.id === 'overview-graph-shared') || + parsedGraphs[0]; + const selectedGraph = graphToSelect || preferredDefault; setCurrentGraph(selectedGraph); // Save to localStorage for persistence localStorage.setItem('currentGraphId', selectedGraph.id); @@ -660,10 +667,26 @@ export function GraphProvider({ children }: GraphProviderProps) { const getGraphPath = (graphId: string): Graph[] => { const graph = availableGraphs.find(g => g.id === graphId); if (!graph?.path) return []; - + return graph.path.map(pathId => availableGraphs.find(g => g.id === pathId)).filter(Boolean) as Graph[]; }; + // Altium-style hierarchy navigation: descend into a node's sub-graph, ascend + // back up via the breadcrumb. Both reduce to selectGraph; the breadcrumb is + // derived from the target graph's `path` (ancestors) + itself. + const descendInto = async (subgraphId: string): Promise => { + await selectGraph(subgraphId); + }; + + const ascendTo = async (graphId: string): Promise => { + await selectGraph(graphId); + }; + + const getBreadcrumb = (): Graph[] => { + if (!currentGraph) return []; + return [...getGraphPath(currentGraph.id), currentGraph]; + }; + const getGraphDepth = (graphId: string): number => { const graph = availableGraphs.find(g => g.id === graphId); return graph?.depth || 0; @@ -718,6 +741,9 @@ export function GraphProvider({ children }: GraphProviderProps) { moveGraph, getGraphPath, getGraphChildren, + descendInto, + ascendTo, + getBreadcrumb, shareGraph, updatePermissions, joinSharedGraph, diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 31007e2c..0ff19cdd 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus } from 'lucide-react'; +import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, CalendarDays, GanttChartSquare, LayoutDashboard, Database, AlertTriangle, Map, X, Minimize2, Edit3, Trash2, FolderPlus, ChevronLeft, ChevronRight } from 'lucide-react'; import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; @@ -26,7 +26,8 @@ export function Workspace() { const [graphToEdit, setGraphToEdit] = useState(null); const [viewMode, setViewMode] = useState<'graph' | 'dashboard' | 'table' | 'cards' | 'kanban' | 'gantt' | 'calendar' | 'activity'>('graph'); const [showMiniMap, setShowMiniMap] = useState(true); - const { currentGraph, availableGraphs } = useGraph(); + const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); + const breadcrumb = getBreadcrumb(); const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); @@ -249,6 +250,42 @@ export function Workspace() {
+ {/* Hierarchy breadcrumb — shown when we've descended into a sub-graph */} + {breadcrumb.length > 1 && ( +
+ + | + {breadcrumb.map((g, i) => { + const isLast = i === breadcrumb.length - 1; + return ( + + {i > 0 && } + {isLast ? ( + {g.name} + ) : ( + + )} + + ); + })} +
+ )} + {/* Main Content */}
{!currentGraph ? ( diff --git a/packages/web/src/types/graph.ts b/packages/web/src/types/graph.ts index 0198e276..f4f3c82f 100644 --- a/packages/web/src/types/graph.ts +++ b/packages/web/src/types/graph.ts @@ -160,6 +160,10 @@ export interface GraphContextType { moveGraph: (graphId: string, newParentId?: string) => Promise; getGraphPath: (graphId: string) => Graph[]; getGraphChildren: (graphId: string) => Graph[]; + // Altium-style drill-in navigation + descendInto: (subgraphId: string) => Promise; + ascendTo: (graphId: string) => Promise; + getBreadcrumb: () => Graph[]; // Sharing and permissions shareGraph: (graphId: string, settings: Partial) => Promise; diff --git a/tests/diagnostics/hierarchy-navigation.spec.ts b/tests/diagnostics/hierarchy-navigation.spec.ts new file mode 100644 index 00000000..3fdfd8ad --- /dev/null +++ b/tests/diagnostics/hierarchy-navigation.spec.ts @@ -0,0 +1,91 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Altium-style "graphs of graphs" navigation: from the System Overview, a + * sheet-symbol node drills into its sub-graph; a breadcrumb ascends back. Needs + * the hierarchy demo seeded (`npm run create-hierarchy-demo`). Report-only + * screenshots + hard assertions on descend/ascend. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/hierarchy'); +const OVERVIEW_ID = 'overview-graph-shared'; + +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); +} + +async function readState(page: Page) { + return page.evaluate(() => { + const nodes = [...document.querySelectorAll('.graph-container svg .node')]; + const sheets = nodes.filter((n) => (n as any).__data__?.subgraphId); + return { + currentGraphId: localStorage.getItem('currentGraphId'), + nodeCount: nodes.length, + sheetCount: sheets.length, + firstSheetSubgraphId: (sheets[0] as any)?.__data__?.subgraphId ?? null, + }; + }); +} + +test.describe('hierarchy navigation @geometry', () => { + test.describe.configure({ timeout: 150_000 }); + + test('descend into a sheet symbol, ascend via breadcrumb', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Overview should render sheet-symbol nodes (each with a subgraphId). + const overview = await readState(page); + await page.screenshot({ path: path.join(OUT, '1-overview.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] overview ' + JSON.stringify(overview)); + expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID); + expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0); + + // Descend: click a sheet node's card. + const targetSubgraphId = overview.firstSheetSubgraphId as string; + await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const bg = (sheet?.querySelector('.node-bg') ?? sheet) as Element | undefined; + (bg as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await page.waitForTimeout(5000); + + const descended = await readState(page); + await page.screenshot({ path: path.join(OUT, '2-descended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] descended ' + JSON.stringify(descended)); + expect(descended.currentGraphId, 'descended into the sheet sub-graph').toBe(targetSubgraphId); + expect(descended.nodeCount, 'sub-graph rendered its own nodes').toBeGreaterThan(0); + + // Breadcrumb shows two crumbs (overview / sub-graph). + const crumbs = await page.locator('[data-testid="graph-breadcrumb"]').count(); + expect(crumbs, 'breadcrumb is shown after descending').toBe(1); + + // Ascend via the "Up" button. + await page.locator('[data-testid="graph-breadcrumb"] button', { hasText: 'Up' }).click(); + await page.waitForTimeout(4000); + const ascended = await readState(page); + await page.screenshot({ path: path.join(OUT, '3-ascended.png') }); + // eslint-disable-next-line no-console + console.log('[hierarchy] ascended ' + JSON.stringify(ascended)); + expect(ascended.currentGraphId, 'back on the overview after Up').toBe(OVERVIEW_ID); + + fs.writeFileSync( + path.join(OUT, 'report.json'), + JSON.stringify({ overview, descended, ascended }, null, 2) + ); + }); +}); From 042de188491e48647b84bc96f28384c361320085 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 11:04:49 -0700 Subject: [PATCH 12/24] Visual treatment for sheet-symbol nodes (hierarchical graphs, PR4) (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes drill-able nodes (those with a subgraphId) read as sub-sheets: - a "stacked cards" depth effect (offset indigo rects behind the card), - an indigo accent border (thicker), - a bottom-right "descend" glyph (⤢) that drills in on click, - a LOD-gated child-count line ("▸ N nodes · M edges") from the subgraph summary fetched in PR1. Verified: hierarchy-navigation diagnostic still green (descend into the 1000-node Compute Core sub-graph, ascend back); overview screenshot shows the stacked/accented sheet cards + glyphs; web typecheck clean. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 23836d12..5fad9634 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -2398,6 +2398,24 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Monopoly-style rectangular nodes with colored title bars // getNodeDimensions is now defined outside and shared with updateVisualizationData + // Sheet-symbol "stack" — offset rects BEHIND the card imply this node opens + // a whole sub-graph (Altium-style hierarchical sheet). Rendered first so the + // main card sits on top. Only for nodes that drill into a sub-graph. + [10, 5].forEach((off) => { + nodeElements.filter((d: WorkItem) => !!d.subgraphId).append('rect') + .attr('class', 'node-subgraph-stack') + .attr('x', (d: WorkItem) => -getNodeDimensions(d).width / 2 + off) + .attr('y', (d: WorkItem) => -getNodeDimensions(d).height / 2 + off) + .attr('width', (d: WorkItem) => getNodeDimensions(d).width) + .attr('height', (d: WorkItem) => getNodeDimensions(d).height) + .attr('rx', 8) + .attr('fill', '#1f2937') + .attr('stroke', '#6366f1') + .attr('stroke-width', 1.5) + .style('opacity', 0.45) + .style('pointer-events', 'none'); + }); + // Main node rectangle (dark theme background) nodeElements.append('rect') .attr('class', (d: WorkItem) => { @@ -2442,6 +2460,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap if (d.status === 'COMPLETED' || d.status === 'Completed' || d.status === 'Done' || d.status === 'DONE') { return '#4b5563'; } + // Sheet symbols (drill into a sub-graph) get an indigo accent border. + if (d.subgraphId) { + return '#818cf8'; + } // In-progress work breathes with its type color (LIVE-1) if (isActiveStatus(d.status)) { return getTypeConfig(d.type as WorkItemType).hexColor; @@ -2457,6 +2479,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap if (selectedNode && selectedNode.id === d.id) { return 3; } + if (d.subgraphId) { + return 2.5; // Sheet symbol — emphasize it's a container + } return 1.5; }) .style('stroke-opacity', (d: WorkItem) => { @@ -2692,6 +2717,72 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(true); }); + // 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); + + const descendIcon = sheetNodes.append('g') + .attr('class', 'node-descend-icon') + .attr('transform', (d: WorkItem) => { + const x = getNodeDimensions(d).width / 2 - iconSize / 2 - 8; + const y = getNodeDimensions(d).height / 2 - iconSize / 2 - 6; + return `translate(${x}, ${y}) scale(${1 / (currentTransform?.k || 1)})`; + }) + .style('cursor', 'pointer') + .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.9 : 0) + .style('pointer-events', 'all'); + descendIcon.append('rect') + .attr('class', 'descend-bg') + .attr('x', -iconSize / 2) + .attr('y', -iconSize / 2) + .attr('width', iconSize) + .attr('height', iconSize) + .attr('rx', 4) + .attr('fill', 'rgba(99, 102, 241, 0.9)') + .attr('stroke', 'rgba(255, 255, 255, 0.85)') + .attr('stroke-width', 1); + descendIcon.append('text') + .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('⤢'); + descendIcon + .on('mouseenter', function() { + d3.select(this).select('.descend-bg').transition().duration(150) + .attr('fill', 'rgba(129, 140, 248, 1)'); + }) + .on('mouseleave', function() { + d3.select(this).select('.descend-bg').transition().duration(150) + .attr('fill', 'rgba(99, 102, 241, 0.9)'); + }) + .on('click', (event: MouseEvent, d: WorkItem) => { + event.stopPropagation(); + event.preventDefault(); + if (d.subgraphId) descendIntoRef.current(d.subgraphId); + }); + + // Child-graph count line (LOD-gated like the description text). + sheetNodes.append('text') + .attr('class', 'node-subgraph-count') + .attr('x', 0) + .attr('y', (d: WorkItem) => getNodeDimensions(d).height / 2 - 10) + .attr('text-anchor', 'middle') + .style('font-size', '9px') + .style('font-weight', '600') + .style('fill', '#a5b4fc') + .style('pointer-events', 'none') + .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.CLOSE ? 1 : 0) + .text((d: WorkItem) => { + const n = d.subgraph?.nodeCount ?? 0; + const e = d.subgraph?.edgeCount ?? 0; + return `▸ ${n} nodes · ${e} edges`; + }); + // Node title section - with text wrapping nodeElements.each(function(d: WorkItem) { const nodeGroup = d3.select(this); From 63f3bb5b14cc7288c0a8e85b33c7be69cc74f36b Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 12:03:25 -0700 Subject: [PATCH 13/24] Graph PR-A: one-shot physics (settle then stop), spread-start layout, camera centering, layout metrics (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * One-shot physics (settle then stop), spread-start layout, camera centering, layout metrics (PR-A) Graphs were "garbage piles" that drifted forever. Root causes (found via new metrics): the 2s poll re-kicked the sim every cycle (perpetual drift), and the force equilibrium itself was overlapping (an inward centering force compressed dense graphs into a core collision couldn't separate). - One-shot physics: the routine poll no longer reheats a placed/frozen graph; the sim settles to alphaMin and STAYS idle (no continuous drift). - Spread-start: unplaced nodes / Organize start on a clean grid (spacing > collision diameter) so physics REFINES a non-overlapping layout instead of failing to explode a pile from the origin. - Force tuning (physicsConfig): centering near-off (tiny containment only), stronger/longer-range charge, collision strength 1 + 4 iterations, faster cool-down + heavier damping → dense graphs settle to ZERO card overlaps and come fully to rest. Verified: a 90-node graph → 0 overlaps, sim stops ~13s. - Camera centers on the graph on load AND graph change (was keyed on hasNodes only with one stale global transform) — covers login, graph switch, drill-in. - Edge labels: a forced de-overlap pass runs at settle and for pinned graphs (which don't tick), so labels start de-overlapped. - Metrics (window.__layoutMetrics): atRest, alpha, TRUE card-overlap pairs, proximity pairs, label overlaps, settle time, drift — for studying the behaviour skeptically. window.__organizeGraph triggers a reflow. - New tests/diagnostics/physics-settle.spec.ts asserts: settles → 0 overlaps → fully stops, no drift. THE GATE 5/5 (grow flow unaffected by the tuning); web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) * Update physicsConfig unit test to the PR-A tuned values The defaults test pinned the old tuning (charge -60, velocityDecay 0.65, collision 0.85); PR-A intentionally retuned for a one-shot non-overlapping settle. Update the asserted values to match. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 203 ++++++++++++------ .../src/lib/__tests__/physicsConfig.test.ts | 11 +- packages/web/src/lib/physicsConfig.ts | 16 +- tests/diagnostics/physics-settle.spec.ts | 100 +++++++++ 4 files changed, 261 insertions(+), 69 deletions(-) create mode 100644 tests/diagnostics/physics-settle.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 5fad9634..040a7cbc 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -1208,14 +1208,17 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap let x = item.positionX; let y = item.positionY; - // If node has never been positioned (0,0) and has no connections, place it on periphery - if (!isPlaced && !hasConnections) { - const angle = (index / validatedNodes.length) * 2 * Math.PI; - const radius = Math.min(window.innerWidth, window.innerHeight) * 0.4; // Place on outer ring - const centerX = 0; // Start from center - const centerY = 0; - x = centerX + Math.cos(angle) * radius; - y = centerY + Math.sin(angle) * radius; + // Unplaced (never-positioned) nodes start on a CLEAN grid spread sized to + // the node count, spacing > collision diameter (~224) so there are no + // initial overlaps. Physics then REFINES this (links pull connected nodes + // together, collision holds the gap) and settles fast & clean — far + // better than exploding a pile at the origin. Jitter breaks symmetry. + if (!isPlaced) { + const cols = Math.max(1, Math.ceil(Math.sqrt(validatedNodes.length))); + const spacing = 260; + const half = (cols * spacing) / 2; + x = (index % cols) * spacing - half + ((index * 13) % 23) - 11; + y = Math.floor(index / cols) * spacing - half + ((index * 7) % 19) - 9; } const node = { @@ -1730,8 +1733,14 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }); }); - // Gentle restart to settle any property changes - simulation.alpha(0.1).restart(); + // Physics is one-shot: it settles a graph, then stays idle. A routine + // data poll must NOT reheat a fully-placed (frozen) graph — that caused + // perpetual drift. Only nudge the sim if there are still-unsettled + // (unpinned / never-placed) nodes that actually need to find a spot. + const hasUnpinned = (simulation.nodes() as any[]).some((n: any) => n.fx == null || n.fy == null); + if (hasUnpinned) { + simulation.alpha(0.1).restart(); + } console.log('[Graph Debug] Simulation data and DOM elements updated'); }, [nodes, validatedEdges, getNodeDimensions]); @@ -1928,7 +1937,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // never-placed nodes are seeded near center and left free to flow. // (This block used to null every node's fx/fy unconditionally, which is // why arrangements never survived a reload — the real drift bug.) - nodes.forEach((node: any) => { + const spreadCols = Math.max(1, Math.ceil(Math.sqrt(nodes.length))); + const spreadSpacing = 260; // > collision diameter so the spread has no overlaps + const spreadHalf = (spreadCols * spreadSpacing) / 2; + nodes.forEach((node: any, i: number) => { node.userPreferredPosition = null; node.userPreferenceVector = null; const placed = !(((node.positionX ?? 0) === 0) && ((node.positionY ?? 0) === 0)); @@ -1942,8 +1954,10 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap node.userPinned = false; node.fx = null; node.fy = null; - if (!node.x) node.x = centerX + (Math.random() - 0.5) * 100; - if (!node.y) node.y = centerY + (Math.random() - 0.5) * 100; + // Clean grid spread (not a random pile) so physics refines from a + // non-overlapping start. + if (!node.x) node.x = (i % spreadCols) * spreadSpacing - spreadHalf + ((i * 13) % 23) - 11; + if (!node.y) node.y = Math.floor(i / spreadCols) * spreadSpacing - spreadHalf + ((i * 7) % 19) - 9; } }); @@ -3524,7 +3538,7 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }); let labelAvoidCounter = 0; - const updateEdgePositions = () => { + const updateEdgePositions = (forceAvoid = false) => { // Border-to-border anchors: the edge starts/ends where the center line // crosses each card's border, not at the buried center. Computed once per // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor @@ -3563,7 +3577,9 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // slidable by the user (d.labelT persists because the data merge keeps // edge object identity stable; d.labelTUser pins a manual slide). labelAvoidCounter++; - const runAvoidance = simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0; + // forceAvoid lets a one-shot caller (layout settle / pinned graphs that + // don't tick) run a full label de-overlap pass on demand. + const runAvoidance = forceAvoid || (simulation.alpha() < 0.1 && labelAvoidCounter % 15 === 0); const obstacles = runAvoidance ? (simulation.nodes() as any[]).map((n: any) => { const dims = getNodeDimensions(n); @@ -3763,14 +3779,27 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // unpinned (new / never-placed) nodes to lay out; an already-arranged // graph loads pinned and stays put (snapshot-authoritative). const hasUnpinnedNodes = (simulation.nodes() as any[]).some((n: any) => n.fx == null || n.fy == null); + if (hasUnpinnedNodes) { + // Mark the start of a one-shot layout so we can report how long it took + // the physics to settle (a metric for studying the behavior). + layoutStartRef.current = performance.now(); + lastSettleMsRef.current = null; + } simulation .alpha(hasUnpinnedNodes ? DEFAULT_PHYSICS.alpha.loadEnergy : 0) .alphaDecay(0.015) .restart(); - // When the layout settles, persist it so the arrangement is durable - // across reloads (covers physics-laid-out graphs the user never dragged). - simulation.on('end.persist', () => persistAllPositions()); + // When the layout settles: record settle time, persist the arrangement so + // it's durable across reloads, run a final edge-label de-overlap pass, and + // center the camera. After this the simulation is idle (one-shot physics). + simulation.on('end.persist', () => { + if (layoutStartRef.current != null && lastSettleMsRef.current == null) { + lastSettleMsRef.current = Math.round(performance.now() - layoutStartRef.current); + } + persistAllPositions(); + runLabelAvoidanceRef.current?.(); + }); // Add method to restart collision detection (simulation as any).restartCollisions = () => { @@ -3779,8 +3808,19 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap simulation.alphaTarget(0); }, 2000); }; + + // Expose a one-shot edge-label de-overlap pass + run one shortly after init. + // Fully-pinned graphs don't tick, so without this their labels would stay at + // the default midpoint and could overlap → clean starting positions need it. + runLabelAvoidanceRef.current = () => updateEdgePositions(true); + setTimeout(() => updateEdgePositions(true), 500); }, [nodes, validatedEdges, handleNodeClick, initializeEmptyVisualization]); // Include handleNodeClick to get fresh connection state + // One-shot layout instrumentation + the forced label-avoidance hook. + const layoutStartRef = useRef(null); + const lastSettleMsRef = useRef(null); + const runLabelAvoidanceRef = useRef<(() => void) | null>(null); + // Store simulation reference for resize handling const simulationRef = useRef | null>(null); const zoomBehaviorRef = useRef | null>(null); @@ -3941,17 +3981,21 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // authoritative snapshot. const resetLayout = useCallback(() => { layoutReflowingRef.current = true; + // Unpin + mark unplaced; initializeVisualization will lay the unplaced + // nodes out on a CLEAN spread grid (see getUnplacedSpread) so physics + // REFINES a non-overlapping start instead of trying to explode a pile. nodes.forEach((node: any) => { node.userPinned = false; node.userPreferredPosition = null; node.userPreferenceVector = null; node.fx = null; node.fy = null; - // Treat as unplaced so init/merge won't re-pin to the old spot node.positionX = 0; node.positionY = 0; node.targetX = null; node.targetY = null; + node.x = 0; + node.y = 0; }); initializeVisualization(); setTimeout(() => { @@ -3961,51 +4005,88 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap }, 2500); }, [nodes, initializeVisualization, fitViewToNodes, persistAllPositions]); - // Auto-fit view when component first mounts with nodes - using stable dependency - const hasNodes = nodes.length > 0; + // Comprehensive layout metrics for studying the physics behaviour skeptically: + // is the simulation actually idle (not silently reheating), do node cards + // overlap, do edge labels overlap, how long did the last layout take to + // settle, plus the live drift sample. Exposed for the diagnostic + console. useEffect(() => { - if (hasNodes && svgRef.current) { - // Check if this is the initial load (no previous transform stored) - const hasStoredTransform = sessionStorage.getItem('graphViewTransform'); - if (!hasStoredTransform) { - // First time loading - auto fit after simulation settles - const timer = setTimeout(() => { - fitViewToNodes(); - // Store the fitted transform - if (svgRef.current) { - const svg = d3.select(svgRef.current); - const transform = d3.zoomTransform(svg.node()!); - sessionStorage.setItem('graphViewTransform', JSON.stringify({ - x: transform.x, - y: transform.y, - k: transform.k - })); - } - }, 1500); - return () => clearTimeout(timer); - } else { - // Restore previous transform - try { - const saved = JSON.parse(hasStoredTransform); - const timer = setTimeout(() => { - if (svgRef.current) { - const svg = d3.select(svgRef.current); - const transform = d3.zoomIdentity.translate(saved.x, saved.y).scale(saved.k); - svg.call(d3.zoom().transform as any, transform); - } - }, 500); - return () => clearTimeout(timer); - } catch (e) { - // If stored transform is invalid, auto-fit - const timer = setTimeout(() => { - fitViewToNodes(); - }, 1500); - return () => clearTimeout(timer); + (window as any).__organizeGraph = () => resetLayout(); + (window as any).__layoutMetrics = () => { + const sim = simulationRef.current; + if (!sim) return null; + const ns = sim.nodes() as any[]; + // TRUE visual overlap = the node CARD rectangles intersect (AABB). The + // collision radius is the half-diagonal, which over-counts side-by-side + // cards that don't actually overlap — this metric measures the real pile. + let overlapPairs = 0; + let maxOverlap = 0; + let proximityPairs = 0; // closer than collision radius (soft crowding) + const dims = ns.map((n) => getNodeDimensions(n)); + for (let i = 0; i < ns.length; i++) { + const a = ns[i]; + const da = dims[i]; + const ra = collisionRadius(da); + for (let j = i + 1; j < ns.length; j++) { + const b = ns[j]; + const db = dims[j]; + const dx = Math.abs((a.x || 0) - (b.x || 0)); + const dy = Math.abs((a.y || 0) - (b.y || 0)); + const ox = (da.width + db.width) / 2 - dx; + const oy = (da.height + db.height) / 2 - dy; + if (ox > 0 && oy > 0) { overlapPairs++; if (Math.min(ox, oy) > maxOverlap) maxOverlap = Math.min(ox, oy); } + if (Math.hypot(dx, dy) < ra + collisionRadius(db)) proximityPairs++; } } - } - return undefined; - }, [hasNodes]); // Removed fitViewToNodes dependency to prevent camera jumps + const labelRects = Array.from(document.querySelectorAll('.graph-container svg .edge-label-group')) + .map((g) => (g as SVGGElement).getBoundingClientRect()) + .filter((r) => r.width > 0 && r.height > 0); + let labelOverlaps = 0; + for (let i = 0; i < labelRects.length; i++) { + for (let j = i + 1; j < labelRects.length; j++) { + const a = labelRects[i]; + const b = labelRects[j]; + if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) labelOverlaps++; + } + } + const alpha = sim.alpha(); + // The sim stops ticking once alpha drops past alphaMin (~0.001); at that + // point nodes are frozen. (The drift field below is sampled in the tick + // loop, so it goes STALE after the sim stops — use atRest, not drift, to + // judge "has it stopped moving".) + const atRest = alpha <= 0.0015; + return { + simRunning: !atRest, + atRest, + alpha: Math.round(alpha * 10000) / 10000, + lastSettleMs: lastSettleMsRef.current, + nodeCount: ns.length, + pinnedCount: ns.filter((n: any) => n.fx != null).length, + edgeCount: validatedEdges.length, + overlappingNodePairs: overlapPairs, + maxNodeOverlapPx: Math.round(maxOverlap), + proximityPairs, + labelCount: labelRects.length, + overlappingLabelPairs: labelOverlaps, + drift: (window as any).__graphPerf?.spatial ?? null, + }; + }; + return () => { + delete (window as any).__layoutMetrics; + delete (window as any).__organizeGraph; + }; + }, [getNodeDimensions, validatedEdges, resetLayout]); + + // Center the camera on the graph whenever it loads or CHANGES (login, graph + // switch, drill-in / ascend). Keyed on the graph id — the old effect keyed on + // hasNodes only and restored one global transform, so it never recentered on + // a graph change. We wait briefly for the one-shot layout to settle, then fit. + const hasNodes = nodes.length > 0; + const currentGraphId = currentGraph?.id; + useEffect(() => { + if (!hasNodes || !svgRef.current) return undefined; + const timer = setTimeout(() => fitViewToNodes(), 1500); + return () => clearTimeout(timer); + }, [hasNodes, currentGraphId, fitViewToNodes]); // Expose reset function to parent component useEffect(() => { diff --git a/packages/web/src/lib/__tests__/physicsConfig.test.ts b/packages/web/src/lib/__tests__/physicsConfig.test.ts index 37508f86..2d1830ec 100644 --- a/packages/web/src/lib/__tests__/physicsConfig.test.ts +++ b/packages/web/src/lib/__tests__/physicsConfig.test.ts @@ -10,10 +10,13 @@ import { describe('physicsConfig defaults', () => { it('matches the production-tuned values', () => { - expect(DEFAULT_PHYSICS.charge.strength).toBe(-60); - expect(DEFAULT_PHYSICS.alpha.velocityDecay).toBe(0.65); + // Tuned for one-shot, non-overlapping settle (PR-A): centering near-off so + // dense graphs expand until collision is satisfied, stronger collision, + // faster cool-down + damping so the sim reaches rest quickly. + expect(DEFAULT_PHYSICS.charge.strength).toBe(-70); + expect(DEFAULT_PHYSICS.alpha.velocityDecay).toBe(0.78); expect(DEFAULT_PHYSICS.alpha.restTarget).toBe(0); // fully stops when settled - expect(DEFAULT_PHYSICS.collision.strength).toBe(0.85); + expect(DEFAULT_PHYSICS.collision.strength).toBe(1); }); }); @@ -52,6 +55,6 @@ describe('withOverrides (live tuning)', () => { }); it('does not mutate the defaults', () => { withOverrides({ charge: { strength: 999 } }); - expect(DEFAULT_PHYSICS.charge.strength).toBe(-60); + expect(DEFAULT_PHYSICS.charge.strength).toBe(-70); }); }); diff --git a/packages/web/src/lib/physicsConfig.ts b/packages/web/src/lib/physicsConfig.ts index 27044b3f..ece4b247 100644 --- a/packages/web/src/lib/physicsConfig.ts +++ b/packages/web/src/lib/physicsConfig.ts @@ -34,12 +34,20 @@ export interface PhysicsConfig { /** Current production defaults (extracted verbatim from the component). */ export const DEFAULT_PHYSICS: PhysicsConfig = { - charge: { strength: -60, distanceMax: 200 }, + charge: { strength: -70, distanceMax: 350 }, link: { minDistanceFactor: 0.4, maxDistanceFactor: 0.6, strengthNormal: 0.2, strengthStretched: 0.5 }, - centering: { center: 0.01, axis: 0.002 }, - collision: { paddingPx: 12, strength: 0.85, iterations: 2 }, + // Only a TINY inward pull: a strong centering compresses dense graphs into a + // core that collision can't separate (the force equilibrium ends up + // overlapping). A tiny value just contains the layout so it converges instead + // of slowly expanding, while strong collision still spreads it to a clean, + // non-overlapping settle. The camera fit handles actual centering. + centering: { center: 0.0015, axis: 0.0003 }, + collision: { paddingPx: 12, strength: 1, iterations: 4 }, hierarchy: { distance: 250, strength: 0.05 }, - alpha: { loadEnergy: 0.6, decay: 0.015, velocityDecay: 0.65, restTarget: 0 }, + // Faster cool-down + heavier damping so a one-shot layout reaches REST + // quickly (it stops when alpha < alphaMin) instead of micro-drifting for + // many seconds — important on big graphs where low fps stretches the settle. + alpha: { loadEnergy: 0.7, decay: 0.03, velocityDecay: 0.78, restTarget: 0 }, reheat: { drag: 0.1, dragNeighbors: 0.2, collisions: 0.3, resize: 0.3 }, }; diff --git a/tests/diagnostics/physics-settle.spec.ts b/tests/diagnostics/physics-settle.spec.ts new file mode 100644 index 00000000..ea29827b --- /dev/null +++ b/tests/diagnostics/physics-settle.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Physics-lifecycle diagnostic — proves the one-shot model: a graph settles, + * then the simulation goes IDLE and stays idle (no continuous drift), and the + * manual Organize reflow re-settles a piled graph. Captures the full metric + * time series (window.__layoutMetrics) so the behaviour can be studied deeply + * and skeptically. Report-only; needs the hierarchy demo seeded. + */ +const OUT = path.resolve(process.cwd(), 'test-artifacts/physics'); +// A mid-size sub-graph (varies the load); change via env if desired. +const GRAPH_ID = process.env.PHYS_GRAPH_ID || 'subgraph-power-shared'; + +async function openGraph(page: Page, id: string) { + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, id); + await page.reload(); + await page.waitForTimeout(6000); +} + +async function metrics(page: Page) { + return page.evaluate(() => (window as any).__layoutMetrics?.() ?? null); +} + +async function sampleSeries(page: Page, label: string, seconds: number) { + const series: any[] = []; + for (let i = 0; i < seconds; i++) { + series.push({ t: i, ...(await metrics(page)) }); + await page.waitForTimeout(1000); + } + // eslint-disable-next-line no-console + console.log(`[physics ${label}] ` + JSON.stringify(series[series.length - 1])); + return series; +} + +test.describe('physics settle diagnostic @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + + test('graph settles then stays idle (no drift); Organize reflows', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openGraph(page, GRAPH_ID); + + // After the initial settle, sample for 8s: the sim must be IDLE and nodes + // must NOT keep moving (the perpetual-reheat bug would show movingNodes>0). + const settleSeries = await sampleSeries(page, 'settled', 8); + const last4 = settleSeries.slice(-4); + const maxMoving = Math.max(...last4.map((s) => s.drift?.movingNodes ?? 0)); + const anyRunning = last4.some((s) => s.simRunning); + + // Organize: reflow a (possibly piled) graph with physics, then it settles + // and STOPS. Poll the live signals (alpha-based atRest + true overlap) — the + // drift field goes stale once the sim stops ticking, so don't rely on it. + await page.evaluate(() => (window as any).__organizeGraph?.()); + const organizeSeries: any[] = []; + let lastAfter: any = null; + for (let i = 0; i < 30; i++) { + await page.waitForTimeout(1000); + lastAfter = await metrics(page); + organizeSeries.push({ t: i, ...lastAfter }); + if (lastAfter?.atRest && i >= 2) break; // settled + stopped + } + // eslint-disable-next-line no-console + console.log('[physics after-organize] ' + JSON.stringify(lastAfter)); + + const report = { + graphId: GRAPH_ID, + settleSeries, + organizeSeries, + summary: { + idleAfterSettle: !anyRunning, + maxMovingNodesLast4s: maxMoving, + overlapBefore: settleSeries[settleSeries.length - 1]?.overlappingNodePairs, + overlapAfterOrganize: lastAfter?.overlappingNodePairs, + labelOverlapAfter: lastAfter?.overlappingLabelPairs, + settledAndStopped: lastAfter?.atRest, + settleSeconds: organizeSeries.length, + lastSettleMs: lastAfter?.lastSettleMs, + }, + }; + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + // eslint-disable-next-line no-console + console.log('[physics] summary ' + JSON.stringify(report.summary)); + + // No continuous drift after the initial settle (the poll-reheat fix). + expect(maxMoving, 'no continuous drift: nodes stop moving after settle').toBeLessThanOrEqual(1); + expect(anyRunning, 'simulation is idle after settle (not reheating)').toBe(false); + // Organize lays the graph out CLEAN (zero true card overlaps) and the sim + // comes to a full STOP (one-shot physics, then disabled). + expect(lastAfter.overlappingNodePairs, 'Organize settles to zero card overlaps').toBe(0); + expect(lastAfter.atRest, 'simulation reaches rest (stops) after Organize').toBe(true); + }); +}); From ee976568eb3491c2ea81906a0e3071556848c49a Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 12:03:28 -0700 Subject: [PATCH 14/24] Seed demo hierarchy with clean (non-overlapping) spacing + force reseed (PR-B) (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo sub-graphs were laid out on a 140px grid — far under the node-card collision diameter (~224px) — so they loaded as overlapping "garbage piles". - hierarchyDemo.ts: sub-graph grid spacing 140 -> 260 (> collision diameter), so the seeded layout is non-overlapping on load with zero physics cost (matters for the 1000-node showcase). Nodes stay placed/pinned (no drift). - create-hierarchy-demo --force: deletes the existing demo (edges -> work items -> graphs, in order, so no orphan edges) and recreates it, so the spacing fix can be applied to an already-seeded DB. Verified: reseeded (17 graphs / 2911 items / 4073 edges); a 90-node sub-graph has 0 overlapping card pairs; THE GATE 5/5. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/scripts/create-hierarchy-demo.ts | 10 ++++-- packages/server/src/services/hierarchyDemo.ts | 36 ++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/server/src/scripts/create-hierarchy-demo.ts b/packages/server/src/scripts/create-hierarchy-demo.ts index 4b76e971..a2bdd691 100644 --- a/packages/server/src/scripts/create-hierarchy-demo.ts +++ b/packages/server/src/scripts/create-hierarchy-demo.ts @@ -1,10 +1,14 @@ import { driver } from '../db.js'; -import { createHierarchyDemo, hierarchyDemoExists } from '../services/hierarchyDemo.js'; +import { createHierarchyDemo, hierarchyDemoExists, deleteHierarchyDemo } from '../services/hierarchyDemo.js'; async function ensureHierarchyDemo() { - console.log('🏗️ Ensuring hierarchical "graphs of graphs" demo exists...\n'); + const force = process.argv.includes('--force'); + console.log(`🏗️ Ensuring hierarchical "graphs of graphs" demo exists${force ? ' (force reseed)' : ''}...\n`); try { - if (await hierarchyDemoExists(driver)) { + if (force && (await hierarchyDemoExists(driver))) { + await deleteHierarchyDemo(driver); + } + if (!force && (await hierarchyDemoExists(driver))) { console.log('⏭️ Hierarchy demo already exists - skipping creation\n'); } else { const r = await createHierarchyDemo(driver); diff --git a/packages/server/src/services/hierarchyDemo.ts b/packages/server/src/services/hierarchyDemo.ts index 53f58c54..16f634d4 100644 --- a/packages/server/src/services/hierarchyDemo.ts +++ b/packages/server/src/services/hierarchyDemo.ts @@ -57,7 +57,10 @@ function gridPositions(n: number, spacing: number): Array<{ x: number; y: number /** A connected sub-graph: backbone chain + deterministic forward links (~1.4x). */ function buildSubgraph(graphId: string, size: number): { nodes: NodeRow[]; edges: EdgeRow[] } { - const pos = gridPositions(size, 140); + // Spacing must exceed the node-card collision diameter (~224px) so the seeded + // layout is non-overlapping on load — a clean starting state with no physics + // needed. (140 produced "garbage piles".) + const pos = gridPositions(size, 260); const nodes: NodeRow[] = Array.from({ length: size }, (_, i) => { const type = NODE_TYPES[(i * 7) % NODE_TYPES.length]; return { @@ -91,6 +94,37 @@ function chunk(arr: T[], n: number): T[][] { return out; } +/** Demo graph ids (overview + every sub-graph) — for a clean force-reseed. */ +function demoGraphIds(): string[] { + return [OVERVIEW_GRAPH_ID, ...SUBSYSTEMS.map((s) => `subgraph-${s.key}-shared`)]; +} + +/** Tear down the demo (edges → work items → graphs, in that order so we never + * leave orphan edges). Used by the --force reseed path. */ +export async function deleteHierarchyDemo(driver: Driver): Promise { + const session = driver.session(); + const ids = demoGraphIds(); + try { + await session.run( + `UNWIND $ids AS gid + MATCH (g:Graph {id: gid})<-[:BELONGS_TO]-(w:WorkItem) + OPTIONAL MATCH (w)<-[:EDGE_SOURCE|EDGE_TARGET]-(e:Edge) + DETACH DELETE e`, + { ids } + ); + await session.run( + `UNWIND $ids AS gid + MATCH (g:Graph {id: gid})<-[:BELONGS_TO]-(w:WorkItem) + DETACH DELETE w`, + { ids } + ); + await session.run(`UNWIND $ids AS gid MATCH (g:Graph {id: gid}) DETACH DELETE g`, { ids }); + console.log(`🗑️ Removed previous hierarchy demo (${ids.length} graphs)`); + } finally { + await session.close(); + } +} + export async function hierarchyDemoExists(driver: Driver): Promise { const session = driver.session(); try { From 6a255ecce77c87b709b2b1986e636305d454b731 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 12:24:44 -0700 Subject: [PATCH 15/24] Minimap wheel + pinch zoom (PR-C) (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimap only supported click-to-navigate. Add wheel and pinch zoom so you can zoom from the minimap as well as the main view. - MiniMap.tsx: native (passive:false) wheel + 2-touch pinch listeners on the minimap svg → map the gesture point (minimap px → graph coords via the existing inverse transform) and drive the main view via window.miniMapZoom. Listener attaches when the svg actually appears (the minimap renders a "No nodes yet" div first), via a nodes-present effect dep. - InteractiveGraphVisualization.tsx: window.miniMapZoom(graphX, graphY, targetK) — zoom the main view to targetK (clamped to the [0.1,4] scaleExtent) centered on the graph point, applied through the shared zoom behavior. Verified: tests/diagnostics/minimap-zoom.spec.ts — wheel-in increases and wheel-out decreases the main view scale (0.30 → 0.49 → 0.25). Web typecheck clean. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 13 +++++ packages/web/src/components/MiniMap.tsx | 57 ++++++++++++++++++ tests/diagnostics/minimap-zoom.spec.ts | 58 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 tests/diagnostics/minimap-zoom.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 040a7cbc..70bfe946 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3888,8 +3888,21 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k); svg.transition().duration(350).call(zoomBehaviorRef.current.transform as any, t); }; + // Mini-map wheel/pinch → zoom the main view to a target scale, centered on + // the gesture's graph point. Clamped to the same scaleExtent as the main + // zoom; applied via the shared zoom behavior so state + handlers stay in sync. + (window as any).miniMapZoom = (graphX: number, graphY: number, targetK: number) => { + if (!svgRef.current || !containerRef.current || !zoomBehaviorRef.current) return; + const svg = d3.select(svgRef.current); + const k = Math.max(0.1, Math.min(4, targetK)); + const w = containerRef.current.clientWidth; + const h = containerRef.current.clientHeight; + const t = d3.zoomIdentity.translate(w / 2 - graphX * k, h / 2 - graphY * k).scale(k); + svg.transition().duration(120).call(zoomBehaviorRef.current.transform as any, t); + }; return () => { delete (window as any).miniMapNavigate; + delete (window as any).miniMapZoom; }; }, []); diff --git a/packages/web/src/components/MiniMap.tsx b/packages/web/src/components/MiniMap.tsx index bd78d0cf..9307efd4 100644 --- a/packages/web/src/components/MiniMap.tsx +++ b/packages/web/src/components/MiniMap.tsx @@ -25,6 +25,61 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? const [nodes, setNodes] = useState>({}); const [viewport, setViewport] = useState(null); const nodeTypesRef = useRef>({}); + // Live minimap-px <-> graph-coord conversion params, updated each render so + // the native (non-passive) wheel/touch listeners can map a gesture point to + // a graph point and drive the main view's zoom. + const geomRef = useRef({ minX: 0, minY: 0, offsetX: 0, offsetY: 0, scale: 1, k: 1 }); + const svgElRef = useRef(null); + + // Wheel + pinch on the minimap zoom the MAIN view (centered on the gesture + // point). Attached natively with passive:false so we can preventDefault and + // stop the page from scrolling while zooming the map. + useEffect(() => { + const el = svgElRef.current; + if (!el) return undefined; + const toGraph = (clientX: number, clientY: number) => { + const r = el.getBoundingClientRect(); + const g = geomRef.current; + return { + x: g.minX + (clientX - r.left - g.offsetX) / g.scale, + y: g.minY + (clientY - r.top - g.offsetY) / g.scale, + }; + }; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const p = toGraph(e.clientX, e.clientY); + const factor = e.deltaY < 0 ? 1.18 : 1 / 1.18; + (window as any).miniMapZoom?.(p.x, p.y, geomRef.current.k * factor); + }; + let pinchDist0 = 0; + let pinchK0 = 1; + const dist = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); + const mid = (t: TouchList) => ({ x: (t[0].clientX + t[1].clientX) / 2, y: (t[0].clientY + t[1].clientY) / 2 }); + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { pinchDist0 = dist(e.touches); pinchK0 = geomRef.current.k; } + }; + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length === 2 && pinchDist0 > 0) { + e.preventDefault(); + const m = mid(e.touches); + const p = toGraph(m.x, m.y); + (window as any).miniMapZoom?.(p.x, p.y, pinchK0 * (dist(e.touches) / pinchDist0)); + } + }; + const onTouchEnd = () => { pinchDist0 = 0; }; + el.addEventListener('wheel', onWheel, { passive: false }); + el.addEventListener('touchstart', onTouchStart, { passive: false }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd); + return () => { + el.removeEventListener('wheel', onWheel); + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + // Re-run when the svg appears: the minimap renders a "No nodes yet" div + // first (svg ref null), then the once positions arrive — attach then. + }, [Object.keys(nodes).length > 0]); useEffect(() => { (window as any).updateMiniMapPositions = (positions: Record) => { @@ -75,6 +130,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? const scale = Math.min(width / spanX, height / spanY); const offsetX = (width - spanX * scale) / 2; const offsetY = (height - spanY * scale) / 2; + geomRef.current = { minX, minY, offsetX, offsetY, scale, k: viewport?.k || 1 }; const toMini = useCallback( (gx: number, gy: number) => ({ @@ -117,6 +173,7 @@ export function MiniMap({ width = 192, height = 128 }: { width?: number; height? return ( { + return page.evaluate(() => { + const g = document.querySelector('.graph-container svg .main-graph-group') as SVGGElement | null; + const t = g?.getAttribute('transform') || ''; + const m = /scale\(([-0-9.]+)/.exec(t); + return m ? parseFloat(m[1]) : 1; + }); +} + +test.describe('minimap zoom diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('wheel on the minimap changes the main view zoom', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await page.evaluate((gid) => { + localStorage.setItem('currentGraphId', gid); + localStorage.setItem('graphdone.quality.override', 'HIGH'); + }, GRAPH_ID); + await page.reload(); + await page.waitForTimeout(6000); + + const mini = page.locator('[data-testid="mini-map"]'); + await mini.waitFor({ timeout: 15000 }); + const box = await mini.boundingBox(); + expect(box, 'minimap is visible').not.toBeNull(); + + const cx = box!.x + box!.width / 2; + const cy = box!.y + box!.height / 2; + await page.mouse.move(cx, cy); + const before = await mainScale(page); + + // Zoom IN: real (trusted) wheel events at the minimap centre. + for (let i = 0; i < 3; i++) { await page.mouse.wheel(0, -120); await page.waitForTimeout(250); } + await page.waitForTimeout(400); + const afterIn = await mainScale(page); + + // Zoom OUT. + for (let i = 0; i < 4; i++) { await page.mouse.wheel(0, 120); await page.waitForTimeout(250); } + await page.waitForTimeout(400); + const afterOut = await mainScale(page); + + // eslint-disable-next-line no-console + console.log(`[minimap-zoom] before=${before} afterIn=${afterIn} afterOut=${afterOut}`); + expect(afterIn, 'wheel-in increases main zoom').toBeGreaterThan(before); + expect(afterOut, 'wheel-out decreases zoom below the zoomed-in level').toBeLessThan(afterIn); + }); +}); From 5eea7cb043bfb2c4b9cf2e66e55f721dad8422f6 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 12:46:23 -0700 Subject: [PATCH 16/24] Expandable project-explorer graph tree (PR-D) (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph selector showed only top-level graphs; sub-graphs (the hierarchy) were invisible there — reachable only by drilling into the canvas. Now a parent graph expands in place to reveal its sub-graphs. - GraphSelector.tsx: a graph row with children (graph.children, from the context's existing buildHierarchy tree) gets an expand chevron; expanding it lists the sub-graphs (indented, selectable, with node/edge counts). Per-graph expand state; reuses the existing ChevronRight rotate pattern. The 'system' folder is expanded by default so the seeded "System Overview" is discoverable. Verified: tests/diagnostics/explorer-tree.spec.ts — expand "System Overview" → "Compute Core" sub-graph row appears → selecting it switches currentGraphId to subgraph-compute-shared. Web typecheck clean. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web/src/components/GraphSelector.tsx | 47 ++++++++++++++++++- tests/diagnostics/explorer-tree.spec.ts | 39 +++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/diagnostics/explorer-tree.spec.ts diff --git a/packages/web/src/components/GraphSelector.tsx b/packages/web/src/components/GraphSelector.tsx index 8bad8734..978f485a 100644 --- a/packages/web/src/components/GraphSelector.tsx +++ b/packages/web/src/components/GraphSelector.tsx @@ -16,7 +16,16 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph }: Gra const { currentGraph, graphHierarchy, selectGraph } = useGraph(); const { currentTeam, currentUser } = useAuth(); const [isOpen, setIsOpen] = useState(false); - const [expandedFolders, setExpandedFolders] = useState>(new Set(['team', 'personal'])); + const [expandedFolders, setExpandedFolders] = useState>(new Set(['team', 'personal', 'system'])); + // Which parent graphs are expanded to show their sub-graphs (the hierarchy). + const [expandedGraphs, setExpandedGraphs] = useState>(new Set()); + const toggleGraphExpand = (id: string) => { + setExpandedGraphs((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; const [buttonPosition, setButtonPosition] = useState<{ top: number; left: number; width: number } | null>(null); const [hoveredTooltip, setHoveredTooltip] = useState<{ message: string; x: number; y: number } | null>(null); const dropdownRef = useRef(null); @@ -310,7 +319,17 @@ export function GraphSelector({ onCreateGraph, onEditGraph, onDeleteGraph }: Gra {isExpanded && (
{graphs.map((graph) => ( -
+
+
+ {graph.children && graph.children.length > 0 && ( + + )}
+ {graph.children && graph.children.length > 0 && expandedGraphs.has(graph.id) && ( +
+ {graph.children.map((child: any) => ( + + ))} +
+ )} +
))}
)} diff --git a/tests/diagnostics/explorer-tree.spec.ts b/tests/diagnostics/explorer-tree.spec.ts new file mode 100644 index 00000000..5760243b --- /dev/null +++ b/tests/diagnostics/explorer-tree.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Project-explorer hierarchy: the graph selector expands a parent graph + * ("System Overview") to reveal its sub-graphs, and selecting a sub-graph + * switches the current graph. Needs the hierarchy demo seeded. + */ +test.describe('explorer tree diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('expand System Overview → sub-graphs appear → select one switches graph', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(2500); + + // Open the graph selector. + await page.locator('[data-testid="graph-selector"]').click(); + await page.waitForTimeout(500); + + // Expand the "System Overview" parent (its chevron has a "sub-graphs" title). + const expander = page.locator('button[title$="sub-graphs"]').first(); + await expander.waitFor({ timeout: 10000 }); + await expander.click(); + await page.waitForTimeout(500); + + // A sub-graph row should now be visible. + const child = page.getByRole('button', { name: /Compute Core/ }); + await expect(child.first(), 'sub-graph row appears under the overview').toBeVisible(); + + // Selecting it switches the current graph. + await child.first().click(); + await page.waitForTimeout(3000); + const current = await page.evaluate(() => localStorage.getItem('currentGraphId')); + // eslint-disable-next-line no-console + console.log('[explorer-tree] currentGraphId after select = ' + current); + expect(current, 'selecting a sub-graph switches to it').toBe('subgraph-compute-shared'); + }); +}); From 89b0a3a7748a289c975a6ec5a8f86adf111803d2 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 13:41:04 -0700 Subject: [PATCH 17/24] Add NodeContentRenderer: readable markdown + syntax-highlighted code (PR-1) (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the "read contents/diagrams at a useful scale" rework. The app had NO markdown or code rendering — a node's `description` only showed as a plain textarea in a modal. NodeContentRenderer turns it into readable, GitHub-flavored markdown with syntax-highlighted fenced code, at a fixed legible size (independent of canvas zoom). - deps: react-markdown + remark-gfm + react-syntax-highlighter (Prism). - NodeContentRenderer.tsx (default export so callers can React.lazy it → stays out of the main bundle until a node's contents are first viewed). compact flag for the on-canvas peek. Styled for the dark theme; inline vs fenced code, headings, lists, tables, links. Not wired to any view yet — PR-2 (docked inspector) consumes it. Web build + typecheck clean; renderer is lazy so the heavy deps aren't in the main chunk. Co-authored-by: Claude Opus 4.8 (1M context) --- package-lock.json | 1653 ++++++++++++++++- packages/web/package.json | 4 + .../src/components/NodeContentRenderer.tsx | 88 + 3 files changed, 1715 insertions(+), 30 deletions(-) create mode 100644 packages/web/src/components/NodeContentRenderer.tsx diff --git a/package-lock.json b/package-lock.json index 41026237..5be30855 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1367,7 +1367,6 @@ }, "node_modules/@babel/runtime": { "version": "7.28.4", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3373,11 +3372,28 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "4.17.23", "license": "MIT", @@ -3410,6 +3426,15 @@ "version": "7946.0.16", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "license": "MIT" @@ -3432,13 +3457,21 @@ "version": "4.0.2", "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -3525,9 +3558,14 @@ "@types/passport": "*" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", - "devOptional": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -3540,7 +3578,6 @@ }, "node_modules/@types/react": { "version": "18.3.24", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3555,6 +3592,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "dev": true, @@ -3584,6 +3631,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "9.0.8", "license": "MIT" @@ -3827,7 +3880,6 @@ }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", - "dev": true, "license": "ISC" }, "node_modules/@vitejs/plugin-react": { @@ -4470,6 +4522,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -4837,6 +4899,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "4.5.0", "dev": true, @@ -4868,6 +4940,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "1.0.3", "dev": true, @@ -4958,6 +5070,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "7.2.0", "license": "MIT", @@ -5092,7 +5214,6 @@ }, "node_modules/csstype": { "version": "3.1.3", - "devOptional": true, "license": "MIT" }, "node_modules/d3": { @@ -5529,6 +5650,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "license": "MIT", @@ -5653,6 +5787,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "license": "MIT", @@ -5668,6 +5811,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "license": "Apache-2.0" @@ -6322,6 +6478,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "dev": true, @@ -6497,6 +6663,12 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -6561,6 +6733,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "funding": [ @@ -6718,6 +6903,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "license": "MIT", @@ -7208,6 +7401,91 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "license": "BSD-3-Clause", @@ -7231,6 +7509,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "license": "BSD-2-Clause", @@ -7379,6 +7667,12 @@ "version": "1.3.8", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "dev": true, @@ -7413,6 +7707,30 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.2.0", "dev": true, @@ -7555,6 +7873,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "license": "MIT", @@ -7610,6 +7938,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "license": "MIT", @@ -7674,6 +8012,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "dev": true, @@ -8189,6 +8539,16 @@ "version": "4.0.0", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -8207,6 +8567,20 @@ "get-func-name": "^2.0.1" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "7.18.3", "license": "ISC", @@ -8334,6 +8708,16 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -8341,44 +8725,889 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "0.3.0", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", + "engines": { + "node": ">=12" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/methods": { + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { "version": "1.1.2", "license": "MIT", - "engines": { - "node": ">= 0.6" + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "license": "MIT", @@ -9056,6 +10285,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "7.3.0", "dev": true, @@ -9567,6 +10821,15 @@ "dev": true, "license": "MIT" }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "license": "ISC", @@ -9601,6 +10864,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -9761,6 +11034,33 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "dev": true, @@ -9797,6 +11097,26 @@ "react-dom": ">=16.8" } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "license": "MIT", @@ -9859,6 +11179,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "dev": true, @@ -9894,6 +11230,72 @@ } } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -10476,6 +11878,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sqlite3": { "version": "5.1.7", "hasInstallScript": true, @@ -10658,6 +12070,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "license": "MIT", @@ -10740,6 +12166,24 @@ ], "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sucrase": { "version": "3.35.0", "license": "MIT", @@ -11070,6 +12514,26 @@ "node": ">=18" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "dev": true, @@ -11393,6 +12857,25 @@ "version": "6.21.0", "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-filename": { "version": "1.1.1", "license": "ISC", @@ -11409,6 +12892,74 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "0.2.0", "dev": true, @@ -11513,6 +13064,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "5.4.20", "dev": true, @@ -12076,6 +13655,16 @@ } } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/core": { "name": "@graphdone/core", "version": "0.3.1-alpha", @@ -13394,7 +14983,10 @@ "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.20.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", "zustand": "^4.4.0" }, @@ -13405,6 +14997,7 @@ "@testing-library/react": "^14.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react": "^4.1.0", diff --git a/packages/web/package.json b/packages/web/package.json index bfb7084e..77a85d78 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,10 @@ "lucide-react": "^0.294.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", "react-router-dom": "^6.20.0", + "react-syntax-highlighter": "^16.1.1", + "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", "zustand": "^4.4.0" }, @@ -36,6 +39,7 @@ "@testing-library/react": "^14.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", "@vitejs/plugin-react": "^4.1.0", diff --git a/packages/web/src/components/NodeContentRenderer.tsx b/packages/web/src/components/NodeContentRenderer.tsx new file mode 100644 index 00000000..603d5012 --- /dev/null +++ b/packages/web/src/components/NodeContentRenderer.tsx @@ -0,0 +1,88 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +/** + * Renders a node's contents (its `description`) as readable, GitHub-flavored + * markdown with syntax-highlighted fenced code blocks — at a fixed legible size, + * independent of the canvas zoom. This is the "file contents / code" face of a + * node. Heavy (markdown + Prism), so it's imported lazily by callers via + * React.lazy so it stays out of the main bundle until a node's contents are + * first viewed. + * + * Default export so `React.lazy(() => import('./NodeContentRenderer'))` works. + */ +interface NodeContentRendererProps { + content: string; + /** Smaller type + tighter spacing for the on-canvas peek panel. */ + compact?: boolean; + className?: string; +} + +export default function NodeContentRenderer({ content, compact = false, className = '' }: NodeContentRendererProps) { + const trimmed = (content ?? '').trim(); + if (!trimmed) { + return
No contents yet.
; + } + + const prose = compact + ? 'text-xs leading-relaxed' + : 'text-sm leading-relaxed'; + + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ href, children }) => {children}, + strong: ({ children }) => {children}, + blockquote: ({ children }) =>
    {children}
    , + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + code(props) { + const { children, className: cn, ...rest } = props as any; + const match = /language-(\w+)/.exec(cn || ''); + const isInline = !(cn || '').includes('language-') && !String(children).includes('\n'); + if (isInline) { + return ( + + {children} + + ); + } + return ( + + {String(children).replace(/\n$/, '')} + + ); + }, + }} + > + {trimmed} +
    +
    + ); +} From 2ed0012ea138ee352825365fc3483e61c7911c19 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 13:51:34 -0700 Subject: [PATCH 18/24] Docked node inspector: Card / Contents / Diagram, decoupled from zoom (PR-2) (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a node opens a docked right-hand inspector with an explicit Card · Contents · Diagram toggle (per-node, session-remembered), each rendered at full legible size regardless of canvas zoom — replacing the old "zoom in to make text readable" anti-pattern. - Contents renders node.description as readable markdown + syntax-highlighted code (lazy NodeContentRenderer). - Diagram draws the node's sub-graph statically from persisted positions (NodeSubgraphPreview), capped to 300 nodes / 600 edges so even the 1000-node Compute Core sub-graph previews instantly; "Open" descends in. - Plain node click now SELECTS (opens inspector) instead of descending; sheet nodes descend via the explicit ⤢ glyph or the inspector's Open button, so a click never navigates you away unexpectedly. - handleClickOutside ignores clicks inside the inspector so its own controls don't deselect the node and close it. - Selection lifted to Workspace via onNodeSelected through SafeGraphVisualization. Verified: tests/diagnostics/node-inspector.spec.ts (Contents/Diagram/Card + legible-when-zoomed-out) and hierarchy-navigation.spec.ts green; THE GATE 5/5. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 29 ++-- packages/web/src/components/NodeInspector.tsx | 134 ++++++++++++++++++ .../src/components/NodeSubgraphPreview.tsx | 115 +++++++++++++++ .../src/components/SafeGraphVisualization.tsx | 11 +- packages/web/src/pages/Workspace.tsx | 13 +- .../diagnostics/hierarchy-navigation.spec.ts | 7 +- tests/diagnostics/node-inspector.spec.ts | 72 ++++++++++ 7 files changed, 365 insertions(+), 16 deletions(-) create mode 100644 packages/web/src/components/NodeInspector.tsx create mode 100644 packages/web/src/components/NodeSubgraphPreview.tsx create mode 100644 tests/diagnostics/node-inspector.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 70bfe946..7b5caf13 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -85,9 +85,12 @@ interface DragState { interface InteractiveGraphVisualizationProps { onResetLayout?: () => void; + /** Notifies the host (Workspace) which node is selected, so a docked + * inspector can show its contents/diagram. Fires null on deselect. */ + onNodeSelected?: (node: WorkItem | null) => void; } -export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGraphVisualizationProps = {}) { +export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: InteractiveGraphVisualizationProps = {}) { const svgRef = useRef(null); const containerRef = useRef(null); const { currentGraph, availableGraphs, descendInto } = useGraph(); @@ -281,6 +284,12 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap const [showUpdateGraphModal, setShowUpdateGraphModal] = useState(false); const [showDeleteGraphModal, setShowDeleteGraphModal] = useState(false); const [selectedNode, setSelectedNode] = useState(null); + // Lift selection to the host (Workspace) for the docked inspector. One effect + // captures every path that changes selectedNode (node click, edit icon, + // background-click deselect) without instrumenting each call site. + useEffect(() => { + onNodeSelected?.(selectedNode); + }, [selectedNode, onNodeSelected]); const lastSelectedNodeRef = useRef(null); // Track last selected node for centering const [selectedEdge, setSelectedEdge] = useState(null); const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined); @@ -963,7 +972,13 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap // Close menus when clicking outside or pressing ESC useEffect(() => { - const handleClickOutside = () => { + const handleClickOutside = (event: MouseEvent) => { + // Clicks inside the docked inspector (a sibling tree) must not deselect + // the node — otherwise its own Card/Contents/Diagram controls close it. + const target = event.target as Element | null; + if (target && target.closest('[data-testid="node-inspector"]')) { + return; + } setNodeMenu(prev => ({ ...prev, visible: false })); setEdgeMenu(prev => ({ ...prev, visible: false })); setEditingEdge(null); // Close inline edge editor @@ -1040,13 +1055,11 @@ export function InteractiveGraphVisualization({ onResetLayout }: InteractiveGrap setIsConnecting(false); setConnectionSource(null); - } else if (node.subgraphId) { - // Altium-style sheet symbol: a plain click descends into its sub-graph. - // (Grow/connect is handled above; drag is suppressed by mousedownNodeRef; - // edit/relationship icons stopPropagation, so this only fires on a plain - // click of a sheet node.) Called via ref to avoid re-binding the handler. - descendIntoRef.current(node.subgraphId); } else { + // A plain click SELECTS the node (opens the inspector). Descending into a + // sheet node's sub-graph is an explicit action — the descend glyph (⤢) on + // the card or the inspector's "Open" — so clicking never navigates you + // away unexpectedly (the user loses context otherwise). // Handle node selection with 2-item ring buffer setSelectedNodes(prev => { const newSet = new Set(prev); diff --git a/packages/web/src/components/NodeInspector.tsx b/packages/web/src/components/NodeInspector.tsx new file mode 100644 index 00000000..475d66b4 --- /dev/null +++ b/packages/web/src/components/NodeInspector.tsx @@ -0,0 +1,134 @@ +import { lazy, Suspense, useState } from 'react'; +import { X, FileText, Network, CreditCard } from 'lucide-react'; +import { useGraph } from '../contexts/GraphContext'; +import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; +import { NodeSubgraphPreview } from './NodeSubgraphPreview'; + +// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's +// contents are first opened. +const NodeContentRenderer = lazy(() => import('./NodeContentRenderer')); + +type Mode = 'card' | 'contents' | 'diagram'; + +interface NodeInspectorProps { + node: any; + onClose: () => void; +} + +/** + * 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. + */ +export function NodeInspector({ node, onClose }: NodeInspectorProps) { + const { descendInto } = useGraph(); + const hasSubgraph = !!node?.subgraphId; + const [modeByNode, setModeByNode] = useState>({}); + const mode: Mode = modeByNode[node.id] ?? (node.description ? 'contents' : 'card'); + const setMode = (m: Mode) => setModeByNode((prev) => ({ ...prev, [node.id]: m })); + + const typeCfg = getTypeConfig(node.type as WorkItemType); + const statusCfg = getStatusConfig(node.status as any); + + return ( +
    + {/* Header */} +
    +
    +
    {typeCfg.label}
    +
    {node.title}
    +
    + +
    + + {/* Mode toggle */} +
    + setMode('card')} icon={} label="Card" /> + setMode('contents')} icon={} label="Contents" /> + setMode('diagram')} icon={} label="Diagram" disabled={!hasSubgraph} title={hasSubgraph ? 'Sub-graph' : 'No sub-graph'} /> +
    + + {/* Body */} +
    + {mode === 'card' && ( +
    + + + {typeof node.priority === 'number' && } + {Array.isArray(node.tags) && node.tags.length > 0 && ( +
    +
    Tags
    +
    + {node.tags.map((t: string) => {t})} +
    +
    + )} + {node.description && ( +
    +
    Description (preview)
    +
    {node.description}
    + +
    + )} +
    + )} + + {mode === 'contents' && ( +
    + Loading…
    }> + + +
    + )} + + {mode === 'diagram' && ( + hasSubgraph ? ( + descendInto(node.subgraphId)} + /> + ) : ( +
    This node has no sub-graph diagram.
    + ) + )} +
    +
    + ); +} + +function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) { + return ( + + ); +} + +function Row({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
    + {label} + {value} +
    + ); +} diff --git a/packages/web/src/components/NodeSubgraphPreview.tsx b/packages/web/src/components/NodeSubgraphPreview.tsx new file mode 100644 index 00000000..4a18b7f1 --- /dev/null +++ b/packages/web/src/components/NodeSubgraphPreview.tsx @@ -0,0 +1,115 @@ +import { useQuery } from '@apollo/client'; +import { Maximize2 } from 'lucide-react'; +import { GET_WORK_ITEMS, GET_EDGES } from '../lib/queries'; +import { getTypeConfig } from '../constants/workItemConstants'; +import type { WorkItemType } from '../constants/workItemConstants'; + +/** + * A STATIC, legible render of a node's sub-graph (its "diagram"), drawn from the + * sub-graph's persisted node positions — no force simulation. Lets you READ a + * diagram at a useful scale without navigating away; "Open" descends into it. + */ +interface NodeSubgraphPreviewProps { + subgraphId: string; + subgraphName?: string; + onOpen: () => void; +} + +export function NodeSubgraphPreview({ subgraphId, subgraphName, onOpen }: NodeSubgraphPreviewProps) { + // A preview is a thumbnail — cap the payload so even a 1000-node sub-graph + // loads fast and stays legible. "Open" shows the full thing. + const PREVIEW_LIMIT = 300; + const { data: wiData, loading } = useQuery(GET_WORK_ITEMS, { + variables: { where: { graph: { id: subgraphId } }, options: { limit: PREVIEW_LIMIT } }, + fetchPolicy: 'cache-and-network', + }); + const { data: edgeData } = useQuery(GET_EDGES, { + variables: { where: { source: { graph: { id: subgraphId } } }, options: { limit: 600 } }, + fetchPolicy: 'cache-and-network', + }); + + const nodes: any[] = wiData?.workItems ?? []; + const edges: any[] = edgeData?.edges ?? []; + + if (loading && nodes.length === 0) { + return
    Loading diagram…
    ; + } + if (nodes.length === 0) { + return ( +
    +
    This sub-graph is empty.
    + +
    + ); + } + + // Bounds from persisted positions, scaled to fit the preview viewBox. + const W = 320; + const H = 240; + const pad = 30; + const xs = nodes.map((n) => n.positionX ?? 0); + const ys = nodes.map((n) => n.positionY ?? 0); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const scale = Math.min((W - pad * 2) / spanX, (H - pad * 2) / spanY); + const ox = (W - spanX * scale) / 2; + const oy = (H - spanY * scale) / 2; + const toX = (x: number) => ox + (x - minX) * scale; + const toY = (y: number) => oy + (y - minY) * scale; + const byId: Record = {}; + for (const n of nodes) byId[n.id] = n; + + // Cap labels so a dense sub-graph stays readable, not a wall of text. + const showLabels = nodes.length <= 40; + + return ( +
    +
    + {nodes.length} nodes · {edges.length} edges + +
    + + {edges.map((e) => { + const s = byId[typeof e.source === 'object' ? e.source?.id : e.source]; + const t = byId[typeof e.target === 'object' ? e.target?.id : e.target]; + if (!s || !t) return null; + return ( + + ); + })} + {nodes.map((n) => { + const color = getTypeConfig(n.type as WorkItemType).hexColor; + const x = toX(n.positionX ?? 0); + const y = toY(n.positionY ?? 0); + return ( + + + {showLabels && ( + + {String(n.title).slice(0, 18)} + + )} + + ); + })} + +
    + ); +} + +function OpenButton({ onOpen, name }: { onOpen: () => void; name?: string }) { + return ( + + ); +} diff --git a/packages/web/src/components/SafeGraphVisualization.tsx b/packages/web/src/components/SafeGraphVisualization.tsx index aed230a8..3b91e3ae 100644 --- a/packages/web/src/components/SafeGraphVisualization.tsx +++ b/packages/web/src/components/SafeGraphVisualization.tsx @@ -1,19 +1,24 @@ import { GraphErrorBoundary } from './GraphErrorBoundary'; import { InteractiveGraphVisualization } from './InteractiveGraphVisualization'; +import type { WorkItem } from '../types/graph'; /** * Wrapper component that adds error handling to the graph visualization * without modifying the core InteractiveGraphVisualization component. * This prevents breaking the UI when implementing error handling. */ -export function SafeGraphVisualization() { +interface SafeGraphVisualizationProps { + onNodeSelected?: (node: WorkItem | null) => void; +} + +export function SafeGraphVisualization({ onNodeSelected }: SafeGraphVisualizationProps = {}) { return ( { // Error logged by boundary for debugging }} > - + ); -} \ No newline at end of file +} diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 0ff19cdd..82bc49b0 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -3,6 +3,7 @@ import { Plus, Share2, Users, Table, Activity, Network, CreditCard, Columns, Cal import { createPortal } from 'react-dom'; import { useQuery } from '@apollo/client'; import { SafeGraphVisualization } from '../components/SafeGraphVisualization'; +import { NodeInspector } from '../components/NodeInspector'; import { GraphSelector } from '../components/GraphSelector'; import { MiniMap } from '../components/MiniMap'; import { CreateWorkItemModal } from '../components/CreateWorkItemModal'; @@ -28,6 +29,7 @@ export function Workspace() { const [showMiniMap, setShowMiniMap] = useState(true); const { currentGraph, availableGraphs, getBreadcrumb, ascendTo } = useGraph(); const breadcrumb = getBreadcrumb(); + const [inspectorNode, setInspectorNode] = useState(null); const { currentTeam, currentUser } = useAuth(); const { health, loading: healthLoading, error: healthError } = useHealthStatus(); @@ -375,7 +377,8 @@ export function Workspace() {
    ) : viewMode === 'graph' ? ( -
    +
    +
    {/* Neo4j Connection Warning */} {health?.services?.neo4j?.status !== 'healthy' && (
    @@ -397,7 +400,13 @@ export function Workspace() {
    )} - + +
    + {inspectorNode && ( +
    + setInspectorNode(null)} /> +
    + )}
    ) : ( diff --git a/tests/diagnostics/hierarchy-navigation.spec.ts b/tests/diagnostics/hierarchy-navigation.spec.ts index 3fdfd8ad..aed0a135 100644 --- a/tests/diagnostics/hierarchy-navigation.spec.ts +++ b/tests/diagnostics/hierarchy-navigation.spec.ts @@ -52,14 +52,15 @@ test.describe('hierarchy navigation @geometry', () => { expect(overview.currentGraphId, 'on the overview graph').toBe(OVERVIEW_ID); expect(overview.sheetCount, 'overview has sheet-symbol nodes').toBeGreaterThan(0); - // Descend: click a sheet node's card. + // Descend: click a sheet node's DESCEND glyph (plain card-click now selects + // for the inspector; descending is the explicit ⤢ glyph or inspector Open). const targetSubgraphId = overview.firstSheetSubgraphId as string; await page.evaluate(() => { const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( (n) => (n as any).__data__?.subgraphId ) as SVGGElement | undefined; - const bg = (sheet?.querySelector('.node-bg') ?? sheet) as Element | undefined; - (bg as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const glyph = sheet?.querySelector('.node-descend-icon') as Element | undefined; + (glyph as any)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); await page.waitForTimeout(5000); diff --git a/tests/diagnostics/node-inspector.spec.ts b/tests/diagnostics/node-inspector.spec.ts new file mode 100644 index 00000000..1750586c --- /dev/null +++ b/tests/diagnostics/node-inspector.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * Docked inspector: selecting a node opens it; Contents renders its description + * as readable markdown; Diagram renders the sub-graph; modes switch explicitly + * (not via zoom). Needs the hierarchy demo seeded (System Overview). + */ +const OVERVIEW_ID = 'overview-graph-shared'; + +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); +} + +test.describe('node inspector diagnostic @geometry', () => { + test.describe.configure({ timeout: 120_000 }); + + test('select node → inspector Contents + Diagram + Card modes', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openOverview(page); + + // Plain-click a sheet node's card → it SELECTS (no longer descends). + const box = await page.evaluate(() => { + const sheet = [...document.querySelectorAll('.graph-container svg .node')].find( + (n) => (n as any).__data__?.subgraphId + ) as SVGGElement | undefined; + const bg = sheet?.querySelector('.node-bg') as Element | undefined; + if (!bg) return null; + const r = bg.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + expect(box, 'found a sheet node on screen').not.toBeNull(); + await page.mouse.click(box!.x, box!.y); + await page.waitForTimeout(1800); + + const inspector = page.locator('[data-testid="node-inspector"]'); + await expect(inspector, 'inspector opens on node select').toBeVisible({ timeout: 8000 }); + + // Contents (default for a node with a description) renders markdown. + const contents = page.locator('[data-testid="node-content-rendered"]'); + await expect(contents, 'Contents renders the description').toBeVisible({ timeout: 8000 }); + const contentsText = await contents.innerText(); + expect(contentsText.length, 'contents has readable text').toBeGreaterThan(5); + + // Switch to Diagram → static sub-graph preview renders. + await inspector.getByRole('button', { name: 'Diagram' }).click(); + await page.waitForTimeout(2000); + // eslint-disable-next-line no-console + console.log('[node-inspector] diagram pane: ' + (await inspector.innerText()).slice(0, 160).replace(/\n/g, ' ')); + await expect(page.locator('[data-testid="subgraph-preview"]'), 'Diagram renders the sub-graph').toBeVisible({ timeout: 15000 }); + + // Switch to Card → summary rows. + await inspector.getByRole('button', { name: 'Card' }).click(); + await expect(inspector.getByText('Type', { exact: true }), 'Card shows the summary').toBeVisible({ timeout: 5000 }); + + // Legibility independent of zoom: zoom the canvas way out, inspector text stays. + await page.mouse.move(400, 400); + for (let i = 0; i < 5; i++) { await page.mouse.wheel(0, 240); await page.waitForTimeout(100); } + await inspector.getByRole('button', { name: 'Contents', exact: true }).click(); + await expect(page.locator('[data-testid="node-content-rendered"]'), 'contents readable regardless of zoom').toBeVisible(); + + // eslint-disable-next-line no-console + console.log('[node-inspector] ok — inspector + Contents/Diagram/Card verified'); + }); +}); From dfd1d620699404e68130c30ab43fb09f99d82529 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 23:03:39 -0700 Subject: [PATCH 19/24] =?UTF-8?q?Perf=20S1:=20size-gate=20continuous=20nod?= =?UTF-8?q?e/edge=20effects=20on=20dense=20graphs=20(idle=20FPS=206.7?= =?UTF-8?q?=E2=86=9260)=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large graphs collapsed to ~7 idle FPS at HIGH quality while sitting still. Root cause (measured): every node's .node-bg carries an SVG drop-shadow halo and the living-graph animations (node-breathe / node-ache / edge-flow) run continuously, so ~1000 filtered layers repaint every frame. LOW tier already strips these and hits 60 idle FPS on the IDENTICAL DOM — proving the effects, not the node count, were the idle killer. Fix: gate those same effects off by graph SIZE, independent of the quality tier. A graph with > DENSE_GRAPH_NODE_THRESHOLD (150) nodes sets data-dense on .graph-container; new CSS rules (mirroring the LOW strips) drop the animations and the drop-shadow filter. Small graphs keep the full living-graph aesthetic at any tier — a user who picks HIGH still gets pretty small graphs and fast big ones. Measured on the Compute Core example (1000 nodes / 1400 edges), HIGH quality: idle FPS 6.7 → 59.7 (filter/blur elements 1166 → 166) Drag (1.2) and zoom (3.2) are per-tick work, addressed in following stages. Adds tests/diagnostics/large-graph-profile.spec.ts (report-only baseline: idle/drag/zoom FPS + DOM weight + data-dense confirmation). Verified: web typecheck 0 errors; living-graph e2e 3/3 (small-graph effects intact); THE GATE 5/5. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 11 +- packages/web/src/index.css | 23 ++++ tests/diagnostics/large-graph-profile.spec.ts | 108 ++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/diagnostics/large-graph-profile.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 7b5caf13..100e0c93 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -59,6 +59,12 @@ const LOD_THRESHOLDS = { CLOSE: 1.0, }; +// Above this node count a graph is "dense": the continuous living-graph effects +// (breathing/ache/flow animations + per-node drop-shadow halos) are gated off +// via data-dense regardless of the quality tier, because repainting that many +// filtered layers each frame collapses FPS. Below it, the full aesthetic stays. +const DENSE_GRAPH_NODE_THRESHOLD = 150; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -4107,6 +4113,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // hasNodes only and restored one global transform, so it never recentered on // a graph change. We wait briefly for the one-shot layout to settle, then fit. const hasNodes = nodes.length > 0; + const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4331,7 +4338,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
    +
    {/* Error message centered in SVG */} @@ -4515,7 +4522,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
    +
    { + localStorage.setItem('currentGraphId', g); + localStorage.setItem('graphdone.quality.override', q); + }, + { g: gid, q: quality } + ); + await page.reload(); + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 60_000 }).catch(() => {}); + await page.waitForTimeout(8000); // let one-shot physics settle +} + +async function rafFps(page: Page, ms: number): Promise { + await page.evaluate(() => { + (window as any).__fc = 0; + const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; + (window as any).__rafId = requestAnimationFrame(loop); + }); + await page.waitForTimeout(ms); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + return Math.round((frames / (ms / 1000)) * 10) / 10; +} + +test.describe('large-graph baseline profile @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + + for (const quality of ['HIGH', 'LOW']) { + test(`compute-core profile @${quality}`, async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1920, height: 1080 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await openGraph(page, COMPUTE_GRAPH_ID, quality); + + const renderedNodes = await page.locator('.graph-container svg .node').count(); + const renderedEdges = await page.locator('.graph-container svg .edge').count(); + const dataDense = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dense') ?? null); + + // DOM weight: total SVG elements, per-node element count, CSS filter usage. + const dom = await page.evaluate(() => { + const svg = document.querySelector('.graph-container svg'); + const all = svg ? svg.querySelectorAll('*').length : 0; + const nodes = document.querySelectorAll('.graph-container svg .node').length; + const texts = svg ? svg.querySelectorAll('text').length : 0; + const fos = svg ? svg.querySelectorAll('foreignObject').length : 0; + const filtered = document.querySelectorAll('.graph-container [style*="filter"], .graph-container [filter]').length; + const blur = Array.from(document.querySelectorAll('.graph-container *')).filter((e) => { + const s = getComputedStyle(e as Element); + return s.backdropFilter !== 'none' || s.filter !== 'none'; + }).length; + return { totalSvgEls: all, nodes, texts, foreignObjects: fos, inlineFilterEls: filtered, blurOrFilterEls: blur, perNodeEls: nodes ? Math.round(all / nodes) : 0 }; + }); + + // Idle FPS (nothing happening, physics stopped). + const idleFps = await rafFps(page, 3000); + + // Drag-interaction FPS: hold a node and move it for 5s (sim ticks while dragging). + const box = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + let dragFps = -1; + if (box) { + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + const t = Date.now(); + let a = 0; + while (Date.now() - t < 5000) { a += 0.6; await page.mouse.move(box.x + Math.cos(a) * 80, box.y + Math.sin(a) * 60); await page.waitForTimeout(110); } + await page.mouse.up(); + const frames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + dragFps = Math.round((frames / ((Date.now() - t) / 1000)) * 10) / 10; + } + + // Zoom-interaction FPS: wheel-zoom repeatedly for 4s. + await page.mouse.move(960, 540); + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + const zt = Date.now(); + let dir = -1; + while (Date.now() - zt < 4000) { dir = -dir; await page.mouse.wheel(0, dir * 120); await page.waitForTimeout(60); } + const zframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const zoomFps = Math.round((zframes / ((Date.now() - zt) / 1000)) * 10) / 10; + + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, dom }; + fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); + // eslint-disable-next-line no-console + console.log(`[profile] ${quality}: dense=${dataDense} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls} blurOrFilterEls=${dom.blurOrFilterEls}`); + expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); + }); + } +}); From 19e7bec12b9efe0961dc57a040b98584b101a7d7 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 23:16:12 -0700 Subject: [PATCH 20/24] Perf S2: viewport culling (zoomed-in) + throttled minimap (drag FPS) (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When zoomed into a large graph, most nodes are off-screen yet still painted every frame — off-screen SVG costs the same to paint as on-screen. Add geometric viewport culling: node groups (and edges with both ends hidden) outside the viewport + margin get display:none, so they are neither laid out nor painted. Do-no-harm gating (learned by measurement): culling is skipped below scale 0.5 (the whole-graph "fit" view, where every node is on screen and a cull pass is pure overhead — an early attempt that culled unconditionally slowed zoom). When zoomed back out below the threshold, everything is revealed once. Culling is only enabled above 200 nodes. Recomputed on a throttle during sim ticks AND on every pan/zoom (the one-shot sim is usually stopped, so the zoom handler is the only thing that can reveal nodes panned back into view). Also throttle the minimap position-dict rebuild (every tick -> every 8th); it doesn't need 60 Hz and was rebuilding a full 1000-entry dict per tick. Measured (Compute Core, 1000n/1400e, HIGH), zoomed in to scale ~1.7 (982 nodes culled): zoomed-in drag FPS 1.2 -> 9 (~8x). Whole-graph fit-view drag/zoom unchanged (do-no-harm); idle still 60 (S1 preserved). The whole-graph view (scale ~0.1, all elements painted) is bound by element count, addressed next by simplified-node LOD. large-graph-profile.spec.ts now also measures a zoomed-in drag + culled count. Verified: web typecheck 0; THE GATE 5/5; hierarchy-navigation green. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 76 +++++++++++++++++-- tests/diagnostics/large-graph-profile.spec.ts | 37 ++++++++- 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 100e0c93..0e5ffa8e 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3663,8 +3663,63 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const perfMeter = new PerfMeter(240); const driftMeter = new DriftMeter(); let lastPerfReport = 0; + + // Viewport culling (large graphs only). At 1000 nodes the dominant frame + // cost is the browser repainting every on-screen SVG element each time a + // position changes; off-screen elements cost just as much to paint. We hide + // node groups (and edges with both ends hidden) outside the viewport so they + // are neither painted nor laid out. Geometry-only, generous margin, recomputed + // on a throttle during simulation ticks AND on every pan/zoom (the sim is a + // one-shot, so when it has stopped the zoom handler is the only thing that can + // reveal nodes panned back into view). + const cullEnabled = nodes.length > 200; + const CULL_MARGIN_PX = 300; + // Culling only pays off when enough of the graph is actually off-screen, i.e. + // when zoomed IN. At the whole-graph "fit" view every node is visible, so a + // cull pass would be pure overhead (it even slowed zoom). Below this scale we + // skip culling and, if we had culled, reveal everything once. + const CULL_MIN_SCALE = 0.5; + let cullCounter = 0; + let cullActive = false; + const clearCull = () => { + nodeElements.style('display', null); + linkElements.style('display', null); + clickableEdges.style('display', null); + arrowElements.style('display', null); + edgeLabelGroups.style('display', null); + cullActive = false; + }; + const applyViewportCull = () => { + const svgEl = svg.node(); + if (!svgEl) return; + const t = d3.zoomTransform(svgEl); + if (t.k < CULL_MIN_SCALE) { + if (cullActive) clearCull(); + return; + } + cullActive = true; + const minGX = (-CULL_MARGIN_PX - t.x) / t.k; + const maxGX = (width + CULL_MARGIN_PX - t.x) / t.k; + const minGY = (-CULL_MARGIN_PX - t.y) / t.k; + const maxGY = (height + CULL_MARGIN_PX - t.y) / t.k; + nodeElements.style('display', (d: any) => { + const x = d.x ?? 0; + const y = d.y ?? 0; + const visible = x >= minGX && x <= maxGX && y >= minGY && y <= maxGY; + d._culled = !visible; + return visible ? null : 'none'; + }); + const edgeDisplay = (d: any) => (d.source?._culled && d.target?._culled ? 'none' : null); + linkElements.style('display', edgeDisplay); + clickableEdges.style('display', edgeDisplay); + arrowElements.style('display', edgeDisplay); + edgeLabelGroups.style('display', edgeDisplay); + }; + simulation.on('tick', () => { const tickStart = performance.now(); + cullCounter++; + if (cullEnabled && cullCounter % 5 === 0) applyViewportCull(); // 1) Nodes first nodeElements @@ -3695,8 +3750,10 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: } // Update mini-map with current node positions (live simulation objects — - // the React-state nodes are different objects since the identity merge) - if ((window as any).updateMiniMapPositions) { + // the React-state nodes are different objects since the identity merge). + // Throttled: rebuilding a full positions dict for every node on every tick + // was pure overhead at scale; the minimap doesn't need 60 Hz updates. + if (cullCounter % 8 === 0 && (window as any).updateMiniMapPositions) { const simNodesForMap = simulation.nodes() as any[]; if (simNodesForMap.length > 0) { const positions: {[key: string]: {x: number, y: number}} = {}; @@ -3746,12 +3803,17 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Update zoom with LOD updates zoom.on('zoom', (event) => { g.attr('transform', event.transform); - setCurrentTransform({ - x: event.transform.x, - y: event.transform.y, - scale: event.transform.k + setCurrentTransform({ + x: event.transform.x, + y: event.transform.y, + scale: event.transform.k }); - + + // Re-cull on pan/zoom. The one-shot sim is usually stopped during pan, so + // this is the only thing that reveals nodes panned back into view (and + // hides ones panned out) — and it keeps paint bounded while panning. + if (cullEnabled) applyViewportCull(); + // Update mini-map viewport if ((window as any).updateMiniMapViewport) { const viewportUpdate = { diff --git a/tests/diagnostics/large-graph-profile.spec.ts b/tests/diagnostics/large-graph-profile.spec.ts index 427befd1..0964a396 100644 --- a/tests/diagnostics/large-graph-profile.spec.ts +++ b/tests/diagnostics/large-graph-profile.spec.ts @@ -98,10 +98,43 @@ test.describe('large-graph baseline profile @geometry', () => { const zframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); const zoomFps = Math.round((zframes / ((Date.now() - zt) / 1000)) * 10) / 10; - const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, dom }; + // Zoomed-IN drag: zoom in hard so most of the graph is off-screen, then + // drag. This is where viewport culling should help (the fit-view drag above + // keeps every node on screen, so culling can't help there). + await page.mouse.move(960, 540); + for (let i = 0; i < 10; i++) { await page.mouse.wheel(0, 200); await page.waitForTimeout(60); } + await page.waitForTimeout(500); + const zoomState = await page.evaluate(() => { + const g = document.querySelector('.graph-container svg g'); + const tr = g?.getAttribute('transform') ?? ''; + const m = tr.match(/scale\(([0-9.]+)\)/); + const nodes = Array.from(document.querySelectorAll('.graph-container svg .node')); + const hidden = nodes.filter((n) => getComputedStyle(n).display === 'none').length; + return { transform: tr.slice(0, 60), scale: m ? parseFloat(m[1]) : null, hidden, total: nodes.length }; + }); + const culledHidden = zoomState.hidden; + // eslint-disable-next-line no-console + console.log(`[profile] ${quality} zoomState: scale=${zoomState.scale} hidden=${zoomState.hidden}/${zoomState.total} tr="${zoomState.transform}"`); + const zinBox = await page.evaluate(() => { + const n = document.querySelector('.graph-container svg .node .node-bg') as Element | null; + if (!n) return { x: 960, y: 540 }; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(zinBox.x, zinBox.y); + await page.mouse.down(); + const zt2 = Date.now(); + let aa = 0; + while (Date.now() - zt2 < 4000) { aa += 0.6; await page.mouse.move(zinBox.x + Math.cos(aa) * 60, zinBox.y + Math.sin(aa) * 45); await page.waitForTimeout(110); } + await page.mouse.up(); + const zinFrames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const zoomedInDragFps = Math.round((zinFrames / ((Date.now() - zt2) / 1000)) * 10) / 10; + + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); // eslint-disable-next-line no-console - console.log(`[profile] ${quality}: dense=${dataDense} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls} blurOrFilterEls=${dom.blurOrFilterEls}`); + console.log(`[profile] ${quality}: dense=${dataDense} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); } From b872df3c32a04c251ad1a2fef4bf2e19dea72e9c Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sun, 14 Jun 2026 23:25:16 -0700 Subject: [PATCH 21/24] =?UTF-8?q?Perf=20S3:=20simplified-node=20LOD=20when?= =?UTF-8?q?=20zoomed=20out=20(whole-graph=20zoom=20FPS=203.5=E2=86=9210)?= =?UTF-8?q?=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the whole-graph "fit" view (scale ~0.1) every node's ~40 SVG sub-elements are on screen and painted each frame, and the per-tick edge pass positions 1400 arrows + labels — even though none of it is legible at that zoom. That's what makes looking at the entire graph sluggish. Add a simplified LOD: on a dense graph below SIMPLIFY_SCALE (0.45) the container gets data-simplify, and CSS hides per-node detail (title bar, type/title/desc text, status & priority bars/icons/labels, edit/relationship/descend icons) plus arrows and edge labels outright (display:none — opacity:0 still paints). Each node renders as just its colored card; edges stay so structure is legible. updateEdgePositions also skips the now-hidden arrow + label positioning per tick (via simplifiedRef), removing the bulk of the remaining per-tick cost. Zooming back in past the threshold restores full detail (and the one-shot settle pass still runs so labels are correct when shown). Measured (Compute Core, 1000n/1400e, HIGH), whole-graph fit view: zoom FPS 3.5 → 10.5 (paintedDetail 4000 → 0) drag FPS 1.2 → 4.4 idle FPS 60 → 60 (S1 preserved) Numbers are from a SERIAL profiler run (--workers=1); running the two quality tests in parallel thrashed the CPU and produced contradictory FPS — the spec is now mode:'serial'. Verified: web typecheck 0; THE GATE 5/5; node-inspector green (zoomed-in detail restores correctly). Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 24 ++++++++++++-- packages/web/src/index.css | 33 +++++++++++++++++++ tests/diagnostics/large-graph-profile.spec.ts | 13 ++++++-- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 0e5ffa8e..482500a7 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -65,6 +65,12 @@ const LOD_THRESHOLDS = { // filtered layers each frame collapses FPS. Below it, the full aesthetic stays. const DENSE_GRAPH_NODE_THRESHOLD = 150; +// Below this zoom scale on a dense graph, per-node detail (text, icons, status/ +// priority bars) is unreadable, so it is hidden outright (data-simplify) — each +// node renders as just its colored card. This is the dominant win for the +// whole-graph view, where every element is on screen and painted each frame. +const SIMPLIFY_SCALE = 0.45; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -103,6 +109,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // descendInto from context isn't memoized; hold the latest in a ref so the // D3-bound node click handler can call it without re-binding every render. const descendIntoRef = useRef(descendInto); + // Mirrors isSimplified for the d3 tick closure (which captures stale render + // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. + const simplifiedRef = useRef(false); descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); @@ -3583,6 +3592,15 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: .attr('x2', (d: any) => d._ep.x2) .attr('y2', (d: any) => d._ep.y2); + // Simplified (dense + zoomed out): arrows and edge labels are hidden + // (data-simplify CSS), so skip their per-tick positioning entirely — at + // 1400 edges that arrow transform + label placement pass is the bulk of + // the remaining per-tick cost in the whole-graph view. forceAvoid (the + // one-shot settle pass) still runs so labels are correct when you zoom in. + if (simplifiedRef.current && !forceAvoid) { + return; + } + // Arrow sits at the TARGET border, pointing into the node. arrowElements .attr('transform', (d: any) => { @@ -4176,6 +4194,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // a graph change. We wait briefly for the one-shot layout to settle, then fit. const hasNodes = nodes.length > 0; const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; + const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; + simplifiedRef.current = isSimplified; const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4400,7 +4420,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
    +
    {/* Error message centered in SVG */} @@ -4584,7 +4604,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
    +
    { } test.describe('large-graph baseline profile @geometry', () => { - test.describe.configure({ timeout: 180_000 }); + // Serial: two browsers rendering a 1000-node graph at once thrash the CPU and + // make the FPS numbers meaningless. One at a time. + test.describe.configure({ timeout: 180_000, mode: 'serial' }); for (const quality of ['HIGH', 'LOW']) { test(`compute-core profile @${quality}`, async ({ page }) => { @@ -50,6 +52,11 @@ test.describe('large-graph baseline profile @geometry', () => { const renderedNodes = await page.locator('.graph-container svg .node').count(); const renderedEdges = await page.locator('.graph-container svg .edge').count(); const dataDense = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dense') ?? null); + const dataSimplify = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-simplify') ?? null); + const paintedDetail = await page.evaluate(() => { + const sel = '.graph-container svg .node-title-bar, .graph-container svg .status-progress-bg, .graph-container svg .priority-progress-bg, .graph-container svg .node-type-text'; + return Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; + }); // DOM weight: total SVG elements, per-node element count, CSS filter usage. const dom = await page.evaluate(() => { @@ -131,10 +138,10 @@ test.describe('large-graph baseline profile @geometry', () => { const zinFrames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); const zoomedInDragFps = Math.round((zinFrames / ((Date.now() - zt2) / 1000)) * 10) / 10; - const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, paintedDetail, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); // eslint-disable-next-line no-console - console.log(`[profile] ${quality}: dense=${dataDense} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} perNodeEls=${dom.perNodeEls} totalSvgEls=${dom.totalSvgEls}`); + console.log(`[profile] ${quality}: dense=${dataDense} simplify=${dataSimplify} paintedDetail=${paintedDetail} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} totalSvgEls=${dom.totalSvgEls}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); } From 4623f231aa25d00ab0f49d99d417f1b8a8442f71 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Mon, 15 Jun 2026 08:23:19 -0700 Subject: [PATCH 22/24] =?UTF-8?q?Perf=20S5:=20extreme-zoom=20"dot=20mode"?= =?UTF-8?q?=20=E2=80=94=20hide=20edges,=20halve=20painted=20elements=20(wh?= =?UTF-8?q?ole-graph=20pan=2013=E2=86=9230,=20zoom=2010=E2=86=9221)=20(#71?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier stages proved the whole-graph view is PAINT-bound: the browser composites every visible SVG element each time the pan/zoom transform changes, and a JS-side handler throttle did nothing (rejected). The only lever left is painting fewer elements. Below DOT_SCALE (0.2) on a dense graph — i.e. the whole-graph "fit" view — edges are sub-pixel hairlines that convey little but are ~half of what's painted after simplify. Dot mode (`data-dots`) hides all edges + their hitboxes via CSS and skips the entire 1400-edge per-tick positioning pass (dotModeRef early-return in updateEdgePositions). Each node is already just its colored card from S4, so the overview becomes a clean card grid. Edges + full detail return as you zoom in past the threshold (verified: fit → 0 edges; zoomed-in → edges + detail restored). This completes the LOD ladder the user asked for ("remove buttons and simplify nodes as we zoom out"): full detail (zoomed in) → card only, no buttons (S4, <0.45) → dots, no edges (S5, <0.2). Measured (Compute Core, 1000n/1400e, serial), whole-graph fit view: painted els ~2400 → 1000 pan FPS ~13 → 30 (HIGH) / 33 (LOW) zoom FPS 10.5 → 21 / 22 drag FPS 3 → 15 / 8 idle FPS 60 (preserved) profiler adds data-dots + paintedEls + a pan-FPS measurement. Verified: web typecheck 0; THE GATE 5/5; node-inspector + hierarchy-navigation green (zoomed-in detail/edges restore correctly). Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 20 +++++++++++++++++-- packages/web/src/index.css | 9 +++++++++ tests/diagnostics/large-graph-profile.spec.ts | 18 +++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 482500a7..2e0dd4bf 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -71,6 +71,13 @@ const DENSE_GRAPH_NODE_THRESHOLD = 150; // whole-graph view, where every element is on screen and painted each frame. const SIMPLIFY_SCALE = 0.45; +// Below this (much smaller) scale a dense graph is in "dot mode": edges are +// sub-pixel hairlines and nodes are tiny, so edges are hidden entirely and their +// per-tick positioning skipped. This roughly halves the painted element count +// (edges are ~half of what's left after simplify), which is the only lever that +// helps the paint-bound whole-graph pan/zoom. Edges return when you zoom past it. +const DOT_SCALE = 0.2; + // Utility functions const getSmoothedOpacity = (scale: number, threshold: number, fadeRange: number = 0.2) => { if (scale >= threshold + fadeRange) return 1; @@ -112,6 +119,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Mirrors isSimplified for the d3 tick closure (which captures stale render // values otherwise). Lets updateEdgePositions skip hidden arrow/label work. const simplifiedRef = useRef(false); + // Dot mode (extreme zoom-out): edges are hidden, so skip their per-tick work. + const dotModeRef = useRef(false); descendIntoRef.current = descendInto; const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); @@ -3567,6 +3576,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: let labelAvoidCounter = 0; const updateEdgePositions = (forceAvoid = false) => { + // Dot mode (extreme zoom-out): all edges/arrows/labels are hidden, so there + // is nothing to position — skip the whole 1400-edge per-tick pass. + if (dotModeRef.current && !forceAvoid) { + return; + } // Border-to-border anchors: the edge starts/ends where the center line // crosses each card's border, not at the buried center. Computed once per // edge per tick (shared datum) so line, hitbox and arrow agree. The anchor @@ -4195,7 +4209,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const hasNodes = nodes.length > 0; const isDenseGraph = nodes.length > DENSE_GRAPH_NODE_THRESHOLD; const isSimplified = isDenseGraph && (currentTransform?.scale ?? 1) < SIMPLIFY_SCALE; + const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE; simplifiedRef.current = isSimplified; + dotModeRef.current = isDotMode; const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4420,7 +4436,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isNetworkError = errorMessage.includes('Cannot connect'); return ( -
    +
    {/* Error message centered in SVG */} @@ -4604,7 +4620,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: return ( -
    +
    { const renderedEdges = await page.locator('.graph-container svg .edge').count(); const dataDense = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dense') ?? null); const dataSimplify = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-simplify') ?? null); + const dataDots = await page.evaluate(() => document.querySelector('.graph-container')?.getAttribute('data-dots') ?? null); + const paintedEls = await page.evaluate(() => Array.from(document.querySelectorAll('.graph-container svg .node-bg, .graph-container svg .edge')).filter((e) => getComputedStyle(e).display !== 'none').length); const paintedDetail = await page.evaluate(() => { const sel = '.graph-container svg .node-title-bar, .graph-container svg .status-progress-bg, .graph-container svg .priority-progress-bg, .graph-container svg .node-type-text'; return Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; @@ -96,6 +98,18 @@ test.describe('large-graph baseline profile @geometry', () => { dragFps = Math.round((frames / ((Date.now() - t) / 1000)) * 10) / 10; } + // Pan-interaction FPS: drag the empty top-left background (clear of the + // centered cloud at fit view). This is the paint-bound whole-graph metric. + await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); + await page.mouse.move(150, 150); + await page.mouse.down(); + const pt = Date.now(); + let pang = 0; + while (Date.now() - pt < 4000) { pang += 0.5; await page.mouse.move(150 + Math.cos(pang) * 80, 150 + Math.sin(pang) * 80); await page.waitForTimeout(16); } + await page.mouse.up(); + const pframes = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); + const panFps = Math.round((pframes / ((Date.now() - pt) / 1000)) * 10) / 10; + // Zoom-interaction FPS: wheel-zoom repeatedly for 4s. await page.mouse.move(960, 540); await page.evaluate(() => { (window as any).__fc = 0; const loop = () => { (window as any).__fc++; (window as any).__rafId = requestAnimationFrame(loop); }; (window as any).__rafId = requestAnimationFrame(loop); }); @@ -138,10 +152,10 @@ test.describe('large-graph baseline profile @geometry', () => { const zinFrames = await page.evaluate(() => { cancelAnimationFrame((window as any).__rafId); return (window as any).__fc || 0; }); const zoomedInDragFps = Math.round((zinFrames / ((Date.now() - zt2) / 1000)) * 10) / 10; - const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, paintedDetail, renderedNodes, renderedEdges, idleFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; + const result = { graph: COMPUTE_GRAPH_ID, quality, dataDense, dataSimplify, dataDots, paintedEls, paintedDetail, renderedNodes, renderedEdges, idleFps, panFps, dragFps, zoomFps, zoomedInDragFps, culledHidden, dom }; fs.writeFileSync(path.join(OUT, `compute-${quality}.json`), JSON.stringify(result, null, 2)); // eslint-disable-next-line no-console - console.log(`[profile] ${quality}: dense=${dataDense} simplify=${dataSimplify} paintedDetail=${paintedDetail} nodes=${renderedNodes} edges=${renderedEdges} idleFps=${idleFps} dragFps=${dragFps} zoomFps=${zoomFps} zoomInDragFps=${zoomedInDragFps} culledHidden=${culledHidden} totalSvgEls=${dom.totalSvgEls}`); + console.log(`[profile] ${quality}: simplify=${dataSimplify} dots=${dataDots} paintedEls=${paintedEls} idleFps=${idleFps} panFps=${panFps} zoomFps=${zoomFps} dragFps=${dragFps} zoomInDragFps=${zoomedInDragFps}`); expect(renderedNodes, 'compute core renders nodes').toBeGreaterThan(0); }); } From aad517973f0115eaec8aac1c9fc491f9e1ded6df Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Mon, 15 Jun 2026 19:50:13 -0700 Subject: [PATCH 23/24] Fix core interaction bugs: orphan/duplicate arrows, drag-follow edit box, bloated inspector (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix core interaction bugs: orphan/duplicate arrows, drag-follow edit box, bloated inspector Three dogfood bugs in the core interaction layer: 1. Flipping an edge direction added a 2nd arrow, and deleting a node left its edges' arrows behind. ROOT CAUSE: the surgical-reinit path in initializeVisualization() removed .nodes-group/.edges-group/.edge-labels-group/ .node-labels-container but NOT .arrows-group — so every rebuild appended a fresh arrows-group while the stale one (old/orphaned arrows) lingered. One line: also remove .arrows-group. Fixes both the flip-duplicate and the delete-orphan arrows (same cause). 2. The inline title-edit box didn't follow a node while dragging — its position derived from currentTransform (React state, only updated on zoom), so it lagged until release. Now an rAF loop (active only while editing) glues the overlay to the live sim position + live zoom transform, so it tracks drag, tick and zoom. 3. The right-side node inspector was a full-height 384px dock — huge and mostly empty for simple nodes. Now a compact floating card (w-72, max-h-70vh, content-height, top-right overlay) that no longer steals graph width. Also adds tests/diagnostics/core-interactions.spec.ts groundwork (a core-action matrix incl. the arrows==edges invariant that catches the arrow class of bugs). NOTE: verified by root-cause analysis; the local box is at load ~27 (shared LLM inference services), which flakes interaction tests and contaminates FPS — CI's clean runners are the authoritative check here. Co-Authored-By: Claude Opus 4.8 (1M context) * Add core-interactions matrix scaffold (report-only; invariants gate) The 'basic user checks' checklist: each core action is an independent probe that logs PASS/FAIL so one run shows the whole picture. Structural invariants (arrows == edges before/after flip+delete, no JS errors) are asserted; the UI-trigger probes are report-only until their click/coordinate logic is hardened on a quiet machine (the dev box is at load ~27 from shared LLM services, which flakes them). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 31 +++ packages/web/src/components/NodeInspector.tsx | 4 +- packages/web/src/pages/Workspace.tsx | 6 +- tests/diagnostics/core-interactions.spec.ts | 191 ++++++++++++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 tests/diagnostics/core-interactions.spec.ts diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 2e0dd4bf..48ef01d1 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -122,6 +122,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Dot mode (extreme zoom-out): edges are hidden, so skip their per-tick work. const dotModeRef = useRef(false); descendIntoRef.current = descendInto; + // 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); const { currentUser } = useAuth(); const { showSuccess, showError } = useNotifications(); const navigate = useNavigate(); @@ -1823,6 +1826,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Surgical update - only clear data elements, preserve core structure existingMainGroup.selectAll('.nodes-group').remove(); existingMainGroup.selectAll('.edges-group').remove(); + existingMainGroup.selectAll('.arrows-group').remove(); existingMainGroup.selectAll('.edge-labels-group').remove(); existingMainGroup.selectAll('.node-labels-container').remove(); d3.select(containerRef.current).selectAll('.node-labels-container').remove(); @@ -4212,6 +4216,32 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE; simplifiedRef.current = isSimplified; dotModeRef.current = isDotMode; + + // Keep the inline-rename box glued to its node while it's open — through node + // DRAGS, simulation ticks and pan/zoom — by repositioning the overlay div + // directly each frame from the live sim position + live zoom transform. The + // JSX position only recomputes on React renders (zoom), which is why the box + // lagged a drag until release. + const inlineEditNodeId = inlineEdit?.nodeId ?? null; + useEffect(() => { + if (!inlineEditNodeId) return undefined; + let raf = 0; + const sync = () => { + const el = inlineEditRef.current; + const svgEl = svgRef.current; + if (el && svgEl) { + const n = (simulationRef.current?.nodes() as any[])?.find((m: any) => m.id === inlineEditNodeId); + if (n) { + const t = d3.zoomTransform(svgEl); + el.style.left = `${(n.x ?? 0) * t.k + t.x}px`; + el.style.top = `${(n.y ?? 0) * t.k + t.y}px`; + } + } + raf = requestAnimationFrame(sync); + }; + raf = requestAnimationFrame(sync); + return () => cancelAnimationFrame(raf); + }, [inlineEditNodeId]); const currentGraphId = currentGraph?.id; useEffect(() => { if (!hasNodes || !svgRef.current) return undefined; @@ -4720,6 +4750,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: }; return (
    diff --git a/packages/web/src/components/NodeInspector.tsx b/packages/web/src/components/NodeInspector.tsx index 475d66b4..4b865d5d 100644 --- a/packages/web/src/components/NodeInspector.tsx +++ b/packages/web/src/components/NodeInspector.tsx @@ -35,7 +35,7 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) { return (
    {/* Header */}
    @@ -56,7 +56,7 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) {
    {/* Body */} -
    +
    {mode === 'card' && (
    diff --git a/packages/web/src/pages/Workspace.tsx b/packages/web/src/pages/Workspace.tsx index 82bc49b0..ced7c1c6 100644 --- a/packages/web/src/pages/Workspace.tsx +++ b/packages/web/src/pages/Workspace.tsx @@ -377,8 +377,8 @@ export function Workspace() {
    ) : viewMode === 'graph' ? ( -
    -
    +
    +
    {/* Neo4j Connection Warning */} {health?.services?.neo4j?.status !== 'healthy' && (
    @@ -403,7 +403,7 @@ export function Workspace() {
    {inspectorNode && ( -
    +
    setInspectorNode(null)} />
    )} diff --git a/tests/diagnostics/core-interactions.spec.ts b/tests/diagnostics/core-interactions.spec.ts new file mode 100644 index 00000000..d631aee4 --- /dev/null +++ b/tests/diagnostics/core-interactions.spec.ts @@ -0,0 +1,191 @@ +import { test, expect, Page } from '@playwright/test'; +import { login, TEST_USERS } from '../helpers/auth'; + +/** + * CORE INTERACTION MATRIX — the "basic user checks" every build must pass. + * Each check is an independent, resilient probe of one core user action; failures + * are collected (not thrown immediately) so a single run prints the WHOLE pass/ + * fail picture instead of stopping at the first break. The final assertion fails + * the test if any check failed, with the full matrix in the log. + * + * This is the systematic counterpart to ad-hoc bug reports: it exercises node + + * edge CRUD, selection/inspector, inline rename, drag (incl. the open edit box + * following), relationship type change + flip, deletion, and the structural + * invariants that catch whole classes of rendering bugs (e.g. arrows == edges). + */ + +interface Check { name: string; ok: boolean; detail: string } + +async function counts(page: Page) { + return page.evaluate(() => { + const vis = (sel: string) => Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length; + return { + nodes: document.querySelectorAll('.graph-container svg .node').length, + edges: vis('.graph-container svg .edge'), + arrows: vis('.graph-container svg .arrow'), + edgeLabels: vis('.graph-container svg .edge-label'), + }; + }); +} + +async function nodeCenter(page: Page, index = 0) { + return page.evaluate((i) => { + const n = document.querySelectorAll('.graph-container svg .node .node-bg')[i] as Element | undefined; + if (!n) return null; + const r = n.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, index); +} + +test.describe('core interaction matrix @geometry', () => { + test.describe.configure({ timeout: 180_000, mode: 'serial' }); + + test('all basic user checks', async ({ page }) => { + const checks: Check[] = []; + const add = (name: string, ok: boolean, detail = '') => { checks.push({ name, ok, detail }); }; + const jsErrors: string[] = []; + page.on('pageerror', (e) => jsErrors.push(e.message.slice(0, 100))); + + await page.setViewportSize({ width: 1600, height: 1000 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + await page.reload(); + await page.locator('.graph-container svg .node').first().waitFor({ timeout: 30_000 }).catch(() => {}); + await page.waitForTimeout(5000); + + // 1. Graph renders nodes + edges + const c0 = await counts(page); + add('graph renders nodes', c0.nodes > 0, `nodes=${c0.nodes}`); + add('graph renders edges', c0.edges > 0, `edges=${c0.edges}`); + + // 2. INVARIANT: one arrow per visible edge (catches flip/delete orphan arrows) + add('arrows == edges (invariant)', c0.arrows === c0.edges, `arrows=${c0.arrows} edges=${c0.edges}`); + + // 3. Select a node → inspector opens + const nc = await nodeCenter(page, 0); + if (nc) { + await page.mouse.click(nc.x, nc.y); + await page.waitForTimeout(1200); + const inspectorVisible = await page.locator('[data-testid="node-inspector"]').isVisible().catch(() => false); + add('click node opens inspector', inspectorVisible, ''); + // 3b. inspector should be reasonably tight, not a huge mostly-empty panel + if (inspectorVisible) { + const box = await page.locator('[data-testid="node-inspector"]').boundingBox(); + add('inspector width is tight (<=340px)', !!box && box.width <= 340, `width=${box?.width ?? '?'}`); + } + } else { + add('click node opens inspector', false, 'no node found'); + } + + // 4. Inline rename: dblclick node → input → type → Enter → title persists + const nc2 = await nodeCenter(page, 0); + if (nc2) { + await page.mouse.dblclick(nc2.x, nc2.y); + const renameVisible = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false); + add('dblclick opens inline rename', renameVisible, ''); + if (renameVisible) { + const newName = 'CoreCheck ' + Date.now().toString().slice(-5); + await page.locator('[data-testid="inline-rename"]').fill(newName); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1500); + const titleShown = await page.locator(`.graph-container svg text:has-text("${newName.slice(0, 8)}")`).count().catch(() => 0); + add('inline rename updates title', titleShown > 0, `matches=${titleShown}`); + } + } + + // 5. Drag a node: the OPEN edit box must follow during the drag (reported bug) + const nc3 = await nodeCenter(page, 1) ?? await nodeCenter(page, 0); + if (nc3) { + await page.mouse.dblclick(nc3.x, nc3.y); + const editOpen = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false); + if (editOpen) { + const before = await page.locator('[data-testid="inline-rename"]').boundingBox(); + // drag the node body (mid-drag sample, before release) + await page.mouse.move(nc3.x, nc3.y); + await page.mouse.down(); + await page.mouse.move(nc3.x + 220, nc3.y + 140, { steps: 8 }); + await page.waitForTimeout(150); + const during = await page.locator('[data-testid="inline-rename"]').boundingBox(); + await page.mouse.up(); + const moved = !!before && !!during && Math.hypot((during.x - before.x), (during.y - before.y)) > 60; + add('edit box follows node during drag', moved, `moved=${before && during ? Math.round(Math.hypot(during.x - before.x, during.y - before.y)) : '?'}px`); + await page.keyboard.press('Escape').catch(() => {}); + } else { + add('edit box follows node during drag', false, 'edit box did not open'); + } + } + + // 6. Click an edge → relationship editor opens + await page.waitForTimeout(500); + const edgeBox = await page.evaluate(() => { + const e = document.querySelector('.graph-container svg .edge-clickable, .graph-container svg .edge') as SVGLineElement | null; + if (!e) return null; + const r = e.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }); + if (edgeBox) { + await page.mouse.click(edgeBox.x, edgeBox.y); + const editorOpen = await page.getByText('Flip Direction', { exact: false }).isVisible({ timeout: 3000 }).catch(() => false); + add('click edge opens relationship editor', editorOpen, ''); + + // 7. Flip direction → still exactly one arrow per edge (reported bug) + if (editorOpen) { + const beforeFlip = await counts(page); + await page.getByText('Flip Direction', { exact: false }).click().catch(() => {}); + await page.waitForTimeout(2500); + const afterFlip = await counts(page); + add('flip keeps arrows == edges', afterFlip.arrows === afterFlip.edges, `before a/e=${beforeFlip.arrows}/${beforeFlip.edges} after a/e=${afterFlip.arrows}/${afterFlip.edges}`); + } + } else { + add('click edge opens relationship editor', false, 'no edge found'); + } + + // 8. Delete a node → its edges' arrows are also removed (reported bug) + await page.keyboard.press('Escape').catch(() => {}); + await page.waitForTimeout(300); + const beforeDel = await counts(page); + // open node context menu (right-click) and delete, if available + const delTarget = await nodeCenter(page, 0); + if (delTarget) { + await page.mouse.click(delTarget.x, delTarget.y, { button: 'right' }).catch(() => {}); + await page.waitForTimeout(500); + const delBtn = page.getByRole('button', { name: /delete/i }).first(); + const canDelete = await delBtn.isVisible().catch(() => false); + if (canDelete) { + await delBtn.click().catch(() => {}); + // confirm if a confirm dialog appears + await page.getByRole('button', { name: /^(delete|confirm|yes)/i }).first().click({ timeout: 2000 }).catch(() => {}); + await page.waitForTimeout(2500); + const afterDel = await counts(page); + add('delete node removes a node', afterDel.nodes < beforeDel.nodes, `before=${beforeDel.nodes} after=${afterDel.nodes}`); + add('after delete, arrows == edges', afterDel.arrows === afterDel.edges, `arrows=${afterDel.arrows} edges=${afterDel.edges}`); + } else { + add('delete node available', false, 'no delete affordance found via right-click'); + } + } + + // 9. No uncaught JS errors during the whole flow + add('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | ')); + + // ---- Report matrix ---- + const pass = checks.filter((c) => c.ok).length; + // eslint-disable-next-line no-console + console.log('\n===== CORE INTERACTION MATRIX ====='); + for (const c of checks) { + // eslint-disable-next-line no-console + console.log(`${c.ok ? '✅' : '❌'} ${c.name}${c.detail ? ' — ' + c.detail : ''}`); + } + // eslint-disable-next-line no-console + console.log(`===== ${pass}/${checks.length} passed =====\n`); + + // Report-only for now: the UI-trigger probes (click/dblclick/right-click + // coordinates) still need hardening before they can gate, and they are + // unreliable on a CPU-saturated dev box. The STRUCTURAL invariants, however, + // are deterministic — assert those (e.g. arrows must equal edges, which + // catches the orphan/duplicate-arrow class of bugs). Harden the rest on a + // quiet machine, then promote them into the hard assertion. + const invariantNames = ['arrows == edges (invariant)', 'flip keeps arrows == edges', 'after delete, arrows == edges', 'no uncaught JS errors']; + const invariantFails = checks.filter((c) => invariantNames.includes(c.name) && !c.ok).map((c) => c.name); + expect(invariantFails, `failed structural invariants: ${invariantFails.join(', ')}`).toEqual([]); + }); +}); From 0cd009ea270350089f212d1931a41388ebd059ff Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Mon, 15 Jun 2026 21:35:14 -0700 Subject: [PATCH 24/24] Fix #30: node type change now updates the card in graph view (color/border/icon) (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing a node's type elsewhere (dashboard/editor) updated the badge text in the graph but not the type-derived card color/border/icon — the selective update path (updateVisualizationData) refreshes text but not type visuals, and the reinit gatekeeper only watched edge signatures + node COUNT (a type change keeps count the same). Add a per-node id+type signature: when it changes, force a full reinitialization (same approach already used for edge type/flip), so the card re-renders with the new type's color, border and icon. Fixes #30. Co-authored-by: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 48ef01d1..26435cd8 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4271,6 +4271,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // TYPE change or a direction FLIP — which keep the edge COUNT the same — still // forces a rebuild. Without this the edge label/arrow keep the stale value. const prevEdgeSigRef = useRef(''); + // Track a per-node id+type signature: a node TYPE change keeps node COUNT the + // same, and the selective update path refreshes the badge text but NOT the + // type-derived card color/border/icon, so the graph showed a stale type. A + // signature change forces a full rebuild (same approach as edges). (#30) + const prevNodeSigRef = useRef(''); // Comprehensive reinitialization effect - ONLY when actually needed useEffect(() => { @@ -4300,6 +4305,14 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: .join(','); const edgesChanged = prevEdgeSigRef.current !== '' && prevEdgeSigRef.current !== edgeSig; + // Detect a node TYPE change (same count → length checks miss it). The + // selective path refreshes the badge text but not the card color/icon. (#30) + const nodeSig = (nodes as any[]) + .map((n) => `${n.id}:${n.type}`) + .sort() + .join(','); + const nodesChanged = prevNodeSigRef.current !== '' && prevNodeSigRef.current !== nodeSig; + // Only reinitialize if this is truly necessary const shouldReinit = !svgRef.current || @@ -4308,7 +4321,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: !d3.select(svgRef.current).select('.main-graph-group').node() || reinitTrigger > 0 || transitioningFromEmpty || // Force reinit when adding first node to empty graph - edgesChanged; // relationship type changed or direction flipped + edgesChanged || // relationship type changed or direction flipped + nodesChanged; // a node's type changed — re-render its color/border/icon if (shouldReinit) { console.log('[Graph Debug] Full reinitialization required'); @@ -4326,6 +4340,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }: // Update previous node count + edge signature for next comparison prevNodeCountRef.current = nodes.length; prevEdgeSigRef.current = edgeSig; + prevNodeSigRef.current = nodeSig; const handleResize = () => { if (!containerRef.current || !svgRef.current || !simulationRef.current) return;