Skip to content

Release: sync develop → main#74

Merged
mvalancy merged 24 commits into
mainfrom
develop
Jun 16, 2026
Merged

Release: sync develop → main#74
mvalancy merged 24 commits into
mainfrom
develop

Conversation

@mvalancy

Copy link
Copy Markdown
Member

Promote the accumulated develop work to main so the two branches match.

Includes (this session): node Contents/Diagram inspector (#66, #67), large-graph
performance ladder — idle effect-gate, viewport culling, simplified-card LOD, dot
mode (#68, #69, #70, #71), core interaction fixes — orphan/duplicate arrows,
drag-follow edit box, compact inspector (#72), and node-type-change re-render (#73).
Closes out issues #27#31.

All constituent PRs were CI-green on merge to develop.

mvalancy and others added 24 commits June 13, 2026 19:16
* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…aul) (#52)

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) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
…inimum (#54)

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) <noreply@anthropic.com>
)

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) <noreply@anthropic.com>
…uffer) (#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) <noreply@anthropic.com>
…d 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) <noreply@anthropic.com>
…t 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 <body> 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…vigation (#60)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… camera centering, layout metrics (#62)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed (PR-B) (#63)

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…(PR-1) (#66)

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) <noreply@anthropic.com>
… (PR-2) (#67)

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) <noreply@anthropic.com>
… FPS 6.7→60) (#68)

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) <noreply@anthropic.com>
…69)

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) <noreply@anthropic.com>
…5→10) (#70)

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) <noreply@anthropic.com>
… (whole-graph pan 13→30, zoom 10→21) (#71)

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) <noreply@anthropic.com>
…box, bloated inspector (#72)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…order/icon) (#73)

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) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 18.x & 20.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

@mvalancy mvalancy merged commit 775362e into main Jun 16, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant