feat(graph-source-ux): Graph Source UX — all 7 phases - #1
Open
bitmosh wants to merge 22 commits into
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 bumpSA-004: Regenerate button scoped to self-graph adapter onlySA-014:categoryfield onSourceAdapterEntry(file-based|directory-based|database|stream)SA-022:SourceEntrytype +sources.libraryschema (pinned/recent); migration fromsources.historyPhase 2 — Empty state + picker modal
SA-017:EmptyPanecomponent — "Select graph source" affordance when no adapter is activeSA-018:GraphSourcePickermodal — Recent tab + Open new tab; adapter cards grouped by category; inline config form before any load firesSA-019: Cancel button during in-flight loadsSA-020: Error state with three escape hatches (Try again / Different config / Different adapter)SA-023: "Change source" button in loaded stateSA-006:formatHintfield on adapter entries; displayed in picker cardsSA-007: Cytoscape format guard error message rewritePhase 3 — Directory scanning
SA-015:scan(target)interface on adapters; returns scoredScanCandidate | nullSA-021: "Scan directory" action in picker; concurrent scan across all adapters; results ranked by score with "Different type ↓" fallbackPhase 4 — Library tile
SA-009:pushLibraryEntryon successful load; djb2 hash IDs; dedup-in-place; 20-entry recents capSA-010: Pinned + Recent sections inGraphSourcesTileContent; click-to-load restores configSA-024: "Reinterpret as…" — opens picker pre-filled with entry config for adapter switchingSA-025: Delete with inline confirm rowpackageDependencyAdapternormalisesprojectPathending in/package.json(was hitting ENOTDIR)Phase 5 — Thumbnails
SA-011/013: 2-second timer aftersummary.status === "loaded"transition →sigma.once("afterRender")→ composite edges+nodes+labels onto offscreen canvas → 300×200 JPEGSA-012:updateLibraryEntryThumbnailstore action; stored inSourceEntry.thumbnailDataUrlSA-012b: 60px thumbnail strip at top of each library cardPhase 6 — Dev mode gating
SA-026:source-adapter-sectiontile nowrequiresDevMode: true— hidden from standard usersSA-027: Collapsible Advanced section in picker (dev mode only) — shows raw adapterId, inputPattern, config JSONPhase 7 — Polish
SA-028:renameLibraryEntrystore 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 adapterinputPattern.pattern(glob, brace-alternation, exact names); suggestion chips appear below scan input@tauri-apps/plugin-dialoginstalled; Browse file / Browse folder buttons in picker (hidden behindisTauriRuntimeguard in Playwright)New files
src/control-plane/graph-sources/EmptyPane.tsx+.csssrc/control-plane/graph-sources/GraphSourcePicker.tsx+.csssrc/control-plane/graph-sources/GraphSourcesTileContent.tsx(replaced stub)docs/roadmap/GRAPH_SOURCE_UX.mdtests/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.ts—SourceEntry,SourcesSettingssrc/control-plane/settings/settings.store.ts—pushLibraryEntry,pinLibraryEntry,unpinLibraryEntry,removeLibraryEntry,updateLibraryEntryThumbnail,renameLibraryEntrysrc/control-plane/settings/settings.migrations.ts— migration to v96src/control-plane/panels/tileSectionRegistry.ts—source-adapter-sectiongated;graph-sources-sectionaddedsrc/source-adapter/sourceAdapterRegistry.ts—category,formatHint,scan(),ScanCandidatesrc/source-adapter/adapters/packageDependencyAdapter.ts— projectPath normalisationsrc/app/AppShell.tsx— thumbnail capture effectsrc-tauri/src/lib.rs—tauri_plugin_dialog::init()src-tauri/capabilities/default.json—dialog:allow-openTest counts
Not in this PR
.github/ISSUE_TEMPLATE/changes,.gitignore,README.md— pre-existing untracked modifications excluded from all commits🤖 Generated with Claude Code