diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index b7d364c3..488e03a0 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -119,7 +119,7 @@ Evidence: `src-tauri/src/fs.rs`, `.github/workflows/ci.yml`, and `tests/e2e/`. ### Renderer abstraction and 3D data -- `graphRendererInterface.ts` is an extension seam, not a second renderer implementation. +- There is no renderer seam. The `graphRendererInterface.ts` stub was deleted (zero importers, too thin to implement against); the real one is designed in `docs/ledger/RENDERER_MIGRATION.md` (RM-005). Today the de-facto renderer API is the `window.__lwSigma` global. - GWells seeders can write `z`, but the active force integration and Sigma renderer use two-dimensional positions. - Three.js, React Three Fiber, and Drei are dependencies without runtime imports in `src/`. diff --git a/docs/canonical/GRAPH_DISPLAY_MAP.md b/docs/canonical/GRAPH_DISPLAY_MAP.md new file mode 100644 index 00000000..aff4491b --- /dev/null +++ b/docs/canonical/GRAPH_DISPLAY_MAP.md @@ -0,0 +1,297 @@ +# Graph Display Map — everything that decides what you see on the canvas + +**Purpose.** One place to see every subsystem that touches graph rendering, the contract each one speaks, and — critically — which parts are **live**, which are **dead**, and which are **lying**. Written as the reference for importing external work (a physics engine, a three.js renderer, custom GLSL node shaders, a real z-axis) without landing it on a seam that isn't there. + +> **This document is narrative, and narrative goes stale.** It is a snapshot taken **2026-07-12**. +> +> The things that do *not* go stale live elsewhere, and they are the authority: +> - **`docs/ledger/GRAPH_DISPLAY.md`** — the append-only findings ledger (`GD-###`). Items are closed by appending a dated line, never by rewriting. +> - **`tests/e2e/graph-contract.spec.ts`** — executable guards. These are **characterization tests**: they assert what is *currently true*, including several known defects, on purpose. When a guard fails, a documented fact has changed — go close the ledger entry. +> +> If this document and a guard disagree, **the guard is right**. Fix the document. +> +> (This is not theoretical: the attribute-contract guard found GD-050 on its first run — `isSun` is computed by the builder and deleted by the renderer on the next line — a bug that reading the code did not surface, because both halves look correct in isolation.) + +Verified against the tree, not against older docs. Every claim below is `file:line`-checkable. + +--- + +## 0. The five-second version + +- **Graphology is the scene model, and it is renderer-neutral.** Physics, style policy, labels, neighborhood, and dimming all operate on graphology attributes and never import Sigma. This is the single biggest asset in the codebase. **Keep it. Swap only the view.** +- **There is no renderer abstraction.** A `GraphRenderer` interface exists with **zero importers**; `src/renderers/` is an **empty directory**. The de-facto renderer API is the global **`window.__lwSigma`**, read by 7 files outside the renderer. +- **`z` is write-only.** Six writers, one reader — and that reader only round-trips it back into settings so a pin doesn't destroy it. Nothing renders it. No force touches it. +- **The physics is strictly 2D, `dt` is implicit = 1 frame, and force evaluation is brute-force all-pairs.** +- **Several visual features write into the void.** Edge colours, node/edge `alpha` (i.e. all of dim mode), and per-edge shader phase are computed and then discarded. See §6. + +--- + +## 1. The pipeline, end to end + +``` +source adapter + └─► LumaWeaveNodeDraft[] / LumaWeaveEdgeDraft[] (untyped `raw` bag) + └─► buildGraphologyGraph() ← the ONLY builder + └─► graphology Graph({ multi: true }) ← THE SCENE MODEL (renderer-neutral) + ├─► gwells applyDialect() ← seeds, then mutates x/y every frame + ├─► graphStylePolicy ← rewrites color/size/labelColor/alpha + ├─► graphLabelPolicy ← rewrites `label` + ├─► dimmingPolicy ← writes `alpha` (NOTHING READS IT) + └─► SigmaGraphView ← the ONLY renderer + ├─ nodeProgramClasses (5 WebGL programs) + ├─ PlasmaEdgeProgram (1 WebGL program) + └─ window.__lwSigma ← the real, unofficial API surface +``` + +Everything above the `SigmaGraphView` line is portable. Everything at or below it is Sigma. + +--- + +## 2. Node attribute contract + +`size`/`baseSize` are **radii in graph units**, not pixels — Sigma runs with `itemSizesReference: "positions"` (`SigmaGraphView.tsx:430`), so they live in the same coordinate space as `x`/`y` and scale with the camera. **Any renderer or physics engine that assumes pixels will be wrong by the camera ratio.** (This exact confusion produced nodes as wide as the gaps between them; see `LAYOUT_AND_PHYSICS.md` L-019.) + +| Attribute | Type | Meaning | Written by | Read by | +|---|---|---|---|---| +| `x`, `y` | number | graph-space position | builder (init 0), **seeders**, **engine** (per tick), **drag handler** | engine, Sigma, drag | +| **`z`** | number | depth | seeders, `applyPins` | **drag-pin round-trip only — see §3** | +| `fixed` | boolean | engine skips this node | engine, drag handler | engine | +| `rawSize` | number | source content size (aggregate for dirs) | builder | seeders (child sort order) | +| `baseSize` | number | **structural radius**, pre-user-multiplier | builder | style policy, seeders, renderer | +| `size` | number | **rendered radius** = `baseSize × settings.nodeSize`, then restyled | builder, **style policy**, renderer | Sigma | +| `color` | string | fill | builder (from `raw.color`), style policy, path highlight | Sigma / node programs | +| `label` | string | **truncated, mutable, may be `""`** | builder, label policy, hover | Sigma | +| `fullLabel` | string | **the real text** — immutable | builder | label policy, hover | +| `originalLabel` | string | duplicate of `fullLabel` (legacy) | builder | label policy fallback | +| `labelColor` | string? | per-node label tint (hover only) | style policy | Sigma (`labelColor: { attribute }`) | +| `type` | `NodeProgramId` | **Sigma render program** — `glass-sphere \| sun \| crystal \| orb \| pip` | builder, override listener | Sigma | +| `nodeType` | string | **semantic kind** — `directory \| code \| doc \| config \| fixture \| file \| spine \| …` | builder | engine, seeders, structural resolver, renderer | +| `raw` | object | full source payload (`color`, `cluster`, `size`, `path`, …) | builder | style policy (`raw.color`), resolver, inspector | +| `isSun` | boolean | highest-degree node in cluster | builder | style policy (×1.8 size) | +| `isIsolated` | boolean | singleton component | builder | style policy (×0.75 size) | +| `isEndpoint` | boolean | terminal spine node | **seeders** | dialect well-assignment | +| `alpha` | number | dim opacity | dimming policy | **NOBODY — see §6** | + +### Two traps + +**`type` vs `nodeType`.** `type` is the *renderer program id*. `nodeType` is the *semantic kind*. A new renderer keying geometry off `type` gets Sigma program names. Rename deliberately if you import a different geometry system. + +**Anything you write to `color` or `size` will be clobbered.** `resetGraphStyles()` rewrites both on **every** node and edge on **every** interaction change, sourcing colour from `raw.color` and size from `baseSize`. Renderer-owned writes to these do not survive the next hover. + +### Written but never read (safe to delete) +`cluster`, `componentIndex`, `isInLargestComponent` (nodes); `id`, `weight` (edges). Graph-level diagnostics `clusterSunCount`, `componentCount`, `isolatedNodeCount`, `largestComponentSize`. + +### Does not exist +`hidden`, `forceLabel`, `zIndex` are **not graph attributes** anywhere in this tree, despite appearing in older notes. + +--- + +## 3. The z-axis — exact current state + +**Verdict: computed, stored, persisted, and dropped on the floor at the render boundary.** + +**Writers (6):** both seeders (directories, files, spine nodes, fronds), `seederHelpers.applySeedPosition`, and `engine.applyPins` (pass-through only). + +**Readers (1):** `SigmaGraphView.tsx:733`, inside drag-to-pin — it reads `z` purely to write it back into the settings pin map so pinning doesn't destroy it. `applyPins` then reads it back out and re-writes it to the graph. **A closed loop that feeds nothing.** + +**Provably unused:** the Sigma `nodeReducer` returns `{...base, x, y}` and never touches z. The integrator reads only `x`/`y`; `GWNodeState` has `vx`/`vy` and **no `vz`**. No node or edge program consumes z. Spring rest-lengths are computed 2D, ignoring z. + +**Three caveats for the 3D work:** +1. `buildGraphologyGraph` **never initializes `z`** — un-seeded nodes have **no `z` attribute at all** (`undefined`, not `0`). Default defensively. +2. Under **radial-backbone** (the default dialect) `z` is a constant **0** — the seeder inherits it unchanged down the tree. Only **parallel-spines** produces genuine azimuthal depth. +3. **Physics will never update `z`.** It is a frozen seed value. + +There is a hard-won lesson attached to it (`GWELLS_PHYSICS.md`): parallel-spines once fanned its branches in the **x/z plane**, which Sigma projects to a *line* — the structure was invisible and the nodes rendered as a pile. **Structure that must be visible today has to live in the plane the camera actually renders.** `z` is for data that is waiting, not for structure that is load-bearing now. + +--- + +## 4. Physics ↔ graph boundary + +**The graph is the interface.** No position-buffer handoff — `applyDialect(graph, dialectId, opts) => GWController` takes a live graphology `Graph` and mutates it in place. An imported engine must speak graphology. + +| Direction | Surface | +|---|---| +| **In** | node `x`, `y`, `fixed`, `nodeType`/`raw.type`/`raw.kind`, `isEndpoint`, `baseSize`; edge `relationship`/`raw.type === "contains"` | +| **Out** | node `x`, `y` per tick; graph attrs `__gwellsState`, `__gwellsSeedPositions`, `__seededSpinePositions`, `__gwellsPinnedSet` | +| **Control** | `GWController`: `stop/pause/resume/step/getRuntimeState/getDialectId/getResolvedConfig/applyConfigOverride/applyPins` | +| **Drive** | self-scheduling rAF; injectable `GWScheduler`; `step()` for headless | + +### Properties an imported engine must match (or deliberately break) +- **Strictly 2D.** No `vz`, no 3-vectors, no z force. +- **`dt` is implicit = 1 frame.** Position update is literally `x + vx`. Damping is a per-frame multiplier, *not* exponential in dt. A lab that assumes a timestep will not drop in. +- **Brute-force all-pairs.** Interactions bucket by source well type, then O(|source| × |target|). No quadtree, no Barnes-Hut. **This is the scaling wall — the most valuable thing to replace.** +- **Vocabulary is small and fixed:** 4 well types, 8 interactions, 5 force kinds (**2 inert** — `attraction` is registered by nothing; `linear-alignment` is a documented no-op), 3 `requireEdge` filters, **5 per-well parameters**. + +> **The parameter trap.** Any parameter name the force loop doesn't read does nothing, silently. `siblingRepulsion` was tuned across four well types and overridden per-dialect and **read by nothing** — anyone tuning the layout reached for the parameter *named after the problem* and watched it do nothing. It has been deleted. Don't reintroduce the shape. + +### The seed handshake — easy to miss, breaks silently +A seeder must write **two graph-level maps**, not just `x`/`y`: + +- **`__gwellsSeedPositions`** — `Map`, **every** node. Feeds the `seedAdherence` restoring force **and** the per-pair spring rest lengths. The spring's ideal length for a parent/child pair is *the distance the seeder chose*, not the well-type default. +- **`__seededSpinePositions`** — `Map`, spine nodes only. Read by the Sigma `nodeReducer`, which **overrides x/y at render time**. Spines are pinned twice: by `wellType.pinned` and again at the renderer. + +An imported seeder that writes only x/y silently disables seed adherence, collapses springs to a static default, and breaks spine pinning — **with no error.** + +### Well assignment +Runs **once**, at `applyDialect` (not on config override). Two-tier: explicit attribute tags (`nodeType`, `isEndpoint`) first, then structural inference from `analyzeGraphStructure` (roles: `root/spine/container/leaf/orphan/hub/bridge/unknown`, via BFS depth + Tarjan articulation points). **The `contains` edge is the load-bearing structural fact** across the entire system — roles, parent map, `requireEdge` filters, seeder trees, aggregate sizing. + +### Observability that already exists +`GWStepResult` carries `stepsRun, movedNodeCount, maxVelocity, averageVelocity, warnings` plus `GWStepTimings { totalMs, resetMs, seedLookupMs, forceInteractionsMs, auxForcesMs, integrationMs }` — **ready-made for benchmarking an imported engine against this one.** `GWNodeState.activeInteractions` tells you which forces pulled on a node this frame. + +⚠️ **Gap:** `GWStepResult.warnings` (non-finite force/position guards) is **discarded by the rAF loop** — it only surfaces if you call `step()` yourself. NaN guards fire silently in the animated path. + +--- + +## 5. Visual / style / theme pipeline + +``` +settings.appearance.theme ──► getThemeRuntimeTokens ──► themeTokens + ├─► app.* ──► rAF crossfade (300ms) ──► --lw-* CSS vars ──► DOM chrome + overlays + ├─► backdrop.* ──► React props ──► SolarBackdrop (DOM) + ├─► graph.* ──► resolveGraphVisualTokens ──► resolvedTokens prop + │ └─► applyGraphStylePolicy ──► graphology attrs + │ ├─ node color/size ──► RENDERED + │ ├─ edge color ──► DISCARDED (§6) + │ ├─ edge size ──► RENDERED + │ └─ alpha ──► DISCARDED (§6) + ├─► node.geometry.preset ──► node `type` attr ──► program select ──► RENDERED + └─► themeId ──(read from store INSIDE the shader)──► PLASMA_THEME_DEFAULTS ──► edge colors ──► RENDERED +``` + +**CSS variables cannot reach WebGL.** The canvas is themed by three separate mechanisms: +1. **JS token props → graphology attributes** → Sigma packs colour strings into vertex buffers. *Node colours only.* +2. **Uniform refs** — `uniformsRef` (`time`, `hum`, `flowSpeed`, `glowStrength`) is monkey-patched onto the Sigma instance and read by every node program's `setUniforms`. Fed from `settings.appearance.*`, **not** from theme tokens. +3. **`PlasmaEdgeProgram` reads the settings store directly** and looks up a *second, parallel per-theme palette* (`PLASMA_THEME_DEFAULTS`) that has nothing to do with `themeTokens.graph.edge*`. + +**Cluster colour outranks the theme.** `resetGraphStyles` reads `raw.color` *before* the token, and `raw.color` is written from a theme-independent cluster palette. So on a clustered graph, `nodeColor.default` is **never seen**; theme switching only repaints unclustered nodes, selection states, and hover. + +**Sizes are not themeable.** All `nodeSizeMultiplier` / `edgeSize` / `labelFontSize` values are hardcoded in `resolveGraphVisualTokens`. Only colours flow from the theme. + +### Node geometry presets (the shader seam you're importing into) +Five programs — `glass-sphere` (default), `sun`, `crystal`, `orb`, `pip` — all extending Sigma's `NodeCircleProgram` and overriding only the **fragment shader**. Selection: `getGlobalOverride("node.geometry.preset")` → theme token → `"glass-sphere"`. Result is written to each node's `type` attribute. All programs must be **pre-registered at construction**. + +**The fragment shaders are portable nearly verbatim** — they're distance-field circle math on `v_diffVector`/`v_radius`. The inherited *vertex* path and attribute layout are not. + +**`PlasmaEdgeProgram` is the hardest thing in the tree to port.** 552 lines: a from-scratch vertex+fragment pair, subdivided quadratic-bezier ribbon (12 segments, 72 verts/edge), hand-packed attributes, ~30 uniforms — and it detects Sigma's picking pass by reading `gl.getParameter(gl.FRAMEBUFFER_BINDING)` and swapping blend modes. That depends on undocumented Sigma internals. + +--- + +## 6. Things that are computed and then thrown away + +These are live bugs, and every one of them is on the seam you're importing across. **Fixing them may be free side-effects of the port.** + +| What | Reality | +|---|---| +| **Edge colours** | `PlasmaEdgeProgram.processVisibleItem` reads `size`, `_phase`, `_midStop`, `_hoverFactor` — **never `data.color`.** Since `plasma` is the only registered edge program, *every* edge-colour write in the style policy (default, selected, hovered, secondary, tertiary) is discarded by the GPU. Only edge *size* survives. `themeTokens.graph.edge*` and `edgeColorScale` are effectively unused. | +| **Dim mode (all of it)** | `dimmingPolicy` faithfully computes and writes `alpha` on every node and edge, and **nothing reads it.** Sigma has no `alpha` attribute; there is no `nodeReducer` mapping it to colour alpha; the shaders' local `float alpha` is anti-aliasing falloff, unrelated. **A renderer that honours `alpha` would fix focus/context for free.** | +| **Per-edge shader variation** | `_phase` and `_midStop` are **read by the shader but written by nobody** — permanently `0` and `0.5`. Every edge pulses identically, in phase. | +| **`dimMode` setting** | Exists in the schema, written by the inspector — but **never passed** to `applyGraphStylePolicy`. Falls through to `"off"` unless `pinnedHighlightActive` forces it. | +| **`dimOpacity`** | Hardcoded `0.18`; the per-theme `selection.dimOpacity` (0.12–0.22) is ignored. | +| **`outside-cluster` BFS** | Unbounded — `clusterDepth` is never used, so it floods the whole connected component. | +| **`zoomLabelThreshold`** | In settings, in props, in the memo comparator — its **only consumer is the debug StatusBar.** No camera-ratio gate exists. | +| **Shortest-path highlight** | Writes the *static* module tokens, not `resolvedTokens` — so it's always `#fbbf24` regardless of theme. | +| **`depth >= 4` styling** | Unreachable: `NeighborhoodDepth = 1 | 2 | 3`. | +| **GlitterField** | Positioned by projecting through `sigma.graphToViewport()` during React render, not on camera move — **desyncs from the node while panning/zooming.** | + +--- + +## 7. Registries — which are real + +**Only three registries actually drive the canvas:** + +| Registry | Drives | +|---|---| +| `nodeProgramRegistry` | the 5 WebGL node programs → `nodeProgramClasses` | +| `themeTargetRegistry` | theme tokens → `resolvedTokens` → node colours | +| gwells `dialects.ts` + `GW_SEED_FUNCTION_REGISTRY` | **the actual layout engine** | + +**Everything else is a catalog, and two of them actively mislead:** + +- **`physicsDialectRegistry` is a stale duplicate of the real dialects — in a different ID namespace.** It says `dialect.gwells.radial-backbone`; the engine says `gwells.dialect.radial-backbone`. **The IDs cannot even be joined.** Its `paramSchema` is not what the engine accepts. Nothing in `src/physics/` imports it. +- **`lensRegistry` does nothing.** `layoutFn` was explicitly dropped ("no dispatch yet"); its IDs are disjoint from the `LayoutLensId` union in the schema. Nothing dispatches on a lens. +- Consequently there are **three unsynchronized copies of the layout tuning numbers** (gwells `seedParams`, `physicsDialectRegistry.defaultSettings`, `lensRegistry.suggestedSettings`). +- `graphViewElementRegistry`, `graphVisualThemeMappingRegistry`, `motionSafetyRegistry`, `audioSourceRegistry`, `musicReactiveMappingRegistry` — all **self-declaredly passive**. Zero canvas effect. + +> **Do not wire an imported engine into `physicsDialectRegistry` or `lensRegistry`.** They look like the taxonomy. They are not. + +### Adding to the real registries +- **Well type:** append to `GW_WELL_TYPE_REGISTRY` (5 params + `pinned`), then reference it from ≥1 interaction *and* a dialect's `wellAssignment` — otherwise no node ever gets it. +- **Interaction:** append to `GW_INTERACTION_REGISTRY`, then add its id to a dialect's `activeInteractions` — **unlisted interactions never fire.** +- **Dialect:** append to `GW_DIALECT_REGISTRY` **and widen `GwellsDialectId`** in `settings.schema.ts` (a hardcoded string union) or the UI cannot select it. + +All four gwells registries are frozen `as const` arrays with linear-scan getters. No `register()`, no subscription. Adding = editing the literal and recompiling. + +--- + +## 8. Settings that reach the canvas + +Of nine `graphView` fields, **three** reach the renderer: `nodeSize`, `hoverNodeColor`, `neighborhoodDepth`. + +**Dead knobs — parsed, defaulted, persisted, migrated, never read:** + +| Knob | Status | +|---|---| +| `defaultLayout` | **Zero readers.** Typed as 7 layouts (`constellation`, `districts`, `solar-orbit`, `helix`, `trihelix`, `pipeline`, `impact-rings`) — **none of which exist.** The single most misleading knob in the schema. | +| `showArrows` | Zero references. | +| `showIsolatedNodes` / `showLowConfidenceEdges` | Write-only — toggled by command-palette entries, read by nobody. | +| `defaultRenderer` | Already typed `"sigma2d" \| "cosmograph2d" \| "three3d"` — **but selects nothing**; its only consumer prints it as a debug string. | +| `dimMode` | Inert on the main canvas (§6). | + +Stale settings paths referenced by control-plane registries but **absent from the schema**: `graphView.nodeSelectionStage`, `physics.nodeSize` (moved to `graphView` in the v81→v82 migration), `graphIntelligence.*` (no such slice). + +**Good news for the renderer swap:** `defaultRenderer` already has the right *shape* and is already migrated. It just isn't wired. + +--- + +## 9. Dead code + +**Swept 2026-07-12** (RM-001). Deleted, with their contract docs retired alongside them: + +| Path | Was | +|---|---| +| `src/renderers/` | empty directory | +| `src/graph/rendering/graphRendererInterface.ts` | `GraphRenderer` interface — zero importers, and too thin to be the real seam (no hit-testing, no viewport projection, no program registration). Deleted rather than implemented against; the real seam is designed in `RENDERER_MIGRATION.md` (RM-005). | +| `src/graph/renderers/sigma2d/labelPolicy.ts` | 331 lines, zero importers (superseded by `visual/graphLabelPolicy.ts`) | +| `src/graph/edges/edgeStyleRegistry.ts` | empty stub, zero consumers | +| `src/control-plane/features/feature-registry.ts` | 0-byte file, zero importers (feature flags live in `feature-flags.ts`, which is config, not a registry) | +| `docs/graph/contracts/GRAPH_RENDERER_INTERFACE_CONTRACT.md` | contract for a module that no longer exists | +| `docs/graph/contracts/EDGE_STYLE_REGISTRY_CONTRACT.md` | ditto | + +**Still outstanding:** + +| Path | Note | +|---|---| +| `src/graph/edgePrograms/shaders/*.glsl` + `src/graph/nodePrograms/shaders/*.glsl` | **7 orphaned files.** No GLSL loader in Vite; every live shader is an inline template literal in its `.ts`. **Stale duplicates that may have drifted.** Not deleted yet because the decision is *which way* to resolve them — see RM-002, and resolve it **before importing any shaders**. | +| `@sigma/node-image` | in `package.json`, never imported. Removing it touches the lockfile, so it needs a deliberate `npm uninstall`. | + +**Already installed, entirely unused:** `three`, `@react-three/fiber`, `@react-three/drei`. There is a `three3d` feature flag hardcoded to `false`, and `graphView.defaultRenderer` is already typed `"sigma2d" | "cosmograph2d" | "three3d"`. **The runway is built; nothing has taken off.** + +--- + +## 10. What the import actually has to land on + +### Portable as-is (do not rewrite) +`buildGraphologyGraph`, `selectionNeighborhood`, the whole `src/graph/visual/*` policy layer, all of `src/physics/gwells/*`, selection state in AppShell. **Graphology is load-bearing across physics + policy + neighborhood — keep it.** + +### Must be rewritten for a renderer swap +1. `SigmaGraphView.tsx` (~1250 lines). The props interface survives nearly intact — drop `resolvedTokens.sigmaConfig`, which names the renderer inside the theme contract. +2. `PlasmaEdgeProgram` — the picking-pass framebuffer hack has no analogue; needs a raycaster or GPU-picking pass. +3. Node program **vertex** paths (fragment shaders port nearly verbatim). +4. Camera: pan/zoom/rotate input, `viewportToGraph`/`graphToViewport`, `getDimensions`. +5. Hit-testing: `clickNode`/`enterNode`/`enterEdge`/`downNode` have no three.js equivalent. +6. Node drag — the logic is portable, the coordinate conversion is not. + +### Blocks a clean swap until fixed +**`window.__lwSigma` is the real seam, and it is a global.** The minimap (4 files) and AppShell (thumbnail capture, GlitterField positioning) bypass the props interface entirely. `useMinimapNavigation` **reimplements Sigma's internal coordinate normalization** and would silently produce wrong pans under any other camera model. + +> **The honest sequencing.** The abstraction this repo needs is *not* the `GraphRenderer.mount()` stub — that's too thin (no hit-testing, no viewport projection, no per-item hover, no program registration). It's a **viewport-projection + hit-testing + camera facade** that the minimap and AppShell consume *instead of* `window.__lwSigma`. **Land that first, against Sigma, with the suite green. Only then is there a seam to swap at.** + +--- + +## 11. Where to observe this at runtime + +`GraphVisualInventoryPanel` (Evidence tab / Advanced / poppable tile) already renders eight registries and is the only surface with this ambition. To become authoritative it needs: +- the **three registries that actually drive the canvas** (`nodeProgramRegistry`, `themeTargetRegistry`, gwells `dialects`) — none of which it currently shows; +- a **live/dead column** — today it renders the fake `physicsDialectRegistry` and inert `lensRegistry` with **exactly the same visual weight as the real ones**, which actively misleads. + +It is a *viewer*, and its e2e suite asserts read-onlyness. Making it authoritative means feeding it truth, not adding controls. diff --git a/docs/canonical/GRAPH_SIGMA_AND_RENDERING.md b/docs/canonical/GRAPH_SIGMA_AND_RENDERING.md index a420ac9b..0c6b746e 100644 --- a/docs/canonical/GRAPH_SIGMA_AND_RENDERING.md +++ b/docs/canonical/GRAPH_SIGMA_AND_RENDERING.md @@ -101,7 +101,7 @@ Theme runtime tokens are resolved into graph visual tokens before policy writes ## Future renderer boundary -`src/graph/rendering/graphRendererInterface.ts` describes a small mount/camera/refresh contract, but integrating a second renderer requires real adapter work: +There is **no renderer seam today.** A `graphRendererInterface.ts` stub once existed; it had zero importers and was too thin to be the real boundary (no hit-testing, no viewport projection, no program registration), so it was deleted rather than implemented against. The real seam is designed in `docs/ledger/RENDERER_MIGRATION.md` (RM-005). Integrating a second renderer requires real adapter work: - Express graph visual policy without relying on Sigma-specific attributes. - Map selection, camera, labels, materials, and theme tokens. @@ -126,5 +126,5 @@ Three.js, React Three Fiber, and Drei are installed but unused by runtime source - Programs: `src/graph/nodePrograms/`, `src/graph/edgePrograms/` - Policies: `src/graph/visual/` - Overlays/camera: `src/graph/overlay/` -- Renderer type seam: `src/graph/rendering/graphRendererInterface.ts` +- Renderer seam: none yet — see `docs/ledger/RENDERER_MIGRATION.md` (RM-005). The de-facto API is the `window.__lwSigma` global (GD-002). - Physics: `src/physics/gwells/` diff --git a/docs/canonical/GWELLS_PHYSICS.md b/docs/canonical/GWELLS_PHYSICS.md index 30313594..950fad31 100644 --- a/docs/canonical/GWELLS_PHYSICS.md +++ b/docs/canonical/GWELLS_PHYSICS.md @@ -120,7 +120,13 @@ All four are registry additions — the engine reads the registries; you don't t ## §5 — How it's designed to grow - **Composition scales by registration.** N well types × their interactions compose additively in the force loop; a new dialect is a new bundle, not new engine code. The cost of growth is registry entries and per-frame pair cost, not engine complexity. +- **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. Read them that way or the layout silently breaks: `computeNodeSize` was once inflated to a 48–360 range while `directoryOffset` stayed at 220, so the median node's *radius* equalled the entire distance to its parent and every node overlapped its neighbours at every zoom level — while the seed positions were provably correct. **A node radius is only meaningful relative to the distance to the next node.** If you change one, change the other. + +- **Geometry reads `baseSize`; only rendering reads `size`.** `size` is `baseSize × settings.nodeSize`, and `graphStylePolicy` further rewrites it on hover and selection. Anything that computes *positions* from it — file orbits, repulsion, spacing — coupled the layout to what happened to be selected, and made the node-size slider silently reshape the graph. `baseSize` is the structural radius and the only one layout may touch. + - **3D is seeded already.** Seed functions store a `z` attribute (parallel-spines arranges spines in a ring around a central axis in 3D) for forward-compatibility with a future 3D camera — the data is there ahead of the renderer. This is the seam the eventual three.js/react-three-fiber path consumes. + + **Caveat, learned the hard way (L-001):** seeding into `z` is free, but *arranging* into it is not. parallel-spines used to fan directory branches and orbit files in the **x/z plane**. Sigma renders only `(x, y)`, so that entire arrangement was projected away: branches that were correctly spread in 3D rendered on top of one another, and file orbits — horizontal rings viewed exactly edge-on — collapsed to a line segment in which every ±θ pair was coincident. Both now fan in the spine's own **vertical plane**, spanned by the spine's outward radial direction and ŷ, so the structure varies in `x` *and* `y` and survives the projection, while `z` still carries the azimuth for the future 3D camera. The rule: **any structure that must be visible today has to live in the plane the 2D camera actually renders.** `z` is for data that is waiting, not for structure that is load-bearing now. - **Live tuning + pinning** are first-class via the controller (`applyConfigOverride`, `applyPins`), enabling interactive layout authoring without restarts — the basis for a future dialect-tuning UI. - **Decoration hook** is the seam for audio-reactive and other per-frame visual modulation (deferred features) without entangling them with physics. - **Performance note:** `stepPhysics` is benchmarked via `npm run physics:gwells:bench`, which records `benchmarks/gwells-latest.json` and coarse `GWStepTimings` buckets. `npm run physics:gwells:bench -- --update-baseline` intentionally refreshes the committed `benchmarks/gwells-baseline.json`; normal runs leave the baseline unchanged. The current fixture matrix covers filesystem-small/medium/large, current-like-400, hierarchy-1000, hierarchy-2000, stress-5000, wide-roots-30, mixed-graph, generic-no-spine, and disconnected-orphan-heavy. diff --git a/docs/canonical/LAYOUT_PIPELINE.md b/docs/canonical/LAYOUT_PIPELINE.md new file mode 100644 index 00000000..d58326ad --- /dev/null +++ b/docs/canonical/LAYOUT_PIPELINE.md @@ -0,0 +1,108 @@ +# The Layout Pipeline — the standard order of operations + +This is the template. Every layout/seed change follows these five stages, in this order. It exists because the previous approach — independently tuned constants, adjusted whenever the picture looked wrong — broke every time the content changed, and each fix broke something else. + +The distinction that matters: + +- **Preservative layout** hardcodes the numbers that produced a picture we liked. It is correct for exactly one input and silently wrong for every other. `directoryOffset: 220` is preservative: it was fine until node radii changed, then fine again until a directory was deleted. +- **Adaptive layout** derives the numbers from the content. It has no opinion about how far apart things should be; it computes how far apart they *must* be, and grows when the content grows. + +**We build adaptive layouts.** A constant that encodes a distance is a bug in waiting. + +--- + +## The five stages + +Each stage is a **pure function**. No stage reads the graph's mutable render state (`size`, `color`) — geometry reads `baseSize` only (see `GRAPH_DISPLAY_MAP.md`). No stage mutates anything except the last. + +``` +1. DERIVE content → intrinsic quantities (pure, no layout knowledge) +2. MEASURE tree + (1) → footprints, bottom-up (pure) +3. ALLOCATE footprints → disjoint regions, top-down (pure) +4. PLACE regions → coordinates (pure) +5. VERIFY coordinates → invariants hold (assertion, in a test) +``` + +### 1 · DERIVE — content to intrinsic quantities + +Everything a node knows about itself, independent of where it will go. + +- `radius(v)` from `rawSize` — the only place a node's size is decided. + +**Constants are allowed here and nowhere else.** This is the single tuning surface: change `NODE_RADIUS_MIN`/`MAX` and every downstream distance re-derives automatically. That property is the whole point. + +### 2 · MEASURE — bottom-up footprints + +Compute, for every subtree, **how much room it actually needs** — never how much room we think it should have. + +- `discRadius(v)` — the radius of the disc containing `v` *and everything that orbits it* (its files). Derived from the file radii, not from a constant: + - files must clear the parent: `orbit ≥ radius(v) + maxFileRadius + PAD` + - files must clear *each other* around the orbit: `2π·orbit ≥ Σ 2·fileRadius`, i.e. `orbit ≥ Σ fileRadius / π` + - take the max of both. A directory with 40 files gets a bigger disc than one with 2 — automatically. +- `width(v)` — the tangential width the subtree needs: + ``` + width(v) = max( 2·discRadius(v) + PAD , Σ width(child) ) + ``` + A subtree is at least as wide as its own disc, and at least as wide as its children laid side by side. + +This is the stage that makes the layout adaptive. **Growth propagates upward automatically:** add files to a leaf directory and its disc grows, so its width grows, so its ancestors' widths grow, so the rings and sectors that contain it grow. Nothing is re-tuned. + +### 3 · ALLOCATE — top-down disjoint regions + +Turn measured need into **disjoint** angular sectors and ring radii. + +- **Ring radius** — the adaptive replacement for `directoryOffset`: + ``` + r(d) = max( + r(d-1) + maxDisc(d-1) + maxDisc(d) + PAD, // radial clearance between rings + Σ width(v) for v at depth d / 2π // enough circumference to hold them all + ) + ``` + The second term is the one that makes the graph *spread out as it grows*. More content at a depth ⇒ a bigger ring. No constant. + +- **Angular sector** — each subtree's share of its parent's sector, weighted by measured `width`, tiled exactly (no floor — a floor over-allocates, and over-allocation across a full circle is how sectors start overlapping again). + ``` + θ(v) = width(v) / r(depth(v)) + ``` + +**The invariant this buys, by construction:** + +Children fit inside their parent's sector, always. Since `Σ width(child) ≤ width(parent)` (stage 2) and `r(d) ≥ r(d-1)` (stage 3): + +``` +Σ θ(child) = Σ width(child) / r(d) ≤ width(parent) / r(d) ≤ width(parent) / r(d-1) = θ(parent) ∎ +``` + +And a node's disc fits inside its own sector, because the arc it owns is `θ(v)·r(d) = width(v) ≥ 2·discRadius(v)`. + +**Therefore files cannot invade a sibling subtree** — the disc that contains them is contained in the sector. That was L-020, and it is now impossible rather than merely unlikely. + +### 4 · PLACE — regions to coordinates + +Purely mechanical: put each node at the centre of its allocated sector, on its ring; orbit its files inside its own disc (phyllotaxis for even spread). No decisions left to make. + +### 5 · VERIFY — invariants, not thresholds + +**Assert relations, never magic numbers.** A test that says "no pair closer than 8 units" is preservative: it encodes a snapshot and breaks when content changes, teaching everyone to nudge the threshold. The correct invariant is *relational and scale-free*: + +``` +for every pair (a, b): distance(a, b) ≥ radius(a) + radius(b) +``` + +This is content-independent. It holds for 40 nodes and 40,000. It cannot be satisfied by fiddling a constant — only by a layout that is actually correct. **When you can't state the invariant without a magic number, the design is wrong, not the number.** + +--- + +## Idempotence + +- **Deterministic.** Same input ⇒ same output, bit for bit. No `Math.random()` anywhere in the layout path. Ordering comes from sorted ids. (Symmetry-breaking for coincident nodes uses a hash of the node ids, never randomness — see `GWELLS_PHYSICS.md` L-002.) +- **Re-runnable.** Seeding twice produces the same positions. Stages 1–4 are pure functions of the graph, not of the previous layout. +- **Stable under change.** Adding content changes the layout *proportionally* — it does not require re-tuning constants elsewhere. This is the property `directoryOffset: 220` did not have, and it is why deleting one directory could break a spacing assertion three subtrees away. + +## Rules + +1. **A constant that encodes a distance belongs in stage 1 or nowhere.** If you find yourself adding `const SOMETHING_OFFSET = 220`, you are writing preservative layout. Derive it. +2. **Never tune a constant to make a test pass.** If the invariant fails, the allocation is wrong. Fix stage 2 or 3. +3. **Never relax the invariant in stage 5.** It is relational; there is nothing to relax without making it meaningless. +4. **Geometry reads `baseSize`, never `size`.** `size` is presentation — it is multiplied by a user setting and rewritten on hover. A layout that reads it changes shape when you mouse over a node. +5. **Bottom-up before top-down.** You cannot allocate room before you know how much room is needed. Every attempt to place first and fix up later reintroduces exactly the class of bug this document exists to prevent. diff --git a/docs/canonical/REGISTRY_AND_LINK_NETWORK.md b/docs/canonical/REGISTRY_AND_LINK_NETWORK.md index 342d1950..1c88a078 100644 --- a/docs/canonical/REGISTRY_AND_LINK_NETWORK.md +++ b/docs/canonical/REGISTRY_AND_LINK_NETWORK.md @@ -101,7 +101,6 @@ Static list known at build time → **Tier 1**. Runtime-mutable with UI that mus | `lensRegistry` | Tier 2 | lens | Dev extension point | | `physicsDialectRegistry` | Tier 2 | graph/physics | Dev extension point | | `animationPrimitiveRegistry` | Tier 2 | motion | | -| `edgeStyleRegistry` | Tier 2 | graph/edges | | | `fontAxisRegistry` | Tier 2 | themes | | | `typographyRegistry` | Tier 2 | themes | | | `tileSectionRegistry` | Tier 2– (register, no subscribe) | control-plane/panels | Tile catalog | @@ -115,7 +114,6 @@ Static list known at build time → **Tier 1**. Runtime-mutable with UI that mus | `bookmarkRegistry` | Legacy (factory) | graph/overlay | Migration candidate | | `assetRegistry` | Legacy (class) | themes | Empty asset bank | | `qa-registry`, `advisory-registry` | Data registry | control-plane/qa | Large data stores, not patterns to imitate | -| `feature-registry` | Stub | control-plane/features | Currently empty | `seedFunctions` is **no longer a registry** — it was `seedFunctionRegistry.ts`, now plain module exports in `physics/gwells/seedFunctions.ts`. Treat seed functions as a module, not a registry. diff --git a/docs/graph/contracts/EDGE_STYLE_REGISTRY_CONTRACT.md b/docs/graph/contracts/EDGE_STYLE_REGISTRY_CONTRACT.md deleted file mode 100644 index 6f1b9845..00000000 --- a/docs/graph/contracts/EDGE_STYLE_REGISTRY_CONTRACT.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -id: graph.edge.style.registry.contract -title: Edge Style Registry Contract -type: contract -status: accepted -version: v86e -cluster: slate -domain: graph -agent_readable: true -include_in_self_graph: true -last_updated: 2026-05-21 -tags: [graph, edge, style, registry, contract, v86e] ---- - -# Edge Style Registry Contract - -**Version**: v86e -**Purpose**: Govern the registry of edge rendering style presets for graph visualization. - -## Purpose - -Declares named edge style presets (plasma, wire, ribbon) that graph renderers may apply. -Separates style declaration from rendering implementation. v86e lands contract + empty -registry; v91 implements edge plasma rendering. - -## Allowed Behavior - -- Entries registered via `register(entry)` after validation. -- `list()`, `getById()`, `filterByCategory(mode)` perform pure lookups. -- `subscribe(listener)` notifies on change. -- Dev probe `window.__lwEdgeStyleRegistry` may be exposed in DEV/PLAYWRIGHT mode. - -## Forbidden Behavior - -- Must not mutate graph edges, Sigma state, or any rendering pipeline. -- Must not perform I/O or async operations. -- Must not write CSS variables or DOM. - -## Schema - -```typescript -interface EdgeStyleEntry { - id: string; - label: string; - mode: "plasma" | "wire" | "ribbon"; - config: Record; // mode-specific configuration -} -``` - -## Evidence Required - -- `npm run typecheck` passes with registry in place. -- v91: Playwright confirms edge style presets render correctly. - -## Forbidden Boundaries - -- No Sigma or graph mutations in v86e. -- No rendering implementation in v86e (v91+). -- No UI surface for edge styles in v86e. - -## Acceptance Criteria - -- Contract doc exists at this path. -- TS stub at `src/graph/edges/edgeStyleRegistry.ts` with empty registry. -- `npm run typecheck` passes. - -## Future Implementation Ladder - -- **v86e**: Contract + empty registry stub. -- **v91**: Edge plasma rendering; seed style entries populated. -- **v91+**: Edge style selector UI surface. diff --git a/docs/graph/contracts/GRAPH_RENDERER_INTERFACE_CONTRACT.md b/docs/graph/contracts/GRAPH_RENDERER_INTERFACE_CONTRACT.md deleted file mode 100644 index 5bcbaa72..00000000 --- a/docs/graph/contracts/GRAPH_RENDERER_INTERFACE_CONTRACT.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -id: graph.renderer.interface.contract -title: Graph Renderer Interface Contract -type: contract -status: accepted -version: v86e -cluster: slate -domain: graph -agent_readable: true -include_in_self_graph: true -last_updated: 2026-05-21 -tags: [graph, renderer, interface, contract, v86e, webgpu, sigma] ---- - -# Graph Renderer Interface Contract - -**Version**: v86e -**Purpose**: Define the abstract renderer interface that current (Sigma2d) and future -(Sigma WebGPU, Three.js companion) implementations honor. - -## Purpose - -Establishes a shared interface so that the graph layer can swap rendering backends -without changing consumer code. The current implementation (Sigma2d) satisfies this -interface implicitly; v94 introduces a second implementation (WebGPU). - -## Allowed Behavior - -- `mount(container, graph)` attaches the renderer to an HTML container. -- `unmount()` detaches and cleans up all renderer resources. -- `refresh()` redraws the graph without changing state. -- `getCamera()` returns the current camera state as a plain object. -- `setCamera(camera)` applies a camera state. -- Implementations may add renderer-specific methods beyond this interface. - -## Forbidden Behavior - -- `mount` must not be called twice without an intervening `unmount`. -- `getCamera` must not mutate internal state. -- `setCamera` must not perform async operations or schedule deferred work. -- Implementations must not leak event listeners after `unmount`. -- Must not depend on React lifecycle — interface is renderer-agnostic. - -## Schema - -```typescript -interface RendererCamera { - x: number; - y: number; - zoom: number; - rotation: number; -} - -interface GraphRenderer { - mount: (container: HTMLElement, graph: any) => void; - unmount: () => void; - refresh: () => void; - getCamera: () => RendererCamera; - setCamera: (camera: RendererCamera) => void; -} -``` - -## Evidence Required - -- `npm run typecheck` passes with interface in place. -- v94: Second renderer implementation satisfies the interface; Playwright tests confirm - behavior parity between Sigma2d and the new backend. - -## Forbidden Boundaries - -- No second renderer implementation in v86e. -- Current Sigma2d implementation need not be refactored to explicitly implement this - interface in v86e; refactor authorized in v94 WebGPU pass. -- No WebGPU or Three.js imports in v86e. - -## Acceptance Criteria - -- Contract doc exists at this path. -- TS interface stub at `src/graph/rendering/graphRendererInterface.ts`. -- `npm run typecheck` passes. - -## Future Implementation Ladder - -- **v86e**: Interface contract + TS stub. Sigma2d satisfies it implicitly. -- **v94**: Sigma2d explicitly implements `GraphRenderer`; WebGPU renderer added. -- **v94+**: Renderer switching mechanism via `graphRendererInterface`. diff --git a/docs/ledger/GRAPH_DISPLAY.md b/docs/ledger/GRAPH_DISPLAY.md new file mode 100644 index 00000000..4ceb10ac --- /dev/null +++ b/docs/ledger/GRAPH_DISPLAY.md @@ -0,0 +1,440 @@ +# Graph Display Ledger + +Append-only. Rules: `docs/ledger/README.md`. Narrative map: `docs/canonical/GRAPH_DISPLAY_MAP.md`. +Guards: `tests/e2e/graph-contract.spec.ts`. + +Opened 2026-07-12 from a five-way sweep of the tree (renderer seam, attribute contract, physics boundary, style/theme pipeline, registries/settings). Every entry below was verified against on-disk code, not against older docs. + +--- + +## Renderer seam + +### GD-001 · There is no renderer abstraction +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `src/graph/rendering/graphRendererInterface.ts` declares `GraphRenderer` and has **zero importers** (grepped across `src`, `tests`, `scripts`). `src/renderers/` is an **empty directory**. `AppShell` constructs `` directly. +Impact: nothing to implement a three.js view against. The stub is also too thin to be the real seam — no hit-testing, no viewport projection, no per-item hover, no program registration. +Guard: none +Amended: 2026-07-12 · the stub and `src/renderers/` are deleted (RM-001). The FINDING STANDS AND STAYS OPEN: there is still no renderer abstraction. Deleting the stub removes a false seam that would have attracted an implementation with the wrong shape; the real one is RM-005. + +### GD-002 · `window.__lwSigma` is the de-facto renderer API +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: set at `SigmaGraphView.tsx:444` (unconditionally, not dev-gated). Read by 7 files **outside** the renderer directory: `AppShell.tsx`, all four minimap hooks/components, plus test helpers. +Impact: the seam is a global variable, not the props interface. "Swap the renderer" currently also means "rewrite the minimap." **This is the single biggest blocker to a clean swap.** +Guard: `tests/e2e/graph-contract.spec.ts` › "seam ratchet: __lwSigma consumers outside the renderer do not increase" + +### GD-003 · The minimap reimplements Sigma's internal coordinate normalization +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `useMinimapNavigation.ts` derives `ratio_norm` from "sigma's `normalizationFunction` source" and encodes Sigma's Y-up axis convention in comments. `useMinimapCamera.ts` polls for `window.__lwSigma` on a 200ms `setInterval`. +Impact: would silently produce wrong pans under any other camera model. Not a compile error — a correctness one. +Guard: none + +### GD-004 · The theme contract names the renderer +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `ResolvedGraphVisualTokens.sigmaConfig` (`labelRenderedSizeThreshold`, `labelFont`, `edgeLabelFont`) — `graphVisualTokens.ts`, threaded through `SigmaGraphViewProps`. +Impact: `labelRenderedSizeThreshold` is a Sigma concept with no three.js analogue. Must be dropped or generalized before a swap. +Guard: none + +### GD-005 · The visual policy layer imports *up* from the renderer directory +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `src/graph/visual/graphStylePolicy.ts` and `graphLabelPolicy.ts` both import `selectionNeighborhood` from `src/graph/renderers/sigma2d/`. +Impact: the directory boundary is inverted — the renderer-neutral layer depends on the renderer directory. `selectionNeighborhood` is itself pure graphology and should move up. +Guard: none + +### GD-006 · Seven orphaned `.glsl` files; no GLSL loader exists +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `src/graph/edgePrograms/shaders/plasma.{vert,frag}.glsl` and `src/graph/nodePrograms/shaders/{crystal,glass-sphere,orb,pip,sun}.frag.glsl` — **none imported by anything**. No GLSL plugin in `vite.config.ts`. Every shader that actually runs is an inline template literal in the corresponding `.ts`. +Impact: **they are stale duplicates and may have drifted from the live shaders.** Anyone importing GLSL will assume these are the source of truth. Decide on one source before importing more shaders. +Guard: none + +### GD-007 · The three.js runway is already installed and entirely unused +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `three`, `@react-three/fiber`, `@react-three/drei` in `package.json`, **zero imports in `src`**. `feature-flags.ts` has `three3d` hardcoded `false`. `settings.schema.ts` types `graphView.defaultRenderer` as `"sigma2d" | "cosmograph2d" | "three3d"` — and it selects nothing (only consumer prints it as a debug string in `StatusBar`). +Impact: good news — the dependency and the settings *shape* already exist and are migrated. No new packages needed to start. +Guard: none + +### GD-008 · `PlasmaEdgeProgram` depends on undocumented Sigma internals +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: `PlasmaEdgeProgram.ts` `setUniforms` detects Sigma's picking pass by reading `gl.getParameter(gl.FRAMEBUFFER_BINDING)`, then swaps `gl.blendFunc` between premultiplied-alpha (picking) and additive `ONE, ONE` (visual). 552 lines total: from-scratch vertex+fragment pair, subdivided quadratic-bezier ribbon (12 segments, 72 verts/edge), hand-packed attributes, ~30 uniforms. +Impact: **the hardest single artifact to port.** The picking hack has no three.js analogue — needs a raycaster or GPU-picking pass. By contrast the five node *fragment* shaders are portable nearly verbatim (distance-field circle math). +Guard: none + +### GD-009 · Dead: `@sigma/node-image` dependency +Opened: 2026-07-12 +Status: OPEN +Area: renderer +Evidence: in `package.json`, never imported anywhere in `src`, `tests`, or `scripts`. +Impact: free to drop. +Guard: none +Note: 2026-07-12 · NOT dropped in RM-001. Removing a dependency touches the lockfile, so it needs a deliberate `npm uninstall @sigma/node-image` run by the developer. Still open. + +### GD-010 · Dead: `renderers/sigma2d/labelPolicy.ts` (331 lines) +Opened: 2026-07-12 +Status: CLOSED +Area: renderer +Evidence: zero importers. Superseded by `src/graph/visual/graphLabelPolicy.ts`. +Impact: dead weight that looks live. Delete. +Guard: none +Closed: 2026-07-12 · deleted in RM-001. + +--- + +## Attribute contract + +### GD-011 · `z` is write-only +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: six writers (both seeders ×4 sites each, `seederHelpers.applySeedPosition`, `engine.applyPins`). **One reader**: `SigmaGraphView.tsx:733`, inside drag-to-pin, which reads `z` only to round-trip it back into the settings pin map so pinning doesn't destroy it. `applyPins` reads it back out. A closed loop feeding nothing. The Sigma `nodeReducer` returns `{...base, x, y}`; no node or edge program consumes `z`. +Impact: the 3D work inherits a plumbed-but-never-pressure-tested axis. It has never been *wrong* in a way anyone would notice. +Guard: `tests/e2e/graph-contract.spec.ts` › "z is not consumed by the renderer or the physics" + +### GD-012 · `z` is never initialized by the graph builder +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `buildGraphologyGraph.ts:83-84` sets `x: 0, y: 0` only. Nodes the seeders never touch have **no `z` attribute at all** — `undefined`, not `0`. +Impact: a 3D renderer must default defensively or it will read `undefined` into a float. +Guard: `tests/e2e/graph-contract.spec.ts` › "z is not consumed by the renderer or the physics" + +### GD-013 · `z` is constant 0 under the default dialect +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `radialBackbone` sets `myZ = parentPos.z` — z is inherited unchanged down the whole tree from a zero root. Only `parallelSpines` produces genuine azimuthal depth (`myZ = parentPos.z + sin(axis)·cosPhi·directoryOffset`). +Impact: turning on a 3D camera against the default dialect shows a flat plane. Depth exists only in parallel-spines today. +Guard: `tests/e2e/graph-contract.spec.ts` › "radial-backbone seeds a planar graph (z === 0)" + +### GD-014 · Node `size` is a RADIUS IN GRAPH UNITS, not pixels +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: Sigma is constructed with `itemSizesReference: "positions"` (`SigmaGraphView.tsx:430`). `size`/`baseSize` therefore share the coordinate space of `x`/`y` and scale with the camera. +Impact: **any imported renderer or physics engine that assumes screen-space pixels will be wrong by the camera ratio.** This exact confusion already cost us once — `computeNodeSize` was inflated until a node's radius equalled the gap to its neighbour (see `LAYOUT_AND_PHYSICS.md` L-019). +Guard: `tests/e2e/gwells-seed-separation.spec.ts` › "node scale › no node is so large it cannot fit between a directory and its parent" + +### GD-015 · `type` and `nodeType` mean different things +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `type` is the **Sigma render program id** (`glass-sphere | sun | crystal | orb | pip`); `nodeType` is the **semantic kind** (`directory | code | doc | config | fixture | file | spine | …`). +Impact: a renderer keying geometry off `type` gets Sigma program names. The most confusable pair in the model — rename deliberately when importing a different geometry system. +Guard: `tests/e2e/graph-contract.spec.ts` › "node attribute contract is exactly this set" + +### GD-016 · `x`/`y` and `size` each have three independent writers +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `x`/`y` ← builder (init), gwells (seeders + per-tick integrator), renderer drag handler. `size` ← builder, `graphStylePolicy`, renderer live `nodeSize` effect. +Impact: **anything a new renderer writes to `color` or `size` is clobbered on the next hover** — `resetGraphStyles()` rewrites both on every node and edge on every interaction change, sourcing colour from `raw.color` and size from `baseSize`. +Guard: none + +### GD-017 · `alpha` is written by dimming policy and read by nobody — all of dim mode is inert +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `dimmingPolicy.ts` writes `alpha` on every node and edge. Sigma has no `alpha` attribute. No `nodeReducer` maps it to colour alpha (the only reducer handles spine pinning). `PlasmaEdgeProgram` ignores colour entirely. The shaders' local `float alpha` is anti-aliasing falloff, unrelated. +Impact: **the entire focus/context dimming feature writes into the void.** A renderer that honours `alpha` would fix it for free — this is a genuine win available during the port. +Guard: `tests/e2e/graph-contract.spec.ts` › "alpha is written but not consumed" + +### GD-018 · `_phase` and `_midStop` are read by the edge shader and written by nobody +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `PlasmaEdgeProgram.processVisibleItem` reads `data._phase` and `data._midStop`; no writer exists anywhere. They fall back to `0` and `0.5` forever. +Impact: every edge pulses **identically and in phase**. Likely a large part of why the edges read as one undifferentiated animated mass. +Guard: none + +### GD-019 · Edge colour is discarded by the GPU +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `PlasmaEdgeProgram.processVisibleItem` reads `size`, `_phase`, `_midStop`, `_hoverFactor` — **never `data.color`**. `defaultEdgeType: "plasma"` and `plasma` is the only registered edge program. +Impact: every edge-colour write in `graphStylePolicy` (default, selected, hovered, secondary, tertiary) is thrown away. `themeTokens.graph.edge*` and `edgeColorScale` are effectively unused. Only edge *size* survives to the GPU. +Guard: none + +### GD-020 · `label` is destructively truncated; `fullLabel` is the real text +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `label` is rewritten (and set to `""` to hide) by the label policy on every pass. `getStoredLabel` reads `fullLabel ?? originalLabel`. +Impact: an imported renderer must read `fullLabel` for real text. Reading `label` gets you a truncated, possibly empty, display string. +Guard: `tests/e2e/graph-contract.spec.ts` › "node attribute contract is exactly this set" + +### GD-021 · Attributes written and never read +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: nodes — `cluster` (everyone reads `raw.cluster` instead), `componentIndex`, `isInLargestComponent`. Edges — `id` (the key is authoritative), `weight` (hardcoded `1`, never varies). +Impact: dead payload. Safe to delete; do it before adding more. +Guard: none + +### GD-022 · `nodeType` and `relationship` are free-form strings with no validation +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: draft types are `type?: string` / `relationship?: string`. No central enum. Values are recognized by string comparison at ~8 sites; unrecognized values fall through to structural inference. The only real union in the tree (`fixtures/types.ts`) applies solely to the bundled self-graph fixture. +Impact: **`"contains"` is the single relationship with structural meaning** — it defines the tree that drives all seeding, roles, `requireEdge` filters, and aggregate sizing. Everything else is decoration. An imported adapter that misspells it silently loses the hierarchy. +Guard: none + +--- + +## Physics boundary + +### GD-023 · The simulation is strictly 2D and `dt` is implicit = 1 frame +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: `GWNodeState` has `vx`/`vy` and **no `vz`**. Every force is a 2-vector. Integration is `newX = x + state.vx` — no timestep term. Damping is a per-frame multiplier, not exponential in dt. +Impact: **an imported engine that assumes a `dt` parameter or 3-vectors will not drop in.** Also means physics is framerate-dependent. +Guard: `tests/e2e/graph-contract.spec.ts` › "z is not consumed by the renderer or the physics" + +### GD-024 · Force evaluation is brute-force all-pairs +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: interactions bucket by source well type, then O(|source nodes| × |target nodes|) per interaction. No quadtree, no Barnes-Hut anywhere. +Impact: **the scaling wall.** Probably the single most valuable thing an imported physics engine replaces. +Guard: none + +### GD-025 · Two of the five force kinds are inert +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: `attraction` is registered by **no** interaction. `linear-alignment` is a documented no-op in the force loop ("Documentary force for C1") — it only marks `interactionFired`. Live kinds: `repulsion`, `spring`, `perpendicular`. +Impact: the vocabulary is smaller than the type union advertises. +Guard: none + +### GD-026 · The seed handshake fails silently +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: a seeder must write **two graph-level maps**, not just `x`/`y`. `__gwellsSeedPositions` (`Map`, every node) feeds the `seedAdherence` restoring force **and** the per-pair spring rest lengths — a parent/child spring's ideal length is *the distance the seeder chose*, not the well-type default. `__seededSpinePositions` (spine nodes only) is read by the Sigma `nodeReducer` and **overrides x/y at render time**. +Impact: an imported seeder that writes only x/y silently disables seed adherence, collapses springs to a static default, and breaks spine pinning — **with no error**. Highest-risk contract for the import. +Guard: `tests/e2e/graph-contract.spec.ts` › "the seed handshake maps exist and are populated" + +### GD-027 · Non-finite guard warnings are discarded in the animated path +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: `GWStepResult.warnings` accumulates non-finite force/position events, and the rAF `tick()` throws the result away. Warnings surface only if you call `controller.step()` yourself. The `"warning"` `GWDebugEvent` type is declared but **never emitted**. +Impact: NaN guards fire silently in the normal path. A physics import that destabilizes would look like "the graph is a bit weird" rather than an error. +Guard: none + +### GD-028 · `seedParams` is `Record` and unvalidated +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: each seeder re-validates with its own `resolveParams`, defaulting unknown keys. +Impact: a typo in a seed param is a silent default, not an error. This is the escape hatch an imported lab will use, and the place its config will quietly not apply. +Guard: none + +### GD-029 · Well assignment runs once, not on config override +Opened: 2026-07-12 +Status: OPEN +Area: physics +Evidence: assignment happens at `applyDialect`; `applyConfigOverride` re-seeds and rebuilds caches but does **not** re-assign well types. +Impact: a config change that should move nodes between well types won't. +Guard: none + +--- + +## Theme / style pipeline + +### GD-030 · Cluster colour outranks the theme +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: `resetGraphStyles` reads `attrs.raw.color` **before** `tokens.nodeColor.default`, and `raw.color` is written from a theme-independent cluster palette (`cluster-colors.json`, absolute hex, "decision D2"). +Impact: on a clustered graph `nodeColor.default` is **never seen**. Theme switching only repaints unclustered nodes, selection states, and hover. This is by design — but it means "the theme controls node colour" is false. +Guard: none + +### GD-031 · Sizes are not themeable +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: all `nodeSizeMultiplier`, `edgeSize`, `labelFontSize`, `labelTruncation`, `sigmaConfig` values are **hardcoded** inside `resolveGraphVisualTokens`. Only colours flow from the theme. `relationshipEndpoint: "#2563eb"` is hardcoded too. +Impact: a theme cannot change geometry, only palette. +Guard: none + +### GD-032 · Edge colours come from a second, parallel per-theme palette read from inside the shader +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: `PlasmaEdgeProgram.setUniforms` calls `useSettingsStore.getState()` directly and looks up `PLASMA_THEME_DEFAULTS[themeId]` — ~24 uniform params + `colorIn`/`colorOut` hex pairs, entirely unrelated to `themeTokens.graph.edge*`. +Impact: the shader is coupled to global app state, and there are **two disconnected theme systems for edges**. Pairs with GD-019 (the attribute path is dead, so this is the *only* live path). +Guard: none + +### GD-033 · `dimMode` never reaches the style policy +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: `settings.graphView.dimMode` exists and is written by `InspectorMiniGraph`, but **all three** `applyGraphStylePolicy` call sites omit the `dimMode` argument, so it falls through to `"off"` unless `pinnedHighlightActive` forces `"outside-pinned"`. +Impact: inert on the main canvas. Compounds GD-017 (even if passed, nothing reads `alpha`). +Guard: none + +### GD-034 · `dimOpacity` is hardcoded, ignoring the per-theme token +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: hardcoded `0.18` at the call site; `themeTokens.selection.dimOpacity` varies 0.12–0.22 per theme and is not read. +Impact: minor, but it means a theme token exists that does nothing. +Guard: none + +### GD-035 · The `outside-cluster` dim BFS is unbounded +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: `DimPolicyState.clusterDepth` is **never used**; the comment `// Stop at cluster depth` describes intent only. The BFS floods the entire connected component. +Impact: on a connected graph it dims nothing except other components. +Guard: none + +### GD-036 · `zoomLabelThreshold` is dead +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: declared in props, defaulted `1.15`, in settings, in the memo comparator — its **only consumer is the debug StatusBar**. No camera-ratio gate exists. Sigma's own `labelRenderedSizeThreshold: 6` (a *size*, not a *zoom*, threshold) is the de-facto behaviour. +Impact: a knob that looks like it controls label zoom behaviour and does not. +Guard: none + +### GD-037 · Shortest-path highlight ignores the theme +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: writes `graphVisualTokens.nodeColor.selected` — the **static module fallback**, not `resolvedTokens`. Always `#fbbf24`. +Impact: path highlight is the same colour in every theme. +Guard: none + +### GD-038 · Neighborhood depth ≥ 4 styling is unreachable +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: `NeighborhoodDepth = 1 | 2 | 3`, and every caller narrows to `1|2|3`. The quaternary block in `graphStylePolicy` never runs. +Impact: dead branch. +Guard: none + +### GD-039 · GlitterField desyncs from its node on pan/zoom +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: positioned by projecting the selected node through `sigma.graphToViewport()` inside an IIFE **in JSX**, recomputed on every AppShell render — not on camera move. +Impact: the selection sparkle drifts away from the node while panning or zooming. +Guard: none + +### GD-040 · Target-scoped geometry overrides never reach the canvas +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: the `lw:override-change` listener early-returns when the **global** override is `undefined`, so only `setGlobalOverride("node.geometry.preset")` takes effect. `GeometryTab` can write target-scoped overrides that do nothing. +Impact: a UI control that silently does nothing in one of its scopes. +Guard: none + +### GD-041 · `SolarBackdrop` re-renders every 16ms and has a hardcoded base gradient +Opened: 2026-07-12 +Status: OPEN +Area: theme +Evidence: starfield animated by `setInterval(…, 16)` driving React state — a component re-render per frame. Base `linear-gradient(135deg, #0a0a0f, #1a1025, #0f0a15)` is hardcoded, so the backdrop's base never changes with the theme. +Impact: avoidable per-frame React work behind the canvas, and a theme that can't fully theme its own background. +Guard: none + +--- + +## Registries / settings + +### GD-042 · `physicsDialectRegistry` is a stale duplicate in an incompatible ID namespace +Opened: 2026-07-12 +Status: OPEN +Area: registries +Evidence: it registers `dialect.gwells.radial-backbone`; the engine's real registry uses `gwells.dialect.radial-backbone`. **The IDs cannot even be joined.** Its `paramSchema` is not what the engine accepts. Nothing in `src/physics/` imports it. Header still reads "v86e: contract stub. Empty registry; v93 implements." +Impact: **do not wire an imported engine into this.** It looks like the taxonomy and is not. +Guard: `tests/e2e/graph-contract.spec.ts` › "the real dialect registry is the one the engine reads" + +### GD-043 · `lensRegistry` dispatches nothing +Opened: 2026-07-12 +Status: OPEN +Area: registries +Evidence: `layoutFn` was explicitly dropped ("no dispatch yet"). Its ids (`lens.radial-backbone`, …) are **disjoint** from the `LayoutLensId` union in the schema. Sole consumer is the inventory panel. +Impact: a descriptive catalog masquerading as a dispatch table. +Guard: none + +### GD-044 · Three unsynchronized copies of the layout tuning numbers +Opened: 2026-07-12 +Status: OPEN +Area: registries +Evidence: gwells `seedParams` (real), `physicsDialectRegistry.defaultSettings` (hand-copied), `lensRegistry.suggestedSettings` (hand-copied again). No synchronization. +Impact: two of the three are already wrong and nothing notices. Consolidate or delete before importing a fourth. +Guard: none + +### GD-045 · `graphView.defaultLayout` enumerates seven layouts that do not exist +Opened: 2026-07-12 +Status: OPEN +Area: settings +Evidence: typed `LayoutLensId = "constellation" | "districts" | "solar-orbit" | "helix" | "trihelix" | "pipeline" | "impact-rings"`. **Zero readers** outside schema/defaults/migrations. None of the seven exist in `lensRegistry` or in the engine (which knows two dialects). +Impact: the single most misleading knob in the schema. +Guard: none + +### GD-046 · Dead and write-only graphView knobs +Opened: 2026-07-12 +Status: OPEN +Area: settings +Evidence: `showArrows` — zero references. `showIsolatedNodes`, `showLowConfidenceEdges` — written only by command-palette entries, **read by nobody**. `defaultRenderer` — only consumer prints it as a debug string. +Impact: four knobs a user can change that do nothing. +Guard: none + +### GD-047 · Control-plane registries reference settings paths that no longer exist +Opened: 2026-07-12 +Status: OPEN +Area: settings +Evidence: `handleset.registry` and `controlSurfaceContract.registry` declare `graphView.nodeSelectionStage` (superseded by `neighborhoodDepth`), `physics.nodeSize` (moved to `graphView` in the v81→v82 migration), and `graphIntelligence.*` (no such slice exists). +Impact: dangling handles. The registries assert a contract the schema does not honour. +Guard: none + +### GD-048 · Dead registry files +Opened: 2026-07-12 +Status: CLOSED +Area: registries +Evidence: `src/control-plane/features/feature-registry.ts` is a **0-byte file** with zero importers. `src/graph/edges/edgeStyleRegistry.ts` is an empty stub (`entries: EdgeStyleEntry[] = []`) with zero consumers — despite `themeTokens.edge.stylePreset` already carrying `"plasma" | "wire" | "ribbon"` per theme. +Impact: delete, or populate `edgeStyleRegistry` and make the theme token mean something. +Guard: none +Closed: 2026-07-12 · both deleted in RM-001, along with `EDGE_STYLE_REGISTRY_CONTRACT.md`. NOTE THE LOOSE END: `themeTokens.edge.stylePreset` still carries `"plasma" | "wire" | "ribbon"` per theme and now has no registry behind it at all — it was already inert (only `plasma` is registered with Sigma), so this changes nothing at runtime, but if edge style presets are wanted, they must be built fresh. Folded into RM-010. + +### GD-049 · The Graph Visual Inventory renders fake registries with the same weight as real ones +Opened: 2026-07-12 +Status: OPEN +Area: registries +Evidence: `GraphVisualInventoryPanel` displays eight registries — including the stale `physicsDialectRegistry` and inert `lensRegistry` — with no live/dead distinction. It does **not** display the three registries that actually drive the canvas (`nodeProgramRegistry`, `themeTargetRegistry`, gwells `dialects`/`seedFunctions`). +Impact: the one surface built to be "the place you observe the system" is actively misleading. Fixing it means **feeding it truth, not adding controls** — its e2e suite correctly asserts read-onlyness. +Guard: none + +--- + +## Found by the guards + +### GD-050 · `isSun` is computed by the builder and deleted by the renderer on the next line +Opened: 2026-07-12 +Status: OPEN +Area: attributes +Evidence: `buildGraphologyGraph` finds the highest-degree node per cluster and tags them (`setNodeAttribute(sunNodeId, "isSun", true)`; `clusterSunCount` records **11** on the self-graph). `SigmaGraphView.tsx:347-350`, immediately after `buildGraphologyGraph` returns, does: +```ts +// Clear solar orbit attributes on rebuild +graph.forEachNode((nodeId) => { graph.removeNodeAttribute(nodeId, "isSun"); }); +``` +…on the freshly built graph. Measured: **0 nodes carry `isSun`; 11 carry `cluster`** — which is set on the adjacent line and survives. That asymmetry is what exposed it. +Impact: `graphStylePolicy`'s sun branch (`isSun` → cluster colour + **×1.8 size**) is **unreachable dead code**. The "sun" concept exists in the builder, the style policy, and the theme geometry preset — and never fires. Comment says "on rebuild", but it runs on **every** build, not only rebuilds. +Guard: `tests/e2e/graph-contract.spec.ts` › "isSun is deleted immediately after it is computed" +Note: found by the attribute-contract guard on its **first run** — no amount of reading the map would have caught it, because both halves look correct in isolation. This is the case for guards over prose. diff --git a/docs/ledger/README.md b/docs/ledger/README.md new file mode 100644 index 00000000..91a6c77a --- /dev/null +++ b/docs/ledger/README.md @@ -0,0 +1,65 @@ +# Ledgers — how they work + +A ledger is an **append-only log of facts about the tree**. It exists because the alternative — a descriptive document that gets rewritten in place — goes stale silently, and a stale audit is worse than no audit. This repo has been burned by that: audits have pointed at testids and components that no longer existed, and roadmap items have been edited in place until nobody could tell what was actually done or when. + +## The rules + +1. **Entries are append-only.** Once an entry has an ID, its body is never rewritten. Not to fix a typo in the finding, not to "update" it. If the finding was wrong, you append a correction; you do not edit history. + +2. **You close an entry by appending a line to it**, never by deleting it: + ``` + Closed: 2026-07-20 · · one line on what actually changed + ``` + A closed entry stays in the file forever. The log is the record of what was true, when, and what we did about it. + +3. **If a finding turns out to be wrong**, append: + ``` + Withdrawn: 2026-07-20 · why it was wrong + ``` + Do not quietly delete it. A wrong finding that was acted on is history worth keeping — that's how you avoid re-deriving the same mistake. + +4. **New findings go at the bottom**, with the next free ID. IDs are never reused, never renumbered. + +5. **`Status:` is the ONE mutable field.** Everything else in an entry is frozen once written. Status is the lifecycle marker, so it is updated in place — but it may only move in step with an appended dated line that says what happened. A status change with no appended line is a lie. + One of: `OPEN` · `CLOSED` · `WITHDRAWN` · `WONTFIX` · `BLOCKED`. + `BLOCKED` must name what it's blocked on. + +6. **An entry can be partly acted on and still be OPEN.** If the fix removed a symptom but the finding still holds, append an `Amended:` line saying so and leave it open. Closing an entry because you touched the file is how a ledger starts lying. + +## The anti-stale mechanism + +Append-only fixes the history. It does **not** fix the truth: an `OPEN` entry can quietly become false when someone fixes the underlying thing by accident, and nobody notices. + +So: **every load-bearing claim gets a guard.** A guard is a test that fails when the claim stops being true. + +- `Guard:` names the test that holds the fact in place. +- `Guard: none` is an admission, not a default. It means the claim can rot without anyone noticing, and it should be treated as a lower-confidence entry. + +When a guard fails, that is **not** a broken test — it is the tree telling you a documented fact has changed. The correct response is to close or amend the ledger entry, then update the guard. Never "fix" a guard by relaxing it to match new behaviour without an accompanying ledger line explaining what changed. + +Guards for the graph-display ledger live in `tests/e2e/graph-contract.spec.ts`. + +## The ratchet + +Some entries describe damage that must not spread while we work toward fixing it — e.g. the number of files reaching into the renderer through `window.__lwSigma`. Those get a **ratchet guard**: a test asserting the count does not *increase*. It permits progress and forbids regression, which is exactly the pressure you want during a migration. + +## Entry format + +``` +### GD-001 · Short imperative title +Opened: YYYY-MM-DD +Status: OPEN +Area: renderer | attributes | physics | theme | registries | settings +Evidence: what is actually true, with file:line. No speculation. +Impact: why it matters — especially for anything being imported. +Guard: tests/e2e/ › "" | none +``` + +## The ledgers + +- `GRAPH_DISPLAY.md` — everything that decides what appears on the canvas. The map of what is live, dead, and lying. +- `RENDERER_MIGRATION.md` — the staged work to make a renderer swap (Sigma → three.js) possible, and to prepare the tree to receive imported assets. + +## What does NOT belong in a ledger + +Narrative, design rationale, and how-it-works explanations belong in `docs/canonical/`. A ledger holds **facts and their lifecycle**, nothing else. If an entry needs a paragraph of theory, the theory goes in canonical and the ledger links to it. diff --git a/docs/ledger/RENDERER_MIGRATION.md b/docs/ledger/RENDERER_MIGRATION.md new file mode 100644 index 00000000..1de20235 --- /dev/null +++ b/docs/ledger/RENDERER_MIGRATION.md @@ -0,0 +1,250 @@ +# Renderer Migration Ledger — preparing to receive three.js, GLSL, and a real z-axis + +Append-only. Rules: `docs/ledger/README.md`. +Findings this plan is built on: `docs/ledger/GRAPH_DISPLAY.md` (GD-###). +Guards: `tests/e2e/graph-contract.spec.ts`. + +Opened 2026-07-12. + +--- + +## The thesis + +**Graphology is the scene model, and it is renderer-neutral.** Physics, style policy, labels, neighborhood, and dimming all operate on graphology attributes and never import Sigma. Of the five files in `renderers/sigma2d/`, only `SigmaGraphView.tsx` touches Sigma at all. That is the asset this whole plan is built on: **keep graphology, swap only the view.** + +**The blocker is not the renderer — it is that there is no seam.** `window.__lwSigma` is the de-facto renderer API, read by 7 files outside the renderer (GD-002), and the minimap goes so far as to reimplement Sigma's internal coordinate normalization (GD-003). So "swap the renderer" today secretly means "also rewrite the minimap, and hope the camera maths agrees." + +Therefore: **build the facade first, against Sigma, with the suite green.** A swap is only cheap once there is something to swap *at*. Everything in R0–R2 exists to make R4 a replacement rather than an excavation. + +**The sequencing rule:** do not port a bug. Several visual features currently compute values and throw them away (GD-017 alpha, GD-019 edge colour, GD-018 edge phase, GD-050 isSun). Porting them as-is means reimplementing dead code in a new renderer and inheriting the confusion. Make the payload honest **before** the swap, not after. + +--- + +## Phase R0 — Clear the ground + +Cheap, low-risk, no behaviour change. Do these first so nothing imported lands on rot. + +### RM-001 · Delete the dead renderer scaffolding +Opened: 2026-07-12 +Status: CLOSED +Blocks: nothing +Scope: `src/renderers/` (empty dir) · `src/graph/rendering/graphRendererInterface.ts` (zero importers, GD-001) · `src/graph/renderers/sigma2d/labelPolicy.ts` (331 lines, zero importers, GD-010) · `src/graph/edges/edgeStyleRegistry.ts` (empty stub, GD-048) · `src/control-plane/features/feature-registry.ts` (0-byte, GD-048) · `@sigma/node-image` dependency (GD-009). +Why: every one of these looks like a seam and is not. The `GraphRenderer` interface in particular will attract an implementation that then doesn't fit — it has no hit-testing, no viewport projection, no program registration. Delete it and design the real one (RM-005) rather than inheriting a bad shape. +Acceptance: typecheck clean, suite green, `grep -r "graphRendererInterface" src/` empty. +Closed: 2026-07-12 · deleted `src/renderers/` (empty dir), `graphRendererInterface.ts`, `renderers/sigma2d/labelPolicy.ts` (331 lines), `edges/edgeStyleRegistry.ts`, `features/feature-registry.ts`, and the three now-empty directories. Retired the two contract docs that described the deleted modules (`GRAPH_RENDERER_INTERFACE_CONTRACT.md`, `EDGE_STYLE_REGISTRY_CONTRACT.md`) and updated every doc that pointed at them, so the deletion does not leave a trail of dangling references. `@sigma/node-image` was NOT removed — that touches the lockfile and needs a developer-run `npm uninstall`. Typecheck clean; 35 passed / 2 skipped across the guard + physics blast radius. + +### RM-002 · Resolve the GLSL source of truth — BEFORE importing any shaders +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-020, and any shader import +Scope: 7 orphaned `.glsl` files (GD-006). No GLSL loader exists in Vite; every shader that actually runs is an inline template literal in its `.ts`. +Decision required: **(a)** add a `?raw` / glsl-loader path and make the `.glsl` files authoritative, deleting the inline copies; or **(b)** delete the `.glsl` files and keep shaders inline. +Why this is urgent: the orphaned files **may already have drifted** from the live shaders, and anyone importing GLSL will naturally assume they are the source of truth. Importing custom node shaders into a tree that already has a *fake* shader directory is how you get two divergent sets. +Recommendation: **(a)** — external GLSL is what an imported shader pack will look like, and `.glsl` files get syntax highlighting and can be linted. But it needs a Vite plugin, which is a **dependency request**. +Acceptance: exactly one source of truth for every shader; a test that the live shader text comes from where the docs say it does. + +### RM-003 · Kill the fake dialect taxonomy +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-024 (physics import) +Scope: `physicsDialectRegistry` (GD-042 — stale duplicate in an **incompatible** id namespace: `dialect.gwells.*` vs the engine's `gwells.dialect.*`) · `lensRegistry` (GD-043 — dispatches nothing) · the **three unsynchronized copies** of the layout tuning numbers (GD-044). +Why: an imported physics lab will look for the dialect registry and find the wrong one. Two of the three copies of the tuning numbers are already wrong and nothing notices. +Decision required: delete both, or make `physicsDialectRegistry` a *view* over the real gwells registry (single source, derived). +Acceptance: one dialect taxonomy. The guard `"the real dialect registry is the one the engine reads"` gets deleted, and GD-042 closes. + +### RM-004 · Remove the knobs that lie +Opened: 2026-07-12 +Status: OPEN +Scope: `graphView.defaultLayout` (7 layouts that do not exist, zero readers — GD-045) · `showArrows`, `showIsolatedNodes`, `showLowConfidenceEdges` (dead/write-only — GD-046) · stale settings paths in `handleset.registry` and `controlSurfaceContract.registry` (GD-047). +Keep: `defaultRenderer` — its union already reads `"sigma2d" | "cosmograph2d" | "three3d"` and is already migrated. **It is the right shape and we will wire it in R4.** +Why: four user-visible knobs that do nothing, and a control-plane registry asserting a contract the schema doesn't honour. +Acceptance: settings migration; the removed fields have no readers left; suite green. + +--- + +## Phase R1 — Build the seam (the load-bearing phase) + +This is the phase that makes a swap possible. **Nothing in R4 should start before this is done.** + +### RM-005 · Design the real renderer facade +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-006, RM-007, RM-019 +Scope: a new interface — **not** the deleted `GraphRenderer` stub, which was too thin. +It must cover what consumers actually reach for through `window.__lwSigma`: +- **viewport projection** — `graphToViewport` / `viewportToGraph`, `getDimensions` +- **camera** — get/set state, animate, enable/disable (drag freezes it), reset +- **hit-testing** — node/edge under pointer; this is what `clickNode`/`enterNode`/`downNode` really are +- **per-item hover/pick** state +- **program/material registration** — the node-geometry preset system +- **lifecycle** — mount, unmount, refresh, scheduleRender, `afterRender` (gwells start ordering depends on it) +Note the camera vocabulary must be chosen deliberately: `cameraController` speaks Sigma's `{x, y, ratio, angle}` while the dead stub spoke `{x, y, zoom, rotation}`. **Two incompatible camera types coexist today.** Pick one. +Acceptance: interface lands with a Sigma implementation behind it. No behaviour change. Suite green. + +### RM-006 · Route the minimap through the facade +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-019 +Scope: `useMinimapCamera`, `useMinimapNavigation`, `useMinimapSnapshot`, `MinimapSnapshotCanvas` — all four read `window.__lwSigma` directly (GD-002). `useMinimapCamera` polls for it on a 200ms `setInterval`; `useMinimapNavigation` reimplements Sigma's internal normalization and encodes its Y-up convention (GD-003). +Why: **this is the single biggest blocker to a clean swap.** Under any other camera model the minimap silently produces wrong pans — not a compile error, a correctness one. +Acceptance: zero `__lwSigma` references in `src/graph/overlay/minimap*`; the seam ratchet guard drops. + +### RM-007 · Route AppShell through the facade +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-019 +Scope: theme-thumbnail capture (`sigma.getCanvases()`) and GlitterField positioning (`getNodeDisplayData` + `graphToViewport`). Fold in **GD-039** while here: GlitterField projects during React render, not on camera move, so it **desyncs from its node while panning/zooming** — the facade should expose a camera-change subscription. +Acceptance: `AppShell` has no `__lwSigma` references. Seam ratchet → **0**. Update `MAX_EXTERNAL_LWSIGMA_FILES` and close GD-002. + +### RM-008 · Get the renderer's name out of the theme contract +Opened: 2026-07-12 +Status: OPEN +Scope: `ResolvedGraphVisualTokens.sigmaConfig` (`labelRenderedSizeThreshold`, `labelFont`, `edgeLabelFont`) — GD-004. `labelRenderedSizeThreshold` is a Sigma concept with no three.js analogue. +Acceptance: theme tokens describe *intent* (e.g. "hide labels below this rendered size"), and each renderer maps it. + +### RM-009 · Un-invert the visual/renderer dependency +Opened: 2026-07-12 +Status: OPEN +Scope: `graphStylePolicy` and `graphLabelPolicy` import `selectionNeighborhood` **up** from `renderers/sigma2d/` (GD-005). It is pure graphology and belongs in `src/graph/visual/` or `src/graph/query/`. +Acceptance: nothing in `src/graph/visual/` imports from `src/graph/renderers/`. + +--- + +## Phase R2 — Make the payload honest + +An imported renderer must consume **truth**, not four features that compute values and discard them. Do this before the swap so the bugs aren't reimplemented in three.js. + +### RM-010 · Edge colour reaches the GPU, or is honestly declared uniform-driven +Opened: 2026-07-12 +Status: OPEN +Scope: `PlasmaEdgeProgram` never reads `data.color` (GD-019), so **every** edge-colour write in the style policy is discarded. Meanwhile edge colour actually comes from `PLASMA_THEME_DEFAULTS`, read from the settings store **inside the shader** (GD-032) — a second, parallel theme system. +Decision required: either the edge program consumes `data.color` (making the style policy real), or per-edge colour is deleted and edge appearance is declared uniform-driven. **Right now both exist and one is a lie.** +Note: this lands squarely on the Phase 3 edge-semantics work (`LAYOUT_AND_PHYSICS.md` L-010–L-014) — 866 of 1,278 edges are semantic and should render dimmed/dashed. **That work is impossible until edge colour is real.** + +### RM-011 · Make `alpha` real, or delete it +Opened: 2026-07-12 +Status: OPEN +Scope: `dimmingPolicy` writes `alpha` on every node and edge; nothing reads it (GD-017). `dimMode` never even reaches the style policy (GD-033), `dimOpacity` is hardcoded (GD-034), and the `outside-cluster` BFS is unbounded (GD-035). +Why it matters for the import: **a three.js renderer that honours `alpha` fixes focus/context for free.** This is the clearest win available in the port — the policy layer is already written and correct; only the consumer is missing. +Acceptance: either dimming visibly works, or `alpha` and `dimmingPolicy` are deleted. No third option. + +### RM-012 · Wire `_phase` / `_midStop`, or drop them +Opened: 2026-07-12 +Status: OPEN +Scope: read by the edge shader, **written by nobody** (GD-018) — so every edge pulses identically and in phase. +Why: this is likely a real part of why the edges read as one undifferentiated animated mass. + +### RM-013 · Stop deleting `isSun` the instant it is computed +Opened: 2026-07-12 +Status: OPEN +Scope: GD-050. The builder tags 11 cluster suns; `SigmaGraphView` removes the attribute from every node on the next line ("Clear solar orbit attributes on rebuild" — but it runs on **every** build). `graphStylePolicy`'s sun branch (×1.8 size + cluster colour) is unreachable. +Acceptance: decide whether suns are a concept. If yes, stop deleting them and the style branch comes alive. If no, delete the builder pass, the style branch, and the attribute. + +### RM-014 · Declare the size units, loudly +Opened: 2026-07-12 +Status: OPEN +Scope: Sigma runs `itemSizesReference: "positions"`, so `size`/`baseSize` are **radii in graph units**, not pixels (GD-014). +Why: this already cost us once — `computeNodeSize` was inflated until a node's radius equalled the gap to its neighbour (L-019). **Any imported renderer or physics engine that assumes screen-space pixels will be wrong by the camera ratio.** The three.js renderer must make an explicit, documented choice here, and the guard must be updated to hold it. + +--- + +## Phase R3 — The z-axis fork + +`z` is currently write-only: six writers, one round-trip reader, zero renderers, zero forces (GD-011). It is **constant 0 under the default dialect** (GD-013) and **never initialized by the builder** (GD-012). + +### RM-015 · Initialize `z` in the builder +Opened: 2026-07-12 +Status: OPEN +Scope: `buildGraphologyGraph` sets `x: 0, y: 0` and **never `z`** — un-seeded nodes have no `z` attribute at all (`undefined`, not `0`). A 3D renderer will read `undefined` into a float. +Acceptance: every node has a numeric `z` from the moment it exists. + +### RM-016 · Give the default dialect real depth, or accept planar +Opened: 2026-07-12 +Status: OPEN +Scope: `radialBackbone` inherits `z` unchanged from a zero root, so the whole tree is planar (GD-013). Only `parallelSpines` produces genuine azimuthal depth. +Why: **turning on a 3D camera against the default dialect today shows a flat plane.** That will read as "3D doesn't work." +Ties to: the user's stated goal of a flatter simulated-2D mode for some graph types *and* true 3D for others — which means **planar-vs-volumetric should be a property of the dialect/lens**, not an accident of which seeder ran. + +### RM-017 · THE FORK — decide the physics dimensionality +Opened: 2026-07-12 +Status: OPEN +Blocks: RM-025, and any physics import +Scope: the simulation is **strictly 2D** — `GWNodeState` has `vx`/`vy` and no `vz`; every force is a 2-vector (GD-023). +Decision required, and it is the biggest one in this plan: +- **(a) 2D sim, 3D render.** z stays a static seed value; depth is layout, not dynamics. Cheap. Keeps every existing force valid. +- **(b) 3D sim.** Add `vz`, make every force a 3-vector. Every tuned constant must be re-validated (repulsion in 3D falls off differently; the angular-budget seeders are 2D-planar by construction). +This choice determines what the imported physics lab must export. **Make it before importing, not after.** + +### RM-018 · Carry `z` through the render path +Opened: 2026-07-12 +Status: OPEN +Scope: the Sigma `nodeReducer` returns `{...base, x, y}` and drops z; `__seededSpinePositions` is `{x, y}` only, by explicit comment. +Acceptance: the facade's projection layer accepts and preserves z, whichever renderer is behind it. + +--- + +## Phase R4 — The swap + +Only once R1 is done and the seam ratchet is at 0. + +### RM-019 · three.js view behind the facade +Opened: 2026-07-12 +Status: OPEN +Scope: implement the RM-005 facade with three.js. Gate it on the **existing** `three3d` feature flag (currently hardcoded `false`) and the **existing** `graphView.defaultRenderer` setting (currently selects nothing, GD-007). +Good news: `three`, `@react-three/fiber`, `@react-three/drei` are **already installed and unused** — no dependency request needed to start. +Acceptance: both renderers run behind the setting. The full suite passes against **both**. That is the real test of the facade. + +### RM-020 · Port the node fragment shaders +Opened: 2026-07-12 +Status: OPEN +Blocked by: RM-002 (GLSL source of truth) +Scope: the five node programs (`glass-sphere`, `sun`, `crystal`, `orb`, `pip`). The **fragment shaders port nearly verbatim** — they are distance-field circle maths on `v_diffVector`/`v_radius`. The inherited `NodeCircleProgram` vertex path and attribute layout do not. +Note: the four animation uniforms (`u_time`, `u_hum`, `u_flowSpeed`, `u_glowStrength`) are currently **monkey-patched onto the Sigma instance** (`sigma.__uniformsRef`). The facade must expose a real uniform channel. + +### RM-021 · Rebuild the edge program — the hardest single artifact +Opened: 2026-07-12 +Status: OPEN +Scope: `PlasmaEdgeProgram`, 552 lines — from-scratch vertex+fragment pair, subdivided quadratic-bezier ribbon (12 segments, 72 verts/edge), hand-packed attributes, ~30 uniforms, **and it detects Sigma's picking pass by reading `gl.getParameter(gl.FRAMEBUFFER_BINDING)` and swapping blend modes** (GD-008). +The picking hack has **no three.js analogue** — it becomes a raycaster or a GPU-picking pass. Budget for this being the long pole. +Do RM-010 first, or you will faithfully port an edge program that ignores its own colour input. + +### RM-022 · Camera, hit-testing, drag +Opened: 2026-07-12 +Status: OPEN +Scope: pan/zoom/rotate input (free from Sigma today, must be rebuilt); `viewportToGraph`/`graphToViewport`; `getDimensions`; node/edge picking; the drag-to-move block (logic is portable — `resolveDragSet` BFS over `contains`, scope single/family/subtree — the coordinate conversion is not). + +--- + +## Phase R5 — The physics import + +### RM-023 · Benchmark harness before replacing anything +Opened: 2026-07-12 +Status: OPEN +Scope: `GWStepResult` already carries `stepsRun, movedNodeCount, maxVelocity, averageVelocity` plus `GWStepTimings { totalMs, resetMs, seedLookupMs, forceInteractionsMs, auxForcesMs, integrationMs }` — **measured every step and ready-made for benchmarking an imported engine against this one.** Use it before swapping, so "faster" is a measurement rather than a feeling. +While here, fix GD-027: `GWStepResult.warnings` (the non-finite guards) is **discarded by the rAF loop**, so NaN guards fire silently in the normal path. + +### RM-024 · Replace brute-force all-pairs +Opened: 2026-07-12 +Status: OPEN +Blocked by: RM-003 (fake taxonomy), RM-017 (dimensionality) +Scope: force evaluation is O(|source| × |target|) per interaction, with no quadtree and no Barnes-Hut (GD-024). **This is the scaling wall and the most valuable thing an imported engine replaces.** + +### RM-025 · Introduce a real timestep +Opened: 2026-07-12 +Status: OPEN +Scope: `dt` is implicit = 1 frame — position update is literally `x + vx`, and damping is a per-frame multiplier rather than exponential in dt (GD-023). The physics is therefore framerate-dependent. +Warning: this re-scales **every** tuned constant in the engine. Do it deliberately, with the benchmark harness (RM-023) in place, not as a side-effect of an import. + +### RM-026 · The imported seeder must satisfy the handshake +Opened: 2026-07-12 +Status: OPEN +Scope: a seeder must write **two graph-level maps**, not just `x`/`y` (GD-026): `__gwellsSeedPositions` (every node, `{x,y,z}` — feeds the seed-anchor force **and** the per-pair spring rest lengths) and `__seededSpinePositions` (spine nodes — read by the render-time reducer). +An imported seeder that writes only x/y **silently** disables seed adherence, collapses springs to a static default, and breaks spine pinning. **No error is raised.** Guarded by `"the seed handshake maps exist and are populated"`. + +--- + +## Recommended order + +**R0 → R1 → R2 → R3(decide) → R4 → R5.** + +If you only do one thing before the import lands: **R1**. The facade is what turns "swap the renderer" from an excavation into a replacement, and the seam ratchet in `graph-contract.spec.ts` will tell you honestly how far along it is — it is at **7** today and the target is **0**. diff --git a/docs/registries/REGISTRY_INVENTORY.md b/docs/registries/REGISTRY_INVENTORY.md index a55d902e..87c69efe 100644 --- a/docs/registries/REGISTRY_INVENTORY.md +++ b/docs/registries/REGISTRY_INVENTORY.md @@ -77,7 +77,7 @@ Registry files that are empty or deprecated and candidates for cleanup. | Registry | File | Status | Recommendation | |----------|------|--------|----------------| -| Feature Registry | `src/control-plane/features/feature-registry.ts` | Empty file | Delete or repurpose for v86 asset registry | +| Feature Registry | *(deleted 2026-07-12)* | Removed | Was a 0-byte file with zero importers. Feature flags live in `feature-flags.ts` (config, not a registry). | **Why stale:** File exists but contains no content. Originally intended for feature flag management, but feature flags are now defined in `feature-flags.ts` (config, not registry). @@ -99,7 +99,7 @@ Files that use "registry" naming but are not registries in the governance sense. ## Summary - **Total registries:** 18 (4 link network + 14 orthogonal) -- **Stale:** 1 (feature-registry.ts) +- **Stale:** 0 (feature-registry.ts deleted 2026-07-12) - **Out of scope:** 2 (feature-flags.ts, qa.types.ts) All active registries follow the registry contract pattern: `list / getById / filterByCategory / validateShape / register`. The link network registries are the spine for visual governance traversal. Orthogonal subsystems serve distinct purposes and are confirmed clean clusters from the rehaul. diff --git a/docs/roadmap/GRAPH_SOURCE_UX.md b/docs/roadmap/GRAPH_SOURCE_UX.md new file mode 100644 index 00000000..34307e57 --- /dev/null +++ b/docs/roadmap/GRAPH_SOURCE_UX.md @@ -0,0 +1,454 @@ +# Graph Source UX — Feature Roadmap + +Branch: `feat/graph-source-ux` + +The source adapter system works mechanically, but the new-user experience of +loading an external graph is broken at two hard stops and rough everywhere else. +This roadmap addresses the full arc: unblock users immediately, then layer on a +proper selection UI, graph library, and visual thumbnails. + +--- + +## UX principles (apply to every phase) + +These are not aspirations — every ticket is reviewed against them before close. + +**Escape hatches everywhere.** +At every point in the flow, the user must be able to correct, undo, or +reinterpret their previous decisions without restarting. + +**Detection assists; it never decides silently.** +- Wrong: "detected as X, loading." +- Right: "candidates found: X (strong), Y (weak). [Load as X] [Load as Y] [Different type]" +- Detection produces a ranked candidate list. User selects. Confirmed adapter + stored on SourceEntry. + +**Collision handling — no auto-resolution.** +When multiple adapters match the same target (e.g. both `self-graph-yaml-frontmatter` +and `markdown-vault` match `**/*.md`), both surface as candidates with scores +as *display cues* — not silent tiebreakers. If all scores are low, the picker +still shows them alongside a "type not detected — pick one" option that lists +every registered adapter. + +**Reinterpretation on SourceEntry.** +Every loaded SourceEntry supports "Reinterpret as..." — user can switch adapter +in-place without re-adding. Source path stays; adapter changes, reload fires +only when the user commits. + +**Every state has a visible way out.** +- Empty: "Select graph source" affordance +- Loading: "Cancel loading" button +- Loaded: "Change source" affordance +- Error: three distinct options — "Try again" / "Different config" / "Different adapter" + +**Configuration non-destructive until committed.** +Editing config in the picker never fires a load. Only the "Load" button commits. +After load, changing config again does not affect the loaded graph until "Reload +with new config" is explicitly committed. + +**Library entries recoverable.** +Delete requires confirmation. Dialog makes clear the underlying file is not +affected. Confirmation cannot be triggered by accident. + +**Automated decisions are inspectable.** +When scan finds N candidates, all N surface. Scores are visible. User selection +is stored and reversible. + +--- + +## Current adapter inventory + +6 working adapters, 6 stubs. No database category yet. + +| ID | Reads | Category | Detection hint | Status | +|---|---|---|---|---| +| `cytoscape-json` | Cytoscape.js `*.json` | file-based | `*.json` extension | **working** | +| `package-dependency` | `package.json` / `Cargo.toml` / `pyproject.toml` / `go.mod` | file-based | manifest filenames | **working** | +| `csv-edge-list` | `*.csv` edge list | file-based | `*.csv` extensiector +─ +× +GRAPH INSPECTORon | **working** | +| `cerebra-snapshot` | `.cerebra/graph.json` | file-based | path marker | **working** | +| `openapi-spec` | OpenAPI `*.{json,yaml,yml}` | file-based | extension | stub | +| `database-schema` | `*.{sql,prisma}` | file-based | extension | stub | +| `cloud-infrastructure` | Terraform `*.{tf,yaml,yml}` | file-based | extension | stub | +| `self-graph-yaml-frontmatter` | YAML frontmatter `**/*.md` | directory-based | `**/*.md` glob | **working** | +| `markdown-vault` | Obsidian vault `**/*.md` | directory-based | `**/*.md` glob | **working** | +| `git-codebase` | `.git` directory | directory-based | `.git` presence | stub | +| `website-url` | HTTP/HTTPS crawl | stream | `^https?://` | stub | +| `issue-tracker` | GitHub / Linear / Jira API | stream | URL pattern | stub | + +**Known collision:** `self-graph-yaml-frontmatter` and `markdown-vault` share the +`**/*.md` detection pattern. Scanning any `.md` directory surfaces both. Score +differentiation (e.g. presence of `luma-*` frontmatter keys → strong +self-graph signal) is a display cue only. User always selects. + +--- + +## Phases + +### Phase 1 — Structural foundation + +Data shapes and lifecycle plumbing. No visible UI yet. + +**SA-001 · Explicit load trigger** +`useGraphSourceSummary` reruns on `[activeAdapterId, refreshToken]` only. Config +edits do nothing. Fix: decouple config mutation from load. Load fires only when +the user explicitly commits (button or keyboard shortcut). Changing config after +load does not affect the running graph until "Reload with new config" is committed. + +**SA-004 · Regenerate scope** +"Regenerate" in Graph Sources tile bumps the shared `refreshToken` regardless of +active adapter. Hide or relabel it when active adapter is not +`self-graph-yaml-frontmatter`. + +**SA-014 · Adapter `category` declaration** +Add `category: "file-based" | "directory-based" | "database" | "stream"` to +`SourceAdapterEntry`. Update all 12 registrations. UI groups adapters by category +in the picker. Category is also the dispatch key for the `scan(target)` interface +in Phase 3. + +**SA-022 · `SourceEntry` type + `sources.library` schema** +Single data shape used everywhere — picker history, pinned sources, tile display, +thumbnail storage: +```ts +interface SourceEntry { + id: string; // uuid, stable across reloads + adapterId: string; // confirmed by user, not auto-assigned + config: AdapterConfig; + label: string; // from summary.label on successful load; user-editable (Phase 7) + pinnedAt?: string; // ISO — set when user pins; absent for recent-only entries + loadedAt: string; // ISO — updated on each successful load + nodeCount?: number; + edgeCount?: number; + thumbnailDataUrl?: string; // Phase 5 +} + +sources: { + active: string | null; // adapterId of currently loaded graph + library: { + pinned: SourceEntry[]; // user-selected; order preserved + recent: SourceEntry[]; // auto-populated; max 20, newest first + }; +}; +``` +Add migration. `sources.history` (old name) → `sources.library.recent`. + +--- + +### Phase 2 — Empty-state + entry points + +First user-visible surface. Ships the GraphSourcePicker modal in minimal form. + +**SA-017 · EmptyPane component** +Net-new — no existing pattern to mirror. Center affordance: icon + "Select a +graph source" primary button. Top-right dropdown affordance (secondary): quick +access to recent entries (if any) and "Open new source". Both trigger the same +GraphSourcePicker modal. Goes into `GraphSourcesTileContent` when +`sources.library.pinned.length === 0` and no graph is loaded. + +**SA-018 · GraphSourcePicker modal** +Replaces the registry-browser surface as the user-facing "choose your source" UI. +The registry browser (`SourceAdapterPanel`) stays intact — it becomes dev-mode-only +(Phase 6). The picker is a modal, not a tile. + +Minimal form for Phase 2 (no thumbnails, no directory scan): +- **Recent** tab: list of `sources.library.recent` entries; one click reloads + (sets config + active adapter + triggers load). Empty if no history. +- **Open new** tab: adapter cards grouped by category (file-based, directory-based, + stream). Each card shows adapter name, description, accepted formatector +─ +× +GRAPH INSPECTORs. Selecting + a card expands an inline config form *before* any load fires. +- "Load" button at the bottom of the config form is the only commit action. +- Cancel / ✕ at any point returns the user to wherever they were with no change. + +Escape hatch audit: +- Empty state → EmptyPane visible ✓ +- Picking adapter → configure before load ✓ +- Cancel closes picker, active source unchanged ✓ + +**SA-019 · Loading state Cancel button** +While a load is in progress, display a "Cancel" button in `GraphSourcesTileContent` +and in the picker. Cancel aborts the in-flight loader, returns to the previous +loaded state (if any), or to empty state if nothing was previously loaded. + +**SA-020 · Error state three-option escape hatch** +When `summary.error` is populated, `GraphSourcesTileContent` and the picker both +display three actions: +- **Try again** — retries current config without re-opening picker +- **Different config** — opens picker with current adapter pre-selected and config + form expanded +- **Different adapter** — opens picker at "Open new" tab, no pre-selection + +Replaces SA-002 (inline error display only) with a full escape hatch surface. + +**SA-023 · "Change source" affordance in loaded state** +When a graph is loaded and healthy, `GraphSourcesTileContent` shows aector +─ +× +GRAPH INSPECTOR visible +"Change source" button (not buried in a menu). Triggers the GraphSourcePicker +modal with the Recent tab active. User can switch without losing the current graph +until they commit a new load. + +**SA-006 · Format hints in picker cards** +Each adapter card in the picker shows what the input must look like: one-line +format note + minimal example. Requires adding a `formatHint` string field to +`SourceAdapterEntry`. + +**SA-007 · Adapter format guard UX** +When `cytoscape-json` fails with a format error, rewrite the message: +``` +This adapter expects Cytoscape.js format: { "elements": { "nodes": [...], "edges": [...] } } +If your file uses { "nodes": [...], "edges": [...] } without an "elements" wrapper, +use "Different adapter" to try a compatible format. +``` + +--- + +### Phase 3 — Directory scanning + +**SA-015 · `scan(target)` interface on SourceAdapterEntry** +Each adapter optionally declares: +```ts +scan?: (target: string) => Promise; +// target is an absolute path (directory or file) +// returns null if this adapter cannot handle the target +interface ScanCandidate { + adapterId: string; + score: number; // 0.0–1.0 — display cue only, never a silent tiebreaker + scoreLabel: "strong match" | "weak match" | "possible"; + suggestedConfig: Partial; + reason: string; // human-readable: "Found 3 .md files with luma frontmatter" +} +``` +Stubs for adapters without a `scan` implementation are acceptable — they simply +return null for all targets. + +**SA-021 · "Scan directory" action** +"Open new" tab in the picker adds a "Scan directory" button (path entry or, when +Tauri dialog is available, native folder picker). Runs all registered adapters' +`scan()` functions against the target concurrently. Surfaces all non-null results +as a ranked candidate list with scores visible. + +Candidate list display: +- All candidates shown, ordered by score +- Score label ("strong match" / "weak match" / "possible") is visible on each card +- If no candidates: "No adapter recognized this directory — pick one manually" + with the full adapter list below +- User selects; selected adapter + suggested config populate the config form +- User reviews config, then presses "Load" to commit +ector +─ +× +GRAPH INSPECTOR +Collision display (self-graph vs markdown-vault example): +``` +Scan results for ~/Projects/lumaweave + +● self-graph-yaml-frontmatter strong match (412 .md files, luma frontmatter detected) + [Load as LumaWeave Docs] + +○ markdown-vault weak match (412 .md files, no luma frontmatter) + [Load as Markdown Vault] + +[Different type ↓] lists all adapters +``` + +--- + +### Phase 4 — Graph Sources tile as library + +**SA-009 · Push SourceEntry on successful load** +In the load lifecycle, when `result.status === "loaded"`, push to +`sources.library.recent`. Dedup by `adapterId + configHash` (update `loadedAt` +and counts in place rather than appending). Trim to 20. + +**SA-010 · Library display in Graph Sources tile** +`GraphSourcesTileContent` becomes the persistent library surface: +- **Pinned** section: user-selected entries with "Unpin" and "Reinterpret as..." + actions per entryector +─ +× +GRAPH INSPECTOR +- **Recent** section: auto-populated entries with "Pin" and "Reinterpret as..." + per entry + +**SA-024 · "Reinterpret as..." action** +Every SourceEntry in the library shows a "Reinterpret as..." action. Opens the +picker with: +- Current adapter's config pre-loaded +- Adapter selector active — user picks a different adapter +- Config form updates to the new adapter's schema +- "Load with this adapter" commits; original entry's `adapterId` updates, reload fires + +No re-add required. Source path stays; adapter swaps. + +**SA-025 · Library delete confirmation** +Deleting a library entry shows a confirmation dialog: +``` +Remove "~/Projects/lumaweave" from your library? +The underlying file is not affected. You can re-add it at any time. +[Remove from library] [Cancel] +``` +No "recently deleted" buffer needed — the underlying file is untouched and the +picker's "Scan directory" or "Open new" can re-surface it. + +--- + +### Phase 5 — Thumbnails + +**SA-011 · Stable capture threshold** +Thumbnail capture fires when kinetic energy in the GWells engine falls below a +defined threshold for N consecutive frames — not a vibe-based "after physics +settles" timeout. If GWells exposes a settle event to the React layer, subscribe +to it. If not, use an explicit "Capture thumbnail" user action as the MVP (user +presses a button in the loaded tile → snapshot taken). + +A 2-second timeout after `afterRender` is acceptable as a fallback if neither +is available, but the behavior must be documented (not silently variable). + +**SA-012 · Thumbnail capture + storage** +After stable capture: `canvas.toDataURL("image/jpeg", 0.4)` at max 300×200. +Store in the SourceEntry's `thumbnailDataUrl`. Total budget: 20 entries × ~10 KB += ~200 KB in localStorage — acceptable. + +**SA-013 · Sigma ref threading** +AppShell watches for `summary.status === "loaded"` transition, waits for GWells +settle signal (or user action), captures canvas, calls +`updateLibraryEntryThumbnail(entryId, dataUrl)`. AppShell already holds the +Sigma ref; capture stays in AppShell rather than threading the ref to the tile. + +**SA-012b · Thumbnail display** +Pinned and Recent entries in the tile show thumbnails as small cards (120×80, +`object-fit: cover`). Fallback: node/edge count badge when no thumbnaector +─ +× +GRAPH INSPECTORil. +In the picker's Recent tab, same display. + +--- + +### Phase 6 — Dev mode gating + +**SA-026 · Gate source-adapter-section** +Add `requiresDevMode: true` to `source-adapter-section` in the tile section +registry. One field, one line. The SourceAdapterPanel (registry browser) becomes +invisible to standard users. `graph-sources-section` stays ungated. + +**SA-027 · Advanced picker section under dev gate** +In the GraphSourcePicker modal, add an "Advanced" section below the standard +config form. Visible only when `developer.devMode === true`. Shows raw adapter +config (adapter ID, full config object, inputPattern). Allows power users to +override any config field without navigating the registry browser. + +Dev mode toggle exists at `settings.developer.devMode` (default `false`, wired +in `CategoryAdvanced.tsx` — no new UI needed). + +--- + +### Phase 7 — Polish + +**SA-028 · User-editable source labels** +SourceEntry has a `label` field populated from `summary.label` on load. Add an +edit affordance (inline rename, pencil icon) in the pinned tile and the picker +Recent tab. Updated label persists in `sources.library`. + +**SA-003b · Auto-detect from extension on path entry** +When a user types a path in "Open new", run file-based adapters' detection +patterns against the filename extension and surface suggestions inline +(below the path field, above the adapter cards). These are suggestions — the +user still picks. No silent loading. + +**File picker** +`@tauri-apps/plugin-dialog` provides a native OS file/folder picker. +Eliminates the biggest UX friction in path entry. Requires a +`[DEPENDENCY REQUEST]` — do not implement until approved. Affects all path +entry fields in the picker and Phase 3 scan target. + +--- + +## Scope boundary + +Out of scope for this branch: +- New adapters (generic `{nodes,edges}` JSON, graphology JSON, D3 force format) +- Live-refresh / file-watch for loaded sources +- Multi-source overlay (load two graphs simultaneously) + +--- + +## Work order + +``` +Phase 1: SA-001, SA-004, SA-014, SA-022 (structural foundation) +Phase 2: SA-017, SA-018, SA-019, SA-020, SA-023, + SA-006, SA-007 (empty-state + entry points) +Phase 3: SA-015, SA-021 (directory scanning) +Phase 4: SA-009, SA-010, SA-024, SA-025 (library tile) +Phase 5: SA-011, SA-012, SA-012b, SA-013 (thumbnails) +Phase 6: SA-026, SA-027 (dev mode gating) +Phase 7: SA-028, SA-003b, file picker (polish) +``` + +Each phase is independently shippable. Phase 1 is the priority. + +--- + +## Open questions + +1. **GWells settle signal** — Does GWells currently emit a settle/quiesce event + accessible to the React layer? If yes, SA-011 uses it directly. If no, Phase 5 + MVP is user-triggered capture (SA-011 noted above). + +2. **Cancel abort semantics** — When the user cancels an in-flight load (SA-019), + can the current loader be interrupted mid-stream, or does it need to complete + and then be discarded? Depends on whether adapters are structured as + cancellable async operations. + +3. **`sources.active` migration** — Current default is `"self-graph-yaml-frontmatter"`. + After SA-022 lands, `sources.active` becomes `string | null` pointing to an + adapter ID. How does the existing self-graph entry get promoted into + `sources.library`? Either: auto-create a synthetic SourceEntry on migration, + or start `sources.library` empty and let the user re-add. + +4. **Score thresholds** — How are "strong match" / "weak match" / "possible" labels + assigned from a 0.0–1.0 score? Suggested: ≥0.7 = strong, 0.4–0.7 = weak, + <0.4 = possible. Revisit when first adapters implement `scan()`. + + +## Acceptance criteria (user-experience level) + +Each phase has a code-level Definition of Done (linter passes, tests pass, +etc.) AND a user-experience Definition of Done. + +### Phase 2 acceptance +A first-time user, with no prior context, can: +- Launch LumaWeave (installed but never opened) +- See an empty state with a clear affordance to load data +- Click the affordance and reach the source picker +- Load their own data (from a path they type or select) +- See a rendered graph + +In under 60 seconds. Using only visible affordances. Without asking for help. + +### Phase 3 acceptance +A first-time user, with a directory containing mixed content, can: +- Trigger a directory scan +- See what candidates were found, with clear labels for each +- Understand why each candidate matched +- Pick one and see the graph render + +Without seeing any dead-end error states. Without needing to know what +"adapter" means. + +### Phase 4 acceptance +A returning user, having previously loaded 3+ sources, can: +- See their library on launch +- Recognize sources by their thumbnails/labels/counts +- Pin frequently-used ones +- Switch between them instantly (no reload configuration) +- Reinterpret a source as a different adapter without losing the entry + +### (etc. for phases 5-7) diff --git a/docs/roadmap/GRAPH_SOURCE_UX_REVIEW.md b/docs/roadmap/GRAPH_SOURCE_UX_REVIEW.md new file mode 100644 index 00000000..9baf09d2 --- /dev/null +++ b/docs/roadmap/GRAPH_SOURCE_UX_REVIEW.md @@ -0,0 +1,400 @@ +# Graph Source UX — Architectural Review + +Reviewed against `docs/roadmap/GRAPH_SOURCE_UX.md` (the branch's own stated UX principles), +Nielsen's heuristics, ISO 9241-110 (dialogue principles) / -171 (accessibility), and WCAG 2.2. + +Method: 7 independent review lenses over the on-disk code, each finding adversarially +verified against the source by a second pass. 56 findings raised, 49 survived, 7 refuted. +Every claim below carries a `file:line`. The five load-bearing structural claims were +additionally hand-verified. + +## Status + +| Fix-order step | State | Commit | +|---|---|---| +| 1. `commitSource` verb | **done** | `b341393` | +| 2. Picker draft layer | **done** | `b341393` | +| 3. Hoist the load lifecycle | **in progress** — side effects given a single owner (`af83c7c`); `loadSource()` still runs 4× and `cancelLoad` still mutes 1 of 4 | `af83c7c` | +| 4. Fix what the canvas says | **partial** — fixture no longer renders on error (`af83c7c`); loading busy-overlay still open | `af83c7c` | +| 5. One overlay contract (Escape layering, focus trap) | open | — | +| 6. One library, one card | open | — | +| 7. Fix the entry points (`EmptyPane` is dead code) | open | — | +| 8. Keep scan evidence on screen | **done** | `b341393` | +| 9. Registry owns adapter identity | open | — | +| 10. The sweep — token portal fix | **done** | `67ff407` | + +Also closed along the way: Playwright collection was broken, hiding **57 tests**; 11 stale +assertions and 1 needless quarantine (`8c5781c`). Suite: 828 passed, 0 failed, 13 skipped. + +Two findings **added** during implementation, not present in the original review: + +- **The Cerebra listener was registered 4×.** `listen("cerebra:snapshot-available")` lives + inside `useGraphSourceSummary`, which has four instances — so one snapshot called + `commitSource()` four times, each bumping `refreshToken`: a reload storm. It was dormant only + because an unchanged `sources.active` used to no-op; **the `commitSource` fix armed it.** Needs + the Tauri runtime, so no spec covers it. Fixed by the owner flag in `af83c7c`. +- **The `{...prev}` spread in the loading state is load-bearing.** It retains `normalizedNodes`, + which keeps `hasRealNodes` true mid-switch. Rewriting the loading state "cleanly" would flip + the fixture gate and flash the built-in self-graph on every source change. + +--- + +## The diagnosis + +**The app has no `Source`.** It has an adapter id (`sources.active: string`), a config map keyed +*by adapter* (`sources.configurations: Record` — `settings.defaults.ts:4-6`), +a library of entries, and four private copies of a `summary`. Nowhere is there a single value +meaning "the source that is loaded." The library subsystem actually got this right — +`makeEntryId(adapterId, config)` hashes *both* (`settings.store.ts:23-32`) — but the load path +does not use that model. `useGraphSourceSummary`'s effect keys on `[activeAdapterId, refreshToken]` +(`useGraphSourceSummary.ts:163`), so the system's identity for a source is *the adapter alone*. +Almost every "weird" symptom falls out of that one line. Two Cytoscape files are the same source, +so clicking the second one does nothing (`GraphSourcePicker.tsx:185-189`). Reinterpreting a CSV as +JSON can't carry the path, because config is filed under the old adapter's key +(`GraphSourcePicker.tsx:163-171`). Clicking a Recent entry overwrites `configurations[entry.adapterId]` +wholesale (`GraphSourcePicker.tsx:191-199`), because an adapter can only hold one config at a time. +The loading card shows the *previous* source's label (`GraphSourcesTileContent.tsx:322`) because the +incoming source has no name until it has loaded. Same missing noun, five faces. + +**And it has no commit.** The store is the only channel between surfaces, and a Zustand write carries +no verb — only a new value. So "edit" and "commit" are the same operation, from both ends. + +From the editing end: `AdapterConfigForm.tsx:29-35` writes every keystroke straight into persisted +settings, and all four picker exits call a bare `onClose()` — so Cancel is a lie, and the working +config the user was only *looking at* is gone. + +From the committing end: since a commit is just a value change, an *unchanged* value is not a commit. +`handleLoad` writes the same string to `sources.active` and the effect never re-runs. The error card's +own "Different config" escape (`GraphSourcesTileContent.tsx:352`) is therefore **100% dead by +construction**: it pre-selects the failing adapter, so the corrected path can never trigger a reload. +The team already felt this hole and patched around it — `sources.refreshToken` exists as an out-of-band +"I really mean it" channel, used by exactly two buttons (`GraphSourcesTileContent.tsx:242`, `:268`). +Those are the only two paths in the app that reliably load anything twice. **That token is the missing +verb, wearing a disguise.** + +**The load lifecycle has no owner, so cause and effect land on different surfaces.** +`useGraphSourceSummary` is a plain hook with local `useState` (`:36-38`) and a local `cancelledRef` +(`:45`), instantiated **four times** in the default layout — `AppShell.tsx:61`, +`GraphSourcesTileContent.tsx:213`, `StatusCluster.tsx:7`, `GraphInspectorTileContent.tsx:6`. +One source switch fires four independent `loadSource()` calls, and `cancelLoad` flips exactly one of +the four refs. So "Cancel loading" mutes the tile while the canvas and the topbar complete the load and +render the graph you just cancelled. This is the purest form of the felt discontinuity: **the tile owns +the controls for a lifecycle it does not own the state of.** + +The modal compounds it. `handleLoad` closes the picker at the exact instant the work begins, and because +the loading summary is `{...prev, status: "loading"}` (`:94-97`), the canvas keeps rendering the *old* +graph, fully interactive, until it hard-swaps. You commit on one surface, and for the next several +seconds every surface you're looking at says nothing happened. Then, if it fails, +`useFixture = isTestEnv || !hasRealSource` (`AppShell.tsx:108`) quietly renders the built-in self-graph — +**the app fabricates a graph in the error state**, and the canvas's own "Failed to load graph data" +branch (`AppShell.tsx:647`) is unreachable dead code. + +**Each surface then speaks its own dialect of the app.** +- *Escape*: the picker registers an unguarded `document` listener (`GraphSourcePicker.tsx:173-179`), + Settings a `window` listener with an input guard (`SettingsPanel.tsx:151-162`), the palette a third. + All fire on one keypress — Escape-to-cancel-a-rename closes the whole picker, and closing the picker + also closes the Settings panel behind it. +- *Focus*: the palette saves and restores it (`useCommandPaletteState.ts:69`, `:121`); the picker does + neither. Its adapter cards are `
` (`:485`) while its scan candidates are real ` + {hasRecents && onOpenRecent && ( + + )} +
+ ); +} diff --git a/src/control-plane/graph-sources/GraphSourcePicker.css b/src/control-plane/graph-sources/GraphSourcePicker.css new file mode 100644 index 00000000..56b98425 --- /dev/null +++ b/src/control-plane/graph-sources/GraphSourcePicker.css @@ -0,0 +1,606 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* The modal used to appear and vanish in a single frame. An instant pop is the most common + reason a UI reads as "not smooth" — the eye gets no signal about where the surface came + from. Durations sit in the same 140–220ms band SettingsPanel already uses. */ +@keyframes lw-picker-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes lw-picker-modal-in { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +.lw-picker__backdrop { + position: fixed; + inset: 0; + z-index: 1100; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + animation: lw-picker-backdrop-in 140ms ease-out; +} + +.lw-picker__modal { + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 92%, transparent); + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 80%, transparent); + border-radius: 12px; + inline-size: min(520px, 92vi); + max-block-size: 75vb; + display: flex; + flex-direction: column; + overflow: hidden; + backdrop-filter: blur(var(--lw-panel-blur, 40px)) saturate(180%); + -webkit-backdrop-filter: blur(var(--lw-panel-blur, 40px)) saturate(180%); + /* Elevation matched to SettingsPanel — without it the modal reads as flat-on-glass. */ + box-shadow: + 0 18px 60px rgba(0, 0, 0, 0.55), + 0 0 80px color-mix(in oklab, var(--lw-app-glow) 22%, transparent); + animation: lw-picker-modal-in 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@media (prefers-reduced-motion: reduce) { + .lw-picker__backdrop, + .lw-picker__modal { + animation: none; + } +} + +.lw-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 12px; + border-block-end: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 50%, transparent); + flex-shrink: 0; +} + +.lw-picker__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--lw-text-primary, #e2e8f0); + letter-spacing: 0.01em; +} + +.lw-picker__close { + background: none; + border: none; + color: var(--lw-text-muted, #64748b); + font-size: 0.875rem; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} + +.lw-picker__close:hover { + color: var(--lw-text-primary, #e2e8f0); + background: color-mix(in oklab, var(--lw-text-primary, #e2e8f0) 8%, transparent); +} + +.lw-picker__tabs { + display: flex; + gap: 0; + padding: 0 20px; + border-block-end: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 40%, transparent); + flex-shrink: 0; +} + +.lw-picker__tab { + background: none; + border: none; + border-block-end: 2px solid transparent; + color: var(--lw-text-muted, #64748b); + font-size: 0.75rem; + padding: 10px 12px 8px; + cursor: pointer; + margin-block-end: -1px; + transition: color 0.12s ease, border-color 0.12s ease; +} + +.lw-picker__tab:hover { + color: var(--lw-text-primary, #e2e8f0); +} + +.lw-picker__tab--active { + color: var(--lw-accent, #22d3ee); + border-block-end-color: var(--lw-accent, #22d3ee); +} + +.lw-picker__body { + flex: 1; + overflow-y: auto; + padding: 12px 20px; +} + +/* Recents tab */ + +.lw-picker__recents { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lw-picker__empty-recents { + font-size: 0.75rem; + color: var(--lw-text-muted, #64748b); + padding: 24px 0; + text-align: center; + margin: 0; +} + +.lw-picker__recent-entry { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid transparent; + background: none; + text-align: start; + cursor: pointer; + transition: background 0.1s ease, border-color 0.1s ease; + inline-size: 100%; +} + +.lw-picker__recent-entry:hover { + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 6%, transparent); + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 20%, transparent); +} + +.lw-picker__recent-label { + font-size: 0.8rem; + color: var(--lw-text-primary, #e2e8f0); + font-weight: 500; +} + +.lw-picker__recent-meta { + font-size: 0.7rem; + color: var(--lw-text-muted, #64748b); +} + +/* Open new tab */ + +.lw-picker__open-new { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Scan row */ + +.lw-picker__scan { + display: flex; + gap: 6px; + align-items: center; +} + +.lw-picker__scan-input { + flex: 1; + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 80%, transparent); + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 70%, transparent); + border-radius: 6px; + color: var(--lw-text-primary, #e2e8f0); + font-size: 0.75rem; + padding: 6px 10px; + outline: none; + transition: border-color 0.12s ease; + min-inline-size: 0; +} + +.lw-picker__scan-input:focus { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 50%, transparent); +} + +.lw-picker__scan-input::placeholder { + color: var(--lw-text-muted, #64748b); +} + +.lw-picker__scan-btn { + padding: 5px 12px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--lw-accent, #22d3ee) 40%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 10%, transparent); + color: var(--lw-accent, #22d3ee); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, opacity 0.12s ease; + flex-shrink: 0; +} + +.lw-picker__scan-btn:hover:not(:disabled) { + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 18%, transparent); +} + +.lw-picker__scan-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* Scan results */ + +.lw-picker__scan-results { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lw-picker__scan-results-header { + font-size: 0.7rem; + color: var(--lw-text-muted, #64748b); + padding-block-end: 4px; +} + +.lw-picker__scan-path-preview { + font-family: inherit; + color: var(--lw-text-primary, #e2e8f0); + font-size: inherit; +} + +/* Wraps a candidate button plus, when selected, its inline config form. The list stays on + screen after selection so the ranked evidence remains inspectable and reversible. */ +.lw-picker__scan-candidate-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.lw-picker__scan-candidate-row--selected .lw-picker__scan-candidate { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 55%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 10%, transparent); +} + +.lw-picker__scan-candidate { + display: flex; + flex-direction: column; + gap: 3px; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 50%, transparent); + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 60%, transparent); + text-align: start; + cursor: pointer; + inline-size: 100%; + transition: border-color 0.1s ease, background 0.1s ease; +} + +.lw-picker__scan-candidate:hover { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 35%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 6%, transparent); +} + +.lw-picker__scan-candidate-header { + display: flex; + align-items: center; + gap: 8px; +} + +.lw-picker__scan-candidate-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--lw-text-primary, #e2e8f0); +} + +.lw-picker__scan-candidate-reason { + font-size: 0.7rem; + color: var(--lw-text-muted, #64748b); +} + +/* Score badges */ + +.lw-picker__score-badge { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 10px; + white-space: nowrap; +} + +/* These were the only raw hexes in the file, so the badges ignored the theme entirely — + and under a gold theme the "weak match" amber read as MORE accent-native than the cyan + "strong match", inverting the visual ranking the scores are there to convey. */ +.lw-picker__score-badge--strong-match { + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 15%, transparent); + color: var(--lw-accent, #22d3ee); +} + +.lw-picker__score-badge--weak-match { + background: color-mix(in oklab, var(--lw-color-flare-500, #f59e0b) 15%, transparent); + color: var(--lw-color-flare-500, #f59e0b); +} + +.lw-picker__score-badge--possible { + background: color-mix(in oklab, var(--lw-text-muted, #64748b) 15%, transparent); + color: var(--lw-text-muted, #64748b); +} + +.lw-picker__show-all-btn { + background: none; + border: none; + color: var(--lw-accent, #22d3ee); + font-size: 0.7rem; + cursor: pointer; + padding: 4px 0; + text-align: start; + opacity: 0.75; + transition: opacity 0.12s ease; +} + +.lw-picker__show-all-btn:hover { + opacity: 1; +} + +.lw-picker__scan-no-match { + font-size: 0.75rem; + color: var(--lw-text-muted, #64748b); + margin: 0; + padding: 4px 0 8px; +} + +.lw-picker__adapter-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.lw-picker__category { + display: flex; + flex-direction: column; + gap: 4px; +} + +.lw-picker__category-label { + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--lw-text-muted, #64748b); + padding-block-end: 4px; +} + +.lw-picker__adapter-card { + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 50%, transparent); + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 60%, transparent); +} + +.lw-picker__adapter-card:hover:not(.lw-picker__adapter-card--candidate) { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 35%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 5%, transparent); +} + +.lw-picker__adapter-card--selected { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 60%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 8%, transparent); + cursor: default; +} + +.lw-picker__adapter-card--candidate { + opacity: 0.45; + cursor: default; +} + +.lw-picker__adapter-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.lw-picker__adapter-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--lw-text-primary, #e2e8f0); +} + +.lw-picker__adapter-badge { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 10px; + background: color-mix(in oklab, var(--lw-text-muted, #64748b) 15%, transparent); + color: var(--lw-text-muted, #64748b); + white-space: nowrap; +} + +.lw-picker__adapter-hint { + font-size: 0.7rem; + color: var(--lw-text-muted, #64748b); + margin: 6px 0 0; + line-height: 1.45; +} + +.lw-picker__config-area { + margin-block-start: 10px; + padding-block-start: 10px; + border-block-start: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 40%, transparent); +} + +/* Footer */ + +.lw-picker__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px 16px; + border-block-start: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 40%, transparent); + flex-shrink: 0; +} + +.lw-picker__cancel-btn { + padding: 5px 14px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 60%, transparent); + background: none; + color: var(--lw-text-muted, #64748b); + font-size: 0.75rem; + cursor: pointer; + transition: color 0.12s ease, border-color 0.12s ease; +} + +.lw-picker__cancel-btn:hover { + color: var(--lw-text-primary, #e2e8f0); + border-color: color-mix(in oklab, var(--lw-panel-border, #334155) 90%, transparent); +} + +.lw-picker__load-btn { + padding: 5px 18px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--lw-accent, #22d3ee) 50%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 15%, transparent); + color: var(--lw-accent, #22d3ee); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background 0.12s ease, opacity 0.12s ease; +} + +.lw-picker__load-btn:hover:not(:disabled) { + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 22%, transparent); +} + +.lw-picker__load-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* SA-003b: extension suggestions + file picker browse buttons */ + +.lw-picker__browse-row { + display: flex; + gap: 6px; + margin-block-start: 4px; +} + +.lw-picker__browse-btn { + padding: 3px 10px; + border-radius: 4px; + border: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 70%, transparent); + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 60%, transparent); + color: var(--lw-text-muted, #94a3b8); + font-size: 0.7rem; + cursor: pointer; +} + +.lw-picker__browse-btn:hover { + border-color: color-mix(in oklab, var(--lw-accent, #22d3ee) 40%, transparent); + color: var(--lw-accent, #22d3ee); +} + +.lw-picker__ext-suggestions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-block-start: 6px; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 5%, transparent); + border: 1px solid color-mix(in oklab, var(--lw-accent, #22d3ee) 20%, transparent); +} + +.lw-picker__ext-suggestions-label { + font-size: 0.68rem; + color: var(--lw-text-muted, #94a3b8); + flex-shrink: 0; +} + +.lw-picker__ext-suggestion-chip { + padding: 2px 8px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--lw-accent, #22d3ee) 40%, transparent); + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 10%, transparent); + color: var(--lw-accent, #22d3ee); + font-size: 0.7rem; + cursor: pointer; + transition: background 0.1s ease; +} + +.lw-picker__ext-suggestion-chip:hover { + background: color-mix(in oklab, var(--lw-accent, #22d3ee) 18%, transparent); +} + +/* SA-028: Recent entry rename */ + +.lw-picker__recent-row { + display: flex; + align-items: center; + gap: 4px; +} + +.lw-picker__recent-row .lw-picker__recent-entry { + flex: 1; + min-inline-size: 0; +} + +.lw-picker__recent-rename-input { + flex: 1; + min-inline-size: 0; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid color-mix(in oklab, var(--lw-accent, #22d3ee) 40%, transparent); + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 80%, black); + color: var(--lw-text-primary, #e2e8f0); + font-size: 0.75rem; + outline: none; +} + +.lw-picker__recent-rename-btn { + flex-shrink: 0; + background: none; + border: none; + color: color-mix(in oklab, var(--lw-text-muted, #94a3b8) 60%, transparent); + font-size: 0.75rem; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + line-height: 1; +} + +.lw-picker__recent-rename-btn:hover { + color: var(--lw-text-muted, #94a3b8); + background: color-mix(in oklab, var(--lw-panel-border, #334155) 30%, transparent); +} + +/* SA-027: Advanced section (dev mode only) */ + +.lw-picker__advanced { + margin-block-start: 8px; + border-block-start: 1px solid color-mix(in oklab, var(--lw-panel-border, #334155) 60%, transparent); + padding-block-start: 6px; +} + +.lw-picker__advanced-toggle { + background: none; + border: none; + color: color-mix(in oklab, var(--lw-text-muted, #94a3b8) 80%, transparent); + font-size: 0.7rem; + cursor: pointer; + padding: 0; + letter-spacing: 0.03em; +} + +.lw-picker__advanced-toggle:hover { + color: var(--lw-text-muted, #94a3b8); +} + +.lw-picker__advanced-json { + margin-block-start: 6px; + padding: 8px; + border-radius: 4px; + background: color-mix(in oklab, var(--lw-panel-background, #0f172a) 80%, black); + color: var(--lw-text-muted, #94a3b8); + font-size: 0.68rem; + font-family: monospace; + overflow-x: auto; + white-space: pre; + max-block-size: 180px; + overflow-y: auto; +} diff --git a/src/control-plane/graph-sources/GraphSourcePicker.tsx b/src/control-plane/graph-sources/GraphSourcePicker.tsx new file mode 100644 index 00000000..b89ed338 --- /dev/null +++ b/src/control-plane/graph-sources/GraphSourcePicker.tsx @@ -0,0 +1,591 @@ +// SPDX-License-Identifier: Apache-2.0 +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AdapterConfigForm } from "../../source-adapter/AdapterConfigForm"; +import { getAdapterConfigForm } from "../../source-adapter/adapterConfigFormRegistry"; +import { + getAllSourceAdapterEntries, + getSourceAdapterEntryById, + scanTarget, + type AdapterCategory, + type ScanCandidate, + type SourceAdapterEntry, +} from "../../source-adapter/sourceAdapterRegistry"; +import { useSettingsStore } from "../settings/settings.store"; +import type { AdapterConfig } from "../../source-adapter/baseSourceAdapter"; +import type { SourceEntry } from "../settings/settings.schema"; +// GraphSourcePicker.css is imported by AppShell, not here — see the note at its import site. + +// Importing sourceAdapterRegistry above is enough to pull in all adapter files +// and their config-form side effects transitively. + +// SA-003b: match a typed path's extension against a glob-style inputPattern.pattern. +// Supports *.ext, **/*.ext, **/*.{a,b,c}, and exact basename matches (e.g. package.json). +function patternMatchesPath(pattern: string, filePath: string): boolean { + const basename = filePath.split(/[\\/]/).pop() ?? filePath; + // Expand brace alternatives: **/*.{json,yaml} → ["**/*.json", "**/*.yaml"] + let patterns: string[]; + const braceMatch = pattern.match(/^(.*)\{([^}]+)\}(.*)$/); + if (braceMatch) { + const [, prefix, inner, suffix] = braceMatch; + patterns = inner.split(",").map((s) => `${prefix}${s.trim()}${suffix}`); + } else { + patterns = [pattern]; + } + return patterns.some((p) => { + // Exact basename match (e.g. "package.json", "Cargo.toml") + if (!p.includes("*")) return basename === p; + // Extension glob: *.ext or **/*.ext + const dotIdx = p.lastIndexOf("."); + if (dotIdx >= 0) { + const ext = p.slice(dotIdx); // e.g. ".json" + return basename.endsWith(ext); + } + return false; + }); +} + +function getExtensionSuggestions( + path: string, + allAdapters: readonly SourceAdapterEntry[], +): SourceAdapterEntry[] { + const trimmed = path.trim(); + if (!trimmed || trimmed.length < 3) return []; + return allAdapters.filter( + (a) => + a.category === "file-based" && + a.status !== "candidate" && + patternMatchesPath(a.inputPattern.pattern, trimmed), + ); +} + +// SA-003b / file-picker: open native OS file/folder dialog when running in Tauri. +async function openFilePicker(options: { directory: boolean }): Promise { + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + const result = await open({ directory: options.directory, multiple: false }); + if (typeof result === "string") return result; + return null; + } catch { + return null; + } +} + +// True when running inside Tauri (not Playwright / plain browser). +const isTauriRuntime = + typeof window !== "undefined" && + !(window as any).PLAYWRIGHT && + !!(window as any).__TAURI_INTERNALS__; + +const ADAPTER_DISPLAY_NAMES: Record = { + "self-graph-yaml-frontmatter": "Self Graph", + "markdown-vault": "Markdown Vault", + "cytoscape-json": "Cytoscape JSON", + "package-dependency": "Package Dependencies", + "csv-edge-list": "CSV Edge List", + "cerebra-snapshot": "Cerebra Snapshot", + "git-codebase": "Git Codebase", + "website-url": "Website URL", + "openapi-spec": "OpenAPI Spec", + "database-schema": "Database Schema", + "cloud-infrastructure": "Cloud Infrastructure", + "issue-tracker": "Issue Tracker", +}; + +const CATEGORY_ORDER: AdapterCategory[] = ["file-based", "directory-based", "database", "stream"]; +const CATEGORY_LABELS: Record = { + "file-based": "File", + "directory-based": "Directory", + "database": "Database", + "stream": "Stream", +}; + +function isConfigValid( + adapterId: string, + configurations: Record, + adapterStatus: string, +): boolean { + if (adapterStatus === "candidate") return false; + const config = configurations[adapterId] as any; + const hasForm = !!getAdapterConfigForm(adapterId); + if (!hasForm) return true; // no config form = no config needed + if (!config) return false; + switch (adapterId) { + case "markdown-vault": return !!config.vaultRoot?.trim(); + case "cytoscape-json": return !!config.filePath?.trim(); + case "package-dependency": return !!config.projectPath?.trim(); + case "csv-edge-list": return !!config.filePath?.trim(); + case "cerebra-snapshot": return !!config.filePath?.trim(); + default: return Object.keys(config).length > 1; + } +} + +function formatRelativeTime(isoString: string): string { + const ms = Date.now() - new Date(isoString).getTime(); + const m = Math.floor(ms / 60_000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +interface GraphSourcePickerProps { + onClose: () => void; + initialTab?: "recent" | "open-new"; + initialAdapterId?: string; + reinterpretEntry?: SourceEntry; +} + +export function GraphSourcePicker({ onClose, initialTab, initialAdapterId, reinterpretEntry }: GraphSourcePickerProps) { + const [activeTab, setActiveTab] = useState<"recent" | "open-new">(initialTab ?? "open-new"); + const [selectedAdapterId, setSelectedAdapterId] = useState( + reinterpretEntry?.adapterId ?? initialAdapterId ?? null, + ); + const [scanPath, setScanPath] = useState(""); + const [scanState, setScanState] = useState<"idle" | "running" | "done">("idle"); + const [scanResults, setScanResults] = useState([]); + const [showAllAdapters, setShowAllAdapters] = useState(false); + const [extSuggestions, setExtSuggestions] = useState([]); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [renamingRecentId, setRenamingRecentId] = useState(null); + const [renamingLabel, setRenamingLabel] = useState(""); + + const recents = useSettingsStore((s) => s.settings.sources.library.recent); + const devMode = useSettingsStore((s) => s.settings.developer.devMode); + const commitSource = useSettingsStore((s) => s.commitSource); + const renameLibraryEntry = useSettingsStore((s) => s.renameLibraryEntry); + + // Draft config layer. Every edit in this modal — typing in a config form, picking a scan + // candidate, pre-filling for reinterpret — lands here and NOWHERE else. Persisted settings + // are only touched by commitSource() on Load. That is what makes Cancel/✕/Escape true: + // closing the picker discards the drafts with the component, leaving the working source + // exactly as the user found it. + // + // Seeded from the store so an existing config is visible for editing. SA-024 reinterpret + // seeds the entry's own config instead, so the path survives the adapter swap. + const [draftConfigs, setDraftConfigs] = useState>(() => { + const seed = { ...useSettingsStore.getState().settings.sources.configurations }; + if (reinterpretEntry) seed[reinterpretEntry.adapterId] = reinterpretEntry.config; + return seed; + }); + + const backdropRef = useRef(null); + + function updateDraft(adapterId: string, next: AdapterConfig) { + setDraftConfigs((prev) => ({ ...prev, [adapterId]: next })); + } + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === backdropRef.current) onClose(); + } + + function handleLoad() { + if (!selectedAdapterId) return; + commitSource(selectedAdapterId, draftConfigs[selectedAdapterId]); + onClose(); + } + + function handleLoadRecent(entry: SourceEntry) { + commitSource(entry.adapterId, entry.config); + onClose(); + } + + function handleScanPathChange(value: string) { + setScanPath(value); + setExtSuggestions(getExtensionSuggestions(value, allAdapters)); + } + + async function handleScan() { + const t = scanPath.trim(); + if (!t) return; + setScanState("running"); + setScanResults([]); + setShowAllAdapters(false); + setExtSuggestions([]); + try { + const results = await scanTarget(t); + setScanResults(results); + } catch { + setScanResults([]); + } + setScanState("done"); + } + + async function handleBrowseFile() { + const result = await openFilePicker({ directory: false }); + if (result) { + handleScanPathChange(result); + } + } + + async function handleBrowseDirectory() { + const result = await openFilePicker({ directory: true }); + if (result) { + handleScanPathChange(result); + } + } + + function handleSelectCandidate(candidate: ScanCandidate) { + updateDraft(candidate.adapterId, { + ...draftConfigs[candidate.adapterId], + ...candidate.suggestedConfig, + adapterId: candidate.adapterId, + } as AdapterConfig); + setSelectedAdapterId(candidate.adapterId); + // Deliberately NOT clearing scanResults/scanState: the ranked candidate list stays on + // screen so the user can compare, reconsider, and pick the runner-up without re-scanning. + // "Automated decisions are inspectable — selection reversible" (GRAPH_SOURCE_UX.md). + } + + const allAdapters = getAllSourceAdapterEntries(); + + const selectedEntry = selectedAdapterId + ? getSourceAdapterEntryById(selectedAdapterId) + : undefined; + + // Validate the draft — that is what Load commits. Reading the store here would enable + // Load on a stale persisted config the user has since edited away. + const canLoad = + !!selectedAdapterId && + !!selectedEntry && + isConfigValid(selectedAdapterId, draftConfigs, selectedEntry.status); + + return createPortal( +
+
+
+ Select Graph Source + +
+ +
+ + +
+ +
+ {activeTab === "recent" && ( +
+ {recents.length === 0 ? ( +

No recent sources yet.

+ ) : ( + recents.map((entry) => ( +
+ {renamingRecentId === entry.id ? ( + setRenamingLabel(e.target.value)} + onBlur={() => { + const trimmed = renamingLabel.trim(); + if (trimmed && trimmed !== entry.label) renameLibraryEntry(entry.id, trimmed); + setRenamingRecentId(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const trimmed = renamingLabel.trim(); + if (trimmed && trimmed !== entry.label) renameLibraryEntry(entry.id, trimmed); + setRenamingRecentId(null); + } + if (e.key === "Escape") setRenamingRecentId(null); + }} + data-testid={`graph-source-recent-rename-input-${entry.id}`} + /> + ) : ( + + )} + +
+ )) + )} +
+ )} + + {activeTab === "open-new" && ( +
+ {/* Scan row */} +
+ handleScanPathChange(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && scanPath.trim()) void handleScan(); }} + data-testid="graph-source-scan-input" + /> + +
+ {isTauriRuntime && ( +
+ + +
+ )} + + {/* SA-003b: extension-based adapter suggestions */} + {extSuggestions.length > 0 && scanState === "idle" && ( +
+ Detected: + {extSuggestions.map((adapter) => ( + + ))} +
+ )} + + {/* Scan results */} + {scanState === "done" && scanResults.length > 0 && ( +
+
+ Scan results for {scanPath} +
+ {scanResults.map((candidate) => { + const isSelected = selectedAdapterId === candidate.adapterId; + return ( +
+ {/* The row is a div so the config form can live outside the button — + nesting form controls inside a + {isSelected && ( +
+ updateDraft(candidate.adapterId, next)} + /> +
+ )} +
+ ); + })} + {!showAllAdapters && ( + + )} +
+ )} + + {scanState === "done" && scanResults.length === 0 && ( +

+ No adapter recognized this path — pick one manually +

+ )} + + {/* Adapter list — shown when: scan idle, no results, or "Different type" expanded */} + {(scanState !== "done" || scanResults.length === 0 || showAllAdapters) && ( +
+ {CATEGORY_ORDER.map((category) => { + const group = allAdapters.filter((a) => a.category === category); + if (group.length === 0) return null; + return ( +
+
+ {CATEGORY_LABELS[category]} +
+ {group.map((adapter) => { + const isCandidate = adapter.status === "candidate"; + const isSelected = selectedAdapterId === adapter.adapterId; + return ( +
!isCandidate && setSelectedAdapterId(adapter.adapterId)} + data-testid={`graph-source-adapter-card-${adapter.adapterId}`} + > +
+ + {ADAPTER_DISPLAY_NAMES[adapter.adapterId] ?? adapter.adapterId} + + {isCandidate && ( + Coming soon + )} +
+ {adapter.formatHint && ( +

{adapter.formatHint}

+ )} + {isSelected && !isCandidate && ( +
+ updateDraft(adapter.adapterId, next)} + /> + {devMode && ( +
+ + {advancedOpen && ( +
+                                          {JSON.stringify(
+                                            {
+                                              adapterId: adapter.adapterId,
+                                              inputPattern: adapter.inputPattern,
+                                              // The draft — i.e. what Load would actually commit.
+                                              config: draftConfigs[adapter.adapterId] ?? {},
+                                            },
+                                            null,
+                                            2,
+                                          )}
+                                        
+ )} +
+ )} +
+ )} +
+ ); + })} +
+ ); + })} +
+ )} +
+ )} +
+ +
+ + {activeTab === "open-new" && ( + + )} +
+
+
, + document.body, + ); +} diff --git a/src/control-plane/graph-sources/GraphSourcesTileContent.tsx b/src/control-plane/graph-sources/GraphSourcesTileContent.tsx index dd25d739..26096c0f 100644 --- a/src/control-plane/graph-sources/GraphSourcesTileContent.tsx +++ b/src/control-plane/graph-sources/GraphSourcesTileContent.tsx @@ -1,19 +1,251 @@ // SPDX-License-Identifier: Apache-2.0 import { useState } from "react"; +import { EmptyPane } from "./EmptyPane"; +import { GraphSourcePicker } from "./GraphSourcePicker"; import { useGraphSourceSummary } from "../../graph/ingest/useGraphSourceSummary"; import { useSettingsStore } from "../settings/settings.store"; import { invoke } from "../../lib/tauri-invoke"; +import type { SourceEntry } from "../settings/settings.schema"; + +const ADAPTER_DISPLAY_NAMES: Record = { + "self-graph-yaml-frontmatter": "Self Graph", + "markdown-vault": "Markdown Vault", + "cytoscape-json": "Cytoscape JSON", + "package-dependency": "Package Dependencies", + "csv-edge-list": "CSV Edge List", + "cerebra-snapshot": "Cerebra Snapshot", + "git-codebase": "Git Codebase", + "website-url": "Website URL", + "openapi-spec": "OpenAPI Spec", + "database-schema": "Database Schema", + "cloud-infrastructure": "Cloud Infrastructure", + "issue-tracker": "Issue Tracker", +}; + +function formatRelativeTime(isoString: string): string { + const ms = Date.now() - new Date(isoString).getTime(); + const m = Math.floor(ms / 60_000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} type RegenerateState = "idle" | "running" | "success" | "error"; +interface PickerState { + open: boolean; + initialTab: "recent" | "open-new"; + initialAdapterId?: string; + reinterpretEntry?: SourceEntry; +} + +const CLOSED_PICKER: PickerState = { open: false, initialTab: "open-new" }; + +interface LibraryEntryCardProps { + entry: SourceEntry; + isPinned: boolean; + pendingDeleteId: string | null; + onPin: () => void; + onUnpin: () => void; + onReinterpret: () => void; + onDeleteRequest: () => void; + onDeleteConfirm: () => void; + onDeleteCancel: () => void; + onLoad: () => void; + onRename: (label: string) => void; +} + +function LibraryEntryCard({ + entry, + isPinned, + pendingDeleteId, + onPin, + onUnpin, + onReinterpret, + onDeleteRequest, + onDeleteConfirm, + onDeleteCancel, + onLoad, + onRename, +}: LibraryEntryCardProps) { + const [isEditing, setIsEditing] = useState(false); + const [editLabel, setEditLabel] = useState(""); + + const isPending = pendingDeleteId === entry.id; + const adapterName = ADAPTER_DISPLAY_NAMES[entry.adapterId] ?? entry.adapterId; + + function startEdit() { + setEditLabel(entry.label); + setIsEditing(true); + } + + function commitEdit() { + const trimmed = editLabel.trim(); + if (trimmed && trimmed !== entry.label) onRename(trimmed); + setIsEditing(false); + } + + function cancelEdit() { + setIsEditing(false); + } + + return ( +
+ {entry.thumbnailDataUrl && ( + + )} +
+ {isEditing ? ( + setEditLabel(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(); + if (e.key === "Escape") cancelEdit(); + }} + data-testid={`library-entry-rename-input-${entry.id}`} + /> + ) : ( + + )} +
+ + {isPinned ? ( + + ) : ( + + )} + + +
+
+ + {isPending && ( +
+ Remove from library? + + +
+ )} +
+ ); +} + export function GraphSourcesTileContent() { - const { summary } = useGraphSourceSummary(); - const refreshToken = useSettingsStore((s) => s.settings.sources.refreshToken); + const { summary, isLoading, cancelLoad } = useGraphSourceSummary(); + const activeAdapterId = useSettingsStore((s) => s.settings.sources.active); + const pinned = useSettingsStore((s) => s.settings.sources.library.pinned); + const recents = useSettingsStore((s) => s.settings.sources.library.recent); const setSetting = useSettingsStore((s) => s.setSetting); + const commitSource = useSettingsStore((s) => s.commitSource); + const pinLibraryEntry = useSettingsStore((s) => s.pinLibraryEntry); + const unpinLibraryEntry = useSettingsStore((s) => s.unpinLibraryEntry); + const removeLibraryEntry = useSettingsStore((s) => s.removeLibraryEntry); + const renameLibraryEntry = useSettingsStore((s) => s.renameLibraryEntry); + const [picker, setPicker] = useState(CLOSED_PICKER); const [regenState, setRegenState] = useState("idle"); const [regenError, setRegenError] = useState(null); const [lastGenerated, setLastGenerated] = useState(null); + const [pendingDeleteId, setPendingDeleteId] = useState(null); + + const isSelfGraph = activeAdapterId === "self-graph-yaml-frontmatter"; + const hasLibrary = pinned.length > 0 || recents.length > 0; + + function openPicker(tab: "recent" | "open-new" = "open-new", adapterId?: string) { + setPicker({ open: true, initialTab: tab, initialAdapterId: adapterId }); + } + + function openPickerReinterpret(entry: SourceEntry) { + setPicker({ open: true, initialTab: "open-new", initialAdapterId: entry.adapterId, reinterpretEntry: entry }); + } + + function handleTryAgain() { + const { settings } = useSettingsStore.getState(); + setSetting("sources.refreshToken", settings.sources.refreshToken + 1); + } + + function handleLoadEntry(entry: SourceEntry) { + commitSource(entry.adapterId, entry.config); + } async function handleRegenerate() { setRegenState("running"); @@ -28,7 +260,8 @@ export function GraphSourcesTileContent() { } setLastGenerated(new Date().toLocaleTimeString()); setRegenState("success"); - setSetting("sources.refreshToken", refreshToken + 1); + const { settings } = useSettingsStore.getState(); + setSetting("sources.refreshToken", settings.sources.refreshToken + 1); setTimeout(() => setRegenState("idle"), 2000); } catch (err) { setRegenError(err instanceof Error ? err.message : String(err)); @@ -39,79 +272,238 @@ export function GraphSourcesTileContent() { const isRunning = regenState === "running"; const isSuccess = regenState === "success"; + // --- State machine --- + const isEmpty = !activeAdapterId; + const isErrorState = !isEmpty && !isLoading && summary.status === "error"; + const isLoadedState = !isEmpty && !isLoading && summary.status === "loaded"; + return (
-
-
-
{summary.label}
- {summary.sourcePath && ( -
{summary.sourcePath}
- )} -
setPicker(CLOSED_PICKER)} + initialTab={picker.initialTab} + initialAdapterId={picker.initialAdapterId} + reinterpretEntry={picker.reinterpretEntry} + /> + )} + + {isEmpty && !hasLibrary && ( + openPicker("open-new")} + hasRecents={false} + onOpenRecent={() => openPicker("recent")} + /> + )} + + {isEmpty && hasLibrary && ( +
+
-
-
- Raw nodes: - {summary.nodeCount ?? 0} + + Open a graph source + +
+ )} + + {!isEmpty && isLoading && ( +
+
+
+ + Loading…
-
- Raw edges: - {summary.edgeCount ?? 0} +
{summary.label || activeAdapterId}
+ +
+
+ )} + + {isErrorState && ( +
+
+
Load failed
+
{summary.error}
+
+ + +
-
- Normalized nodes: - {summary.normalizedNodeCount ?? 0} +
+
+ )} + + {isLoadedState && ( +
+
+
{summary.label}
+ {summary.sourcePath && ( +
{summary.sourcePath}
+ )} +
+ {summary.status}
-
- Normalized edges: - {summary.normalizedEdgeCount ?? 0} +
+
+ Raw nodes: + {summary.nodeCount ?? 0} +
+
+ Raw edges: + {summary.edgeCount ?? 0} +
+
+ Normalized nodes: + {summary.normalizedNodeCount ?? 0} +
+
+ Normalized edges: + {summary.normalizedEdgeCount ?? 0} +
-
-
- {lastGenerated && ( - - {lastGenerated} - + + {isSelfGraph && ( + <> +
+ + {lastGenerated && ( + + {lastGenerated} + + )} +
+ + {regenError && ( +
+ {regenError} +
+ )} + )}
+
+ )} - {regenError && ( -
- {regenError} + {/* SA-010: Library sections — always visible when entries exist */} + {hasLibrary && ( +
+ {pinned.length > 0 && ( +
+
+ Pinned +
+
+ {pinned.map((entry) => ( + handleLoadEntry(entry)} + onPin={() => {}} + onUnpin={() => unpinLibraryEntry(entry.id)} + onReinterpret={() => openPickerReinterpret(entry)} + onDeleteRequest={() => setPendingDeleteId(entry.id)} + onDeleteConfirm={() => { removeLibraryEntry(entry.id); setPendingDeleteId(null); }} + onDeleteCancel={() => setPendingDeleteId(null)} + onRename={(label) => renameLibraryEntry(entry.id, label)} + /> + ))} +
+
+ )} + + {recents.length > 0 && ( +
+
+ Recent +
+
+ {recents.map((entry) => ( + handleLoadEntry(entry)} + onPin={() => pinLibraryEntry(entry.id)} + onUnpin={() => {}} + onReinterpret={() => openPickerReinterpret(entry)} + onDeleteRequest={() => setPendingDeleteId(entry.id)} + onDeleteConfirm={() => { removeLibraryEntry(entry.id); setPendingDeleteId(null); }} + onDeleteCancel={() => setPendingDeleteId(null)} + onRename={(label) => renameLibraryEntry(entry.id, label)} + /> + ))} +
)}
-
+ )}
); } diff --git a/src/control-plane/panels/CollapsibleSection.tsx b/src/control-plane/panels/CollapsibleSection.tsx index 775f5b4c..26de1db4 100644 --- a/src/control-plane/panels/CollapsibleSection.tsx +++ b/src/control-plane/panels/CollapsibleSection.tsx @@ -24,8 +24,10 @@ export function CollapsibleSection({ onToggle, children, testId, - accentColor = "#22d3ee", - borderColor = "rgba(34,211,238,0.1)", + // Section titles were a hardcoded cyan, so they stayed cyan under every theme — no caller + // ever overrode either default. The hex remains only as the var() fallback. + accentColor = "var(--lw-accent, #22d3ee)", + borderColor = "color-mix(in oklab, var(--lw-accent, #22d3ee) 10%, transparent)", tileableKey, }: CollapsibleSectionProps) { const ctx = useTileContext(); diff --git a/src/control-plane/panels/tileSectionRegistry.ts b/src/control-plane/panels/tileSectionRegistry.ts index 3464ca4c..67e58ebb 100644 --- a/src/control-plane/panels/tileSectionRegistry.ts +++ b/src/control-plane/panels/tileSectionRegistry.ts @@ -225,6 +225,7 @@ const entries: TileSectionEntry[] = [ defaultVisible: false, defaultExpanded: true, iconGlyph: "🔌", + requiresDevMode: true, }, ]; diff --git a/src/control-plane/settings/SettingsPanel.css b/src/control-plane/settings/SettingsPanel.css index 8a0fef06..2a4962ea 100644 --- a/src/control-plane/settings/SettingsPanel.css +++ b/src/control-plane/settings/SettingsPanel.css @@ -26,16 +26,43 @@ position: absolute; inset: 0; border-radius: 10px; - background: var(--lw-panel-bg); + /* No background here — fill is on ::before so the blur renders unobstructed */ border: 1px solid color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 100%, transparent); - backdrop-filter: blur(18px) saturate(140%); - -webkit-backdrop-filter: blur(18px) saturate(140%); + backdrop-filter: blur(var(--lw-panel-blur, 40px)) saturate(180%); + -webkit-backdrop-filter: blur(var(--lw-panel-blur, 40px)) saturate(180%); box-shadow: 0 18px 60px rgba(0,0,0,0.55), 0 0 0 1px color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 40%, transparent) inset, - 0 0 60px color-mix(in oklab, var(--lw-glow) 18%, transparent); - opacity: var(--lw-settings-bg-opacity); + 0 0 80px color-mix(in oklab, var(--lw-app-glow) 28%, transparent); + pointer-events: none; +} +/* Fill layer: opacity-controlled independently so the blur above stays fully visible */ +.lw-settings-panel-bg::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--lw-panel-background); + opacity: var(--lw-settings-bg-opacity, 1); transition: opacity 220ms ease; +} +/* Diffusion bloom layer — creates frosted-glass aesthetic in all environments. + filter:blur on radial gradients produces soft light scatter that supplements + backdrop-filter when available and carries the look on its own when not. */ +.lw-settings-panel-bg::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: + radial-gradient(ellipse 100% 60% at 18% -5%, + color-mix(in oklab, var(--lw-accent) 26%, transparent), + transparent 65%), + radial-gradient(ellipse 70% 50% at 82% 108%, + color-mix(in oklab, var(--lw-app-glow) 38%, transparent), + transparent 68%); + filter: blur(28px); + opacity: calc(var(--lw-settings-bg-opacity, 1) * 0.5); pointer-events: none; } .lw-settings-panel-chrome { @@ -65,7 +92,7 @@ background: linear-gradient(180deg, color-mix(in oklab, var(--lw-accent) 7%, transparent), - color-mix(in oklab, var(--lw-app-bg) 26%, transparent)); + color-mix(in oklab, var(--lw-app-background) 26%, transparent)); cursor: grab; user-select: none; } @@ -123,7 +150,7 @@ } .lw-searchbar-input { flex: 1; - background: color-mix(in oklab, var(--lw-app-bg) 40%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 40%, transparent); border: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); border-radius: 6px; padding: 7px 10px 7px 32px; @@ -192,7 +219,7 @@ color: var(--lw-text-primary); background: color-mix(in oklab, var(--lw-accent) 12%, transparent); border-inline-start-color: var(--lw-accent); - box-shadow: inset 0 0 12px color-mix(in oklab, var(--lw-glow) 30%, transparent); + box-shadow: inset 0 0 12px color-mix(in oklab, var(--lw-app-glow) 30%, transparent); } .lw-sidebar-item.is-active .lw-sidebar-icon { color: var(--lw-accent); } .lw-sidebar-item.is-dim { opacity: 0.4; } @@ -206,7 +233,7 @@ padding: 1px 5px; border-radius: 8px; background: var(--lw-accent); - color: var(--lw-app-bg); + color: var(--lw-app-background); font-weight: 600; } .is-collapsed .lw-sidebar-label, @@ -292,7 +319,7 @@ transition: background 100ms; } .lw-row:last-child { border-block-end: none; } -.lw-row:hover { background: color-mix(in oklab, var(--lw-panel-bg) 60%, transparent); } +.lw-row:hover { background: color-mix(in oklab, var(--lw-panel-background) 60%, transparent); } .lw-row.is-placeholder { opacity: 0.55; } .lw-row.is-block { grid-template-columns: 1fr; @@ -359,7 +386,7 @@ width: 34px; height: 18px; border-radius: 9px; border: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); - background: color-mix(in oklab, var(--lw-app-bg) 60%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 60%, transparent); transition: 160ms; flex-shrink: 0; } @@ -381,7 +408,7 @@ .lw-settings-panel-chrome .lw-toggle.is-on::after { inset-inline-start: 18px; background: var(--lw-accent); - box-shadow: 0 0 6px var(--lw-glow); + box-shadow: 0 0 6px var(--lw-app-glow); } .lw-settings-panel-chrome .lw-toggle.is-disabled { opacity: 0.5; cursor: not-allowed; } @@ -417,16 +444,16 @@ border-radius: 50%; background: var(--lw-accent); margin-block-start: -4.5px; - border: 2px solid var(--lw-app-bg); - box-shadow: 0 0 10px var(--lw-glow); + border: 2px solid var(--lw-app-background); + box-shadow: 0 0 10px var(--lw-app-glow); cursor: pointer; } .lw-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--lw-accent); - border: 2px solid var(--lw-app-bg); - box-shadow: 0 0 10px var(--lw-glow); + border: 2px solid var(--lw-app-background); + box-shadow: 0 0 10px var(--lw-app-glow); cursor: pointer; } .lw-slider-with-readout { display: flex; align-items: center; gap: 12px; width: 100%; } @@ -446,7 +473,7 @@ .lw-select { appearance: none; -webkit-appearance: none; - background: color-mix(in oklab, var(--lw-app-bg) 60%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 60%, transparent); color: var(--lw-text-primary); border: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); border-radius: 5px; @@ -486,7 +513,7 @@ border: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); border-radius: 5px; color: var(--lw-text-primary); - background: color-mix(in oklab, var(--lw-app-bg) 40%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 40%, transparent); transition: 120ms; } .lw-btn:hover { @@ -530,7 +557,7 @@ display: flex; align-items: stretch; border-block-start: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); - background: color-mix(in oklab, var(--lw-app-bg) 30%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 30%, transparent); font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; letter-spacing: 0.04em; @@ -557,7 +584,7 @@ width: 7px; height: 7px; border-radius: 50%; background: var(--lw-accent); - box-shadow: 0 0 8px var(--lw-glow); + box-shadow: 0 0 8px var(--lw-app-glow); } .lw-status-dot.is-warn { background: var(--lw-color-gold-500); box-shadow: 0 0 8px var(--lw-color-gold-500); } .lw-status-dot.is-alert { background: var(--lw-color-flare-500); box-shadow: 0 0 8px var(--lw-color-flare-500); } @@ -578,18 +605,18 @@ padding: 8px; text-align: start; transition: 120ms; - background: color-mix(in oklab, var(--lw-app-bg) 40%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 40%, transparent); } .lw-themecard:hover { border-color: var(--lw-accent); transform: translateY(-1px); box-shadow: 0 8px 28px rgba(0,0,0,0.4), - 0 0 24px color-mix(in oklab, var(--lw-glow) 30%, transparent); + 0 0 24px color-mix(in oklab, var(--lw-app-glow) 30%, transparent); } .lw-themecard.is-active { border-color: var(--lw-accent); box-shadow: 0 0 0 2px color-mix(in oklab, var(--lw-accent) 30%, transparent), - 0 0 24px color-mix(in oklab, var(--lw-glow) 35%, transparent); + 0 0 24px color-mix(in oklab, var(--lw-app-glow) 35%, transparent); } .lw-themecard-thumb { block-size: 110px; @@ -690,7 +717,7 @@ font-size: 12px; } .lw-override-row:last-child { border-block-end: none; } -.lw-override-row:hover { background: color-mix(in oklab, var(--lw-panel-bg) 60%, transparent); } +.lw-override-row:hover { background: color-mix(in oklab, var(--lw-panel-background) 60%, transparent); } .lw-override-path { font-family: 'IBM Plex Mono', monospace; font-size: 11px; @@ -714,7 +741,7 @@ align-items: center; justify-content: space-between; padding: 8px 12px; - background: color-mix(in oklab, var(--lw-app-bg) 30%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 30%, transparent); border-block-start: 1px solid color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 50%, transparent); } .lw-overrides-count { @@ -771,10 +798,10 @@ z-index: 90; border: 1px solid var(--lw-accent); border-radius: 8px; - background: var(--lw-panel-bg-solid); + background: var(--lw-panel-background); padding: 14px; box-shadow: 0 16px 50px rgba(0,0,0,0.6), - 0 0 24px color-mix(in oklab, var(--lw-glow) 40%, transparent); + 0 0 24px color-mix(in oklab, var(--lw-app-glow) 40%, transparent); font-size: 12px; } .lw-announcement-head { @@ -975,7 +1002,7 @@ border-color: color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-accent)) 80%, transparent); box-shadow: 0 4px 14px rgba(0,0,0,0.5), - 0 0 14px color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-glow)) 55%, transparent), + 0 0 14px color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-app-glow)) 55%, transparent), 0 0 0 1px color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-accent)) 60%, transparent) inset; } .lw-radial-spoke.is-open { @@ -987,7 +1014,7 @@ var(--lw-panel-background, var(--lw-app-background, #1b0830)); box-shadow: 0 6px 20px rgba(0,0,0,0.55), - 0 0 22px color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-glow)) 65%, transparent), + 0 0 22px color-mix(in oklab, var(--lw-inspector-radial-spoke-color, var(--lw-app-glow)) 65%, transparent), 0 0 0 1.5px var(--lw-inspector-radial-spoke-color, var(--lw-accent)) inset; } .lw-radial-spoke.is-dim { @@ -1099,7 +1126,7 @@ border: 1px solid color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 60%, transparent); border-radius: 6px; padding: 5px 10px; - background: color-mix(in oklab, var(--lw-app-bg) 50%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 50%, transparent); } .lw-radial-caption { margin: 10px auto 0; @@ -1112,7 +1139,7 @@ .lw-radial-caption code { font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; - background: color-mix(in oklab, var(--lw-app-bg) 60%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 60%, transparent); padding: 1px 6px; border-radius: 3px; color: var(--lw-text-primary); @@ -1127,7 +1154,7 @@ padding: 10px 12px; border: 1px solid color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 60%, transparent); border-radius: 6px; - background: color-mix(in oklab, var(--lw-app-bg) 30%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 30%, transparent); } .lw-hotkey-pill { display: inline-flex; @@ -1136,7 +1163,7 @@ padding: 5px 10px; border: 1px solid var(--lw-panel-border-current, var(--lw-panel-border)); border-radius: 6px; - background: color-mix(in oklab, var(--lw-app-bg) 60%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 60%, transparent); } .lw-hotkey-pill.is-capturing { border-color: var(--lw-accent); @@ -1168,7 +1195,7 @@ color: var(--lw-text-muted); padding: 6px 10px; border-radius: 5px; - background: color-mix(in oklab, var(--lw-app-bg) 35%, transparent); + background: color-mix(in oklab, var(--lw-app-background) 35%, transparent); border: 1px solid color-mix(in oklab, var(--lw-panel-border-current, var(--lw-panel-border)) 40%, transparent); flex-wrap: wrap; } @@ -1299,7 +1326,7 @@ /* Theme card */ .theme-card { appearance: none; - background: var(--lw-panel-bg); + background: var(--lw-panel-background); border: 1px solid var(--lw-panel-border); border-radius: 8px; cursor: pointer; @@ -1322,7 +1349,7 @@ .theme-card--applied { border-color: var(--lw-accent); - box-shadow: 0 0 8px color-mix(in oklab, var(--lw-glow) 60%, transparent); + box-shadow: 0 0 8px color-mix(in oklab, var(--lw-app-glow) 60%, transparent); } /* Swatch preview */ @@ -1546,7 +1573,7 @@ .lw-palette-chip { block-size: 14px; border-radius: 3px; - box-shadow: 0 0 0 1px color-mix(in oklab, var(--lw-app-bg) 30%, transparent) inset; + box-shadow: 0 0 0 1px color-mix(in oklab, var(--lw-app-background) 30%, transparent) inset; flex: 1; transition: transform 100ms ease; } diff --git a/src/control-plane/settings/SettingsPanel.tsx b/src/control-plane/settings/SettingsPanel.tsx index 6becce20..da8acfe8 100644 --- a/src/control-plane/settings/SettingsPanel.tsx +++ b/src/control-plane/settings/SettingsPanel.tsx @@ -35,7 +35,7 @@ function readRect(): { left: number; top: number; width: number; height: number export function SettingsPanel({ open, onClose, title = 'Settings', subtitle, initialRect, onPositionChange, - opacity: _opacity, + opacity, headerSlot, sidebarSlot, contentSlot, statusBarSlot, }: SettingsPanelProps) { const panelRef = React.useRef(null); @@ -192,7 +192,10 @@ React.useEffect(() => { left: rect.left, top: rect.top, width: rect.width, height: minimized ? undefined : rect.height, - }} + '--lw-settings-bg-opacity': String(opacity), + '--lw-settings-chrome-opacity': String(0.6 + opacity * 0.4), + '--lw-settings-text-opacity': '1', + } as React.CSSProperties} >
diff --git a/src/control-plane/settings/SettingsPanelHost.tsx b/src/control-plane/settings/SettingsPanelHost.tsx index 3a5cccca..a9d70700 100644 --- a/src/control-plane/settings/SettingsPanelHost.tsx +++ b/src/control-plane/settings/SettingsPanelHost.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; import { SETTINGS_PANEL_CATEGORIES } from './settingsPanelCategoryRegistry'; import { settingsRegistry } from './settings.registry'; +import { useSettingsStore } from './settings.store'; import { t } from '../../i18n'; function normCatId(id: string) { @@ -12,7 +13,7 @@ import { SettingsPanel } from './SettingsPanel'; import { SettingsSidebar } from './SettingsSidebar'; import { SettingsContent } from './SettingsContent'; import { SettingsSearchBar } from './SettingsSearchBar'; -import { SettingsStatusBar, applyOpacityLayers } from './SettingsStatusBar'; +import { SettingsStatusBar } from './SettingsStatusBar'; export interface SettingsPanelHostHandle { toggle: () => void; @@ -24,8 +25,9 @@ export const SettingsPanelHost = forwardRef( const [isOpen, setIsOpen] = useState(false); const [activeCategory, setActiveCategory] = useState('theme'); const [search, setSearch] = useState(''); - const [opacity, setOpacity] = useState(1); const [position, setPosition] = useState('floating'); + const panelTransparency = useSettingsStore((s) => s.settings.appearance.panelTransparency); + const setSetting = useSettingsStore((s) => s.setSetting); const [drilledIn, setDrilledIn] = useState(false); useImperativeHandle(ref, () => ({ @@ -45,11 +47,6 @@ export const SettingsPanelHost = forwardRef( return () => document.removeEventListener('keydown', handler); }, []); - useEffect(() => { - const panel = document.querySelector('[data-testid="settings-panel-root"]'); - if (panel) applyOpacityLayers(panel, opacity); - }, [opacity, isOpen]); - const q = search.trim().toLowerCase(); const matchCounts = useMemo(() => { @@ -87,7 +84,7 @@ export const SettingsPanelHost = forwardRef( title={t("settings.panel.title")} subtitle={catLabel.toLowerCase()} onPositionChange={setPosition} - opacity={opacity} + opacity={panelTransparency} headerSlot={} sidebarSlot={ ( setSetting("appearance.panelTransparency", v)} /> } /> diff --git a/src/control-plane/settings/settings.defaults.ts b/src/control-plane/settings/settings.defaults.ts index 13bc8c2b..8f18d5ed 100644 --- a/src/control-plane/settings/settings.defaults.ts +++ b/src/control-plane/settings/settings.defaults.ts @@ -5,6 +5,10 @@ export const defaultSources: SourcesSettings = { active: "self-graph-yaml-frontmatter", configurations: {}, refreshToken: 0, + library: { + pinned: [], + recent: [], + }, }; export const defaultMinimapSettings: MinimapSettings = { @@ -22,7 +26,7 @@ export const defaultMinimapSettings: MinimapSettings = { }; export const defaultSettings: LumaWeaveSettings = { - version: 95, // v112.5b.1: agents.inference config added + version: 96, // v113.0: SourceEntry + sources.library (SA-022, SA-014) general: { startupProjectId: null, @@ -40,7 +44,7 @@ export const defaultSettings: LumaWeaveSettings = { // NEW v86a defaults drama: "cranked", motionScale: 0.6, - panelBlur: 16, + panelBlur: 40, nodeHum: 0.7, nodeFlowSpeed: 0.55, nodeGlow: 1.0, diff --git a/src/control-plane/settings/settings.migrations.ts b/src/control-plane/settings/settings.migrations.ts index b4b2f3b5..9ad9f6eb 100644 --- a/src/control-plane/settings/settings.migrations.ts +++ b/src/control-plane/settings/settings.migrations.ts @@ -155,6 +155,18 @@ const MIGRATIONS: Record; }, + // v95 → v96: sources.library added (v113.0 SA-022). Additive only. + 96: (s) => { + const sources = (s as any).sources ?? defaultSources; + return { + ...s, + sources: { + ...sources, + library: sources.library ?? { pinned: [], recent: [] }, + }, + } as Partial; + }, + // v94 → v95: agents.inference config added (v112.5b.1). Additive only. 95: (s) => { const agents = (s as any).agents ?? {}; diff --git a/src/control-plane/settings/settings.registry.ts b/src/control-plane/settings/settings.registry.ts index 84a44ea2..9163bd3b 100644 --- a/src/control-plane/settings/settings.registry.ts +++ b/src/control-plane/settings/settings.registry.ts @@ -144,12 +144,12 @@ export const settingsRegistry: SettingControl[] = [ label: "Theme Preset", description: "Active theme.", options: [ - { value: "solar-plasma", label: "Solar Plasma" }, - { value: "obsidian-aurora", label: "Obsidian Aurora" }, - { value: "midnight-loom", label: "Midnight Loom" }, - { value: "void-circuit", label: "Void Circuit" }, - { value: "agartha-dream", label: "Agartha Dream" }, - { value: "agartha-dusk", label: "Agartha Dusk" }, + { value: "solar-plasma", label: "Plasma" }, + { value: "obsidian-aurora", label: "Aurora" }, + { value: "midnight-loom", label: "Midnight" }, + { value: "void-circuit", label: "Neon Pink" }, + { value: "agartha-dream", label: "Light Pastel" }, + { value: "agartha-dusk", label: "Lavender" }, ], }, { @@ -157,7 +157,7 @@ export const settingsRegistry: SettingControl[] = [ category: "theme", path: "appearance.drama", label: "Drama", - description: "Solar Plasma mood preset — multiplier on glow + motion intensity.", + description: "Plasma mood preset — multiplier on glow + motion intensity.", options: [ { value: "quiet", label: "Quiet" }, { value: "cranked", label: "Cranked" }, diff --git a/src/control-plane/settings/settings.schema.ts b/src/control-plane/settings/settings.schema.ts index 9dbc215b..c0707fe3 100644 --- a/src/control-plane/settings/settings.schema.ts +++ b/src/control-plane/settings/settings.schema.ts @@ -57,10 +57,26 @@ export interface MinimapAnchor { y?: number; } +export interface SourceEntry { + id: string; + adapterId: string; + config: AdapterConfig; + label: string; + pinnedAt?: string; + loadedAt: string; + nodeCount?: number; + edgeCount?: number; + thumbnailDataUrl?: string; +} + export interface SourcesSettings { active: string | null; configurations: Record; // v109.0.1: narrowed from { inputPath?: string } refreshToken: number; // v108.0.1: incremented on regenerate success to re-trigger useGraphSourceSummary + library: { + pinned: SourceEntry[]; + recent: SourceEntry[]; + }; } export interface MinimapSettings { @@ -78,7 +94,7 @@ export interface MinimapSettings { } export interface LumaWeaveSettings { - version: 95; // v112.5b.1: agents.inference config added + version: 96; // v113.0: SourceEntry + sources.library (SA-022, SA-014) general: { startupProjectId: string | null; diff --git a/src/control-plane/settings/settings.store.ts b/src/control-plane/settings/settings.store.ts index db32479e..da4fd6c5 100644 --- a/src/control-plane/settings/settings.store.ts +++ b/src/control-plane/settings/settings.store.ts @@ -2,16 +2,46 @@ import { create } from "zustand"; import { defaultSettings } from "./settings.defaults"; import { migrateSettings } from "./settings.migrations"; -import type { LumaWeaveSettings } from "./settings.schema"; +import type { LumaWeaveSettings, SourceEntry } from "./settings.schema"; +import type { AdapterConfig } from "../../source-adapter/baseSourceAdapter"; -export const CURRENT_SCHEMA_VERSION = 95; +export const CURRENT_SCHEMA_VERSION = 96; export type SettingsStore = { settings: LumaWeaveSettings; setSetting: (path: string, value: unknown) => void; + commitSource: (adapterId: string, config?: AdapterConfig) => void; resetSettings: () => void; + pushLibraryEntry: (partial: Omit) => void; + pinLibraryEntry: (entryId: string) => void; + unpinLibraryEntry: (entryId: string) => void; + removeLibraryEntry: (entryId: string) => void; + updateLibraryEntryThumbnail: (entryId: string, dataUrl: string) => void; + renameLibraryEntry: (entryId: string, label: string) => void; }; +// Stable ID for a library entry: hash of adapterId + sorted config keys. +// Must be deterministic across page loads (no Math.random / Date). +function makeEntryId(adapterId: string, config: Record): string { + const stable = JSON.stringify( + Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b))), + ); + let h = 0; + for (let i = 0; i < stable.length; i++) { + h = (Math.imul(31, h) + stable.charCodeAt(i)) | 0; + } + return `${adapterId}:${(h >>> 0).toString(36)}`; +} + +function updateLibrary( + settings: LumaWeaveSettings, + library: { pinned: SourceEntry[]; recent: SourceEntry[] }, +): LumaWeaveSettings { + const copy = structuredClone(settings); + copy.sources.library = library; + return copy; +} + function setNestedValue(obj: any, path: string, value: unknown) { const keys = path.split("."); const copy = structuredClone(obj); @@ -56,10 +86,130 @@ export const useSettingsStore = create((set) => ({ settings: setNestedValue(state.settings, path, value), })), + // The single commit verb for "load this source". Writes config, active adapter and + // refreshToken in one set() so the load fires exactly once. + // + // The refreshToken bump is load-bearing, not a nicety: useGraphSourceSummary keys its + // effect on [sources.active, sources.refreshToken]. Committing the adapter that is + // already active leaves `active` byte-identical, so without the bump the effect never + // re-runs and the load silently no-ops — which is what made "Different config" and + // "reload the same adapter with a new path" dead affordances. Config alone is not in + // the dep list, so it cannot serve as the trigger; the token is the trigger. + // + // Omit `config` to re-commit whatever is already stored for the adapter. + commitSource: (adapterId: string, config?: AdapterConfig) => + set((state) => { + const settings = structuredClone(state.settings); + if (config) settings.sources.configurations[adapterId] = config; + settings.sources.active = adapterId; + settings.sources.refreshToken += 1; + return { settings }; + }), + resetSettings: () => set({ settings: defaultSettings, }), + + pushLibraryEntry: (partial) => + set((state) => { + const id = makeEntryId(partial.adapterId, partial.config as unknown as Record); + const entry: SourceEntry = { ...partial, id }; + const library = state.settings.sources.library; + + // If already pinned, update in-place (label/counts/time) — don't re-add to recent. + const pinnedIdx = library.pinned.findIndex((e) => e.id === id); + if (pinnedIdx >= 0) { + const pinned = library.pinned.map((e, i) => + i === pinnedIdx + ? { ...e, label: entry.label, loadedAt: entry.loadedAt, nodeCount: entry.nodeCount, edgeCount: entry.edgeCount } + : e, + ); + return { settings: updateLibrary(state.settings, { ...library, pinned }) }; + } + + // Upsert in recent: move to front if exists, else prepend. Trim to 20. + const recentIdx = library.recent.findIndex((e) => e.id === id); + let recent: SourceEntry[] = recentIdx >= 0 + ? [entry, ...library.recent.filter((_, i) => i !== recentIdx)] + : [entry, ...library.recent]; + if (recent.length > 20) recent = recent.slice(0, 20); + return { settings: updateLibrary(state.settings, { ...library, recent }) }; + }), + + pinLibraryEntry: (entryId) => + set((state) => { + const library = state.settings.sources.library; + const idx = library.recent.findIndex((e) => e.id === entryId); + if (idx < 0) return {}; + const entry = { ...library.recent[idx], pinnedAt: new Date().toISOString() }; + const recent = library.recent.filter((_, i) => i !== idx); + const pinned = [entry, ...library.pinned]; + return { settings: updateLibrary(state.settings, { pinned, recent }) }; + }), + + unpinLibraryEntry: (entryId) => + set((state) => { + const library = state.settings.sources.library; + const idx = library.pinned.findIndex((e) => e.id === entryId); + if (idx < 0) return {}; + const { pinnedAt: _removed, ...unpinned } = library.pinned[idx]; + const pinned = library.pinned.filter((_, i) => i !== idx); + let recent = [unpinned, ...library.recent]; + if (recent.length > 20) recent = recent.slice(0, 20); + return { settings: updateLibrary(state.settings, { pinned, recent }) }; + }), + + removeLibraryEntry: (entryId) => + set((state) => { + const library = state.settings.sources.library; + return { + settings: updateLibrary(state.settings, { + pinned: library.pinned.filter((e) => e.id !== entryId), + recent: library.recent.filter((e) => e.id !== entryId), + }), + }; + }), + + updateLibraryEntryThumbnail: (entryId, dataUrl) => + set((state) => { + const library = state.settings.sources.library; + const pinnedIdx = library.pinned.findIndex((e) => e.id === entryId); + if (pinnedIdx >= 0) { + const pinned = library.pinned.map((e, i) => + i === pinnedIdx ? { ...e, thumbnailDataUrl: dataUrl } : e, + ); + return { settings: updateLibrary(state.settings, { ...library, pinned }) }; + } + const recentIdx = library.recent.findIndex((e) => e.id === entryId); + if (recentIdx >= 0) { + const recent = library.recent.map((e, i) => + i === recentIdx ? { ...e, thumbnailDataUrl: dataUrl } : e, + ); + return { settings: updateLibrary(state.settings, { ...library, recent }) }; + } + return {}; + }), + + renameLibraryEntry: (entryId, label) => + set((state) => { + const library = state.settings.sources.library; + const pinnedIdx = library.pinned.findIndex((e) => e.id === entryId); + if (pinnedIdx >= 0) { + const pinned = library.pinned.map((e, i) => + i === pinnedIdx ? { ...e, label } : e, + ); + return { settings: updateLibrary(state.settings, { ...library, pinned }) }; + } + const recentIdx = library.recent.findIndex((e) => e.id === entryId); + if (recentIdx >= 0) { + const recent = library.recent.map((e, i) => + i === recentIdx ? { ...e, label } : e, + ); + return { settings: updateLibrary(state.settings, { ...library, recent }) }; + } + return {}; + }), })); // Subscribe to state changes and persist to localStorage diff --git a/src/control-plane/topbar/StatusPill.tsx b/src/control-plane/topbar/StatusPill.tsx index 5d6b0cd9..de9855b8 100644 --- a/src/control-plane/topbar/StatusPill.tsx +++ b/src/control-plane/topbar/StatusPill.tsx @@ -4,12 +4,12 @@ import { getAccessibilityProfile } from "../../themes/themeAccessibilityProfile" function getThemeDisplayName(themeId: string): string { const map: Record = { - "solar-plasma": "Solar Plasma", - "obsidian-aurora": "Obsidian Aurora", - "midnight-loom": "Midnight Loom", - "void-circuit": "Void Circuit", - "agartha-dream": "Agartha Dream", - "agartha-dusk": "Agartha Dusk", + "solar-plasma": "Plasma", + "obsidian-aurora": "Aurora", + "midnight-loom": "Midnight", + "void-circuit": "Neon Pink", + "agartha-dream": "Light Pastel", + "agartha-dusk": "Lavender", }; return map[themeId] ?? themeId; } diff --git a/src/control-plane/topbar/topbar.css b/src/control-plane/topbar/topbar.css index 23494eb3..bf438bd0 100644 --- a/src/control-plane/topbar/topbar.css +++ b/src/control-plane/topbar/topbar.css @@ -153,7 +153,9 @@ font-family: var(--lw-font-body, "IBM Plex Sans", system-ui, sans-serif); font-size: 12px; color: var(--lw-text-primary); - background: rgba(11,4,22,0.85); + /* Was a hardcoded near-black (rgba(11,4,22,0.85)), so the theme picker stayed dark under + every theme — including the light ones, where it sat as a black box in a pale topbar. */ + background: var(--lw-panel-background, rgba(11, 4, 22, 0.85)); border: 1px solid var(--lw-panel-border); border-radius: 8px; padding: 6px 10px; diff --git a/src/graph/edges/edgeStyleRegistry.ts b/src/graph/edges/edgeStyleRegistry.ts deleted file mode 100644 index 9c5424cf..00000000 --- a/src/graph/edges/edgeStyleRegistry.ts +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Edge Style Registry - * - * Declares edge style presets for graph rendering (v91+). - * v86e: contract stub. Empty registry; v91 populates with plasma/wire/ribbon presets. - * - * Contract: docs/graph/contracts/EDGE_STYLE_REGISTRY_CONTRACT.md - */ - -export type EdgeStyleMode = "plasma" | "wire" | "ribbon"; - -export interface EdgeStyleEntry { - id: string; - label: string; - mode: EdgeStyleMode; - config: Record; -} - -export interface EdgeStyleFilterQuery { - mode?: EdgeStyleMode; -} - -export interface EdgeStyleRegistryContract { - list: () => EdgeStyleEntry[]; - getById: (id: string) => EdgeStyleEntry | undefined; - filterByCategory: (query: EdgeStyleFilterQuery) => EdgeStyleEntry[]; - validateShape: (entry: unknown) => entry is EdgeStyleEntry; - register: (entry: EdgeStyleEntry) => void; - subscribe: (listener: () => void) => () => void; -} - -const entries: EdgeStyleEntry[] = []; -const listeners: Set<() => void> = new Set(); - -export const edgeStyleRegistry: EdgeStyleRegistryContract = { - list: () => [...entries], - getById: (id) => entries.find((e) => e.id === id), - filterByCategory: ({ mode }) => - mode !== undefined ? entries.filter((e) => e.mode === mode) : [...entries], - validateShape: (entry): entry is EdgeStyleEntry => { - if (typeof entry !== "object" || entry === null) return false; - const e = entry as any; - return ( - typeof e.id === "string" && - typeof e.label === "string" && - (e.mode === "plasma" || e.mode === "wire" || e.mode === "ribbon") && - typeof e.config === "object" && e.config !== null - ); - }, - register: (entry) => { - entries.push(entry); - listeners.forEach((l) => l()); - }, - subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, -}; - -if ( - typeof window !== "undefined" && - (import.meta.env.DEV || (window as any).PLAYWRIGHT) -) { - (window as any).__lwEdgeStyleRegistry = edgeStyleRegistry; -} diff --git a/src/graph/ingest/useGraphSourceSummary.ts b/src/graph/ingest/useGraphSourceSummary.ts index 574d1ac3..ce7f624d 100644 --- a/src/graph/ingest/useGraphSourceSummary.ts +++ b/src/graph/ingest/useGraphSourceSummary.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useRef, useState } from "react"; +import { useEffect } from "react"; +import { create } from "zustand"; import { useSettingsStore } from "../../control-plane/settings/settings.store"; import { invokeEmitSourceLoadFailed, @@ -32,32 +33,119 @@ const idleState: GraphSourceSummary = { warnings: [], }; +/** + * The source lifecycle has exactly one owner. + * + * It used to be a plain hook with local useState, instantiated four times (AppShell, the + * Graph Sources tile, StatusCluster, GraphInspectorTileContent). Four copies of the state + * meant four independent loads per switch — eight under StrictMode — and, worse, four + * different answers to "what am I looking at". `cancelLoad` flipped one of the four refs, so + * 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: one Zustand store, one `useGraphSourceLifecycle()` (mounted once, in AppShell), and + * `useGraphSourceSummary()` as a read-only view that every consumer — including AppShell — + * subscribes to. + */ +interface GraphSourceState { + summary: GraphSourceSummary; + error: string | null; + isLoading: boolean; + cancelLoad: () => void; +} + +// Lifecycle bookkeeping. Module-level is safe *because* there is exactly one lifecycle — but +// it must survive React StrictMode, which mounts, cleans up, and mounts every effect again in +// dev (and dev is what Playwright runs). Anything here that a second invocation could consume +// or corrupt has to be idempotent. See `skipLoadFor`. +let prevSummary: GraphSourceSummary = idleState; +let prevAdapterId: string | null = null; +let causationId: string | null = null; + +// The adapter that produced `prevSummary` — i.e. the one cancelLoad must return to. +// +// This is NOT prevAdapterId. That is overwritten 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 reverting to it is a no-op. The restore point has to be captured alongside +// the summary it belongs to, at the same instant, under the same status guard. +let restoreAdapterId: string | null = null; + +// Bumped by cancelLoad. An in-flight load captures the value at its start and discards its +// own result if the sequence has moved on. Replaces the old per-instance `cancelledRef`. +let cancelSeq = 0; + +// Set by cancelLoad when it reverts sources.active, so that the revert does not itself kick +// off a fresh load of the source we just returned to. +// +// Keyed on (adapterId, refreshToken) rather than being a boolean, and deliberately never +// cleared: a boolean would be consumed by StrictMode's first effect invocation and let the +// second one load anyway. A stale key is harmless — every real commit path goes through +// commitSource(), which always bumps refreshToken, so a genuine load can never match it. +let skipLoadFor: { adapterId: string | null; refreshToken: number } | null = null; + +export const useGraphSourceStore = create((set) => ({ + summary: idleState, + error: null, + isLoading: false, + + cancelLoad: () => { + // Not a true abort: loadSource() takes no AbortSignal, so the in-flight read runs to + // completion and its result is discarded. Cancelling is therefore about restoring the + // user's world, not about stopping the disk. Making it a real abort means threading a + // signal through every adapter's load(config) — a separate change. + cancelSeq += 1; + + const { settings, setSetting } = useSettingsStore.getState(); + + // Revert the active adapter as well. Restoring the previous summary while sources.active + // still points at the adapter we just cancelled leaves the store and the screen telling + // two different stories — the source switch would have "happened" despite the cancel. + if (settings.sources.active !== restoreAdapterId) { + skipLoadFor = { + adapterId: restoreAdapterId, + refreshToken: settings.sources.refreshToken, + }; + setSetting("sources.active", restoreAdapterId); + } + + set({ summary: prevSummary, isLoading: false, error: null }); + }, +})); + +/** + * Read-only view of the source lifecycle. Safe to call from any number of components. + */ export function useGraphSourceSummary() { - const [summary, setSummary] = useState(idleState); - const [error, setError] = useState(null); + const summary = useGraphSourceStore((s) => s.summary); + const error = useGraphSourceStore((s) => s.error); + const isLoading = useGraphSourceStore((s) => s.isLoading); + const cancelLoad = useGraphSourceStore((s) => s.cancelLoad); + return { summary, error, isLoading, cancelLoad }; +} +/** + * Drives the lifecycle. Must be mounted exactly once — AppShell does it. + */ +export function useGraphSourceLifecycle() { const activeAdapterId = useSettingsStore((s) => s.settings.sources.active); const refreshToken = useSettingsStore((s) => s.settings.sources.refreshToken); - const prevAdapterIdRef = useRef(null); - const causationIdRef = useRef(null); - // Listen for Cerebra GraphSnapshotAvailable events forwarded from the Rust watcher. - // On receipt: stash causation_id, configure the cerebra-snapshot adapter, then - // switch to it — the active-adapter change re-triggers the load effect below. + // On receipt: stash causation_id, then commit the new snapshot as the active source. + // commitSource bumps refreshToken, so a *second* snapshot arriving while cerebra-snapshot + // is already active still re-triggers the load effect below. useEffect(() => { let unlisten: (() => void) | null = null; (async () => { try { const { listen } = await import("@tauri-apps/api/event"); unlisten = await listen("cerebra:snapshot-available", (ev) => { - causationIdRef.current = ev.payload.causation_id; - const { setSetting } = useSettingsStore.getState(); - setSetting("sources.configurations.cerebra-snapshot", { + causationId = ev.payload.causation_id; + useSettingsStore.getState().commitSource("cerebra-snapshot", { adapterId: "cerebra-snapshot", filePath: ev.payload.snapshot_ref, }); - setSetting("sources.active", "cerebra-snapshot"); }); } catch { // Not in Tauri environment — no-op @@ -69,58 +157,98 @@ export function useGraphSourceSummary() { }, []); useEffect(() => { + if ( + skipLoadFor && + skipLoadFor.adapterId === activeAdapterId && + skipLoadFor.refreshToken === refreshToken + ) { + prevAdapterId = activeAdapterId; + return; + } + let isMounted = true; + const seq = cancelSeq; + const live = () => isMounted && seq === cancelSeq; - const prevAdapterId = prevAdapterIdRef.current; - prevAdapterIdRef.current = activeAdapterId; + const from = prevAdapterId; + prevAdapterId = activeAdapterId; - if (prevAdapterId !== null && prevAdapterId !== activeAdapterId && activeAdapterId) { - invokeEmitSourceSwitched(prevAdapterId, activeAdapterId).catch(() => {}); + if (from !== null && from !== activeAdapterId && activeAdapterId) { + invokeEmitSourceSwitched(from, activeAdapterId).catch(() => {}); } async function loadSummary() { - setSummary((prev) => ({ ...prev, status: "loading" })); - setError(null); + const current = useGraphSourceStore.getState().summary; + + // Capture the restore point: the summary on screen right now, and the adapter that + // produced it. `from` — not prevAdapterId, which we already advanced to the incoming + // adapter above. + // + // The status guard does double duty. It stops StrictMode's second invocation from + // capturing the first invocation's {status: "loading"} as "the state to go back to", + // and it stops a rapid second switch from overwriting a good restore point with an + // in-flight one. + if (current.status !== "loading") { + prevSummary = current; + restoreAdapterId = from; + } + + // The {...current} spread is load-bearing: it RETAINS normalizedNodes, so a load over + // an existing graph keeps that graph on screen (and keeps AppShell's fixture gate from + // flipping and flashing the built-in self-graph mid-switch). Do not "clean this up". + useGraphSourceStore.setState({ + summary: { ...current, status: "loading" }, + isLoading: true, + error: null, + }); try { const result = await loadSource(activeAdapterId); - if (isMounted) { - setSummary(result); - if (result.status === "error") { - setError(result.error || "Unknown error"); - invokeEmitSourceLoadFailed( - activeAdapterId ?? "", - result.sourcePath, - result.error ?? "unknown error", - ).catch(() => {}); - } else { - const causationId = causationIdRef.current; - causationIdRef.current = null; - invokeEmitSourceLoaded( - activeAdapterId ?? "", - result.sourcePath, - result.nodeCount, - result.edgeCount, - causationId, - ).catch(() => {}); - } - } - } catch (err) { - if (isMounted) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - setSummary((prev) => ({ - ...prev, - status: "error", - error: errorMessage, - })); + if (!live()) return; + + useGraphSourceStore.setState({ summary: result, isLoading: false }); + + if (result.status === "error") { + useGraphSourceStore.setState({ error: result.error || "Unknown error" }); invokeEmitSourceLoadFailed( activeAdapterId ?? "", - "", - errorMessage, + result.sourcePath, + result.error ?? "unknown error", + ).catch(() => {}); + } else { + // SA-009: push SourceEntry to library on successful load + if (activeAdapterId) { + const { settings, pushLibraryEntry } = useSettingsStore.getState(); + pushLibraryEntry({ + adapterId: activeAdapterId, + config: settings.sources.configurations[activeAdapterId] ?? { adapterId: activeAdapterId }, + label: result.label, + loadedAt: new Date().toISOString(), + nodeCount: result.normalizedNodeCount, + edgeCount: result.normalizedEdgeCount, + }); + } + const cid = causationId; + causationId = null; + invokeEmitSourceLoaded( + activeAdapterId ?? "", + result.sourcePath, + result.nodeCount, + result.edgeCount, + cid, ).catch(() => {}); } + } catch (err) { + if (!live()) return; + + const errorMessage = + err instanceof Error ? err.message : "Unknown error occurred"; + useGraphSourceStore.setState((s) => ({ + summary: { ...s.summary, status: "error", error: errorMessage }, + error: errorMessage, + isLoading: false, + })); + invokeEmitSourceLoadFailed(activeAdapterId ?? "", "", errorMessage).catch(() => {}); } } @@ -129,8 +257,6 @@ export function useGraphSourceSummary() { return () => { isMounted = false; }; - // refreshToken is incremented by Regenerate button to re-trigger after script runs + // refreshToken is bumped by commitSource (and by Regenerate / Try again) to re-trigger. }, [activeAdapterId, refreshToken]); - - return { summary, error }; } diff --git a/src/graph/renderers/sigma2d/labelPolicy.ts b/src/graph/renderers/sigma2d/labelPolicy.ts deleted file mode 100644 index 313f3c1e..00000000 --- a/src/graph/renderers/sigma2d/labelPolicy.ts +++ /dev/null @@ -1,331 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * LumaWeave Label Policy Helpers - * Functions for controlling node and edge label visibility and truncation - */ - -import Graph from "graphology"; - -export interface SelectionContext { - selectedNodeId: string | null; - selectedEdgeId: string | null; - neighborhoodDepth: number; - hoveredNodeId: string | null; -} - -export interface LabelPolicyOptions { - maxEdgeLabelLength: number; - showLabelsOnHover: boolean; - hoverLabelColor: string; -} - -export type NodeLabelMode = "off" | "selected-neighborhood" | "important-only" | "all"; -export type EdgeLabelMode = "off" | "selected-neighborhood" | "important-only" | "all-short" | "all-medium"; - -/** - * Get the stored label from attributes, avoiding reliance on the "label" field - * which may have been reset by the label policy. - * This prevents reading empty strings after labels have been cleared. - */ -function getStoredLabel(attrs: any): string { - return String(attrs.fullLabel ?? attrs.originalLabel ?? ""); -} - -/** - * Truncate a label to a maximum length with ellipsis - */ -export function truncateLabel(label: string, maxLength: number): string { - if (!label) return ""; - if (label.length <= maxLength) return label; - return label.slice(0, maxLength) + "..."; -} - -/** - * Get the degree of a node (number of connected edges) - */ -function getNodeDegree(graph: Graph, nodeId: string): number { - return graph.degree(nodeId); -} - -/** - * Get high-degree nodes (top N or threshold) - */ -function getImportantNodeIds(graph: Graph, threshold: number = 3): Set { - const importantNodes = new Set(); - const nodeDegrees: Array<{ nodeId: string; degree: number }> = []; - - graph.forEachNode((nodeId) => { - const degree = getNodeDegree(graph, nodeId); - nodeDegrees.push({ nodeId, degree }); - }); - - // Sort by degree descending - nodeDegrees.sort((a, b) => b.degree - a.degree); - - // Add nodes meeting threshold or top 20 - for (let i = 0; i < nodeDegrees.length; i++) { - const { nodeId, degree } = nodeDegrees[i]; - if (degree >= threshold || i < 20) { - importantNodes.add(nodeId); - } - } - - return importantNodes; -} - -/** - * Apply node label policy to graph - */ -export function applyNodeLabelPolicy( - graph: Graph, - selectionContext: SelectionContext, - options: LabelPolicyOptions, - mode: NodeLabelMode, -): void { - const depth = Math.floor(selectionContext.neighborhoodDepth || 2) as 1 | 2 | 3; - const { selectedNodeId, selectedEdgeId, hoveredNodeId } = selectionContext; - const { showLabelsOnHover } = options; - - // Reset all node labels to empty - graph.forEachNode((nodeId) => { - graph.setNodeAttribute(nodeId, "label", ""); - }); - - // If showLabelsOnHover is true and a node is hovered, show that node's label - if (showLabelsOnHover && hoveredNodeId && graph.hasNode(hoveredNodeId)) { - const attrs = graph.getNodeAttributes(hoveredNodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(hoveredNodeId, "label", label); - } - - if (mode === "off") { - return; - } - - if (mode === "all") { - graph.forEachNode((nodeId) => { - const attrs = graph.getNodeAttributes(nodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(nodeId, "label", label); - }); - return; - } - - if (mode === "important-only") { - const importantNodeIds = getImportantNodeIds(graph); - importantNodeIds.forEach((nodeId) => { - if (graph.hasNode(nodeId)) { - const attrs = graph.getNodeAttributes(nodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(nodeId, "label", label); - } - }); - return; - } - - if (mode === "selected-neighborhood") { - const importantNodeIds = getImportantNodeIds(graph); - - // If edge selected, show source and target labels - if (selectedEdgeId) { - if (graph.hasEdge(selectedEdgeId)) { - const extremities = graph.extremities(selectedEdgeId); - extremities.forEach((nodeId) => { - if (graph.hasNode(nodeId)) { - const attrs = graph.getNodeAttributes(nodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(nodeId, "label", label); - } - }); - } - return; - } - - // If node selected, show selected node label (all stages) - if (selectedNodeId && graph.hasNode(selectedNodeId)) { - const attrs = graph.getNodeAttributes(selectedNodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(selectedNodeId, "label", label); - - // Stage 2: show direct neighbor labels - if (depth >= 2) { - graph.edges(selectedNodeId).forEach((edgeId) => { - const extremities = graph.extremities(edgeId); - extremities.forEach((nodeId) => { - if (nodeId !== selectedNodeId && graph.hasNode(nodeId)) { - const neighborAttrs = graph.getNodeAttributes(nodeId); - const neighborLabel = getStoredLabel(neighborAttrs); - graph.setNodeAttribute(nodeId, "label", neighborLabel); - } - }); - }); - } - - // Stage 3: show secondary neighbor labels - if (depth >= 3) { - const directNeighbors = new Set(); - graph.edges(selectedNodeId).forEach((edgeId) => { - const extremities = graph.extremities(edgeId); - extremities.forEach((nodeId) => { - if (nodeId !== selectedNodeId) { - directNeighbors.add(nodeId); - } - }); - }); - - directNeighbors.forEach((neighborId) => { - if (graph.hasNode(neighborId)) { - graph.edges(neighborId).forEach((edgeId) => { - const extremities = graph.extremities(edgeId); - extremities.forEach((nodeId) => { - // Show secondary neighbors (not selected node, not direct neighbors) - if (nodeId !== selectedNodeId && !directNeighbors.has(nodeId) && graph.hasNode(nodeId)) { - const attrs = graph.getNodeAttributes(nodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(nodeId, "label", label); - } - }); - }); - } - }); - } - return; - } - - // If no selection, show important/core labels only - importantNodeIds.forEach((nodeId) => { - if (graph.hasNode(nodeId)) { - const attrs = graph.getNodeAttributes(nodeId); - const label = getStoredLabel(attrs); - graph.setNodeAttribute(nodeId, "label", label); - } - }); - } -} - -/** - * Apply edge label policy to graph - */ -export function applyEdgeLabelPolicy( - graph: Graph, - selectionContext: SelectionContext, - options: LabelPolicyOptions, - mode: EdgeLabelMode, -): void { - const depth = Math.floor(selectionContext.neighborhoodDepth || 2) as 1 | 2 | 3; - const { selectedNodeId, selectedEdgeId } = selectionContext; - const { maxEdgeLabelLength } = options; - - // Reset all edge labels to empty - graph.forEachEdge((edgeId) => { - graph.setEdgeAttribute(edgeId, "label", ""); - }); - - if (mode === "off") { - return; - } - - if (mode === "all-short") { - graph.forEachEdge((edgeId) => { - const attrs = graph.getEdgeAttributes(edgeId); - const fullLabel = getStoredLabel(attrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength); - graph.setEdgeAttribute(edgeId, "label", truncated); - }); - return; - } - - if (mode === "all-medium") { - graph.forEachEdge((edgeId) => { - const attrs = graph.getEdgeAttributes(edgeId); - const fullLabel = getStoredLabel(attrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength * 2); - graph.setEdgeAttribute(edgeId, "label", truncated); - }); - return; - } - - if (mode === "important-only") { - // For now, hide labels unless selected (no confidence/weight data yet) - // Future: show labels for edges with high confidence/weight - return; - } - - if (mode === "selected-neighborhood") { - // If edge selected, show selected edge label - if (selectedEdgeId && graph.hasEdge(selectedEdgeId)) { - const attrs = graph.getEdgeAttributes(selectedEdgeId); - const fullLabel = getStoredLabel(attrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength); - graph.setEdgeAttribute(selectedEdgeId, "label", truncated); - - // Stage 2: show secondary edge labels - if (depth >= 2) { - // Get relationship neighborhood for secondary edges - // For now, we'll show all edges connected to source/target except the primary edge - // This is a simplification - full implementation would use getRelationshipNeighborhood - const extremities = graph.extremities(selectedEdgeId); - extremities.forEach((nodeId) => { - graph.edges(nodeId).forEach((edgeId) => { - if (edgeId !== selectedEdgeId && graph.hasEdge(edgeId)) { - const edgeAttrs = graph.getEdgeAttributes(edgeId); - const fullLabel = getStoredLabel(edgeAttrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength); - graph.setEdgeAttribute(edgeId, "label", truncated); - } - }); - }); - } - - // Stage 3: show tertiary edge labels - if (depth >= 3) { - // Show edges connected to secondary nodes (excluding primary and secondary) - // This would use getRelationshipNeighborhood with tertiary edges - // For v0, we keep it simple and show all connected edges at stage 2+ - } - } - - // If node selected and stage >= 2, show direct connected edge labels - if (selectedNodeId && depth >= 2 && graph.hasNode(selectedNodeId)) { - graph.edges(selectedNodeId).forEach((edgeId) => { - if (graph.hasEdge(edgeId)) { - const edgeAttrs = graph.getEdgeAttributes(edgeId); - const fullLabel = getStoredLabel(edgeAttrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength); - graph.setEdgeAttribute(edgeId, "label", truncated); - } - }); - } - - // If node selected and stage >= 3, show secondary edge labels - if (selectedNodeId && depth >= 3 && graph.hasNode(selectedNodeId)) { - const directNeighbors = new Set(); - graph.edges(selectedNodeId).forEach((edgeId) => { - const extremities = graph.extremities(edgeId); - extremities.forEach((nodeId) => { - if (nodeId !== selectedNodeId) { - directNeighbors.add(nodeId); - } - }); - }); - - directNeighbors.forEach((neighborId) => { - if (graph.hasNode(neighborId)) { - graph.edges(neighborId).forEach((edgeId) => { - const extremities = graph.extremities(edgeId); - // Only show edges that connect to secondary neighbors (not back to selected node) - if (extremities.includes(selectedNodeId)) { - return; - } - if (graph.hasEdge(edgeId)) { - const edgeAttrs = graph.getEdgeAttributes(edgeId); - const fullLabel = getStoredLabel(edgeAttrs); - const truncated = truncateLabel(fullLabel, maxEdgeLabelLength); - graph.setEdgeAttribute(edgeId, "label", truncated); - } - }); - } - }); - } - } -} diff --git a/src/graph/rendering/graphRendererInterface.ts b/src/graph/rendering/graphRendererInterface.ts deleted file mode 100644 index 30f03e07..00000000 --- a/src/graph/rendering/graphRendererInterface.ts +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * GraphRenderer Interface - * - * Abstract renderer contract. Current implementation is Sigma2d; - * future implementations (Sigma WebGPU, Three.js companion) honor the same interface. - */ - -import type Graph from "graphology"; - -export interface RendererCamera { - x: number; - y: number; - zoom: number; - rotation: number; -} - -export interface GraphRenderer { - mount: (container: HTMLElement, graph: Graph) => void; - unmount: () => void; - refresh: () => void; - getCamera: () => RendererCamera; - setCamera: (camera: RendererCamera) => void; -} diff --git a/src/i18n/manifests/en.json b/src/i18n/manifests/en.json index 48497275..88d78d93 100644 --- a/src/i18n/manifests/en.json +++ b/src/i18n/manifests/en.json @@ -14,12 +14,12 @@ "reduceMotion": "Reduce Motion" }, "themes": { - "solarPlasma": "Solar Plasma", - "obsidianAurora": "Obsidian Aurora", - "midnightLoom": "Midnight Loom", - "voidCircuit": "Void Circuit", - "agarthaDream": "Agartha Dream", - "agarthaDusk": "Agartha Dusk" + "solarPlasma": "Plasma", + "obsidianAurora": "Aurora", + "midnightLoom": "Midnight", + "voidCircuit": "Neon Pink", + "agarthaDream": "Light Pastel", + "agarthaDusk": "Lavender" }, "brand": { "name": "LumaWeave", @@ -224,7 +224,7 @@ }, "controls": { "appearance_theme": { "label": "Theme Preset", "description": "Active theme." }, - "appearance_drama": { "label": "Drama", "description": "Solar Plasma mood preset — multiplier on glow + motion intensity." }, + "appearance_drama": { "label": "Drama", "description": "Plasma mood preset — multiplier on glow + motion intensity." }, "appearance_motionScale": { "label": "Motion Scale", "description": "Master multiplier on backdrop and effect motion. 0 = still." }, "appearance_panelBlur": { "label": "Panel Blur", "description": "backdrop-filter blur amount on dock and panels." }, "appearance_nodeHum": { "label": "Sphere Hum", "description": "Node interior fade rate." }, diff --git a/src/physics/gwells/dialects.ts b/src/physics/gwells/dialects.ts index 52fae161..dfe3a175 100644 --- a/src/physics/gwells/dialects.ts +++ b/src/physics/gwells/dialects.ts @@ -32,21 +32,16 @@ const sharedWellOverrides = { // pinned; physics params don't apply }, "gwells.well.directory-anchor": { - siblingRepulsion: 280, damping: 0.85, centerGravity: 0.05, }, "gwells.well.file-orbit": { - attractionStrength: 0.6, - siblingRepulsion: 120, springStiffness: 0.08, damping: 0.9, idealDistance: 90, centerGravity: 0.02, }, "gwells.well.endpoint-fan": { - attractionStrength: 0.5, - siblingRepulsion: 90, springStiffness: 0.06, damping: 0.9, idealDistance: 100, diff --git a/src/physics/gwells/engine.ts b/src/physics/gwells/engine.ts index 5c80c6e1..dd14f64a 100644 --- a/src/physics/gwells/engine.ts +++ b/src/physics/gwells/engine.ts @@ -27,6 +27,38 @@ function nowMs(): number { return typeof performance !== "undefined" ? performance.now() : Date.now(); } +/** + * L-002: below this separation, two nodes are treated as coincident and given an artificial + * direction to push apart along. Small enough that it never perturbs a healthy layout — a + * sub-pixel gap is already a visual overlap — and large enough to escape float noise. + */ +const COINCIDENT_EPSILON = 0.5; +const COINCIDENT_EPSILON_SQ = COINCIDENT_EPSILON * COINCIDENT_EPSILON; + +/** + * A stable pseudo-angle for separating two coincident nodes. + * + * Deliberately NOT Math.random(): the layout must stay deterministic and repeatable, so the + * same pair always separates along the same axis and the same graph always resolves to the + * same picture. Derived from the node ids via FNV-1a, and made antisymmetric so that A pushing + * away from B and B pushing away from A produce opposite directions rather than the same one + * (which would drift the pair sideways instead of separating it). + */ +function stableSeparationAngle(a: string, b: string): number { + // Order-independent hash of the unordered pair, so both halves of the interaction agree. + const lo = a < b ? a : b; + const hi = a < b ? b : a; + let h = 0x811c9dc5; + const s = `${lo} ${hi}`; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + const base = ((h >>> 0) / 0xffffffff) * Math.PI * 2; + // Antisymmetric: the node that sorts first pushes one way, the other pushes back. + return a === lo ? base : base + Math.PI; +} + const noopScheduler: GWScheduler = { request: () => ({ kind: "gwells.noop-frame" }), cancel: () => undefined, @@ -213,8 +245,6 @@ export function applyDialect( const wellType = getWellTypeById(wellTypeId); if (!wellType) { return { - attractionStrength: 0, - siblingRepulsion: 0, springStiffness: 0, damping: 1, idealDistance: 0, @@ -224,10 +254,6 @@ export function applyDialect( } const override = resolvedConfig.wellOverrides?.[wellTypeId]; return { - attractionStrength: - override?.attractionStrength ?? wellType.defaults.attractionStrength, - siblingRepulsion: - override?.siblingRepulsion ?? wellType.defaults.siblingRepulsion, springStiffness: override?.springStiffness ?? wellType.defaults.springStiffness, damping: override?.damping ?? wellType.defaults.damping, @@ -411,9 +437,28 @@ export function applyDialect( const ox = graph.getNodeAttribute(otherId, "x") as number; const oy = graph.getNodeAttribute(otherId, "y") as number; - const dx = ox - x; - const dy = oy - y; - const distSq = dx * dx + dy * dy; + let dx = ox - x; + let dy = oy - y; + let distSq = dx * dx + dy * dy; + + // L-002: symmetry-breaking for coincident nodes. + // + // The `+ 0.0001` below prevents a division by zero, but it does NOT supply a + // direction. For two nodes at the same coordinates dx = dy = 0, so every force + // becomes (0/dist, 0/dist) * magnitude = exactly ZERO — the magnitude can be + // enormous and it still moves nothing. A perfect stack was a stable fixed point + // that repulsion could never break. + // + // Give them a direction. The angle is derived from the node ids, NOT from + // Math.random(), so the sim stays deterministic and repeatable: the same graph + // always resolves the same way. + if (distSq < COINCIDENT_EPSILON_SQ) { + const angle = stableSeparationAngle(nodeId, otherId); + dx = Math.cos(angle) * COINCIDENT_EPSILON; + dy = Math.sin(angle) * COINCIDENT_EPSILON; + distSq = COINCIDENT_EPSILON_SQ; + } + const dist = Math.sqrt(distSq) + 0.0001; // avoid div-by-zero // Range cutoff. diff --git a/src/physics/gwells/layout/adaptiveRadial.ts b/src/physics/gwells/layout/adaptiveRadial.ts new file mode 100644 index 00000000..ae87153d --- /dev/null +++ b/src/physics/gwells/layout/adaptiveRadial.ts @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Adaptive radial layout — the pipeline from docs/canonical/LAYOUT_PIPELINE.md. + * + * DERIVE -> MEASURE -> ALLOCATE -> PLACE. Four pure functions, in that order. + * + * The point of this module is what it does NOT contain: there is no `directoryOffset`, no + * `spineSpacing`, no `MIN_ORBIT`. Not one constant encodes a distance. Every distance is derived + * from the content, so the layout grows when the content grows and no number has to be re-tuned. + * + * That is the difference between adaptive and preservative layout. The old seeder was + * preservative: `directoryOffset: 220` was tuned against one snapshot, and it was correct until + * node radii changed, and correct again until a directory was deleted. Files orbited at >= 122 + * units while siblings sat 220 apart, so a file ALWAYS crossed into a neighbouring subtree (L-020) + * — a fact nobody noticed until the content shifted and a different pair became the closest. + * + * Here, containment is 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 cannot invade a sibling — not because we constrain the orbit, but because the disc + * that holds them is contained. Files still orbit a full circle; they simply have room to. + * + * Deterministic: no randomness, ordering comes from the caller's sorted ids. Re-running produces + * identical output. + */ + +import { NODE_RADIUS_MIN } from "../seederHelpers"; + +/** The one distance in this file, and it is expressed in stage-1 units, not invented. */ +const PAD = NODE_RADIUS_MIN; + +/** + * A disc of radius p centred at distance r from the origin fits inside a wedge of angle theta + * when sin(theta/2) >= p/r. Allocation gives theta = width/r, so it suffices that + * width >= 2r*asin(p/r), and since asin(x) <= (pi/2)x on [0,1], width >= pi*p is sufficient for + * ANY r > p. Hence the factor — it is a proof obligation, not a fudge. + */ +const WIDTH_FACTOR = Math.PI; + +export interface LayoutTree { + /** Roots, already sorted by the caller. Determinism comes from this ordering. */ + roots: string[]; + /** Directory children of a node, sorted. */ + childDirs: (id: string) => string[]; + /** Leaf (file) children of a node, sorted. */ + childFiles: (id: string) => string[]; + /** Structural radius. MUST come from `baseSize`, never `size` — see LAYOUT_PIPELINE.md rule 4. */ + radius: (id: string) => number; +} + +export interface Measured { + /** Radius of the disc containing this node AND its orbiting files. */ + discRadius: number; + /** Radius at which this node's files orbit. */ + orbitRadius: number; + /** Tangential width this subtree requires. */ + width: number; + depth: number; +} + +export interface Placed { + x: number; + y: number; + z: number; +} + +// ─── 2 · MEASURE ───────────────────────────────────────────────────────────── +// Bottom-up. How much room does this subtree ACTUALLY need? Never how much we think it deserves. + +export function measure(tree: LayoutTree): Map { + const out = new Map(); + + const visit = (id: string, depth: number): Measured => { + const cached = out.get(id); + if (cached) return cached; + + const files = tree.childFiles(id); + const selfRadius = tree.radius(id); + + // The file orbit has to satisfy two independent constraints, and we take the stricter: + // (a) files clear the parent's disc -> orbit >= selfRadius + maxFile + PAD + // (b) files clear EACH OTHER around the ring + // + // (b) is derived, not guessed. Each file gets an angular slot proportional to its own radius + // (see `place`), so neighbours i, i+1 are separated by an angle + // delta = pi * (r_i + r_i+1) / sumR. + // Their chord separation is 2*orbit*sin(delta/2) >= (2/pi)*orbit*delta (sin x >= 2x/pi on + // [0, pi/2]), and requiring that to exceed r_i + r_i+1 gives, after the sumR cancels: + // + // orbit >= sumR / 2 [*] + // + // No fudge factor and no magic number — the bound falls out of the placement rule. This is + // also why (b) makes a directory with 40 files automatically bigger than one with 2. + // + // My first attempt used `sumR / pi` with phyllotaxis placement, and the invariant test caught + // it immediately: phyllotaxis distributes files well *on average* but guarantees no minimum + // gap between ADJACENT files of differing size, so two large neighbours overlapped at a ratio + // of 0.5. Sizing the orbit for total arc is not the same as spacing the files. + let orbitRadius = 0; + let discRadius = selfRadius; + if (files.length > 0) { + let maxFile = 0; + let sumFile = 0; + for (const f of files) { + const r = tree.radius(f); + if (r > maxFile) maxFile = r; + sumFile += r; + } + orbitRadius = Math.max(selfRadius + maxFile + PAD, sumFile / 2); // [*] + discRadius = orbitRadius + maxFile; + } + + // Children are laid side by side inside this node's sector, so the subtree is at least as + // wide as their sum — and at least as wide as its own disc. + let childWidthSum = 0; + for (const c of tree.childDirs(id)) { + childWidthSum += visit(c, depth + 1).width; + } + + const width = Math.max(WIDTH_FACTOR * discRadius + PAD, childWidthSum); + + const m: Measured = { discRadius, orbitRadius, width, depth }; + out.set(id, m); + return m; + }; + + tree.roots.forEach((r) => visit(r, 0)); + return out; +} + +// ─── 3 · ALLOCATE ──────────────────────────────────────────────────────────── +// Top-down. Turn measured need into DISJOINT sectors and ring radii. + +export interface Sector { + centre: number; + extent: number; + ring: number; +} + +export function allocate( + tree: LayoutTree, + measured: Map, +): { sectors: Map; ringRadius: number[] } { + // Group by depth so a ring can be sized against everything that has to fit on it. + const byDepth: string[][] = []; + const walk = (id: string) => { + const m = measured.get(id)!; + (byDepth[m.depth] ??= []).push(id); + tree.childDirs(id).forEach(walk); + }; + tree.roots.forEach(walk); + + const maxDiscAt = (d: number) => + (byDepth[d] ?? []).reduce((mx, id) => Math.max(mx, measured.get(id)!.discRadius), 0); + + // THE ADAPTIVE REPLACEMENT FOR `directoryOffset`. + // + // A ring must satisfy two things, and again we take the stricter: + // (a) radial clearance — its discs must not touch the previous ring's discs + // (b) circumference — 2*pi*r must be long enough to hold everything on it + // (b) is what makes the graph SPREAD OUT as it grows. Add 500 files and the ring expands; + // nothing is re-tuned. + const ringRadius: number[] = []; + for (let d = 0; d < byDepth.length; d++) { + const totalWidth = (byDepth[d] ?? []).reduce((s, id) => s + measured.get(id)!.width, 0); + const circumferenceNeed = totalWidth / (2 * Math.PI); + + if (d === 0) { + // A single root sits at the origin and owns the whole circle; there is no ring to size. + ringRadius[0] = tree.roots.length <= 1 ? 0 : Math.max(circumferenceNeed, maxDiscAt(0)); + } else { + const clearance = ringRadius[d - 1] + maxDiscAt(d - 1) + maxDiscAt(d) + PAD; + ringRadius[d] = Math.max(clearance, circumferenceNeed); + } + } + + const sectors = new Map(); + + // Roots tile the full circle, weighted by measured width. Exactly — no minimum-arc floor. + // A floor over-allocates, and over-allocating a full circle is precisely how sectors began + // overlapping again (see GWELLS_PHYSICS.md / L-001b: 41 roots x 12deg floor = 492deg of 360). + tileInto(tree, measured, sectors, tree.roots, 0, 2 * Math.PI, ringRadius, 0); + + return { sectors, ringRadius }; +} + +/** + * Give each child its own slice of the parent's sector. Each child receives AT LEAST the angle it + * measured as needing (width / r); leftover slack is shared out in proportion, so the sector is + * filled rather than clumped at its centre. + * + * Slack is guaranteed non-negative by the MEASURE invariant — see the proof in the file header. + * We clamp anyway: a guarantee you don't check is a guarantee you don't have. + */ +function tileInto( + tree: LayoutTree, + measured: Map, + sectors: Map, + ids: string[], + centre: number, + extent: number, + ringRadius: number[], + depth: number, +): void { + if (ids.length === 0) return; + + const r = ringRadius[depth]; + const needed = ids.map((id) => { + const w = measured.get(id)!.width; + // At the origin (single root) the whole circle is available. + return r <= 0 ? extent / ids.length : w / r; + }); + + const totalNeeded = needed.reduce((a, b) => a + b, 0); + const slack = Math.max(0, extent - totalNeeded); + + let cursor = centre - extent / 2; + ids.forEach((id, i) => { + const share = + totalNeeded > 0 ? needed[i] + slack * (needed[i] / totalNeeded) : extent / ids.length; + const mid = cursor + share / 2; + cursor += share; + + sectors.set(id, { centre: mid, extent: share, ring: r }); + + tileInto(tree, measured, sectors, tree.childDirs(id), mid, share, ringRadius, depth + 1); + }); +} + +// ─── 4 · PLACE ─────────────────────────────────────────────────────────────── +// Mechanical. Every decision was already made; this just evaluates cos/sin. + +export function place( + tree: LayoutTree, + measured: Map, + sectors: Map, +): Map { + const out = new Map(); + + const visit = (id: string) => { + const s = sectors.get(id); + const m = measured.get(id); + if (!s || !m) return; + + const x = s.ring * Math.cos(s.centre); + const y = s.ring * Math.sin(s.centre); + // Planar for now. z is carried but unused by the renderer and by the 2D simulation — + // see docs/ledger/GRAPH_DISPLAY.md GD-011/GD-013 and RM-016. + out.set(id, { x, y, z: 0 }); + + // Files orbit inside this node's OWN disc, which the allocation guarantees fits inside its + // sector. So they may orbit a FULL CIRCLE without ever reaching a sibling — no angular squeeze + // is needed, and none is applied. Containment comes from the disc, not from cramping the orbit. + // + // Each file gets an angular slot proportional to its own radius — the same proportional tiling + // used for sectors, one level down. That is what makes the `orbit >= sumR / 2` bound in + // `measure` sufficient: a big file is given a big slice, so it cannot crowd its neighbour. + // (Evenly-spaced or golden-angle placement does NOT have this property when radii differ.) + const files = tree.childFiles(id); + if (files.length > 0) { + const radii = files.map((f) => tree.radius(f)); + const sumR = radii.reduce((a, b) => a + b, 0) || 1; + + let cursor = 0; + files.forEach((f, i) => { + const slot = (2 * Math.PI * radii[i]) / sumR; + const angle = cursor + slot / 2; + cursor += slot; + + out.set(f, { + x: x + m.orbitRadius * Math.cos(angle), + y: y + m.orbitRadius * Math.sin(angle), + z: 0, + }); + }); + } + + tree.childDirs(id).forEach(visit); + }; + + tree.roots.forEach(visit); + return out; +} + +/** The whole pipeline, in order. */ +export function layoutAdaptiveRadial(tree: LayoutTree): Map { + const measured = measure(tree); + const { sectors } = allocate(tree, measured); + return place(tree, measured, sectors); +} diff --git a/src/physics/gwells/seederHelpers.ts b/src/physics/gwells/seederHelpers.ts index 02491cec..d77e2c5f 100644 --- a/src/physics/gwells/seederHelpers.ts +++ b/src/physics/gwells/seederHelpers.ts @@ -245,52 +245,166 @@ export function computeOrbitRadius( } /** - * Maps raw content size (line count or byte count) to a visual node size. - * + * Maps raw content size (line count or byte count) to a visual node RADIUS. + * * Uses logarithmic scaling because raw sizes span 4+ orders of magnitude * (1 line to 10000+ lines), and linear mapping would crush most files * into the minimum visual size while outliers dominate. - * + * * Formula: clamp(MIN + (MAX - MIN) * log(1 + size) / log(1 + SCALE_REF), MIN, MAX) - * + * * - size = 0 returns MIN * - size = SCALE_REF returns MAX * - sizes between scale log-linearly - * - * Defaults give: - * size=1 -> 4.5 - * size=10 -> 10.4 - * size=100 -> 19.0 - * size=1000 -> 30.0 - * size=11000 (max in our data) -> 40 + * + * L-019 — THE UNITS, which is the whole point: + * + * Sigma is configured with `itemSizesReference: "positions"`, so a node's `size` is a **radius in + * graph units** — the same units as x/y — not in pixels. The value returned here is therefore + * directly comparable to the seeders' spacing constants, and it has to be read that way. + * + * It was not. MIN/MAX were 48/360, giving a median radius of ~219 against a `directoryOffset` of + * **220**: a node's radius equalled the entire distance to its parent, so its DIAMETER was twice + * the spacing. Every node overlapped its neighbours by construction, at every zoom level, no + * matter how correct the seed positions were. (The docblock above this function still described + * an output range of 4.5–40 — the constants had been inflated ~9x and the doc left behind, which + * is how a node radius and a node gap ended up as the same number without anyone noticing.) + * + * The constants below are the old ones scaled by 1/4, which preserves the log curve and the + * dynamic range (max/min stays 7.5) and simply moves the whole scale into a sane relationship + * with the spacing: + * + * median radius ~55 vs directoryOffset 220 -> two adjacent nodes need 110 of the 220 available + * largest radius 90 vs directoryOffset 220 -> even two maximal nodes clear each other + * + * If you change these, change them against the spacing constants in the seeders, not by eye. + * A node radius is only meaningful relative to the distance to the next node. */ +export const NODE_RADIUS_MIN = 12; +export const NODE_RADIUS_MAX = 90; + export function computeNodeSize(rawSize: number): number { - const MIN = 48; - const MAX = 360; const SCALE_REF = 6000; - - if (rawSize <= 0) return MIN; - - const scaled = MIN + (MAX - MIN) * Math.log(1 + rawSize) / Math.log(1 + SCALE_REF); - return Math.max(MIN, Math.min(MAX, scaled)); + + if (rawSize <= 0) return NODE_RADIUS_MIN; + + const scaled = + NODE_RADIUS_MIN + + (NODE_RADIUS_MAX - NODE_RADIUS_MIN) * Math.log(1 + rawSize) / Math.log(1 + SCALE_REF); + return Math.max(NODE_RADIUS_MIN, Math.min(NODE_RADIUS_MAX, scaled)); +} + +/** + * L-001: the narrowest wedge a subtree may be given. Without a floor, a subtree with one leaf + * sitting beside one with hundreds gets an arc so thin its own descendants re-collapse onto a + * line — trading one pile-up for another. + */ +export const MIN_FAN_ARC_RAD = (12 * Math.PI) / 180; + +/** + * L-001: leaf count of a directory subtree. + * + * WEIGHTS a subtree's angular budget, so a directory holding 200 files gets proportionally more + * arc than one holding 2. Memoized — the tree is re-entered constantly during placement. + * + * `isDirectory` is injected because the two seeders resolve node type from slightly different + * attribute shapes. + */ +export function makeLeafCounter( + parentToChildren: Map>, + isDirectory: (id: string) => boolean, +): (dirId: string) => number { + const cache = new Map(); + function countLeaves(dirId: string): number { + const cached = cache.get(dirId); + if (cached !== undefined) return cached; + const children = parentToChildren.get(dirId); + if (!children || children.size === 0) { + cache.set(dirId, 1); + return 1; + } + let total = 0; + for (const cid of children) { + total += isDirectory(cid) ? countLeaves(cid) : 1; + } + const n = Math.max(1, total); + cache.set(dirId, n); + return n; + } + return countLeaves; +} + +/** + * L-001: split an angular wedge among children, weighted by leaf count. THE fix for the pile-up. + * + * Previously every sibling was handed the SAME direction vector — no per-sibling term existed + * anywhere — so siblings computed byte-identical coordinates. The physics could never recover: + * for two coincident nodes the repulsion DIRECTION is the zero vector, so the applied force is + * exactly zero no matter how large its magnitude. A perfect stack was a stable fixed point. + * + * Because the sub-wedges TILE the parent's wedge without overlapping, siblings own disjoint + * angular ranges — overlap becomes impossible by construction rather than something the + * simulation has to win. + * + * Deterministic: no randomness; ordering comes from the caller's already-sorted array. + */ +export function subdivideWedge( + childIds: string[], + centerAngle: number, + angularExtent: number, + countLeaves: (id: string) => number, + /** + * Floor on each child's arc. Defaults to MIN_FAN_ARC_RAD, which keeps a one-leaf subtree from + * being squeezed to nothing beside a hundred-leaf one. + * + * Pass 0 when the wedge being divided must be TILED EXACTLY — above all when subdividing the + * full circle among roots. The floor is a deliberate over-allocation: it hands a child more arc + * than its weight earned, so the children's extents can sum to more than the parent's. Inside a + * parent's wedge that is harmless slack. Across the whole circle it is not: 41 roots each + * floored to 12 degrees claim 492 degrees of a 360 degree circle, and the sectors overlap again + * — which is precisely the bug this parameter exists to avoid re-introducing. + */ + minArc: number = MIN_FAN_ARC_RAD, +): Array<{ id: string; centerAngle: number; angularExtent: number }> { + const weights = childIds.map((id) => countLeaves(id)); + const totalWeight = weights.reduce((a, b) => a + b, 0) || 1; + let cursor = centerAngle - angularExtent / 2; + return childIds.map((id, i) => { + const share = (weights[i] / totalWeight) * angularExtent; + const mid = cursor + share / 2; + cursor += share; + return { + id, + centerAngle: mid, + angularExtent: Math.max(share, minArc), + }; + }); } /** * Phyllotaxis spiral file placement. - * + * * Returns the radial distance from parent and the angular position for * a file, given: * - fileIndex: position in size-sorted file list (0 = smallest, N-1 = largest) * - fileCount: total files in this directory - * - parentVisualSize: the directory parent's visual size (orbits scale up - * for larger parents so files don't crowd them) - * + * - parentVisualSize: the directory parent's radius, so orbits scale up for larger parents and + * files clear the parent's disc instead of landing inside it. Callers must pass **`baseSize`**, + * NOT `size`. `size` is `baseSize x settings.nodeSize`, and it is further rewritten on hover and + * selection by graphStylePolicy — so feeding it in here made the node-size slider silently + * change the LAYOUT on the next reseed, and made file orbits depend on what happened to be + * selected. `baseSize` is the structural radius and the only one geometry may read. + * * Uses φ-angle (137.508°) between successive files for natural non-overlap. * Radial position scales log-linearly with index (smallest closest, largest * farthest) within [MIN, MAX] envelope. - * + * * The MIN clamp keeps even the smallest file visibly separated from the parent. * The MAX clamp prevents the largest files from drifting absurdly far. + * + * NOTE: the returned angleRad is RELATIVE to the parent — callers must add their own + * frame's base angle. (L-005: one caller shadowed the variable and added the base angle + * to itself, discarding the orbit angle entirely and stacking every file on one ray.) */ export function computeFileOrbit( fileIndex: number, @@ -299,10 +413,20 @@ export function computeFileOrbit( ): { radius: number; angleRad: number } { // φ angle in radians: 137.508° = (3 - √5) * π const PHYLLOTAXIS_ANGLE = (3 - Math.sqrt(5)) * Math.PI; - - // Envelope. Scales modestly with parent size — bigger parents push files farther. - const MIN_ORBIT = 30 + parentVisualSize * 1; - const MAX_ORBIT = 120 + parentVisualSize * 3; + + // Envelope. Scales with parent size — bigger parents push files farther out. + // + // L-019: the inner bound is a CLEARANCE, so it is derived from the radii rather than from a + // magic number. A file orbiting its parent must clear the parent's disc (parentVisualSize) plus + // its own radius — and a file can be as large as NODE_RADIUS_MAX, so that is what has to fit. + // + // It used to read `30 + parentVisualSize`, i.e. a flat 30 units of clearance. That was tuned + // when node radii were 48–360 and 30 was a rounding error against them; once the scale was + // corrected it became the binding constraint, and a file with a 90-unit radius sitting 30 units + // off its parent's edge lands *inside* the parent. The constants and the radii have to move + // together — which is the same lesson as the size/spacing coupling above. + const MIN_ORBIT = parentVisualSize + NODE_RADIUS_MAX + 20; + const MAX_ORBIT = MIN_ORBIT + parentVisualSize * 2 + 90; // Radial position: index 0 -> MIN, index N-1 -> MAX // Linear in index. Could be log-linear if we wanted heavier weighting near MIN. diff --git a/src/physics/gwells/seeders/parallelSpines.ts b/src/physics/gwells/seeders/parallelSpines.ts index 0c030abb..2b673db0 100644 --- a/src/physics/gwells/seeders/parallelSpines.ts +++ b/src/physics/gwells/seeders/parallelSpines.ts @@ -25,7 +25,7 @@ */ import type { GWSeedFunctionContext, GWHelixTwistRecord } from "../types"; -import { axisOffsetForN, resolveHelixTwist, buildContainsMap, flattenSpinesFromRoot, assignSpinesToAxes, computeFileOrbit, seedGenericFallbackLayout, placeUnseededNodesWithFallback, shouldUseHubRing, computeHubRingRadius, computeHubRingPosition } from "../seederHelpers"; +import { axisOffsetForN, resolveHelixTwist, buildContainsMap, flattenSpinesFromRoot, assignSpinesToAxes, computeFileOrbit, seedGenericFallbackLayout, placeUnseededNodesWithFallback, shouldUseHubRing, computeHubRingRadius, computeHubRingPosition, makeLeafCounter, subdivideWedge } from "../seederHelpers"; interface ParallelSpinesParams { spineCount: number; @@ -37,6 +37,15 @@ interface ParallelSpinesParams { fileOrbitRadius: number; endpointFanArc: number; endpointFanCount: number; + /** + * L-001: total angular width, in degrees, of the wedge a directory's children fan into, + * measured within that spine's vertical plane (see placeBranchRecursive). + * + * Narrower than radial-backbone's 150° because this wedge is bounded by the spine itself: + * at ±90° a branch would run straight along the spine axis and collide with the run it hangs + * off. 120° keeps the extremes 30° clear. + */ + directoryFanArc: number; // degrees } const DEFAULTS: ParallelSpinesParams = { @@ -49,6 +58,7 @@ const DEFAULTS: ParallelSpinesParams = { fileOrbitRadius: 90, endpointFanArc: 100, endpointFanCount: 6, + directoryFanArc: 120, }; function resolveParams(raw: Record): ParallelSpinesParams { @@ -68,6 +78,8 @@ function resolveParams(raw: Record): ParallelSpinesParams { fileOrbitRadius: typeof raw.fileOrbitRadius === "number" ? raw.fileOrbitRadius : DEFAULTS.fileOrbitRadius, endpointFanArc: typeof raw.endpointFanArc === "number" ? raw.endpointFanArc : DEFAULTS.endpointFanArc, endpointFanCount: typeof raw.endpointFanCount === "number" ? raw.endpointFanCount : DEFAULTS.endpointFanCount, + directoryFanArc: + typeof raw.directoryFanArc === "number" ? raw.directoryFanArc : DEFAULTS.directoryFanArc, }; } @@ -89,58 +101,66 @@ export function seedParallelSpines(ctx: GWSeedFunctionContext): void { // NEW: Stored for engine's seed-anchor force — ALL nodes const allSeedPositions = new Map(); - // NEW: Recursive directory placement (Pass C8 fern-frond) + // L-001: shared with radialBackbone via seederHelpers — one implementation, not two. + const countLeaves = makeLeafCounter(parentToChildren, (id) => { + const t = graph.getNodeAttributes(id).nodeType || graph.getNodeAttributes(id).raw?.type; + return t === "directory"; + }); + const fanOut = (ids: string[], centerAngle: number, extent: number) => + subdivideWedge(ids, centerAngle, extent, countLeaves); + + // Recursive directory placement (Pass C8 fern-frond, reworked by L-001). + // + // Every position here is expressed in the SPINE'S VERTICAL PLANE: the plane spanned by that + // spine's outward radial direction, outward(α) = (cos α, 0, sin α), and the y axis. A direction + // in that plane is one angle φ (an elevation, 0 = straight out horizontally): + // + // dir(φ) = outward(α)·cos φ + ŷ·sin φ = (cos α · cos φ, sin φ, sin α · cos φ) // - // Places a directory and recursively all its descendants in the fern-frond shape. - // depth=0 means this directory is a first-level branch off a spine — it gets - // placed outward from the spine axis with y-offset for sibling alternation. - // depth>0 means this directory is a deeper descendant — it continues along - // the same outwardDir as its parent without further y-jitter. + // The old code instead fanned in x/z — the HORIZONTAL plane — and Sigma renders only (x, y). + // That fan was therefore projected away in its entirety: two siblings differing only in azimuth + // landed on the same rendered pixel, and azimuths symmetric about the axis collapsed onto each + // other exactly. It was worse than that, though: siblings never got distinct azimuths in the + // first place. Every child of a directory was handed the same `myDir`, so they were coincident + // in 3D too, and coincidence is the one thing the simulation cannot undo (for two nodes at + // identical coordinates the repulsion direction is the zero vector, so the force is exactly + // zero regardless of its magnitude — a perfect stack is a stable fixed point). + // + // Fanning in the spine's own vertical plane varies both x and y, so the fan survives the 2D + // projection, while z keeps carrying the spine's azimuth for the eventual 3D camera. function placeBranchRecursive( dirId: string, parentPos: { x: number; y: number; z: number }, - outwardDir: { dx: number; dy: number; dz: number }, + centerAngle: number, // φ — the direction THIS directory extends from its parent + angularExtent: number, // the wedge THIS directory's own children may fan into depth: number, - alternationSign: number, // Pass C8.4: renamed from siblingIndex; values +1 or -1 - spineAngleRad: number, // angle of the spine this branch belongs to - yAlongSpine: number, // y position along spine for helix twist calculation + alternationSign: number, // Pass C8.4: +1 or -1; still biases first-level branches off the run + axisAngleRad: number, // α — twist-adjusted azimuth of the spine this branch hangs off + yAlongSpine: number, // y position along spine, for helix twist ): void { - // Compute this directory's position. - let myDir: { dx: number; dy: number; dz: number }; - let myX: number, myY: number, myZ: number; + // Directory helix twist rotates the plane itself, once, where the frond leaves the spine. + // Applying it at depth 0 and then handing the twisted axis down means the whole subtree + // stays in ONE plane — a frond that twists is still a flat frond, just aimed elsewhere. + const directoryTwist = resolveHelixTwist(params.helixTwist, "directory"); + const twistRad = + depth === 0 && directoryTwist !== 0 + ? (directoryTwist * (yAlongSpine / 100) * Math.PI) / 180 + : 0; + const axis = axisAngleRad + twistRad; + + const cosPhi = Math.cos(centerAngle); + const sinPhi = Math.sin(centerAngle); + + const myX = parentPos.x + Math.cos(axis) * cosPhi * params.directoryOffset; + const myZ = parentPos.z + Math.sin(axis) * cosPhi * params.directoryOffset; + let myY = parentPos.y + sinPhi * params.directoryOffset; if (depth === 0) { - // First-level branch: outward from center with y-offset for sibling alternation - // Outward direction is constant (away from central axis) - myDir = { - dx: Math.cos(spineAngleRad), - dy: 0, - dz: Math.sin(spineAngleRad), - }; - - // Pass C8.4: use the passed alternation sign directly for y-offset - const ySpacing = params.directoryOffset * 0.25; // small vertical jitter - myY = parentPos.y + alternationSign * ySpacing; - - // Apply helix twist to outward direction - const directoryTwist = resolveHelixTwist(params.helixTwist, "directory"); - const twistRad = directoryTwist === 0 ? 0 : (directoryTwist * (yAlongSpine / 100) * Math.PI) / 180; - const twistedAngle = spineAngleRad + twistRad; - - myDir = { - dx: Math.cos(twistedAngle), - dy: 0, - dz: Math.sin(twistedAngle), - }; - - myX = parentPos.x + myDir.dx * params.directoryOffset; - myZ = parentPos.z + myDir.dz * params.directoryOffset; - } else { - // Deeper level: continue along parent's outwardDir without y-jitter - myDir = outwardDir; - myX = parentPos.x + myDir.dx * params.directoryOffset; - myY = parentPos.y; // match parent's y - myZ = parentPos.z + myDir.dz * params.directoryOffset; + // Preserved from Pass C8.4: consecutive spine nodes push their first-level branches to + // opposite sides, so adjacent runs' fronds interleave rather than stack. This is a nudge + // between DIFFERENT parents — a density problem the simulation can actually solve — not + // the sibling coincidence the wedge above fixes. + myY += alternationSign * params.directoryOffset * 0.25; } graph.setNodeAttribute(dirId, "x", myX); @@ -163,17 +183,18 @@ export function seedParallelSpines(ctx: GWSeedFunctionContext): void { } }); - // Place each child directory recursively along myDir. - // Pass C8.4: children inherit parent's alternation sign - childDirs.forEach((cid) => { + // L-001: children split MY wedge between them, weighted by leaf count, so each owns a + // disjoint angular range within the frond's plane. Siblings cannot coincide by construction. + fanOut(childDirs, centerAngle, angularExtent).forEach((child) => { placeBranchRecursive( - cid, + child.id, { x: myX, y: myY, z: myZ }, - myDir, // children continue along my direction - depth + 1, // depth advances - alternationSign, // Pass C8.4: inherited — child uses parent's sign - spineAngleRad, // unchanged - yAlongSpine, // unchanged (helix twist only at depth=0) + child.centerAngle, // the child's own direction, its share of my wedge + child.angularExtent, // the sub-wedge its own children will split + depth + 1, + alternationSign, // Pass C8.4: inherited — child uses parent's sign + axis, // twisted axis, so the subtree stays in one plane + yAlongSpine, ); }); @@ -186,20 +207,23 @@ export function seedParallelSpines(ctx: GWSeedFunctionContext): void { }); // Parent's visual size for orbit scaling - const parentVisualSize = (graph.getNodeAttributes(dirId) as any).size ?? 10; + const parentVisualSize = (graph.getNodeAttributes(dirId) as any).baseSize ?? 10; sortedFiles.forEach((fid, fi) => { - const { radius, angleRad } = computeFileOrbit(fi, sortedFiles.length, parentVisualSize); - + const { radius, angleRad: orbitAngle } = computeFileOrbit(fi, sortedFiles.length, parentVisualSize); + // Apply helix twist if present (preserves existing twist behavior) const fileTwist = resolveHelixTwist(params.helixTwist, "file"); const dDir = Math.sqrt(myX * myX + myZ * myZ); const fileTwistRad = fileTwist === 0 ? 0 : (fileTwist * (dDir / 100) * Math.PI) / 180; - const finalAngle = angleRad + fileTwistRad; + const theta = orbitAngle + fileTwistRad; - const fx = myX + radius * Math.cos(finalAngle); - const fy = myY; - const fz = myZ + radius * Math.sin(finalAngle); + // Orbit in the same vertical plane as the frond, for the same reason the frond fans there: + // the old x/z orbit was a horizontal ring seen exactly edge-on, so it projected to a line + // segment and every pair of files at ±θ rendered on top of each other. + const fx = myX + radius * Math.cos(axis) * Math.cos(theta); + const fy = myY + radius * Math.sin(theta); + const fz = myZ + radius * Math.sin(axis) * Math.cos(theta); graph.setNodeAttribute(fid, "x", fx); graph.setNodeAttribute(fid, "y", fy); @@ -283,11 +307,16 @@ export function seedParallelSpines(ctx: GWSeedFunctionContext): void { const axisAlternationSign = (nodeIndex % 2 === 0) ? +1 : -1; - dirChildren.forEach((childId) => { + // L-001: the fronds hanging off THIS spine node split a wedge centred on straight-out + // (φ=0). Bounded well clear of ±90°, where a branch would run along the spine itself. + const fanArcRad = (params.directoryFanArc * Math.PI) / 180; + + fanOut(dirChildren, 0, fanArcRad).forEach((child) => { placeBranchRecursive( - childId, + child.id, spinePos, - { dx: 0, dy: 0, dz: 0 }, + child.centerAngle, + child.angularExtent, 0, axisAlternationSign, spineAngleAtThisHeight, @@ -302,14 +331,17 @@ export function seedParallelSpines(ctx: GWSeedFunctionContext): void { return sa - sb; }); - const parentVisualSize = (graph.getNodeAttributes(spineNodeId) as any).size ?? 10; + const parentVisualSize = (graph.getNodeAttributes(spineNodeId) as any).baseSize ?? 10; sortedFiles.forEach((fileId, fileIdx) => { - const { radius, angleRad } = computeFileOrbit(fileIdx, sortedFiles.length, parentVisualSize); - const finalAngle = angleRad + spineAngleAtThisHeight; - const fileX = spinePos.x + radius * Math.cos(finalAngle); - const fileY = spinePos.y; - const fileZ = spinePos.z + radius * Math.sin(finalAngle); + const { radius, angleRad: theta } = computeFileOrbit(fileIdx, sortedFiles.length, parentVisualSize); + // Same vertical-plane orbit as the frond files above — an x/z ring is edge-on to the + // camera and collapses to a line. `theta` is an elevation now, not an azimuth, so the + // spine's azimuth enters through cos/sin(spineAngleAtThisHeight), not by being added + // to the orbit angle. + const fileX = spinePos.x + radius * Math.cos(spineAngleAtThisHeight) * Math.cos(theta); + const fileY = spinePos.y + radius * Math.sin(theta); + const fileZ = spinePos.z + radius * Math.sin(spineAngleAtThisHeight) * Math.cos(theta); graph.setNodeAttribute(fileId, "x", fileX); graph.setNodeAttribute(fileId, "y", fileY); diff --git a/src/physics/gwells/seeders/radialBackbone.ts b/src/physics/gwells/seeders/radialBackbone.ts index 6d2f438b..f3a8bea3 100644 --- a/src/physics/gwells/seeders/radialBackbone.ts +++ b/src/physics/gwells/seeders/radialBackbone.ts @@ -17,7 +17,8 @@ */ import type { GWSeedFunctionContext, GWHelixTwistRecord } from "../types"; -import { resolveHelixTwist, buildContainsMap, flattenSpinesFromRoot, assignSpinesToAxes, computeFileOrbit, seedGenericFallbackLayout, placeUnseededNodesWithFallback, shouldUseHubRing, computeHubRingRadius, computeHubRingPosition } from "../seederHelpers"; +import { layoutAdaptiveRadial } from "../layout/adaptiveRadial"; +import { resolveHelixTwist, buildContainsMap, flattenSpinesFromRoot, assignSpinesToAxes, computeFileOrbit, seedGenericFallbackLayout, placeUnseededNodesWithFallback, shouldUseHubRing, makeLeafCounter, subdivideWedge, NODE_RADIUS_MIN } from "../seederHelpers"; interface RadialBackboneParams { spineCount: number; @@ -30,6 +31,13 @@ interface RadialBackboneParams { fileOrbitRadius: number; endpointFanArc: number; // degrees endpointFanCount: number; + /** + * L-001: total angular width, in degrees, of the wedge a directory's children may fan into. + * This is the "angular budget" — the thing whose absence caused every sibling directory to be + * seeded at one identical point. Siblings split this wedge between them, weighted by leaf + * count, so they occupy disjoint angular ranges and cannot overlap by construction. + */ + directoryFanArc: number; // degrees } const DEFAULTS: RadialBackboneParams = { @@ -43,8 +51,10 @@ const DEFAULTS: RadialBackboneParams = { fileOrbitRadius: 90, endpointFanArc: 100, endpointFanCount: 6, + directoryFanArc: 150, }; + function resolveParams(raw: Record): RadialBackboneParams { return { spineCount: typeof raw.spineCount === "number" ? raw.spineCount : DEFAULTS.spineCount, @@ -63,6 +73,8 @@ function resolveParams(raw: Record): RadialBackboneParams { fileOrbitRadius: typeof raw.fileOrbitRadius === "number" ? raw.fileOrbitRadius : DEFAULTS.fileOrbitRadius, endpointFanArc: typeof raw.endpointFanArc === "number" ? raw.endpointFanArc : DEFAULTS.endpointFanArc, endpointFanCount: typeof raw.endpointFanCount === "number" ? raw.endpointFanCount : DEFAULTS.endpointFanCount, + directoryFanArc: + typeof raw.directoryFanArc === "number" ? raw.directoryFanArc : DEFAULTS.directoryFanArc, }; } @@ -108,40 +120,32 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { // depth=0 means this directory is a first-level branch off a spine — it gets // placed perpendicular to the spine axis. depth>0 means this directory is a // deeper descendant — it continues along the same outwardDir as its parent. + // L-001: shared with parallelSpines via seederHelpers — one implementation, not two. + const countLeaves = makeLeafCounter(parentToChildren, (id) => { + const t = graph.getNodeAttributes(id).nodeType || graph.getNodeAttributes(id).raw?.type; + return t === "directory"; + }); + const fanOut = (ids: string[], centerAngle: number, extent: number) => + subdivideWedge(ids, centerAngle, extent, countLeaves); + function placeBranchRecursive( dirId: string, parentPos: { x: number; y: number; z: number }, - outwardDir: { dx: number; dy: number; dz: number }, + centerAngle: number, // L-001: the direction THIS directory extends from its parent + angularExtent: number, // L-001: the wedge this directory's own children may fan into depth: number, - alternationSign: number, // Pass C8.4: renamed from siblingIndex; values +1 or -1 spineAxisAngle: number, // angle of the spine this branch belongs to dHub: number, // distance from hub for helix twist calculation ): void { - // Compute this directory's position. - let myDir: { dx: number; dy: number; dz: number }; - if (depth === 0) { - // First-level branch: alternate perpendicular up/down (or left/right for vertical spines). - // Perpendicular to spine axis is spineAxisAngle + 90°. - // Apply helix twist for depth=0 only. - const perpAngleBase = spineAxisAngle + Math.PI / 2; - const directoryTwist = resolveHelixTwist(params.helixTwist, "directory"); - const twistRad = directoryTwist * (dHub / 100) * Math.PI / 180; - const perpAngle = perpAngleBase + twistRad; - - // Pass C8.4: use the passed alternation sign directly instead of computing from index - myDir = { - dx: Math.cos(perpAngle) * alternationSign, - dy: Math.sin(perpAngle) * alternationSign, - dz: 0, - }; - } else { - // Deeper level: continue along parent's outwardDir - myDir = outwardDir; - } - - const myX = parentPos.x + myDir.dx * params.directoryOffset; - const myY = parentPos.y + myDir.dy * params.directoryOffset; - const myZ = parentPos.z + myDir.dz * params.directoryOffset; + // L-001: position is now a function of this node's OWN angle within its parent's wedge. + // + // It used to be a pure function of (parentPos, alternationSign, spineAxisAngle) with no + // per-sibling term at all — so every sibling directory computed byte-identical coordinates + // and 35 of them ended up in 4 coincident piles. Deeper levels were worse: a child inherited + // its parent's direction verbatim, so a subtree was a straight ray, not a fan. + const myX = parentPos.x + Math.cos(centerAngle) * params.directoryOffset; + const myY = parentPos.y + Math.sin(centerAngle) * params.directoryOffset; + const myZ = parentPos.z; graph.setNodeAttribute(dirId, "x", myX); graph.setNodeAttribute(dirId, "y", myY); @@ -163,17 +167,19 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { } }); - // Place each child directory recursively along myDir. - // Pass C8.4: children inherit parent's alternation sign - childDirs.forEach((cid) => { + // L-001: children split MY wedge between them, weighted by leaf count. Each gets its own + // direction, so siblings fan out instead of collapsing onto one another. The wedge narrows + // with depth (each child's share is a fraction of mine), which is what makes a subtree read + // as a frond rather than a ray. + fanOut(childDirs, centerAngle, angularExtent).forEach((child) => { placeBranchRecursive( - cid, + child.id, { x: myX, y: myY, z: myZ }, - myDir, // children continue along my direction - depth + 1, // depth advances - alternationSign, // Pass C8.4: inherited — child uses parent's sign - spineAxisAngle, // unchanged - dHub, // unchanged (helix twist only at depth=0) + child.centerAngle, // this child's own direction within my wedge + child.angularExtent, // the sub-wedge it may fan its own children into + depth + 1, + spineAxisAngle, + dHub, ); }); @@ -186,7 +192,7 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { }); // Parent's visual size for orbit scaling - const parentVisualSize = (graph.getNodeAttributes(dirId) as any).size ?? 10; + const parentVisualSize = (graph.getNodeAttributes(dirId) as any).baseSize ?? 10; sortedFiles.forEach((fid, fi) => { const { radius, angleRad } = computeFileOrbit(fi, sortedFiles.length, parentVisualSize); @@ -208,16 +214,17 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { } const useHubRing = shouldUseHubRing(sortedRoots.length, params.spineCount); - const hubRingRadius = computeHubRingRadius( - sortedRoots.length, - params.spineSpacing / 2, - ); - const rootIndexById = new Map(sortedRoots.map((id, index) => [id, index])); function placeSpineRun( spineNodes: string[], angleRad: number, rootOffset: { x: number; y: number; z: number }, + /** + * L-001b: this root's slice of the GLOBAL angular budget — its own sector of the full circle, + * and the direction it points away from the hub. Hub-ring mode only; undefined keeps the + * legacy perpendicular frond used by the few-root spine layouts. + */ + sector?: { centre: number; extent: number }, ): void { const spineDirX = Math.cos(angleRad); const spineDirY = Math.sin(angleRad); @@ -267,15 +274,55 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { } }); + // L-001: the root of the angular budget. + // + // A spine node's directory children fan into a wedge, and they subdivide it by leaf count so + // they occupy disjoint arcs. Previously every child was handed one identical direction with + // no wedge and no per-sibling term, which is what put 20 of src.control-plane's children on + // a single point. + // + // L-001b — WHERE that wedge comes from, which is the half that was still wrong: + // + // The wedge used to be a flat `directoryFanArc` (150 degrees), centred perpendicular to the + // spine, handed to EVERY root regardless of how many roots there were. The self-graph has 41 + // roots on the hub ring, so that allocated 41 x 150 = 6150 degrees of wedge out of a circle + // that only 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. + // + // The angular budget was only ever enforced WITHIN a parent. Across roots it was not + // enforced at all, so sibling overlap was impossible while subtree overlap was routine. + // + // Now each root owns a disjoint sector of the full circle (leaf-count weighted, tiled + // exactly — see subdivideWedge's minArc), and its descendants recursively subdivide only + // that sector. Subtrees therefore cannot cross, for the same reason siblings cannot: they + // own disjoint angular ranges by construction. The fan also points radially OUTWARD from the + // hub rather than sideways across the ring, which is what makes the hierarchy read as + // concentric rings of directories instead of a tangle. const axisAlternationSign = (nodeIndex % 2 === 0) ? +1 : -1; + const directoryTwist = resolveHelixTwist(params.helixTwist, "directory"); + const twistRad = (directoryTwist * (dHub / 100) * Math.PI) / 180; + + let fanCentre: number; + let fanArcRad: number; + if (sector) { + fanCentre = sector.centre + twistRad; + // Never wider than the sector we own, and never wider than the configured fan. + fanArcRad = Math.min(sector.extent, (params.directoryFanArc * Math.PI) / 180); + } else { + const perpAngle = angleRad + Math.PI / 2 + twistRad; + // A negative sign means "fan out the other side of the spine" — i.e. rotate 180°. + fanCentre = perpAngle + (axisAlternationSign < 0 ? Math.PI : 0); + fanArcRad = (params.directoryFanArc * Math.PI) / 180; + } - dirChildren.forEach((childId) => { + fanOut(dirChildren, fanCentre, fanArcRad).forEach((child) => { placeBranchRecursive( - childId, + child.id, spinePos, - { dx: 0, dy: 0, dz: 0 }, + child.centerAngle, + child.angularExtent, 0, - axisAlternationSign, angleRad, dHub, ); @@ -288,11 +335,21 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { return sa - sb; }); - const parentVisualSize = (graph.getNodeAttributes(spineNodeId) as any).size ?? 10; + const parentVisualSize = (graph.getNodeAttributes(spineNodeId) as any).baseSize ?? 10; sortedFiles.forEach((fileId, fileIdx) => { - const { radius, angleRad } = computeFileOrbit(fileIdx, sortedFiles.length, parentVisualSize); - const finalAngle = angleRad + angleRad; + // L-005: `angleRad` here is the orbit angle destructured from computeFileOrbit, which + // SHADOWS placeSpineRun's `angleRad` (the spine's axis angle). The intent was to rotate + // the orbit into the spine's frame — `orbitAngle + spineAxisAngle` — but shadowing made + // it `orbitAngle + orbitAngle`, silently doubling the phyllotaxis angle. Renamed the + // local so the two can no longer be confused. parallelSpines.ts:309 already did this + // correctly. + const { radius, angleRad: orbitAngle } = computeFileOrbit( + fileIdx, + sortedFiles.length, + parentVisualSize, + ); + const finalAngle = orbitAngle + angleRad; const fileX = spinePos.x + radius * Math.cos(finalAngle); const fileY = spinePos.y + radius * Math.sin(finalAngle); @@ -305,21 +362,69 @@ export function seedRadialBackbone(ctx: GWSeedFunctionContext): void { }); } - // Process each spine axis. Small/default graphs keep the legacy origin-based - // placement; larger top-level sets offset each root run onto a deterministic ring. + // L-020: THE ADAPTIVE PIPELINE. + // + // Hub-ring mode (many roots — the real-world case) is now laid out by the pipeline in + // docs/canonical/LAYOUT_PIPELINE.md: DERIVE -> MEASURE -> ALLOCATE -> PLACE. It contains no + // distance constants at all — every spacing is derived from the content, so the layout grows + // when the content grows and nothing has to be re-tuned. + // + // This replaces the leaf-count angular budget (L-001b), which was a real improvement but only + // half the story: it gave every DIRECTORY a disjoint sector and left FILES orbiting a full + // circle at radii that always exceeded half the distance to a sibling (>=122 against a 220 + // `directoryOffset`). Subtrees could not overlap; their files always did. That was invisible + // until the content changed and a different pair became the closest — which is exactly the + // failure mode a preservative layout produces, and exactly what the pipeline removes. + // + // `directoryOffset`, `spineSpacing` and the orbit constants are no longer consulted on this + // path. They remain for the legacy few-root spine layout below. + if (useHubRing) { + const isDir = (id: string) => { + const a = graph.getNodeAttributes(id); + return (a.nodeType || a.raw?.type) === "directory"; + }; + const sortedChildren = (id: string) => + Array.from(parentToChildren.get(id) ?? []).sort(); + + const placed = layoutAdaptiveRadial({ + roots: sortedRoots, + childDirs: (id) => sortedChildren(id).filter(isDir), + childFiles: (id) => sortedChildren(id).filter((c) => !isDir(c)), + // baseSize, never size — size is presentation (LAYOUT_PIPELINE.md rule 4). + radius: (id) => (graph.getNodeAttributes(id) as any).baseSize ?? NODE_RADIUS_MIN, + }); + + placed.forEach((p, id) => { + graph.setNodeAttribute(id, "x", p.x); + graph.setNodeAttribute(id, "y", p.y); + graph.setNodeAttribute(id, "z", p.z); + allSeedPositions.set(id, p); + }); + + // Spine nodes are the roots here; the render-time reducer pins them from this map (GD-026). + sortedRoots.forEach((id) => { + const p = placed.get(id); + if (p) seededPositions.set(id, { x: p.x, y: p.y }); + const kids = parentToChildren.get(id); + if (!kids || kids.size === 0) graph.setNodeAttribute(id, "isEndpoint", true); + }); + + placeUnseededNodesWithFallback(graph, allSeedPositions, seededPositions); + + const positionsForReducer = new Map(); + seededPositions.forEach((pos, id) => positionsForReducer.set(id, { x: pos.x, y: pos.y })); + graph.setAttribute("__seededSpinePositions", positionsForReducer); + graph.setAttribute("__gwellsSeedPositions", allSeedPositions); + return; + } + + // Legacy few-root spine layout. Still preservative — see L-021. for (let spineIndex = 0; spineIndex < params.spineCount; spineIndex++) { const angleDeg = params.spineAngles[spineIndex]; const angleRad = angleDeg * Math.PI / 180; const rootsForThisAxis = axes[spineIndex]; - if (useHubRing) { - for (const rootId of rootsForThisAxis) { - const allSpineNodes = flattenSpinesFromRoot(rootId, parentToChildren, graph); - const rootIndex = rootIndexById.get(rootId) ?? 0; - const rootOffset = computeHubRingPosition(rootIndex, sortedRoots.length, hubRingRadius); - placeSpineRun(allSpineNodes, angleRad, rootOffset); - } - } else { + { const allSpineNodes: string[] = []; for (const rootId of rootsForThisAxis) { allSpineNodes.push(...flattenSpinesFromRoot(rootId, parentToChildren, graph)); diff --git a/src/physics/gwells/types.ts b/src/physics/gwells/types.ts index 35aad295..ab2f0c75 100644 --- a/src/physics/gwells/types.ts +++ b/src/physics/gwells/types.ts @@ -65,10 +65,15 @@ export interface GWHelixTwistRecord { * Default physics parameters for a well type. */ export interface GWWellTypeDefaults { - /** Strength of attraction toward this well's anchor (0–10). */ - attractionStrength: number; - /** Strength of repulsion from sibling wells of the same type (0–500). */ - siblingRepulsion: number; + // L-004: `attractionStrength` and `siblingRepulsion` used to live here. They were resolved + // into the runtime config and carefully tuned — siblingRepulsion at 250/120/100/80 across the + // well types, overridden again per-dialect — and then read by nothing. The force loop never + // referenced either name, and no interaction even uses `kind: "attraction"`. + // + // Deleting them is a zero-behaviour change, which is exactly the point: they were a trap. + // Anyone tuning the layout would reach for the parameter that is *named* after the problem + // and watch it do nothing. Repulsion actually comes from `interaction.strength`; attraction + // comes from the springs. /** Spring stiffness for attraction force (0–1). */ springStiffness: number; /** Per-frame damping factor (0–1, where 1 = no damping). */ diff --git a/src/physics/gwells/wellTypes.ts b/src/physics/gwells/wellTypes.ts index 81d163a5..1f713342 100644 --- a/src/physics/gwells/wellTypes.ts +++ b/src/physics/gwells/wellTypes.ts @@ -23,8 +23,6 @@ export const GW_WELL_TYPE_REGISTRY: readonly GWWellTypeEntry[] = [ status: "active", pinned: true, defaults: { - attractionStrength: 0, - siblingRepulsion: 0, springStiffness: 0, damping: 1, idealDistance: 0, @@ -42,8 +40,6 @@ export const GW_WELL_TYPE_REGISTRY: readonly GWWellTypeEntry[] = [ status: "active", pinned: false, defaults: { - attractionStrength: 0.15, - siblingRepulsion: 250, springStiffness: 0.03, damping: 0.85, idealDistance: 460, @@ -62,8 +58,6 @@ export const GW_WELL_TYPE_REGISTRY: readonly GWWellTypeEntry[] = [ status: "active", pinned: false, defaults: { - attractionStrength: 0.15, - siblingRepulsion: 100, springStiffness: 0.02, damping: 0.9, idealDistance: 360, @@ -82,8 +76,6 @@ export const GW_WELL_TYPE_REGISTRY: readonly GWWellTypeEntry[] = [ status: "active", pinned: false, defaults: { - attractionStrength: 0.4, - siblingRepulsion: 80, springStiffness: 0.05, damping: 0.9, idealDistance: 100, diff --git a/src/source-adapter/AdapterConfigForm.tsx b/src/source-adapter/AdapterConfigForm.tsx index b6eedcc0..a9522e84 100644 --- a/src/source-adapter/AdapterConfigForm.tsx +++ b/src/source-adapter/AdapterConfigForm.tsx @@ -6,13 +6,29 @@ import type { AdapterConfig } from "./baseSourceAdapter"; interface AdapterConfigFormHostProps { adapterId: string; + /** + * Controlled ("draft") mode: the caller owns the config and receives every edit. + * Nothing is written to persisted settings — that is the caller's job, on commit. + * GraphSourcePicker uses this so Cancel can genuinely discard. + * + * Omit both props for store-backed mode, where edits persist immediately. + * SourceAdapterPanel (dev registry browser) relies on that. + */ + config?: AdapterConfig; + onChange?: (next: AdapterConfig) => void; } -export function AdapterConfigForm({ adapterId }: AdapterConfigFormHostProps): React.JSX.Element { +export function AdapterConfigForm({ + adapterId, + config: controlledConfig, + onChange: controlledOnChange, +}: AdapterConfigFormHostProps): React.JSX.Element { const storedConfig = useSettingsStore( (s) => s.settings.sources.configurations[adapterId], ); - const config = storedConfig ?? ({ adapterId } as AdapterConfig); + const isControlled = controlledOnChange !== undefined; + const source = isControlled ? controlledConfig : storedConfig; + const config = source ?? ({ adapterId } as AdapterConfig); const FormComponent = getAdapterConfigForm(adapterId); if (!FormComponent) { @@ -28,6 +44,10 @@ export function AdapterConfigForm({ adapterId }: AdapterConfigFormHostProps): Re const handleChange = (next: Partial) => { const merged = { ...config, ...next, adapterId } as AdapterConfig; + if (controlledOnChange) { + controlledOnChange(merged); + return; + } useSettingsStore.getState().setSetting("sources.configurations", { ...useSettingsStore.getState().settings.sources.configurations, [adapterId]: merged, diff --git a/src/source-adapter/SourceAdapterPanel.tsx b/src/source-adapter/SourceAdapterPanel.tsx index acc39fa8..c5ba67c6 100644 --- a/src/source-adapter/SourceAdapterPanel.tsx +++ b/src/source-adapter/SourceAdapterPanel.tsx @@ -161,7 +161,7 @@ function EntryCard({ const isRegistered = entry.status === "registered"; function handleSetActive() { - useSettingsStore.getState().setSetting("sources.active", entry.adapterId); + useSettingsStore.getState().commitSource(entry.adapterId); } return ( diff --git a/src/source-adapter/adapters/cytoscapeJsonAdapter.ts b/src/source-adapter/adapters/cytoscapeJsonAdapter.ts index 092da203..b81a8eb1 100644 --- a/src/source-adapter/adapters/cytoscapeJsonAdapter.ts +++ b/src/source-adapter/adapters/cytoscapeJsonAdapter.ts @@ -124,8 +124,15 @@ class CytoscapeJsonAdapter extends SingleFileAdapter { // D9: strict elements key required if (p.elements === undefined) { + const hasTopLevelNodes = Array.isArray(p.nodes) || Array.isArray(p.edges); return makeErrorSummary( - "Missing required `elements` key — not a Cytoscape.js JSON file", + hasTopLevelNodes + ? 'Your file uses { "nodes": [...], "edges": [...] } without an "elements" wrapper. ' + + 'Use "Different adapter" to find a compatible format, or wrap your data: ' + + '{ "elements": { "nodes": [...], "edges": [...] } }' + : 'Missing required "elements" key — not a Cytoscape.js JSON file. ' + + 'Expected: { "elements": { "nodes": [...], "edges": [...] } }. ' + + 'Use "Different adapter" if your file uses a different graph format.', cfg.filePath, ); } diff --git a/src/source-adapter/adapters/packageDependencyAdapter.ts b/src/source-adapter/adapters/packageDependencyAdapter.ts index 5d7a21a9..8a236b7a 100644 --- a/src/source-adapter/adapters/packageDependencyAdapter.ts +++ b/src/source-adapter/adapters/packageDependencyAdapter.ts @@ -52,14 +52,21 @@ class PackageDependencyAdapter extends SingleFileAdapter { return makeErrorSummary("Project path not configured"); } - if (cfg.manifestType !== "package.json") { + const manifestType = cfg.manifestType ?? "package.json"; + if (manifestType !== "package.json") { return makeErrorSummary( - `${cfg.manifestType} not yet supported; only package.json is implemented in v1.0`, + `${manifestType} not yet supported; only package.json is implemented in v1.0`, cfg.projectPath, ); } - const filePath = `${cfg.projectPath.replace(/\/$/, "")}/${cfg.manifestType}`; + // Guard: if the user pasted the manifest path itself instead of the project dir, strip the filename. + const MANIFEST_NAMES = ["package.json", "Cargo.toml", "pyproject.toml", "go.mod"]; + const projectPathNorm = MANIFEST_NAMES.some((m) => cfg.projectPath.endsWith(`/${m}`) || cfg.projectPath.endsWith(`\\${m}`)) + ? cfg.projectPath.replace(/[/\\][^/\\]+$/, "") + : cfg.projectPath; + + const filePath = `${projectPathNorm.replace(/\/$/, "")}/${manifestType}`; const warnings: string[] = []; let raw: string; diff --git a/src/source-adapter/sourceAdapterRegistry.ts b/src/source-adapter/sourceAdapterRegistry.ts index 46f35a31..208ac88a 100644 --- a/src/source-adapter/sourceAdapterRegistry.ts +++ b/src/source-adapter/sourceAdapterRegistry.ts @@ -13,6 +13,7 @@ import type { LoaderFn, SelfGraphConfig } from "./baseSourceAdapter"; import type { GraphSourceSummary } from "../graph/schema/graph.types"; import { loadSelfGraph } from "../graph/ingest/loadSelfGraph"; +import { invokeListFiles } from "../lib/tauri-invoke"; import { loadMarkdownVault } from "./adapters/markdownVaultAdapter"; import { loadCytoscapeJson } from "./adapters/cytoscapeJsonAdapter"; import { loadPackageDependency } from "./adapters/packageDependencyAdapter"; @@ -39,6 +40,8 @@ export type SourceAdapterType = export type InputPatternType = "url" | "path" | "manifest" | "schema"; +export type AdapterCategory = "file-based" | "directory-based" | "database" | "stream"; + export type ConfidenceType = "observed" | "inferred" | "ai-inferred"; export type AdapterStatus = "candidate" | "registered" | "validated" | "accepted" | "active"; @@ -67,10 +70,22 @@ export interface QAReportFormat { requiredFields: string[]; } +export interface ScanCandidate { + adapterId: string; + score: number; + scoreLabel: "strong match" | "weak match" | "possible"; + suggestedConfig: Record; + reason: string; +} + +export type ScanFn = (target: string) => Promise; + export interface SourceAdapterEntry { adapterId: string; adapterType: SourceAdapterType; adapterVersion: string; + category: AdapterCategory; + formatHint?: string; inputPattern: InputPattern; translationSet: TranslationSet; limits: SafetyLimits; @@ -87,15 +102,17 @@ export interface SourceAdapterEntry { const entries: SourceAdapterEntry[] = []; const loaderMap = new Map(); +const scanMap = new Map(); const listeners: Array<() => void> = []; // --------------------------------------------------------------------------- // Registration API // --------------------------------------------------------------------------- -export function registerSourceAdapter(entry: SourceAdapterEntry, loader: LoaderFn): void { +export function registerSourceAdapter(entry: SourceAdapterEntry, loader: LoaderFn, scan?: ScanFn): void { entries.push(entry); loaderMap.set(entry.adapterId, loader); + if (scan) scanMap.set(entry.adapterId, scan); listeners.forEach((l) => l()); } @@ -135,6 +152,37 @@ export function getSourceAdapterEntriesByContractVersion(contractVersion: string return entries.filter((entry) => entry.contractVersion === contractVersion); } +export function getSourceAdapterEntriesByCategory(category: AdapterCategory): SourceAdapterEntry[] { + return entries.filter((entry) => entry.category === category); +} + +// --------------------------------------------------------------------------- +// Scan API (SA-015) +// --------------------------------------------------------------------------- + +// Browser-safe path helpers (no Node.js path module in the webview). +function pathBasename(p: string): string { + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return i >= 0 ? p.slice(i + 1) : p; +} + +function pathDirname(p: string): string { + const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); + return i > 0 ? p.slice(0, i) : (i === 0 ? "/" : "."); +} + +/** + * Runs all registered scan() functions concurrently against `target`. + * Returns all non-null candidates sorted by score descending. + */ +export async function scanTarget(target: string): Promise { + const fns = Array.from(scanMap.entries()); + const settled = await Promise.allSettled(fns.map(([, fn]) => fn(target))); + return settled + .flatMap((r) => (r.status === "fulfilled" && r.value ? [r.value] : [])) + .sort((a, b) => b.score - a.score); +} + // --------------------------------------------------------------------------- // Loaders // --------------------------------------------------------------------------- @@ -176,6 +224,8 @@ registerSourceAdapter( adapterId: "self-graph-yaml-frontmatter", adapterType: "self-graph", adapterVersion: "0.1.0", + category: "directory-based", + formatHint: "Reads YAML frontmatter from **/*.md files in the LumaWeave docs tree. No configuration required.", inputPattern: { type: "path", pattern: "**/*.md", @@ -214,6 +264,7 @@ registerSourceAdapter( adapterId: "git-codebase", adapterType: "git-codebase", adapterVersion: "0.1.0", + category: "directory-based", inputPattern: { type: "path", pattern: ".git", @@ -239,6 +290,7 @@ registerSourceAdapter( adapterId: "website-url", adapterType: "website-url", adapterVersion: "0.1.0", + category: "stream", inputPattern: { type: "url", pattern: "^https?://", @@ -264,6 +316,8 @@ registerSourceAdapter( adapterId: "markdown-vault", adapterType: "markdown-vault", adapterVersion: "0.1.0", + category: "directory-based", + formatHint: "Reads **/*.md files in a directory, following wiki-links as edges. Set the vault root path.", inputPattern: { type: "path", pattern: "**/*.md", @@ -282,6 +336,22 @@ registerSourceAdapter( coupling: "external", }, loadMarkdownVault, + async (target) => { + try { + const files = await invokeListFiles(target, ["md"], [], 2); + if (files.length === 0) return null; + const count = files.length; + return { + adapterId: "markdown-vault", + score: 0.6, + scoreLabel: "weak match", + suggestedConfig: { adapterId: "markdown-vault", vaultRoot: target }, + reason: `Found ${count > 20 ? "20+" : count} .md file${count !== 1 ? "s" : ""} in directory`, + }; + } catch { + return null; + } + }, ); registerSourceAdapter( @@ -289,6 +359,8 @@ registerSourceAdapter( adapterId: "cytoscape-json", adapterType: "cytoscape-json", adapterVersion: "0.1.0", + category: "file-based", + formatHint: '{ "elements": { "nodes": [{"data":{"id":"a"}}], "edges": [{"data":{"id":"e1","source":"a","target":"b"}}] } }', inputPattern: { type: "path", pattern: "**/*.json", @@ -307,6 +379,16 @@ registerSourceAdapter( coupling: "external", }, loadCytoscapeJson, + async (target) => { + if (!target.endsWith(".json")) return null; + return { + adapterId: "cytoscape-json", + score: 0.45, + scoreLabel: "possible", + suggestedConfig: { adapterId: "cytoscape-json", filePath: target }, + reason: "File has .json extension — may be Cytoscape.js format", + }; + }, ); registerSourceAdapter( @@ -314,6 +396,7 @@ registerSourceAdapter( adapterId: "openapi-spec", adapterType: "openapi-spec", adapterVersion: "0.1.0", + category: "file-based", inputPattern: { type: "schema", pattern: "**/*.{json,yaml,yml}", @@ -339,6 +422,7 @@ registerSourceAdapter( adapterId: "database-schema", adapterType: "database-schema", adapterVersion: "0.1.0", + category: "file-based", inputPattern: { type: "schema", pattern: "**/*.{sql,prisma}", @@ -364,6 +448,8 @@ registerSourceAdapter( adapterId: "package-dependency", adapterType: "package-dependency", adapterVersion: "0.1.0", + category: "file-based", + formatHint: "Reads package.json, Cargo.toml, pyproject.toml, or go.mod. Set the project root path.", inputPattern: { type: "manifest", pattern: "**/{package.json,Cargo.toml,pyproject.toml,go.mod}", @@ -382,6 +468,21 @@ registerSourceAdapter( coupling: "external", }, loadPackageDependency, + async (target) => { + const b = pathBasename(target); + if (b !== "package.json") return null; + return { + adapterId: "package-dependency", + score: 0.95, + scoreLabel: "strong match", + suggestedConfig: { + adapterId: "package-dependency", + projectPath: pathDirname(target), + manifestType: "package.json", + }, + reason: "package.json manifest detected", + }; + }, ); registerSourceAdapter( @@ -389,6 +490,8 @@ registerSourceAdapter( adapterId: "csv-edge-list", adapterType: "csv-edge-list", adapterVersion: "0.1.0", + category: "file-based", + formatHint: "CSV with columns: source, target (required); label (optional). First row is header by default.", inputPattern: { type: "path", pattern: "**/*.csv", @@ -407,6 +510,16 @@ registerSourceAdapter( coupling: "external", }, loadCsvEdgeList, + async (target) => { + if (!target.endsWith(".csv")) return null; + return { + adapterId: "csv-edge-list", + score: 0.9, + scoreLabel: "strong match", + suggestedConfig: { adapterId: "csv-edge-list", filePath: target }, + reason: "File has .csv extension", + }; + }, ); registerSourceAdapter( @@ -414,6 +527,7 @@ registerSourceAdapter( adapterId: "cloud-infrastructure", adapterType: "cloud-infrastructure", adapterVersion: "0.1.0", + category: "file-based", inputPattern: { type: "manifest", pattern: "**/*.{tf,yaml,yml}", @@ -439,6 +553,7 @@ registerSourceAdapter( adapterId: "issue-tracker", adapterType: "issue-tracker", adapterVersion: "0.1.0", + category: "stream", inputPattern: { type: "url", pattern: "^https?://(github|linear|jira)\\.", @@ -464,6 +579,8 @@ registerSourceAdapter( adapterId: "cerebra-snapshot", adapterType: "cerebra-snapshot", adapterVersion: "0.1.0", + category: "file-based", + formatHint: "Reads a Cerebra .cerebra/graph.json snapshot. Set the absolute path to the snapshot file.", inputPattern: { type: "path", pattern: "**/.cerebra/graph.json", @@ -487,4 +604,18 @@ registerSourceAdapter( coupling: "external", }, loadCerebraSnapshot, + async (target) => { + const isCerebraPath = target.includes("/.cerebra/graph.json") || target.endsWith(".cerebra/graph.json"); + const isPossible = !isCerebraPath && pathBasename(target) === "graph.json" && target.includes("/.cerebra/"); + if (!isCerebraPath && !isPossible) return null; + return { + adapterId: "cerebra-snapshot", + score: isCerebraPath ? 0.95 : 0.5, + scoreLabel: isCerebraPath ? "strong match" : "possible", + suggestedConfig: { adapterId: "cerebra-snapshot", filePath: target }, + reason: isCerebraPath + ? "Path matches Cerebra snapshot pattern (.cerebra/graph.json)" + : "Filename is graph.json inside a .cerebra directory", + }; + }, ); diff --git a/src/styles/lumaweave-visual-handles.css b/src/styles/lumaweave-visual-handles.css index 90d61f3d..51cbe384 100644 --- a/src/styles/lumaweave-visual-handles.css +++ b/src/styles/lumaweave-visual-handles.css @@ -13,6 +13,28 @@ * - Graph Effects */ +/* ======================================== + Native form controls + ======================================== */ + +/** + *