diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index 9741fed7e..91170f7f1 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -917,7 +917,7 @@ Common recipes: the relations between them. Picks up model types added later with no template change. - **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content. - **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all. - Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings + Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], reified?: Record, width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, controls?: string[], onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings --- @@ -989,6 +989,41 @@ Names resolvable inside GraphView props: seed sources (seeds.source), expanders - yField: string — Node data field holding y. Default "y". - Example: `{ "type": "manual" }` +**style** + +- `curve` — Edge style — the shape a connection is drawn with. "smooth" (default) leaves and arrives along the axis the edge mostly runs on, the flow-chart S, so it reads as direction and suits hierarchies and pipelines. "straight" is a direct line, right when the layout is already doing the talking. "arc" bows to one side, for a graph dense enough that lines need telling apart by shape. "step" turns at right angles, for containment and org charts where the eye follows a rank. Two nodes related in both directions are always separated — shifted sideways, or crossed at different points — so picking a shape never hides a relationship. + - Example: `"edgeStyle": [{ "style": { "curve": "smooth" } }]` +- `arrow` — Edge style — which ends carry an arrowhead. "target" (default) points at the thing being related to; "both" for a mutual relationship drawn as one line; "none" when the relation has no direction worth showing. The head scales with the line's width, and the line stops short of it rather than running underneath. + - Example: `"edgeStyle": [{ "style": { "arrow": "none" } }]` +- `scaleWithZoom` — Edge style. true (default) treats the line as part of the drawing, so it thickens as you zoom in — right for a board. false pins it to a constant on-screen width, so hairlines stay visible when you zoom out to see a whole network. + - Example: `"edgeStyle": [{ "style": { "scaleWithZoom": false } }]` +- `scaleLabelWithZoom` — Node style. true (default) scales the label with the camera; false keeps it a constant on-screen size, which keeps text readable at any zoom on a map you navigate by reading. Affects the label only — a node mark always scales, because its size and its hit area are both world units. + - Example: `"nodeStyle": [{ "style": { "scaleLabelWithZoom": false } }]` +- `labelMinZoom` — Node style. Hides the label below this zoom level, so a dense graph stays readable when zoomed out and gains its detail as you move in. + - Example: `"nodeStyle": [{ "style": { "labelMinZoom": 0.6 } }]` + +**metric** + +- `degree` — How connected a node is, normalised 0..1. The usual answer to "make the important things bigger". Reference it from a style value rather than a fixed number. + - range: [number, number] — Output range, e.g. [8, 30]. + - Example: `"nodeStyle": [{ "style": { "size": { "metric": "degree", "range": [10, 34] } } }]` +- `community` — Groups the visible graph by label propagation. Pair with scale: "categorical" to colour each cluster differently — this is what makes a cluster map. + - rounds: number — Propagation rounds. Default 8. + - Example: `"nodeStyle": [{ "style": { "color": { "metric": "community", "scale": "categorical" } } }]` + +**control** + +- `zoom-in` — Zooms toward the centre of the view. Shown by default. + - Example: `"controls": ["zoom-in", "zoom-out", "fit"]` +- `zoom-out` — Zooms out from the centre. Shown by default. +- `fit` — Frames everything currently on the graph. Deliberately not a re-layout — it moves the camera, never the nodes. +- `pin` — Holds the selected nodes where they are, so the layout stops moving them; press again to release. The usual way to shape a force graph — put the thing you care about where you want it, hold it there, and let the rest settle around it. Held nodes are ringed so the state is visible. Not shown by default: on a board every node is placed already and it means nothing. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "pin"]` +- `lock` — Blocks moving nodes, so a graph cannot be rearranged by accident while it is being read or shown to someone. Affects dragging only — panning, zooming and a settling force layout all carry on. Not shown by default, and only meaningful where the template allows dragging at all. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "lock"]` +- `relayout` — Re-runs the layout. Not shown by default: a rescue for a tangled force graph, and destructive on a board, where it would discard every position somebody chose. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "relayout"]` + **behaviour** - `pan-zoom` — Drag the background to pan, wheel to zoom about the pointer. List it last — it is the fallback. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9741fed7e..91170f7f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -917,7 +917,7 @@ Common recipes: the relations between them. Picks up model types added later with no template change. - **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content. - **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all. - Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings + Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], reified?: Record, width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, controls?: string[], onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings --- @@ -989,6 +989,41 @@ Names resolvable inside GraphView props: seed sources (seeds.source), expanders - yField: string — Node data field holding y. Default "y". - Example: `{ "type": "manual" }` +**style** + +- `curve` — Edge style — the shape a connection is drawn with. "smooth" (default) leaves and arrives along the axis the edge mostly runs on, the flow-chart S, so it reads as direction and suits hierarchies and pipelines. "straight" is a direct line, right when the layout is already doing the talking. "arc" bows to one side, for a graph dense enough that lines need telling apart by shape. "step" turns at right angles, for containment and org charts where the eye follows a rank. Two nodes related in both directions are always separated — shifted sideways, or crossed at different points — so picking a shape never hides a relationship. + - Example: `"edgeStyle": [{ "style": { "curve": "smooth" } }]` +- `arrow` — Edge style — which ends carry an arrowhead. "target" (default) points at the thing being related to; "both" for a mutual relationship drawn as one line; "none" when the relation has no direction worth showing. The head scales with the line's width, and the line stops short of it rather than running underneath. + - Example: `"edgeStyle": [{ "style": { "arrow": "none" } }]` +- `scaleWithZoom` — Edge style. true (default) treats the line as part of the drawing, so it thickens as you zoom in — right for a board. false pins it to a constant on-screen width, so hairlines stay visible when you zoom out to see a whole network. + - Example: `"edgeStyle": [{ "style": { "scaleWithZoom": false } }]` +- `scaleLabelWithZoom` — Node style. true (default) scales the label with the camera; false keeps it a constant on-screen size, which keeps text readable at any zoom on a map you navigate by reading. Affects the label only — a node mark always scales, because its size and its hit area are both world units. + - Example: `"nodeStyle": [{ "style": { "scaleLabelWithZoom": false } }]` +- `labelMinZoom` — Node style. Hides the label below this zoom level, so a dense graph stays readable when zoomed out and gains its detail as you move in. + - Example: `"nodeStyle": [{ "style": { "labelMinZoom": 0.6 } }]` + +**metric** + +- `degree` — How connected a node is, normalised 0..1. The usual answer to "make the important things bigger". Reference it from a style value rather than a fixed number. + - range: [number, number] — Output range, e.g. [8, 30]. + - Example: `"nodeStyle": [{ "style": { "size": { "metric": "degree", "range": [10, 34] } } }]` +- `community` — Groups the visible graph by label propagation. Pair with scale: "categorical" to colour each cluster differently — this is what makes a cluster map. + - rounds: number — Propagation rounds. Default 8. + - Example: `"nodeStyle": [{ "style": { "color": { "metric": "community", "scale": "categorical" } } }]` + +**control** + +- `zoom-in` — Zooms toward the centre of the view. Shown by default. + - Example: `"controls": ["zoom-in", "zoom-out", "fit"]` +- `zoom-out` — Zooms out from the centre. Shown by default. +- `fit` — Frames everything currently on the graph. Deliberately not a re-layout — it moves the camera, never the nodes. +- `pin` — Holds the selected nodes where they are, so the layout stops moving them; press again to release. The usual way to shape a force graph — put the thing you care about where you want it, hold it there, and let the rest settle around it. Held nodes are ringed so the state is visible. Not shown by default: on a board every node is placed already and it means nothing. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "pin"]` +- `lock` — Blocks moving nodes, so a graph cannot be rearranged by accident while it is being read or shown to someone. Affects dragging only — panning, zooming and a settling force layout all carry on. Not shown by default, and only meaningful where the template allows dragging at all. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "lock"]` +- `relayout` — Re-runs the layout. Not shown by default: a rescue for a tangled force graph, and destructive on a board, where it would discard every position somebody chose. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "relayout"]` + **behaviour** - `pan-zoom` — Drag the background to pan, wheel to zoom about the pointer. List it last — it is the fallback. diff --git a/AUDIT_2026-08-11.md b/AUDIT_2026-08-11.md new file mode 100644 index 000000000..8a562f48e --- /dev/null +++ b/AUDIT_2026-08-11.md @@ -0,0 +1,287 @@ +# WE Monorepo — Full-Repo Audit + +**11 August 2026 · branch `dev` @ `0fbaaf90` (template-kit merge) · 44 workspace packages** + +Five parallel audit passes: design system, docs & vision, core packages, app-shell/backends/apps, repo hygiene & tooling. All numbers come from commands actually run against the working tree. No changes were made. + +--- + +## Scoreboard + +| Check | Result | Detail | +| -------------------- | --------------- | -------------------------------------------------------------------------------------------------------------- | +| `pnpm lint` | **FAILS** | 1,295 errors — but 1,293 come from one generated file that was never lint-ignored. Real errors: 2. | +| `pnpm test` | **PASSES** | Fully green (app-shell 255, module-call 42, transcribe 33, electron 39, …) — yet CI treats it as non-blocking. | +| CI blocking gates | **Build only** | Lint and Test are `continue-on-error: true` with a stale justification. No typecheck step exists. | +| Typecheck coverage | **5 / 42** | Only 5 packages define a `typecheck` script; type errors elsewhere surface only incidentally via bundlers. | +| Test-script coverage | **24 / 42** | 18 packages have no `test` script, so `pnpm -r test` silently skips them. | +| TODO / FIXME / HACK | **6 total** | Essentially zero across the repo. `@ts-ignore`: 0. Empty undocumented catches: 2. | +| `any` usage | **~76 sites** | Heavily concentrated in test harnesses; production `any` ≤ 2 per file — with one critical exception (P1-1). | +| Docs accuracy | **Major drift** | The central README and the "start here" architecture doc both describe removed APIs (`adamStore` era). | + +**One-paragraph verdict:** code discipline is genuinely strong — near-zero TODO debt, documented catch blocks, a well-tested schema-system (76% test:src ratio), and a real template-kit consolidation. The rot is concentrated in three places: **CI that gates on nothing but the build**, **a test gap shaped exactly like the riskiest code** (block persistence, the big app-shell stores, the design system), and **documentation two refactors behind the code**. Most of the P0 tier is hours of work, not days. + +--- + +## P0 — Broken now, cheap to fix + +Things that are actively wrong today and mostly fixable in an afternoon each. + +_Two items have since been fixed on `feat/graph-engine-improvements` and are marked inline: the `we-alert` registration in P0-4, and the theming layering in P0-6._ + +### P0-1 · Lint failure is one un-ignored generated file — HIGH + +1,293 of the 1,295 errors are prettier complaints inside `packages/models/src/generated/coreManifest.ts` — machine-generated, committed, and covered by neither `.prettierignore` nor the eslint `ignores` block (the sibling generated dir in design-system _is_ ignored). Adding `**/src/generated/**` drops the repo to 2 real errors: + +- `packages/module-system/transcribe/src/store.ts:482` — unused variable `call` +- `packages/models/src/index.ts:31` — prettier line-wrap + +### P0-2 · CI's escape hatches are no longer justified — HIGH + +Both Lint and Test in `.github/workflows/build.yaml` carry `continue-on-error: true`, justified by comments about a backlog that no longer exists (the cited app-shell test import failure is fixed; the "no-explicit-any warnings" rationale matches zero actual warnings — there are none). Tests are green today. The only thing that can currently fail a build is the build itself — lint and test regressions land silently on `dev`. Also: + +- CI installs with `--no-frozen-lockfile`, so lockfile drift is never caught. +- No typecheck step exists at all (and no root `typecheck` script). + +### P0-3 · The design-system ESLint rule points at a package renamed months ago — HIGH + +`eslint.config.js` scopes `design-system/prefer-ds-props` to `packages/app-framework/**/*.tsx` — a path that no longer exists (renamed to `app-shell`). The rule silently skips app-shell's 30 TSX files and editor's 15. There are 52 `style=`/`styles=` occurrences in app-shell/editor/module-system the rule never sees. One-line glob fix; expect new findings when it lands. + +### P0-4 · Four pieces of genuinely broken public surface in @we/primitives — HIGH + +- ~~**`we-alert` cannot render.**~~ **FIXED** on `feat/graph-engine-improvements` — `alert.ts` was the one primitive missing from `3-primitives/src/index.ts`, while appearing fully documented in the generated registry, so schemas using it got an unknown element silently. Found the hard way: the graph's status strip is built from `we-alert` and painted nothing. +- **`combobox.ts` is a dead, stale duplicate of `select.ts`** registering the same `we-select` tag — which is why `we-select` is listed twice in CLAUDE.md. It also lags behind the real one (hardcoded heights, raw tokens). Delete it. +- **Every per-component subpath export is broken.** `3-primitives/tsup.config.ts` builds `src/components/**` but the source lives in `src/primitives/` — `dist/components/` doesn't exist, so `import '@we/primitives/button'` typechecks (types come from a different script) and fails at runtime. The same stale `components/` path also makes the `eslint-plugin-lit` file glob match nothing. +- **The peer range can never be satisfied:** `4-components` and `5-widgets` declare `@we/primitives ^1.0.0`; primitives is version `0.1.0`. + +Related: `@we/widgets` imports `@we/design-utils` at runtime but declares it only as a devDependency (bundled today, one `external` edit from a broken publish). + +### P0-5 · Three silent wrong-rendering bugs — HIGH + +- **Dark theme leaks into every theme:** `2-themes/src/dark/index.css:14` styles `we-text[variant='heading-md']` with no `[data-we-theme='dark']` scope — the only unscoped rule in all five themes, applied globally via the aggregate. +- **`disabledProps` is accepted and silently dropped:** it's in `designSystemKeys` and the docs, but `useStateProps` and all four layout components only handle hover/active/focus. +- **Deleting a space theme leaves it in the list:** `templates/default/.../ThemesList.ts` is a ~90% copy of `TemplatesList.ts` but is missing the refresh `onSuccess` the template version has; `ThemeStore` never updates `spaceThemes` on remove. Exactly the "drifting twins" class the template-kit PR fixed for the marketplace pair. + +--- + +## P1 — Structural risk + +Correctness time-bombs: untyped or untested code sitting exactly where bugs hurt most. + +### P1-1 · The block persistence pipeline is untyped and untested — HIGH + +`packages/block-system/shared/src/types.ts:9` reads `export type SerializedBlockNode = any` — and every function in the 841-line `serialization.ts` (create, reconcile, load) flows through it. A _second, real_ interface of the same name exists in `block-solid` (`createBlockNodeClass.tsx:19`), so the name means two different things in sibling packages. Meanwhile `reconcileBlocks` (`serialization.ts:572-698`) — 267 lines of id-claiming, duplicate detection, and orphan deletion where a regression silently deletes user content — has zero tests; block-system's entire test surface is one 35-line metadata check. The least-typed code in the repo is also the least-tested (0.5% ratio). **Typing this properly is the highest-leverage single fix in the audit.** + +Trivially testable pure functions in the same file, all untested: `groupListItems`, `blockToLexical`, `extractTextContent` (has a 5,000-char truncation rule), `extractInlineText`, `extractBlockData`. + +### P1-2 · @coasys/ad4m has three contradictory truths — HIGH + +Eight packages declare `0.11.0`; the root pnpm override forces `0.13.0-test-9` (a mutable prerelease test tag); CI overrides _again_ to `file:./ad4m/core` built from a git clone. CI never tests what a developer runs, and neither tests what the manifests claim — and `@we/models` is publishable (`publishConfig.access: public`) with a peer range that's a two-minor-version lie. + +### P1-3 · Duplicated core helpers have diverged — one copy is unguarded — MEDIUM + +- `deepUnwrap` exists in `schema-system/frameworks/solid/src/SchemaRenderer.tsx:40` (with a `MAX_UNWRAP_DEPTH` guard) and in `schema-system/shared/src/propResolvers/action.ts:81` (**no guard** — unbounded recursion, on the action-dispatch path every user interaction hits). They also disagree on `Object.create(null)` objects. +- `isPropsSchemaNode` is copy-pasted in 4 places (`indexer.ts:41`, `scope.ts:81`, `editor/InspectorPanel.tsx:19`, `editor/EditorOverlay.tsx:106`); `replaceNodeInTree` (~50 lines of recursive tree rewriting) in 2, one labelled "local copy". `isSchemaChild` has two variants in the same package that disagree on whether an array counts as a node (`indexer.ts:25` vs `scope.ts:88`). + +All belong in `@we/schema-shared`, which editor already depends on. + +### P1-4 · The test gap maps exactly onto the riskiest code — MEDIUM + +| Package | Src LOC | Test:src | Biggest untested thing | +| ------------- | ------- | -------- | -------------------------------------------------------------- | +| schema-system | 8,016 | **76%** | — (the model to copy) | +| graph-system | 4,431 | 22% | `SpatialIndex`, viewport transforms, pointer state machines | +| module-system | 7,869 | 18% | globe family: 0 tests, 13 non-null assertions, no build script | +| models | 1,561 | 6% | `modelRegistry` (its own docs describe a silent-failure mode) | +| editor | 8,360 | **0.8%** | its only test duplicates the implementation, so it can't fail | +| block-system | 6,472 | **0.5%** | the entire persistence path (P1-1) | + +- **Design system:** 2 test files across 7 sub-packages, both testing one function; only `utils` even has a `test` script. **Highest-value first test:** assert `DesignSystemProps` (85 props, `types/src/index.ts:101`) ↔ `designSystemKeys` (5 hand-maintained arrays, `utils/src/index.ts:140`) stay in sync — they agree today by luck; a drifted prop is silently dropped by every layout component. ~5 lines, eliminates a whole bug class. Then `mergeProps`/`parseBorder`/`mapFlexAxes` in the 801-line `utils/src/index.ts`, and snapshot-test the token CSS generator (`1-tokens/scripts/generate-css.ts`, 388 lines, generates every `--we-*` variable). +- **app-shell:** 4,060 test LOC covers accounts/runtime/boot — but `SpaceStore` (1,842), `TemplateStore` (1,268), `EditorStore` (1,249), `ThemeStore` (1,145), `DatasetStore` (530) are untested, i.e. every write path: space create/join, template install/fork, theme persistence. +- **backend-ad4m:** no tests for `sdnaModels.ts` (419 LOC — the schema _write_ path), `lifecycleAdapter.ts`, `agentHelpers.ts`, `perspectiveHelpers.ts`. +- **editor:** the only test (`geometry.test.ts`) defines a local copy of the function it tests, so it cannot fail when the real code changes. The pure helpers are all extractable today: `nearestToken`, `computeSizeDelta`, `parseRing`/`composeRing`, `operandValueType`, `exprComplete`, `deepClone` (whose doc comment records a production bug nothing pins). + +### P1-5 · backend-inmemory does not actually mirror the backend contract — MEDIUM + +`createInMemoryBackendPorts` (`inmemory/src/lifecycle.ts`) stubs the data plane (`ephemeral: () => null`, two bindings) vs the AD4M side's full `$getModel`/`$queryAdapter`/mutations/runtime/transcription surface — so the boot suite that claims to double as a conformance test cannot exercise a single query or mutation. A _second_, unrelated in-memory backend in the same package (`createInMemoryBackend`, `index.ts:80`) _does_ implement queries and is what schema-system tests actually use. Two in-memory backends, neither a complete `BackendPorts`, no conformance test asserting the adapters agree. Given backend independence is a first-class goal, this seam deserves a real conformance suite. + +### P1-6 · Token drift traps in the build — MEDIUM + +- `@we/design-utils` inlines a frozen copy of `@we/tokens`' font scale (devDep, not externalized) — adding a lineHeight token does nothing until design-utils rebuilds, then silently emits invalid CSS. +- Two scrollbar values in `1-tokens/src/component.ts:26-27` are filtered out and hardcoded by the generator — editing the source has no effect. +- Generated token CSS hardcodes three Google Fonts `@import` URLs (`generate-css.ts:364-366`) — a third-party network request on every load of a local-first, offline-capable app. + +--- + +## P2 — Documentation + +The docs tree structure is sound — the problem is drift. Ranked by how much each doc misleads someone who trusts it. + +### P2-1 · The central package's README documents an API that no longer exists — HIGH + +`packages/app-shell/README.md` — the front door to an 18.6k-LOC package all three apps consume — shows `import { useAdamStore } from '@we/app-shell/solid'` (zero hits repo-wide), a `PlatformAdapter` interface whose methods (`buildAd4mClient`, `getConnectionDetails`) don't exist (actual: `resolveAppUrl`, `isDesktop`, `accounts?`, `executor?`…), wrong consumer package names (`@we/we-web` → `@we/app-web`), and a directory tree missing five real directories. The highest-value doc in the repo is the most wrong. + +### P2-2 · The "start here" doc describes the pre-refactor world — HIGH + +`docs/architecture/codebase-map.md` still teaches `adamStore.me.did`, `adamStore.currentPerspective`, `adamStore.initializeAsWeSpace` (now on Session/Dataset/Space stores), uses perspective-speak where the codebase says dataset, and covers none of the last two merged PRs — no graph-system (6 packages), no template-kit, no backend-system layer, no ai-context. The "tokens → **elements** → … → **pages**" stack it lists names two layers that don't exist — and the same line lives in `ai-context/src/fragments/architecture.ts:34`, propagating into all three generated context files. + +### P2-3 · Guides that document things that don't exist — HIGH + +- `docs/guides/cesium/*` — both guides document `@we/cesium-layers` and a `CesiumGlobe` import from `@we/widgets/solid`, from before the globe moved into the module system (`@we/globe-layers`, `@we/globe-widget`). Linked from the docs index as current. +- `docs/guides/launcher-ui-customization.md` — built around `launcherUIRegistry`, which has zero hits in the codebase. +- `packages/schema-system/OPERATORS.md` — the canonical operator reference uses `adamStore.*` in six examples (lines 332, 398, 416, 570, 1127, 1386); every one is now uncompilable vocabulary. The 1,489-line file is otherwise current and comprehensive. +- `apps/EMBEDDING.md` — linked from seven live docs, but headlines a flow around `AdamStore.tsx` and message constants (`REQUEST_AD4M_CONFIG`) with zero hits in source. + +### P2-4 · The seed file has three mutually inconsistent descriptions — HIGH + +- `docs/getting-started/seed-system.md` documents fields that don't exist (`host.ui.appSettings`, app `route`) and omits the ones that matter most — **`modules` and `features` are entirely undocumented**, though `seed.ts` calls the modules list "what the seed is for". Also undocumented: `globalSpaceUrl`, `marketplaceUrl`, `electron.basePort`, `ad4m.dataPath`. +- `packages/app-shell/src/seed/README.md` defines a _different_ `WeSeedFile` (top-level `paths`/`commands`) matching neither the doc nor `seed.ts`. +- `seed-examples/` has schema-drifted from the real `we-seed.json` (no modules, no space/marketplace URLs) while `validate-seed.cjs` only ever checks the root file. + +Collapse to one description generated from or diffed against `packages/app-shell/src/types/seed.ts` (well-commented, the actual truth). + +### P2-5 · Front-door drift: README, VISION, developer-setup — MEDIUM + +- `README.md`: three of seven package links 404 (`packages/utils` is deleted; two `frameworks/` paths moved); six of twelve top-level package dirs (graph, module, backend, templates, ai-context, editor) are invisible from the front page; no mention of `pnpm test`/`lint`; same "elements/pages" phantom layers. +- `VISION.md` (2026-07-15): **the narrative holds — the update it needs is targeted, not a rewrite.** "How It's Built" (line 185) names Elements and Pages layers that don't exist; the contributor-ladder table repeats them; and the graph engine and the template-kit fragment rung — the mechanism the "forking is a first-class act" argument depends on most — are absent. `docs/architecture/template-fragments.md` is the canonical statement of that mechanism and VISION doesn't link it (neither does `docs/README.md` — the 290-line output of the template-kit PR is unreachable from the docs index). +- `docs/getting-started/developer-setup.md`: says Node 18+ (`.nvmrc` pins v24.14.0), clones `your-org`, lists 2 of 12 package dirs, omits `pnpm test`/`lint`/`generate-context`/`validate:schemas`. + +### P2-6 · Missing READMEs / CONVENTIONS, ranked by what matters — MEDIUM + +Gaps worth filling, in order: + +1. **app-shell CONVENTIONS.md** (biggest package, none) +2. **module-system/README** (9.2k LOC, only `shared/` and `globe/layers` have READMEs) +3. **backend-system/README** (9.1k LOC) +4. **templates/README** (no map of default/kit/shell) +5. **ai-context/README** — 4.6k LOC that generates 540 KB of tracked output across three byte-identical files (`CLAUDE.md`, copilot, cursor), with its fragment/extractor architecture explained nowhere +6. `block-system/shared` and `graph-system/protocol` — contract packages violating the repo's own written rule (package-conventions.md rule 6) that contract packages must have a README + +Design-system READMEs actively mislead: `4-components/README.md` describes per-component `.module.scss` files that have never existed, a deleted `PostCard`, and wrong directories; `3-primitives/README.md` points at `src/components/` (actual `src/primitives/`) and a nonexistent `pnpm storybook` script; `5-widgets/README.md` is a single heading line. `1-tokens/README.md` documents a renamed color system (`ui` hue → `neutral`). `packages/design-system/README.md` links `CONTRIBUTING.md` and `LICENSE` — neither exists in the repo. + +`cli` (79 LOC, but wired into root `prepare` and providing the `we-build` bin ~25 packages use — deserves a paragraph) and `playgrounds` are noise-level gaps. + +### P2-7 · Reorganisation — four moves, not a restructure — LOW + +1. **Freshness contract:** the stale docs went stale because nothing forced updates. Add a docs-sync checklist item to a PR template (`.github/` has workflows but no PR template). +2. **One seed description** (P2-4). +3. **Move the strays:** `apps/WEB_SCRAPING_STRATEGY.md` (544-line unimplemented plan for a package that was never created) → `docs/internal/plans/` or delete; `apps/EMBEDDING.md` → `docs/guides/` after verifying the mechanism still exists (evidence says it may not); fold `docs/architecture/{examples,meta-app-vs-separate-apps}.md` (both restated better by VISION.md, both from March) into it or a `docs/concepts/` sibling. +4. **Every `-system` dir gets a root README**, copying `graph-system/README.md`'s shape (verified current). Banner the 4 of 5 `docs/internal/old/` files missing one; `docs/internal/plans/` is 440 KB with 21 broken cross-links in its own index (`pr-roadmap.md`). + +--- + +## P3 — Hygiene & housekeeping + +Real but non-urgent. Batch into quiet moments. + +### P3-1 · Version drift and unused dependencies — MEDIUM + +- **vitest spans three majors:** `module-call`/`module-transcribe` run 1.6.1 while everything else runs 4.1.10 — two test runners with different expect/mocking semantics in one `pnpm test`. +- **typescript:** three ranges (`^5.3.3` across all of graph-system + most modules, `^5.7.2` root/apps, `^5.9.3` playgrounds). Also `@types/node` ×3, `tsup` ×2 (`^8.0.0` module-system vs `^8.5.1`), vite/vite-plugin-solid minor drift. `solid-js` is clean (`^1.9.5` × 24 packages). +- **Registry-resolvable workspace deps:** `@we/components@^0.1.0` (×2) and `@we/primitives@^1.0.0` (×2) instead of `workspace:*` — these can resolve from the registry. +- **Unused declared deps:** the full Apollo stack (`@apollo/client`, `graphql`, `graphql-ws`) in both desktop apps; `graph-explorer` declares `@we/graph-core`/`-expanders`/`-layouts` and imports none of them; `d3-force` + 4 more in `5-widgets`; `zod` + `@we/design-types` in app-shell; `@types/leaflet` in primitives (while `location-picker.ts` types its map as `any`). + +### P3-2 · Copy-paste pairs that will drift (one already has) — MEDIUM + +- `Column`/`Row` are byte-identical but for one string; `Grid`/`Card` repeat the same 25-line scaffold; four copies of the same `hasStateProps` closure. A `createLayoutComponent(direction, defaults)` factory collapses four copies and fixes the `disabledProps` bug (P0-5) in one place. +- `apolloClient.ts` duplicated verbatim between electron and tauri (differs only in comments); platform adapters and connectors structurally identical but for the IPC transport — a `createDesktopPlatform(transport)` factory in app-shell collapses all three pairs. +- The account registry is implemented twice (504-line JS in electron, 1,052-line Rust in tauri) against one shared on-disk format (`we-accounts/registry.json`), each documenting the same data-loss hazard, with **no shared fixture proving a file written by one host round-trips through the other** — and the Rust tests never run in `pnpm test` (we-tauri has no test script). +- `TemplatesList.ts`/`ThemesList.ts` — the drifted twins from P0-5; collapse like the marketplace pair was. + +### P3-3 · Store architecture smells (app-shell) — MEDIUM + +- `EditorStore.tsx` (1,249 LOC) carries six unrelated concerns — chat persistence, four panels' geometry (with four byte-identical debounced width persisters at :621-641), undo/redo, the fork/fresh picker, the API key, and the full agentic tool loop. +- The 14-level provider nesting in `StoreProvider.tsx` is a load-bearing implicit contract with no test, worked around by four upward callback back-channels (`ThemeStore.registerHistoryCallbacks`, `TemplateStore.provideSpaceLookup`, `AppStore.provideInstalledModules`, `RouteStore.setNavigateFunction`). +- `SpaceStore.tsx`: 1,842 LOC, 15 signals, 11 `createEffect`s, reads 8 other stores — the highest-fan-in module in the repo, untested. +- 3,037 LOC of developer test schemas (`schema-tests`) ship in every production bundle — exported through the barrel every app imports and registered unconditionally in `TemplateStore.tsx:138` with no `import.meta.env.DEV` guard. Also the source of 18 of app-shell's 27 `any`s. +- Leftover names: `EditorStore.tsx:316` comment references `AiStore`; `@we/editor` aliases the host port as `adamStore`/`aiStore` in ~70 local bindings across 10 files — functionally fine, but every reader now has to know two dead store names. + +### P3-4 · Dead code, dead assets, dead config — LOW + +- **~26 MB of skybox JPEGs** (both 2k and 4k sets) committed under `src/`; **2.1 MB** About-page image; **776 KB** of unreachable `CTAv2` images pinned off by `const IMAGES = IMAGE_SETS.v1` (`templates/shell/src/about/index.ts:27`); `CTAv1/ForCommunities.jpg` imported by nobody. LFS/CDN candidates. +- **Dead exports:** `blocksToLexicalJSON` (60+ lines, zero consumers), the five individual graph behaviours (only the bundle is used), `signalNormalize.ts`/`signalAggregate.ts` entirely unreferenced, the `--we-depth-*` token category (zero consumers vs the `shadow` scale actually used), `tokens.zIndex` omitted from the aggregate object, seven `@we/editor` public exports with no external consumers. +- `packages/module-system/assistant/` — an empty directory containing only `node_modules/`; incompletely deleted module. +- `netlify.toml` — almost certainly dead: `pnpm build` alone can't succeed there (needs the ad4m link script it never calls), no Node pin. Its companion `scripts/build-with-ad4m-link.sh` is orphaned from every configured pipeline while CI reimplements the same logic inline. +- `apps/playgrounds/{react,vue,svelte}` — one-line README stubs advertising frameworks with no implementation. (The three Solid playgrounds are healthy.) +- `RerenderLog` — a debug component that `console.log`s on every mount, exported in the public barrel and schema registry. +- `3-primitives/docs/notes.md` — stale scratchpad documenting a dist structure the build doesn't produce. +- Storybook: covers 12 of 51 primitives, stories unchanged since March; root README claims a monorepo-level Storybook that doesn't exist. + +### P3-5 · Console noise and silent failures — LOW + +- Leftover debug logs on hot paths: `SpaceStore` (6, e.g. `'Spaces in dataset:'`), `EditorStore.tsx:858-962` (nine logs including two full `JSON.stringify(…, null, 2)` template dumps on every AI tool call), `CollectionInput.tsx:175-177`, `TemplateStore.tsx:145` (fires on every init). No `no-console` ESLint rule exists. (The top offenders by count are CLI/build scripts where logging is the point — only ~14 of 178 `console.log`s are in library src.) +- 16 unconditional `console.warn`s in `propResolvers/local.ts` bypass the `$onError` port — template-author mistakes in `$local`/`$setLocal` never reach the host toast surface. Two error conventions in one package. +- `backend-ad4m/src/agentHelpers.ts:32` swallows every avatar-resolution failure with no log — the only undocumented catch in the package. A broken avatar is indistinguishable from an absent one. +- `ai-context`'s drift detector (`mergeStoreEntries`) warns on stale store entries but never exits non-zero, so context drift can never fail a build; the ts-morph extractor whose own header documents a months-long silent failure (`appShell.ts`) is untested. +- `pnpm build` mutates six tracked generated files (CLAUDE.md et al.) and CI never diffs afterwards — a forgotten regeneration is invisible. + +### P3-6 · Git and CI housekeeping — LOW + +- **147 branches; 99 already merged into dev** are pure deletion candidates. `main` hasn't moved since 2025-11-29. +- CI caches `*/node_modules` one level deep in a 2–4-level-deep workspace (mostly useless; cache the pnpm store instead), keyed on a lockfile the workflow then mutates before installing; no `concurrency:` group, so duplicate pushes each run a full AD4M source build; CI runs pnpm 9 while `packageManager` pins 10.18.3. +- `pnpm clean` deletes the lockfile (guaranteeing dependency float on next install) yet misses the actual disk hogs — 69 GB of tauri `target/` and 1.1 GB of `dist-electron/` survive a "clean". +- Undocumented flags: `--workspace-concurrency=1` on build and `--no-bail` on test have zero recorded rationale anywhere in the repo. +- Stylelint is fully configured (config, deps, editor integration) but has no npm script and no CI step — enforced only in editors. +- `.gitignore` gaps (latent): `coverage/`, `*.tsbuildinfo`, `.turbo` (which CI explicitly cleans up by hand), `.DS_Store`, `*.log`. +- `@we/cli`'s build script (`[ -f dist/we-build.js ] && node dist/we-build.js || pnpm run build:steps`) masks its own failures — if the run fails, the fallback executes and the script reports success. Also POSIX-only. +- `pnpm-workspace.yaml`'s `packages/module-system/*/*` glob matches 38 dirs of which ~7 are packages — harmless noise. +- The `no-explicit-any` rule produced 0 warnings against 76 `any` sites — worth confirming the rule actually reaches those files; it's currently doing nothing either way. + +--- + +## P0-6 · Theming cannot be used outside app-shell — HIGH + +_Surfaced while adding a light/dark toggle to the graph-explorer playground. Partly fixed on `feat/graph-engine-improvements`; the rest is recorded below._ + +A theme in WE is **not a stylesheet, it is a parameter set** — colours are generated from a hue, a saturation and a lightness ramp, with `multiplier`/`subtractor` transforming it (`multiplier: -1` inverts the whole scale, which is what "dark" means). The `@we/themes` CSS files carry only the few rules that cannot be parametric. + +The consequence: **importing `@we/themes` and setting `data-we-theme="dark"` looks like it should work and does almost nothing.** The part that matters — writing the parameters as custom properties — lived in `ThemeStore.applyThemeToDOM`, with the presets in `themeRegistry`, both inside `@we/app-shell`. So the design system could not theme itself, and any second host (playground, embed, a future non-Solid shell) hit the same wall. This is also a layering inversion: the repo's own rule is that the design system is host-agnostic. + +**Fixed:** + +- Presets moved to `@we/themes/presets` as data (`THEME_PRESETS`, `ThemeParameters`, `isThemeName`). The design system owns what a theme *is*. +- `applyThemeVars(root, overrides)` added to `@we/schema-shared` beside `themeToStyle`, carrying the removal bookkeeping — it clears exactly the previous theme's variables, which the `cssText` shortcut does not (that wiped the host's own `--we-dock-*` layout state; the fix was previously a comment in `ThemeStore` and is now shared behaviour with tests). +- `app-shell` consumes both, so presets have a single home and cannot drift. + +**Still open:** + +1. **`themeToStyle` / `applyThemeVars` sit in `@we/schema-shared`, not the design system.** They map theme parameters onto token variable names — design-system knowledge. They live in schema-shared because schema nodes can carry a `theme`, which makes schema-shared a *consumer*, not the owner. Moving them to `@we/themes` (which already depends on `@we/tokens`) would put the vocabulary and its mapping together; schema-shared can re-export. +2. **One vocabulary, two declarations.** `ThemeParameters` (`@we/themes`) and `ThemeOverrides` (`@we/schema-shared`) describe the same thing. The first is deliberately narrow — only what the presets set — but they will drift. Consolidate when (1) lands. +3. **No semantic role tokens — the substantive gap.** Colour tokens are *scale positions* (`neutral-0`…`neutral-1000`), not *roles* (`surface`, `surface-raised`, `text-muted`, `border`). A component writes `bg="neutral-0"` meaning "page background", which is true only by convention and holds in dark only because *everything* inverts uniformly. Good dark themes are not inverted light themes: + - **Elevation inverts** — in light, raised surfaces cast shadows; in dark, raised surfaces get *lighter*. A lightness flip cannot express this, because "raised" is not a token. + - **Contrast compresses** — pure white on pure black is uncomfortable. `dark`'s `subtractor: '108%'` and `cyberpunk`'s `'110%'` are hand-tuned constants keeping the ramp off pure black: a linear inversion approximating a dark theme rather than designing one. + - Saturation reduction *is* handled, per-theme. Credit where due. + + The fix is additive and does not threaten user theming: add role variables over the scale, default each to a parametric expression so every existing and user-authored theme keeps working untouched, and let a theme override individual roles. This is what Material 3 does — parametric tonal generation *plus* a role→tone mapping that differs per mode. WE has the generation and not the mapping. +4. **P0-5's unscoped `we-text[variant='heading-md']` is a symptom of the same thing.** The theme CSS files are vestigial — nobody maintains them closely because the real work happens elsewhere — which is exactly how an unscoped rule survives in one. + +**Sequencing note:** (3) will change what a theming audit finds, so it is worth doing before any broad pass over component colour usage. + +--- + +## What's healthy + +Stated so it doesn't get re-litigated: + +- **TODO/FIXME/HACK debt is essentially zero** — 6 markers across the entire repo; `@ts-ignore`: 0. +- **Catch-block discipline is unusual** — nearly every silent catch carries a written justification; only 2 undocumented empty catches repo-wide (`tooltip.ts:181,195`). +- **schema-system is genuinely well-tested** (76% test:src, 23 files) and the architecture is clean: no circular deps, no cross-package deep imports, no undeclared imports, `strict: true` universal. +- **The template-kit consolidation is real** — the four marketplace browsers genuinely collapsed to one fragment; 33 of 64 template files now import the kit. +- **The store refactor left no orphan files** in app-shell. +- **Production `any` is rare** and annotated; templates and we-web have zero console noise. +- **Recent docs are good** — `package-conventions.md`, `template-fragments.md`, `graph-system/README.md`, and `editor/README.md` are current and strong; they're the template for fixing the rest. + +--- + +## Suggested order of attack + +1. Ignore `**/src/generated/**` in eslint + `.prettierignore`; fix the 2 real lint errors. _(minutes)_ +2. Drop `continue-on-error` from CI Lint + Test; switch to `--frozen-lockfile`; add a typecheck step and root script. _(an hour)_ +3. Fix the `app-framework` → `app-shell` ESLint glob; triage what it newly flags. _(an hour + follow-ups)_ +4. Design-system broken surface: register `we-alert`, delete `combobox.ts`, fix the tsup `components/`→`primitives/` path, fix the peer range, scope the dark-theme rule. _(half a day)_ +5. Write the `DesignSystemProps`↔`designSystemKeys` invariant test + add `test` scripts to the six design-system packages missing them. _(half a day)_ +6. Type `SerializedBlockNode`; extract and test the block serialization pure functions, then `reconcileBlocks`. _(the big one — days, highest leverage)_ +7. Unify `deepUnwrap` (keep the depth guard) and the tree helpers into `@we/schema-shared`. _(half a day)_ +8. Docs sprint: app-shell README, codebase-map, OPERATORS.md adamStore refs, seed single-source, delete/move the dead guides. _(1–2 days)_ +9. Resolve the ad4m version triangle; unify vitest and typescript; `workspace:*` everywhere. _(half a day)_ +10. Delete the 99 merged branches; batch the P3 hygiene into a cleanup PR. _(background)_ diff --git a/CLAUDE.md b/CLAUDE.md index 9741fed7e..91170f7f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -917,7 +917,7 @@ Common recipes: the relations between them. Picks up model types added later with no template change. - **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content. - **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all. - Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings + Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], reified?: Record, width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, controls?: string[], onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings --- @@ -989,6 +989,41 @@ Names resolvable inside GraphView props: seed sources (seeds.source), expanders - yField: string — Node data field holding y. Default "y". - Example: `{ "type": "manual" }` +**style** + +- `curve` — Edge style — the shape a connection is drawn with. "smooth" (default) leaves and arrives along the axis the edge mostly runs on, the flow-chart S, so it reads as direction and suits hierarchies and pipelines. "straight" is a direct line, right when the layout is already doing the talking. "arc" bows to one side, for a graph dense enough that lines need telling apart by shape. "step" turns at right angles, for containment and org charts where the eye follows a rank. Two nodes related in both directions are always separated — shifted sideways, or crossed at different points — so picking a shape never hides a relationship. + - Example: `"edgeStyle": [{ "style": { "curve": "smooth" } }]` +- `arrow` — Edge style — which ends carry an arrowhead. "target" (default) points at the thing being related to; "both" for a mutual relationship drawn as one line; "none" when the relation has no direction worth showing. The head scales with the line's width, and the line stops short of it rather than running underneath. + - Example: `"edgeStyle": [{ "style": { "arrow": "none" } }]` +- `scaleWithZoom` — Edge style. true (default) treats the line as part of the drawing, so it thickens as you zoom in — right for a board. false pins it to a constant on-screen width, so hairlines stay visible when you zoom out to see a whole network. + - Example: `"edgeStyle": [{ "style": { "scaleWithZoom": false } }]` +- `scaleLabelWithZoom` — Node style. true (default) scales the label with the camera; false keeps it a constant on-screen size, which keeps text readable at any zoom on a map you navigate by reading. Affects the label only — a node mark always scales, because its size and its hit area are both world units. + - Example: `"nodeStyle": [{ "style": { "scaleLabelWithZoom": false } }]` +- `labelMinZoom` — Node style. Hides the label below this zoom level, so a dense graph stays readable when zoomed out and gains its detail as you move in. + - Example: `"nodeStyle": [{ "style": { "labelMinZoom": 0.6 } }]` + +**metric** + +- `degree` — How connected a node is, normalised 0..1. The usual answer to "make the important things bigger". Reference it from a style value rather than a fixed number. + - range: [number, number] — Output range, e.g. [8, 30]. + - Example: `"nodeStyle": [{ "style": { "size": { "metric": "degree", "range": [10, 34] } } }]` +- `community` — Groups the visible graph by label propagation. Pair with scale: "categorical" to colour each cluster differently — this is what makes a cluster map. + - rounds: number — Propagation rounds. Default 8. + - Example: `"nodeStyle": [{ "style": { "color": { "metric": "community", "scale": "categorical" } } }]` + +**control** + +- `zoom-in` — Zooms toward the centre of the view. Shown by default. + - Example: `"controls": ["zoom-in", "zoom-out", "fit"]` +- `zoom-out` — Zooms out from the centre. Shown by default. +- `fit` — Frames everything currently on the graph. Deliberately not a re-layout — it moves the camera, never the nodes. +- `pin` — Holds the selected nodes where they are, so the layout stops moving them; press again to release. The usual way to shape a force graph — put the thing you care about where you want it, hold it there, and let the rest settle around it. Held nodes are ringed so the state is visible. Not shown by default: on a board every node is placed already and it means nothing. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "pin"]` +- `lock` — Blocks moving nodes, so a graph cannot be rearranged by accident while it is being read or shown to someone. Affects dragging only — panning, zooming and a settling force layout all carry on. Not shown by default, and only meaningful where the template allows dragging at all. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "lock"]` +- `relayout` — Re-runs the layout. Not shown by default: a rescue for a tangled force graph, and destructive on a board, where it would discard every position somebody chose. + - Example: `"controls": ["zoom-in", "zoom-out", "fit", "relayout"]` + **behaviour** - `pan-zoom` — Drag the background to pan, wheel to zoom about the pointer. List it last — it is the fallback. diff --git a/apps/playgrounds/solid/graph-explorer/package.json b/apps/playgrounds/solid/graph-explorer/package.json index 44ec3219d..376a11ceb 100644 --- a/apps/playgrounds/solid/graph-explorer/package.json +++ b/apps/playgrounds/solid/graph-explorer/package.json @@ -2,7 +2,7 @@ "private": true, "name": "@we/playground-graph-explorer", "version": "0.0.0", - "description": "Browser harness — the graph engine, its expanders and layouts over an in-memory dataset. No AD4M.", + "description": "Browser harness \u2014 the graph engine, its expanders and layouts over an in-memory dataset. No AD4M.", "type": "module", "scripts": { "dev": "vite", @@ -11,12 +11,15 @@ "test": "vitest run" }, "dependencies": { + "@we/components": "workspace:*", "@we/graph-core": "workspace:*", "@we/graph-expanders": "workspace:*", "@we/graph-layouts": "workspace:*", "@we/graph-protocol": "workspace:*", "@we/graph-solid": "workspace:*", "@we/primitives": "workspace:*", + "@we/schema-shared": "workspace:*", + "@we/themes": "workspace:*", "@we/tokens": "workspace:*", "solid-js": "^1.9.5" }, diff --git a/apps/playgrounds/solid/graph-explorer/src/env.d.ts b/apps/playgrounds/solid/graph-explorer/src/env.d.ts index ce0dba04f..36a8f4a3c 100644 --- a/apps/playgrounds/solid/graph-explorer/src/env.d.ts +++ b/apps/playgrounds/solid/graph-explorer/src/env.d.ts @@ -2,3 +2,4 @@ declare module '@we/tokens/css'; declare module '*.css'; +declare module '@we/themes'; diff --git a/apps/playgrounds/solid/graph-explorer/src/fixture.ts b/apps/playgrounds/solid/graph-explorer/src/fixture.ts index a38c95cfd..5e278580f 100644 --- a/apps/playgrounds/solid/graph-explorer/src/fixture.ts +++ b/apps/playgrounds/solid/graph-explorer/src/fixture.ts @@ -80,6 +80,25 @@ export const SHAPES: EntityShape[] = [ { name: 'topic', target: 'Topic', cardinality: 'one' }, ], }, + { + name: 'SemanticRelationship', + description: 'An edge with data — which topic a belief is about, and how strongly.', + properties: [{ name: 'relevance', type: 'number', required: true }], + relations: [ + { name: 'expression', target: 'Belief', cardinality: 'one' }, + { name: 'tag', target: 'Topic', cardinality: 'one' }, + ], + }, + { + name: 'Utterance', + identityProperty: 'text', + description: 'A line of transcript — enough of them to make paging visible.', + properties: [ + { name: 'text', type: 'string', required: true }, + { name: 'speaker', type: 'string' }, + ], + relations: [{ name: 'topic', target: 'Topic', cardinality: 'one' }], + }, { name: 'CollectionBlock', identityProperty: 'name', @@ -87,6 +106,10 @@ export const SHAPES: EntityShape[] = [ properties: [ { name: 'name', type: 'string', required: true }, { name: 'kind', type: 'string' }, + // Board positions live on the entity — the inversion that makes a freeform canvas a *mode* of + // this engine rather than a different engine. `manual` layout reads exactly these. + { name: 'x', type: 'number' }, + { name: 'y', type: 'number' }, ], // `children` is deliberately absent from `relations`: it is untyped in WE, which is exactly why // the containment expander has to reach it through the drill-down path instead. @@ -143,6 +166,25 @@ export const TABLES: Record = { author: 'ag-josh', topic: 'to-interp', }, + { + // Deliberately authored by someone who is not in the Agent table. In a peer-to-peer system a + // relation target that has not synced is ordinary, and the engine must render it as a + // placeholder — "not here yet" rather than "nothing there". Nothing else exercises that path. + id: 'be-5', + title: 'Peers will disagree about what was said', + confidence: 'low', + author: 'ag-unsynced', + topic: 'to-sync', + }, + ], + + // Edges with data. Drawn naively these would be four extra dots; the engine collapses each into the + // single relationship it stands for, carrying `relevance` and staying clickable via `reifiedAs`. + SemanticRelationship: [ + { id: 'sr-1', relevance: 0.9, expression: 'be-1', tag: 'to-graph' }, + { id: 'sr-2', relevance: 0.6, expression: 'be-2', tag: 'to-graph' }, + { id: 'sr-3', relevance: 0.95, expression: 'be-2', tag: 'to-interp' }, + { id: 'sr-4', relevance: 0.4, expression: 'be-4', tag: 'to-sync' }, ], Task: [ @@ -186,9 +228,48 @@ export const TABLES: Record = { ], CollectionBlock: [ - { id: 'co-standup', name: 'Standup, 10 Aug', kind: 'call', children: ['tx-1', 'tx-2', 'co-thread'] }, - { id: 'co-thread', name: 'Side thread on layouts', kind: 'call', children: ['tx-3', 'tx-4'] }, - { id: 'co-notes', name: 'Scratch notes', kind: 'notes', children: ['tx-5'] }, + { id: 'co-standup', name: 'Standup, 10 Aug', kind: 'call', children: ['tx-1', 'tx-2', 'co-thread'], x: 120, y: 90 }, + { id: 'co-thread', name: 'Side thread on layouts', kind: 'call', children: ['tx-3', 'tx-4'], x: 460, y: 260 }, + { id: 'co-notes', name: 'Scratch notes', kind: 'notes', children: ['tx-5'], x: 150, y: 420 }, + { id: 'co-board', name: 'Roadmap board', kind: 'board', children: [], x: 520, y: 60 }, + ], + + Utterance: [ + { id: 'ut-01', text: 'The expander is the unit, not the widget.', speaker: 'James', topic: 'to-graph' }, + { + id: 'ut-02', + text: 'Reverse traversal is half of what makes it explorable.', + speaker: 'Nico', + topic: 'to-interp', + }, + { id: 'ut-03', text: 'Collapse has to bundle, or the view lies.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-04', text: 'Warm start, or the map jumps every expansion.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-05', text: 'Hit-testing belongs to the core.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-06', text: 'Placeholders are a first-class state here.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-07', text: 'Paging is not optional on a hub node.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-08', text: 'A budget that truncates silently is worse than none.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-09', text: 'Tree layout wants crossing reduction.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-10', text: 'Community detection gives us cluster maps.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-11', text: 'Metrics stay out of hit-testing.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-12', text: 'One address scheme or the explorer is a rewrite.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-13', text: 'Reified edges must not render as nodes.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-14', text: 'The catalog is what makes plugins reachable.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-15', text: 'Manual layout inverts who owns position.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-16', text: 'Schema maps work in an empty space.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-17', text: 'Degree is a decent proxy for importance.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-18', text: 'Barycentre sweeps beat traversal order.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-19', text: 'Two conventions for one problem is one too many.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-20', text: 'The harness found the bug, which is the point.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-21', text: 'Fit has to survive a zero-sized box.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-22', text: 'Reindex on every path that moves a node.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-23', text: 'Untyped relations need the drill-down path.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-24', text: 'Value nodes converge or they are pointless.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-25', text: 'Seed sources and expanders are the same shape.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-26', text: 'A cluster is a collapsed synthetic node.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-27', text: 'Do not grow JSON toward a language.', speaker: 'Josh', topic: 'to-sync' }, + { id: 'ut-28', text: 'Name the plugin, keep the data declarative.', speaker: 'James', topic: 'to-graph' }, + { id: 'ut-29', text: 'Forward-only relations shape the whole engine.', speaker: 'Nico', topic: 'to-interp' }, + { id: 'ut-30', text: 'Bundles carry a weight so the count survives.', speaker: 'Josh', topic: 'to-sync' }, ], TextBlock: [ @@ -202,3 +283,110 @@ export const TABLES: Record = { /** The dataset id the harness pretends to be scoped to. */ export const DATASET = 'playground'; + +/** + * Which field of a node is worth editing, if any. + * + * The harness needs *a* write path to exercise — a graph that can only be read tells you nothing + * about whether editing a record flows back through seeds and expanders. This picks the identity + * property, which is the one a card actually displays. + */ +export function editableField(node: { type: string; data?: Record }): string | null { + const shape = SHAPES.find((s) => s.name === node.type); + const field = shape?.identityProperty; + return field && node.data && field in node.data ? field : null; +} + +/** + * Write a value back into the fixture. + * + * Mutating the source rows rather than patching the rendered node on purpose: the point is to prove + * the value survives a round trip *through* the seed and expander path, exactly as it would through a + * real data layer. Returns false when the row cannot be found, so the caller can leave the UI alone + * rather than showing a change that did not happen. + */ +export function writeField(node: { type: string; id: string }, field: string, value: string): boolean { + // The graph node's id is an address; the row id is its last segment. + const rowId = decodeURIComponent(node.id.split('/').pop() ?? ''); + const row = (TABLES[node.type] ?? []).find((candidate) => candidate.id === rowId); + if (!row) return false; + row[field] = value; + return true; +} + +/* + Board positions, persisted the way a backend would persist them. + + The manual layout reads each node's position from its own data, which is what makes a board a board + — position is the thing being edited, not something derived. In the playground that was invisible: + the fixture carries no coordinates, so dragging a card moved it until the next reload and the layout + looked like it did nothing. + + Writing them back into the rows, rather than keeping a side-map of positions in the app, is + deliberate. It goes through `onNodeDragEnd` → a write → the query that reads it back, which is the + exact path a real board takes; a playground that stored positions beside the data would demonstrate + persistence while testing none of the wiring that has to work. + + localStorage is the stand-in for the backend, and only for these two fields — everything else about + the fixture stays in memory, so a reload is otherwise a clean slate. +*/ +const POSITION_KEY = 'we-graph-explorer:positions'; + +type StoredPositions = Record; + +function readStored(): StoredPositions { + try { + const raw = localStorage.getItem(POSITION_KEY); + return raw ? (JSON.parse(raw) as StoredPositions) : {}; + } catch { + // A malformed or unavailable store is not worth failing a playground over. + return {}; + } +} + +/** Apply any saved positions onto the rows, so a query reads them back as ordinary fields. */ +export function restorePositions(): void { + const stored = readStored(); + for (const rows of Object.values(TABLES)) { + for (const row of rows) { + const at = stored[String(row.id)]; + if (!at) continue; + row.x = at.x; + row.y = at.y; + } + } +} + +/** Record where a node was dropped, on the row itself and in the store behind it. */ +export function savePosition(nodeId: string, at: { x: number; y: number }): void { + const rowId = decodeURIComponent(nodeId.split('/').pop() ?? ''); + for (const rows of Object.values(TABLES)) { + const row = rows.find((candidate) => String(candidate.id) === rowId); + if (!row) continue; + row.x = Math.round(at.x); + row.y = Math.round(at.y); + const stored = readStored(); + stored[rowId] = { x: row.x as number, y: row.y as number }; + try { + localStorage.setItem(POSITION_KEY, JSON.stringify(stored)); + } catch { + // Full or disabled storage: the drag still stands for this session. + } + return; + } +} + +/** Forget every saved position, so the layout goes back to parking cards in a grid. */ +export function clearPositions(): void { + for (const rows of Object.values(TABLES)) { + for (const row of rows) { + delete row.x; + delete row.y; + } + } + try { + localStorage.removeItem(POSITION_KEY); + } catch { + // Nothing to do; the rows above are already clear for this session. + } +} diff --git a/apps/playgrounds/solid/graph-explorer/src/main.tsx b/apps/playgrounds/solid/graph-explorer/src/main.tsx index b5edffb38..d80ec22af 100644 --- a/apps/playgrounds/solid/graph-explorer/src/main.tsx +++ b/apps/playgrounds/solid/graph-explorer/src/main.tsx @@ -9,21 +9,96 @@ */ import '@we/primitives'; import '@we/tokens/css'; +// Every theme, switched by the `data-we-theme` attribute below — the same mechanism the app uses. +import '@we/themes'; import '@we/graph-solid/styles'; import './styles.css'; -import { GraphView } from '@we/graph-solid'; -import { createMemo, createSignal, For, Show } from 'solid-js'; +import { Column, Row } from '@we/components/solid'; +import type { GraphNode } from '@we/graph-protocol'; +import { type GraphHostBindings, GraphView, type GraphViewProps } from '@we/graph-solid'; +import { applyThemeVars } from '@we/schema-shared'; +import { THEME_PRESETS, type ThemeName } from '@we/themes/presets'; +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; import { render } from 'solid-js/web'; +import { clearPositions, editableField, restorePositions, savePosition, writeField } from './fixture'; import { createHost, type QueryLog } from './host'; -import { LAYOUTS, type Scenario, SCENARIOS } from './scenarios'; +import { CURVES, LAYOUTS, type Scenario, SCENARIOS } from './scenarios'; + +/** + * The graph, wrapped so its remount key has somewhere to live. + * + * `Show keyed` hands the key to its child and `GraphView` has no use for it, so rather than smuggle + * it past the types this makes it a real prop. The wrapper also keeps the scenario switch honest: a + * new key is a genuine remount, which is what a template does when a `$if` swaps one graph for + * another, and the path a leaked engine would show up on. + */ +function Graph(props: { + remountKey: { scenario: string; version: number }; + spec: GraphViewProps; + host: GraphHostBindings; + onNodeClick: (node: GraphNode) => void; + onChanged: () => void; + onNodeDragEnd: (payload: { id: string; x: number; y: number }) => void; +}) { + void props.remountKey; + return ( + + ); +} + +restorePositions(); function App() { const [scenarioId, setScenarioId] = createSignal(SCENARIOS[0].id); const [layoutOverride, setLayoutOverride] = createSignal(null); - const [selected, setSelected] = createSignal<{ type: string; label?: string; kind: string } | null>(null); + const [curveOverride, setCurveOverride] = createSignal<(typeof CURVES)[number] | null>(null); + /* + Live force tuning, and deliberately here rather than in the graph's own chrome. + + `distance`, `charge` and `collide` are already authorable — they are the force layout's options — + so what is missing is not a way to express them but a way to *find* the numbers worth writing + down. That is a playground's job. Putting sliders in the engine's chrome would make every graph + ship a tuning panel for a decision its author already made, and hand a reader controls over + something they have no reason to have an opinion about. + */ + const [force, setForce] = createSignal({ distance: 90, charge: -220, collide: 28 }); + const [selected, setSelected] = createSignal(null); + /** Bumped after a fixture edit, to force the graph to re-seed and pick the new value up. */ + const [dataVersion, setDataVersion] = createSignal(0); const [log, setLog] = createSignal([]); + const [theme, setTheme] = createSignal('light'); + + /** + * Applying a theme is two things, and the first attempt here did only the second. + * + * A theme is a *parameter set* — hue, saturation, and a multiplier that inverts the lightness ramp + * — written onto the root as custom properties. That is what actually recolours anything. The + * `data-we-theme` attribute drives the handful of rules that cannot be parametric (a modal shadow, + * a tooltip inversion), so setting it alone changes almost nothing, which is exactly what the first + * version of this toggle did. + * + * Both halves come from shared code rather than from numbers copied into the harness: the presets + * are the design system's, and `applyThemeVars` is the same function the app uses. + * + * Worth having beyond convenience — the graph paints tokens rather than colours, and flipping the + * theme is the fastest way to catch anything that does not. + */ + createEffect(() => { + const name = theme(); + document.documentElement.setAttribute('data-we-theme', name); + applyThemeVars(document.documentElement, THEME_PRESETS[name].parameters); + }); // A live log of what the graph actually asked the data layer for. Worth having in front of you: // "one expansion, four queries" is the kind of thing that is obvious here and invisible in an app. @@ -33,10 +108,32 @@ function App() { const scenario = createMemo(() => SCENARIOS.find((s) => s.id === scenarioId()) ?? SCENARIOS[0]); - /** The scenario's spec, with the layout picker applied over it. */ + /** + * Identity of the graph currently mounted. + * + * An object rather than a string because `Show keyed` compares by reference, and a memo only mints + * a new one when the scenario or the data actually changed — which is precisely when the graph + * should be rebuilt from scratch. + */ + const graphKey = createMemo(() => ({ scenario: scenarioId(), version: dataVersion() })); + + /** The scenario's spec, with the pickers applied over it. */ const specFor = (current: Scenario) => { - const override = layoutOverride(); - return override ? { ...current.spec, layout: { type: override } } : current.spec; + const layout = layoutOverride(); + const curve = curveOverride(); + let spec = current.spec; + if (layout) spec = { ...spec, layout: { type: layout } }; + // Tuning applies wherever the graph is actually running a force layout, whether that came from + // the scenario or from the picker above. + if ((spec.layout as { type?: string } | undefined)?.type === 'force') { + spec = { ...spec, layout: { type: 'force', options: force() } }; + } + if (curve) { + // Appended rather than replacing, so a scenario's own edge rules still decide colour, width and + // labels — the picker is overriding one property, not the styling. + spec = { ...spec, edgeStyle: [...(spec.edgeStyle ?? []), { style: { curve } }] }; + } + return spec; }; function pick(id: string) { @@ -44,107 +141,323 @@ function App() { setLog([]); setSelected(null); setLayoutOverride(null); + setCurveOverride(null); setScenarioId(id); } return ( -
- + + Drag the background to pan · wheel to zoom · double-click a node to expand or collapse it + + -
+ {/* Keyed on the scenario so switching is a genuine remount — the same thing a template does when a `$if` swaps one graph for another, and the path a leaked engine would show up on. */} - - {(current) => ( - + {(remountKey) => ( + { - setSelected({ kind: node.kind, type: node.type, label: node.label }); + setSelected(node); + flushLog(); + }} + onChanged={flushLog} + /* + The persistence path a board actually takes: the drop is written to the record, and + the next query reads it back as an ordinary field. Nothing here keeps a side-map of + positions — a playground that did would demonstrate persistence while testing none of + the wiring that has to work. + */ + onNodeDragEnd={(at) => { + savePosition(at.id, at); flushLog(); }} - onSelectionChange={() => flushLog()} - onNodeDragEnd={() => flushLog()} /> )} -
-
+ + ); } diff --git a/apps/playgrounds/solid/graph-explorer/src/render.test.tsx b/apps/playgrounds/solid/graph-explorer/src/render.test.tsx index e0b04e222..7f313b59e 100644 --- a/apps/playgrounds/solid/graph-explorer/src/render.test.tsx +++ b/apps/playgrounds/solid/graph-explorer/src/render.test.tsx @@ -10,10 +10,11 @@ * both. Anything about *where* things are drawn belongs to the layout tests. */ import { GraphView } from '@we/graph-solid'; +import { createSignal } from 'solid-js'; import { render } from 'solid-js/web'; import { afterEach, describe, expect, it } from 'vitest'; -import { createHost } from './host'; +import { createHost, type QueryLog } from './host'; import { SCENARIOS } from './scenarios'; const host = createHost(); @@ -32,6 +33,17 @@ function mount(spec: Record) { return container; } +/** + * Mount with a spec that is rebuilt whenever a signal changes — how a host with its own controls + * behaves, as opposed to the fixed object the other tests hand over. + */ +function mountReactive(build: () => Record, bindings = host) { + const container = document.createElement('div'); + document.body.appendChild(container); + dispose = render(() => , container); + return container; +} + /** Expansion and layout are async; a few macrotask turns is enough for this fixture. */ async function settle(turns = 6) { for (let i = 0; i < turns; i += 1) await new Promise((resolve) => setTimeout(resolve, 0)); @@ -60,12 +72,23 @@ describe('the graph paints', () => { expect(container.textContent).toContain('Publish'); }); + it('draws edge labels as DOM, so they track the camera like every other piece of text', async () => { + // They were SVG `` and jittered for seconds after a zoom while the lines moved cleanly. + const container = mount(scenario('static')); + await settle(); + + const labels = container.querySelectorAll('.we-graph__edge-label'); + expect(labels.length).toBeGreaterThan(0); + expect(container.querySelectorAll('.we-graph__edges text')).toHaveLength(0); + expect([...labels].map((el) => el.textContent)).toContain('approve'); + }); + it('maps the dataset schema, one node per entity type', async () => { const container = mount(scenario('schema')); await settle(); - // Seven shapes in the fixture. - expect(nodes(container)).toHaveLength(7); + // One node per shape the fixture declares. + expect(nodes(container)).toHaveLength(9); expect(container.textContent).toContain('Belief'); expect(container.textContent).toContain('CollectionBlock'); }); @@ -116,6 +139,54 @@ describe('the graph paints', () => { expect(container.textContent).toContain('Node limit reached'); }); + it('draws its chrome as a sibling of the canvas, not on top of its handlers', async () => { + // Gestures are handled on a dedicated surface, so chrome is an ordinary sibling that the canvas + // never hears about. Previously the handlers sat on the common ancestor and every overlay had to + // be marked so the canvas would ignore it — a new overlay that forgot silently broke the canvas. + const container = mount(scenario('static')); + await settle(); + + expect(container.querySelector('.we-graph__surface')).not.toBeNull(); + expect(container.querySelectorAll('.we-graph__surface we-button')).toHaveLength(0); + expect(container.querySelectorAll('we-button').length).toBeGreaterThanOrEqual(3); + }); + + it('honours a request for no chrome at all', async () => { + const container = mount({ ...scenario('static'), controls: [] }); + await settle(); + + expect(container.querySelectorAll('we-button')).toHaveLength(0); + }); + + it('never lets a drawing layer stand between the pointer and the canvas', async () => { + // The transformed layer is viewport-sized and the camera moves it, so with default + // `pointer-events` it silently covers whichever region it has been translated over — which showed + // up as a dead quadrant of the canvas once gestures moved off the root onto their own surface. + const container = mount(scenario('static')); + await settle(); + + const layer = container.querySelector('.we-graph__layer') as HTMLElement | null; + expect(layer).not.toBeNull(); + expect(layer!.style.pointerEvents).toBe('none'); + + // And everything it contains, so nothing inside can reintroduce the problem either. + for (const selector of ['.we-graph__edges', '.we-graph__node']) { + const child = container.querySelector(selector); + expect(child, selector).not.toBeNull(); + } + }); + + it('leaves edge picking to the engine rather than the DOM', async () => { + // `pointer-events: stroke` on a path was the one thing the DOM still picked, which broke + // behaviours on any non-DOM surface. + const container = mount(scenario('static')); + await settle(); + + const path = container.querySelector('.we-graph__edges path'); + expect(path).not.toBeNull(); + expect(path?.getAttribute('onClick')).toBeNull(); + }); + it('shows an empty state rather than a blank canvas', async () => { const container = mount({ seeds: { source: 'query', options: { entity: 'Nonexistent' } }, @@ -126,4 +197,38 @@ describe('the graph paints', () => { expect(nodes(container)).toHaveLength(0); expect(container.textContent).toContain('Nothing to show yet'); }); + + /* + A host rebuilding its spec object must not restart the graph. + + Any control outside the graph — an edge-shape picker, a colour toggle — hands over a fresh spec, + and reading `seeds` from it re-runs whatever computed it. When the reload effect tracked that + rather than comparing values, switching edge shape called `start()` and threw away every node + position, which presents as the layout randomly resetting and gives no hint that a style control + caused it. + */ + it('keeps its nodes when a host rebuilds the spec without changing what the graph is', async () => { + /* + Asserted on the query log rather than on where the nodes ended up. + + Position is the symptom but it is a poor probe: several of these scenarios lay out + deterministically, so a restart puts everything back exactly where it was and the test passes + whether or not the graph was destroyed and rebuilt. Queries do not lie — `start()` clears the + store and re-seeds, so a restart is visible as the seed query running a second time. + */ + const log: QueryLog = { entries: [] }; + const spec = scenario('knowledge'); + const [curve, setCurve] = createSignal('smooth'); + const container = mountReactive(() => ({ ...spec, edgeStyle: [{ style: { curve: curve() } }] }), createHost(log)); + await settle(); + + expect([...nodes(container)].length).toBeGreaterThan(0); + const queriesAfterLoad = log.entries.length; + expect(queriesAfterLoad).toBeGreaterThan(0); + + setCurve('step'); + await settle(); + + expect(log.entries.length).toBe(queriesAfterLoad); + }); }); diff --git a/apps/playgrounds/solid/graph-explorer/src/scenarios.ts b/apps/playgrounds/solid/graph-explorer/src/scenarios.ts index 689c8ad78..d9674e83f 100644 --- a/apps/playgrounds/solid/graph-explorer/src/scenarios.ts +++ b/apps/playgrounds/solid/graph-explorer/src/scenarios.ts @@ -5,7 +5,7 @@ * here is what an author gets. Between them they cover every extension axis: seeds, expanders, * layouts, styling, behaviours. */ -import type { GraphSpec } from '@we/graph-protocol'; +import type { GraphSpec, NodeStyleRules } from '@we/graph-protocol'; export interface Scenario { id: string; @@ -15,6 +15,24 @@ export interface Scenario { spec: GraphSpec; } +/** + * One palette, shared. + * + * These were copy-pasted across four scenarios, which is how `Belief` ends up two different purples. + * A base rule plus per-type overrides, spread into whichever scenario needs it. + */ +const PALETTE: NodeStyleRules = [ + { style: { size: 12, color: 'neutral-400' } }, + { when: { type: 'Belief' }, style: { size: 20, color: 'primary-500' } }, + { when: { type: 'Agent' }, style: { size: 18, color: 'success-500' } }, + { when: { type: 'Topic' }, style: { size: 22, color: 'warning-500', shape: 'rect' } }, + { when: { type: 'Task' }, style: { size: 18, color: 'danger-500' } }, + { when: { type: 'Question' }, style: { size: 16, color: 'success-600' } }, + { when: { type: 'Utterance' }, style: { size: 10, color: 'neutral-500' } }, + // Last, so "not here yet" wins over whatever the type would otherwise paint. + { when: { unresolved: true }, style: { color: 'neutral-200' } }, +]; + export const SCENARIOS: Scenario[] = [ { id: 'static', @@ -44,6 +62,9 @@ export const SCENARIOS: Scenario[] = [ // review↔revise are mutual: they must bow apart, not draw as one line. edgeStyle: [{ style: { showLabel: true, arrow: 'target' } }], behaviours: ['pan-zoom', 'select', { type: 'drag-node', options: { pin: true } }], + // `pin` as well as `lock`, because dragging here pins: without a release, arranging the diagram + // would leave a held node behind on every card you touched and no way to undo it. + controls: ['zoom-in', 'zoom-out', 'fit', 'pin', 'lock'], }, }, @@ -55,6 +76,9 @@ export const SCENARIOS: Scenario[] = [ seeds: { source: 'schema' }, expansion: { defaultDepth: 0, limit: 20 }, layout: { type: 'force', options: { distance: 170, charge: -320 } }, + // `pin` belongs on a derived layout: select the node the map is really about, hold it, and let + // the simulation arrange everything else around it. + controls: ['zoom-in', 'zoom-out', 'fit', 'pin'], nodeStyle: [ { style: { shape: 'rect', size: 20, color: 'primary-500' } }, { when: { 'data.relations': 0 }, style: { color: 'neutral-400', size: 14 } }, @@ -73,19 +97,12 @@ export const SCENARIOS: Scenario[] = [ { id: 'knowledge', label: 'Knowledge map', - note: 'Beliefs one hop out. Double-click any node to expand it further; double-click again to collapse. Watch the author nodes converge — two beliefs by one person reach the same node.', + note: 'Beliefs with their author and topic drawn straight from the seed — no expansion yet. Double-click to open a node further, again to collapse. Two beliefs by one person converge on one author node, and the fifth belief cites an author who has not synced: it renders as a dashed placeholder, which is "not here yet", not "nothing there".', spec: { seeds: { source: 'query', options: { entity: 'Belief', limit: 20, relations: ['author', 'topic'] } }, expansion: { defaultDepth: 0, direction: 'both', limit: 25, maxNodes: 300 }, layout: { type: 'force' }, - nodeStyle: [ - { style: { size: 12, color: 'neutral-400' } }, - { when: { type: 'Belief' }, style: { size: 20, color: 'primary-500' } }, - { when: { type: 'Agent' }, style: { size: 18, color: 'success-500' } }, - { when: { type: 'Topic' }, style: { size: 22, color: 'warning-500', shape: 'rect' } }, - { when: { type: 'Task' }, style: { color: 'danger-500' } }, - { when: { unresolved: true }, style: { color: 'neutral-200' } }, - ], + nodeStyle: PALETTE, edgeStyle: [{ style: { curve: 'bezier', arrow: 'target', showLabel: true } }], behaviours: ['pan-zoom', 'select', 'expand-on-double-click', { type: 'drag-node' }], }, @@ -99,13 +116,7 @@ export const SCENARIOS: Scenario[] = [ seeds: { source: 'query', options: { entity: 'Topic', limit: 10 } }, expansion: { defaultDepth: 1, direction: 'in', limit: 25 }, layout: { type: 'radial', options: { ringGap: 190 } }, - nodeStyle: [ - { style: { size: 12, color: 'neutral-400' } }, - { when: { type: 'Topic' }, style: { size: 26, color: 'warning-500' } }, - { when: { type: 'Belief' }, style: { color: 'primary-500' } }, - { when: { type: 'Task' }, style: { color: 'danger-500' } }, - { when: { type: 'Question' }, style: { color: 'success-500' } }, - ], + nodeStyle: [...PALETTE, { when: { type: 'Topic' }, style: { size: 26 } }], edgeStyle: [{ style: { arrow: 'target', showLabel: true } }], behaviours: ['pan-zoom', 'select', 'expand-on-double-click'], }, @@ -161,6 +172,102 @@ export const SCENARIOS: Scenario[] = [ behaviours: ['pan-zoom', 'select'], }, }, + + { + id: 'reified', + label: 'Edges with data', + note: 'SemanticRelationship is an entity whose two relations name what it connects. Drawn naively each one is an extra dot; here each collapses into the single edge it stands for, thicker where relevance is higher. Click an edge — it still knows the record it came from.', + spec: { + seeds: [ + { source: 'query', options: { entity: 'SemanticRelationship', limit: 20 } }, + { source: 'query', options: { entity: 'Topic', limit: 10 } }, + ], + expansion: { defaultDepth: 0, direction: 'both' }, + layout: { type: 'force', options: { distance: 150 } }, + nodeStyle: PALETTE, + edgeStyle: [ + { style: { curve: 'bezier', arrow: 'target', color: 'neutral-300' } }, + { when: { type: 'tagged' }, style: { showLabel: false, color: 'primary-400', width: 2 } }, + { when: { 'data.relevance': { gt: 0.8 } }, style: { color: 'primary-600', width: 4 } }, + ], + behaviours: ['pan-zoom', 'select', 'expand-on-double-click'], + }, + }, + + { + id: 'clusters', + label: 'Cluster map', + note: 'Colour and size are computed, not declared: community detection groups the graph and degree sizes it. Both are metric plugins named from JSON — the escape hatch for anything the rule vocabulary cannot express.', + spec: { + seeds: { source: 'query', options: { entity: 'Utterance', limit: 30, relations: ['topic'] } }, + expansion: { defaultDepth: 1, direction: 'in', limit: 30, maxNodes: 200 }, + layout: { type: 'force', options: { distance: 60, charge: -140, collide: 16 } }, + nodeStyle: [ + { + style: { + size: { metric: 'degree', range: [8, 30] }, + color: { metric: 'community', scale: 'categorical' }, + }, + }, + ], + edgeStyle: [{ style: { color: 'neutral-200', arrow: 'none' } }], + behaviours: ['pan-zoom', 'select', 'expand-on-double-click'], + }, + }, + + { + id: 'paging', + label: 'Paging a hub', + note: 'Thirty utterances behind a page size of eight. The type node shows a "+" while more remain; double-click repeatedly to pull the next page, and watch the query log. A node that has given everything stops responding — an expansion that silently repeats itself looks identical to one that is broken.', + spec: { + seeds: { source: 'schema', options: { entities: ['Utterance', 'Topic', 'Belief'] } }, + expansion: { defaultDepth: 0, limit: 8, maxNodes: 400 }, + layout: { type: 'force', options: { distance: 90 } }, + nodeStyle: [ + { style: { shape: 'rect', size: 22, color: 'primary-500' } }, + { when: { type: { not: '$schema' } }, style: { shape: 'circle', size: 9, color: 'neutral-400' } }, + ], + edgeStyle: [{ style: { color: 'neutral-200', arrow: 'none' } }], + behaviours: ['pan-zoom', 'select', 'expand-on-double-click'], + }, + }, + + { + id: 'board', + label: 'Board (manual)', + note: 'Post-it cards, positioned by their own x/y rather than by a layout — position is the data here. Drag one and it stays put; a real board would persist the drop via onNodeDragEnd. Select a card to read and edit it in the inspector. Inline editing is deliberately absent: text editing on a transformed canvas is the board project, not a flag.', + spec: { + seeds: { source: 'query', options: { entity: 'CollectionBlock', limit: 10 } }, + expansion: { defaultDepth: 0, expanders: ['collection'] }, + layout: { type: 'manual' }, + nodeStyle: [ + // A card carries its text inside the box — the node *is* the content, rather than a mark with + // a caption. `size` stops meaning radius here and the width/height take over. + { style: { shape: 'card', width: 170, color: 'primary-100', labelColor: 'primary-900' } }, + { when: { 'data.kind': 'call' }, style: { color: 'success-100', labelColor: 'success-900' } }, + { when: { 'data.kind': 'notes' }, style: { color: 'warning-100', labelColor: 'warning-900' } }, + { when: { 'data.kind': 'board' }, style: { color: 'danger-100', labelColor: 'danger-900' } }, + // Children opened out of a card stay small marks, so the two levels read differently. + { when: { type: { not: 'CollectionBlock' } }, style: { shape: 'circle', size: 10, color: 'neutral-400' } }, + ], + edgeStyle: [{ style: { curve: 'orthogonal', color: 'neutral-300', arrow: 'none' } }], + // `pin: true` is what separates a board from an explorer: a dropped node stays dropped rather + // than being reclaimed by the layout on the next change. + behaviours: ['pan-zoom', 'select', { type: 'drag-node', options: { pin: true } }], + // And `lock` rather than `pin`: every card is placed already, so there is nothing to hold, and + // the risk worth guarding against is rearranging somebody else's board by accident. + controls: ['zoom-in', 'zoom-out', 'fit', 'lock'], + }, + }, ]; -export const LAYOUTS = ['force', 'tree', 'radial', 'grid'] as const; +export const LAYOUTS = ['force', 'tree', 'radial', 'grid', 'manual'] as const; + +/** + * The edge shapes, for the picker. + * + * Worth being able to flip between on a live graph rather than choosing from a description: which one + * reads best depends on how dense the graph is and how much the layout is already saying, and that is + * not a judgement anybody makes correctly from a name. + */ +export const CURVES = ['arc', 'straight', 'smooth', 'step'] as const; diff --git a/apps/playgrounds/solid/graph-explorer/src/solid-elements.d.ts b/apps/playgrounds/solid/graph-explorer/src/solid-elements.d.ts new file mode 100644 index 000000000..170f7e0a0 --- /dev/null +++ b/apps/playgrounds/solid/graph-explorer/src/solid-elements.d.ts @@ -0,0 +1,3 @@ +// Typed `we-*` JSX intrinsics — see the note in @we/app-shell's copy for why this is a file rather +// than a tsconfig `types` entry. +import '@we/primitives/solid/types'; diff --git a/apps/playgrounds/solid/graph-explorer/src/styles.css b/apps/playgrounds/solid/graph-explorer/src/styles.css index f73beb3f7..8eafba502 100644 --- a/apps/playgrounds/solid/graph-explorer/src/styles.css +++ b/apps/playgrounds/solid/graph-explorer/src/styles.css @@ -1,4 +1,10 @@ -/* Harness chrome only — the graph brings its own styles from @we/graph-solid/styles. */ +/* + Almost nothing. + + Layout is `Column`/`Row` with design-system props, and everything with an appearance is a primitive, + so what is left is the page reset and the one list that has no component equivalent. + The graph brings its own styles from @we/graph-solid/styles. +*/ * { box-sizing: border-box; @@ -11,149 +17,16 @@ body { background: var(--we-color-neutral-0); } -.app { - display: grid; - grid-template-columns: 280px 1fr; - height: 100vh; -} - -.rail { - display: flex; - flex-direction: column; - gap: 20px; - padding: 20px; - overflow-y: auto; - border-right: 1px solid var(--we-color-neutral-200); - background: var(--we-color-neutral-0); -} - -.rail h1 { - margin: 0; - font-size: 18px; -} - -.sub { - margin: -14px 0 0; - font-size: 12px; - color: var(--we-color-neutral-500); -} - -.group { - display: flex; - flex-direction: column; - gap: 6px; -} - -.grow { - flex: 1; - min-height: 0; -} - -.label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--we-color-neutral-500); -} - -.item { - padding: 8px 10px; - text-align: left; - border: 1px solid transparent; - border-radius: 6px; - background: transparent; - color: var(--we-color-neutral-800); - font-size: 14px; - cursor: pointer; -} - -.item:hover { - background: var(--we-color-neutral-100); -} - -.item--active { - background: var(--we-color-primary-100); - border-color: var(--we-color-primary-300); - color: var(--we-color-primary-900); -} - -.note { - margin: 0; - padding: 10px; - border-radius: 6px; - background: var(--we-color-neutral-100); - font-size: 12px; - line-height: 1.5; - color: var(--we-color-neutral-700); -} - -.row { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.chip { - padding: 4px 8px; - border: 1px solid var(--we-color-neutral-200); - border-radius: 999px; - background: transparent; - font-size: 12px; - cursor: pointer; -} - -.chip--active { - background: var(--we-color-primary-500); - border-color: var(--we-color-primary-500); - color: #fff; -} - -.selected { - padding: 8px 10px; - border-radius: 6px; - background: var(--we-color-neutral-100); - font-size: 12px; - line-height: 1.6; - overflow-wrap: anywhere; -} - .log { margin: 0; padding: 0; list-style: none; - font-size: 11px; - line-height: 1.7; overflow-y: auto; } .log li { display: flex; - gap: 6px; + align-items: center; + gap: var(--we-space-100); justify-content: space-between; - color: var(--we-color-neutral-600); -} - -.log span { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.empty { - margin: 0; - font-size: 12px; - color: var(--we-color-neutral-400); -} - -.hint { - margin: 0; - font-size: 11px; - line-height: 1.6; - color: var(--we-color-neutral-500); -} - -.stage { - position: relative; - overflow: hidden; } diff --git a/apps/playgrounds/solid/graph-explorer/tsconfig.json b/apps/playgrounds/solid/graph-explorer/tsconfig.json index 945ef51a4..dc609967a 100644 --- a/apps/playgrounds/solid/graph-explorer/tsconfig.json +++ b/apps/playgrounds/solid/graph-explorer/tsconfig.json @@ -5,12 +5,17 @@ "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "solid-js", - "types": ["vite/client"], + "types": [ + "vite/client" + ], "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true, "esModuleInterop": true }, - "include": ["src", "vite.config.ts"] + "include": [ + "src", + "vite.config.ts" + ] } diff --git a/apps/playgrounds/solid/graph-explorer/vite.config.ts b/apps/playgrounds/solid/graph-explorer/vite.config.ts index e59a243eb..b24dcf669 100644 --- a/apps/playgrounds/solid/graph-explorer/vite.config.ts +++ b/apps/playgrounds/solid/graph-explorer/vite.config.ts @@ -6,6 +6,18 @@ export default defineConfig({ // One solid-js instance across app and libraries. Load-bearing: two instances give two owner // graphs and effects silently stop updating, which looks correct on first paint. resolve: { dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'] }, + /* + Never pre-bundle the WE packages. + + They resolve to `dist/`, so Vite treats them as ordinary dependencies and caches an optimised copy + under node_modules/.vite. That copy does not invalidate when the package is rebuilt, so after a + `pnpm build` the dev server keeps serving the previous design system — silently, and indefinitely. + It cost a full round of testing here: a fix was verified present in dist and still absent from the + page, because the page was running a bundle from before it. + */ + optimizeDeps: { + exclude: ['@we/primitives', '@we/tokens', '@we/themes', '@we/schema-shared', '@we/design-utils'], + }, server: { port: 3300, fs: { allow: ['../../../..'] }, diff --git a/apps/playgrounds/solid/portable-ui-slice/package.json b/apps/playgrounds/solid/portable-ui-slice/package.json index 431e61a38..d1e2e503c 100644 --- a/apps/playgrounds/solid/portable-ui-slice/package.json +++ b/apps/playgrounds/solid/portable-ui-slice/package.json @@ -11,14 +11,14 @@ "test": "vitest run" }, "dependencies": { + "@we/backend-inmemory": "workspace:*", "@we/components": "workspace:*", + "@we/editor": "workspace:*", "@we/primitives": "workspace:*", "@we/schema-shared": "workspace:*", "@we/schema-solid": "workspace:*", "@we/tokens": "workspace:*", - "solid-js": "^1.9.5", - "@we/backend-inmemory": "workspace:*", - "@we/editor": "workspace:*" + "solid-js": "^1.9.5" }, "devDependencies": { "@solidjs/testing-library": "^0.8.10", diff --git a/apps/playgrounds/solid/portable-ui-slice/src/main.tsx b/apps/playgrounds/solid/portable-ui-slice/src/main.tsx index f9a25d894..42edc4130 100644 --- a/apps/playgrounds/solid/portable-ui-slice/src/main.tsx +++ b/apps/playgrounds/solid/portable-ui-slice/src/main.tsx @@ -6,7 +6,8 @@ import '@we/primitives'; // side-effect: defines all we-* custom elements import '@we/tokens/css'; // design-token CSS variables -import { createInMemoryBackend, type Row } from '@we/backend-inmemory'; +import { createInMemoryBackend, type Row as BackendRow } from '@we/backend-inmemory'; +import { Row } from '@we/components/solid'; import { mountTemplateEditor } from '@we/editor'; import type { TemplateSchema } from '@we/schema-shared'; import { RenderSchema } from '@we/schema-solid'; @@ -40,7 +41,7 @@ let n = 0; function addPost() { n += 1; backend.mutate((tables) => { - (tables.Post as Row[]).push({ + (tables.Post as BackendRow[]).push({ id: `new-${n}`, title: `Graph update #${n}`, content: 'Added at runtime — the live subscription re-rendered this.', @@ -77,21 +78,14 @@ function App() { return (
- + {/* A `we-button` rather than a styled `
); diff --git a/apps/playgrounds/solid/portable-ui-slice/src/solid-elements.d.ts b/apps/playgrounds/solid/portable-ui-slice/src/solid-elements.d.ts new file mode 100644 index 000000000..170f7e0a0 --- /dev/null +++ b/apps/playgrounds/solid/portable-ui-slice/src/solid-elements.d.ts @@ -0,0 +1,3 @@ +// Typed `we-*` JSX intrinsics — see the note in @we/app-shell's copy for why this is a file rather +// than a tsconfig `types` entry. +import '@we/primitives/solid/types'; diff --git a/apps/playgrounds/solid/portable-ui-slice/tsconfig.json b/apps/playgrounds/solid/portable-ui-slice/tsconfig.json index 34def9f69..dc609967a 100644 --- a/apps/playgrounds/solid/portable-ui-slice/tsconfig.json +++ b/apps/playgrounds/solid/portable-ui-slice/tsconfig.json @@ -6,7 +6,6 @@ "jsx": "preserve", "jsxImportSource": "solid-js", "types": [ - "@we/primitives/solid", "vite/client" ], "strict": true, diff --git a/apps/playgrounds/vanilla/flicker-probe/README.md b/apps/playgrounds/vanilla/flicker-probe/README.md new file mode 100644 index 000000000..276fd0b2d --- /dev/null +++ b/apps/playgrounds/vanilla/flicker-probe/README.md @@ -0,0 +1,149 @@ +# Hover flicker probe + +```sh +pnpm --filter @we/playground-flicker-probe dev # http://localhost:3310 +``` + +Sweep your pointer down the columns. **The instant you see a flicker, press space.** + +--- + +## Why this exists + +Four rounds of instrumentation in the graph explorer all came back clean — zero dropped frames, zero +long tasks, transition sequences that read as textbook — while the flicker stayed plainly visible. +That is not a hard bug, it is the wrong instrument, in two ways. + +**It listened to transition events.** A `transitionrun`/`cancel` pair describes an animation the +browser agreed to run. A flicker can be a single frame painted the wrong colour with no animation +involved: a style recalculation landing between frames, an element repainting at its base state for +one tick, a rule briefly losing the cascade. None of those emit anything. The instrument was +structurally incapable of seeing the class of bug it was pointed at, so its silence meant nothing. + +**It ran in one app.** Every measurement so far came from the graph explorer, which is Solid, plus +the design system, plus a scroll rail, plus a resizing panel. Nothing separated those, so "the design +system has a hover bug" was never actually established — only assumed. + +This page fixes both. + +## How to read it + +Six columns, identical in size, spacing and layout. They differ in exactly one thing each: + +| Column | What it adds | What it means if the flicker is here | +|---|---|---| +| `we-button` ghost | Lit, shadow DOM, DS state rules | The explorer's scenario buttons exactly — the bug is in the design system | +| `we-button` + `hoverProps` | The hover colour as a DS prop rather than from the variant | The DS prop path specifically, not the variant styling | +| plain ` - - - + {/* + Chrome is design-system, canvas is not — and the line is drawn on cost, not taste. + + Everything below is ordinary UI that appears once, so it is `Column`/`Row` with design-system + props and primitives inside: the theme reaches it, and there is no stylesheet to keep in sync. + + The canvas above is not. `we-graph__layer` is re-transformed every frame, and `we-graph__node` + exists once per node — at a two-thousand-node budget that is two thousand component instances + wrapping two thousand divs, on the hottest path in the system. They also have to survive a + canvas renderer that has no elements at all. So they stay raw, and the SCSS that remains is + exactly that: the canvas, plus where these overlays sit. + */} + 0}> + + + {(control) => { + /* + Recomputed against the live scene rather than captured once. + + A toggle has to redraw when what it reflects changes, and what it reflects is engine + state — the selection for `pin`, the lock for `lock`. Reading it through the same + signals everything else here depends on is what makes the button follow a selection + made by clicking a node, rather than only by pressing the button itself. + */ + const state = createMemo(() => { + version(); + statusVersion(); + const ctx = controlContext(); + return { + active: control.active?.(ctx) ?? false, + enabled: control.enabled?.(ctx) ?? true, + }; + }); + return ( + control.run(controlContext())} + > + + + ); + }} + + -
+ - Loading… + + + + Loading… + + - - Node limit reached — collapse something to keep exploring - + Node limit reached — collapse something to keep exploring - - {(warning) => {warning}} - -
+ {(warning) => {warning}} +
-
Nothing to show yet.
+ {/* + Covers the whole canvas, so it must not intercept anything — an empty graph is still one you + can pan and drop things onto. + */} + + + + Nothing to show yet. + +
); } -/** Zoom about the centre of the surface — what a button press means, as opposed to a wheel. */ -function zoomBy(engine: GraphEngine, factor: number): void { - const { width, height } = engine.viewport.get(); - engine.behaviourContext().zoomAt({ x: width / 2, y: height / 2 }, factor); -} - -/** Metrics are computed on demand by the algorithms package; nothing here requests them yet. */ -const EMPTY_METRICS = new Map>(); - export type { GraphNode }; diff --git a/packages/graph-system/frameworks/solid/src/GraphView.types.ts b/packages/graph-system/frameworks/solid/src/GraphView.types.ts index efa2a9df4..50da005ad 100644 --- a/packages/graph-system/frameworks/solid/src/GraphView.types.ts +++ b/packages/graph-system/frameworks/solid/src/GraphView.types.ts @@ -48,6 +48,17 @@ export interface GraphViewProps { edgeStyle?: EdgeStyleRules; /** Interactions to enable, by registered id. Defaults to pan-zoom, select and expand-on-double-click. */ behaviours?: BehaviourSpec[]; + /** + * Entity types that are really *edges*, keyed by name — `{ SemanticRelationship: { source, target } }`. + * + * Some relationships carry data and are modelled as entities; drawn naively each becomes a node, so + * a map of tagged messages shows three times as many dots and no relationships. Declaring one here + * collapses each instance into the edge it stands for. Defaults to the shapes AD4M's interpretation + * work and Flux already produce; pass `{}` to switch it off. + * + * Read once when the graph mounts, since expanders are constructed with it. + */ + reified?: Record; width?: string; height?: string; @@ -55,8 +66,15 @@ export interface GraphViewProps { bg?: string; /** Show the loading/paging/warning strip. Defaults to true. */ showStatus?: boolean; - /** Show the controls (zoom, fit, re-layout). Defaults to true. */ + /** Show the controls. Defaults to true. Superseded by `controls`, which names them individually. */ showControls?: boolean; + /** + * Which chrome buttons to draw, by registered id — `zoom-in`, `zoom-out`, `fit`, `relayout`. + * + * Omit for the sensible set; pass `[]` for a graph with no chrome, which is what an embedded + * thumbnail wants. A module contributing its own control makes it nameable here. + */ + controls?: string[]; onNodeClick?: (node: GraphNode) => void; onNodeDoubleClick?: (node: GraphNode) => void; diff --git a/packages/graph-system/frameworks/solid/src/geometry.test.ts b/packages/graph-system/frameworks/solid/src/geometry.test.ts deleted file mode 100644 index 4a399d1b3..000000000 --- a/packages/graph-system/frameworks/solid/src/geometry.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Edge geometry tests. - * - * Both behaviours here are the difference between a graph that looks drawn and one that looks - * emitted: an arrowhead buried under the node it points at, and two mutual edges rendered exactly on - * top of each other so the graph understates its own connectivity. - */ -import { describe, expect, it } from 'vitest'; - -import { bowOffsets, edgePath, groupByEndpoints, trimToRadius } from './geometry'; - -describe('trimToRadius', () => { - it('stops the segment at the node edge, not its centre', () => { - expect(trimToRadius({ x: 0, y: 0 }, { x: 100, y: 0 }, 20)).toEqual({ x: 80, y: 0 }); - }); - - it('leaves the endpoint alone when the nodes already overlap', () => { - // Trimming past the start would flip the arrow around. - expect(trimToRadius({ x: 0, y: 0 }, { x: 10, y: 0 }, 20)).toEqual({ x: 10, y: 0 }); - }); - - it('does not divide by zero on a self-loop', () => { - expect(trimToRadius({ x: 5, y: 5 }, { x: 5, y: 5 }, 20)).toEqual({ x: 5, y: 5 }); - }); -}); - -describe('bowOffsets', () => { - it('draws a lone edge straight', () => { - expect(bowOffsets(1)).toEqual([0]); - }); - - it('splits a mutual pair symmetrically', () => { - // Both bending the same way would still overlap; opposite signs separate them. - const [first, second] = bowOffsets(2, 20); - expect(first).toBe(20); - expect(second).toBe(-20); - }); - - it('fans a bundle of parallel edges outward', () => { - expect(bowOffsets(4, 10)).toEqual([10, -10, 20, -20]); - }); -}); - -describe('edgePath', () => { - it('draws a straight line when asked and unbowed', () => { - expect(edgePath({ x: 0, y: 0 }, { x: 10, y: 10 }, 'straight')).toBe('M 0 0 L 10 10'); - }); - - it('bows a curve to the requested side', () => { - const left = edgePath({ x: 0, y: 0 }, { x: 100, y: 0 }, 'bezier', 20); - const right = edgePath({ x: 0, y: 0 }, { x: 100, y: 0 }, 'bezier', -20); - expect(left).not.toBe(right); - expect(left).toContain('Q'); - }); - - it('routes orthogonally through a midpoint', () => { - expect(edgePath({ x: 0, y: 0 }, { x: 100, y: 50 }, 'orthogonal')).toBe('M 0 0 L 50 0 L 50 50 L 100 50'); - }); - - it('gives a self-loop a visible shape rather than a zero-length path', () => { - const path = edgePath({ x: 10, y: 10 }, { x: 10, y: 10 }, 'bezier'); - expect(path).toContain('C'); - }); -}); - -describe('groupByEndpoints', () => { - it('groups mutual edges together regardless of direction', () => { - const groups = groupByEndpoints([ - { source: 'a', target: 'b' }, - { source: 'b', target: 'a' }, - { source: 'a', target: 'c' }, - ]); - expect(groups.size).toBe(2); - expect([...groups.values()].find((group) => group.length === 2)).toBeDefined(); - }); -}); diff --git a/packages/graph-system/frameworks/solid/src/geometry.ts b/packages/graph-system/frameworks/solid/src/geometry.ts deleted file mode 100644 index 08e2bd911..000000000 --- a/packages/graph-system/frameworks/solid/src/geometry.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Edge geometry — paths, and where an edge should actually stop. - * - * Small, and worth its own file because both facts here are the difference between a graph that looks - * drawn and one that looks emitted: an arrowhead buried under the node it points at, and a pair of - * mutual edges drawn exactly on top of each other, are the two things every first-attempt graph - * renderer gets wrong. - */ -import type { Point } from '@we/graph-protocol'; - -/** - * Trim a segment so it ends at the node's edge rather than its centre. - * - * Without this the arrowhead sits under the target node and every edge looks unterminated. - */ -export function trimToRadius(from: Point, to: Point, radius: number): Point { - const dx = to.x - from.x; - const dy = to.y - from.y; - const length = Math.hypot(dx, dy); - if (length <= radius || length === 0) return to; - const ratio = (length - radius) / length; - return { x: from.x + dx * ratio, y: from.y + dy * ratio }; -} - -/** - * The path for one edge. - * - * `offset` bows the curve to one side. Two nodes related in both directions produce two edges with - * the same endpoints; drawn straight they are one line and the graph silently understates itself. - */ -export function edgePath(from: Point, to: Point, curve: 'straight' | 'bezier' | 'orthogonal', offset = 0): string { - if (from.x === to.x && from.y === to.y) { - // A self-loop has no direction to bow along, so it gets a fixed teardrop above the node. - const r = 26; - return `M ${from.x} ${from.y} C ${from.x - r} ${from.y - r * 1.6}, ${from.x + r} ${from.y - r * 1.6}, ${to.x} ${to.y}`; - } - - if (curve === 'straight' && !offset) return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; - - if (curve === 'orthogonal') { - const midX = (from.x + to.x) / 2; - return `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`; - } - - const dx = to.x - from.x; - const dy = to.y - from.y; - const length = Math.hypot(dx, dy) || 1; - // Perpendicular to the segment, so the bow is symmetrical whichever way the edge runs. - const bow = offset || Math.min(length * 0.12, 40); - const cx = (from.x + to.x) / 2 + (-dy / length) * bow; - const cy = (from.y + to.y) / 2 + (dx / length) * bow; - return `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`; -} - -/** - * How far to bow each edge in a group sharing endpoints. - * - * Zero for a lone edge — a single relationship should be a straight-ish line — then alternating out - * in both directions so a pair splits symmetrically rather than both bending the same way. - */ -export function bowOffsets(count: number, spacing = 26): number[] { - if (count <= 1) return [0]; - return Array.from({ length: count }, (_, index) => { - const step = Math.ceil((index + 1) / 2); - return (index % 2 === 0 ? 1 : -1) * step * spacing; - }); -} - -/** Group edges by unordered endpoint pair, so mutual and parallel edges can be fanned apart. */ -export function groupByEndpoints(edges: T[]): Map { - const groups = new Map(); - for (const edge of edges) { - const key = edge.source < edge.target ? `${edge.source}|${edge.target}` : `${edge.target}|${edge.source}`; - const group = groups.get(key); - if (group) group.push(edge); - else groups.set(key, [edge]); - } - return groups; -} diff --git a/packages/graph-system/frameworks/solid/src/index.ts b/packages/graph-system/frameworks/solid/src/index.ts index d1c1b146a..6d4a90323 100644 --- a/packages/graph-system/frameworks/solid/src/index.ts +++ b/packages/graph-system/frameworks/solid/src/index.ts @@ -4,6 +4,5 @@ * One component and its props. Everything interesting is in `@we/graph-core`; this package exists so * that a second framework is a second adapter of this size rather than a second engine. */ -export { GraphView } from './GraphView.solid'; +export { GraphView, pathFrom } from './GraphView.solid'; export type { GraphHostBindings, GraphViewProps } from './GraphView.types'; -export { bowOffsets, edgePath, groupByEndpoints, trimToRadius } from './geometry'; diff --git a/packages/graph-system/frameworks/solid/src/pathFrom.test.ts b/packages/graph-system/frameworks/solid/src/pathFrom.test.ts new file mode 100644 index 000000000..f7359e0f5 --- /dev/null +++ b/packages/graph-system/frameworks/solid/src/pathFrom.test.ts @@ -0,0 +1,91 @@ +/** + * The renderer's only remaining piece of geometry. + * + * Everything about *where* an edge runs lives in the core, so what is left here is a translation: + * world-space control points into one drawing syntax, plus the gap an arrowhead needs. Worth a test + * because it is the seam — a canvas renderer would write the same four cases into `quadraticCurveTo` + * and `bezierCurveTo` and must agree with this one, or two renderers would draw the same graph + * differently. + */ +import type { EdgeGeometry } from '@we/graph-protocol'; +import { describe, expect, it } from 'vitest'; + +import { pathFrom } from './GraphView.solid'; + +const base = { id: 'e', from: { x: 0, y: 0 }, to: { x: 100, y: 50 }, mid: { x: 50, y: 25 } }; + +describe('pathFrom', () => { + it('draws a line for a route with no control point', () => { + expect(pathFrom({ ...base, curve: 'straight' } as EdgeGeometry)).toBe('M 0 0 L 100 50'); + }); + + it('draws a quadratic through the control point', () => { + const route = { ...base, curve: 'arc', control: { x: 50, y: -30 } } as EdgeGeometry; + expect(pathFrom(route)).toBe('M 0 0 Q 50 -30 100 50'); + }); + + it('draws a cubic when a second control point makes the route smooth', () => { + const route = { + ...base, + curve: 'smooth', + control: { x: 50, y: 0 }, + control2: { x: 50, y: 50 }, + } as EdgeGeometry; + expect(pathFrom(route)).toBe('M 0 0 C 50 0 50 50 100 50'); + }); + + it('draws a step through both of its corners', () => { + const route = { + ...base, + curve: 'step', + elbows: [ + { x: 50, y: 0 }, + { x: 50, y: 50 }, + ], + } as EdgeGeometry; + expect(pathFrom(route)).toBe('M 0 0 L 50 0 L 50 50 L 100 50'); + }); + + it('prefers the corners when a route somehow carries both', () => { + // Defensive rather than expected: the step branch is the more constrained shape, so it wins. + const route = { + ...base, + curve: 'step', + elbows: [{ x: 50, y: 0 }], + control: { x: 10, y: 10 }, + } as EdgeGeometry; + expect(pathFrom(route)).toContain('L 50 0'); + }); + + /* + The arrow gap. + + The stroke stops an arrowhead's length short so the head sits at the end of the line rather than + on top of it — the marker's base is at the path end, and without this the line would run out from + under the triangle and show its edges either side of the tip. + */ + it('ends the stroke short by the gap, along the closing direction', () => { + const route = { ...base, to: { x: 100, y: 0 }, curve: 'straight' } as EdgeGeometry; + expect(pathFrom(route, 10)).toBe('M 0 0 L 90 0'); + }); + + it('backs off along the final tangent, not along the chord', () => { + // Closing tangent runs straight down from the second control, so the gap comes off y alone even + // though the edge as a whole travels right. + const route = { + ...base, + to: { x: 100, y: 100 }, + curve: 'smooth', + control: { x: 50, y: 0 }, + control2: { x: 100, y: 50 }, + } as EdgeGeometry; + expect(pathFrom(route, 10)).toBe('M 0 0 C 50 0 100 50 100 90'); + }); + + it('leaves a route alone when the gap would consume it', () => { + // A node dropped almost on top of its neighbour still gets a line rather than one running + // backwards through itself. + const route = { ...base, to: { x: 4, y: 0 }, curve: 'straight' } as EdgeGeometry; + expect(pathFrom(route, 10)).toBe('M 0 0 L 4 0'); + }); +}); diff --git a/packages/graph-system/frameworks/solid/src/solid-elements.d.ts b/packages/graph-system/frameworks/solid/src/solid-elements.d.ts new file mode 100644 index 000000000..972140774 --- /dev/null +++ b/packages/graph-system/frameworks/solid/src/solid-elements.d.ts @@ -0,0 +1,3 @@ +// Typed `we-*` JSX intrinsics, generated from the Custom Elements Manifest. A DX layer only — the +// primitives are Lit custom elements and render as plain tags without it. +import '@we/primitives/solid/types'; diff --git a/packages/graph-system/frameworks/solid/vitest.config.ts b/packages/graph-system/frameworks/solid/vitest.config.ts new file mode 100644 index 000000000..da0f389e5 --- /dev/null +++ b/packages/graph-system/frameworks/solid/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + // The component this package exports is JSX, so the test run needs the same compiler the build uses + // — without it a plain `.ts` test importing from a `.tsx` module fails to parse. + plugins: [solidPlugin()], + resolve: { dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'] }, + test: { environment: 'happy-dom' }, +}); diff --git a/packages/graph-system/layouts/src/deterministic.ts b/packages/graph-system/layouts/src/deterministic.ts index e4d05db8b..16c751e65 100644 --- a/packages/graph-system/layouts/src/deterministic.ts +++ b/packages/graph-system/layouts/src/deterministic.ts @@ -71,18 +71,92 @@ export interface TreeLayoutOptions { direction?: 'down' | 'right'; } +/** + * Order each row so its edges cross as little as possible. + * + * Without this a layered graph is only *layered* — rows are correct and the lines between them are a + * tangle, because a node's position in its row is whatever order the traversal happened to reach it. + * The barycentre heuristic is the standard fix and most of what a full layered engine buys you: sort + * each row by the mean position of its neighbours in the row above, sweep down, then up, repeat. + * + * A few sweeps get most of the benefit; this is not Sugiyama, and when `tree` visibly fails on real + * data the answer is to adapt dagre or ELK rather than to grow this function. + */ +function reduceCrossings(levels: Map, input: LayoutInput, sweeps = 4): Map { + const above = new Map(); + const below = new Map(); + for (const edge of input.edges) { + (above.get(edge.target) ?? above.set(edge.target, []).get(edge.target)!).push(edge.source); + (below.get(edge.source) ?? below.set(edge.source, []).get(edge.source)!).push(edge.target); + } + + const ordered = new Map(levels); + const indexIn = (row: string[]) => new Map(row.map((id, i) => [id, i])); + + const sortRow = (row: string[], neighbourRow: string[], neighbours: Map) => { + const position = indexIn(neighbourRow); + const barycentre = (id: string): number => { + const linked = (neighbours.get(id) ?? []).map((other) => position.get(other)).filter((p) => p !== undefined); + // A node with no neighbour in the adjacent row has no opinion; leaving it where it is keeps the + // sort stable rather than dragging it to one end. + if (!linked.length) return row.indexOf(id); + return (linked as number[]).reduce((sum, p) => sum + p, 0) / linked.length; + }; + return [...row].sort((a, b) => barycentre(a) - barycentre(b) || a.localeCompare(b)); + }; + + const depths = [...ordered.keys()].sort((a, b) => a - b); + for (let sweep = 0; sweep < sweeps; sweep += 1) { + // Down: each row settles against the one above it. + for (let i = 1; i < depths.length; i += 1) { + const row = ordered.get(depths[i])!; + ordered.set(depths[i], sortRow(row, ordered.get(depths[i - 1])!, above)); + } + // Up: and then against the one below, which is what resolves the rows the downward pass fixed early. + for (let i = depths.length - 2; i >= 0; i -= 1) { + const row = ordered.get(depths[i])!; + ordered.set(depths[i], sortRow(row, ordered.get(depths[i + 1])!, below)); + } + } + return ordered; +} + +/** + * Keep a parent's children next to each other. + * + * The protocol passes a containment tree and this used to ignore it, so an expanded collection's + * children scattered across their row wherever crossing-reduction put them. Grouping by parent first + * means a group reads as a group; ordering *within* each group is still the barycentre's job. + */ +function groupByParent(row: string[], containment: ReadonlyMap | undefined): string[] { + if (!containment?.size) return row; + const parentOf = new Map(); + for (const [parent, children] of containment) { + for (const child of children) parentOf.set(child, parent); + } + if (!row.some((id) => parentOf.has(id))) return row; + + const groups = new Map(); + for (const id of row) { + const key = parentOf.get(id) ?? `\u0000${id}`; + (groups.get(key) ?? groups.set(key, []).get(key)!).push(id); + } + return [...groups.values()].flat(); +} + /** Layered hierarchy — the right shape for containment, org charts and dependency chains. */ export function treeLayout(rawOptions?: Record): Layout { const options = { levelGap: 120, siblingGap: 90, direction: 'down', ...(rawOptions as TreeLayoutOptions) }; return { id: 'tree', - description: 'Layered hierarchy from the graph roots, laid out downward or rightward.', + description: 'Layered hierarchy with barycentre crossing reduction; groups children under their parent.', init(input): LayoutResult { - const levels = groupByLevel(levelise(input, findRoots(input))); + const levels = reduceCrossings(groupByLevel(levelise(input, findRoots(input))), input); const positions = new Map(); const widest = Math.max(1, ...[...levels.values()].map((row) => row.length)); - for (const [level, row] of levels) { + for (const [level, unordered] of levels) { + const row = groupByParent(unordered, input.containment); row.forEach((id, index) => { // Centre each row against the widest, so the tree is symmetrical rather than left-ragged. const offset = (index - (row.length - 1) / 2) * options.siblingGap + (widest * options.siblingGap) / 2; @@ -184,9 +258,14 @@ export function manualLayout(rawOptions?: Record): Layout { return { id: 'manual', description: 'Reads each node position from its own data; new nodes are parked in a grid.', + // Position is the data here, so every node is placed and none is held *against* anything. Saying + // so keeps a renderer from marking all of them as pinned, which marks the rule rather than the + // exception and reads as every card being in some special state. + derivesPositions: false, init(input): LayoutResult { const positions = new Map(); let unplaced = 0; + let fromData = 0; for (const node of input.nodes) { const override = pinned.get(node.id); @@ -198,6 +277,7 @@ export function manualLayout(rawOptions?: Record): Layout { const y = Number(node.data?.[options.yField]); if (Number.isFinite(x) && Number.isFinite(y)) { positions.set(node.id, { x, y, fixed: true }); + fromData += 1; continue; } const previous = input.previous?.get(node.id); @@ -212,7 +292,23 @@ export function manualLayout(rawOptions?: Record): Layout { }); unplaced += 1; } - return { positions }; + + /* + Say so when there was nothing to read. + + This layout's whole job is to take positions from the data, so a dataset that carries none + leaves it holding everything exactly where it found it — on screen, identical to a layout that + ran and decided nothing needed to move. Picking it and seeing no change is then indistinguishable + from picking it and having it silently do nothing, which is the version people conclude. + */ + const warnings: string[] = []; + if (input.nodes.length && fromData === 0) { + warnings.push( + `manual layout: no node carries "${options.xField}" and "${options.yField}", so positions were left as they were. ` + + `It suits a board, where position is the data being edited — a graph without stored positions wants a layout that derives them.`, + ); + } + return { positions, warnings }; }, fix(id, at) { diff --git a/packages/graph-system/protocol/src/index.ts b/packages/graph-system/protocol/src/index.ts index 54d62a60e..78d096644 100644 --- a/packages/graph-system/protocol/src/index.ts +++ b/packages/graph-system/protocol/src/index.ts @@ -38,11 +38,23 @@ export type { ExpandResult, SeedSource, } from './expander'; -export type { Layout, LayoutFactory, LayoutInput, LayoutResult, Placement, Point } from './layout'; +export type { + EdgeCurve, + EdgeGeometry, + Layout, + LayoutFactory, + LayoutInput, + LayoutResult, + Placement, + Point, +} from './layout'; export type { Behaviour, BehaviourContext, BehaviourFactory, + ControlContext, + GraphControl, + GraphControlFactory, GraphEvent, NodeRenderer, NodeVisual, diff --git a/packages/graph-system/protocol/src/layout.ts b/packages/graph-system/protocol/src/layout.ts index d5290924c..9cfa838e4 100644 --- a/packages/graph-system/protocol/src/layout.ts +++ b/packages/graph-system/protocol/src/layout.ts @@ -51,6 +51,16 @@ export interface LayoutResult { * pass simply never sets it, and costs nothing. */ running?: boolean; + /** + * Anything the layout could not do, in the author's terms. + * + * A layout that finds nothing to work with still has to return positions, so without somewhere to + * say so its only options are to fail silently or to invent an arrangement and pretend it derived + * one. `manual` is the case that forced this: asked to read positions from node data that does not + * carry any, it keeps what is already there — indistinguishable, on screen, from a layout that ran + * and decided nothing needed moving. + */ + warnings?: string[]; } /** @@ -61,6 +71,16 @@ export interface LayoutResult { */ export interface Layout { id: string; + /** + * Whether this layout works out where nodes go, as opposed to reading it from them. + * + * Almost all of them do, so it is omitted by default and only `manual` says otherwise. What it + * buys is the difference between a pinned node being an *exception* and being the rule: a node held + * against a force or tree layout is worth marking, because the layout would otherwise move it, while + * on a board every node is placed by definition and the same mark is on everything and means + * nothing. + */ + derivesPositions?: boolean; description?: string; /** Seed or re-seed. Called when the node set changes. */ init(input: LayoutInput): LayoutResult; @@ -73,3 +93,60 @@ export interface Layout { } export type LayoutFactory = (options?: TOptions) => Layout; + +/** + * The shape an edge is drawn with. + * + * Named for what it looks like rather than for the maths behind it, because these names are written by + * hand into templates and chosen by a model from a sentence like "show me how these connect". `arc` + * says bows-to-one-side; `bezier` said quadratic-with-one-control, which is both harder to picture and + * actively misleading — in most node editors "bezier" means the S-curve, which is `smooth` here. + * + * - `straight` — a direct line. Says the least, and is the right answer when the layout is doing the + * talking. + * - `arc` — bows to one side. Deliberate curvature, when a graph is dense enough that lines need + * telling apart by shape. + * - `smooth` — leaves and arrives along the dominant axis, the flow-chart S. The default: it reads as + * direction without insisting on it, and it is the shape people expect from a node graph. + * - `step` — right angles. For containment and org charts, where the eye follows a rank rather than a + * line. + */ +export type EdgeCurve = 'straight' | 'arc' | 'smooth' | 'step'; + +/** + * Where an edge actually runs, in world units. + * + * Geometry, not drawing instructions: control points rather than an SVG path string, so the engine can + * measure an edge — for picking — without knowing how any particular renderer expresses it, and a + * canvas renderer can stroke the same curve without re-deriving it. + * + * That split is what lets the DOM stop owning edge hit-testing. While `pointer-events: stroke` did the + * picking, edges were the one thing behaviours could only reach through the DOM, which is also why a + * canvas renderer could not have supported clicking one. + */ +export interface EdgeGeometry { + id: string; + from: Point; + /** Trimmed to the target's edge, so an arrowhead lands on the node rather than under it. */ + to: Point; + /** + * First control point: the whole of an `arc`'s quadratic, or the departure tangent of a `smooth` + * cubic. Absent for `straight` and `step`. + */ + control?: Point; + /** Second control point — the arrival tangent of a `smooth` cubic. Its presence is what makes the + * route cubic rather than quadratic, so a renderer picks its path command from that alone. */ + control2?: Point; + /** + * Corners of a `step` route, between `from` and `to`. + * + * A list rather than the single point this used to be, because a step turns twice, and which way it + * turns first depends on the axis the edge mostly runs along. Storing one corner forced every + * consumer to re-derive the second and to assume horizontal-first, which is wrong for a graph laid + * out top-to-bottom. + */ + elbows?: Point[]; + curve: EdgeCurve; + /** Midpoint of the drawn route — where a label sits. */ + mid: Point; +} diff --git a/packages/graph-system/protocol/src/render.ts b/packages/graph-system/protocol/src/render.ts index 73f1f8285..083b2de38 100644 --- a/packages/graph-system/protocol/src/render.ts +++ b/packages/graph-system/protocol/src/render.ts @@ -18,8 +18,12 @@ import type { NodeStyle } from './style'; * possible later without rewriting the plugins that produce these. */ export interface NodeVisual { - shape: 'circle' | 'rect' | 'template'; + shape: 'circle' | 'rect' | 'card' | 'template'; + /** Radius for a mark; half-height for a box. A card uses `width`/`height` instead. */ size: number; + /** Card geometry, in world units. Present only for `shape: 'card'`. */ + width?: number; + height?: number; color: string; borderColor?: string; borderWidth?: number; @@ -27,6 +31,8 @@ export interface NodeVisual { label?: string; labelColor?: string; labelSize?: number; + /** False pins the label to a constant on-screen size. See `NodeStyle.scaleLabelWithZoom`. */ + scaleLabelWithZoom?: boolean; icon?: string; image?: string; } @@ -52,6 +58,13 @@ export interface NodeRenderer { export interface BehaviourContext { /** Nodes currently under the pointer, nearest first. */ hitTest(at: Point): string[]; + /** + * The edge under the pointer, if any, within a tolerance. + * + * Separate from {@link hitTest} because nodes win: an edge passing behind a node is not what you + * meant to click, and a caller that wants both asks for nodes first. + */ + hitTestEdge(at: Point, tolerance?: number): string | null; select(ids: string[], mode?: 'replace' | 'add' | 'toggle'): void; selection(): string[]; /** Ask the engine to expand a node — the click-to-explore behaviour's whole job. */ @@ -59,6 +72,20 @@ export interface BehaviourContext { collapse(id: string): void; /** Pin a node at a world position, or release it. */ pin(id: string, at: Point | null): void; + /** + * Whether the user is currently allowed to move nodes. + * + * Read by anything that moves one on a gesture, so a locked graph refuses at the point the gesture + * starts rather than by silently discarding the result. + */ + locked(): boolean; + /** + * Where a node currently is, in world units. + * + * Needed by anything that moves a node *relative* to where it already was — a drag has to preserve + * the offset between the node's centre and the point you grabbed it by, or it snaps to the cursor. + */ + positionOf(id: string): Point | null; /** Move the camera. */ pan(dx: number, dy: number): void; zoomAt(at: Point, factor: number): void; @@ -104,8 +131,81 @@ export interface Behaviour { onPointerDown?(input: PointerInput, ctx: BehaviourContext): boolean | void; onPointerMove?(input: PointerInput, ctx: BehaviourContext): boolean | void; onPointerUp?(input: PointerInput, ctx: BehaviourContext): boolean | void; + /** + * The gesture was abandoned — the pointer was captured away, the window lost focus, a touch was + * interrupted. Any behaviour holding state across a gesture must reset here, or it stays latched. + */ + onPointerCancel?(input: PointerInput, ctx: BehaviourContext): boolean | void; onWheel?(input: PointerInput, ctx: BehaviourContext): boolean | void; onDoubleClick?(input: PointerInput, ctx: BehaviourContext): boolean | void; } export type BehaviourFactory = (options?: TOptions) => Behaviour; + +/** + * A button in the graph's own chrome. + * + * Declared as data — an icon, a title and what it does — rather than as a component, so the renderer + * draws every control the same way and a module can contribute one without shipping framework code. + * The same reasoning as a module's `launcher`: the contributor knows what the control *means*, only + * the host knows where controls go and how they should look. + */ +export interface GraphControl { + id: string; + /** Phosphor icon name. */ + icon: string; + /** Tooltip, and the accessible name. */ + title: string; + run(ctx: ControlContext): void; + /** + * Whether this control is currently *on*. + * + * Every control used to be a momentary action — zoom, fit, re-run the layout — so there was nothing + * for a button to be. A lock is not that: it has a state, and a toggle that does not show its own + * state is a switch you have to remember the position of. Omitted for an action, which is most of + * them. + */ + active?(ctx: ControlContext): boolean; + /** + * Whether it can be used at all right now. + * + * Pinning acts on the selection, so with nothing selected it has nothing to act on. A button that + * silently does nothing teaches people it is broken. + */ + enabled?(ctx: ControlContext): boolean; + /** Icon and tooltip while active, for a toggle that reads better as two states than one pressed one. */ + activeIcon?: string; + activeTitle?: string; +} + +/** + * What a control is allowed to do. + * + * Narrow on purpose: chrome acts on the *scene* — what is on screen and how it is arranged — and + * never on the data. `relayout` has always moved nodes, so the line was never "does not touch + * positions"; it is that nothing here writes anything back. Pinning and locking sit on the same side + * of it: a pinned node is held by the layout, not saved, and persisting a position remains the job of + * `onNodeDragEnd` and the host that listens to it. + */ +export interface ControlContext { + zoomBy(factor: number): void; + fit(): void; + relayout(): void; + viewport(): { x: number; y: number; zoom: number; width: number; height: number }; + /** Ids currently selected. */ + selection(): readonly string[]; + /** Whether a node is held where it was put, so a layout will not move it. */ + isPinned(id: string): boolean; + /** Hold nodes where they are, or release them back to the layout. */ + setPinned(ids: readonly string[], pinned: boolean): void; + /** + * Whether node movement by the user is blocked. + * + * Deliberately about the user rather than the layout: locking a board stops it being rearranged by + * accident, and freezing a force simulation is a different request that nobody has made. + */ + isLocked(): boolean; + setLocked(locked: boolean): void; +} + +export type GraphControlFactory = (options?: TOptions) => GraphControl; diff --git a/packages/graph-system/protocol/src/spec.ts b/packages/graph-system/protocol/src/spec.ts index fa8e0c967..c059dbdc3 100644 --- a/packages/graph-system/protocol/src/spec.ts +++ b/packages/graph-system/protocol/src/spec.ts @@ -75,6 +75,13 @@ export interface GraphSpec { nodeStyle?: NodeStyleRules; edgeStyle?: EdgeStyleRules; behaviours?: BehaviourSpec[]; + /** + * Buttons the graph draws in its own corner, by registered id. + * + * Omit for the sensible set (`zoom-in`, `zoom-out`, `fit`); pass `[]` for a graph with no chrome at + * all, which is what an embedded thumbnail wants. + */ + controls?: string[]; /** Starting camera. Absent fits the seeds to the viewport. */ viewport?: { x?: number; y?: number; zoom?: number }; } diff --git a/packages/graph-system/protocol/src/style.ts b/packages/graph-system/protocol/src/style.ts index 1608e3fdd..c806053dc 100644 --- a/packages/graph-system/protocol/src/style.ts +++ b/packages/graph-system/protocol/src/style.ts @@ -17,6 +17,7 @@ * with components. */ import type { GraphValue } from './graph'; +import type { EdgeCurve } from './layout'; /** Operators a match clause may use against a node/edge field. Mirrors the schema system's `$filter`. */ export interface MatchOperators { @@ -61,13 +62,34 @@ export interface NodeStyle { color?: StyleValue; borderColor?: string; borderWidth?: number; - /** `circle` and `rect` draw in both DOM and canvas modes; `template` requires DOM. */ - shape?: 'circle' | 'rect' | 'template'; + /** + * `circle` and `rect` draw in both DOM and canvas modes; `template` requires DOM. + * + * `card` is the post-it: a sized box with the label *inside* it, wrapped, rather than a mark with a + * caption underneath. Worth being a shape rather than a flag because it changes what `size` means — + * a card is `width` × `height`, not a radius — and because a board is mostly cards. + */ + shape?: 'circle' | 'rect' | 'card' | 'template'; + /** Card width in world units. Only meaningful for `shape: 'card'`; defaults to a readable box. */ + width?: number; + /** Card height. Defaults to `width` × 0.75, roughly a post-it. */ + height?: number; opacity?: number; labelColor?: string; labelSize?: number; /** Hide the label below this zoom, so a dense graph stays readable when zoomed out. */ labelMinZoom?: number; + /** + * Whether the label grows and shrinks with the camera. Default `true`. + * + * `false` pins it to a constant on-screen size, which keeps text readable at any zoom — right for a + * map you navigate by reading, wrong for a board where the text *is* the artwork. + * + * Only the label. A node's mark always scales: its size is world units and so is its hit area, and + * letting the two disagree is precisely the class of bug where what you can click stops matching + * what you can see. + */ + scaleLabelWithZoom?: boolean; icon?: string; image?: string; /** Name of a registered node renderer, when the built-in shapes are not enough. */ @@ -79,9 +101,21 @@ export interface EdgeStyle { color?: StyleValue; opacity?: number; /** `bezier` is the readable default for dense graphs; `orthogonal` suits trees and flows. */ - curve?: 'straight' | 'bezier' | 'orthogonal'; + /** + * See `EdgeCurve`. `bezier` and `orthogonal` are accepted as the previous names for `arc` and + * `step`, so templates written against them keep working. + */ + curve?: EdgeCurve | 'bezier' | 'orthogonal'; arrow?: 'none' | 'target' | 'both'; dashed?: boolean; + /** + * Whether stroke width grows with the camera. Default `true`. + * + * `true` treats the edge as part of the drawing, which is what a board wants — zoom in and the line + * gets thicker, like ink. `false` keeps it a constant on-screen width, which is what a large network + * wants, since hairlines vanish when you zoom out to see the whole thing. + */ + scaleWithZoom?: boolean; showLabel?: boolean; labelColor?: string; } diff --git a/packages/module-system/graph/src/catalog.ts b/packages/module-system/graph/src/catalog.ts index 94459c5b3..3375bdac1 100644 --- a/packages/module-system/graph/src/catalog.ts +++ b/packages/module-system/graph/src/catalog.ts @@ -150,6 +150,97 @@ export const GRAPH_PLUGIN_CATALOG: PluginCatalog = { example: `{ "type": "manual" }`, }, + // ─── Presentation ────────────────────────────────────────────────────────── + { + id: 'curve', + category: 'style', + description: + 'Edge style — the shape a connection is drawn with. "smooth" (default) leaves and arrives along the axis the edge mostly runs on, the flow-chart S, so it reads as direction and suits hierarchies and pipelines. "straight" is a direct line, right when the layout is already doing the talking. "arc" bows to one side, for a graph dense enough that lines need telling apart by shape. "step" turns at right angles, for containment and org charts where the eye follows a rank. Two nodes related in both directions are always separated — shifted sideways, or crossed at different points — so picking a shape never hides a relationship.', + example: `"edgeStyle": [{ "style": { "curve": "smooth" } }]`, + }, + { + id: 'arrow', + category: 'style', + description: + 'Edge style — which ends carry an arrowhead. "target" (default) points at the thing being related to; "both" for a mutual relationship drawn as one line; "none" when the relation has no direction worth showing. The head scales with the line\'s width, and the line stops short of it rather than running underneath.', + example: `"edgeStyle": [{ "style": { "arrow": "none" } }]`, + }, + { + id: 'scaleWithZoom', + category: 'style', + description: + 'Edge style. true (default) treats the line as part of the drawing, so it thickens as you zoom in — right for a board. false pins it to a constant on-screen width, so hairlines stay visible when you zoom out to see a whole network.', + example: `"edgeStyle": [{ "style": { "scaleWithZoom": false } }]`, + }, + { + id: 'scaleLabelWithZoom', + category: 'style', + description: + 'Node style. true (default) scales the label with the camera; false keeps it a constant on-screen size, which keeps text readable at any zoom on a map you navigate by reading. Affects the label only — a node mark always scales, because its size and its hit area are both world units.', + example: `"nodeStyle": [{ "style": { "scaleLabelWithZoom": false } }]`, + }, + { + id: 'labelMinZoom', + category: 'style', + description: + 'Node style. Hides the label below this zoom level, so a dense graph stays readable when zoomed out and gains its detail as you move in.', + example: `"nodeStyle": [{ "style": { "labelMinZoom": 0.6 } }]`, + }, + + // ─── Metrics ─────────────────────────────────────────────────────────────── + { + id: 'degree', + category: 'metric', + description: + 'How connected a node is, normalised 0..1. The usual answer to "make the important things bigger". Reference it from a style value rather than a fixed number.', + options: [{ name: 'range', type: '[number, number]', description: 'Output range, e.g. [8, 30].' }], + example: `"nodeStyle": [{ "style": { "size": { "metric": "degree", "range": [10, 34] } } }]`, + }, + { + id: 'community', + category: 'metric', + description: + 'Groups the visible graph by label propagation. Pair with scale: "categorical" to colour each cluster differently — this is what makes a cluster map.', + options: [{ name: 'rounds', type: 'number', description: 'Propagation rounds. Default 8.' }], + example: `"nodeStyle": [{ "style": { "color": { "metric": "community", "scale": "categorical" } } }]`, + }, + + // ─── Controls ────────────────────────────────────────────────────────────── + { + id: 'zoom-in', + category: 'control', + description: 'Zooms toward the centre of the view. Shown by default.', + example: `"controls": ["zoom-in", "zoom-out", "fit"]`, + }, + { id: 'zoom-out', category: 'control', description: 'Zooms out from the centre. Shown by default.' }, + { + id: 'fit', + category: 'control', + description: + 'Frames everything currently on the graph. Deliberately not a re-layout — it moves the camera, never the nodes.', + }, + { + id: 'pin', + category: 'control', + description: + 'Holds the selected nodes where they are, so the layout stops moving them; press again to release. The usual way to shape a force graph — put the thing you care about where you want it, hold it there, and let the rest settle around it. Held nodes are ringed so the state is visible. Not shown by default: on a board every node is placed already and it means nothing.', + example: `"controls": ["zoom-in", "zoom-out", "fit", "pin"]`, + }, + { + id: 'lock', + category: 'control', + description: + 'Blocks moving nodes, so a graph cannot be rearranged by accident while it is being read or shown to someone. Affects dragging only — panning, zooming and a settling force layout all carry on. Not shown by default, and only meaningful where the template allows dragging at all.', + example: `"controls": ["zoom-in", "zoom-out", "fit", "lock"]`, + }, + { + id: 'relayout', + category: 'control', + description: + 'Re-runs the layout. Not shown by default: a rescue for a tangled force graph, and destructive on a board, where it would discard every position somebody chose.', + example: `"controls": ["zoom-in", "zoom-out", "fit", "relayout"]`, + }, + // ─── Behaviours ──────────────────────────────────────────────────────────── { id: 'pan-zoom', diff --git a/packages/module-system/graph/src/fragments.ts b/packages/module-system/graph/src/fragments.ts index 13657bfad..68256c3d5 100644 --- a/packages/module-system/graph/src/fragments.ts +++ b/packages/module-system/graph/src/fragments.ts @@ -34,7 +34,7 @@ export const schemaMap: SchemaNode = { { style: { size: 20, color: 'primary-500', shape: 'rect' } }, { when: { 'data.relations': { gt: 3 } }, style: { size: 28, color: 'primary-700' } }, ], - edgeStyle: [{ style: { showLabel: true, curve: 'bezier' } }], + edgeStyle: [{ style: { showLabel: true, curve: 'arc' } }], height: '100%', }, }; @@ -62,7 +62,7 @@ export const knowledgeMap = (opts: KnowledgeMapOptions): SchemaNode => ({ { when: { type: opts.entity }, style: { size: 20, color: 'primary-500' } }, { when: { unresolved: true }, style: { color: 'neutral-200' } }, ], - edgeStyle: [{ style: { curve: 'bezier', arrow: 'target' } }], + edgeStyle: [{ style: { curve: 'arc', arrow: 'target' } }], behaviours: ['pan-zoom', 'select', 'expand-on-double-click', { type: 'drag-node' }], height: '100%', }, @@ -86,7 +86,7 @@ export const contentTree: SchemaNode = { { when: { type: 'CollectionBlock' }, style: { size: 18, color: 'primary-500' } }, { when: { 'data.kind': 'call' }, style: { color: 'success-500', icon: 'phone' } }, ], - edgeStyle: [{ style: { curve: 'orthogonal', arrow: 'target', color: 'neutral-200' } }], + edgeStyle: [{ style: { curve: 'step', arrow: 'target', color: 'neutral-200' } }], height: '100%', }, }; diff --git a/packages/schema-system/shared/src/generated/contextData.ts b/packages/schema-system/shared/src/generated/contextData.ts index f75625953..e1c6d1a1f 100644 --- a/packages/schema-system/shared/src/generated/contextData.ts +++ b/packages/schema-system/shared/src/generated/contextData.ts @@ -1279,11 +1279,13 @@ export const contextData: ContextData = { { name: 'nodeStyle', type: 'NodeStyleRules', optional: true }, { name: 'edgeStyle', type: 'EdgeStyleRules', optional: true }, { name: 'behaviours', type: 'BehaviourSpec[]', optional: true }, + { name: 'reified', type: 'Record', optional: true }, { name: 'width', type: 'string', optional: true }, { name: 'height', type: 'string', optional: true }, { name: 'bg', type: 'string', optional: true }, { name: 'showStatus', type: 'boolean', optional: true }, { name: 'showControls', type: 'boolean', optional: true }, + { name: 'controls', type: 'string[]', optional: true }, { name: 'onNodeClick', type: '((node: GraphNode) => void)', optional: true }, { name: 'onNodeDoubleClick', type: '((node: GraphNode) => void)', optional: true }, { name: 'onEdgeClick', type: '((edge: GraphEdge) => void)', optional: true }, diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index 13ba2ab9f..1f5fd17df 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -72,7 +72,7 @@ export { findMutations, isLengthMutation } from './mutations'; export { resolveProp, resolveProps, resolveQueryProp, splitProps, REACTIVE_ACCESSOR } from './propResolvers'; export type { LocalFieldMeta, LocalMetaMap, MapProp } from './propResolvers'; export { hasToken } from './predicates'; -export { themeToStyle } from './themeStyles'; +export { applyThemeVars, themeToStyle } from './themeStyles'; export { validateField } from './validation'; export { computeSectionIndex, diff --git a/packages/schema-system/shared/src/themeStyles.test.ts b/packages/schema-system/shared/src/themeStyles.test.ts new file mode 100644 index 000000000..7796396aa --- /dev/null +++ b/packages/schema-system/shared/src/themeStyles.test.ts @@ -0,0 +1,141 @@ +/** + * `applyThemeVars` tests. + * + * The interesting behaviour is what it *removes*. Switching themes has to clear the previous theme's + * variables and nothing else — the root is shared, and a host publishes layout variables there too. + * The shortcut (`style.cssText = ''`) passes any test that only checks the new theme applied, and + * silently deletes the host's own state, which is how a docked panel's chrome ends up snapped to the + * window edge until something forces a recompute. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { applyThemeVars } from './themeStyles'; + +/** A minimal stand-in for an element's inline style, so this needs no DOM. */ +function fakeRoot() { + const props = new Map(); + return { + props, + el: { + style: { + setProperty: (name: string, value: string) => props.set(name, value), + removeProperty: (name: string) => props.delete(name), + }, + } as unknown as HTMLElement, + }; +} + +describe('applyThemeVars', () => { + it('writes a theme as custom properties', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { multiplier: -1, subtractor: '108%' }); + + expect(props.get('--we-color-multiplier')).toBe('-1'); + expect(props.get('--we-color-subtractor')).toBe('108%'); + }); + + it('clears variables the previous theme set and this one does not', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { primaryHue: 230, multiplier: -1 }); + expect(props.has('--we-color-primary-hue')).toBe(true); + + applyThemeVars(el, { multiplier: 1 }); + + expect(props.has('--we-color-primary-hue')).toBe(false); + expect(props.get('--we-color-multiplier')).toBe('1'); + }); + + it('leaves variables it never set alone', () => { + // The bug this exists to prevent: a host publishes layout state on the same root, and clearing + // wholesale takes it with the old theme. + const { el, props } = fakeRoot(); + props.set('--we-dock-right', '320px'); + + applyThemeVars(el, { multiplier: -1 }); + applyThemeVars(el, { multiplier: 1 }); + + expect(props.get('--we-dock-right')).toBe('320px'); + }); + + it('tracks each root separately', () => { + // A page and a space-scoped subtree are both themed; neither may clear the other's variables. + const a = fakeRoot(); + const b = fakeRoot(); + applyThemeVars(a.el, { primaryHue: 230 }); + applyThemeVars(b.el, { multiplier: -1 }); + applyThemeVars(b.el, { multiplier: 1 }); + + expect(a.props.has('--we-color-primary-hue')).toBe(true); + }); +}); + +/** + * The cross-fade window. + * + * `--we-theme-switch-duration` is the one seam that lets a theme change animate the same properties a + * hover exit uses, without a hover exit ever inheriting a duration from it. Both halves matter and + * both are easy to break silently: leave it set and every button trails on the way out again; never + * set it and a light/dark switch becomes a repaint. + * + * The first-application case is the one that already went wrong once. Opening the window on initial + * paint is not a switch — there is nothing to fade *from* — and it left every component running a + * 250ms departure transition for the first fraction of a second of the page's life, which was long + * enough to be sampled and reported as the exact fault the variable exists to avoid. + */ +describe('applyThemeVars cross-fade window', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const DURATION = '--we-theme-switch-duration'; + + it('does not open a window on the first application, which is the initial paint', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { multiplier: -1 }); + + expect(props.has(DURATION)).toBe(false); + }); + + it('opens one on a later application, when there is something to fade from', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { multiplier: -1 }); + applyThemeVars(el, { multiplier: 1 }); + + expect(props.get(DURATION)).toBe('250ms'); + }); + + it('closes the window afterwards, so a hover exit never inherits a duration', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { multiplier: -1 }); + applyThemeVars(el, { multiplier: 1 }); + + vi.advanceTimersByTime(400); + expect(props.has(DURATION)).toBe(false); + }); + + it('restarts the window when a second switch lands mid-fade, rather than closing early', () => { + const { el, props } = fakeRoot(); + applyThemeVars(el, { multiplier: -1 }); + applyThemeVars(el, { multiplier: 1 }); + + vi.advanceTimersByTime(300); + applyThemeVars(el, { multiplier: -1 }); + + // The first switch's timer would have fired by now had it not been cleared. + vi.advanceTimersByTime(200); + expect(props.get(DURATION)).toBe('250ms'); + + vi.advanceTimersByTime(200); + expect(props.has(DURATION)).toBe(false); + }); + + it('tracks the window per root, so two subtrees do not close each other', () => { + const a = fakeRoot(); + const b = fakeRoot(); + applyThemeVars(a.el, { multiplier: -1 }); + applyThemeVars(a.el, { multiplier: 1 }); + applyThemeVars(b.el, { multiplier: -1 }); + + expect(a.props.get(DURATION)).toBe('250ms'); + expect(b.props.has(DURATION)).toBe(false); + }); +}); diff --git a/packages/schema-system/shared/src/themeStyles.ts b/packages/schema-system/shared/src/themeStyles.ts index a13166f69..d484cd66c 100644 --- a/packages/schema-system/shared/src/themeStyles.ts +++ b/packages/schema-system/shared/src/themeStyles.ts @@ -192,3 +192,75 @@ export function themeToStyle(theme: ThemeOverrides): Record { return style; } + +/** + * Write a theme's variables onto an element, removing exactly the ones the previous theme set. + * + * The removal bookkeeping is the whole reason this is a function rather than a loop at each call + * site. Clearing with `style.cssText = ''` is the obvious shortcut and is wrong: the root is shared, + * and a host publishes layout variables there too. Doing that deleted `--we-dock-right` and + * `--we-chrome-transition` along with the old theme, so every piece of chrome positioned against a + * docked panel snapped to the window edge and stayed there until something happened to recompute it. + * Dragging the panel healed it, which was the tell — a repaint fixing a value nobody had recalculated. + * + * State is per element, so two roots (a page and a space-scoped subtree) do not clear each other's + * variables. + */ +const appliedThemeVars = new WeakMap>(); + +/** + * How long the switch itself cross-fades for, and how long the window stays open. + * + * The window has to outlast the fade or the duration is withdrawn mid-flight and everything jumps to + * its new colour. The margin is generous because it costs nothing: while it is open the only + * difference is that a colour change would animate, and no colour is changing. + */ +const SWITCH_DURATION_MS = 250; +const SWITCH_WINDOW_MS = 400; +const switchTimers = new WeakMap>(); + +export function applyThemeVars(root: HTMLElement, theme: ThemeOverrides): void { + const styles = themeToStyle(theme); + const previous = appliedThemeVars.get(root); + + /* + Open the cross-fade window before writing anything. + + Components animate their colours from `var(--we-theme-switch-duration, 0s)`, which is `0s` at all + other times — deliberately, because that same declaration is what governs a hover *exit*, and a + duration there is what made a fast pass across a list of buttons leave a trail of decaying + highlights behind the pointer (see the note in @we/primitives `shared/helpers.ts`). Raising it + around the switch and lowering it again gives the theme change its cross-fade without a hover exit + ever inheriting one. + + A `