Skip to content

feat(graph-source-ux): Graph Source UX — all 7 phases - #1

Open
bitmosh wants to merge 22 commits into
mainfrom
feat/graph-source-ux
Open

feat(graph-source-ux): Graph Source UX — all 7 phases#1
bitmosh wants to merge 22 commits into
mainfrom
feat/graph-source-ux

Conversation

@bitmosh

@bitmosh bitmosh commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Summary

Complete overhaul of the graph source selection experience. Replaces implicit adapter activation with an explicit, reversible flow: picker modal → config form → Load commit. Adds a persistent library tile with pin/unpin, thumbnails, inline rename, and reinterpret-as. Phases 1–7 of the GRAPH_SOURCE_UX roadmap.

What changed

Phase 1 — Structural foundation

  • SA-001: Decouple config mutation from load; graph only re-runs on explicit Load commit or refreshToken bump
  • SA-004: Regenerate button scoped to self-graph adapter only
  • SA-014: category field on SourceAdapterEntry (file-based | directory-based | database | stream)
  • SA-022: SourceEntry type + sources.library schema (pinned/recent); migration from sources.history

Phase 2 — Empty state + picker modal

  • SA-017: EmptyPane component — "Select graph source" affordance when no adapter is active
  • SA-018: GraphSourcePicker modal — Recent tab + Open new tab; adapter cards grouped by category; inline config form before any load fires
  • SA-019: Cancel button during in-flight loads
  • SA-020: Error state with three escape hatches (Try again / Different config / Different adapter)
  • SA-023: "Change source" button in loaded state
  • SA-006: formatHint field on adapter entries; displayed in picker cards
  • SA-007: Cytoscape format guard error message rewrite

Phase 3 — Directory scanning

  • SA-015: scan(target) interface on adapters; returns scored ScanCandidate | null
  • SA-021: "Scan directory" action in picker; concurrent scan across all adapters; results ranked by score with "Different type ↓" fallback

Phase 4 — Library tile

  • SA-009: pushLibraryEntry on successful load; djb2 hash IDs; dedup-in-place; 20-entry recents cap
  • SA-010: Pinned + Recent sections in GraphSourcesTileContent; click-to-load restores config
  • SA-024: "Reinterpret as…" — opens picker pre-filled with entry config for adapter switching
  • SA-025: Delete with inline confirm row
  • Fix: packageDependencyAdapter normalises projectPath ending in /package.json (was hitting ENOTDIR)

Phase 5 — Thumbnails

  • SA-011/013: 2-second timer after summary.status === "loaded" transition → sigma.once("afterRender") → composite edges+nodes+labels onto offscreen canvas → 300×200 JPEG
  • SA-012: updateLibraryEntryThumbnail store action; stored in SourceEntry.thumbnailDataUrl
  • SA-012b: 60px thumbnail strip at top of each library card

Phase 6 — Dev mode gating

  • SA-026: source-adapter-section tile now requiresDevMode: true — hidden from standard users
  • SA-027: Collapsible Advanced section in picker (dev mode only) — shows raw adapterId, inputPattern, config JSON

Phase 7 — Polish

  • SA-028: renameLibraryEntry store action; pencil ✎ button on library cards and picker Recent entries; inline input (Enter commits, Escape cancels)
  • SA-003b: Extension auto-detect as user types a path — patternMatchesPath() matches filename against adapter inputPattern.pattern (glob, brace-alternation, exact names); suggestion chips appear below scan input
  • File picker: @tauri-apps/plugin-dialog installed; Browse file / Browse folder buttons in picker (hidden behind isTauriRuntime guard in Playwright)

New files

  • src/control-plane/graph-sources/EmptyPane.tsx + .css
  • src/control-plane/graph-sources/GraphSourcePicker.tsx + .css
  • src/control-plane/graph-sources/GraphSourcesTileContent.tsx (replaced stub)
  • docs/roadmap/GRAPH_SOURCE_UX.md
  • tests/e2e/graph-sources.spec.ts (expanded)
  • tests/e2e/graph-source-library.spec.ts (new — 12 tests)
  • tests/e2e/graph-source-thumbnails.spec.ts (new — 6 tests)
  • tests/e2e/graph-source-rename.spec.ts (new — 6 tests)
  • tests/e2e/graph-source-ext-detect.spec.ts (new — 6 tests)
  • tests/e2e/graph-source-scan.spec.ts (new)
  • tests/e2e/cerebra-snapshot-adapter.spec.ts (new)
  • tests/fixtures/cerebra-snapshot/ (new)

Modified files (key)

  • src/control-plane/settings/settings.schema.tsSourceEntry, SourcesSettings
  • src/control-plane/settings/settings.store.tspushLibraryEntry, pinLibraryEntry, unpinLibraryEntry, removeLibraryEntry, updateLibraryEntryThumbnail, renameLibraryEntry
  • src/control-plane/settings/settings.migrations.ts — migration to v96
  • src/control-plane/panels/tileSectionRegistry.tssource-adapter-section gated; graph-sources-section added
  • src/source-adapter/sourceAdapterRegistry.tscategory, formatHint, scan(), ScanCandidate
  • src/source-adapter/adapters/packageDependencyAdapter.ts — projectPath normalisation
  • src/app/AppShell.tsx — thumbnail capture effect
  • src-tauri/src/lib.rstauri_plugin_dialog::init()
  • src-tauri/capabilities/default.jsondialog:allow-open

Test counts

  • 38 E2E tests pass (graph-source suite)
  • Pre-existing failures in 4 unrelated spec files (CSS SyntaxError at collection time, not caused by this branch)

Not in this PR

  • .github/ISSUE_TEMPLATE/ changes, .gitignore, README.md — pre-existing untracked modifications excluded from all commits

🤖 Generated with Claude Code

bitmosh added 22 commits July 10, 2026 22:46
…ectory scan

SA-001: decouple config mutation from load; useGraphSourceSummary now reruns
only on [activeAdapterId, refreshToken], never on config edits alone.

SA-004: "Regenerate" in Graph Sources tile bumps refreshToken regardless of
active adapter; hidden when adapter is not self-graph-yaml-frontmatter.

SA-014: add `category` field to SourceAdapterEntry; all 6 working adapters
(plus stubs) declare file-based / directory-based / stream.

SA-022: SourceEntry interface + sources.library schema (pinned[], recent[]);
migration from sources.history → sources.library.recent.

SA-006/SA-007: formatHint on adapter entries; cytoscape-json error message
rewritten to name the expected format and suggest "Different adapter."

SA-017/SA-018: EmptyPane component + GraphSourcePicker modal (Recent tab,
Open new tab with adapter cards grouped by category, inline config form,
Load-only commit).

SA-019/SA-020/SA-023: Cancel button during load; three-option error escape
hatch (Try again / Different config / Different adapter); Change source
affordance in loaded state.

SA-015/SA-021: scan(target) interface on adapters; concurrent scan via
Promise.allSettled; Scan UI in picker Open new tab with ranked candidate
cards and score badges; "Different type" toggle.

Tests: graph-sources.spec.ts expanded (42 tests); graph-source-scan.spec.ts
new (9 tests); cerebra-snapshot-adapter.spec.ts new (10 tests);
package-dependency-adapter.spec.ts expanded (6 tests).
cytoscape-json: replace maximally synthetic fixture (n1-n10 nodes, e1-e15
edges) with a real cy.json() export — a protein-protein interaction network
(TP53, MDM2, BRCA1, ATM, CHEK2, PTEN, RB1, CDK4, CDKN2A, EGFR). New
fixture exercises the name→label fallback (MDM2), classes passthrough
(TP53 "hub-node"), dedup (TP53 duplicate), orphan skip (e-orphan), and
top-level zoom/pan tolerance. Adds invalid-format.json and
top-level-nodes.json for the error-branch tests. Updated spec from 5 to
15 tests.

markdown-vault: add AnchorAndDisplayLink.md (raw.anchor, raw.displayText
passthrough), ModifiedDate.md (modified frontmatter key → raw.updatedAt),
HexColorBody.md (hex color guard — #FF0000/#abc/#DeadBe rejected,
#hex-color-test accepted). Full-vault counts updated (13→16 notes,
11→13 tags, 10→13 wikilink edges). Five new focused-mock tests cover
each behaviour in isolation. Spec grows from 2 to 7 tests.
SA-009: pushLibraryEntry on every successful load; stable djb2 entryId;
dedup-in-place for pinned entries; max 20 recent, move-to-front upsert.

SA-010: Library tile sections (Pinned + Recent) auto-appear after first
load. Click-to-load restores adapter config and sets sources.active.
Pin/unpin moves entries between sections with pinnedAt timestamp.

SA-024: "Reinterpret as…" opens GraphSourcePicker pre-populated with the
entry's config, allowing adapter switching without re-entering the path.

SA-025: Delete button shows inline confirm row; Cancel leaves entry intact.

pkg-dep fix: normalise projectPath that ends with a manifest filename
(e.g. /path/to/project/package.json → /path/to/project) to prevent the
ENOTDIR (os error 20) error Tauri throws on canonicalize of a file path
treated as a directory.

SA-011/SA-012/SA-013: Thumbnail capture — 2s timer after status→loaded
transition fires sigma.once("afterRender") and composites edges+nodes+
labels canvases onto a 300×200 offscreen JPEG (quality 0.4). Stored via
updateLibraryEntryThumbnail in the settings store. Skipped in test env
(__PLAYWRIGHT__ flag) to avoid timer races in E2E. Uses window.__lwSigma
already set by SigmaGraphView — no ref threading needed.

SA-012b: Library card shows a 60px thumbnail strip when thumbnailDataUrl
is set; strip absent otherwise.

Tests: 21 new E2E tests (12 library, 6 thumbnails, 3 pkg-dep regression);
76 passing, 1 pre-existing entry-count failure in source-adapter.spec.ts.
SA-026: source-adapter-section now requires devMode. The raw registry
browser is hidden from standard users; Graph Sources tile stays visible.

SA-027: GraphSourcePicker shows a collapsible Advanced section below the
config form when devMode is enabled. Displays adapterId, inputPattern,
and the full raw config object as formatted JSON — useful for debugging
adapter detection without navigating the registry panel.
renameLibraryEntry store action updates the label field on pinned or
recent entries in-place; no-op for unknown IDs.

LibraryEntryCard gains a pencil (✎) button that activates an inline
input. Enter commits; Escape or blur cancels without saving. The same
affordance is present on Recent tab entries in GraphSourcePicker.
…ve browse

Installs tauri-plugin-dialog (npm + Cargo). Plugin registered in lib.rs;
dialog:allow-open added to capabilities.

SA-003b: as the user types a path in "Open new", patternMatchesPath()
matches the filename extension against each file-based adapter's
inputPattern.pattern (handles globs, brace-alternations, exact names).
Matching non-candidate adapters surface as clickable suggestion chips
below the scan input; suggestions clear when a scan is triggered.

File picker: "Browse file…" / "Browse folder…" buttons invoke
@tauri-apps/plugin-dialog via lazy import; hidden behind isTauriRuntime
guard so Playwright tests are unaffected.
The suite had 11 failures. None were product bugs — every one was a test
that had drifted from a codebase that moved on without it.

Stale constants. The schema reached v96 and the adapter registry reached 12
entries, but three specs still asserted older values (93, 95, 95, and 11).
The version assertions now derive from defaultSettings.version rather than
hardcoding a number: "the migration chain reaches the current schema" is the
property worth testing, and pinning a literal only guarantees it goes stale
on the next bump. That is precisely what happened — settings-migrations sat
at 93 for three bumps, unnoticed because it was in the collection-broken set.

The adapter count stays hardcoded (11 -> 12) on purpose, with a comment
saying why: it is a tripwire. Registering an adapter should force a conscious
review here, and reading the number back from the registry would make the
assertion tautological and silently accept any count.

Stale placeholders. The type and motion spokes graduated — they render real
TypeTab/MotionTab components now and carry their own coverage in
inspector-type-spoke.spec.ts and inspector-motion-spoke.spec.ts — but
spoke-placeholders still asserted a placeholder UI that no longer exists.
layout is the last remaining placeholder. Separately, v89-4 asserted the
message contains "Coming" after the copy changed to "...in development".

A test race, misdiagnosed as product flake. camera-wrapper-mount waited on
the <canvas> selector, but the element exists before Sigma is constructed and
assigns window.__lwSigma (SigmaGraphView.tsx:444), so getSigmaCameraState()
threw "Sigma instance not exposed". Waiting for the instance instead of its
container fixes it — and fixes the sibling test that v105.0.2 quarantined as
a "camera state race on reload". The race was in the test, not the product,
so that test.fixme is removed. It passes 6/6 across repeats.
A UX review of the graph-source flow traced the felt "discontinuity" to
structural causes rather than a pile of small bugs. This closes two of them.

The app had no commit verb. A Zustand write carries a value, not an intent,
so editing and committing were the same operation. Two consequences, both
bad. Since a commit is just a value change, an *unchanged* value was not a
commit: writing the already-active adapter back to sources.active left the
string identical, useGraphSourceSummary's [active, refreshToken] dep array
never changed, and the load silently no-opped. The error card's "Different
config" escape hatch was therefore 100% dead by construction — it
pre-selects the failing adapter, so a corrected path could never reload.
The hole was already felt: sources.refreshToken existed as an out-of-band
"I really mean it" channel used by exactly two buttons, which were the only
two paths in the app that could reliably load anything twice. That token was
the missing verb in disguise.

commitSource(adapterId, config?) now writes config, active and refreshToken
in one set(). All five commit paths route through it — three from the picker
and tile, plus two the review had missed: the dev registry browser, and the
Cerebra snapshot listener, where a second snapshot arriving while
cerebra-snapshot was already active would never have reloaded.

Cancel was a lie. AdapterConfigForm wrote every keystroke straight into
persisted settings, so opening the picker merely to look at a config and
then cancelling destroyed it — with no cause the user could connect to. The
picker now owns a draft: every edit inside the modal (typing, selecting a
scan candidate, the reinterpret pre-fill) lands in draft state and nowhere
else, and only commitSource flushes it. AdapterConfigForm takes optional
config/onChange and is controlled when given them, store-backed otherwise,
so SourceAdapterPanel keeps its existing behaviour. The individual adapter
forms needed no change; they were already fully controlled.

Selecting a scan candidate no longer wipes the ranked list. It marks the
candidate and expands its config form in place, so the evidence stays on
screen and the runner-up remains reachable without re-scanning — the
roadmap's "automated decisions are inspectable, selection reversible".

Also hoists GraphSourcePicker.css and EmptyPane.css into AppShell, following
the precedent already set for palette.css. Both components are reachable from
tileSectionRegistry, which settings.migrations and the command registry
import, so their bare CSS imports were being pulled into Playwright's
Node-side transform — a SyntaxError that broke collection for four specs and
silently stopped 57 tests from running at all. It lands here rather than in
its own commit because it lives in the same files as the changes above and
this environment has no interactive staging.

graph-source-scan.spec.ts is rewritten where it asserted the two behaviours
deliberately changed here (results clearing on select, and candidate
selection writing to persisted settings). It now asserts the draft is staged
but not persisted, and that Load is what commits.

Full suite: 822 passed, 0 failed, 13 skipped.
…t the theme

GraphSourcePicker createPortals to document.body, but all sixteen --lw-*
custom properties were declared only as an inline style on <main>. Custom
properties inherit downward and body is an *ancestor* of main, so every
var(--lw-*) in GraphSourcePicker.css silently resolved to its hardcoded
cyan/slate fallback: pick an amber theme and a cyan modal from a different
application appears on top of it. The busiest new surface in the app was the
one surface no theme could reach.

AppShell now mirrors the token block onto documentElement, which sits above
every mount point including portals. themeCrossfade already did exactly this
for one token — the precedent existed, it had just never been generalised.
The token map moves into a useMemo so <main> and the effect consume one
source instead of two copies free to drift apart.

One writer per token. The first cut of this promotion regressed
theme-crossfade ("reduce-motion snaps"), failing 3/3 in isolation.
Instrumenting rather than guessing showed why:

  t+16ms  root:#03000A  main:#050505   <- root stale, main fresh
  t+50ms  root:#050505  main:#050505

themeCrossfade writes --lw-app-background to documentElement from inside a
rAF loop. Writing it here as well gave one property two writers, and this one
lags by a render: the crossfade effect calls setActiveTokens(), so our
crossfadeTokens does not catch up until the next render, and we overwrote
each fresh animation frame with the previous one. The effect now skips that
token and leaves it to its owner.

Motion and craft, which is what "missing smoothness" actually meant:

The modal had no transition at all — it appeared and vanished in a single
frame, giving the eye no signal about where the surface came from. Adds a
140ms backdrop fade and a 180ms modal fade/rise inside the same band
SettingsPanel already uses, honouring prefers-reduced-motion.

Adds the elevation and the -webkit-backdrop-filter pair the modal was missing
(there is no autoprefixer in the build), and aligns saturate() with
SettingsPanel at 180%.

Tokenises the score badges. They held the only raw hexes in the file, so they
ignored the theme entirely — and under a gold theme the amber "weak match"
read as more accent-native than the cyan "strong match", inverting the visual
ranking the scores exist to convey.

picker-theme-tokens.spec.ts asserts the portaled modal resolves --lw-accent
to the same value as the root, and follows a theme change. It also asserts
the modal is genuinely outside <main>, so if someone later stops portaling
the spec fails loudly instead of passing for the wrong reason.

Full suite: 824 passed, 0 failed, 13 skipped.
…dering the fixture on error

Narrow precursor to the full lifecycle hoist. Scoped deliberately: it defuses
a live bug and stops the canvas lying, without the day-long refactor.

The 4x fan-out, and a bug this branch armed.

useGraphSourceSummary is instantiated four times — AppShell, the Graph Sources
tile, StatusCluster, GraphInspectorTileContent — and each instance carries its
own state and its own effects. So a single load pushed the same library entry
four times and emitted sourceLoaded four times. Worse, the Cerebra listen()
effect lives inside the hook, which meant four live listeners: one snapshot
event called commitSource() four times, and since commitSource bumps
refreshToken on every call, that is four bumps and a reload storm.

That was dormant until this branch. It only stayed harmless while writing an
unchanged sources.active silently no-opped; giving commitSource a real
refreshToken bump armed it. It needs the Tauri runtime, so no spec would have
caught it — it surfaced from mapping every other reader and writer of the
state before touching it.

The hook now takes an `owner` flag. AppShell is the sole owner and the only
consumer that pushes to the library, emits the Tauri source events, or listens
for Cerebra snapshots. The other three are read-only views of the summary.

Not done on purpose: loadSource() still runs four times. It is a pure read, so
this is wasteful rather than incorrect, and collapsing it means hoisting the
state to one owner — the follow-up refactor. Left alone and documented instead
of half-done.

The fixture gate was lying.

useFixture was `isTestEnv || !hasRealSource`, and hasRealSource went false
whenever an error was present — so a failed load quietly filled the canvas with
LumaWeave's own self-graph. You typed a bad path, the tile said "Load failed",
and a graph you had never seen appeared beside it. It also left AppShell's
"Failed to load graph data" placeholder as dead code, because the fixture always
supplied nodes and the empty branch could never render.

Error now falls through to the real summary and that placeholder is reachable.
Every other branch is deliberately unchanged: isTestEnv still forces the fixture
(the whole E2E suite depends on stable geometry), first run still shows it, and
a load over an existing graph still keeps the old graph on screen — which relies
on the {...prev} spread in the hook's loading state. Resetting to a clean loading
state would have dropped normalizedNodes and flashed the fixture on every source
switch.

The changed branch is invisible to the E2E harness: isTestEnv is __PLAYWRIGHT__,
so Playwright forces the fixture unconditionally and no browser test can observe
it. Rather than claim coverage that does not exist, the gate is extracted to a
pure shouldUseFixture() and its truth table asserted directly — including "test
env always wins", so that half cannot be moved by accident.

Full suite: 828 passed, 0 failed, 13 skipped.
The diagnosis behind the four commits on this branch, kept because the
reasoning is worth more than the fixes: the felt "discontinuity" in the graph
source flow came from three structural causes, not a pile of small bugs.

The app had no Source (identity was the adapter alone, so two files under one
adapter were the same source), no commit verb (a Zustand write carries a value
but not an intent, so an unchanged value was not a commit and Load silently
no-opped), and no owner for the load lifecycle (four instances of one hook, so
cause and effect landed on different surfaces).

Records 49 findings that survived adversarial verification, the 7 that were
refuted — kept so they don't get re-raised — and a fix order sequenced by
leverage rather than severity. A status table maps each step to the commit that
closed it, and notes the two findings that only surfaced during implementation:
the quadruple Cerebra listener that the commitSource fix armed, and the {...prev}
spread in the loading state that stops the canvas flashing the fixture mid-switch.

The methodology matters as much as the findings: every claim carries a file:line,
and the useful ones came from asking who ELSE reads or writes a piece of state
before touching it.
The third and last structural cause of the discontinuity in the graph source
flow. useGraphSourceSummary was a plain hook with local useState, instantiated
four times — AppShell, the Graph Sources tile, StatusCluster, and
GraphInspectorTileContent. Four copies of the state meant four independent
loads per switch and four different answers to "what am I looking at", and
cancelLoad flipped exactly one of the four refs: cancelling muted the tile
while the canvas and the topbar happily finished loading the graph you had
just cancelled. The tile owned the controls for a lifecycle it did not own the
state of.

Now there is one Zustand store, one useGraphSourceLifecycle() mounted once in
AppShell, and useGraphSourceSummary() as a read-only view. The other three
consumers needed no changes at all — the read API already had the right shape.

Measured rather than assumed: a source switch now costs at most two disk reads
instead of eight, counted by instrumenting the Tauri mock. Two rather than one
because StrictMode double-invokes effects, and Playwright runs the dev build —
so that was always happening, four times over.

Cancel actually cancels. It restores the previous graph, reverts sources.active
along with it, and the discarded load can no longer land later and clobber the
restored state. Scoped honestly: this is a discard, not a true abort.
loadSource() takes no AbortSignal, so the read still runs to completion and its
result is thrown away. A real abort means threading a signal through every
adapter's load(config) — a separate change with its own blast radius.

Two hazards worth recording, because neither was obvious.

StrictMode makes module-level lifecycle state dangerous: a boolean "suppress
the next load" flag would be consumed by the first effect invocation and let
the second one load anyway. The guard is therefore keyed on
(adapterId, refreshToken) and never cleared. A stale key is harmless, because
every real commit path goes through commitSource(), which always bumps
refreshToken.

And the first cancel implementation was wrong. It reverted to prevAdapterId —
but the effect overwrites that with the *incoming* adapter the moment a load
starts, so by the time the user hits Cancel it already names the adapter being
cancelled, and the revert is a silent no-op. The restore point has to be
captured alongside the summary it belongs to, under the same status guard. A
test that only checked the rendered graph would have passed; this one checks
sources.active too, which is the only reason it was caught.

Full suite: 830 passed, 0 failed, 13 skipped.
`npm run tauri dev` refused to start on a version mismatch: the Rust
crate resolves to tauri 2.10.3 while the npm package had floated to
2.11.1 under `^2`.

The npm side was AHEAD, not behind — `cargo update -p tauri` left the
crate at 2.10.3, which is what proved 2.10.3 is current and the JS
package was the one out of step. Pinning to `~2.10` keeps the two
sides on the same minor and stops the float from reopening this.
Three surfaces had colors baked in rather than themed, so they did not
move when the theme did:

- Native `<select>` dropdowns inherited the UA default (dark text on a
  white popup) in every theme, so the darker themes rendered white-on-
  white. A global `select option` rule now takes its background and
  text from `--lw-panel-background` / `--lw-text-primary`, which fixes
  every dropdown at once instead of one at a time.
- `.lw-theme-select` in the topbar hardcoded rgba(11,4,22,0.85) — the
  one control whose entire job is switching themes was the one control
  that ignored them.
- Tile titles hardcoded cyan (#22d3ee). Now `var(--lw-accent)`, with
  the border derived from it via color-mix so it tracks the accent.

The fallbacks in each `var()` keep the previous values, so a theme that
does not define a token renders exactly as before.

Preset display names are toned down: Solar Plasma -> Plasma, Obsidian
Aurora -> Aurora, Midnight Loom -> Midnight, Void Circuit -> Neon Pink,
Agartha Dream -> Light Pastel, Agartha Dusk -> Lavender.

Display names ONLY — the theme IDs are unchanged, so persisted settings
keep resolving and no migration is needed.
A regression I introduced in af83c7c. The fixture gate became

    useFixture = isTestEnv || (!hasRealNodes && !isErrorState)

which is right only where loading can succeed. Every adapter reads
files through the Tauri `invoke` bridge, and in a plain browser that
bridge does not exist — so every source read throws, the summary sits
permanently in `error`, and `npm run dev` rendered nothing but "Failed
to load graph data" over a blank canvas.

In an environment that cannot load ANY source, "error" is not an
exceptional condition — it is the only condition, and it carries no
information. So the gate now asks `canLoadSources` first: no bridge,
show the demo. A failed load still surfaces as a failure wherever
loading is actually possible, which is the case af83c7c existed to fix.

E2E could not catch this: `isTestEnv` forces the fixture, so the whole
branch is invisible under Playwright. `shouldUseFixture` is therefore a
pure function with its truth table asserted directly, and it imports
nothing — in particular nothing that reaches a CSS import — so it stays
safe to load in Playwright's Node-side transform.
35 directories were seeded into 4 coincident piles — 20 under
src.control-plane, 11 under src.graph, and two smaller ones. Closest
pair (docs.graph.contracts, docs.graph.intelligence) at 0.00 units.

`placeBranchRecursive` had no per-sibling term. A directory's position
was a pure function of its parent's position, an alternation sign, and
the spine angle, so every sibling computed byte-identical coordinates.
Deeper levels inherited the parent's direction verbatim, making each
subtree a straight ray rather than a fan.

The simulation could never undo this. For two nodes at identical
coordinates dx = dy = 0, so the force is (0/dist, 0/dist) * magnitude
= EXACTLY zero however large the magnitude. A perfect stack is a stable
fixed point. Separation cannot be delegated to the physics; the seed
has to be right.

So each subtree is now handed an angular extent weighted by its leaf
count, recursively subdivided among children (`subdivideWedge`). The
sub-wedges tile the parent's wedge without overlapping, so siblings own
disjoint angular ranges and overlap is impossible by construction
rather than something the simulation has to win. Both seeders share the
one implementation.

parallelSpines needed a second fix. It fanned branches AND orbited
files in the x/z plane, and Sigma renders only (x, y) — so the entire
arrangement was projected away. Even a correct azimuthal spread would
have rendered as a pile, and the file orbits were horizontal rings seen
exactly edge-on: a line segment in which every pair at +/-theta was
coincident. Both now fan in the spine's own vertical plane, which
varies x and y, while z still carries the azimuth for a future 3D
camera. The general rule is recorded in GWELLS_PHYSICS.md: structure
that must be visible today has to live in the plane the 2D camera
actually renders.

Also in the same seam, since they are the same bug class:

- Engine-side insurance: when two nodes are within 0.5 units they are
  given a separation direction derived from an FNV-1a hash of their
  ids, made antisymmetric so A->B and B->A oppose rather than drift the
  pair sideways. Deliberately not Math.random() — the layout must stay
  deterministic and repeatable. Inert on a healthy layout.

- radialBackbone destructured `angleRad` from computeFileOrbit into a
  name that shadowed the spine's own `angleRad`, then computed
  `angleRad + angleRad` — discarding the orbit angle and stacking every
  file in a directory onto one ray. computeFileOrbit's contract (the
  angle it returns is relative to the parent) is now stated at the
  definition.

- Deleted `siblingRepulsion` and `attractionStrength`. Tuned to
  250/120/100/80 across the well types, overridden again per-dialect,
  resolved into the runtime config — and read by nothing. No
  interaction even uses kind: "attraction". A clean typecheck with zero
  remaining references is the proof that the deletion changes no
  behaviour, which is the point: they were a trap. Anyone tuning the
  layout would reach for the parameter named after the problem and
  watch it do nothing. Repulsion comes from `interaction.strength`;
  attraction comes from the springs.

The new spec covers both dialects and was verified to fail against both
pre-fix seeders with the 0.00-unit signature above, so it is not
passing for the wrong reason. It guards against a vacuous pass on an
empty seed set — an earlier draft "passed" with no seeds at all.
Five phases (unstack, loosen, edge semantics, controls, new seeds)
against the concentric-hierarchy target, with the diagnosis behind each
item recorded from the live fixture rather than from memory.

Phase 1 is marked done except L-003, whose premise turned out to be
wrong and is written up as such:

- It said "read the `size` attribute the engine ignores." But `size` is
  a live presentation attribute — graphStylePolicy rewrites it on hover
  and selection. Feeding it to the force loop would make selecting a
  node physically shove its neighbourhood apart. The structural
  attribute is `baseSize`.
- "min separation = r_a + r_b + padding" would inflate the layout
  rather than tidy it: measured baseSize median is 219 against a
  directoryOffset of 220, so it would demand ~438 units of separation
  where the seeder allots 220.

Only 0.62% of pairs (652/104,653) actually overlap after settling, so
this does not want a force-law rewrite. It wants the node scale fixed —
nodes are currently as wide as the gap between them, which is likely a
large share of the "everything is stacked" look and is a one-number
change. Logged as L-019 in a new Phase 1b, to be done BEFORE further
physics tuning: every force constant is otherwise judged against a
picture whose nodes are too big for their spacing, and the error gets
baked into the constants.
…whole circle

Two defects, committed together because the first cannot land green without
the second: correcting the node scale is what exposed the root-wedge bug, and
the min-separation test is red in between.

NODE SIZE IS A RADIUS IN GRAPH UNITS, NOT PIXELS.

Sigma runs with `itemSizesReference: "positions"`, so `size`/`baseSize` are in
the same units as x/y and are directly comparable to the seeders' spacing
constants. computeNodeSize had been inflated to a 48-360 range — its own
docblock still described the intended output as 4.5-40 — giving a median node
radius of 219 against a `directoryOffset` of 220. A node's radius equalled the
whole distance to its parent, so its DIAMETER was twice the spacing: every node
overlapped its neighbours at every zoom level, however correct the seed was.
That is why the graph still read as "everything stacked" after the piles were
provably gone.

Rescaled to 12-90 — the old curve times 1/4, same shape, same dynamic range —
and the bounds are exported so the orbit maths can be written against them
instead of against magic numbers that drift.

Two couplings fell out, both presentation leaking into geometry:

- The seeders read `size` for `parentVisualSize`. `size` is
  `baseSize * settings.nodeSize`, and graphStylePolicy rewrites it again on
  hover and selection — so the node-size slider silently reshaped the LAYOUT on
  the next reseed, and file orbits depended on what happened to be selected.
  They read `baseSize` now. Geometry may only read `baseSize`.

- computeFileOrbit's inner bound was `30 + parentRadius`: a flat 30 units of
  clearance, a rounding error when radii were 48-360, but the binding
  constraint once the scale was right — a file with a 90-unit radius sitting 30
  units off its parent's edge lands inside the parent. The clearance is derived
  from the radii now.

THE GLOBAL ANGULAR BUDGET.

L-001 gave each PARENT a wedge to divide among its children. Nothing gave the
ROOTS one. Every root was handed a flat directoryFanArc (150 degrees) centred
perpendicular to its spine, regardless of how many roots there were. The
self-graph has 41 roots on the hub ring: that allocates 41 x 150 = 6150 degrees
of wedge out of a circle that has 360. The root wedges overlapped enormously
and unrelated subtrees swept straight through one another —
src.control-plane.system-index and src.graph, different subtrees entirely, were
seeded 13 units apart, and their files collided at 7.

So the budget was enforced within a parent and never across roots: sibling
overlap was impossible while subtree overlap was routine. Measured worst
sibling pair, 49.9 units; worst cross-subtree pair, 7.06.

The full circle is now subdivided among the roots by leaf count and tiled
exactly. That required subdivideWedge to take a minArc it can set to 0: its 12
degree floor is a deliberate over-allocation, harmless slack inside a parent's
wedge but fatal when dividing a circle, where 41 roots floored to 12 degrees
reclaim 492 degrees of 360 and the sectors overlap again. Each root now sits on
the hub ring at its own sector's bearing and runs radially outward along it, so
a root's position, its sector, and its subtree's direction finally agree.
Subtrees cannot cross for the same reason siblings cannot.

Measured on the self-graph, settled, 458 nodes:

                                    before   +scale   +budget
  median nearest-neighbour gap       0.72     1.23     1.61
  nodes overlapping their neighbour  65.1%    42.4%    16.4%
  worst cross-subtree pair           7.06     7.06    20.33

(gap < 1 means the two discs overlap; the median node went from overlapping its
nearest neighbour to clearing it by 61%.)

The remaining 16.4% is now genuinely a physics job — those pairs are close but
not coincident, so the force direction is well defined — rather than a seeding
bug. That is L-003, which this unblocks: it would have demanded r_a + r_b = 438
units of separation against 220 of spacing, and now demands at most 180.
L-019 and L-001b written up with the measurements, and L-003 reopened: the
reason it was blocked (it would have demanded ~438 units of separation against
220 of spacing) is gone now that radii are 12-90.

Also records the two rules the pass cost us: node size is a radius in graph
units and is only meaningful relative to the distance to the next node; and
geometry reads baseSize, never size.
Every spacing number in the seeder — directoryOffset: 220, spineSpacing: 150,
MIN_ORBIT, MIN_FAN_ARC — was tuned independently against a snapshot of the
content. They were mutually inconsistent, and the inconsistency was invisible:
files orbited at >= 122 units from their parent while sibling directories sat
220 apart, a midline of 110. Every file therefore crossed into a neighbouring
subtree. Subtrees could not overlap; their files always did.

Nobody noticed until the content changed. Deleting one directory reshuffled the
wedges under src.graph and a different pair became the closest, at 4.48 units —
and a test that had been passing for weeks went red. That is the signature of a
PRESERVATIVE layout: numbers chosen to hold one picture still, correct for
exactly one input and silently wrong for every other.

So the layout is now ADAPTIVE. It contains no distance constants at all.
directoryOffset, spineSpacing and the orbit constants are not consulted on the
hub-ring path. Every distance is derived, bottom-up, from the content, so the
layout grows when the content grows and no number has to be re-tuned.

The order of operations is written down as a template, because it is the part
worth reusing (docs/canonical/LAYOUT_PIPELINE.md):

  DERIVE   content     -> intrinsic quantities   (the ONLY place constants live)
  MEASURE  tree        -> footprints, bottom-up
  ALLOCATE footprints  -> disjoint regions, top-down
  PLACE    regions     -> coordinates
  VERIFY   coordinates -> invariants hold

Containment stops being tuned and becomes structural:

  Sum(width(child)) <= width(parent)              [MEASURE]
  r(d) >= r(d-1)                                  [ALLOCATE]
    => Sum(theta(child)) <= theta(parent)         => subtrees cannot cross

  theta(v) * r(d) >= width(v) >= pi * disc(v)     => a node's disc — itself AND
                                                     its orbiting files — fits
                                                     inside its own sector

So files still orbit a full circle and simply have room to. No angular squeeze
was needed and none was applied: containment comes from the disc, not from
cramping the orbit. The adaptive term that replaces directoryOffset is the ring
radius, max(radial clearance, total width / 2*pi) — the second half is what
makes the graph spread out as it grows.

THE ACCEPTANCE CRITERION CHANGED, AND IT MATTERS MORE THAN THE NUMBERS.

The old test asserted "no pair closer than 8 units". That is a magic number: it
encodes a snapshot, it broke the moment content shifted, and it teaches everyone
to nudge the threshold. It is replaced by the relational invariant

  for every pair (a, b):   distance(a, b) >= radius(a) + radius(b)

which is scale-free and content-independent — it holds for 40 nodes and 40,000 —
and cannot be satisfied by fiddling a constant, only by a layout that is actually
correct. When you cannot state the invariant without a magic number, the design
is wrong, not the number.

It caught a real bug in this very implementation on its first run. The file orbit
was sized for TOTAL arc (sumR / pi) but files were placed by phyllotaxis, which
distributes well on average and guarantees NO minimum gap between ADJACENT files
of differing size — two large neighbours overlapped at a ratio of 0.5. Sizing an
orbit is not the same as spacing what sits on it. Files now get an angular slot
proportional to their own radius, which is what makes the corrected bound
orbit >= sumR / 2 fall out of the placement rule rather than being guessed.

Measured on the self-graph, settled, 460 nodes:

                                    before P1   after L-019   after pipeline
  nodes overlapping nearest nbr       65.1%        16.4%          0.0%
  median nearest-neighbour gap         0.72         1.61          1.53
  10th-percentile gap                  0.24         0.71          1.09

Idempotence is guarded separately: reseeding via a dialect round-trip must land
on bit-identical coordinates. Stages 1-4 are pure functions of the graph, not of
the previous layout.

parallelSpines and the legacy few-root spine branch are still on the old
constant-driven path and still carry this defect (L-021). They should be moved
onto the pipeline rather than tuned.
A five-way sweep of everything that decides what reaches the canvas — renderer
seam, attribute contract, physics boundary, style/theme pipeline, registries and
settings — ahead of importing a three.js renderer, custom GLSL, and a real
z-axis. 50 findings, verified against on-disk code rather than against older
docs, which have repeatedly pointed at components that no longer existed.

The tracking has two halves, because they solve different problems.

A LEDGER fixes HISTORY. Entries are append-only: an entry is closed by appending
a dated line, never by rewriting its body, and a wrong finding is Withdrawn
rather than deleted. Status is the one mutable field, and it may only move
alongside an appended line saying what happened. Rewriting roadmap items in
place is how we lost track of what was actually done, and when.

But a ledger does NOT fix TRUTH. An open entry can quietly become false when
someone fixes the underlying thing by accident, and nobody notices. Prose cannot
police itself.

So the load-bearing claims get GUARDS: characterization tests that assert what is
CURRENTLY true, several of them pinning a known defect in place on purpose. A
failure is not a broken test — it means a documented fact about the tree has
changed, and the correct response is to close the ledger entry, then update the
guard. Never relax a guard to match new behaviour without the accompanying line.

The guards earned their keep immediately. The attribute-contract guard failed on
its FIRST run, and the reason was a live bug no amount of reading would have
surfaced: buildGraphologyGraph computes the highest-degree node per cluster and
tags 11 of them with isSun, and SigmaGraphView deletes every one of them on the
very next line — a "clear on rebuild" loop that runs on every build. So
graphStylePolicy's sun branch (cluster colour + x1.8 size) is unreachable dead
code. Both halves look correct in isolation; only the contract catches it. That
is GD-050, and it is now guarded.

Nine guards:
- the seam ratchet — files reaching through window.__lwSigma from outside the
  renderer. At 7 today, target 0. It permits progress and forbids regression,
  which is the pressure you want during a migration.
- z is consumed by nothing, the sim never moves it, radial-backbone seeds a plane
- the node and edge attribute contracts, exactly
- alpha is written by dimmingPolicy and read by nobody (all of dim mode is inert)
- isSun is deleted the instant it is computed
- the seed handshake maps exist and cover every node
- the engine's dialect registry is not the display-only duplicate

RENDERER_MIGRATION.md stages the work to make a renderer swap possible: clear the
ground, build the seam, make the payload honest, decide the z-axis fork, then
swap. The thesis is that graphology is the scene model and is already
renderer-neutral — physics, style, labels and neighborhood never import Sigma —
so the blocker is not the renderer, it is that there is no seam to swap AT.
window.__lwSigma is the de-facto API, and the minimap reimplements Sigma's
internal coordinate normalization, so "swap the renderer" today secretly means
"rewrite the minimap and hope the camera maths agrees".
Five things that look like a seam and are not:

- src/renderers/ — an empty directory
- graphRendererInterface.ts — a GraphRenderer interface with ZERO importers. It
  is also too thin to be the real boundary: no hit-testing, no viewport
  projection, no per-item hover, no program registration. Left in place it would
  attract an implementation that then does not fit, so it is deleted rather than
  implemented against. The real seam is designed in RENDERER_MIGRATION.md
  (RM-005). GD-001 stays OPEN — removing a false seam is not the same as having
  a true one.
- renderers/sigma2d/labelPolicy.ts — 331 lines, zero importers, superseded by
  visual/graphLabelPolicy.ts
- edges/edgeStyleRegistry.ts — an empty stub with zero consumers
- control-plane/features/feature-registry.ts — a 0-byte file with zero importers
  (feature flags live in feature-flags.ts, which is config, not a registry)

Verified individually before deleting, and that check earned its keep: the first
grep for "GraphRenderer" matched MiniGraphRenderer, a substring false positive
that looked like five live importers.

Their contract docs are retired with them — a contract for a module that no
longer exists is exactly the stale doc we are trying to stop producing — and the
four docs that pointed at them now point at the ledger instead, so the deletion
does not leave a trail of dangling references.

@sigma/node-image is also unused but NOT removed here: dropping a dependency
touches the lockfile and wants a deliberate npm uninstall. Logged as GD-009.
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