From bbb532e4f1def4c221a879681995844614f7a533 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:02:59 +0100 Subject: [PATCH 01/14] fix(themes): thirteen theme rules that never matched anything, and a guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS fails silently, so a theme rule naming an element that does not exist is invisible: no warning, no build error, nothing to attribute the result to. Retro and cyberpunk had accumulated thirteen such rules between them, which is most of what those two themes thought they were doing. Three failure modes, all found in the wild: - Wrong tag — `we-toggle` (it is `we-switch`), `we-tab-item` (`we-tab`), `we-menu-group-item` (`we-menu-group`). - Wrong part — `we-modal::part(modal)` (it is `base`, in retro *and* dark), `we-input::part(input-wrapper)` (that part is on `we-select`; input's is `base`). - Wrong attribute — `we-button[circle]` (the prop is `square`). The guard comes first and is the durable half: tags and attributes are checked against the generated `custom-elements.json`, and parts against the `part="x"` the primitives render plus the `[part='x']` their own styles target, since either can introduce one. It also asserts it found a non-trivial vocabulary, so a moved path or a renamed decorator fails loudly rather than passing by matching nothing. Retro's `we-modal::part(header)` is deleted rather than repaired. It wanted a Win95 title bar, and the modal renders backdrop / base / close-button-wrapper / slot and nothing else — there is no header to attach to. Removing a rule that never matched changes no pixels; restoring the intent is a modal API change, not a theme fix. Note that fixing these *changes how retro and cyberpunk render*, for the first time since the rules were written. That is the point — they cannot be matched against a reference while most of their styling is inert — but it does mean both themes now want looking at. Co-Authored-By: Claude Opus 5 (1M context) --- .../2-themes/src/cyberpunk/index.css | 6 +- .../design-system/2-themes/src/dark/index.css | 2 +- .../2-themes/src/retro/index.css | 27 ++-- .../2-themes/src/themeSelectors.test.ts | 146 ++++++++++++++++++ 4 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 packages/design-system/2-themes/src/themeSelectors.test.ts diff --git a/packages/design-system/2-themes/src/cyberpunk/index.css b/packages/design-system/2-themes/src/cyberpunk/index.css index 64b829381..6d8b8b5b6 100644 --- a/packages/design-system/2-themes/src/cyberpunk/index.css +++ b/packages/design-system/2-themes/src/cyberpunk/index.css @@ -40,11 +40,11 @@ clip-path: polygon(90% 0, 0 0, 0 50%, 5% 100%, 0% 100%, 100% 100%, 100% 0%); } -[data-we-theme='cyberpunk'] we-tab-item::part(base) { +[data-we-theme='cyberpunk'] we-tab::part(base) { clip-path: polygon(87% 0, 0 0, 0 50%, 13% 100%, 88% 100%, 100% 100%, 99% 16%); } -[data-we-theme='cyberpunk'] we-input::part(input-wrapper) { +[data-we-theme='cyberpunk'] we-input::part(base) { height: 60px; font-size: 16px; border: 10px solid var(--we-color-primary-500); @@ -67,7 +67,7 @@ ); } -[data-we-theme='cyberpunk'] we-input::part(input-wrapper):focus-within { +[data-we-theme='cyberpunk'] we-input::part(base):focus-within { border-color: var(--we-color-primary-500); } diff --git a/packages/design-system/2-themes/src/dark/index.css b/packages/design-system/2-themes/src/dark/index.css index 3c2cbd892..bc9a45ab0 100644 --- a/packages/design-system/2-themes/src/dark/index.css +++ b/packages/design-system/2-themes/src/dark/index.css @@ -7,7 +7,7 @@ --we-role-surface-raised: var(--we-color-neutral-100); } -[data-we-theme='dark'] we-modal::part(modal) { +[data-we-theme='dark'] we-modal::part(base) { box-shadow: 0 0 40px 20px hsl(var(--we-color-primary-hue) var(--we-color-saturation) 20% / 5%); } diff --git a/packages/design-system/2-themes/src/retro/index.css b/packages/design-system/2-themes/src/retro/index.css index 46ca504b5..001fff51e 100644 --- a/packages/design-system/2-themes/src/retro/index.css +++ b/packages/design-system/2-themes/src/retro/index.css @@ -12,7 +12,7 @@ --we-scrollbar-thumb-border-radius: 0; } -[data-we-theme='retro'] we-modal::part(modal) { +[data-we-theme='retro'] we-modal::part(base) { position: relative; background: silver; box-shadow: @@ -40,13 +40,12 @@ justify-content: center; } -[data-we-theme='retro'] we-modal::part(header) { - display: block; - height: 50px; - background: linear-gradient(90deg, navy, #1084d0); -} +/* A Win95 title bar wants a `header` part on we-modal. There isn't one — the element renders + backdrop, base, close-button-wrapper and a default slot, and nothing else — so the rule that + used to sit here never matched anything. Removing it changes no pixels. Restoring the intent + means adding the part to the primitive, which is a modal API change, not a theme fix. */ -[data-we-theme='retro'] we-toggle::part(indicator) { +[data-we-theme='retro'] we-switch::part(thumb) { border-radius: 0; box-shadow: inset -1px -1px #0a0a0a, @@ -55,7 +54,7 @@ inset 2px 2px #dfdfdf; } -[data-we-theme='retro'] we-toggle::part(toggle) { +[data-we-theme='retro'] we-switch::part(track) { border-radius: 0; box-shadow: inset -1px -1px #0a0a0a, @@ -116,7 +115,7 @@ inset 2px 2px #dfdfdf; } -[data-we-theme='retro'] we-button[circle]::part(base) { +[data-we-theme='retro'] we-button[square]::part(base) { border-radius: 0; } @@ -133,11 +132,11 @@ background: var(--we-color-neutral-100); } -[data-we-theme='retro'] we-menu-group-item::part(summary) { +[data-we-theme='retro'] we-menu-group::part(summary) { padding-left: var(--we-space-300); } -[data-we-theme='retro'] we-menu-group-item::part(content) { +[data-we-theme='retro'] we-menu-group::part(content) { border-left: 1px dotted grey; margin-left: var(--we-space-500); } @@ -156,7 +155,7 @@ border-top: 1px dotted grey; } -[data-we-theme='retro'] we-tab-item::part(base) { +[data-we-theme='retro'] we-tab::part(base) { box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, @@ -164,7 +163,7 @@ inset 2px 2px #dfdfdf; } -[data-we-theme='retro'] we-input::part(input-wrapper) { +[data-we-theme='retro'] we-input::part(base) { background: white; border: 0; box-shadow: @@ -174,7 +173,7 @@ inset 2px 2px #dfdfdf; } -[data-we-theme='retro'] we-input::part(input-wrapper):focus-within { +[data-we-theme='retro'] we-input::part(base):focus-within { border: 0; box-shadow: inset -1px -1px #0a0a0a, diff --git a/packages/design-system/2-themes/src/themeSelectors.test.ts b/packages/design-system/2-themes/src/themeSelectors.test.ts new file mode 100644 index 000000000..4e60b4eb1 --- /dev/null +++ b/packages/design-system/2-themes/src/themeSelectors.test.ts @@ -0,0 +1,146 @@ +/** + * Every selector a theme writes must resolve against a real element, part and attribute. + * + * CSS fails silently. A rule targeting `we-toggle` when the element is `we-switch` does not warn, + * does not throw and does not show up in a build — it simply never applies, and the theme looks + * *almost* right in a way nobody can attribute to anything. Retro and cyberpunk had accumulated + * thirteen such rules between them, which is most of what those two themes thought they were doing. + * + * The three failure modes, all found in the wild here: + * + * - **Wrong tag** — `we-toggle` (it is `we-switch`), `we-tab-item` (`we-tab`), `we-menu-group-item` + * (`we-menu-group`). + * - **Wrong part** — `we-modal::part(modal)` (the part is `base`), `we-input::part(input-wrapper)` + * (that part is on `we-select`; input's is `base`). + * - **Wrong attribute** — `we-button[circle]` (the prop is `square`). + * + * Tags and attributes come from the generated `custom-elements.json`. Parts are not in the manifest, + * so they are read from the primitives' source — both the `part="x"` a template renders and the + * `[part='x']` its own stylesheet targets, since a part can be introduced by either. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const primitivesRoot = join(here, '..', '..', '3-primitives'); + +/** Selectors that intentionally name something outside the primitives' vocabulary. */ +const IGNORED_TAGS = new Set([]); + +interface ElementInfo { + attributes: Set; + parts: Set; +} + +/** Tags and attributes from the generated manifest; parts from source (the manifest omits them). */ +function readElements(): Map { + const cem = JSON.parse(readFileSync(join(primitivesRoot, 'custom-elements.json'), 'utf8')) as { + modules?: Array<{ declarations?: Array<{ tagName?: string; attributes?: Array<{ name: string }> }> }>; + }; + + const elements = new Map(); + for (const module of cem.modules ?? []) { + for (const declaration of module.declarations ?? []) { + if (!declaration.tagName) continue; + elements.set(declaration.tagName, { + attributes: new Set((declaration.attributes ?? []).map((a) => a.name)), + parts: new Set(), + }); + } + } + + const primitivesDir = join(primitivesRoot, 'src', 'primitives'); + for (const file of readdirSync(primitivesDir)) { + if (!file.endsWith('.ts')) continue; + const source = readFileSync(join(primitivesDir, file), 'utf8'); + const tag = /@customElement\('(we-[a-z0-9-]+)'\)/.exec(source)?.[1]; + if (!tag) continue; + const info = elements.get(tag); + if (!info) continue; + + // `part="a b"` in a rendered template, and `[part='a']` in the element's own styles. + for (const [, names] of source.matchAll(/\bpart="([a-z0-9 _-]+)"/g)) { + for (const name of names.split(/\s+/)) if (name) info.parts.add(name); + } + for (const [, name] of source.matchAll(/\[part=['"]([a-z0-9_-]+)['"]\]/g)) info.parts.add(name); + } + + return elements; +} + +interface Usage { + file: string; + tag: string; + attributes: string[]; + part?: string; +} + +/** Every `we-*` selector in the theme stylesheets, with its attribute filters and `::part()`. */ +function readThemeUsages(): Usage[] { + const cssRoot = join(here); + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) walk(join(dir, entry.name)); + else if (entry.name.endsWith('.css')) files.push(join(dir, entry.name)); + } + }; + walk(cssRoot); + + // Lookbehind rejects custom properties and data attributes: `--we-color-*`, `data-we-theme`. + const selector = /(? name), + ...(part ? { part } : {}), + }); + } + } + return usages; +} + +const elements = readElements(); +const usages = readThemeUsages().filter((u) => !IGNORED_TAGS.has(u.tag)); + +describe('theme selectors resolve', () => { + it('reads a non-trivial element vocabulary and a non-trivial set of theme usages', () => { + // Guards the guard: a broken path or a changed decorator would otherwise make everything below + // pass by finding nothing at all. + expect(elements.size).toBeGreaterThan(40); + expect(elements.get('we-button')?.parts.has('base')).toBe(true); + expect(usages.length).toBeGreaterThan(20); + }); + + it('names only elements that exist', () => { + const unknown = usages.filter((u) => !elements.has(u.tag)).map((u) => `${u.file}: <${u.tag}>`); + expect([...new Set(unknown)]).toEqual([]); + }); + + it('names only parts the element actually exposes', () => { + const unknown = usages + .filter((u) => u.part && elements.has(u.tag) && !elements.get(u.tag)!.parts.has(u.part)) + .map((u) => `${u.file}: ${u.tag}::part(${u.part}) — has: ${[...elements.get(u.tag)!.parts].sort().join(', ')}`); + expect([...new Set(unknown)]).toEqual([]); + }); + + it('names only attributes the element actually declares', () => { + const unknown = usages + .filter((u) => elements.has(u.tag)) + .flatMap((u) => + u.attributes + .filter((name) => !elements.get(u.tag)!.attributes.has(name)) + .map((name) => `${u.file}: ${u.tag}[${name}]`), + ); + expect([...new Set(unknown)]).toEqual([]); + }); +}); From 31e3290d3e9632430167bfc9d5cc465ad8359b40 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:06:42 +0100 Subject: [PATCH 02/14] feat(tokens,primitives): the nine missing roles, and the overlays that now read them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--we-role-*` shipped in #114 with zero consumers, so the dark preset's `surfaceRaised` override has been inert since it landed: in dark mode every modal, drawer and popover still painted *darker* than the page it floated over, with a black shadow invisible against near-black. This makes the vocabulary real. Phase 0 — nine roles that were missing and blocking: - `overlay` — one scrim, replacing six hardcoded black alphas that differed by accident rather than by decision. Drawer's backdrop moves 0.4 → 0.6 as a result; a scrim that varies by which overlay opened it reads as a bug. - `shadowColor` — opaque, with the consumer supplying alpha via `color-mix`. Kept opaque because the seven distinct alphas across the primitives encode genuinely different elevations and collapsing them would flatten the hierarchy. What was never a decision is the hue. - `focus` — `--we-ring-color` now resolves to it, so the two cannot drift. - `surfaceHover` / `surfaceActive`, `accentMuted`, `{danger,success,warning}Surface`. Phase 1 — every overlay/floating surface and every hardcoded shadow colour: - `modal.ts` and `drawer.ts` default `bg` → `surface-raised`. These are the two lines the whole thing was for. - Both backdrops → `overlay`. - The five floating panels (select, date-picker, colour-picker, icon-picker, location-picker) → `surface-raised` + `border` on the panel itself. - Sixteen `rgba(0,0,0,α)` shadows across eleven files → the role, each keeping its own alpha exactly. No hardcoded black remains in the primitives outside the vendored leaflet stylesheet. Deliberately untouched: the ~463 non-floating `neutral-*` background call sites (the audit's phases 2–5), and the places where a scale position is genuinely correct — input hover tints, the badge/tag/alert variant maps, the skeleton gradient. The role test is now derived from the token object rather than a hand-written list, and asserts no role hardcodes a colour. A role added and forgotten was exactly how `overlay` and `shadowColor` ended up inlined in nine primitives. Co-Authored-By: Claude Opus 5 (1M context) --- .../__snapshots__/generate-css.test.ts.snap | 9 +++++ .../1-tokens/scripts/generate-css.test.ts | 39 +++++++++++-------- .../1-tokens/scripts/generate-css.ts | 5 ++- packages/design-system/1-tokens/src/role.ts | 36 +++++++++++++++++ .../design-system/2-themes/src/overrides.ts | 11 +++++- .../src/primitives/color-picker.ts | 6 +-- .../src/primitives/date-picker.ts | 6 +-- .../3-primitives/src/primitives/drawer.ts | 6 +-- .../src/primitives/icon-picker.ts | 6 +-- .../src/primitives/location-picker.ts | 6 +-- .../3-primitives/src/primitives/modal.ts | 4 +- .../3-primitives/src/primitives/select.ts | 6 +-- .../3-primitives/src/primitives/slider.ts | 4 +- .../3-primitives/src/primitives/sortable.ts | 2 +- .../3-primitives/src/primitives/switch.ts | 2 +- .../3-primitives/src/primitives/tooltip.ts | 2 +- 16 files changed, 106 insertions(+), 44 deletions(-) diff --git a/packages/design-system/1-tokens/scripts/__snapshots__/generate-css.test.ts.snap b/packages/design-system/1-tokens/scripts/__snapshots__/generate-css.test.ts.snap index 935ad2b9c..7e9dba259 100644 --- a/packages/design-system/1-tokens/scripts/__snapshots__/generate-css.test.ts.snap +++ b/packages/design-system/1-tokens/scripts/__snapshots__/generate-css.test.ts.snap @@ -132,6 +132,15 @@ exports[`token CSS generation > color.css — hues, lightness ramp, palettes, ro --we-role-border-strong: var(--we-color-neutral-500); --we-role-accent: var(--we-color-primary-500); --we-role-accent-text: var(--we-color-neutral-0); + --we-role-accent-muted: var(--we-color-primary-100); + --we-role-surface-hover: var(--we-color-neutral-100); + --we-role-surface-active: var(--we-color-neutral-200); + --we-role-overlay: hsl(var(--we-color-neutral-hue) var(--we-color-neutral-saturation) 4% / 60%); + --we-role-shadow-color: hsl(var(--we-color-neutral-hue) var(--we-color-neutral-saturation) 4%); + --we-role-focus: var(--we-color-primary-500); + --we-role-danger-surface: var(--we-color-danger-50); + --we-role-success-surface: var(--we-color-success-50); + --we-role-warning-surface: var(--we-color-warning-50); /* Focus Colors */ --we-color-focus: var(--we-color-primary-500); diff --git a/packages/design-system/1-tokens/scripts/generate-css.test.ts b/packages/design-system/1-tokens/scripts/generate-css.test.ts index 44dd73448..e828b7ca8 100644 --- a/packages/design-system/1-tokens/scripts/generate-css.test.ts +++ b/packages/design-system/1-tokens/scripts/generate-css.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest'; import { color } from '../src/color'; import { component } from '../src/component'; import { font } from '../src/font'; +import { role } from '../src/role'; import { avatarSize, componentHeight, radius, size } from '../src/size'; import { space } from '../src/space'; import { zIndex } from '../src/z-index'; @@ -51,23 +52,29 @@ describe('token CSS generation', () => { expect(css).toContain(`--we-scrollbar-thumb-background: ${component.scrollbar.thumbBackground}`); }); - it('every role token resolves to a parametric expression over the scale', () => { + it('every declared role is emitted, and none of them hardcodes a colour', () => { + // Derived from the token object rather than a hand-listed set: a role added to `role.ts` and + // forgotten here would otherwise be untested, which is how `overlay` and `shadowColor` came to + // be hardcoded in nine primitives in the first place. const css = generateColorCSS(color); - for (const name of [ - 'page', - 'surface', - 'surface-raised', - 'surface-sunken', - 'text', - 'text-muted', - 'text-faint', - 'text-inverse', - 'border', - 'border-strong', - 'accent', - 'accent-text', - ]) { - expect(css).toMatch(new RegExp(`--we-role-${name}: var\\(--we-color-`)); + for (const name of Object.keys(role)) { + const cssName = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); + const declaration = new RegExp(`--we-role-${cssName}: (.+);`).exec(css); + expect(declaration, `role '${name}' is not emitted`).not.toBeNull(); + + // Either a scale position or an expression over the hue/saturation variables — never a + // literal, which is what makes a role themeable at all. + expect(declaration![1], `role '${name}' hardcodes a colour`).toMatch( + /^(var\(--we-color-|hsl\(var\(--we-color-)/, + ); + } + }); + + it('covers the roles the overlay primitives depend on', () => { + // These are the ones the modal/drawer/popover migration reads; losing one silently un-themes + // an overlay rather than failing a build. + for (const name of ['overlay', 'shadow-color', 'surface-raised', 'focus']) { + expect(generateColorCSS(color)).toContain(`--we-role-${name}:`); } }); }); diff --git a/packages/design-system/1-tokens/scripts/generate-css.ts b/packages/design-system/1-tokens/scripts/generate-css.ts index c9b32fcfa..da62d2b53 100644 --- a/packages/design-system/1-tokens/scripts/generate-css.ts +++ b/packages/design-system/1-tokens/scripts/generate-css.ts @@ -103,8 +103,9 @@ export function generateBorderCSS(border: typeof borderTokens) { --we-border-color: var(--we-color-neutral-100); --we-border-color-strong: var(--we-color-neutral-200); - /* Focus ring color — themes override this to match their accent colour */ - --we-ring-color: var(--we-color-primary-500); + /* Focus ring color — resolves to the focus role, so a theme pinning that role moves the ring + with it. Themes may still override this variable directly (ThemeOverrides.ringColor). */ + --we-ring-color: var(--we-role-focus); }`; return css; diff --git a/packages/design-system/1-tokens/src/role.ts b/packages/design-system/1-tokens/src/role.ts index b26c6d02f..72db5522e 100644 --- a/packages/design-system/1-tokens/src/role.ts +++ b/packages/design-system/1-tokens/src/role.ts @@ -38,6 +38,42 @@ export const role = { accent: 'var(--we-color-primary-500)', /** Text/icon colour on top of the accent. */ accentText: 'var(--we-color-neutral-0)', + /** A de-emphasised accent — accent-tinted fills, selected rows, subtle highlights. */ + accentMuted: 'var(--we-color-primary-100)', + + /** Hover tint on a surface (menu items, list rows, ghost buttons). */ + surfaceHover: 'var(--we-color-neutral-100)', + /** Pressed tint on a surface. */ + surfaceActive: 'var(--we-color-neutral-200)', + + /** + * The scrim behind a modal or drawer. + * + * One value, deliberately: the six hardcoded black alphas this replaces differed by accident + * rather than by decision, and a scrim that varies by which overlay opened it reads as a bug. + * Alpha is baked in because a scrim is a finished colour, not a base to tint from. + */ + overlay: 'hsl(var(--we-color-neutral-hue) var(--we-color-neutral-saturation) 4% / 60%)', + + /** + * The colour shadows are built from — opaque, with the consumer supplying alpha: + * `color-mix(in srgb, var(--we-role-shadow-color) 12%, transparent)`. + * + * Opaque rather than pre-alpha'd because the nine primitives that hardcoded `rgba(0,0,0,…)` + * used seven different alphas for genuinely different elevations, and collapsing those would + * flatten the hierarchy. What was never a decision is the *hue*: a black shadow is invisible on + * a near-black surface, which is why a dark theme has to reach for elevation-by-lightness + * instead. Pinning this lets a theme tint or lighten shadows rather than work around them. + */ + shadowColor: 'hsl(var(--we-color-neutral-hue) var(--we-color-neutral-saturation) 4%)', + + /** The focus ring. `--we-ring-color` resolves to this, so the two cannot drift. */ + focus: 'var(--we-color-primary-500)', + + /** Tinted surfaces behind status content (alerts, badges, destructive confirmations). */ + dangerSurface: 'var(--we-color-danger-50)', + successSurface: 'var(--we-color-success-50)', + warningSurface: 'var(--we-color-warning-50)', } as const; export type RoleToken = keyof typeof role; diff --git a/packages/design-system/2-themes/src/overrides.ts b/packages/design-system/2-themes/src/overrides.ts index 7e4fe8a9a..7a0b9dc8d 100644 --- a/packages/design-system/2-themes/src/overrides.ts +++ b/packages/design-system/2-themes/src/overrides.ts @@ -22,6 +22,8 @@ export type ThemeRole = | 'surface' | 'surfaceRaised' | 'surfaceSunken' + | 'surfaceHover' + | 'surfaceActive' | 'text' | 'textMuted' | 'textFaint' @@ -29,7 +31,14 @@ export type ThemeRole = | 'border' | 'borderStrong' | 'accent' - | 'accentText'; + | 'accentText' + | 'accentMuted' + | 'overlay' + | 'shadowColor' + | 'focus' + | 'dangerSurface' + | 'successSurface' + | 'warningSurface'; export type ThemeOverrides = { // Named preset diff --git a/packages/design-system/3-primitives/src/primitives/color-picker.ts b/packages/design-system/3-primitives/src/primitives/color-picker.ts index 78828cf0e..53c4f79b0 100644 --- a/packages/design-system/3-primitives/src/primitives/color-picker.ts +++ b/packages/design-system/3-primitives/src/primitives/color-picker.ts @@ -29,10 +29,10 @@ const styles = css` [part='popover'] { position: absolute; z-index: var(--we-z-dropdown); - background: var(--we-color-neutral-0); - border: 1px solid var(--we-color-neutral-200); + background: var(--we-role-surface-raised); + border: 1px solid var(--we-role-border); border-radius: var(--we-radius-500); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 16px color-mix(in srgb, var(--we-role-shadow-color) 12%, transparent); padding: var(--we-space-400); display: flex; flex-direction: column; diff --git a/packages/design-system/3-primitives/src/primitives/date-picker.ts b/packages/design-system/3-primitives/src/primitives/date-picker.ts index 5ae18108c..df03b08f2 100644 --- a/packages/design-system/3-primitives/src/primitives/date-picker.ts +++ b/packages/design-system/3-primitives/src/primitives/date-picker.ts @@ -60,10 +60,10 @@ const styles = css` top: 100%; left: 0; z-index: var(--we-z-dropdown); - background: var(--we-color-neutral-0); - border: 1px solid var(--we-color-neutral-200); + background: var(--we-role-surface-raised); + border: 1px solid var(--we-role-border); border-radius: var(--we-radius-400); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 12px color-mix(in srgb, var(--we-role-shadow-color) 10%, transparent); padding: var(--we-space-300); margin-top: var(--we-space-100); } diff --git a/packages/design-system/3-primitives/src/primitives/drawer.ts b/packages/design-system/3-primitives/src/primitives/drawer.ts index 6f982bfcd..808f45685 100644 --- a/packages/design-system/3-primitives/src/primitives/drawer.ts +++ b/packages/design-system/3-primitives/src/primitives/drawer.ts @@ -8,7 +8,7 @@ import sharedStyles from '../shared/styles'; import type { DrawerPosition } from '../types'; const DEFAULT_PROPS: Partial = { - bg: 'neutral-0', + bg: 'var(--we-role-surface-raised)', r: '600', p: '600', direction: 'column', @@ -31,13 +31,13 @@ const styles = css` position: absolute; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.4); + background: var(--we-role-overlay); } [part='base'] { position: absolute; overflow-y: auto; - box-shadow: var(--we-theme-shadow, var(--we-shadow-lg, 0 10px 40px rgba(0, 0, 0, 0.15))); + box-shadow: var(--we-theme-shadow, var(--we-shadow-lg, 0 10px 40px color-mix(in srgb, var(--we-role-shadow-color) 15%, transparent))); transition: transform var(--we-transition-300, 250ms) ease; } diff --git a/packages/design-system/3-primitives/src/primitives/icon-picker.ts b/packages/design-system/3-primitives/src/primitives/icon-picker.ts index c00f05a16..493b6407e 100644 --- a/packages/design-system/3-primitives/src/primitives/icon-picker.ts +++ b/packages/design-system/3-primitives/src/primitives/icon-picker.ts @@ -152,10 +152,10 @@ const styles = css` z-index: var(--we-z-dropdown, 9999); min-width: 320px; max-width: 380px; - background: var(--we-color-neutral-0); - border: 1px solid var(--we-color-neutral-200); + background: var(--we-role-surface-raised); + border: 1px solid var(--we-role-border); border-radius: var(--we-radius-500); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + box-shadow: 0 8px 24px color-mix(in srgb, var(--we-role-shadow-color) 12%, transparent); padding: var(--we-space-400); display: flex; flex-direction: column; diff --git a/packages/design-system/3-primitives/src/primitives/location-picker.ts b/packages/design-system/3-primitives/src/primitives/location-picker.ts index d5e1e3a45..95da61e78 100644 --- a/packages/design-system/3-primitives/src/primitives/location-picker.ts +++ b/packages/design-system/3-primitives/src/primitives/location-picker.ts @@ -75,10 +75,10 @@ const styles = css` inset: unset; /* Component styles */ z-index: var(--we-z-dropdown, 9999); - background: var(--we-color-neutral-0); - border: 1px solid var(--we-color-neutral-200); + background: var(--we-role-surface-raised); + border: 1px solid var(--we-role-border); border-radius: var(--we-radius-500); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + box-shadow: 0 8px 24px color-mix(in srgb, var(--we-role-shadow-color) 14%, transparent); display: flex; flex-direction: column; overflow: hidden; diff --git a/packages/design-system/3-primitives/src/primitives/modal.ts b/packages/design-system/3-primitives/src/primitives/modal.ts index 818aec34c..d858a247c 100644 --- a/packages/design-system/3-primitives/src/primitives/modal.ts +++ b/packages/design-system/3-primitives/src/primitives/modal.ts @@ -6,7 +6,7 @@ import { OverlayElement } from '../shared/overlay-element'; import sharedStyles from '../shared/styles'; const DEFAULT_PROPS: Partial = { - bg: 'neutral-0', + bg: 'var(--we-role-surface-raised)', r: '600', p: '900', ax: 'stretch', @@ -27,7 +27,7 @@ const CSS_STYLES = css` position: absolute; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.6); + background: var(--we-role-overlay); } [part='base'] { diff --git a/packages/design-system/3-primitives/src/primitives/select.ts b/packages/design-system/3-primitives/src/primitives/select.ts index eae2e8d13..ec01b9b01 100644 --- a/packages/design-system/3-primitives/src/primitives/select.ts +++ b/packages/design-system/3-primitives/src/primitives/select.ts @@ -100,10 +100,10 @@ const styles = css` z-index: var(--we-z-dropdown); max-height: 200px; overflow-y: auto; - background: var(--we-color-neutral-0); - border: 1px solid var(--we-color-neutral-200); + background: var(--we-role-surface-raised); + border: 1px solid var(--we-role-border); border-radius: var(--we-theme-surface-radius, var(--we-radius-400)); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 12px color-mix(in srgb, var(--we-role-shadow-color) 10%, transparent); margin-top: var(--we-space-100); padding: var(--we-space-100) 0; } diff --git a/packages/design-system/3-primitives/src/primitives/slider.ts b/packages/design-system/3-primitives/src/primitives/slider.ts index 2a59bf8e4..594e46e3a 100644 --- a/packages/design-system/3-primitives/src/primitives/slider.ts +++ b/packages/design-system/3-primitives/src/primitives/slider.ts @@ -77,7 +77,7 @@ const styles = css` border-radius: var(--we-radius-full); background: var(--we-color-primary-500); border: 2px solid white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px color-mix(in srgb, var(--we-role-shadow-color) 20%, transparent); margin-top: calc((var(--thumb-size) - var(--track-height)) / -2); } @@ -87,7 +87,7 @@ const styles = css` border-radius: var(--we-radius-full); background: var(--we-color-primary-500); border: 2px solid white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px color-mix(in srgb, var(--we-role-shadow-color) 20%, transparent); } :host([disabled]) { diff --git a/packages/design-system/3-primitives/src/primitives/sortable.ts b/packages/design-system/3-primitives/src/primitives/sortable.ts index 1eacbd09b..74f85c5e8 100644 --- a/packages/design-system/3-primitives/src/primitives/sortable.ts +++ b/packages/design-system/3-primitives/src/primitives/sortable.ts @@ -131,7 +131,7 @@ export default class Sortable extends DesignSystemElement { `pointer-events:none`, `opacity:0.85`, `z-index:9999`, - `box-shadow:0 4px 16px rgba(0,0,0,0.2)`, + `box-shadow:0 4px 16px color-mix(in srgb, var(--we-role-shadow-color) 20%, transparent)`, `border-radius:6px`, `margin:0`, ].join(';'); diff --git a/packages/design-system/3-primitives/src/primitives/switch.ts b/packages/design-system/3-primitives/src/primitives/switch.ts index bce30ee77..7b28e1b0c 100644 --- a/packages/design-system/3-primitives/src/primitives/switch.ts +++ b/packages/design-system/3-primitives/src/primitives/switch.ts @@ -53,7 +53,7 @@ const styles = css` border-radius: var(--we-radius-full); background: white; transition: transform var(--we-transition-200, 150ms) ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px color-mix(in srgb, var(--we-role-shadow-color) 20%, transparent); } :host([checked]) [part='thumb'] { diff --git a/packages/design-system/3-primitives/src/primitives/tooltip.ts b/packages/design-system/3-primitives/src/primitives/tooltip.ts index 0d4ae5a20..ac257ed6f 100644 --- a/packages/design-system/3-primitives/src/primitives/tooltip.ts +++ b/packages/design-system/3-primitives/src/primitives/tooltip.ts @@ -71,7 +71,7 @@ const CSS_STYLES = css` background: #222; color: white; border-radius: var(--we-border-radius, 4px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 8px color-mix(in srgb, var(--we-role-shadow-color) 15%, transparent); pointer-events: none; } From a94ff328f3fcc19c8f393a2f0231939069325603 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:10:01 +0100 Subject: [PATCH 03/14] fix(app-shell): shell overlays had no query params at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShellRouteStore` declares `const store: RouteStore = {…}` and simply omitted `params` and `setParam` when the routing work added them, so every `{ $store: 'routeStore.params.x' }` inside a shell surface — profile, settings, the marketplace — read `undefined`, and `$localState` `syncParam` fields there had nowhere to sync to. A regression since #114. It compiled because app-shell has no typecheck script (audit P3-1): the annotation is on a `const` whose excess/missing members TypeScript would have caught the moment anything ran `tsc` over the package. That rollout stays out of this branch, so the guard here is the test instead. The implementation cannot mirror the main store's. That one reaches for `history.replaceState` so a param-only change does not re-resolve the route tree, and reads back from `window.location` — neither of which a `MemoryRouter` has, and having its own location is the entire reason the shell store exists. So params are read from the router's location and written by navigating it with `replace`, which keeps the overlay out of the browser URL and out of the app's history. Five tests, including the one that matters most: setting a shell param must leave `window.location` untouched. A shell param leaking into the browser URL would be a different bug of exactly the same size. Co-Authored-By: Claude Opus 5 (1M context) --- .../solid/stores/ShellRouteStore.tsx | 41 +++++++- .../app-shell/tests/shellRouteStore.test.tsx | 97 +++++++++++++++++++ packages/app-shell/vitest.config.ts | 1 + 3 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 packages/app-shell/tests/shellRouteStore.test.tsx diff --git a/packages/app-shell/src/frameworks/solid/stores/ShellRouteStore.tsx b/packages/app-shell/src/frameworks/solid/stores/ShellRouteStore.tsx index bdeca4a99..239d3088a 100644 --- a/packages/app-shell/src/frameworks/solid/stores/ShellRouteStore.tsx +++ b/packages/app-shell/src/frameworks/solid/stores/ShellRouteStore.tsx @@ -21,7 +21,18 @@ import { createContext, createEffect, createMemo, createSignal, JSX, onMount, Pa import type { RouteStore } from './RouteStore'; import { useShellStore } from './ShellStore'; -const ShellRouteContext = createContext(); +/** + * The shell's store, plus the one setter only {@link ShellRouterRoot} may call. + * + * The query string has to come from the MemoryRouter's location rather than `window.location`, + * which is the whole reason this store exists separately: the overlay's URL is deliberately not + * the browser's. Consumers get the narrowed {@link RouteStore} from `useShellRouteStore`. + */ +interface ShellRouteStore extends RouteStore { + setSearch: (search: string) => void; +} + +const ShellRouteContext = createContext(); /** * Provides the ShellRouteStore context. Must wrap everything that renders the shell overlay, @@ -30,8 +41,10 @@ const ShellRouteContext = createContext(); */ export function ShellRouteStoreProvider(props: ParentProps) { const [currentPath, setCurrentPath] = createSignal('/'); + const [search, setSearch] = createSignal(''); const [navigateFunction, setNavigateFunction] = createSignal | null>(null); const segments = createMemo(() => currentPath().split('/').filter(Boolean)); + const params = createMemo(() => Object.fromEntries(new URLSearchParams(search()))); function navigate(to: string, options?: Record) { const nav = navigateFunction(); @@ -39,12 +52,32 @@ export function ShellRouteStoreProvider(props: ParentProps) { else console.warn('ShellRouteStore: navigate called before router was ready'); } - const store: RouteStore = { + /** + * Writes one query parameter by navigating the memory router. + * + * The main `RouteStore` reaches for `history.replaceState` here so the route tree does not + * re-resolve on a param-only change. That is not available to a MemoryRouter — its location is + * not the browser's — so this navigates instead, and `replace` keeps it out of the overlay's + * history the same way. The overlay's route tree is small enough that re-resolving costs + * nothing worth engineering around. + */ + function setParam(name: string, value: string | null, options?: { push?: boolean }) { + const next = new URLSearchParams(search()); + if (value === null || value === undefined || value === '') next.delete(name); + else next.set(name, value); + const query = next.toString(); + navigate(`${currentPath()}${query ? `?${query}` : ''}`, { replace: !options?.push }); + } + + const store: ShellRouteStore = { currentPath, segments, + params, setNavigateFunction, setCurrentPath, + setSearch, navigate, + setParam, }; return {props.children}; @@ -55,13 +88,15 @@ export function ShellRouteStoreProvider(props: ParentProps) { * from inside the router context and wires them into the ShellRouteStore signals. */ export function ShellRouterRoot(props: ParentProps): JSX.Element { - const store = useShellRouteStore(); + const store = useContext(ShellRouteContext); + if (!store) throw new Error('ShellRouterRoot must be mounted within ShellRouteStoreProvider'); const shell = useShellStore(); const navigate = useNavigate(); const location = useLocation(); createEffect(() => store.setNavigateFunction(() => navigate)); createEffect(() => store.setCurrentPath(location.pathname)); + createEffect(() => store.setSearch(location.search)); // A control outside the overlay can ask for a page inside it — see `ShellStore.openShellView`. // Claimed here rather than by the opener because this is the first moment `navigate` exists, and diff --git a/packages/app-shell/tests/shellRouteStore.test.tsx b/packages/app-shell/tests/shellRouteStore.test.tsx new file mode 100644 index 000000000..dbc72ee4e --- /dev/null +++ b/packages/app-shell/tests/shellRouteStore.test.tsx @@ -0,0 +1,97 @@ +/** + * The shell overlay's query params. + * + * `ShellRouteStore` implements the same `RouteStore` contract the main router does, but over a + * `MemoryRouter` — the overlay's URL is deliberately not the browser's, so profile and settings + * never push history entries at the app behind them. That difference is exactly what made the + * store's `params`/`setParam` easy to forget when the routing work added them: nothing in the type + * system objected, because app-shell has no typecheck script (audit P3-1), and every + * `{ $store: 'routeStore.params.x' }` inside a shell surface silently read `undefined`. + * + * These assert against the memory location rather than `window.location`, which is the whole + * point: a shell param that leaked into the browser URL would be a different bug of equal size. + */ +import { createMemoryHistory, MemoryRouter, Route } from '@solidjs/router'; +import { render, waitFor } from '@solidjs/testing-library'; +import { describe, expect, it, vi } from 'vitest'; + +import type { RouteStore } from '../src/frameworks/solid/stores/RouteStore'; +import { + ShellRouteStoreProvider, + ShellRouterRoot, + useShellRouteStore, +} from '../src/frameworks/solid/stores/ShellRouteStore'; + +vi.mock('../src/frameworks/solid/stores/ShellStore', () => ({ + useShellStore: () => ({ takePendingPath: () => undefined }), +})); + +async function mountStore(initial = '/settings'): Promise { + let store!: RouteStore; + const Grab = () => { + store = useShellRouteStore(); + return null; + }; + + // A memory history seeded before mount — the overlay's location, entirely separate from the + // browser's. There is no `initialEntries` on this router; the history *is* the seam. + const history = createMemoryHistory(); + history.set({ value: initial }); + + render(() => ( + + + + + + )); + + // The store's signals start empty; ShellRouterRoot fills them from inside the router context. + await waitFor(() => expect(store?.currentPath()).toBe(initial.split('?')[0])); + return store; +} + +describe('shellRouteStore params', () => { + it('exposes the RouteStore param members at all', async () => { + // The regression itself: these were simply absent from the object literal. + const store = await mountStore(); + expect(typeof store.params).toBe('function'); + expect(typeof store.setParam).toBe('function'); + }); + + it('reads params from the memory location', async () => { + const store = await mountStore('/settings?tab=modules'); + expect(store.params()).toEqual({ tab: 'modules' }); + }); + + it('setParam writes the reactive record, and null removes', async () => { + const store = await mountStore(); + expect(store.params()).toEqual({}); + + store.setParam('tab', 'modules'); + await waitFor(() => expect(store.params()).toEqual({ tab: 'modules' })); + + store.setParam('tab', null); + await waitFor(() => expect(store.params()).toEqual({})); + }); + + it('keeps the overlay out of the browser URL', async () => { + const before = window.location.href; + const store = await mountStore(); + + store.setParam('tab', 'modules'); + await waitFor(() => expect(store.params()).toEqual({ tab: 'modules' })); + + // A shell param reaching window.location would mean the overlay had stopped being isolated. + expect(window.location.href).toBe(before); + }); + + it('leaves the path alone when only a param changes', async () => { + const store = await mountStore(); + + store.setParam('tab', 'modules'); + await waitFor(() => expect(store.params()).toEqual({ tab: 'modules' })); + expect(store.currentPath()).toBe('/settings'); + expect(store.segments()).toEqual(['settings']); + }); +}); diff --git a/packages/app-shell/vitest.config.ts b/packages/app-shell/vitest.config.ts index 8f722782c..f5714c96e 100644 --- a/packages/app-shell/vitest.config.ts +++ b/packages/app-shell/vitest.config.ts @@ -16,6 +16,7 @@ const SOLID_TESTS = [ 'tests/accountStore.test.tsx', 'tests/profileStore.test.tsx', 'tests/routeStore.test.tsx', + 'tests/shellRouteStore.test.tsx', ]; export default defineConfig({ From 72ef86944b50438cb11c12f60f5c04148b6ad9b3 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:10:37 +0100 Subject: [PATCH 04/14] ci: run the schema validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12.6k LOC of template data was gated by `tsc` alone, which cannot see an unknown component type, a misspelled prop, a `$routes` outlet with no `routes` array, or an orphan `$local` — all of which typecheck cleanly and then render nothing. The validator has always existed and been thorough; it was reachable only from the AI editor and the CLI, so nothing ran it on the way in. Placed after Build so workspace dists exist, and before Typecheck so a schema failure reports as itself rather than as a downstream type error. Its roots already include `templates/showcase/`, which is where this branch's edits land. Currently green: 27 schemas, no issues. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2e82c7101..cc51647ed 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -148,6 +148,14 @@ jobs: packages/models/src/generated/coreManifest.ts \ || { echo '::error::Generated files are stale — run `pnpm build` and commit the regenerated output.'; exit 1; } + # 12.6k LOC of template data that `tsc` cannot judge: an unknown component + # type, a misspelled prop, a `$routes` outlet with no `routes` array and an + # orphan `$local` all typecheck cleanly and then render nothing. The + # validator has always existed and been thorough — it was reachable only + # from the AI editor and the CLI, so nothing ran it on the way in. + - name: Validate schemas + run: pnpm validate:schemas + # Runs after Build so every package's dist types exist. Only packages # that define a `typecheck` script participate; coverage is being grown # package by package. From 682d2d421ab357d436e76506b0c9ecb80df14114 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:14:18 +0100 Subject: [PATCH 05/14] =?UTF-8?q?feat(we-preview):=20a=20fourth=20host=20?= =?UTF-8?q?=E2=80=94=20the=20whole=20app,=20over=20an=20in-memory=20backen?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots WE in a headless browser with no executor, no agent setup, no neighbourhood and no network, in a couple of seconds. That is what makes a render → screenshot → adjust loop possible at all; the alternative is a screenshot-and-describe round trip per adjustment. It is the *whole* application, not a template preview. The shell is itself templates — Sidebar, Settings, Profile, BootScreen, TemplateEditor, ModuleRail, marketplace, spaces — so all of it renders and can be clicked around. The only difference from we-web is which BackendConnector PlatformProvider receives. That is the property worth having. The first sketch of this was a harness with stubbed stores, which would have made every screenshot a fiction: templates matched against behaviour the app does not have. The seam already existed one layer down, and `createInMemoryBackendPorts` already implemented every required member of it. Resolves the plan's main open question: TemplateStore and ThemeStore *do* run over the in-memory ports. The executor-free boot suite mocks both — because they pull in the template and theme registries, not because they cannot run — so this was unproven until something mounted them. The dark theme applies and the shell surfaces paint. Deliberately no `we-preview.seed.json`. `templates` is not read at runtime: `generate-templates` compiles the *root* seed's list into a single generated registry for the whole monorepo, so a second seed naming a different set would declare templates this build cannot import. The entry spreads the root seed and overrides only what differs — modules off (Cesium and media devices do not survive a headless screenshot, and a spinning globe makes every render differ from the last), apps off, no ad4m block. Ships with a typecheck script rather than joining the 38 packages without one, and unlike we-web it can actually run: its tsconfig carries the `@shared` / `@solid` path aliases that exist only in Vite, plus the `.glb` ambient declaration the shell's 3D cube needs. Clean at zero errors. Co-Authored-By: Claude Opus 5 (1M context) --- apps/we-preview/.gitignore | 1 + apps/we-preview/README.md | 67 +++++++++++++++++++ apps/we-preview/index.html | 15 +++++ apps/we-preview/package.json | 25 +++++++ apps/we-preview/src/env.d.ts | 13 ++++ apps/we-preview/src/index.tsx | 43 ++++++++++++ .../src/platform/inMemoryConnector.ts | 28 ++++++++ .../src/platform/previewPlatform.ts | 28 ++++++++ apps/we-preview/tsconfig.json | 24 +++++++ apps/we-preview/vite.config.ts | 22 ++++++ pnpm-lock.yaml | 25 +++++++ 11 files changed, 291 insertions(+) create mode 100644 apps/we-preview/.gitignore create mode 100644 apps/we-preview/README.md create mode 100644 apps/we-preview/index.html create mode 100644 apps/we-preview/package.json create mode 100644 apps/we-preview/src/env.d.ts create mode 100644 apps/we-preview/src/index.tsx create mode 100644 apps/we-preview/src/platform/inMemoryConnector.ts create mode 100644 apps/we-preview/src/platform/previewPlatform.ts create mode 100644 apps/we-preview/tsconfig.json create mode 100644 apps/we-preview/vite.config.ts diff --git a/apps/we-preview/.gitignore b/apps/we-preview/.gitignore new file mode 100644 index 000000000..1521c8b76 --- /dev/null +++ b/apps/we-preview/.gitignore @@ -0,0 +1 @@ +dist diff --git a/apps/we-preview/README.md b/apps/we-preview/README.md new file mode 100644 index 000000000..0ddb8c975 --- /dev/null +++ b/apps/we-preview/README.md @@ -0,0 +1,67 @@ +# we-preview — WE with nothing behind it + +The fourth host, beside `we-web` / `we-electron` / `we-tauri`. It runs **the whole application** — +the same ``, the same thirteen stores, the same renderer, the same design system — over +`@we/backend-inmemory` instead of an AD4M executor. + +```sh +pnpm --filter @we/app-preview dev # http://localhost:3100 +pnpm --filter @we/app-preview build +``` + +No executor, no agent setup, no neighbourhood, no network. It boots in a headless browser in a +couple of seconds, which is what makes a render → screenshot → adjust loop possible at all. + +## What it is not + +It is **not** a stripped-down template preview. The shell is itself templates — `Sidebar`, +`Settings`, `Profile`, `BootScreen`, `TemplateEditor`, `ModuleRail`, the marketplace and spaces +surfaces all live in `@we/template-shell` — so all of it renders here, and you can click around. +The only difference from `we-web` is which `BackendConnector` `PlatformProvider` receives. + +That property is the point. A harness with stubbed stores would drift from the real ones, and every +screenshot would then be of a fiction — templates matched against behaviour the application does not +have. Here the stores are the real stores. + +## Why it is a separate app rather than a flag on we-web + +Apps *are* deployments in this monorepo, which is what the seed file expresses. A preview +deployment wants modules off and no `ad4m` block, and it must not drag `@we/backend-inmemory` or +fixture data into the production web bundle — which a runtime `?backend=inmemory` flag would, unless +fought. The cost of the split is one 25-line entry and a platform adapter. + +## The seed is derived, not declared + +There is deliberately no `we-preview.seed.json`. `templates` is not read at runtime: +`pnpm --filter @we/app-shell generate-templates` compiles the **root** seed's list into +`bundledTemplates.generated.ts`, one registry for the whole monorepo. A second seed naming a +different set would declare templates this build cannot import. So `src/index.tsx` spreads the root +seed and overrides only what this host genuinely differs on — `modules: []`, `apps: []`, no `ad4m`. + +Set `modules` back to the root list to photograph module chrome. It is off by default because the +globe mounts Cesium and the call module wants media devices: neither survives a headless screenshot +usefully, and a spinning globe makes every render of the same template differ from the last. + +## What it cannot show truthfully + +Worth knowing before pointing it at shell design work rather than at templates: + +- **Backend-specific settings surfaces render degraded.** `createInMemoryBackendPorts` omits the + optional `runtime` port and this host omits `AccountHost`, so RuntimeSettings, LanguageSettings, + HostSettings and AccountSettings show their capability-gated empty states. Both omissions are + supported states, feature-detected member by member — the same shape the web host has. +- **Join and publish are simulated** against `inmemory://` URIs. Useful rather than limiting: those + flows become screenshottable. +- **The agent starts unlocked.** A locked agent is the port's honest default and what the + executor-free boot suite exercises, but here it would put a password prompt in front of every + screenshot. + +## Typecheck + +```sh +pnpm --filter @we/app-preview typecheck +``` + +Clean, and it typechecks the shell's source along with its own — which `we-web` cannot do, because +its tsconfig lacks the `@shared` / `@solid` path aliases that exist only in Vite (audit P3-1). If +you are copying this app as a starting point, copy its `tsconfig.json` too. diff --git a/apps/we-preview/index.html b/apps/we-preview/index.html new file mode 100644 index 000000000..3d844a1ea --- /dev/null +++ b/apps/we-preview/index.html @@ -0,0 +1,15 @@ + + + + + + WE Preview + + + + +
+ + + + diff --git a/apps/we-preview/package.json b/apps/we-preview/package.json new file mode 100644 index 000000000..455b4f384 --- /dev/null +++ b/apps/we-preview/package.json @@ -0,0 +1,25 @@ +{ + "name": "@we/app-preview", + "version": "0.1.0", + "description": "WE over an in-memory backend — the host the screenshot harness drives", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@we/app-shell": "workspace:*", + "@we/backend-inmemory": "workspace:*", + "@we/backend-shared": "workspace:*", + "solid-js": "^1.9.5" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vite": "^6.0.7", + "vite-plugin-solid": "^2.11.11" + } +} diff --git a/apps/we-preview/src/env.d.ts b/apps/we-preview/src/env.d.ts new file mode 100644 index 000000000..84947217d --- /dev/null +++ b/apps/we-preview/src/env.d.ts @@ -0,0 +1,13 @@ +/// + +/** + * The shell imports a `.glb` for its 3D cube. Vite resolves asset imports to a URL string at build + * time; `vite/client` declares the common extensions but not this one, so a host that actually + * typechecks the shell's source has to say so itself. + * + * we-web needs this too and does not have it — it has no `typecheck` script, so nothing ever asked. + */ +declare module '*.glb' { + const src: string; + export default src; +} diff --git a/apps/we-preview/src/index.tsx b/apps/we-preview/src/index.tsx new file mode 100644 index 000000000..ba4d3c215 --- /dev/null +++ b/apps/we-preview/src/index.tsx @@ -0,0 +1,43 @@ +/* @refresh reload */ +import '@we/app-shell/shared/index.scss'; + +import { App, PlatformProvider, type WeSeedFile } from '@we/app-shell/solid'; +import { render } from 'solid-js/web'; + +import rootSeed from '../../../we-seed.json'; +import { inMemoryConnector } from './platform/inMemoryConnector'; +import { previewPlatform } from './platform/previewPlatform'; + +/** + * The deployment this host runs, derived from the root seed rather than declared beside it. + * + * A separate `we-preview.seed.json` would have been the obvious move and would have been a lie: + * `templates` is not read at runtime. `pnpm --filter @we/app-shell generate-templates` compiles the + * *root* seed's list into `bundledTemplates.generated.ts`, one registry for the whole monorepo, so a + * second seed naming a different set would declare templates this build cannot import. Deriving + * keeps the two in step by construction. + * + * What is overridden is only what this host genuinely differs on: + * + * - **`modules: []`** — the globe mounts Cesium and the call module wants media devices. Neither + * survives a headless screenshot usefully, and a spinning globe would make every render of the + * same template differ from the last. Set it back to the root list to photograph module chrome. + * - **`apps: []`** — embedded apps are iframes onto other dev servers that are not running here. + * - **no `ad4m` block** — there is no executor to point at, which is the entire premise. + */ +const previewSeed: WeSeedFile = { + ...(rootSeed as unknown as WeSeedFile), + project: { ...(rootSeed as unknown as WeSeedFile).project, name: 'WE Preview' }, + modules: [], + apps: [], + ad4m: undefined, +}; + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/apps/we-preview/src/platform/inMemoryConnector.ts b/apps/we-preview/src/platform/inMemoryConnector.ts new file mode 100644 index 000000000..01c052535 --- /dev/null +++ b/apps/we-preview/src/platform/inMemoryConnector.ts @@ -0,0 +1,28 @@ +import type { BackendConnector, BackendInitResult } from '@we/app-shell/shared'; +import { createInMemoryBackendPorts } from '@we/backend-inmemory'; + +/** + * The whole difference between this host and we-web. + * + * we-web's connector runs AD4M's connect choreography — an auth UI, a token, a hosted node — and + * returns a client. This returns the in-memory bundle and nothing else, which is what makes the + * app boot in a headless browser with no executor, no agent setup and no network. + * + * The agent starts **unlocked**. A locked one is the honest default for the port (and what the boot + * suite exercises), but here it would put a password prompt in front of every screenshot. The lock + * flow is a shell surface like any other; a fixture that wants to photograph it can ask for one. + * + * `runtime`, `transcription` and `interop` are absent, exactly as `createInMemoryBackendPorts` + * leaves them. They are feature-detected member by member, so the settings surfaces that would use + * them render their capability-gated empty states — see the preview host's README for what that + * means for anyone pointing this at shell design work rather than at templates. + */ +export const inMemoryConnector: BackendConnector = { + async initialize(ctx): Promise { + const ports = createInMemoryBackendPorts(ctx, { + agent: { id: 'did:preview:me', unlocked: true }, + }); + + return { client: {}, ports }; + }, +}; diff --git a/apps/we-preview/src/platform/previewPlatform.ts b/apps/we-preview/src/platform/previewPlatform.ts new file mode 100644 index 000000000..688c0a2e5 --- /dev/null +++ b/apps/we-preview/src/platform/previewPlatform.ts @@ -0,0 +1,28 @@ +import type { AppConfig, PlatformAdapter } from '@we/app-shell/shared'; + +/** + * The host contract, answered for a browser with nothing behind it. + * + * `accounts` and `executor` are both omitted, which is the same shape the web host has: there is no + * data directory to switch between and no backend process to configure. Every surface that would + * offer those feature-detects and shows nothing, so the omission is a supported state rather than a + * gap — see `accountStore.canManageAccounts` and `runtimeStore.canConfigureExecutor`. + * + * `isDevelopment` is deliberately *not* `import.meta.env.DEV`. This host exists to be screenshotted, + * and a production build of it should behave identically to the dev server it was iterated in; + * anything gated on dev-mode would otherwise appear in one and not the other, which is precisely + * the class of difference a fidelity tool must not have. + */ +export const previewPlatform: PlatformAdapter = { + resolveAppUrl(app: AppConfig, isDevelopment: boolean): string { + if (isDevelopment && app.paths.devServer) { + const host = app.paths.devServer.host || 'localhost'; + return `http://${host}:${app.paths.devServer.port}`; + } + return app.paths.webUrl ?? app.paths.dist; + }, + + isDesktop: false, + isDevelopment: false, + platform: 'web' as const, +}; diff --git a/apps/we-preview/tsconfig.json b/apps/we-preview/tsconfig.json new file mode 100644 index 000000000..bcf1cc672 --- /dev/null +++ b/apps/we-preview/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + // The shell and the template packages import each other with explicit `.ts` extensions. + "allowImportingTsExtensions": true, + "experimentalDecorators": true, + "baseUrl": ".", + "resolveJsonModule": true, + // The shell's own aliases, mirrored from its tsconfig. we-web omits these, which is why it is + // one of the two packages that cannot currently be typechecked at all (audit P3-1): every + // `@shared/*` import inside the shell's source resolves in Vite and not in tsc. + "paths": { + "@shared/*": ["../../packages/app-shell/src/shared/*"], + "@solid/*": ["../../packages/app-shell/src/frameworks/solid/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/we-preview/vite.config.ts b/apps/we-preview/vite.config.ts new file mode 100644 index 000000000..266a79196 --- /dev/null +++ b/apps/we-preview/vite.config.ts @@ -0,0 +1,22 @@ +import path from 'path'; +import { defineConfig } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + assetsInclude: ['**/*.glb'], + plugins: [solidPlugin()], + server: { + // 3000 is we-web, 3200 is the portable-slice playground. Distinct so the preview host can run + // beside a real app — comparing the two is how you find out the preview is lying. + port: 3100, + // The root seed lives above this package. + fs: { allow: ['../..'] }, + }, + build: { target: 'esnext' }, + resolve: { + alias: { + '@shared': path.resolve(__dirname, '../../packages/app-shell/src/shared'), + '@solid': path.resolve(__dirname, '../../packages/app-shell/src/frameworks/solid'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bee555cc..44c8dc8f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,31 @@ importers: specifier: ^8.0.1 version: 8.0.5 + apps/we-preview: + dependencies: + '@we/app-shell': + specifier: workspace:* + version: link:../../packages/app-shell + '@we/backend-inmemory': + specifier: workspace:* + version: link:../../packages/backend-system/inmemory + '@we/backend-shared': + specifier: workspace:* + version: link:../../packages/backend-system/shared + solid-js: + specifier: ^1.9.5 + version: 1.9.14 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.12(solid-js@1.9.14)(vite@6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + apps/we-tauri: dependencies: '@coasys/ad4m': From 1b9289a3abb48b1797885e33a5e34010cf8ed889 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:19:03 +0100 Subject: [PATCH 06/14] feat(backend-inmemory): seedable peer profiles and seedable presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This backend runs one agent, so everything that renders *people* degenerates to a column of one: a feed with a single author, a member list of yourself, a presence roster of yourself. All three render correctly and show nothing about whether a design holds up — which makes them useless as the subject of a screenshot, and every template being matched is a multi-author surface. Two seams, because they are genuinely different things. **Profiles** are a read the directory serves, and `publish` deliberately writes only `ctx.selfId()`'s record — as the real directory does — so a peer's profile had no way to exist at all. `get` on an unseeded DID still returns a blank rather than throwing: a profile that has not arrived yet is the normal case in a real directory too. **Presence** is a message, so seeded peers beat on the same channel and in the same shape a real heartbeat uses, and nothing about `PresenceStore` is special-cased. `SeededPeer` is `PresenceState` minus the two fields the beat owns, so `focus` is stated rather than inferred — which matters, because `online` filters on `focus.datasetUri` and `onlineHere` further filters on `focus.path`, and a peer with no focus is present in the abstract and visible nowhere. They have to keep beating. Presence is self-healing by design — `derivePeers` ages every state out on a TTL — so a single announcement would show a roster that empties itself, and a screenshot would then depend on when it was taken. The interval is 1s rather than the app's 5s: that interval is tuned for the cost of a real broadcast, and there is no network here, so what matters instead is how long after load the roster takes to fill. A subscriber always attaches after the scope it subscribes through exists, so the first beat cannot be synchronous. Two things the tests found rather than confirmed: - The beat registry was keyed on the dataset key alone. `keyFor` counts from zero per bus, so the first dataset of *every* bundle is `ds-0` — two independent backends shared an entry and the second silently never beat. Now keyed by bus (weakly) and dataset. - The bus's `dispose` unsubscribes by *agent*, not by scope, so two scopes sharing `selfId` cannot be told apart by delivery. The refcount test asserts on the timer instead, which is the thing that actually matters: a leaked interval keeps a vitest run alive forever. Both options are absent by default — nothing changes for existing callers. Co-Authored-By: Claude Opus 5 (1M context) --- .../backend-system/inmemory/src/lifecycle.ts | 128 +++++++++++++++- .../inmemory/tests/seeding.test.ts | 142 ++++++++++++++++++ 2 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 packages/backend-system/inmemory/tests/seeding.test.ts diff --git a/packages/backend-system/inmemory/src/lifecycle.ts b/packages/backend-system/inmemory/src/lifecycle.ts index 5ae04cd5a..6a70bc755 100644 --- a/packages/backend-system/inmemory/src/lifecycle.ts +++ b/packages/backend-system/inmemory/src/lifecycle.ts @@ -20,6 +20,7 @@ import type { DatasetRef, EphemeralPort, ModelManifest, + PresenceState, ProfileDirectoryPort, RendererDataBindings, SchemaPort, @@ -219,9 +220,20 @@ export function createInMemorySchemaPort(runtime: EntityRuntime): SchemaPort { }; } -/** Map-backed profile directory; uploads echo a retrievable inmemory URL. */ -export function createInMemoryProfileDirectory(ctx: BackendPortsContext): ProfileDirectoryPort { - const profiles = new Map(); +/** + * Map-backed profile directory; uploads echo a retrievable inmemory URL. + * + * `seed` pre-populates other agents' profiles — the one thing `publish` cannot do, since it writes + * only `ctx.selfId()`'s record, as the real directory does. Without it every peer resolves to a + * blank, and a feed, a member list or a presence roster renders as a column of identical + * initial-less avatars: structurally correct and visually useless. Anything rendering more than one + * person needs this, which is most of what WE renders. + */ +export function createInMemoryProfileDirectory( + ctx: BackendPortsContext, + seed: readonly AgentProfileSummary[] = [], +): ProfileDirectoryPort { + const profiles = new Map(seed.map((p) => [p.did, p])); let uploadCounter = 0; const blank = (did: string): AgentProfileSummary => ({ did, firstName: '', lastName: '', handle: '', bio: '' }); @@ -258,6 +270,97 @@ export interface InMemoryBackendPortsOptions { * manifest, which is what makes `Space.findAll(...)` work in a test with no executor running. */ entities?: ModelManifest | null; + /** + * Other agents' published profiles, by DID. See {@link createInMemoryProfileDirectory} — the + * directory can only publish the *self* profile, so peers have no other way to exist. + */ + profiles?: readonly AgentProfileSummary[]; + /** + * Peers to report as present, and what they are doing. + * + * The ephemeral bus carries one agent — this process — so presence is otherwise always a roster + * of one. `presenceStore.onlineHere` is read directly by templates (the channel header in the + * Discord-shaped template, for one), and a roster showing only yourself is the same failure as a + * feed of one author: it renders, and shows nothing about how the design holds up. + * + * These beat on the same channel a real peer's heartbeat uses, so nothing about the store is + * special-cased — see {@link startSeededPresence} for why they have to keep beating. + */ + presence?: readonly SeededPeer[]; +} + +/** + * A peer to announce on the ephemeral bus — a {@link PresenceState} without the two fields the + * heartbeat owns (`agentId` comes from `did`, `updatedAt` is stamped on every beat). + * + * `focus` is what decides whether a peer shows up at all: `presenceStore.online` filters on + * `focus.datasetUri` and `onlineHere` further filters on `focus.path`. A seeded peer with no focus + * is present in the abstract and visible nowhere. + */ +export interface SeededPeer extends Omit { + did: string; +} + +/** + * Beat seeded peers onto a dataset's presence channel until every scope over it is disposed. + * + * Repeating rather than announcing once, for two reasons. Presence is self-healing by design — + * `derivePeers` ages every state out on a TTL — so a single delivery would show a roster that + * empties itself a few seconds later, and a screenshot would then depend on when it was taken. And + * a subscriber that has not attached yet receives nothing, so a one-shot at connect races the + * store's own setup. + * + * Refcounted per dataset because a leaked interval keeps a vitest run alive forever, which is a + * worse failure than the one this exists to fix. + */ +function startSeededPresence(bus: InMemoryBus, dataset: unknown, peers: readonly SeededPeer[]): () => void { + // Keyed by bus *and* dataset. `keyFor` counts from zero per bus, so the first dataset of every + // bundle is `ds-0` — a registry keyed on that alone has two independent backends sharing an entry, + // and the second one silently never beats. Found by the tests below running in sequence. + const beats = seededPresence.get(bus) ?? new Map(); + seededPresence.set(bus, beats); + + const key = bus.keyFor(dataset); + const existing = beats.get(key); + if (existing) { + existing.refs += 1; + return () => release(beats, key); + } + + const beat = () => { + for (const { did, ...state } of peers) { + bus.deliver(key, 'presence', did, { ...state, agentId: did, updatedAt: Date.now() } satisfies PresenceState); + } + }; + + // Faster than the app's own 5s DEFAULT_HEARTBEAT_INTERVAL, deliberately. That interval is tuned + // for the cost of a broadcast over a real network; there is no network here, and what matters + // instead is how long after load a screenshot has to wait for the roster to fill. A subscriber + // always attaches *after* the scope it subscribes through is created, so the first beat cannot be + // synchronous — one second is the ceiling on that gap, and still far inside the 15s idle + // threshold, so a seeded peer reads `online` in every render. + const timer = setInterval(beat, 1_000); + beats.set(key, { refs: 1, timer }); + // Harmless for a subscriber that is somehow already attached, and free otherwise. + beat(); + return () => release(beats, key); +} + +interface PresenceBeat { + refs: number; + timer: ReturnType; +} + +/** Weak on the bus so a discarded bundle takes its beats with it. */ +const seededPresence = new WeakMap>(); + +function release(beats: Map, key: string): void { + const entry = beats.get(key); + if (!entry) return; + entry.refs -= 1; + if (entry.refs > 0) return; + clearInterval(entry.timer); + beats.delete(key); } /** @@ -288,8 +391,21 @@ export function createInMemoryBackendPorts( // One bus per bundle; the per-agent port is constructed lazily so the agent id is read after // the session unlocks (mirrors the AD4M port's lazy selfId). const bus = new InMemoryBus(); - const ephemeral: EphemeralPort = (dataset) => - createInMemoryEphemeralPort(bus, ctx.selfId() ?? 'did:inmemory:anonymous')(dataset); + const ephemeral: EphemeralPort = (dataset) => { + const scope = createInMemoryEphemeralPort(bus, ctx.selfId() ?? 'did:inmemory:anonymous')(dataset); + if (!scope || !opts.presence?.length) return scope; + + // Seeded peers beat for as long as somebody is listening to this dataset, and the scope's own + // dispose is the only signal for that — hence the wrap rather than starting them at connect. + const stop = startSeededPresence(bus, dataset, opts.presence); + return { + ...scope, + dispose() { + stop(); + scope.dispose(); + }, + }; + }; const mutationDataset = (deps: DataBindingDeps, opts?: Record): unknown => { const explicit = opts?.perspective as { handle?: unknown } | undefined; @@ -301,7 +417,7 @@ export function createInMemoryBackendPorts( agentSession: createInMemoryAgentSession(opts.agent), lifecycle, schemas, - profiles: createInMemoryProfileDirectory(ctx), + profiles: createInMemoryProfileDirectory(ctx, opts.profiles), ephemeral, dataBindings: (deps) => ({ $currentDataset: deps.currentDataset, diff --git a/packages/backend-system/inmemory/tests/seeding.test.ts b/packages/backend-system/inmemory/tests/seeding.test.ts new file mode 100644 index 000000000..219519d32 --- /dev/null +++ b/packages/backend-system/inmemory/tests/seeding.test.ts @@ -0,0 +1,142 @@ +/** + * Seeded peers — other agents' profiles, and other agents being present. + * + * Both exist for the same reason: this backend runs one agent, so anything rendering *people* + * degenerates to a column of one. A feed with a single author and a presence roster containing only + * yourself both render correctly and show nothing about whether the design holds up, which makes + * them useless as the subject of a screenshot. + * + * The two halves are genuinely different seams. A profile is a *read* the directory serves, and it + * cannot be published because `publish` writes only `ctx.selfId()` — as the real directory does. + * Presence is a *message*, so it has to arrive over the bus the way a heartbeat would, and keep + * arriving, because presence ages itself out on a TTL by design. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createInMemoryBackendPorts } from '../src/lifecycle'; + +const ME = 'did:test:me'; +const ADA = 'did:test:ada'; +const BO = 'did:test:bo'; + +const PROFILES = [ + { did: ADA, firstName: 'Ada', lastName: 'Lovelace', handle: 'ada', bio: 'notes', avatar: 'inmemory://ada.png' }, + { did: BO, firstName: 'Bo', lastName: 'Diddley', handle: 'bo', bio: '' }, +]; + +function makePorts(extra: Parameters[1] = {}) { + return createInMemoryBackendPorts( + { selfId: () => ME }, + { agent: { id: ME, unlocked: true }, datasets: [{ id: 'ds-main', name: 'Main' }], ...extra }, + ); +} + +describe('seeded profiles', () => { + it('serves a seeded peer, and still blanks an unknown one', async () => { + const ports = makePorts({ profiles: PROFILES }); + + expect(await ports.profiles.get(ADA)).toMatchObject({ did: ADA, firstName: 'Ada', handle: 'ada' }); + // Unseeded agents keep the existing behaviour — a blank record, never a throw, because a + // profile that has not arrived yet is the normal case in a real directory too. + expect(await ports.profiles.get('did:test:nobody')).toEqual({ + did: 'did:test:nobody', + firstName: '', + lastName: '', + handle: '', + bio: '', + }); + }); + + it('leaves publish writing only the self profile', async () => { + const ports = makePorts({ profiles: PROFILES }); + + await ports.profiles.publish({ firstName: 'Me' }); + + expect(await ports.profiles.get(ME)).toMatchObject({ firstName: 'Me' }); + // The seam exists precisely because this is impossible: publishing must not be able to + // overwrite somebody else, here or in the real directory. + expect(await ports.profiles.get(ADA)).toMatchObject({ firstName: 'Ada' }); + }); + + it('is absent by default, so nothing changes for callers that do not ask', async () => { + expect(await makePorts().profiles.get(ADA)).toMatchObject({ firstName: '', handle: '' }); + }); +}); + +describe('seeded presence', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const PEERS = [ + { did: ADA, availability: 'available' as const, focus: { datasetUri: 'inmemory://ds-main', path: '/channel/1' } }, + { did: BO, availability: 'available' as const, focus: { datasetUri: 'inmemory://ds-main' } }, + ]; + + async function subscribe(ports: ReturnType) { + const dataset = (await ports.lifecycle.get('ds-main'))!; + const scope = ports.ephemeral(dataset.handle)!; + const received: Array<{ from: string; payload: unknown }> = []; + scope.channel('presence').onMessage((from, payload) => received.push({ from, payload })); + return { scope, received }; + } + + it('announces each peer as itself, with a stamped heartbeat', async () => { + const ports = makePorts({ presence: PEERS }); + const { received } = await subscribe(ports); + + // A subscriber always attaches after the scope it subscribes through exists, so the roster is + // empty for up to one beat. That gap is the reason the interval is 1s rather than the app's 5s. + expect(received).toEqual([]); + await vi.advanceTimersByTimeAsync(1_000); + + expect(received.map((r) => r.from)).toEqual([ADA, BO]); + expect(received[0].payload).toMatchObject({ + agentId: ADA, + availability: 'available', + focus: { datasetUri: 'inmemory://ds-main', path: '/channel/1' }, + }); + expect((received[0].payload as { updatedAt: number }).updatedAt).toBeTypeOf('number'); + }); + + it('keeps beating, because presence ages itself out', async () => { + const ports = makePorts({ presence: PEERS }); + const { received } = await subscribe(ports); + + await vi.advanceTimersByTimeAsync(3_000); + + // Two peers, three beats. A one-shot announcement would leave the roster emptying itself on the + // liveness TTL, so a screenshot's contents would depend on when it was taken. + expect(received).toHaveLength(6); + expect(new Set(received.map((r) => r.from))).toEqual(new Set([ADA, BO])); + }); + + it('runs one beat per dataset, and stops it only when the last scope goes', async () => { + const ports = makePorts({ presence: PEERS }); + const dataset = (await ports.lifecycle.get('ds-main'))!; + + const before = vi.getTimerCount(); + const first = ports.ephemeral(dataset.handle)!; + const second = ports.ephemeral(dataset.handle)!; + + // Two scopes over one dataset share a single heartbeat — peers are a property of the dataset, + // not of who is watching it. + expect(vi.getTimerCount()).toBe(before + 1); + + first.dispose(); + expect(vi.getTimerCount()).toBe(before + 1); + + second.dispose(); + // Asserted on the timer rather than on delivery, because the bus's `dispose` unsubscribes by + // *agent*, not by scope: both scopes here share `ctx.selfId()`, so the first dispose already + // removed the second's listener. A leaked interval keeps a vitest run alive forever, which is a + // worse failure than the empty roster this whole seam exists to fix. + expect(vi.getTimerCount()).toBe(before); + }); + + it('does not touch the bus when no peers are seeded', async () => { + const ports = makePorts(); + const { received } = await subscribe(ports); + await vi.advanceTimersByTimeAsync(20_000); + expect(received).toEqual([]); + }); +}); From e6ba5c38b7df28ae14fbd048ab18dea7805229fd Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:36:44 +0100 Subject: [PATCH 07/14] feat(fixtures,we-preview): a populated showcase template, rendering headlessly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview host now paints a Discord-shaped channel with real content in it: distinct authors and avatars, fixed timestamps, multi-paragraph and one-word messages side by side, signal counts, and a rail with categories. That is the whole point — an empty template screenshots as an empty state and says nothing about density or rhythm, which is most of what makes a UI recognisable. `@we/template-fixtures` is the format, expressed in model terms and applied through `@we/models` rather than as rows, so the same fixture can later seed a real perspective or generate marketplace previews. Everything it writes is deterministic — dataset id, space uuid, every node id — because the backend is in memory and re-mints on every load: a shoot script that had to *discover* the id of `#general` would need a second load, which would produce different ids again. Four things had to be fixed to get a single frame, all found by looking at one: - **`scope` was declared unsupported by the in-memory adapter.** The shared engine has always been able to execute a drill-down (`scopeRows`); only the lowering could not express one, and `rowsFor` silently dropped `scope` into `...rest`. So a scoped query answered a different question — every message in the space rather than one channel's. That cost the showcase templates every drill-down they have. - **Untyped relations were omitted from the engine's relation map**, on the grounds that there is nothing to hydrate against. True, and it took `scope` down with it, since a drill-down needs only the foreign key. `CollectionBlock.children` is untyped by design, so containment did not work at all — and `addChildren` never wrote the link either, because it looked for the child in a target table the relation does not name. - **The space was invisible to the template resolver.** It matches a *shared* dataset by `Space.url === dataset.sharedId`, falling back to `uuid` only for a personal one, so a fixture setting `uuid` alone rendered under the default template while looking entirely correct. - **No URL can express "this space, that route".** `buildRoutes` mounts template routes at the router root, so the Discord template owns `/channel/:channelId` outright — while `navigateToSpace` builds `/space//`, a shape only the default template's own `/space/:spaceId` route satisfies. `PreviewBootstrap` states both halves instead (select dataset, then navigate), which is why the root is composed from `StoreProvider` rather than the packaged ``: stores do not exist outside it. The inconsistency itself is real and left where it is. Also recorded as a test rather than fixed: `createBlocks` encodes editor state as UTF-8 and `decodeEditorState` reads it back with a bare `atob`, so any post with a non-ASCII character renders mojibake in the running app. Co-Authored-By: Claude Opus 5 (1M context) --- apps/we-preview/package.json | 6 +- apps/we-preview/src/PreviewBootstrap.tsx | 51 +++++ apps/we-preview/src/index.tsx | 22 ++- .../src/platform/inMemoryConnector.ts | 71 ++++++- .../backend-system/inmemory/src/entities.ts | 35 ++-- packages/backend-system/inmemory/src/index.ts | 1 + .../inmemory/src/queryAdapter.ts | 18 +- .../inmemory/tests/seeding.test.ts | 31 +++ packages/templates/fixtures/package.json | 22 +++ packages/templates/fixtures/src/apply.ts | 182 ++++++++++++++++++ packages/templates/fixtures/src/discord.ts | 147 ++++++++++++++ .../templates/fixtures/src/editorState.ts | 71 +++++++ packages/templates/fixtures/src/index.ts | 26 +++ packages/templates/fixtures/src/types.ts | 125 ++++++++++++ .../templates/fixtures/tests/apply.test.ts | 133 +++++++++++++ packages/templates/fixtures/tsconfig.json | 9 + pnpm-lock.yaml | 32 +++ 17 files changed, 958 insertions(+), 24 deletions(-) create mode 100644 apps/we-preview/src/PreviewBootstrap.tsx create mode 100644 packages/templates/fixtures/package.json create mode 100644 packages/templates/fixtures/src/apply.ts create mode 100644 packages/templates/fixtures/src/discord.ts create mode 100644 packages/templates/fixtures/src/editorState.ts create mode 100644 packages/templates/fixtures/src/index.ts create mode 100644 packages/templates/fixtures/src/types.ts create mode 100644 packages/templates/fixtures/tests/apply.test.ts create mode 100644 packages/templates/fixtures/tsconfig.json diff --git a/apps/we-preview/package.json b/apps/we-preview/package.json index 455b4f384..8c815eea3 100644 --- a/apps/we-preview/package.json +++ b/apps/we-preview/package.json @@ -1,7 +1,7 @@ { "name": "@we/app-preview", "version": "0.1.0", - "description": "WE over an in-memory backend — the host the screenshot harness drives", + "description": "WE over an in-memory backend \u2014 the host the screenshot harness drives", "private": true, "license": "MIT", "type": "module", @@ -15,9 +15,13 @@ "@we/app-shell": "workspace:*", "@we/backend-inmemory": "workspace:*", "@we/backend-shared": "workspace:*", + "@we/components": "workspace:*", + "@we/models": "workspace:*", + "@we/template-fixtures": "workspace:*", "solid-js": "^1.9.5" }, "devDependencies": { + "playwright-core": "^1.62.1", "typescript": "^5.7.2", "vite": "^6.0.7", "vite-plugin-solid": "^2.11.11" diff --git a/apps/we-preview/src/PreviewBootstrap.tsx b/apps/we-preview/src/PreviewBootstrap.tsx new file mode 100644 index 000000000..7c1a7242f --- /dev/null +++ b/apps/we-preview/src/PreviewBootstrap.tsx @@ -0,0 +1,51 @@ +import { useDatasetStore, useRouteStore, useSessionStore, useShellStore } from '@we/app-shell/solid'; +import { createEffect } from 'solid-js'; + +/** + * Puts the app on the fixture's space and route, once the boot has finished. + * + * ## Why this is not just a URL + * + * `buildRoutes` mounts a template's routes at the **router root**, so the Discord-shaped template + * owns `/channel/:channelId` outright. But `spaceStore.navigateToSpace` builds + * `/space//`, and *that* shape only resolves because the default template happens to + * declare a `/space/:spaceId` route. No showcase template declares one, so none of them can be + * deep-linked: `/space/x/channel/y` falls through to the catch-all, and `/channel/y` renders the + * right route against the wrong (unselected) dataset. Neither URL alone can express "this space, + * that route". + * + * That is a real inconsistency in the app and worth fixing there — a template mounted at the root + * cannot coexist with a space prefix the shell adds on its behalf. It is not this branch's to fix, + * so the preview host states both halves explicitly instead: select the dataset, then navigate. + * + * Doing it in a component rather than in the connector is what makes it possible at all — stores + * only exist inside `StoreProvider`, which is why `src/index.tsx` composes the root from + * `StoreProvider` + `TemplateProvider` rather than using the packaged ``. + */ +export function PreviewBootstrap(props: { datasetId: string; route: string }) { + const session = useSessionStore(); + const datasetStore = useDatasetStore(); + const routeStore = useRouteStore(); + const shellStore = useShellStore(); + + let done = false; + + createEffect(() => { + // `bootState` rather than a timer: datasets are loaded and spaces are read by the time it says + // ready, and anything earlier races the very work it depends on. + if (done || session.bootState() !== 'ready') return; + if (!datasetStore.datasetsLoaded()) return; + done = true; + + void (async () => { + await datasetStore.switchDataset(props.datasetId); + routeStore.navigate(props.route); + // The shell deliberately boots onto the landing-page overlay, which sits *over* the template. + // Correct for the real app — a first-run user should meet the pitch, not an empty space — and + // wrong for a host whose entire job is photographing what is underneath it. + shellStore.closeShellView(); + })(); + }); + + return null; +} diff --git a/apps/we-preview/src/index.tsx b/apps/we-preview/src/index.tsx index ba4d3c215..47ccf255e 100644 --- a/apps/we-preview/src/index.tsx +++ b/apps/we-preview/src/index.tsx @@ -1,12 +1,15 @@ /* @refresh reload */ import '@we/app-shell/shared/index.scss'; -import { App, PlatformProvider, type WeSeedFile } from '@we/app-shell/solid'; +import { PlatformProvider, StoreProvider, TemplateProvider, type WeSeedFile } from '@we/app-shell/solid'; +import { ToastContainer } from '@we/components/solid'; +import { datasetIdFor, FIXTURES, pathFor } from '@we/template-fixtures'; import { render } from 'solid-js/web'; import rootSeed from '../../../we-seed.json'; -import { inMemoryConnector } from './platform/inMemoryConnector'; +import { inMemoryConnector, requestedFixture } from './platform/inMemoryConnector'; import { previewPlatform } from './platform/previewPlatform'; +import { PreviewBootstrap } from './PreviewBootstrap'; /** * The deployment this host runs, derived from the root seed rather than declared beside it. @@ -33,10 +36,23 @@ const previewSeed: WeSeedFile = { ad4m: undefined, }; +const fixture = requestedFixture(); + +/** + * The root, composed rather than the packaged ``. + * + * `` is exactly `StoreProvider > TemplateProvider + ToastContainer`; spelling it out is what + * lets {@link PreviewBootstrap} sit *inside* the store scope, which it has to, because selecting the + * fixture's dataset and route is store work. See its docstring for why a URL cannot do it. + */ render( () => ( - + + + + + ), document.getElementById('root')!, diff --git a/apps/we-preview/src/platform/inMemoryConnector.ts b/apps/we-preview/src/platform/inMemoryConnector.ts index 01c052535..34deb1cee 100644 --- a/apps/we-preview/src/platform/inMemoryConnector.ts +++ b/apps/we-preview/src/platform/inMemoryConnector.ts @@ -1,12 +1,14 @@ import type { BackendConnector, BackendInitResult } from '@we/app-shell/shared'; -import { createInMemoryBackendPorts } from '@we/backend-inmemory'; +import { createInMemoryBackendPorts, type SeededPeer } from '@we/backend-inmemory'; +import { getModel } from '@we/models'; +import { applyFixture, datasetIdFor, FIXTURES, type Fixture, type FixtureId } from '@we/template-fixtures'; /** * The whole difference between this host and we-web. * * we-web's connector runs AD4M's connect choreography — an auth UI, a token, a hosted node — and - * returns a client. This returns the in-memory bundle and nothing else, which is what makes the - * app boot in a headless browser with no executor, no agent setup and no network. + * returns a client. This returns the in-memory bundle and nothing else, which is what makes the app + * boot in a headless browser with no executor, no agent setup and no network. * * The agent starts **unlocked**. A locked one is the honest default for the port (and what the boot * suite exercises), but here it would put a password prompt in front of every screenshot. The lock @@ -14,15 +16,74 @@ import { createInMemoryBackendPorts } from '@we/backend-inmemory'; * * `runtime`, `transcription` and `interop` are absent, exactly as `createInMemoryBackendPorts` * leaves them. They are feature-detected member by member, so the settings surfaces that would use - * them render their capability-gated empty states — see the preview host's README for what that - * means for anyone pointing this at shell design work rather than at templates. + * them render their capability-gated empty states — see the README for what that means for anyone + * pointing this at shell design work rather than at templates. */ + +/** Which fixture to load, from `?fixture=`. Defaults to the first — the host must show *something*. */ +export function requestedFixture(): Fixture { + const id = new URLSearchParams(window.location.search).get('fixture') as FixtureId | null; + if (id && id in FIXTURES) return FIXTURES[id]; + if (id) console.warn(`[we-preview] no fixture '${id}' — have ${Object.keys(FIXTURES).join(', ')}`); + return Object.values(FIXTURES)[0]; +} + export const inMemoryConnector: BackendConnector = { async initialize(ctx): Promise { + const fixture = requestedFixture(); + const datasetId = datasetIdFor(fixture); + + // Filled after the fixture is applied, and read later — when the presence store opens a scope + // on this dataset, which happens well after boot. The array identity is what matters, so the + // beat picks up peers that did not exist when the ports were built. + const presence: SeededPeer[] = []; + const ports = createInMemoryBackendPorts(ctx, { agent: { id: 'did:preview:me', unlocked: true }, + // Seeded with a `sharedUri` rather than created and published, so the id is knowable before + // boot — the shoot script navigates straight to `/space//...` on first load, and an + // in-memory backend re-mints everything on every load. + datasets: [{ id: datasetId, name: fixture.space.name, sharedUri: `inmemory://${datasetId}` }], + profiles: fixture.agents.map((agent) => ({ + did: agent.did, + firstName: agent.firstName, + lastName: agent.lastName ?? '', + handle: agent.handle, + bio: agent.bio ?? '', + ...(agent.avatar ? { avatar: agent.avatar } : {}), + })), + presence, }); + const dataset = await ports.lifecycle.get(datasetId); + if (!dataset) throw new Error(`[we-preview] seeded dataset '${datasetId}' is missing`); + + const applied = await applyFixture( + { getModel, dataset: dataset.handle, datasetId, sharedId: dataset.sharedId }, + fixture, + ); + + presence.push( + ...(fixture.presence ?? []).map((peer) => ({ + did: peer.did, + availability: 'available' as const, + // `online` filters on the dataset uri and `onlineHere` further on the path — a peer with + // neither is present in the abstract and visible nowhere. + focus: { datasetUri: `inmemory://${datasetId}`, ...(peer.path ? { path: peer.path } : {}) }, + })), + ); + + // How the shoot script knows where to go without loading the page twice. Everything here is + // derived from the fixture, so it is also knowable ahead of time — this is a convenience and a + // cross-check, not the source of truth. + (window as unknown as Record).__wePreview = { + fixture: fixture.id, + templateId: fixture.templateId, + datasetId, + path: applied.path, + nodes: applied.nodes, + }; + return { client: {}, ports }; }, }; diff --git a/packages/backend-system/inmemory/src/entities.ts b/packages/backend-system/inmemory/src/entities.ts index e78a8b9f5..2072bd82e 100644 --- a/packages/backend-system/inmemory/src/entities.ts +++ b/packages/backend-system/inmemory/src/entities.ts @@ -133,15 +133,21 @@ export function compileEntities(manifest: ModelManifest, runtime: EntityRuntime) foreignKey: cardinality === 'one' ? relName : manyForeignKey(name, relName), }; infos.push(info); - // An untyped relation (no declared target) has nothing to hydrate against, so it stays out - // of the engine's map rather than resolving to an empty table and reading as "no results". - if (info.target) { - engineRelations[name][relName] = { - target: info.target, - cardinality, - foreignKey: info.foreignKey, - } satisfies InMemoryRelation; - } + // Registered even when untyped (no declared target). It used to be omitted, on the grounds + // that there is nothing to *hydrate* against — true, and it also took `scope` down with it, + // because a drill-down needs only the foreign key and resolves through this same map. The + // engine fails a drill-down closed, so an unregistered relation meant "this container has no + // children" rather than an error, and `CollectionBlock.children` — untyped by design, since a + // collection holds any block type — is the relation every showcase template drills through. + // + // The cost is that an `include` over an untyped relation now resolves to `[]` rather than + // being absent. That case is already documented as unsupported (a relation with no declared + // target cannot say which table to read), and both spellings render as nothing. + engineRelations[name][relName] = { + target: info.target, + cardinality, + foreignKey: info.foreignKey, + } satisfies InMemoryRelation; } relationsByEntity[name] = infos; } @@ -226,7 +232,7 @@ export function compileEntities(manifest: ModelManifest, runtime: EntityRuntime) } static rowsFor(dataset: DatasetEntry, query: Record = {}): AnyRow[] { - const { where, order, limit, offset, include, ...rest } = query; + const { where, order, limit, offset, include, scope, ...rest } = query; void rest; const { ir, unsupported } = compileQuery({ entity: name, @@ -235,6 +241,10 @@ export function compileEntities(manifest: ModelManifest, runtime: EntityRuntime) ...(typeof limit === 'number' ? { limit } : {}), ...(typeof offset === 'number' ? { offset } : {}), ...(include ? { include: include as Record } : {}), + // A drill-down the engine has always been able to execute (`scopeRows`) and this layer + // silently dropped into `rest` — so a scoped query answered as if it were unscoped, + // returning every row in the table rather than one container's children. + ...(scope ? { scope: scope as Parameters[0]['scope'] } : {}), }); if (unsupported.length) { throw new Error( @@ -333,9 +343,12 @@ export function compileEntities(manifest: ModelManifest, runtime: EntityRuntime) if (row) row[relation.foreignKey] = relatedId; this[relation.name] = relatedId; } else { + // An untyped relation does not say which table the child is in, so look in all of them. + // Without this the link was never written at all: `addChildren` updated the in-memory + // instance array and nothing else, so containment vanished on the next read. const targetRow = relation.target ? tableOf(dataset, relation.target).find((r) => r.id === relatedId) - : undefined; + : Object.values(dataset.tables).flatMap((rows) => rows as AnyRow[]).find((r) => r.id === relatedId); if (targetRow) targetRow[relation.foreignKey] = this.id; const current = Array.isArray(this[relation.name]) ? (this[relation.name] as unknown[]) : []; if (!current.includes(relatedId)) this[relation.name] = [...current, relatedId]; diff --git a/packages/backend-system/inmemory/src/index.ts b/packages/backend-system/inmemory/src/index.ts index 27a41d4af..346f020f4 100644 --- a/packages/backend-system/inmemory/src/index.ts +++ b/packages/backend-system/inmemory/src/index.ts @@ -102,5 +102,6 @@ export { type InMemoryBackendPortsOptions, type InMemoryDatasetSeed, type InMemoryLifecycle, + type SeededPeer, } from './lifecycle'; export { inMemoryCapabilities, inMemoryQueryAdapter } from './queryAdapter'; diff --git a/packages/backend-system/inmemory/src/queryAdapter.ts b/packages/backend-system/inmemory/src/queryAdapter.ts index 7d29dbf5e..f17fa28bd 100644 --- a/packages/backend-system/inmemory/src/queryAdapter.ts +++ b/packages/backend-system/inmemory/src/queryAdapter.ts @@ -13,14 +13,21 @@ import { // The in-memory backend consumes the flat `$query` dialect (run() re-compiles it via executeQueryIR), // so its adapter lowers with the neutral `irToFlatQuery`. Capabilities mirror what that flat lowering -// expresses — relation filters / non-count aggregates / scope stay gaps (irToFlatQuery throws on them), +// expresses — relation filters and non-count aggregates stay gaps (irToFlatQuery throws on them), // which the renderer then falls back on. This is a real, AD4M-free QueryAdapter — it exercises the // same renderer path the AD4M adapter does. +// +// `scope` is the exception, and is handled rather than declined: the shared engine has always been +// able to execute a drill-down (`scopeRows`), and only the *lowering* could not express one, because +// `irToFlatQuery` refuses a `scope` it cannot resolve to a backend predicate. This dialect needs no +// resolution — the re-compile on the other side carries `scope` straight back into the IR — so it is +// carried across the flat form instead of being lost. Declaring it unsupported cost the showcase +// templates every drill-down they have: a channel's messages, a board column's cards. export const inMemoryCapabilities: AdapterCapabilities = { operators: ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin', 'contains', 'exists'], booleanCombinators: true, relationFilters: false, - scope: false, + scope: true, include: { supported: true }, aggregate: ['count'], sort: { multiKey: true, byRelationPath: true, byAggregate: true }, @@ -32,8 +39,11 @@ export const inMemoryQueryAdapter: QueryAdapter = { capabilities: inMemoryCapabilities, plan: (ir: QueryIR) => planQuery(ir, inMemoryCapabilities), lower: (ir: QueryIR) => { - const { entity: _entity, ...opts } = irToFlatQuery(ir); + // Lowered around `irToFlatQuery` rather than through it: it throws on `scope` by design, since + // resolving `via` to a predicate is adapter work it cannot do. Here there is nothing to resolve. + const { scope, ...rest } = ir; + const { entity: _entity, ...opts } = irToFlatQuery(rest as QueryIR); void _entity; - return opts; + return scope ? { ...opts, scope } : opts; }, }; diff --git a/packages/backend-system/inmemory/tests/seeding.test.ts b/packages/backend-system/inmemory/tests/seeding.test.ts index 219519d32..17018ecdb 100644 --- a/packages/backend-system/inmemory/tests/seeding.test.ts +++ b/packages/backend-system/inmemory/tests/seeding.test.ts @@ -13,6 +13,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getModel } from '@we/models'; + import { createInMemoryBackendPorts } from '../src/lifecycle'; const ME = 'did:test:me'; @@ -140,3 +142,32 @@ describe('seeded presence', () => { expect(received).toEqual([]); }); }); + +describe('scope drill-down', () => { + it('returns one container\'s children, not the whole table', async () => { + const ports = makePorts(); + const dataset = (await ports.lifecycle.get('ds-main'))!; + const handle = dataset.handle; + + const CollectionBlock = getModel('CollectionBlock') as unknown as { + create(h: unknown, d: Record): Promise<{ id: string; addChildren(x: unknown): Promise }>; + findAll(h: unknown, q?: Record): Promise>; + }; + + const channelA = await CollectionBlock.create(handle, { id: 'a', kind: 'channel', title: 'a' }); + const channelB = await CollectionBlock.create(handle, { id: 'b', kind: 'channel', title: 'b' }); + const inA = await CollectionBlock.create(handle, { id: 'a1', kind: 'message' }); + const inB = await CollectionBlock.create(handle, { id: 'b1', kind: 'message' }); + await channelA.addChildren(inA); + await channelB.addChildren(inB); + + const scoped = await CollectionBlock.findAll(handle, { + where: { kind: 'message' }, + scope: { anchor: 'CollectionBlock', via: 'children', anchorId: 'a' }, + }); + + // The adapter used to declare `scope: false`, so this query lowered without it and answered a + // different question — every message in the space, in every channel. + expect(scoped.map((r) => r.id)).toEqual(['a1']); + }); +}); diff --git a/packages/templates/fixtures/package.json b/packages/templates/fixtures/package.json new file mode 100644 index 000000000..1fd03a2f6 --- /dev/null +++ b/packages/templates/fixtures/package.json @@ -0,0 +1,22 @@ +{ + "name": "@we/template-fixtures", + "version": "0.1.0", + "description": "Sample content for the showcase templates — one format, applied to any backend", + "private": true, + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "typescript": "^5.7.2", + "vitest": "^4.0.15" + } +} diff --git a/packages/templates/fixtures/src/apply.ts b/packages/templates/fixtures/src/apply.ts new file mode 100644 index 000000000..37c6b8c87 --- /dev/null +++ b/packages/templates/fixtures/src/apply.ts @@ -0,0 +1,182 @@ +/** + * Writing a fixture into a dataset, through the model layer the app itself writes through. + * + * Not raw rows. `@we/models` classes are compiled from one manifest into row-backed classes on the + * in-memory backend and triple-backed ones on AD4M, so going through them is what makes a fixture + * portable — and, more immediately, what stops a fixture from producing rows that no code path in + * the app could have produced. A fixture that writes a shape the composer cannot create is a + * fixture that photographs something users will never see. + * + * Everything it writes is **deterministic**: the dataset id, the space uuid and every node id are + * derived from the fixture rather than minted. The backend is in memory, so ids are remade on every + * load; without that property a screenshot script could not navigate to a route without first + * loading the page to discover it, and the second load would produce different ids anyway. + */ +import type { Fixture, FixtureNode } from './types'; +import { editorState, textContent } from './editorState'; + +/** The pieces of the host a fixture needs. Passed in rather than imported, so this stays neutral. */ +export interface ApplyDeps { + /** Resolves a model class by name — `getModel` from `@we/models`. */ + getModel(name: string): ModelClass; + /** The dataset to write into, as the backend's own handle. */ + dataset: unknown; + /** That dataset's id. Use {@link datasetIdFor} to know it before the dataset exists. */ + datasetId: string; + /** + * The dataset's *shared* id, when it has one. + * + * Not decoration. `TemplateStore.resolveSpaceFromPerspective` matches a shared dataset to its + * Space by `Space.url === dataset.sharedId`, and only falls back to `uuid === dataset.id` for a + * personal one. A shared fixture space that sets `uuid` alone is therefore invisible to the + * template resolver — the space appears in the sidebar, the content is all there, and the app + * quietly renders the *default* template over it. Cost an hour; hence this comment. + */ + sharedId?: string; +} + +/** + * Only what this file touches. Deliberately *not* an index signature: `Ad4mModel` has none, so one + * here would make the real `getModel` unassignable and force every host to cast. + */ +interface ModelInstance { + id: string; + addChildren?(related: unknown): Promise; + addSignals?(related: unknown): Promise; +} + +interface ModelClass { + create(handle: unknown, data: Record): Promise; +} + +export interface AppliedFixture { + datasetId: string; + /** Every node written, in creation order. */ + nodes: Array<{ id: string; kind: string; title?: string }>; + /** The path this fixture's route lands on, e.g. `/channel/discord-general`. */ + path: string; +} + +/** The dataset a fixture lives in. Knowable before anything is applied, and stable across loads. */ +export function datasetIdFor(fixture: Pick): string { + return `preview-${fixture.id}`; +} + +/** + * The path the fixture's route lands on. + * + * The route as written, with no space prefix: `buildRoutes` mounts a template's routes at the + * router *root*, so `/channel/:channelId` is exactly that. The `/space//` shape + * `navigateToSpace` builds is a different convention that only the default template's own + * `/space/:spaceId` route satisfies — see `PreviewBootstrap` in the preview host. + */ +export function pathFor(fixture: Fixture): string { + return fixture.route ?? '/'; +} + +const slug = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + +export async function applyFixture(deps: ApplyDeps, fixture: Fixture): Promise { + const { getModel, dataset, datasetId } = deps; + const created: AppliedFixture['nodes'] = []; + /** Per-kind counter, so a node with no title still gets a stable id from its position. */ + const counters = new Map(); + + await getModel('Space').create(dataset, { + id: `${fixture.id}-space`, + uuid: datasetId, + ...(deps.sharedId ? { url: deps.sharedId } : {}), + name: fixture.space.name, + description: fixture.space.description, + ...(fixture.space.avatar ? { avatar: fixture.space.avatar } : {}), + discovery: 'hidden', + // What the space opens as. Without these the preview shows whichever template the *agent* + // defaults to, which is `default` — so every fixture would photograph the same layout. + defaultTemplateId: fixture.templateId, + ...(fixture.themeId ? { defaultThemeId: fixture.themeId } : {}), + }); + + // Signal types first, and by slug: a fixture says a message was hearted, and the id that means + // "heart" in this space does not exist until the type does. Templates resolve them the same way, + // by slug through a hoisted query, precisely because the id is per-community. + const signalTypeIds = new Map(); + for (const type of fixture.signalTypes ?? []) { + const instance = await getModel('SignalType').create(dataset, { + id: `${fixture.id}-signal-${type.slug}`, + name: type.name, + slug: type.slug, + icon: type.icon, + ...(type.description ? { description: type.description } : {}), + mode: type.mode ?? 'toggle', + ...(type.semantic ? { semantic: type.semantic } : {}), + rangeMin: 0, + rangeMax: 1, + step: 1, + aggregate: 'count', + allowChange: true, + valueType: 'numeric', + }); + signalTypeIds.set(type.slug, instance.id); + } + + function idFor(node: FixtureNode): string { + if (node.id) return node.id; + if (node.title) return `${fixture.id}-${slug(node.title)}`; + const n = (counters.get(node.kind) ?? 0) + 1; + counters.set(node.kind, n); + return `${fixture.id}-${node.kind}-${n}`; + } + + async function write(node: FixtureNode, parent?: ModelInstance): Promise { + const hasBody = Boolean(node.body?.length); + const id = idFor(node); + const instance = await getModel('CollectionBlock').create(dataset, { + id, + type: 'collection', + kind: node.kind, + mode: node.mode ?? (hasBody ? 'document' : 'feed'), + ...(node.title ? { title: node.title } : {}), + ...(node.description ? { description: node.description } : {}), + ...(hasBody ? { editorState: editorState(node.body!), textContent: textContent(node.body!) } : {}), + // Both are overrides of values the entity layer would otherwise stamp with `selfId()` and + // `now`. Authorship is the entire reason a fixture looks like a community rather than a + // diary, and a feed where every row was written this instant sorts arbitrarily and reads + // "just now" all the way down. + ...(node.author ? { author: node.author } : {}), + ...(node.createdAt ? { createdAt: node.createdAt, timestamp: node.createdAt } : {}), + }); + + created.push({ id, kind: node.kind, ...(node.title ? { title: node.title } : {}) }); + + // Containment is a link, not a field: `we://children` is what a `scope` drill-down and the + // `$latestChild` projection both traverse. + if (parent?.addChildren) await parent.addChildren(instance); + + for (const signal of node.signals ?? []) { + const signalTypeId = signalTypeIds.get(signal.slug); + if (!signalTypeId) { + throw new Error(`fixture '${fixture.id}': signal slug '${signal.slug}' has no matching signalTypes entry`); + } + for (const did of signal.by) { + const value = await getModel('Signal').create(dataset, { + id: `${id}-${signal.slug}-${slug(did)}`, + signalTypeId, + value: 1, + author: did, + }); + await instance.addSignals?.(value); + } + } + + for (const child of node.children ?? []) await write(child, instance); + return instance; + } + + for (const node of fixture.content) await write(node); + + return { datasetId, nodes: created, path: pathFor(fixture) }; +} diff --git a/packages/templates/fixtures/src/discord.ts b/packages/templates/fixtures/src/discord.ts new file mode 100644 index 000000000..59ee4ffd1 --- /dev/null +++ b/packages/templates/fixtures/src/discord.ts @@ -0,0 +1,147 @@ +/** + * A community mid-conversation, for the channels template. + * + * Shaped to stress the things a Discord-like layout is actually judged on rather than to show the + * feature list: consecutive messages from the same author (does the byline repeat?), a one-word + * reply next to a three-paragraph one (does the row rhythm survive?), a message with several + * reactions and one with none, and two categories where one holds a single channel. + * + * Timestamps are fixed rather than relative to now. A fixture whose content ages produces a + * different screenshot every day, and "3 minutes ago" versus "2 hours ago" is a different width. + */ +import type { Fixture } from './types'; + +const ADA = 'did:preview:ada'; +const BO = 'did:preview:bo'; +const CY = 'did:preview:cy'; +const DEE = 'did:preview:dee'; + +export const discordFixture: Fixture = { + id: 'discord', + templateId: 'discord', + + space: { + name: 'Cartography Club', + description: 'People who like maps more than is strictly reasonable.', + }, + + agents: [ + { did: ADA, firstName: 'Ada', lastName: 'Sørensen', handle: 'ada', bio: 'Contour lines enthusiast' }, + { did: BO, firstName: 'Bo', lastName: 'Whitfield', handle: 'bo', bio: 'Mostly here for the projections' }, + { did: CY, firstName: 'Cy', lastName: 'Mendez', handle: 'cy', bio: 'Surveyor' }, + { did: DEE, firstName: 'Dee', lastName: 'Okonkwo', handle: 'dee', bio: 'Archivist' }, + ], + + signalTypes: [ + { name: 'Heart', slug: 'heart', icon: 'heart', semantic: 'like', description: 'Appreciation' }, + { name: 'Compass', slug: 'compass', icon: 'compass', description: 'This helped me find something' }, + ], + + // Ada is reading #general; Bo is in the space but on another page. Both matter: `onlineHere` + // filters on the route, so a roster that ignores `path` would show the same faces everywhere. + presence: [ + { did: ADA, path: '/channel/discord-general' }, + { did: CY, path: '/channel/discord-general' }, + { did: BO }, + ], + + content: [ + { + kind: 'category', + title: 'The Club', + children: [ + { + kind: 'channel', + title: 'general', + description: 'Anything and everything', + children: [ + { + kind: 'message', + author: ADA, + createdAt: '2026-08-11T09:14:00.000Z', + body: ['Morning all. The 1897 survey sheets arrived and they are in much better shape than the listing suggested.'], + signals: [{ slug: 'heart', by: [BO, CY, DEE] }], + }, + { + kind: 'message', + author: ADA, + createdAt: '2026-08-11T09:14:40.000Z', + body: ['Three of them have the original folding cases too.'], + }, + { + kind: 'message', + author: BO, + createdAt: '2026-08-11T09:21:00.000Z', + body: ['Oh that is a genuinely good find. Which county?'], + }, + { + kind: 'message', + author: ADA, + createdAt: '2026-08-11T09:23:00.000Z', + body: [ + 'Cumberland, mostly the western sheets. There is a lovely bit of hachuring around the fells that I have not seen done that way anywhere else.', + 'Whoever engraved these was showing off, and I am entirely here for it.', + ], + signals: [{ slug: 'compass', by: [CY] }], + }, + { + kind: 'message', + author: CY, + createdAt: '2026-08-11T10:02:00.000Z', + body: ['Scan them before you fold them back up, please. I will beg if necessary.'], + signals: [{ slug: 'heart', by: [ADA] }], + }, + { + kind: 'message', + author: DEE, + createdAt: '2026-08-11T11:47:00.000Z', + body: ['Seconded.'], + }, + ], + }, + { + kind: 'channel', + title: 'projections', + description: 'Arguments about Mercator, mainly', + children: [ + { + kind: 'message', + author: BO, + createdAt: '2026-08-10T16:30:00.000Z', + body: ['Reminder that every flat map is wrong and some are wrong on purpose.'], + signals: [{ slug: 'heart', by: [ADA, DEE] }], + }, + { + kind: 'message', + author: DEE, + createdAt: '2026-08-10T17:05:00.000Z', + body: ['This is why I only trust globes and even then not completely.'], + }, + ], + }, + ], + }, + { + kind: 'category', + title: 'Field Work', + children: [ + { + kind: 'channel', + title: 'expeditions', + description: 'Where we are going and who is driving', + children: [ + { + kind: 'message', + author: CY, + createdAt: '2026-08-09T08:00:00.000Z', + body: ['Pencilling in the ridge walk for the 22nd. Bring something waterproof and something warm; last time was educational.'], + }, + ], + }, + ], + }, + ], + + // `discord-general` is the deterministic id `general` gets — see FixtureNode.id. + route: '/channel/discord-general', +}; diff --git a/packages/templates/fixtures/src/editorState.ts b/packages/templates/fixtures/src/editorState.ts new file mode 100644 index 000000000..3c4d72b6b --- /dev/null +++ b/packages/templates/fixtures/src/editorState.ts @@ -0,0 +1,71 @@ +/** + * Plain paragraphs → the editor state a `CollectionBlock` carries. + * + * `BlockRenderer` accepts either a `SerializedBlockNode` object or the `data:…;base64,…` string that + * AD4M's file-storage resolution hands back, and decodes the latter itself. Fixtures produce the + * string, because that is the shape a row actually *reads* as in the running app — an object would + * work here and diverge from what a real post looks like, which is the one thing a fidelity fixture + * must not do. + * + * The node shape is Lexical's, because the renderer hands it straight to `editor.parseEditorState`. + * `version: 1` is not decoration: editor-produced state always carries it, and Lexical throws + * without it — which surfaces as a silently empty message body and a console error several frames + * from the cause. + */ + +interface LexicalNode { + type: string; + version: number; + children?: LexicalNode[]; + [key: string]: unknown; +} + +const text = (value: string): LexicalNode => ({ + type: 'text', + version: 1, + text: value, + format: 0, + detail: 0, + mode: 'normal', + style: '', +}); + +const paragraph = (value: string): LexicalNode => ({ + type: 'paragraph', + version: 1, + children: [text(value)], + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, +}); + +/** Base64 that survives non-ASCII, matching what `createBlocks` writes for a real post. */ +function encode(json: string): string { + const bytes = new TextEncoder().encode(json); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** The root node, as an object — for a caller that wants to inspect rather than store it. */ +export function editorStateNode(paragraphs: readonly string[]): LexicalNode { + return { + type: 'root', + version: 1, + children: paragraphs.map(paragraph), + direction: 'ltr', + format: '', + indent: 0, + }; +} + +/** The stored form: a data URL, exactly as a resolved file field reads. */ +export function editorState(paragraphs: readonly string[]): string { + return `data:application/json;base64,${encode(JSON.stringify(editorStateNode(paragraphs)))}`; +} + +/** What `CollectionBlock.textContent` holds — the searchable plain text of a post. */ +export function textContent(paragraphs: readonly string[]): string { + return paragraphs.join('\n'); +} diff --git a/packages/templates/fixtures/src/index.ts b/packages/templates/fixtures/src/index.ts new file mode 100644 index 000000000..6383274ec --- /dev/null +++ b/packages/templates/fixtures/src/index.ts @@ -0,0 +1,26 @@ +/** + * Sample content for the showcase templates. + * + * See `types.ts` for the format and why it is data rather than a script per template. + */ +export { applyFixture, datasetIdFor, pathFor } from './apply.ts'; +export type { AppliedFixture, ApplyDeps } from './apply.ts'; +export { editorState, editorStateNode, textContent } from './editorState.ts'; +export type { + Fixture, + FixtureAgent, + FixtureNode, + FixturePresence, + FixtureSignalType, +} from './types.ts'; + +import { discordFixture } from './discord.ts'; + +export { discordFixture }; + +/** Every fixture, by id — what the shoot script resolves a `--fixture` argument against. */ +export const FIXTURES = { + discord: discordFixture, +} as const; + +export type FixtureId = keyof typeof FIXTURES; diff --git a/packages/templates/fixtures/src/types.ts b/packages/templates/fixtures/src/types.ts new file mode 100644 index 000000000..3182d446f --- /dev/null +++ b/packages/templates/fixtures/src/types.ts @@ -0,0 +1,125 @@ +/** + * The fixture format — sample content for a template, described once and applied to any backend. + * + * ## Why this is data rather than a script per template + * + * A template renders arbitrary community content, so the only way to judge one is to put content in + * it. An empty Discord clone screenshots as an empty state and says nothing about density, rhythm or + * how a long message wraps — which is most of what makes a UI recognisable. + * + * The format is deliberately expressed in *model* terms (a `CollectionBlock` with a `kind`, a body, + * an author, a timestamp) rather than in rows, because `@we/models` compiles from one manifest into + * row-backed classes on the in-memory backend and triple-backed ones on AD4M, and the difference is + * invisible to a caller. So the same fixture can serve three consumers: + * + * 1. the preview host, which is what exists today; + * 2. "fill this space with sample content" for a template author working in the real app; + * 3. marketplace preview images for a `Template` or `Theme`. + * + * Only the first is built. The format is shaped for all three anyway, because retrofitting the other + * two would mean rewriting every fixture. + * + * ## The one place the consumers genuinely diverge + * + * `author` is a DID this file makes up, and the preview backend can seed a matching profile for it. + * In a real AD4M perspective the author is the signing agent, so applying a fixture there produces + * content authored entirely by whoever ran it — every row showing one face, which is exactly what + * these templates are least able to survive. That is unresolved, and the reason consumer 2 is not + * built yet rather than being assumed to fall out. + */ + +/** A person in a fixture. Becomes a seeded profile, and the `author` of whatever they wrote. */ +export interface FixtureAgent { + did: string; + firstName: string; + lastName?: string; + handle: string; + bio?: string; + /** A URL or data URI. Absent is fine — `we-avatar` falls back to a hash-generated face. */ + avatar?: string; +} + +/** + * One node of content. + * + * Containers and documents are the same shape because in WE they are the same thing: a + * `CollectionBlock` with a `kind` label the template invented and a `mode` saying who owns its + * children. A channel is `{ kind: 'channel', mode: 'feed' }`; a message inside it is + * `{ kind: 'message', mode: 'document' }`. Nothing here mints a content model, which is the whole + * claim the showcase templates exist to demonstrate. + */ +export interface FixtureNode { + /** Free label — `channel`, `category`, `message`, `post`, `column`, whatever the template queries. */ + kind: string; + /** + * Stable id. Defaults to a slug of the title, or `-` for a node with no title. + * + * Deterministic rather than minted, and it has to be: the backend is in memory, so ids are + * remade on every load. A screenshot script that had to *discover* the id of `#general` could + * never navigate straight to it — it would have to load the page, read the id, navigate again, + * and get a different id from the second boot. + */ + id?: string; + /** Defaults to `feed` for a node with children and `document` for one with a body. */ + mode?: 'feed' | 'document'; + /** Shown by containers. A message has a body instead. */ + title?: string; + description?: string; + /** + * Paragraphs of body text, rendered through the real block pipeline. + * + * Deliberately plain strings: a fixture is content, and asking an author to hand-write Lexical + * JSON to get two sentences into a message would make writing fixtures the expensive part. + */ + body?: string[]; + /** DID of the author. Must match a {@link FixtureAgent}, or the byline renders as a stranger. */ + author?: string; + /** + * ISO-8601. **Always state one.** Left out, every row is stamped `now`, so a feed sorted by + * `createdAt` has an arbitrary order and every relative timestamp reads "just now" — which looks + * plausible and is the sort of thing you only notice after matching a screenshot against it. + */ + createdAt?: string; + /** Reactions, by signal-type slug, listing who reacted. */ + signals?: { slug: string; by: string[] }[]; + children?: FixtureNode[]; +} + +/** A signal type the community has defined — what a reaction *means* in this space. */ +export interface FixtureSignalType { + name: string; + slug: string; + icon: string; + description?: string; + mode?: 'toggle' | 'range'; + semantic?: string; +} + +/** A peer to show as present. `path` puts them on a route, which is what `onlineHere` filters on. */ +export interface FixturePresence { + did: string; + /** Route within the template, e.g. `/channel/general`. Omit for "in the space, not on this page". */ + path?: string; +} + +export interface Fixture { + /** Stable id — what the shoot script takes on the command line. */ + id: string; + /** The template this content is shaped for, matching a bundled template id. */ + templateId: string; + /** Optional theme override, when the fixture is for judging a theme rather than a template. */ + themeId?: string; + space: { name: string; description: string; avatar?: string }; + agents: FixtureAgent[]; + signalTypes?: FixtureSignalType[]; + presence?: FixturePresence[]; + content: FixtureNode[]; + /** + * Route within the template where a screenshot of this fixture should land — e.g. + * `/channel/discord-general`. Node ids are deterministic (see {@link FixtureNode.id}), so this is + * a literal string and the shoot script can navigate straight to it on first load. + * + * Defaults to `/`. + */ + route?: string; +} diff --git a/packages/templates/fixtures/tests/apply.test.ts b/packages/templates/fixtures/tests/apply.test.ts new file mode 100644 index 000000000..8d9a2d103 --- /dev/null +++ b/packages/templates/fixtures/tests/apply.test.ts @@ -0,0 +1,133 @@ +/** + * The two properties everything downstream leans on: ids are deterministic, and the editor state a + * fixture writes is the shape the renderer reads. + * + * Both are load-bearing rather than tidy. A screenshot script navigates straight to + * `/channel/discord-general` on first load, which is only possible because that id is derived rather + * than minted — the backend is in memory, so anything minted is different on the next load. And an + * editor state in the wrong shape renders as a silently empty message body with a Lexical error + * several frames from the cause. + */ +import { describe, expect, it } from 'vitest'; + +import { applyFixture, datasetIdFor, pathFor } from '../src/apply'; +import { discordFixture } from '../src/discord'; +import { editorState, editorStateNode } from '../src/editorState'; +import type { Fixture } from '../src/types'; + +/** A model layer that records rather than stores — enough to see what a fixture writes. */ +function recorder() { + const writes: Array<{ model: string; data: Record }> = []; + const links: Array<[string, string]> = []; + const getModel = (model: string) => ({ + async create(_handle: unknown, data: Record) { + writes.push({ model, data }); + const id = (data.id as string) ?? `minted-${writes.length}`; + return { + id, + async addChildren(related: unknown) { + links.push([id, (related as { id: string }).id]); + }, + async addSignals() {}, + }; + }, + }); + return { writes, links, getModel }; +} + +const apply = async (fixture: Fixture) => { + const { writes, links, getModel } = recorder(); + const result = await applyFixture( + { getModel, dataset: {}, datasetId: datasetIdFor(fixture), sharedId: datasetIdFor(fixture) }, + fixture, + ); + return { writes, links, result }; +}; + +describe('deterministic ids', () => { + it('derives the same ids on every run', async () => { + const first = await apply(discordFixture); + const second = await apply(discordFixture); + expect(first.result.nodes).toEqual(second.result.nodes); + }); + + it('slugs a title, and numbers what has none', async () => { + const { result } = await apply(discordFixture); + expect(result.nodes.find((n) => n.title === 'general')?.id).toBe('discord-general'); + // Messages have no title, so their id comes from position within their kind. + expect(result.nodes.filter((n) => n.kind === 'message')[0].id).toBe('discord-message-1'); + }); + + it('nominates a route that names a node it actually created', async () => { + const { result } = await apply(discordFixture); + const path = pathFor(discordFixture); + // No space prefix: template routes mount at the router root. + expect(path).toBe('/channel/discord-general'); + expect(result.nodes.some((n) => path.endsWith(n.id))).toBe(true); + }); +}); + +describe('what it writes', () => { + it('gives the space the url the template resolver matches on', async () => { + const { writes } = await apply(discordFixture); + const space = writes.find((w) => w.model === 'Space')!.data; + // `resolveSpaceFromPerspective` matches a *shared* dataset by `url === sharedId` and only falls + // back to `uuid` for a personal one. Without `url` the space renders under the default template. + expect(space.url).toBe('preview-discord'); + expect(space.uuid).toBe('preview-discord'); + expect(space.defaultTemplateId).toBe('discord'); + }); + + it('carries authorship and timestamps rather than letting them default', async () => { + const { writes } = await apply(discordFixture); + const messages = writes.filter((w) => w.data.kind === 'message'); + expect(messages.every((m) => typeof m.data.author === 'string')).toBe(true); + expect(messages.every((m) => typeof m.data.createdAt === 'string')).toBe(true); + }); + + it('links each child to its container', async () => { + const { links } = await apply(discordFixture); + expect(links).toContainEqual(['discord-the-club', 'discord-general']); + expect(links).toContainEqual(['discord-general', 'discord-message-1']); + }); + + it('refuses a signal slug with no matching type', async () => { + await expect( + apply({ + ...discordFixture, + signalTypes: [], + content: [{ kind: 'message', body: ['x'], signals: [{ slug: 'heart', by: ['did:x'] }] }], + }), + ).rejects.toThrow(/signal slug 'heart'/); + }); +}); + +describe('editor state', () => { + it('is a data URL the renderer can decode, in Lexical shape', () => { + const url = editorState(['One.', 'Two.']); + expect(url.startsWith('data:application/json;base64,')).toBe(true); + + const decoded = JSON.parse(atob(url.split(';base64,')[1])); + expect(decoded).toEqual(editorStateNode(['One.', 'Two.'])); + expect(decoded.type).toBe('root'); + // Lexical throws without it, which surfaces as an empty body and a console error elsewhere. + expect(decoded.version).toBe(1); + expect(decoded.children).toHaveLength(2); + expect(decoded.children[0].children[0].text).toBe('One.'); + }); + + it('encodes UTF-8 the way the app does — which the app then decodes wrongly', () => { + // Documenting a pre-existing bug rather than asserting correctness. `createBlocks` encodes with + // `btoa(unescape(encodeURIComponent(json)))` — UTF-8 bytes, which this matches byte for byte — + // but `decodeEditorState` reads it back with a bare `atob`, whose output is a Latin-1 string. + // The pair is only lossless for ASCII, so any post containing a non-ASCII character renders + // mojibake in the running app. Nothing to do with fixtures; it is why fixture *bodies* are kept + // ASCII, while profile names (a separate field, not editor state) can carry accents safely. + const bytes = atob(editorState(['Sørensen']).split(';base64,')[1]); + expect(JSON.parse(bytes).children[0].children[0].text).toBe('Sørensen'); + + // The UTF-8-aware decode the app should be doing recovers it exactly. + const utf8 = new TextDecoder().decode(Uint8Array.from(bytes, (c) => c.charCodeAt(0))); + expect(JSON.parse(utf8).children[0].children[0].text).toBe('Sørensen'); + }); +}); diff --git a/packages/templates/fixtures/tsconfig.json b/packages/templates/fixtures/tsconfig.json new file mode 100644 index 000000000..13fee8e5d --- /dev/null +++ b/packages/templates/fixtures/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src", "tests"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c8dc8f1..a5690fa50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,10 +270,22 @@ importers: '@we/backend-shared': specifier: workspace:* version: link:../../packages/backend-system/shared + '@we/components': + specifier: workspace:* + version: link:../../packages/design-system/4-components + '@we/models': + specifier: workspace:* + version: link:../../packages/models + '@we/template-fixtures': + specifier: workspace:* + version: link:../../packages/templates/fixtures solid-js: specifier: ^1.9.5 version: 1.9.14 devDependencies: + playwright-core: + specifier: ^1.62.1 + version: 1.62.1 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -1392,6 +1404,18 @@ importers: specifier: ^24.10.0 version: 24.13.2 + packages/templates/fixtures: + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.15 + version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/templates/kit: dependencies: '@we/schema-shared': @@ -3320,6 +3344,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@zip.js/zip.js@2.8.26': resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} @@ -5449,6 +5474,11 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -10807,6 +10837,8 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.62.1: {} + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 From d48453e1dbaff8069d39ed721a77be5b9a0e2bde Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:39:34 +0100 Subject: [PATCH 08/14] =?UTF-8?q?feat(we-preview):=20the=20shoot=20script?= =?UTF-8?q?=20=E2=80=94=20render,=20crop,=20sample,=20composite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop. `pnpm --filter @we/app-preview shoot -- --fixture discord` boots the host in headless Chrome, waits for the fixture the page reports having applied, and writes a PNG at 2× — text at 1× is too soft to judge letterforms or spacing from, which is most of what a theme is. `--clip ` photographs one element at magnification. Judging density and rhythm from a message row that is sixty pixels tall in a 1440px page is guesswork; cropped, it is just looking. `--target ` does the two things a reference screenshot is genuinely good for: - **Samples its palette to real hex.** A coarse histogram over both images recovers the surface, border, text and accent colours exactly — measurement rather than eyeballing, which was the sharpest limit on this whole exercise. - **Composites target beside render into one image**, scaled to a common height so a retina capture does not read as a different design. Both run in the page on a canvas. The browser is already an image library, and this machine has neither ImageMagick nor `sharp`; the alternative was a native dependency to do what a canvas does in twenty lines. Deliberately **no similarity score**. The obvious loop is "diff against the target, iterate until it clears a threshold", and that is right for cloning a page, where the two converge to identical pixels. These templates render arbitrary community content in a platform's *shape* — different names, different messages, a different number of rows — so a pixel diff is dominated by content, sits at a large constant, and barely moves as the layout improves. It cannot drive anything, and optimising it would push toward matching content, which means nothing. Pixel diffing earns its place later, against our own previous render, where identity is the goal. A script rather than a browser MCP server because this is committed: reproducible by anyone, and wrappable in Vitest browser mode as a visual-regression suite once the templates are worth freezing. It uses the Chrome already on the machine (`channel: 'chrome'`), so `playwright-core` downloads nothing. Co-Authored-By: Claude Opus 5 (1M context) --- apps/we-preview/.gitignore | 1 + apps/we-preview/index.html | 3 + apps/we-preview/package.json | 3 +- apps/we-preview/scripts/shoot.mjs | 220 ++++++++++++++++++ .../src/platform/inMemoryConnector.ts | 2 + 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 apps/we-preview/scripts/shoot.mjs diff --git a/apps/we-preview/.gitignore b/apps/we-preview/.gitignore index 1521c8b76..b56f79a7e 100644 --- a/apps/we-preview/.gitignore +++ b/apps/we-preview/.gitignore @@ -1 +1,2 @@ dist +shots/ diff --git a/apps/we-preview/index.html b/apps/we-preview/index.html index 3d844a1ea..0532d3aed 100644 --- a/apps/we-preview/index.html +++ b/apps/we-preview/index.html @@ -4,6 +4,9 @@ WE Preview + + diff --git a/apps/we-preview/package.json b/apps/we-preview/package.json index 8c815eea3..50f8c3150 100644 --- a/apps/we-preview/package.json +++ b/apps/we-preview/package.json @@ -9,7 +9,8 @@ "dev": "vite", "build": "vite build", "serve": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "shoot": "node scripts/shoot.mjs" }, "dependencies": { "@we/app-shell": "workspace:*", diff --git a/apps/we-preview/scripts/shoot.mjs b/apps/we-preview/scripts/shoot.mjs new file mode 100644 index 000000000..e0e85780f --- /dev/null +++ b/apps/we-preview/scripts/shoot.mjs @@ -0,0 +1,220 @@ +/** + * Render a fixture and photograph it. + * + * ```sh + * pnpm --filter @we/app-preview shoot # every fixture, default viewport + * pnpm --filter @we/app-preview shoot -- --fixture discord + * pnpm --filter @we/app-preview shoot -- --fixture discord --width 1280 --clip '[part="base"]' + * pnpm --filter @we/app-preview shoot -- --target ~/discord.png --fixture discord + * ``` + * + * ## Why a script rather than an MCP browser server + * + * This is committed, so a render is reproducible by anyone and can grow into a visual-regression + * suite (Vitest browser mode wraps it, once the templates are worth freezing). A server would be + * neither. It also runs against the Chrome already on the machine — `channel: 'chrome'`, so + * `playwright-core` downloads nothing. + * + * ## Why there is no similarity score + * + * The obvious loop is "diff the render against the target, iterate until the score clears a + * threshold". That is right for cloning a *page*, where the two should converge to identical pixels. + * It is wrong here: these templates render arbitrary community content in a platform's *shape*, so + * the target screenshot has different names, different messages, a different number of rows. A pixel + * diff against it is dominated by content, sits at some large constant, and barely moves as the + * layout improves — so it cannot drive anything, and optimising it would push toward matching + * content, which means nothing. + * + * What the target *is* good for is measurement, and `--target` does two things with it that beat + * looking: it samples the dominant colours to real hex, and it composites target beside render into + * one image, which is far easier to judge than two files. Both run in the page on a canvas, because + * the browser is already an image library and the box has neither ImageMagick nor `sharp`. + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright-core'; + +const here = dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = resolve(here, '../shots'); + +function parseArgs(argv) { + const args = { width: 1440, height: 900, scale: 2, wait: 1500 }; + for (let i = 0; i < argv.length; i += 1) { + const [flag, inlineValue] = argv[i].split('='); + const value = inlineValue ?? argv[i + 1]; + const consume = () => { + if (inlineValue === undefined) i += 1; + return value; + }; + if (flag === '--fixture') args.fixture = consume(); + else if (flag === '--width') args.width = Number(consume()); + else if (flag === '--height') args.height = Number(consume()); + else if (flag === '--scale') args.scale = Number(consume()); + else if (flag === '--wait') args.wait = Number(consume()); + else if (flag === '--clip') args.clip = consume(); + else if (flag === '--target') args.target = consume(); + else if (flag === '--base') args.base = consume(); + else if (flag === '--full') args.full = true; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const base = args.base ?? 'http://localhost:3101'; + +/** + * Deviceless pixel ratio 2 by default: text rendered at 1× is too soft to judge letterforms or + * spacing from, which is most of what a theme is. + */ +const browser = await chromium.launch({ channel: 'chrome' }); + +async function shoot(fixtureId) { + const page = await browser.newPage({ + viewport: { width: args.width, height: args.height }, + deviceScaleFactor: args.scale, + }); + + const problems = []; + page.on('console', (m) => { + if (m.type() === 'error' || m.type() === 'warning') problems.push(`${m.type()}: ${m.text()}`); + }); + page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); + + const url = `${base}/?fixture=${encodeURIComponent(fixtureId)}`; + await page.goto(url, { waitUntil: 'networkidle' }); + + // The host publishes what it applied; waiting on that rather than a bare timeout means a slow + // boot fails as a timeout here instead of silently photographing a landing page. + await page.waitForFunction(() => window.__wePreview !== undefined, { timeout: 20_000 }); + const info = await page.evaluate(() => window.__wePreview); + + // `PreviewBootstrap` selects the dataset and navigates after boot; both are async, and presence + // needs one beat (see the seeded-presence interval) before the roster fills. + await page.waitForTimeout(args.wait); + + await mkdir(OUT_DIR, { recursive: true }); + const stem = `${fixtureId}-${args.width}`; + const shotPath = resolve(OUT_DIR, `${stem}.png`); + + const subject = args.clip ? page.locator(args.clip).first() : page; + await subject.screenshot({ path: shotPath, ...(args.clip ? {} : { fullPage: Boolean(args.full) }) }); + + const report = { fixture: fixtureId, url, path: shotPath, template: info?.templateId, route: info?.path }; + + if (args.target) { + const targetPath = resolve(process.cwd(), args.target); + const analysis = await analyse(page, shotPath, targetPath); + await writeFile(resolve(OUT_DIR, `${stem}-compare.png`), Buffer.from(analysis.composite, 'base64')); + report.compare = resolve(OUT_DIR, `${stem}-compare.png`); + report.targetPalette = analysis.targetPalette; + report.renderPalette = analysis.renderPalette; + } + + if (problems.length) report.problems = [...new Set(problems)].slice(0, 15); + await page.close(); + return report; +} + +/** + * Palette extraction and compositing, in the page. + * + * Both images are read through `createImageBitmap` and drawn to a canvas; the palette is a coarse + * histogram (5-bit per channel) over every 4th pixel, which is plenty to recover a UI's flat + * surface, text and accent colours and cheap enough to run on a full-page shot. + */ +async function analyse(page, renderPath, targetPath) { + const [render, target] = await Promise.all([toDataUri(renderPath), toDataUri(targetPath)]); + + return page.evaluate( + async ([renderUri, targetUri]) => { + const load = async (uri) => createImageBitmap(await (await fetch(uri)).blob()); + + const palette = (bitmap, count = 6) => { + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + const bins = new Map(); + for (let i = 0; i < data.length; i += 16) { + if (data[i + 3] < 128) continue; + const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3); + const bin = bins.get(key) ?? { n: 0, r: 0, g: 0, b: 0 }; + bin.n += 1; + bin.r += data[i]; + bin.g += data[i + 1]; + bin.b += data[i + 2]; + bins.set(key, bin); + } + const total = [...bins.values()].reduce((sum, b) => sum + b.n, 0); + return [...bins.values()] + .sort((a, b) => b.n - a.n) + .slice(0, count) + .map((b) => { + const hex = (v) => Math.round(v / b.n).toString(16).padStart(2, '0'); + return { hex: `#${hex(b.r)}${hex(b.g)}${hex(b.b)}`, share: +(b.n / total).toFixed(3) }; + }); + }; + + const [renderBmp, targetBmp] = await Promise.all([load(renderUri), load(targetUri)]); + + // Scaled to a common height so the two are actually comparable side by side — a target + // captured on a retina display is otherwise twice the size and reads as a different design. + const height = Math.max(renderBmp.height, targetBmp.height); + const widthOf = (b) => Math.round((b.width * height) / b.height); + const gap = 24; + const canvas = new OffscreenCanvas(widthOf(targetBmp) + gap + widthOf(renderBmp), height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#888'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(targetBmp, 0, 0, widthOf(targetBmp), height); + ctx.drawImage(renderBmp, widthOf(targetBmp) + gap, 0, widthOf(renderBmp), height); + + const blob = await canvas.convertToBlob({ type: 'image/png' }); + const buffer = new Uint8Array(await blob.arrayBuffer()); + let binary = ''; + for (const byte of buffer) binary += String.fromCharCode(byte); + + return { + composite: btoa(binary), + targetPalette: palette(targetBmp), + renderPalette: palette(renderBmp), + }; + }, + [render, target], + ); +} + +async function toDataUri(path) { + const { readFile } = await import('node:fs/promises'); + return `data:image/png;base64,${(await readFile(path)).toString('base64')}`; +} + +const fixtures = args.fixture ? [args.fixture] : await listFixtures(); + +async function listFixtures() { + const page = await browser.newPage(); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + const ids = await page.evaluate(() => Object.keys(window.__weFixtures ?? {})); + await page.close(); + return ids.length ? ids : ['discord']; +} + +const reports = []; +for (const id of fixtures) reports.push(await shoot(id)); +await browser.close(); + +for (const report of reports) { + console.log(`\n${report.fixture} → ${report.path}`); + console.log(` template ${report.template} route ${report.route}`); + if (report.targetPalette) { + console.log(` target ${report.targetPalette.map((c) => `${c.hex} ${(c.share * 100).toFixed(0)}%`).join(' ')}`); + console.log(` render ${report.renderPalette.map((c) => `${c.hex} ${(c.share * 100).toFixed(0)}%`).join(' ')}`); + console.log(` compare ${report.compare}`); + } + if (report.problems) { + console.log(' problems:'); + for (const problem of report.problems) console.log(` ${problem.slice(0, 160)}`); + } +} diff --git a/apps/we-preview/src/platform/inMemoryConnector.ts b/apps/we-preview/src/platform/inMemoryConnector.ts index 34deb1cee..42cbdeb03 100644 --- a/apps/we-preview/src/platform/inMemoryConnector.ts +++ b/apps/we-preview/src/platform/inMemoryConnector.ts @@ -76,6 +76,8 @@ export const inMemoryConnector: BackendConnector = { // How the shoot script knows where to go without loading the page twice. Everything here is // derived from the fixture, so it is also knowable ahead of time — this is a convenience and a // cross-check, not the source of truth. + // The full catalogue, so `shoot` with no `--fixture` can enumerate rather than be told twice. + (window as unknown as Record).__weFixtures = FIXTURES; (window as unknown as Record).__wePreview = { fixture: fixture.id, templateId: fixture.templateId, From 45dc5b1e47f64a9a2cfc0390e4797fdd217c3985 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 21:48:13 +0100 Subject: [PATCH 09/14] feat(fixtures): the other five, and the include gap they exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twitter, Instagram, YouTube, Kanban and Events. All six templates now render populated, at every viewport, from `pnpm --filter @we/app-preview shoot`. Each is shaped to stress what its layout is judged on rather than to show the feature list: a 3:1 length difference between adjacent posts, a grid with an odd count so the last row is short, video titles of wildly different lengths beside a fixed thumbnail, an empty Kanban column beside a full one, events spread across months. One cast across all six, so comparing two templates compares the templates rather than the content. **The engine gap Instagram found.** Registering untyped relations for `scope` (previous commit) left `include` over them still resolving to nothing, because `relatedRows` read `data.tables['']`. An untyped relation names no target table, which is not the same as having no targets — it means *of any type*, which is exactly what a collection holding text, images and embeds is. So every cover-image projection resolved to null, and a media grid, which drops posts with no image rather than showing blank tiles, rendered as nothing at all. It now looks across every table, with two tests. Images are offline gradient data URIs. Real photographs would be better subjects, but a fixture that fetches them fails without a network and rots when a URL does, and committing megabytes of JPEG to make six templates render is a bad trade. They are varied enough in hue and value to judge tile spacing, aspect handling and overlay contrast; swap in real assets before judging anything *photographic*. Fixture bodies are ASCII, and the shared cast documents why: the UTF-8/`atob` mismatch recorded last commit bit this branch's own Kanban card, which rendered "Scanning â about a third done" until the em-dashes came out. Profile names keep their accents — those are ordinary model fields, not editor state. Co-Authored-By: Claude Opus 5 (1M context) --- apps/we-preview/scripts/shoot.mjs | 5 +- apps/we-preview/src/index.tsx | 2 +- .../src/platform/inMemoryConnector.ts | 2 +- .../app-shell/tests/shellRouteStore.test.tsx | 2 +- .../backend-system/inmemory/src/entities.ts | 4 +- .../inmemory/tests/seeding.test.ts | 5 +- .../shared/src/queryEngine.test.ts | 40 +++ .../backend-system/shared/src/queryEngine.ts | 6 +- .../1-tokens/scripts/generate-css.test.ts | 4 +- .../3-primitives/src/primitives/drawer.ts | 5 +- packages/templates/fixtures/src/apply.ts | 15 +- packages/templates/fixtures/src/cast.ts | 29 ++ packages/templates/fixtures/src/discord.ts | 23 +- packages/templates/fixtures/src/images.ts | 35 ++ packages/templates/fixtures/src/index.ts | 17 +- packages/templates/fixtures/src/rest.ts | 310 ++++++++++++++++++ packages/templates/fixtures/src/types.ts | 7 + 17 files changed, 475 insertions(+), 36 deletions(-) create mode 100644 packages/templates/fixtures/src/cast.ts create mode 100644 packages/templates/fixtures/src/images.ts create mode 100644 packages/templates/fixtures/src/rest.ts diff --git a/apps/we-preview/scripts/shoot.mjs b/apps/we-preview/scripts/shoot.mjs index e0e85780f..7d2e5be79 100644 --- a/apps/we-preview/scripts/shoot.mjs +++ b/apps/we-preview/scripts/shoot.mjs @@ -152,7 +152,10 @@ async function analyse(page, renderPath, targetPath) { .sort((a, b) => b.n - a.n) .slice(0, count) .map((b) => { - const hex = (v) => Math.round(v / b.n).toString(16).padStart(2, '0'); + const hex = (v) => + Math.round(v / b.n) + .toString(16) + .padStart(2, '0'); return { hex: `#${hex(b.r)}${hex(b.g)}${hex(b.b)}`, share: +(b.n / total).toFixed(3) }; }); }; diff --git a/apps/we-preview/src/index.tsx b/apps/we-preview/src/index.tsx index 47ccf255e..5db9f7ffc 100644 --- a/apps/we-preview/src/index.tsx +++ b/apps/we-preview/src/index.tsx @@ -3,7 +3,7 @@ import '@we/app-shell/shared/index.scss'; import { PlatformProvider, StoreProvider, TemplateProvider, type WeSeedFile } from '@we/app-shell/solid'; import { ToastContainer } from '@we/components/solid'; -import { datasetIdFor, FIXTURES, pathFor } from '@we/template-fixtures'; +import { datasetIdFor, pathFor } from '@we/template-fixtures'; import { render } from 'solid-js/web'; import rootSeed from '../../../we-seed.json'; diff --git a/apps/we-preview/src/platform/inMemoryConnector.ts b/apps/we-preview/src/platform/inMemoryConnector.ts index 42cbdeb03..54f09c58e 100644 --- a/apps/we-preview/src/platform/inMemoryConnector.ts +++ b/apps/we-preview/src/platform/inMemoryConnector.ts @@ -1,7 +1,7 @@ import type { BackendConnector, BackendInitResult } from '@we/app-shell/shared'; import { createInMemoryBackendPorts, type SeededPeer } from '@we/backend-inmemory'; import { getModel } from '@we/models'; -import { applyFixture, datasetIdFor, FIXTURES, type Fixture, type FixtureId } from '@we/template-fixtures'; +import { applyFixture, datasetIdFor, type Fixture, type FixtureId, FIXTURES } from '@we/template-fixtures'; /** * The whole difference between this host and we-web. diff --git a/packages/app-shell/tests/shellRouteStore.test.tsx b/packages/app-shell/tests/shellRouteStore.test.tsx index dbc72ee4e..9e5f1b9cc 100644 --- a/packages/app-shell/tests/shellRouteStore.test.tsx +++ b/packages/app-shell/tests/shellRouteStore.test.tsx @@ -17,8 +17,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { RouteStore } from '../src/frameworks/solid/stores/RouteStore'; import { - ShellRouteStoreProvider, ShellRouterRoot, + ShellRouteStoreProvider, useShellRouteStore, } from '../src/frameworks/solid/stores/ShellRouteStore'; diff --git a/packages/backend-system/inmemory/src/entities.ts b/packages/backend-system/inmemory/src/entities.ts index 2072bd82e..d7f7bea75 100644 --- a/packages/backend-system/inmemory/src/entities.ts +++ b/packages/backend-system/inmemory/src/entities.ts @@ -348,7 +348,9 @@ export function compileEntities(manifest: ModelManifest, runtime: EntityRuntime) // instance array and nothing else, so containment vanished on the next read. const targetRow = relation.target ? tableOf(dataset, relation.target).find((r) => r.id === relatedId) - : Object.values(dataset.tables).flatMap((rows) => rows as AnyRow[]).find((r) => r.id === relatedId); + : Object.values(dataset.tables) + .flatMap((rows) => rows as AnyRow[]) + .find((r) => r.id === relatedId); if (targetRow) targetRow[relation.foreignKey] = this.id; const current = Array.isArray(this[relation.name]) ? (this[relation.name] as unknown[]) : []; if (!current.includes(relatedId)) this[relation.name] = [...current, relatedId]; diff --git a/packages/backend-system/inmemory/tests/seeding.test.ts b/packages/backend-system/inmemory/tests/seeding.test.ts index 17018ecdb..a0af3c702 100644 --- a/packages/backend-system/inmemory/tests/seeding.test.ts +++ b/packages/backend-system/inmemory/tests/seeding.test.ts @@ -11,9 +11,8 @@ * Presence is a *message*, so it has to arrive over the bus the way a heartbeat would, and keep * arriving, because presence ages itself out on a TTL by design. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import { getModel } from '@we/models'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createInMemoryBackendPorts } from '../src/lifecycle'; @@ -144,7 +143,7 @@ describe('seeded presence', () => { }); describe('scope drill-down', () => { - it('returns one container\'s children, not the whole table', async () => { + it("returns one container's children, not the whole table", async () => { const ports = makePorts(); const dataset = (await ports.lifecycle.get('ds-main'))!; const handle = dataset.handle; diff --git a/packages/backend-system/shared/src/queryEngine.test.ts b/packages/backend-system/shared/src/queryEngine.test.ts index 8fc4bc05a..c660fc631 100644 --- a/packages/backend-system/shared/src/queryEngine.test.ts +++ b/packages/backend-system/shared/src/queryEngine.test.ts @@ -204,3 +204,43 @@ describe('executeQueryIR', () => { expect(ids(row.$likes)).toEqual(['s1', 's2']); // same relation, aliased + filtered separately }); }); + +describe('untyped relations', () => { + /** + * A relation with no declared target holds children *of any type* — `CollectionBlock.children` is + * the case, since a collection mixes text, images and embeds. Reading that as an empty table made + * every projection over it resolve to null, so a media grid (which drops posts with no image + * rather than showing blank tiles) rendered as nothing at all. + */ + const data = { + tables: { + Post: [{ id: 'p1' }, { id: 'p2' }], + TextBlock: [{ id: 't1', __Post_children: 'p1', text: 'hello' }], + ImageBlock: [ + { id: 'i1', __Post_children: 'p1', src: 'a.png' }, + { id: 'i2', __Post_children: 'p2', src: 'b.png' }, + ], + }, + relations: { Post: { children: { target: '', cardinality: 'many' as const, foreignKey: '__Post_children' } } }, + }; + + it('hydrates children from every table', () => { + const [first] = executeQueryIR( + { entity: 'Post', include: { children: true }, filter: { field: 'id', op: 'eq', value: 'p1' } } as never, + data, + ); + expect((first.children as Array<{ id: string }>).map((c) => c.id).sort()).toEqual(['i1', 't1']); + }); + + it('still filters a projection over one, which is what a cover image is', () => { + const rows = executeQueryIR( + { + entity: 'Post', + include: { $cover: { over: 'children', filter: { field: 'src', op: 'exists', value: true }, first: true } }, + } as never, + data, + ); + expect((rows[0].$cover as { id: string }).id).toBe('i1'); + expect((rows[1].$cover as { id: string }).id).toBe('i2'); + }); +}); diff --git a/packages/backend-system/shared/src/queryEngine.ts b/packages/backend-system/shared/src/queryEngine.ts index 0f8feab3a..e34948781 100644 --- a/packages/backend-system/shared/src/queryEngine.ts +++ b/packages/backend-system/shared/src/queryEngine.ts @@ -71,7 +71,11 @@ function relatedRows( ): { rows: Row[]; rel: InMemoryRelation } | undefined { const rel = data.relations?.[entity]?.[relName]; if (!rel) return undefined; - const targetRows = data.tables[rel.target] ?? []; + // An untyped relation names no target table, which is not the same as having no targets: it means + // "of any type". A collection holding text, images and embeds is exactly that, and reading it as + // an empty table made every cover-image projection resolve to null — so a media grid, which drops + // posts with no image rather than showing blank tiles, rendered as nothing at all. + const targetRows = rel.target ? (data.tables[rel.target] ?? []) : Object.values(data.tables).flat(); const rows = rel.cardinality === 'one' ? targetRows.filter((r) => r.id === row[rel.foreignKey]) diff --git a/packages/design-system/1-tokens/scripts/generate-css.test.ts b/packages/design-system/1-tokens/scripts/generate-css.test.ts index e828b7ca8..c770b42d8 100644 --- a/packages/design-system/1-tokens/scripts/generate-css.test.ts +++ b/packages/design-system/1-tokens/scripts/generate-css.test.ts @@ -64,9 +64,7 @@ describe('token CSS generation', () => { // Either a scale position or an expression over the hue/saturation variables — never a // literal, which is what makes a role themeable at all. - expect(declaration![1], `role '${name}' hardcodes a colour`).toMatch( - /^(var\(--we-color-|hsl\(var\(--we-color-)/, - ); + expect(declaration![1], `role '${name}' hardcodes a colour`).toMatch(/^(var\(--we-color-|hsl\(var\(--we-color-)/); } }); diff --git a/packages/design-system/3-primitives/src/primitives/drawer.ts b/packages/design-system/3-primitives/src/primitives/drawer.ts index 808f45685..4c297128b 100644 --- a/packages/design-system/3-primitives/src/primitives/drawer.ts +++ b/packages/design-system/3-primitives/src/primitives/drawer.ts @@ -37,7 +37,10 @@ const styles = css` [part='base'] { position: absolute; overflow-y: auto; - box-shadow: var(--we-theme-shadow, var(--we-shadow-lg, 0 10px 40px color-mix(in srgb, var(--we-role-shadow-color) 15%, transparent))); + box-shadow: var( + --we-theme-shadow, + var(--we-shadow-lg, 0 10px 40px color-mix(in srgb, var(--we-role-shadow-color) 15%, transparent)) + ); transition: transform var(--we-transition-300, 250ms) ease; } diff --git a/packages/templates/fixtures/src/apply.ts b/packages/templates/fixtures/src/apply.ts index 37c6b8c87..87252731b 100644 --- a/packages/templates/fixtures/src/apply.ts +++ b/packages/templates/fixtures/src/apply.ts @@ -12,8 +12,8 @@ * load; without that property a screenshot script could not navigate to a route without first * loading the page to discover it, and the second load would produce different ids anyway. */ -import type { Fixture, FixtureNode } from './types'; import { editorState, textContent } from './editorState'; +import type { Fixture, FixtureNode } from './types'; /** The pieces of the host a fixture needs. Passed in rather than imported, so this stays neutral. */ export interface ApplyDeps { @@ -156,6 +156,19 @@ export async function applyFixture(deps: ApplyDeps, fixture: Fixture): Promise + `data:image/svg+xml,${encodeURIComponent( + ``, + )}`; + +/** Six plates, in a deliberate spread of hue and lightness. */ +export const PLATES = [ + gradient('#2b4c7e', '#567ebb'), + gradient('#7e2b4c', '#bb5678'), + gradient('#2b7e5c', '#56bb8e'), + gradient('#7e6b2b', '#bba956'), + gradient('#4c2b7e', '#7856bb'), + gradient('#7e3f2b', '#bb7056'), +]; + +/** Wide plate, for a video thumbnail or cover. */ +export const WIDE = [ + gradient('#1f3a5f', '#4a7fb5', 1280, 720), + gradient('#5f1f3a', '#b54a7f', 1280, 720), + gradient('#1f5f3a', '#4ab57f', 1280, 720), + gradient('#5f4a1f', '#b5a04a', 1280, 720), +]; diff --git a/packages/templates/fixtures/src/index.ts b/packages/templates/fixtures/src/index.ts index 6383274ec..135224be2 100644 --- a/packages/templates/fixtures/src/index.ts +++ b/packages/templates/fixtures/src/index.ts @@ -6,21 +6,22 @@ export { applyFixture, datasetIdFor, pathFor } from './apply.ts'; export type { AppliedFixture, ApplyDeps } from './apply.ts'; export { editorState, editorStateNode, textContent } from './editorState.ts'; -export type { - Fixture, - FixtureAgent, - FixtureNode, - FixturePresence, - FixtureSignalType, -} from './types.ts'; +export type { Fixture, FixtureAgent, FixtureNode, FixturePresence, FixtureSignalType } from './types.ts'; import { discordFixture } from './discord.ts'; +import { eventsFixture, instagramFixture, kanbanFixture, twitterFixture, youtubeFixture } from './rest.ts'; -export { discordFixture }; +export { discordFixture, eventsFixture, instagramFixture, kanbanFixture, twitterFixture, youtubeFixture }; +export { PLATES, WIDE } from './images.ts'; /** Every fixture, by id — what the shoot script resolves a `--fixture` argument against. */ export const FIXTURES = { discord: discordFixture, + twitter: twitterFixture, + instagram: instagramFixture, + youtube: youtubeFixture, + kanban: kanbanFixture, + events: eventsFixture, } as const; export type FixtureId = keyof typeof FIXTURES; diff --git a/packages/templates/fixtures/src/rest.ts b/packages/templates/fixtures/src/rest.ts new file mode 100644 index 000000000..37744ce41 --- /dev/null +++ b/packages/templates/fixtures/src/rest.ts @@ -0,0 +1,310 @@ +/** + * The other five showcase fixtures. + * + * Kept in one file because they share a cast and a set of decisions, and splitting them would put + * five copies of the same four agents in five places. The Discord one lives alone because it is the + * most structurally involved -- two levels of containment -- and is the worked example. + * + * Each is shaped to stress what its layout is actually judged on, not to demonstrate features: + * + * - **Twitter** -- replies threaded under a post, one long post beside several short, so the row + * rhythm has to survive a 3:1 length difference. + * - **Instagram** -- a grid with an odd count, so the last row is short and the tile sizing shows. + * - **YouTube** -- playlists holding videos of very different title lengths, which is where a card + * grid usually breaks. + * - **Kanban** -- an empty column beside a full one, the case a board layout most often gets wrong. + * - **Events** -- events spread across months, so date grouping and relative timestamps both show. + */ +import { ADA, BO, BOOST, CAST, CY, DEE, LIKE } from './cast'; +import { PLATES, WIDE } from './images'; +import type { Fixture } from './types'; + +export const twitterFixture: Fixture = { + id: 'twitter', + templateId: 'twitter', + space: { name: 'The Timeline', description: 'Short thoughts, mostly about maps.' }, + agents: CAST, + signalTypes: [LIKE, BOOST], + presence: [{ did: ADA, path: '/' }, { did: DEE }], + content: [ + { + kind: 'post', + id: 'twitter-post-long', + author: ADA, + createdAt: '2026-08-11T08:05:00.000Z', + body: [ + 'Spent the morning with the 1897 sheets and I keep coming back to the hachuring. Nobody draws slope like that now -- it is doing in ink what a hillshade does with a light source, and doing it by hand, per fell.', + 'The engraver had opinions about which side of the valley mattered. You can see it.', + ], + signals: [ + { slug: 'like', by: [BO, CY, DEE] }, + { slug: 'boost', by: [CY] }, + ], + children: [ + { + kind: 'reply', + author: BO, + createdAt: '2026-08-11T08:31:00.000Z', + body: ['This is the most Ada sentence ever written and I mean that warmly.'], + signals: [{ slug: 'like', by: [DEE] }], + }, + { + kind: 'reply', + author: CY, + createdAt: '2026-08-11T09:02:00.000Z', + body: ['Scan. Please.'], + }, + ], + }, + { + kind: 'post', + author: BO, + createdAt: '2026-08-10T19:40:00.000Z', + body: ['Every flat map is wrong. Some are wrong on purpose. A few are wrong beautifully.'], + signals: [{ slug: 'like', by: [ADA, DEE] }], + }, + { + kind: 'post', + author: DEE, + createdAt: '2026-08-10T12:15:00.000Z', + body: ['Reminder that the archive closes at four on Fridays and I will not be reopening it for anyone.'], + }, + { + kind: 'post', + author: CY, + createdAt: '2026-08-09T16:00:00.000Z', + body: ['Ridge walk on the 22nd. Waterproofs.'], + signals: [{ slug: 'like', by: [ADA] }], + }, + ], + route: '/', +}; + +export const instagramFixture: Fixture = { + id: 'instagram', + templateId: 'instagram', + space: { name: 'Field Notes', description: 'What we saw, where we saw it.' }, + agents: CAST, + signalTypes: [LIKE], + presence: [{ did: CY, path: '/' }], + // Five, deliberately: an odd count leaves the last grid row short, which is where tile sizing and + // gap handling stop being guesses. + content: [ + { + kind: 'post', + author: ADA, + createdAt: '2026-08-11T07:00:00.000Z', + body: ['Western sheets, morning light.'], + images: [{ src: PLATES[0], alt: 'Survey sheet detail', width: 800, height: 800 }], + signals: [{ slug: 'like', by: [BO, CY, DEE] }], + }, + { + kind: 'post', + author: CY, + createdAt: '2026-08-10T14:20:00.000Z', + body: ['Trig point, finally.'], + images: [{ src: PLATES[1], alt: 'Trig point', width: 800, height: 800 }], + signals: [{ slug: 'like', by: [ADA] }], + }, + { + kind: 'post', + author: DEE, + createdAt: '2026-08-09T11:11:00.000Z', + body: ['Marginalia on a sheet nobody has requested since 1974.'], + images: [{ src: PLATES[2], alt: 'Marginalia', width: 800, height: 800 }], + }, + { + kind: 'post', + author: BO, + createdAt: '2026-08-08T18:45:00.000Z', + body: ['Projection argument, settled with a grapefruit.'], + images: [{ src: PLATES[3], alt: 'Grapefruit globe', width: 800, height: 800 }], + signals: [{ slug: 'like', by: [ADA, CY] }], + }, + { + kind: 'post', + author: ADA, + createdAt: '2026-08-07T09:30:00.000Z', + body: ['The folding cases. Look at the folding cases.'], + images: [{ src: PLATES[4], alt: 'Folding case', width: 800, height: 800 }], + signals: [{ slug: 'like', by: [DEE] }], + }, + ], + route: '/', +}; + +export const youtubeFixture: Fixture = { + id: 'youtube', + templateId: 'youtube', + space: { name: 'Cartography Club TV', description: 'Talks, walkthroughs, and one very long argument.' }, + agents: CAST, + signalTypes: [LIKE], + presence: [{ did: BO, path: '/' }], + content: [ + { + kind: 'playlist', + title: 'Reading a Sheet', + description: 'Start here if you have never held one.', + children: [ + { + kind: 'post', + id: 'youtube-video-contours', + author: ADA, + createdAt: '2026-08-05T10:00:00.000Z', + // A deliberately long title beside a two-word one: a card grid with a fixed thumbnail and + // a variable title is where alignment usually gives up. + body: ['Contours, hachures, and why the nineteenth century did it better than we do'], + images: [{ src: WIDE[0], alt: 'Contours', width: 1280, height: 720 }], + signals: [{ slug: 'like', by: [BO, CY] }], + }, + { + kind: 'post', + author: CY, + createdAt: '2026-08-03T10:00:00.000Z', + body: ['Grid north'], + images: [{ src: WIDE[1], alt: 'Grid north', width: 1280, height: 720 }], + }, + { + kind: 'post', + author: DEE, + createdAt: '2026-08-01T10:00:00.000Z', + body: ['Handling and storage without ruining anything'], + images: [{ src: WIDE[2], alt: 'Storage', width: 1280, height: 720 }], + signals: [{ slug: 'like', by: [ADA] }], + }, + ], + }, + { + kind: 'playlist', + title: 'Arguments', + description: 'Mercator, mostly.', + children: [ + { + kind: 'post', + author: BO, + createdAt: '2026-07-28T10:00:00.000Z', + body: ['Ninety minutes on why your world map is lying to you'], + images: [{ src: WIDE[3], alt: 'Projections', width: 1280, height: 720 }], + signals: [{ slug: 'like', by: [ADA, CY, DEE] }], + }, + ], + }, + ], + route: '/', +}; + +export const kanbanFixture: Fixture = { + id: 'kanban', + templateId: 'kanban', + space: { name: 'Club Business', description: 'What needs doing before the exhibition.' }, + agents: CAST, + signalTypes: [LIKE], + presence: [{ did: DEE, path: '/board/kanban-exhibition' }], + content: [ + { + kind: 'board', + id: 'kanban-exhibition', + title: 'Exhibition', + children: [ + { + kind: 'column', + title: 'To do', + children: [ + { + kind: 'post', + author: DEE, + createdAt: '2026-08-11T09:00:00.000Z', + body: ['Condition-check the 1897 sheets before anything goes in a frame'], + }, + { + kind: 'post', + author: ADA, + createdAt: '2026-08-11T09:05:00.000Z', + body: ['Write the wall text for the hachuring panel'], + signals: [{ slug: 'like', by: [BO] }], + }, + { + kind: 'post', + author: CY, + createdAt: '2026-08-10T15:00:00.000Z', + body: ['Borrow the flat files'], + }, + ], + }, + { + kind: 'column', + title: 'In progress', + children: [ + { + kind: 'post', + author: BO, + createdAt: '2026-08-09T11:00:00.000Z', + body: ['Scanning -- about a third done, the folded ones are slow'], + signals: [{ slug: 'like', by: [ADA, DEE] }], + }, + ], + }, + // Deliberately empty: a board where every column has cards never shows what an empty one + // does to the layout, and that is the case boards most often get wrong. + { kind: 'column', title: 'Blocked' }, + { + kind: 'column', + title: 'Done', + children: [ + { + kind: 'post', + author: ADA, + createdAt: '2026-08-02T10:00:00.000Z', + body: ['Book the room'], + }, + ], + }, + ], + }, + ], + route: '/board/kanban-exhibition', +}; + +export const eventsFixture: Fixture = { + id: 'events', + templateId: 'events', + themeId: 'retro', + space: { name: 'Club Calendar', description: 'Walks, talks, and the AGM nobody enjoys.' }, + agents: CAST, + signalTypes: [{ name: 'Going', slug: 'going', icon: 'check-circle', description: 'Count me in' }], + presence: [{ did: ADA, path: '/' }], + content: [ + { + kind: 'event', + author: CY, + createdAt: '2026-08-01T10:00:00.000Z', + body: [ + 'Ridge walk -- Cumberland western fells. Meet 08:00 at the car park. Waterproofs, and something warm; last time was educational.', + ], + signals: [{ slug: 'going', by: [ADA, BO, DEE] }], + }, + { + kind: 'event', + author: DEE, + createdAt: '2026-08-04T10:00:00.000Z', + body: [ + 'Archive open evening. The 1897 sheets will be out of their cases, so: clean hands, no drinks, and do not fold anything.', + ], + signals: [{ slug: 'going', by: [ADA] }], + }, + { + kind: 'event', + author: BO, + createdAt: '2026-08-06T10:00:00.000Z', + body: ['Projections evening. Bring a world map you dislike and explain why.'], + signals: [{ slug: 'going', by: [CY, DEE] }], + }, + { + kind: 'event', + author: ADA, + createdAt: '2026-08-08T10:00:00.000Z', + body: ['AGM. Brief, allegedly.'], + }, + ], + route: '/', +}; diff --git a/packages/templates/fixtures/src/types.ts b/packages/templates/fixtures/src/types.ts index 3182d446f..2c9299a28 100644 --- a/packages/templates/fixtures/src/types.ts +++ b/packages/templates/fixtures/src/types.ts @@ -80,6 +80,13 @@ export interface FixtureNode { * plausible and is the sort of thing you only notice after matching a screenshot against it. */ createdAt?: string; + /** + * Images belonging to this node, written as `ImageBlock` children. + * + * A media grid drops posts with no image rather than showing blank tiles, so a photo-shaped + * template with none of these renders as an empty state no matter how much text it has. + */ + images?: { src: string; alt?: string; width?: number; height?: number }[]; /** Reactions, by signal-type slug, listing who reacted. */ signals?: { slug: string; by: string[] }[]; children?: FixtureNode[]; From dc5e0bfb88497832066ae1f3904eed0806d53ddb Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 22:05:19 +0100 Subject: [PATCH 10/14] feat(we-preview): measure a reference screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Palette and column structure from a PNG, so matching starts from numbers instead of adjectives. A screenshot carries no scale — 5112px wide is a 2556pt window at 2x or a 5112pt one at 1x, and every measurement means something different depending on which. `--window` if you know the capture size; `--calibrate ` otherwise, given the known CSS width of the first column, which is the more reliable of the two because a platform's rail width is published and nobody remembers their window size. On the Discord reference, calibrating against its 72px server rail lands on 1.97x, which is 2x plus one pixel of antialiasing at the boundary — the two methods agreeing is the check that the number means anything. Columns come from the modal colour of each x down 240 sampled rows. The obvious approach, one horizontal scan line, is useless: at any given y it crosses server icons, avatars and embedded images, so the bands it reports are whatever content sat on that line. A rail is flat for hundreds of rows and an avatar is not. Known limit: flat surfaces measure exactly (#1a1a1e, #ffffff come back as themselves), but thin antialiased elements — accent text, 1px dividers — are averaged with their neighbours by the histogram and come back approximate. Read those as "about this hue", not as the value to paste into a theme. Co-Authored-By: Claude Opus 5 (1M context) --- apps/we-preview/scripts/measure.mjs | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 apps/we-preview/scripts/measure.mjs diff --git a/apps/we-preview/scripts/measure.mjs b/apps/we-preview/scripts/measure.mjs new file mode 100644 index 000000000..69dca0456 --- /dev/null +++ b/apps/we-preview/scripts/measure.mjs @@ -0,0 +1,129 @@ +/** + * Measure a reference screenshot: palette, and the vertical columns it is built from. + * + * ```sh + * node scripts/measure.mjs ~/ref/discord.png --row 0.5 --calibrate 72 + * ``` + * + * A screenshot has no intrinsic scale — 5112 pixels wide could be a 2556pt window at 2× or a 5112pt + * one at 1×, and every measurement means something different depending on which. Two ways out: + * pass `--window ` if you know what the capture was taken at, or `--calibrate ` with + * the known CSS width of the *first* column, which is usually the more reliable of the two because + * a platform's rail width is a published constant and nobody remembers their window size. + * + * Columns are found by scanning one horizontal row for runs of near-constant colour. That is crude + * and exactly right for this subject: an app chrome is vertical bands of flat fill, and the run + * boundaries are the rails, gutters and content columns you actually need the widths of. + */ +import { readFile } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; + +import { chromium } from 'playwright-core'; + +const args = process.argv.slice(2); +const file = args.find((a) => !a.startsWith('--')); +if (!file) { + console.error('usage: node scripts/measure.mjs [--row 0.5] [--window 1440] [--calibrate 72]'); + process.exit(1); +} +const flag = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i === -1 ? fallback : Number(args[i + 1]); +}; +const rowFraction = flag('row', 0.5); +const windowWidth = flag('window', 0); +const calibrateFirst = flag('calibrate', 0); + +const browser = await chromium.launch({ channel: 'chrome' }); +const page = await browser.newPage(); +const uri = `data:image/png;base64,${(await readFile(resolve(process.cwd(), file))).toString('base64')}`; + +const result = await page.evaluate( + async ([dataUri, row]) => { + const bitmap = await createImageBitmap(await (await fetch(dataUri)).blob()); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0); + const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height); + const hex = (r, g, b) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('')}`; + + // ── palette ── + const bins = new Map(); + for (let i = 0; i < data.length; i += 16) { + if (data[i + 3] < 128) continue; + const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3); + const bin = bins.get(key) ?? { n: 0, r: 0, g: 0, b: 0 }; + bin.n += 1; + bin.r += data[i]; + bin.g += data[i + 1]; + bin.b += data[i + 2]; + bins.set(key, bin); + } + const total = [...bins.values()].reduce((s, b) => s + b.n, 0); + const palette = [...bins.values()] + .sort((a, b) => b.n - a.n) + .slice(0, 8) + .map((b) => ({ + hex: hex(Math.round(b.r / b.n), Math.round(b.g / b.n), Math.round(b.b / b.n)), + share: +(b.n / total).toFixed(3), + })); + + // ── columns, from the modal colour of each x across many rows ── + // + // A single scan line was the obvious approach and is useless: at any given y it crosses server + // icons, avatars and embedded images, so the "bands" it finds are whatever content happened to + // sit on that line. Taking the most common colour down each column ignores content — a rail is + // flat for hundreds of rows and an avatar is not — and leaves the chrome. + const sampleRows = []; + const rowCount = Math.min(240, bitmap.height); + for (let n = 0; n < rowCount; n += 1) sampleRows.push(Math.floor((n / rowCount) * bitmap.height)); + + const at = (x) => { + const counts = new Map(); + for (const y of sampleRows) { + const i = (y * bitmap.width + x) * 4; + const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3); + const entry = counts.get(key) ?? { n: 0, c: [data[i], data[i + 1], data[i + 2]] }; + entry.n += 1; + counts.set(key, entry); + } + let best = { n: 0, c: [0, 0, 0] }; + for (const entry of counts.values()) if (entry.n > best.n) best = entry; + return best.c; + }; + const near = (a, b, tol = 6) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) <= tol; + + const runs = []; + let start = 0; + let colour = at(0); + for (let x = 1; x < bitmap.width; x += 1) { + const here = at(x); + if (near(here, colour)) continue; + runs.push({ from: start, to: x, width: x - start, hex: hex(...colour) }); + start = x; + colour = here; + } + runs.push({ from: start, to: bitmap.width, width: bitmap.width - start, hex: hex(...colour) }); + + // Runs narrower than 8px are borders, dividers and text — real, but not columns. + return { width: bitmap.width, height: bitmap.height, palette, bands: runs.filter((r) => r.width >= 8) }; + }, + [uri, rowFraction], +); +await browser.close(); + +const scale = windowWidth ? result.width / windowWidth : calibrateFirst ? result.bands[0].width / calibrateFirst : 1; +const css = (px) => (scale === 1 ? `${px}px?` : `${Math.round(px / scale)}px`); + +console.log(`\n${basename(file)} ${result.width}x${result.height}`); +console.log( + scale === 1 + ? ' scale unknown — pass --window or --calibrate, every width below is raw image pixels' + : ` scale ${scale.toFixed(2)}x → logical ${Math.round(result.width / scale)}x${Math.round(result.height / scale)}`, +); + +console.log('\n palette'); +for (const c of result.palette) console.log(` ${c.hex} ${(c.share * 100).toFixed(1)}%`); + +console.log('\n vertical bands (modal colour per column)'); +for (const b of result.bands) console.log(` ${b.hex} ${String(css(b.width)).padStart(7)} x ${css(b.from)}`); From 28361da075b3cb27ef8e36c44204d003feeb3162 Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 22:12:49 +0100 Subject: [PATCH 11/14] feat(design-utils): let a template name a semantic role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bg="surface-sunken"` now resolves to `var(--we-role-surface-sunken)`, on every colour prop and inside a border shorthand. Roles could not be *named* from a template before this. The vocabulary shipped, a theme could pin one, and the primitives now read them — but a template author had to spell `var(--we-role-surface-sunken)` by hand, so templates kept reaching for scale positions instead. That is the missing half of the role work: adoption by components is worth nothing if the layer above cannot participate. It matters more than it sounds, because a scale position cannot express a relationship that *inverts*. The Discord-shaped template paints its rail `neutral-100` over a `neutral-50` page: darker-on-lighter in light mode, and lighter-on-darker in dark, because the whole scale flips. Discord's rails are darker than its page in both. Measured against the reference screenshot, ours is the wrong way round — and no choice of scale position fixes it, because the two modes need opposite answers. A role can say "sunken" and let the theme decide what that means. No collision: colour tokens are `{hue}-{shade}` over five closed hues, and no role name starts with one. Scoped to colour props, so a space prop naming `surface` still resolves to a (nonexistent) space variable rather than silently becoming a colour — a mistake worth keeping visible. Co-Authored-By: Claude Opus 5 (1M context) --- .../design-system/utils/src/dsProps.test.ts | 31 +++++++++++++++++++ packages/design-system/utils/src/index.ts | 21 ++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/design-system/utils/src/dsProps.test.ts b/packages/design-system/utils/src/dsProps.test.ts index 7d63d91dd..85085ac16 100644 --- a/packages/design-system/utils/src/dsProps.test.ts +++ b/packages/design-system/utils/src/dsProps.test.ts @@ -165,3 +165,34 @@ describe('buildLayoutStyles', () => { expect(buildLayoutStyles({ reverse: true }, 'row')['flex-direction']).toBe('row-reverse'); }); }); + +describe('semantic roles as colour prop values', () => { + /** + * A template could not name a role before this — only spell out its variable — so templates used + * scale positions, which cannot express a relationship that inverts between light and dark. + */ + it('resolves a kebab-cased role name to its variable', () => { + expect(tokenVar('color', 'surface-sunken')).toBe('var(--we-role-surface-sunken)'); + expect(tokenVar('color', 'surface-raised')).toBe('var(--we-role-surface-raised)'); + expect(tokenVar('color', 'text-muted')).toBe('var(--we-role-text-muted)'); + expect(tokenVar('color', 'overlay')).toBe('var(--we-role-overlay)'); + }); + + it('leaves scale positions and raw CSS alone', () => { + expect(tokenVar('color', 'neutral-100')).toBe('var(--we-color-neutral-100)'); + expect(tokenVar('color', 'primary-500')).toBe('var(--we-color-primary-500)'); + expect(tokenVar('color', '#1a1a1e')).toBe('#1a1a1e'); + expect(tokenVar('color', 'transparent')).toBe('transparent'); + }); + + it('only applies to colour props', () => { + // `surface` is a role, and also not a space token — a space prop naming it is a mistake, and + // resolving it to a colour would hide that behind a plausible-looking variable. + expect(tokenVar('space', 'surface')).toBe('var(--we-space-surface)'); + }); + + it('resolves a border shorthand naming a role', () => { + expect(parseBorder('1px solid border')).toBe('1px solid var(--we-role-border)'); + expect(parseBorder('1px solid border-strong')).toBe('1px solid var(--we-role-border-strong)'); + }); +}); diff --git a/packages/design-system/utils/src/index.ts b/packages/design-system/utils/src/index.ts index aab3547eb..f833539a0 100644 --- a/packages/design-system/utils/src/index.ts +++ b/packages/design-system/utils/src/index.ts @@ -1,5 +1,5 @@ import type { DesignSystemProps, FlexDirection } from '@we/design-types'; -import { font } from '@we/tokens'; +import { font, role } from '@we/tokens'; // --- Shared sub-arrays (used by CSS helpers directly) --- export const paddingKeys = ['p', 'px', 'py', 'pt', 'pr', 'pb', 'pl'] as const; @@ -195,6 +195,22 @@ export const resolveFontWeight = makeTokenResolver(new Set(Object.keys(font.weig /** Resolves fontFamily: token names → CSS var, raw CSS font stacks → passthrough. */ export const resolveFontFamily = makeTokenResolver(new Set(Object.keys(font.family)), 'font-family'); +/** + * Role names, kebab-cased, as a template writes them: `bg="surface-sunken"`. + * + * Roles could not be *named* from a template before this. The vocabulary existed and a theme could + * pin one, but every consumer had to spell `var(--we-role-surface-sunken)` by hand — so templates + * kept reaching for scale positions instead, and a scale position cannot express a relationship + * that inverts between light and dark. The Discord-shaped template is the case in point: it paints + * its rail `neutral-100` over a `neutral-50` page, which is darker-on-lighter in light mode and + * *lighter-on-darker* in dark, because the whole scale inverts. Discord's rails are darker than its + * page in both. Only a role can say that. + * + * No collision with colour tokens: those are `{hue}-{shade}` over a closed set of five hues, and no + * role name begins with one. + */ +const ROLE_NAMES = new Set(Object.keys(role).map((name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`))); + export function tokenVar(prefix: string, token?: string, fallback = '0') { // If no token, return fallback if (!token) return fallback; @@ -205,6 +221,9 @@ export function tokenVar(prefix: string, token?: string, fallback = '0') { // Allow raw CSS values (hex colors, px, rem, %, rgba, etc.) if (isRawCSSValue(token)) return token; + // A colour prop may name a semantic role instead of a scale position. + if (prefix === 'color' && ROLE_NAMES.has(token)) return `var(--we-role-${token})`; + // Otherwise return CSS variable return `var(--we-${prefix}-${token})`; } From c7ba6f14a4a82c0e62d43a1185e50d639666e1ca Mon Sep 17 00:00:00 2001 From: jhweir Date: Wed, 12 Aug 2026 22:15:07 +0100 Subject: [PATCH 12/14] feat(schema): $each exposes $index and $prev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row could only ever ask about itself. `$each` handed the item down and nothing about its neighbours, so any design where a row depends on the one before it was unreachable — not awkward, unreachable, with no prop or theme able to recover it. The case that forced it is **grouping**. A chat log that repeats the avatar and byline on every consecutive message from the same person is a visibly different and much less dense design from one that collapses them, and that difference is most of the gap between our channels template and the reference screenshot it is being matched to. It is one `$if` away once a row can see its predecessor: condition: { $eq: ['$message.author', '$prev.author'] } The first row has no `$prev` at all, so the condition is false there and it keeps its byline — which is what a feed wants, and the reason absent must not read as "same as the last item". Both keys are plain values, matching how the item itself is passed: a context ref resolves by path lookup and a function under `prev` would break it. The consequence is documented rather than hidden — `$prev` is captured when a row renders, so a *reorder* that leaves a row's own identity unchanged under a keyed `` can leave it stale. Appends and prepends, which is every feed here, are unaffected. Documented in the ai-context fragment, so the in-app AI gets it too. Co-Authored-By: Claude Opus 5 (1M context) --- .cursor/rules/we-schema.mdc | 20 ++++ .github/copilot-instructions.md | 20 ++++ CLAUDE.md | 20 ++++ .../src/fragments/schema-operators.ts | 20 ++++ packages/ai-context/src/schemaContext.ts | 2 +- .../frameworks/solid/src/SchemaRenderer.tsx | 25 +++- .../solid/tests/eachNeighbours.test.tsx | 111 ++++++++++++++++++ 7 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 packages/schema-system/frameworks/solid/tests/eachNeighbours.test.tsx diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index e0cee6b5f..4ea15f579 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -600,6 +600,26 @@ Each loop: { "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] } Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name. +Each row also gets two context keys describing its position in the list: +- { "$index": ... } — read as "$index", the 0-based position. +- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author". + +"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so +a run of messages shows one avatar and byline instead of repeating them. Without it a row can only +ask about itself, and the compact form is unreachable by any prop or theme: +{ + "type": "$if", + "props": { + "condition": { "$eq": ["$message.author", "$prev.author"] }, + "then": { "...": "compact row — no avatar, no byline" }, + "else": { "...": "full row" } + } +} +The first row has no "$prev" at all, so the condition is false there and it keeps its byline — +which is what a feed wants, and why absent must not read as "same as the last item". + +Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0. + Conditional rendering: { "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } } Renders "then" node if condition is truthy, else renders "else" node. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e0cee6b5f..4ea15f579 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -600,6 +600,26 @@ Each loop: { "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] } Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name. +Each row also gets two context keys describing its position in the list: +- { "$index": ... } — read as "$index", the 0-based position. +- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author". + +"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so +a run of messages shows one avatar and byline instead of repeating them. Without it a row can only +ask about itself, and the compact form is unreachable by any prop or theme: +{ + "type": "$if", + "props": { + "condition": { "$eq": ["$message.author", "$prev.author"] }, + "then": { "...": "compact row — no avatar, no byline" }, + "else": { "...": "full row" } + } +} +The first row has no "$prev" at all, so the condition is false there and it keeps its byline — +which is what a feed wants, and why absent must not read as "same as the last item". + +Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0. + Conditional rendering: { "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } } Renders "then" node if condition is truthy, else renders "else" node. diff --git a/CLAUDE.md b/CLAUDE.md index e0cee6b5f..4ea15f579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -600,6 +600,26 @@ Each loop: { "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] } Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name. +Each row also gets two context keys describing its position in the list: +- { "$index": ... } — read as "$index", the 0-based position. +- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author". + +"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so +a run of messages shows one avatar and byline instead of repeating them. Without it a row can only +ask about itself, and the compact form is unreachable by any prop or theme: +{ + "type": "$if", + "props": { + "condition": { "$eq": ["$message.author", "$prev.author"] }, + "then": { "...": "compact row — no avatar, no byline" }, + "else": { "...": "full row" } + } +} +The first row has no "$prev" at all, so the condition is false there and it keeps its byline — +which is what a feed wants, and why absent must not read as "same as the last item". + +Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0. + Conditional rendering: { "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } } Renders "then" node if condition is truthy, else renders "else" node. diff --git a/packages/ai-context/src/fragments/schema-operators.ts b/packages/ai-context/src/fragments/schema-operators.ts index 375842503..42278d9e0 100644 --- a/packages/ai-context/src/fragments/schema-operators.ts +++ b/packages/ai-context/src/fragments/schema-operators.ts @@ -441,6 +441,26 @@ Each loop: { "type": "$each", "props": { "items": { "$store": "storeName.arrayProperty" }, "as": "itemName" }, "children": [ ... ] } Renders children once for each item. The "as" name becomes a context key. Defaults to "item" — omit "as" unless you need a different name. +Each row also gets two context keys describing its position in the list: +- { "$index": ... } — read as "$index", the 0-based position. +- "$prev" — the previous item, absent on the first row. Read fields off it like any context ref: "$prev.author". + +"$prev" is what makes **grouping** expressible — collapsing consecutive rows by the same author so +a run of messages shows one avatar and byline instead of repeating them. Without it a row can only +ask about itself, and the compact form is unreachable by any prop or theme: +{ + "type": "$if", + "props": { + "condition": { "$eq": ["$message.author", "$prev.author"] }, + "then": { "...": "compact row — no avatar, no byline" }, + "else": { "...": "full row" } + } +} +The first row has no "$prev" at all, so the condition is false there and it keeps its byline — +which is what a feed wants, and why absent must not read as "same as the last item". + +Both shadow in a nested $each, exactly as the item does: the inner "$index" restarts at 0. + Conditional rendering: { "type": "$if", "props": { "condition": ..., "then": { ... }, "else": { ... } } } Renders "then" node if condition is truthy, else renders "else" node. diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index 6dfb74b66..1785a0429 100644 --- a/packages/ai-context/src/schemaContext.ts +++ b/packages/ai-context/src/schemaContext.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED by packages/ai-context/src/generate.ts // Do not edit manually. Run: pnpm --filter @we/ai-context generate-context -export const schemaContext = "## Schema Structure\n\nA schema is a tree of nodes. Each node can have:\n- type: The component to render (string, e.g. \"we-button\", \"Column\")\n- props: An object of props for the component\n- children: An array of child nodes (or strings for text), or token objects like { $store: '...' } or { $concat: [...] }.\n- slots: Named slots for advanced composition (optional)\n- slot: The name of the slot this node should be rendered into (optional)\n- routes: For routing components, an array of nestable route objects (optional)\n- styles: Raw CSS escape hatch — Record applied as inline styles on a **wrapper div** that surrounds the component. Use only for CSS that must live on a wrapper: filter, clip-path, backdrop-filter, mix-blend-mode. When present the wrapper participates in layout (no display:contents), so CSS effects apply correctly. **Important:** this is NOT the same as props.styles. If you want to apply custom CSS to a Column, Row, or Grid's own element (e.g. a background image), put it in props.styles instead — node-level styles go on a wrapper div around the component and will be hidden behind the component's own background.\n\nExample node:\n{\n \"type\": \"we-button\",\n \"props\": {\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"house\" } },\n { \"type\": \"we-text\", \"props\": { \"size\": \"600\" }, \"children\": [\"Home\"] }\n ]\n}\n\n## Prop-level Dynamic Logic & Expressions\n\nSpecial tokens in props enable dynamic, reactive, or computed behavior.\n\nStore reference:\n{ \"$store\": \"storeName.property.path\" }\nResolves a value from a named store, supporting nested paths.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nSupports async lifecycle callbacks — fired after the store method's Promise resolves/rejects:\n onSuccess: [...actions] — fired on resolve; '$result' (and '$result.') in args refers to the resolved value\n onError: [...actions] — fired on reject; '$result.message' etc. refers to the error object\n onFinally: [...actions] — fired regardless of outcome\nNon-promise (synchronous) methods are unaffected — lifecycle keys are ignored.\nExample — close modal after async submission:\n{ \"$action\": \"spaceStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"spaceStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$result.uuid\"] }] }] }\n\nModel mutations via $action (use these for creating/updating/deleting model instances):\nmodel.create — creates a model instance in the current perspective (default) or a specified one:\n{ \"$action\": \"model.create\", \"args\": [\"ModelName\", { \"field\": \"value\" }, { \"perspective\": \"datasetStore.rootDataset\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nmodel.update — updates a model instance:\n{ \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"value\" }, { \"perspective\": \"datasetStore.rootDataset\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'datasetStore.rootDataset' for we-root models (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped models (Space, Signal, etc.).\n\nConditional logic:\n{ \"$if\": { \"condition\": ..., \"then\": ..., \"else\": ... } }\nEvaluates condition; if truthy, returns then, else returns else.\n\nMap/iterate:\n{ \"$map\": { \"items\": { \"$store\": \"templateStore.templates\" }, \"select\": { ... } } }\nIterates over an array, mapping each item to a new object using the select mapping.\n\nPick:\n{ \"$pick\": { \"from\": { \"$store\": \"userStore.profile\" }, \"props\": [\"name\", \"email\"] } }\nPicks specific properties from an object.\n\nConcat (string building):\n{ \"$concat\": [\"part1\", \"$context.value\", \"part2\"] }\nJoins multiple parts into a single string.\n\nContext references:\nStrings starting with \"$\" followed by a context key resolve to context values.\nExample: \"$space.name\" resolves to the name property of the space context variable.\nDot paths supported: \"$item.profile.avatar\".\n\nEquality / inequality checks:\n{ \"$eq\": [a, b] } — strict equality\n{ \"$ne\": [a, b] } — strict inequality\n\nNumeric comparisons:\n{ \"$lt\": [a, b] } — a < b (less than)\n{ \"$gt\": [a, b] } — a > b (greater than)\nExample: { \"$gt\": [{ \"$count\": { \"items\": { \"$store\": \"listStore.items\" } } }, 0] }\n\nSet membership:\n{ \"$in\": [value, array] } — true if array contains value (false if second operand is not an array)\nExample: { \"$in\": [{ \"$store\": \"spaceStore.uuid\" }, { \"$store\": \"datasetStore.systemDatasetUuids\" }] }\nExample: { \"$in\": [\"$item.role\", [\"admin\", \"moderator\"]] }\n\nBoolean logic:\n{ \"$and\": [a, b, ...] } — all truthy\n{ \"$or\": [a, b, ...] } — any truthy\n{ \"$not\": a } — negation\n\nArray operators:\n{ \"$filter\": { \"items\": , \"where\": { \"field\": \"value\", ... } } }\nFilters an array to items where all where conditions match. Mirrors the $query where operator set:\n\n { \"field\": \"value\" } — strict equality\n { \"field\": { \"not\": \"value\" } } — inequality; array form excludes multiple values\n { \"field\": { \"contains\": \"text\" } } — case-insensitive substring match (strings only)\n { \"field\": { \"exists\": true } } — non-null / non-undefined presence check\n { \"field\": { \"exists\": false } } — null or undefined check\n\nWhere values (including those inside operator objects) are resolved through the prop system,\nso $store, $local, and context refs like { \"$local\": \"searchText\" } all work.\n\nLogical combinators (OR / AND / NOT) — supported in both $query's where and $filter's where:\n { \"OR\": [ { \"field\": \"value\" }, { \"field2\": \"value2\" } ] } — matches if ANY branch matches\n { \"AND\": [ { ... }, { ... } ] } — matches if ALL branches match (sibling keys at the\n same level are already implicitly ANDed — use AND\n to group a set of conditions alongside an OR/NOT)\n { \"NOT\": { \"field\": \"value\" } } — matches if the branch does NOT match\nBranches are full where-clause objects (can contain multiple fields, and can nest OR/AND/NOT inside each other).\nSibling keys alongside OR/AND/NOT at the same level are implicitly ANDed with it.\nExample — case-insensitive search across two fields ($filter takes the same shape, e.g. a member\nlist matching name OR handle):\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"where\": {\n \"OR\": [\n { \"name\": { \"contains\": { \"$local\": \"searchText\" } } },\n { \"description\": { \"contains\": { \"$local\": \"searchText\" } } }\n ]\n }\n }\n}\nNote: using OR/AND/NOT disables the SPARQL-level sort/pagination pushdown (see count-projection and\nrelation-property ordering below) — those orderings silently stop working if combined with OR/AND/NOT in the\nsame query's where clause, because the fallback sort runs before the projection/relation data is attached.\n\nExamples:\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"role\": \"admin\" } } }\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"location\": { \"exists\": true }, \"handle\": { \"contains\": { \"$local\": \"searchText\" } } } } }\n\n{ \"$count\": { \"items\": } }\nReturns the length of an array.\nExample: { \"badge\": { \"$count\": { \"items\": { \"$store\": \"notificationStore.unread\" } } } }\n\n{ \"$find\": { \"items\": , \"where\"?: { ... }, \"select\"?: \"fieldName\" } }\nFinds the first matching item. where is optional (returns first item if omitted). select plucks a single field.\nExample: { \"$find\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"id\": \"$item.creatorId\" }, \"select\": \"name\" } }\n\n{ \"$plural\": { \"count\": , \"one\": \"singular\", \"other\": \"plural\" } }\nReturns \"one\" when count === 1, otherwise \"other\". Use in children arrays for count-noun labels.\ncount is resolved through the prop system — any numeric expression ($count, $store, context ref) works.\nExample: { \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }\nCompose with we-number for a full \"N Members\" display:\n we-number (value: { \"$count\": ... }, shorten: true) + we-text (children: [{ \"$plural\": { \"count\": { \"$count\": ... }, \"one\": \"Member\", \"other\": \"Members\" } }])\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"ModelName\", \"where\": { \"field\": \"value\" }, \"limit\": 10, \"order\": { \"field\": \"asc\" } } }\nQueries the current dataset for entity instances. Always returns an array.\nOptions: entity (required), where, order, limit, offset, include, scope, dataset, subscribe.\nsubscribe defaults to true — reactive live updates. Set subscribe: false to do a one-time fetch.\nBy default $query targets the current dataset ($currentDataset). Use dataset to query a different dataset —\nrequired when reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } }\n\nBackend-neutral identity & dataset refs — prefer these over backend-store paths inside $query and conditions:\n- $currentDataset — the currently active dataset (an AD4M perspective, in the AD4M backend). Use as a dataset value.\n A host store's dataset accessor (e.g. `dataset: 'datasetStore.marketplaceDataset'`) works as a dataset value too.\n When passing a dataset to a *component prop* rather than a query, append `.handle` — component props take the\n backend's own dataset handle: { \"perspective\": { \"$store\": \"datasetStore.currentDataset.handle\" } }.\n- $me — the current agent's identity object. Use $me.did for their DID (ownership checks, author filters, e.g. { \"$eq\": [\"$post.author\", \"$me.did\"] }); $me.handle / $me.avatar for profile fields once loaded.\n\nEager-loading relations with include (most common relational pattern):\ninclude hydrates related model instances in the same query — no extra fetches needed.\nRelation names come from the HasMany relations listed for each model in externalModels.\n\nSimple include — hydrate all related instances:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": true } } }\nEach item in the result will have a conversations array of hydrated Conversation objects.\n\nSub-query include — filter, sort, or limit the related records:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"order\": { \"createdAt\": \"desc\" }, \"limit\": 10 } } } }\n\nNested include — hydrate relations of relations:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"include\": { \"messages\": true } } } } }\nNesting can go as deep as needed. Each level adds one batched fetch (not N+1).\n\nCount projection — add a derived numeric field:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } } } }\nThe $-prefixed key becomes a new field on each result item (e.g. item.$likeCount = 42).\n\nSorting by a count projection — order can reference a $-prefixed count key directly, sorting by the aggregate:\n{\n \"$query\": {\n \"entity\": \"Post\",\n \"limit\": 20,\n \"order\": { \"$likeCount\": \"desc\" },\n \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } }\n }\n}\nRequirements: only a single order key is supported when it targets a projection (mixing it with a second sort key falls back\nto a plain property sort), and the query must also specify limit or offset — without one the count isn't computed yet at\nsort time and the order silently has no effect. Always pair count-projection ordering with a limit.\nCombine with $if for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": {\n \"$if\": {\n \"condition\": { \"$eq\": [{ \"$local\": \"sortField\" }, \"likes\"] },\n \"then\": { \"$likeCount\": { \"$local\": \"sortDirection\" } },\n \"else\": { \"createdAt\": { \"$local\": \"sortDirection\" } }\n }\n }\n}\n\nSorting by a related model property — order can reference a dotted \"relation.property\" path for a HasOne/HasMany\nrelation declared on the model, sorting by a scalar property on the related instance:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"limit\": 20,\n \"order\": { \"location.country\": \"asc\" },\n \"include\": { \"location\": true }\n }\n}\nSame requirements as count-projection ordering above: only a single order key, and pair with limit/offset — without\none the relation data isn't attached yet at sort time and the order silently has no effect. include isn't required\nfor the sort itself (the relation is resolved from the model's declared shape), but you'll usually want it anyway to\nread the field in the UI (e.g. \"$space.location.country\").\nCombine with $if the same way as count-projection ordering to let the user toggle between sort fields.\n\nSingle-item projection — add a derived field that resolves to one instance or null:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$myLike\": { \"from\": \"likes\", \"where\": { \"author\": \"$me.did\" }, \"limit\": 1 } } } }\nWith limit: 1 the field unwraps to T | null instead of an array.\n\ninclude only works with typed relations — ones where the target model class is known.\nFor WE models this is always the case. For external models, check the externalModels listing:\nrelations marked \"→ ModelName\" are typed (safe for include); relations marked \"parent query only\"\nare untyped and will crash at runtime if used with include — use a scope drill-down instead.\n\nRelational queries — fetch a parent record's children (drill-down navigation):\n{ \"$query\": { \"entity\": \"Conversation\", \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": \"$channel.id\" } } }\nscope.anchor is the parent entity type; scope.via is its relation whose targets are this query's entity (the\nHasMany relation listed for that entity in externalModels); scope.anchorId is the parent record's id (typically\nfrom a $each context variable or a route segment). The adapter resolves the relation to a backend handle —\nno protocol details live in the template.\nUse this pattern when navigating to a detail route and loading only that record's children.\nFor external-app datasets, always add dataset: \"$currentDataset\".\n\nLocal state (scoped ephemeral state):\nDeclare on any node: \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\" } }\nSupported types: \"string\", \"boolean\", \"number\", \"function\", \"object\".\nTwo opt-in persistence tiers (see docs/architecture/routing-and-view-state.md for the full rules):\n- \"syncParam\": \"\" mirrors the field into a URL query parameter — for VIEW STATE (selected\n content type, sort, filters, search): what a shared link's recipient should see exactly as the\n sender does. Object form { \"name\": \"type\", \"push\": true } adds a Back entry on change (use for\n content-type switches; sort/filter changes keep the default replace). A field back at its\n declared initial removes its param, keeping URLs clean.\n Example: { \"type\": \"string\", \"initial\": \"posts\", \"syncParam\": { \"name\": \"type\", \"push\": true } }\n- \"persist\": \"\" keeps the field on the device (localStorage) — for PREFERENCES (display\n density, collapsed rails): things a shared link must NOT impose on its recipient. The key is\n explicit and deployment-global (namespace it, e.g. \"cards.displayMode\").\nPrecedence on mount: URL param > persisted value > declared \"initial\"; $resetLocal clears both.\nNeither applies to \"file\"/\"function\" fields. Open-modal and in-flight flags stay plain (ephemeral).\nThe deciding question: \"if I sent this URL to someone, should they see the effect?\" — yes: syncParam;\nno but future-me should: persist; no one: plain.\nLinks may also carry ?template= and ?theme= — the shell applies them when the recipient has\nthem and warns (toast) when not. Templates never handle these params themselves.\nRead: { \"$local\": \"name\" } — returns the signal value (reactive).\n { \"$local\": \"name.nested.path\" } — dot-notation reads into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"from\": \"$event.target.value\" } — event handler that updates the signal.\n { \"$setLocal\": \"name\", \"value\": \"literal\" } — sets to a literal value (string, number, boolean, null, object).\n { \"$setLocal\": \"name\", \"merge\": { \"field\": \"$event.detail\" } } — shallow-merges fields into an object-typed signal. Values are resolved as event paths (e.g. \"$event.detail\") or passed as literals. Use for partial updates to object state.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nCall function: { \"$callLocal\": \"fieldName\" } — event handler that calls the function stored in a function-typed local field.\n Used when a child component needs to trigger a callback passed in via $localState.\n The field must be declared as type: 'function' and set via $setLocal.\n Example: { \"onClick\": { \"$callLocal\": \"onConfirm\" } }\nState is created on mount and destroyed on unmount. Nested $localState declarations merge, inner fields shadow outer.\n$local values can be used in $action args: { \"$action\": \"store.method\", \"args\": [{ \"$local\": \"name\" }] }\n\nObject-typed local state (consolidating related scalar fields):\nWhen several related fields share a common condition on their initial values (e.g. all null/empty when a store value is absent), prefer a single \"object\" field seeded from the store, then read sub-fields with dot-notation and write with merge.\nExample — location object (replaces 5 separate scalar fields with $if guards):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$store\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$local\": \"location.latitude\" }, { \"$local\": \"location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"from\": \"$event.detail\" }\n Write (partial edit): { \"$setLocal\": \"location\", \"merge\": { \"city\": \"$event.detail\" } }\n Write (clear): { \"$setLocal\": \"location\", \"value\": null }\n Condition (has location): { \"$local\": \"location\" }\nUse \"object\" whenever you would otherwise write 3+ related scalar fields each needing $if on their initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results in $local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available for $if conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into $local as read-only reactive arrays, accessible via { \"$local\": \"signalTypes\" }.\nQuery options are identical to $each's $query prop (entity, where, order, limit, include, dataset, subscribe).\nEach entry also exposes a read-only boolean { \"$local\": \"Loaded\" } — false until the first\nresult set (or error) arrives, then true for good. Gate a loading skeleton on it so the empty\nstate only ever asserts \"loaded and empty\", never \"not answered yet\":\n{ \"$if\": { \"condition\": { \"$local\": \"signalTypesLoaded\" }, \"then\": , \"else\": } }\n$queries and $localState share the same $local namespace — avoid duplicate names across both.\n$setLocal will warn and no-op on $queries entries (they are read-only).\nUse with $count + $gt for conditional visibility:\n{ \"condition\": { \"$gt\": [{ \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } }, 0] } }\nExample:\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [...]\n }\n ]\n}\n\nBoolean toggle pattern (show/hide comments, expand/collapse sections, etc.):\n{\n \"$localState\": { \"showComments\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$toggleLocal\": \"showComments\" }\n },\n \"children\": [{ \"type\": \"we-icon\", \"props\": { \"name\": \"chat-circle\" } }]\n },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"showComments\" },\n \"then\": { \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Comments visible\"] }] }\n }\n }\n ]\n}\n\nForm validation (extends $localState):\nDeclare validation rules on fields:\n\"$localState\": {\n \"email\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [\n { \"rule\": \"required\", \"message\": \"Email is required\" },\n { \"rule\": \"pattern\", \"value\": \"^[^@]+@[^@]+$\", \"message\": \"Invalid email\" }\n ]\n }\n}\n\nBuilt-in rules: required, minLength (value: N), maxLength (value: N), min (value: N), max (value: N), pattern (value: regex string), match (field: otherFieldName). All accept optional \"message\" override.\n\nRead tokens:\n{ \"$error\": \"fieldName\" } — first validation error message (only shown after field is touched), or \"\".\n{ \"$valid\": \"fieldName\" } — true if all rules pass (regardless of touched state).\n{ \"$touched\": \"fieldName\" } — true after the field has been blurred/touched.\n{ \"$formValid\": \"$scope\" } — true if ALL validated fields in the current $localState scope pass.\n\nAction tokens:\n{ \"$touch\": \"fieldName\" } — marks a single field as touched (in onBlur; opt-in, see below).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. Non-function entries (e.g. $if with false condition) are skipped.\nPrefer onSuccess over a bare $setLocal before the $action — the bare form closes the modal immediately (losing the loading spinner); onSuccess waits for the Promise to resolve.\n\nTypical form pattern — validate on submit:\n{\n \"$localState\": {\n \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] },\n \"submitting\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"loading\": { \"$local\": \"submitting\" },\n \"disabled\": { \"$local\": \"submitting\" },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\nThe submit button is disabled only while the request is in flight — NOT on { \"$not\": { \"$formValid\": \"$scope\" } }.\nThose two are mutually exclusive. A button disabled while the form is invalid can never be clicked in the one\nstate where { \"$touch\": \"$all\" } would reveal something, so the guard chain becomes dead code and blur is left\nas the user's only feedback path. Choose one shape:\n - Validate on submit (above). The button is always clickable and the errors appear on the click that was\n refused, which is where the user asked the question.\n - Hard gate: \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } }, and then drop { \"$touch\": \"$all\" } as dead\n and wire \"onBlur\": { \"$touch\": \"fieldName\" } per field — otherwise no error is ever reachable.\n\n\"onBlur\": { \"$touch\": \"fieldName\" } is an opt-in, not boilerplate. It earns its place on long multi-field forms\nwhere a field is worth judging the moment it is left — a \"match\" rule on a confirm-password field, say. On a\nshort form it fires an error at someone who merely clicked through a field they had not filled in yet.\n\nNo validation, just a precondition (sign-in, search, any single-field submit):\nWhen nothing about the value is locally judgeable — a password is only wrong once the backend says so — skip the\nvalidation machinery and gate on the value itself:\n{\n \"$localState\": { \"password\": { \"type\": \"string\", \"initial\": \"\" } },\n ...\n \"disabled\": { \"$not\": { \"$local\": \"password\" } }\n}\nA \"required\" rule here would exist only to drive \"disabled\", and its message is then one stray { \"$touch\": … }\naway from telling the user \"Password is required\" about a field they simply have not typed into yet.\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a context key. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nConditional rendering:\n{ \"type\": \"$if\", \"props\": { \"condition\": ..., \"then\": { ... }, \"else\": { ... } } }\nRenders \"then\" node if condition is truthy, else renders \"else\" node.\nSupports enterTransition / exitTransition for CSS animations when the node mounts/unmounts.\nTransitionConfig = TransitionEffect | TransitionEffect[]\nTransitionEffect = { type: 'fade'|'slide'|'scale'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string }\nfade controls opacity only; slide/scale control transform only. pulse is a persistent looping animation (not a one-shot transition) — starts once entered, stops on exit; direction/distance don't apply (default duration 1200ms, easing 'ease-in-out'). Compose fade/slide/scale together in an array; pulse is typically used alone.\nExample: enterTransition: [{ type: 'fade', duration: 300 }, { type: 'slide', direction: 'up', distance: '40px', duration: 400 }]\nExample (pulse): enterTransition: { type: 'pulse', duration: 1500 }\n\nViewport / mount animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"scrollReveal\"?: true | number, \"scrollLeave\"?: true | number, \"scrollPast\"?: string, \"enterTransition\"?: TransitionConfig, \"exitTransition\"?: TransitionConfig }, \"children\": [] }\nThe child is always mounted. fade/slide/scale are CSS transitions (opacity/transform); pulse is a real CSS @keyframes loop — use this for scroll-reveal effects.\nDo NOT use $animate when the child should be absent from the DOM. Use $if for conditional DOM presence.\nscrollReveal: true fires enterTransition when the element enters the viewport.\nscrollReveal: -100 fires 100px before the element would enter (negative = earlier reveal).\nscrollLeave fires exitTransition when the element leaves the viewport.\nscrollPast: \"element-id\" observes a sentinel element (by DOM id) instead of the $animate element itself.\n enterTransition fires when the sentinel leaves the viewport (user scrolled past it).\n exitTransition fires when the sentinel returns (user scrolled back up).\n Use this for sticky headers: place a zero-height sentinel div at the bottom of the non-sticky header section,\n then wrap the mini-profile in $animate with scrollPast pointing to that sentinel's id.\n scrollPast is mutually exclusive with scrollReveal/scrollLeave.\nWithout any scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (scroll-reveal):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollReveal\": -100,\n \"enterTransition\": [\n { \"type\": \"fade\", \"duration\": 600, \"easing\": \"ease-in-out\" },\n { \"type\": \"slide\", \"direction\": \"left\", \"distance\": \"200px\", \"duration\": 1000, \"easing\": \"ease-in-out\" }\n ]\n },\n \"children\": [{ \"type\": \"SomeCard\", \"children\": [] }]\n}\nExample (sticky header mini-profile):\nPlace a sentinel at the bottom of the header, reference it in the sticky nav:\n{ \"type\": \"div\", \"props\": { \"id\": \"header-sentinel\" }, \"styles\": { \"height\": \"0px\", \"pointerEvents\": \"none\" } }\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollPast\": \"header-sentinel\",\n \"enterTransition\": { \"type\": \"fade\", \"duration\": 250 },\n \"exitTransition\": { \"type\": \"fade\", \"duration\": 200 }\n },\n \"children\": [{ \"type\": \"Row\", \"props\": { \"ay\": \"center\", \"gap\": \"300\" }, \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"600\" }, \"children\": [\"$space.name\"] }\n ]}]\n}\n\nSingle model item (load one record, render children with it in context):\n{\n \"type\": \"$single\",\n \"props\": {\n \"item\": { \"$query\": { \"entity\": \"ModelName\", \"params\": { ... }, \"subscribe\": true } },\n \"as\": \"profile\" // context key for children — default: 'item'\n },\n \"children\": [{ \"type\": \"we-text\", \"children\": [\"$profile.username\"] }]\n}\nRenders nothing until a matching record is found. Like $each but for a single result.\nquery options (entity, params, include, dataset, subscribe) work identically to $query.\n\nRoute outlet:\n{ \"type\": \"$routes\" }\nIndicates where nested routes should render within a layout.\n\nModule slot outlet:\n{ \"type\": \"$slot\", \"props\": { \"anchor\": \"call-controls\" } }\nRenders whatever other feature modules have contributed to that anchor, in order. Only meaningful\ninside a module's own chrome: the module declares the anchor name in its `anchors` list and marks\nwhere contributions land with this. Resolves to nothing when no module has contributed — no empty\ncontainer, no gap. Templates have no use for it; chrome is the host's and the modules', not a\ntemplate's.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', dismissible: boolean = false\n- we-audio (LayoutVisualElement)\n Props: src: string = '', controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', selected: boolean = false, online: boolean = false, initials: string = '', icon: string = '', size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | '{css-length}' | undefined, clickable: boolean = false\n- we-badge (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-blockquote (DesignSystemElement)\n- we-button (DesignSystemElement)\n Props: variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' | 'bare' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', text?: string | undefined, href?: string | undefined, disabled: boolean = false, loading: boolean = false, gradient: boolean = false, square: boolean = false\n- we-checkbox (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-code (DesignSystemElement)\n Props: block: boolean = false\n- we-color-picker (DesignSystemElement)\n Props: value: string = '#000000', disabled: boolean = false, name: string = '', palette: array = [ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc', ]\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', placeholder: string = 'Select date', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-divider (LayoutElement)\n Props: orientation: 'horizontal' | 'vertical' = 'horizontal', variant: 'solid' | 'dashed' | 'dotted' = 'solid', color?: string | undefined, thickness?: string | undefined\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-file-upload (DesignSystemElement)\n Props: accept: string = '', multiple: boolean = false, disabled: boolean = false, name: string = ''\n- we-form-field (DesignSystemElement)\n Props: label: string = '', description: string = '', error: string = '', required: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-html (DesignSystemElement) — Renders a raw HTML string safely via DOMPurify sanitization.\n\nUse this instead of `we-text` when content is stored as HTML (e.g. rich-text\neditor output such as Flux messages). The `content` prop accepts any HTML\nfragment; it is sanitized before rendering so XSS payloads are stripped.\n Props: content: string = ''\n- we-icon (LayoutElement)\n Props: name: string = '', color: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '{css-length}' = '', weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' = 'regular', gradient: string = ''\n- we-icon-picker (DesignSystemElement)\n Props: value: string = '', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', placeholder: string = 'Pick icon'\n- we-iframe (LayoutVisualElement)\n Props: src: string = '', title: string = 'Embedded content', allow: string = '', sandbox?: string | undefined\n- we-image (LayoutVisualElement)\n Props: src: string | File = '', alt: string = '', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', loading: 'eager' | 'lazy' = 'eager', gradient: string = '', objectPosition: string = ''\n- we-input (DesignSystemElement)\n Props: value: string = '', max: string = '', min: string = '', maxlength: unknown = Infinity, minlength: number = 0, pattern: string = '', name: string = '', step: string = '', placeholder: string = '', autocomplete: string = '', autofocus: boolean = false, disabled: boolean = false, required: boolean = false, readonly: boolean = false, type: string = 'text', revealable: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-number (DesignSystemElement) — Displays a number, optionally abbreviated (1 200 → 1.2K, 1 500 000 → 1.5M).\n Props: value: number = 0, shorten: boolean = false, precision: number = 1, locale: string = 'en', formattedValue: string\n- we-number-input (DesignSystemElement)\n Props: value: number = 0, min: number = -Infinity, max: unknown = Infinity, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-pagination (DesignSystemElement)\n Props: page: number = 1, total: number = 1, siblings: number = 1, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-popover (LayoutElement) — Low-level floating panel anchored to a trigger element.\nUse DropdownMenu component for dropdown menus.\n Props: open: boolean = false, placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom', popoverElement: HTMLElement, triggerElement: HTMLElement\n- we-progress-bar (DesignSystemElement)\n Props: value: number = 0, max: number = 100, variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-radio (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-resize-handle (LayoutElement) — A drag target that reports how far it has moved, and nothing else.\n\n## Why it reports a delta rather than owning a size\n\nThe obvious design is a handle that resizes its neighbour. It is the wrong one, because \"what does\nthis drag mean\" is never the handle's business: the editor's panel rails grow *leftwards* from a\nwidth that starts at zero when the panel is closed, clamp at a minimum, and close the panel again\nbelow a threshold — while a docked call panel grows from whichever edge it is attached to. A\nhandle that owned the size could serve one of those and not the other.\n\nSo it emits `resizestart`, `resize` and `resizeend`, each carrying `delta`: pixels moved along its\naxis **since the drag began**, signed in screen direction (right and down positive). The consumer\ncaptures its own starting size and applies whatever sign and limits it has. Delta-from-start\nrather than incremental, because every consumer would otherwise have to accumulate, and one of\nthem would get it wrong after a dropped event.\n\n## Why a primitive rather than a hook\n\nThere were two implementations of this before it existed and they diverged in ways nobody chose:\nthe editor's is mouse-only, so it does not work on a touchscreen at all, and its rail is a plain\ndiv — not focusable, so there is no way to resize a panel from the keyboard. Pointer events and a\n`separator` role fix both once, for every consumer, in the layer where imperative DOM work belongs.\n Props: orientation: 'vertical' | 'horizontal' = 'vertical', align: 'start' | 'center' | 'end' = 'center', step: number = 16, dragging: boolean = false\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = ''\n- we-select (DesignSystemElement) — Pick a single value from a list of options. Custom-rendered dropdown.\nUse for form fields, settings, filters. Set searchable=true for type-to-filter.\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-skeleton (DesignSystemElement)\n Props: width: string = '100%', height: string = '20px', animation: 'pulse' | 'wave' = 'pulse'\n- we-slider (DesignSystemElement)\n Props: value: number = 0, min: number = 0, max: number = 100, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — Drag-to-reorder container primitive.\n\nUsage: wrap a list of elements that each have a `data-we-id` attribute.\nFires a `reorder` CustomEvent on drop with the new ordered array\nof IDs — the event name is unprefixed, like every other primitive's\n(`change`, `select`, `toggle`). In Solid, listen with `on:reorder`; a\nlistener for `we-reorder` never fires and the drop silently does nothing.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = ''\n- we-spinner (LayoutElement)\n Props: size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) = 'md', color: string = ''\n- we-switch (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', labelOff: string = '', labelOn: string = ''\n- we-tab (DesignSystemElement)\n Props: key: string = '', selected: boolean = false, label?: string | undefined, selectedProps?: Partial | undefined\n- we-tabs (DesignSystemElement)\n Props: selectedKey: string = ''\n- we-tag (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', dismissible: boolean = false\n- we-text (DesignSystemElement)\n Props: text?: string | undefined, variant: '' | 'body' | 'label' | 'footnote' | 'subheading' | 'ingress' | 'heading-sm' | 'heading-md' | 'heading-lg' | 'heading-xl' = '', tag: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small' | 'b' | 'i' | 'label' | 'div' = 'span', inline: boolean = false, uppercase: boolean = false, italic: boolean = false, truncate: boolean = false, gradient: string = '', loading: boolean = false, loadingWidth: string = '100%'\n- we-textarea (DesignSystemElement)\n Props: value: string = '', name: string = '', placeholder: string = '', rows: number = 3, maxlength: unknown = Infinity, minlength: number = 0, disabled: boolean = false, required: boolean = false, readonly: boolean = false, resize: 'none' | 'vertical' | 'horizontal' | 'both' = 'vertical', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-timestamp (DesignSystemElement) — Displays a formatted or relative timestamp that self-updates each minute\nwhen `relative` is enabled.\n Props: value: string = '', relative: boolean = false, locale: string = 'en', dateStyle: Intl.DateTimeFormatOptions['dateStyle'] | null = null, timeStyle: Intl.DateTimeFormatOptions['timeStyle'] | null = null, weekday: Intl.DateTimeFormatOptions['weekday'] | null = null, year: Intl.DateTimeFormatOptions['year'] | null = null, month: Intl.DateTimeFormatOptions['month'] | null = null, day: Intl.DateTimeFormatOptions['day'] | null = null, hour: Intl.DateTimeFormatOptions['hour'] | null = null, minute: Intl.DateTimeFormatOptions['minute'] | null = null, second: Intl.DateTimeFormatOptions['second'] | null = null, timeZone: string | null = null, hourCycle: Intl.DateTimeFormatOptions['hourCycle'] | null = null, formattedTime: string\n- we-tooltip (LayoutElement)\n Props: open: boolean = false, title: string = '', placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'top', tooltipEl: HTMLElement, triggerEl: HTMLElement, arrowEl: HTMLElement\n- we-video (LayoutVisualElement)\n Props: src: string = '', poster?: string | undefined, controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', autoplay: boolean = false, loop: boolean = false, muted: boolean = false, playsinline: boolean = false, stream?: MediaStream | null | undefined\n\n@we/components:\n- AudioDisplay\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | undefined, duration: number | undefined, albumArt: string | undefined\n- AudioInput\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | FileData | undefined, duration: number | undefined, albumArt: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- BlockComposer (DesignSystemElement)\n Props: editorState?: SerializedBlockNode, perspective?: PerspectiveProxy | null, onSave?: ((json: SerializedBlockNode) => void), onReady?: ((api: { save: () => void; }) => void)\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: SerializedBlockNode, perspective?: PerspectiveProxy | null, rootClass?: string\n- BlockToolbar\n Props: placement?: BlockToolbarPlacement, children: JSX.Element, stopPropagation?: boolean\n- CalloutDisplay\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined\n- CalloutInput\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CodeDisplay\n Props: code: string | undefined, language: string | undefined, title: string | undefined\n- CodeInput\n Props: code: string | undefined, language: string | undefined, title: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CollectionDisplay\n Props: layout?: string, columnCount?: number, gap?: string, childEditorState?: SerializedBlockNode\n- CollectionInput\n Props: nodeKey: string, layout?: string, columnCount?: number, gap?: string, childEditorState?: SerializedBlockNode, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- DividerDisplay\n Props: style: \"solid\" | \"dashed\" | \"dotted\" | undefined\n- DividerInput\n Props: style: DividerVariant | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EmbedDisplay\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined\n- EmbedInput\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EventDisplay\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined\n- EventInput\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- FileDisplay\n Props: title: string | undefined, name: string | undefined, url: string | undefined, mimeType: string | undefined, size: number | undefined\n- FileInput\n Props: title: string | undefined, name: string | undefined, url: string | FileData | undefined, mimeType: string | undefined, size: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- ImageDisplay\n Props: src: string | undefined, altText: string | undefined, width: number | undefined, height: number | undefined\n- ImageInput\n Props: src: string | FileData | undefined, altText: string | undefined, width: number | undefined, height: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LinkDisplay\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined\n- LinkInput\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LocationDisplay\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined\n- LocationInput\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TagDisplay\n Props: name: string | undefined, color: string | undefined\n- TagInput\n Props: name: string | undefined, color: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TaskDisplay\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined\n- TaskInput\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- VideoDisplay\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined\n- VideoInput\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- AudioVisualiser\n Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string\n- AvatarStack\n Props: avatars: AvatarInfo[], max?: number, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxs\" | \"xxl\", overlap?: number, ring?: string, styles?: Record\n- Calendar\n Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record\n- Card (DesignSystemElement)\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record\n- CollapsedContent\n Props: collapsed: boolean, onExpandClick?: (() => void), showToggle?: boolean, icon?: string, maxHeight?: string, fadeColor?: string, children?: JSX.Element, class?: string, styles?: Record\n- Column (DesignSystemElement)\n- Combobox (DesignSystemElement)\n Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.\n Props: styles?: Record, class?: string, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"cover\" | \"contain\" | \"none\" | \"fill\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\n- ImageCrop\n Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void)\n- ImageLightbox\n Props: srcs: string[], initialIndex: number, onClose: () => void\n- RerenderLog\n Props: location: string\n- Row (DesignSystemElement)\n- Search (DesignSystemElement)\n Props: placeholder?: string, value?: string, onSearch?: ((value: string) => void), debounce?: number\n- Select (DesignSystemElement)\n Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- SignalControl\n Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record\n- ToastContainer\n Props: position?: \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\" | \"top-center\" | \"bottom-center\", styles?: Record\n\n@we/widgets:\n- CollapsibleSidebar\n Props: header?: JSX.Element, footer?: JSX.Element, items: CollapsibleSidebarItem[], footerItems?: CollapsibleSidebarItem[], side?: \"left\" | \"right\", position?: \"static\" | \"absolute\" | \"fixed\", zIndex?: number, collapsedWidth?: string, expandedWidth?: string, defaultExpanded?: boolean, expandOnHover?: boolean, transitionDuration?: number, bg?: string, border?: string, padding?: string, gap?: string, centerItems?: boolean, itemColor?: string, itemColorHover?: string, itemColorActive?: string, itemBg?: string, itemBgHover?: string, itemBgActive?: string, itemPadding?: string, itemGap?: string, badgeBg?: string, badgeColor?: string, iconSize?: IconSize, onItemClick?: ((item: CollapsibleSidebarItem) => void), onExpandedChange?: ((expanded: boolean) => void)\n- GraphView — A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and\nfree-positioned boards, all from the same engine.\n\nThe shape of a graph is set by four independent choices: where it starts (`seeds`), how much of it\nopens (`expansion`), how it is arranged (`layout`), and how it looks (`nodeStyle` / `edgeStyle`).\n\nCommon recipes:\n- **Knowledge map** — `seeds: { source: 'query', options: { entity: 'Belief' } }` with\n`expansion: { defaultDepth: 1 }` and `layout: { type: 'force' }`.\n- **Schema map** — `seeds: { source: 'schema' }`, which draws the dataset's own entity types and\nthe relations between them. Picks up model types added later with no template change.\n- **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content.\n- **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all.\n 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\n\n---\n\n## Component Plugin Registries\n\nSome components resolve named plugins from their props. These are the names each accepts —\na name not listed here does not exist, and the component will warn rather than render.\n\n### GraphView\n\nNames resolvable inside GraphView props: seed sources (seeds.source), expanders (expansion.expanders), layouts (layout.type) and behaviours (behaviours[]).\n\n**seed**\n\n- `query` — Loads instances of one entity type as nodes; can draw named relations immediately.\n - entity: string — Entity type to load (required).\n - where: object — Filter, same operators as $query.\n - order: object — e.g. { createdAt: \"desc\" }.\n - limit: number — Defaults to 100.\n - relations: string[] — Relations to hydrate and draw as edges up front.\n - Example: `{ \"source\": \"query\", \"options\": { \"entity\": \"Post\", \"limit\": 50, \"relations\": [\"author\"] } }`\n- `schema` — Maps the dataset's own entity types and the relations between them — one node per type. Picks up model types installed after the template was written, so it suits spaces whose vocabulary is open-ended.\n - entities: string[] — Restrict to these types; omit for all of them.\n - Example: `{ \"source\": \"schema\" }`\n- `dataset` — Seeds a single node for the current space — the starting point for exploring outward.\n - label: string\n - Example: `{ \"source\": \"dataset\", \"options\": { \"label\": \"This space\" } }`\n\n**expander**\n\n- `entity` — Follows an entity's typed relations, forwards and backwards, from the dataset's schema. The default for knowledge maps.\n - relations: string[] — Only follow these.\n - exclude: string[] — Never follow these.\n - Example: `\"expansion\": { \"expanders\": [\"entity\"], \"direction\": \"both\", \"defaultDepth\": 1 }`\n- `collection` — Opens a container into its children through an untyped to-many relation — the drill-down the schema cannot describe. Recurses naturally into nested collections.\n - parents: string[] — Container types. Defaults to CollectionBlock.\n - via: string — Relation holding the children. Defaults to \"children\".\n - children: string[] — Child entity types to look for.\n - Example: `\"expansion\": { \"expanders\": [\"collection\"], \"defaultDepth\": 2, \"direction\": \"out\" }`\n- `schema` — Opens an entity-type node from the schema seed into instances of that type — the step from \"what kinds of thing are here\" to \"here they are\". Paired with the schema seed it makes one map out of two.\n - limit: number — Instances loaded per type. Default 25.\n - Example: `\"seeds\": { \"source\": \"schema\" }, \"expansion\": { \"expanders\": [\"schema\", \"entity\"] }`\n- `property` — Opens an instance out into its own scalar fields, and optionally into shared value nodes so instances converge on common values. The resolution level below an entity.\n - properties: string[] — Only show these fields.\n - valueNodes: boolean — Promote values to shared nodes. Defaults to true.\n - Example: `\"expansion\": { \"expanders\": [\"property\"] }`\n\n**layout**\n\n- `force` — Force-directed, with warm start so newly expanded nodes settle around what is already placed rather than restarting the whole map. The default.\n - distance: number — Preferred edge length. Default 90.\n - charge: number — Repulsion; more negative spreads further. Default -220.\n - collide: number — Minimum spacing. Default 28.\n - Example: `{ \"type\": \"force\", \"options\": { \"distance\": 140 } }`\n- `tree` — Layered hierarchy from the graph roots. The right choice for containment and org charts.\n - direction: \"down\" | \"right\"\n - levelGap: number\n - siblingGap: number\n - Example: `{ \"type\": \"tree\", \"options\": { \"direction\": \"right\", \"levelGap\": 200 } }`\n- `radial` — Concentric rings by hop distance from the roots — reads as distance from a centre.\n - ringGap: number\n - Example: `{ \"type\": \"radial\" }`\n- `grid` — Uniform grid, optionally ordered by a node data field. Honest default when edges say little.\n - columns: number\n - sortBy: string — Node data field to order by.\n - Example: `{ \"type\": \"grid\", \"options\": { \"columns\": 6, \"sortBy\": \"name\" } }`\n- `manual` — Positions come from the nodes themselves — a board, where position is the data being edited rather than something derived. Pair with drag-node and persist via onNodeDragEnd.\n - xField: string — Node data field holding x. Default \"x\".\n - yField: string — Node data field holding y. Default \"y\".\n - Example: `{ \"type\": \"manual\" }`\n\n**style**\n\n- `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.\n - Example: `\"edgeStyle\": [{ \"style\": { \"curve\": \"smooth\" } }]`\n- `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.\n - Example: `\"edgeStyle\": [{ \"style\": { \"arrow\": \"none\" } }]`\n- `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.\n - Example: `\"edgeStyle\": [{ \"style\": { \"scaleWithZoom\": false } }]`\n- `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.\n - Example: `\"nodeStyle\": [{ \"style\": { \"scaleLabelWithZoom\": false } }]`\n- `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.\n - Example: `\"nodeStyle\": [{ \"style\": { \"labelMinZoom\": 0.6 } }]`\n\n**metric**\n\n- `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.\n - range: [number, number] — Output range, e.g. [8, 30].\n - Example: `\"nodeStyle\": [{ \"style\": { \"size\": { \"metric\": \"degree\", \"range\": [10, 34] } } }]`\n- `community` — Groups the visible graph by label propagation. Pair with scale: \"categorical\" to colour each cluster differently — this is what makes a cluster map.\n - rounds: number — Propagation rounds. Default 8.\n - Example: `\"nodeStyle\": [{ \"style\": { \"color\": { \"metric\": \"community\", \"scale\": \"categorical\" } } }]`\n\n**control**\n\n- `zoom-in` — Zooms toward the centre of the view. Shown by default.\n - Example: `\"controls\": [\"zoom-in\", \"zoom-out\", \"fit\"]`\n- `zoom-out` — Zooms out from the centre. Shown by default.\n- `fit` — Frames everything currently on the graph. Deliberately not a re-layout — it moves the camera, never the nodes.\n- `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.\n - Example: `\"controls\": [\"zoom-in\", \"zoom-out\", \"fit\", \"pin\"]`\n- `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.\n - Example: `\"controls\": [\"zoom-in\", \"zoom-out\", \"fit\", \"lock\"]`\n- `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.\n - Example: `\"controls\": [\"zoom-in\", \"zoom-out\", \"fit\", \"relayout\"]`\n\n**behaviour**\n\n- `pan-zoom` — Drag the background to pan, wheel to zoom about the pointer. List it last — it is the fallback.\n - Example: `\"behaviours\": [\"pan-zoom\", \"select\", \"expand-on-double-click\"]`\n- `select` — Click to select, shift-click to extend, background to clear. Emits onNodeClick.\n- `drag-node` — Drag a node to move it. Releases on drop by default so the layout stays in charge; pass { pin: true } on a board.\n - pin: boolean — Leave the node pinned where it was dropped.\n - Example: `{ \"type\": \"drag-node\", \"options\": { \"pin\": true } }`\n- `expand-on-double-click` — Double-click a node to expand it. The usual gesture on a map you also want to select on.\n - direction: \"in\" | \"out\" | \"both\"\n- `expand-on-click` — Single click expands — for maps meant purely for exploring, where selection is not needed.\n - direction: \"in\" | \"out\" | \"both\"\n\n---\n\n## Design System Props\n\nMost @we/primitives inherit **all** layers below. Props use design token values — not raw CSS.\n\n### Token Value Reference\n\n| Token Type | Valid Values |\n|---|---|\n| SpaceValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length e.g. \"16px\") |\n| ColorValue | \"{hue}-{shade}\" where hue = neutral, primary, success, warning, danger and shade = 0, 25, 50, 75, 100, 200–900, 1000. Also \"white\", \"black\". (or CSS color) |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length) |\n| ShadowValue | \"sm\", \"md\", \"lg\", \"xl\" |\n| FontSizeValue | \"base\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length) |\n| FontFamilyValue | \"base\" (or CSS font-family) |\n| LineHeightValue | \"none\", \"tight\", \"snug\", \"normal\", \"relaxed\", \"loose\" (or CSS value) |\n| LetterSpacingValue | \"tighter\", \"tight\", \"normal\", \"wide\", \"wider\", \"widest\" (or CSS value) |\n| FontWeightValue | Named tokens: \"regular\" (400), \"medium\" (500), \"semibold\" (600), \"bold\" (700). Numeric: \"100\"–\"900\". CSS pass-through: \"light\", \"normal\", \"bolder\". |\n\n**Layout-only primitives** — these accept only Layout props (not Visual, Flex, Typography, or State):\nwe-divider, we-icon, we-menu-group, we-popover, we-spinner, we-tooltip\n\n### Layout\n\n| Prop | Type | Description |\n|------|------|-------------|\n| width | string | Element width |\n| height | string | Element height |\n| minWidth | string | Minimum width |\n| minHeight | string | Minimum height |\n| maxWidth | string | Maximum width |\n| maxHeight | string | Maximum height |\n| position | \"relative\" \\| \"absolute\" \\| \"fixed\" \\| \"sticky\" | CSS position |\n| top | SpaceValue | Top offset — space token or CSS length |\n| right | SpaceValue | Right offset — space token or CSS length |\n| bottom | SpaceValue | Bottom offset — space token or CSS length |\n| left | SpaceValue | Left offset — space token or CSS length |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" | Overflow behavior |\n| m | SpaceValue | Margin (all sides) |\n| mx | SpaceValue | Margin left + right |\n| my | SpaceValue | Margin top + bottom |\n| mt | SpaceValue | Margin top |\n| mr | SpaceValue | Margin right |\n| mb | SpaceValue | Margin bottom |\n| ml | SpaceValue | Margin left |\n\n### Visual\n\n| Prop | Type | Description |\n|------|------|-------------|\n| bg | ColorValue | Background color (token) |\n| bgImage | string | Background image — a URL, or a CSS gradient (linear-, radial- or conic-gradient, including several comma-separated for a mesh). Sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat. Composes with bg, which paints beneath it |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\n| r | RadiusValue | Border radius (all corners) |\n| rt | RadiusValue | Border radius top |\n| rb | RadiusValue | Border radius bottom |\n| rl | RadiusValue | Border radius left |\n| rr | RadiusValue | Border radius right |\n| rtl | RadiusValue | Border radius top-left |\n| rtr | RadiusValue | Border radius top-right |\n| rbr | RadiusValue | Border radius bottom-right |\n| rbl | RadiusValue | Border radius bottom-left |\n\n### Flex (Container)\n\n| Prop | Type | Description |\n|------|------|-------------|\n| direction | \"row\" \\| \"row-reverse\" \\| \"column\" \\| \"column-reverse\" | Flex direction |\n| ax | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Main-axis alignment |\n| ay | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Cross-axis alignment |\n| wrap | boolean | Enable flex wrap |\n| gap | SpaceValue | Gap between children (token) |\n| p | SpaceValue | Padding (all sides) |\n| px | SpaceValue | Padding left + right |\n| py | SpaceValue | Padding top + bottom |\n| pt | SpaceValue | Padding top |\n| pr | SpaceValue | Padding right |\n| pb | SpaceValue | Padding bottom |\n| pl | SpaceValue | Padding left |\n\n### Typography\n\n| Prop | Type | Description |\n|------|------|-------------|\n| textAlign | \"left\" \\| \"center\" \\| \"right\" \\| \"justify\" | Text alignment |\n| fontFamily | \"base\" \\| {css-font-family} | Font family token |\n| fontWeight | \"regular\" \\| \"medium\" \\| \"semibold\" \\| \"bold\" (named tokens) or \"100\"–\"900\" (numeric) or \"light\" \\| \"normal\" \\| \"bolder\" (CSS pass-through) | Font weight |\n| fontSize | \"base\" \\| \"100\"–\"1000\" \\| {css-length} | Font size token |\n| lineHeight | \"none\" \\| \"tight\" \\| \"snug\" \\| \"normal\" \\| \"relaxed\" \\| \"loose\" | Line height token |\n| letterSpacing | \"tighter\" \\| \"tight\" \\| \"normal\" \\| \"wide\" \\| \"wider\" \\| \"widest\" | Letter spacing token |\n| textDecoration | \"underline\" \\| \"line-through\" \\| \"overline\" \\| \"none\" | Text decoration |\n| textTransform | \"uppercase\" \\| \"lowercase\" \\| \"capitalize\" \\| \"none\" | Text transform |\n\n**Typography defaults:** fontSize and fontWeight have **no built-in defaults** — omitting them inherits from parent elements (browser default is ~16px / normal weight). Do not set fontSize or fontWeight unless you need a non-default value. For example, `fontSize: '300'` (16px) and `fontWeight: '500'` (normal) are the inherited defaults — omit them.\n\n`we-text` variants (set via the `variant` prop) bundle typography presets. Always pair with a semantic `tag` prop for correct HTML structure:\nbody (300, tag: p/span), label (200 + medium, tag: span), footnote (100, tag: span), subheading (400 + medium, tag: h5/p), ingress (400 + lineHeight 1.6, tag: p), heading-sm (500 + bold, tag: h4), heading-md (600 + bold, tag: h3), heading-lg (700 + bold, tag: h2), heading-xl (800 + bold, tag: h1).\nVariants set size and weight only — color is always inherited or set explicitly. For muted footnote text add `color=\"neutral-400\"` explicitly.\n\n### State\n\n| Prop | Type | Description |\n|------|------|-------------|\n| hoverProps | Partial\\ | Styles on :hover |\n| activeProps | Partial\\ | Styles on :active |\n| focusProps | Partial\\ | Styles on keyboard focus (:focus-visible) — deliberately not applied on mouse click. `we-button` and `we-input` already carry a default focus ring; only set this to override it |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. Applied last, so it genuinely overrides a DS prop setting the same property. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'multiplier', 'subtractor', 'saturation', 'neutralSaturation'\n\ncolor.hues: 'neutral', 'primary', 'success', 'warning', 'danger'\n\ncolor.lightness: '0', '25', '50', '75', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\ncomponent.scrollbar: 'width', 'backgroundImage', 'background', 'cornerBackground', 'thumbBoxShadow', 'thumbBorderRadius', 'thumbBackground'\n\ncomponentHeight: 'xs', 'sm', 'md', 'lg', 'xl'\n\nfont.family: 'base', 'mozilla', 'boldonse'\n\nfont.letterSpacing: 'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'\n\nfont.lineHeight: 'none', 'tight', 'snug', 'normal', 'relaxed', 'loose'\n\nfont.size: '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000', 'base'\n\nfont.weight: '100', '200', '300', '400', '500', '600', '700', '800', '900', 'regular', 'medium', 'semibold', 'bold'\n\nlayout: 'xs', 'sm', 'md', 'lg'\n\nradius: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'pill', 'full'\n\nshadow: 'sm', 'md', 'lg', 'xl'\n\nsize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nspace: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\nzIndex: 'dropdown', 'sticky', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nAgentSettings extends Ad4mModel:\n Fields:\n - currentTemplateId: string = 'default' [we://current_template]\n - defaultTemplateId: string = 'default' [we://default_template]\n - currentThemeId: string = 'default' [we://current_theme]\n - defaultThemeId: string = 'default' [we://default_theme]\n - claudeApiKey: string [we://claude_api_key]\n - datasetOrder: string [we://dataset_order]\n - globalSpaceJoined: boolean = false [we://global_space_joined]\n - globalSpaceUrl: string [we://global_space_url]\n - useSpaceTemplate: boolean = true [we://use_space_template]\n - useTemplateTheme: boolean = true [we://use_template_theme]\n - themeScope: string [we://theme_scope]\n - installedModules: string [we://installed_modules]\n Relations:\n - installedTemplates: HasMany → Template [we://installed_template]\n - installedThemes: HasMany → Theme [we://installed_theme]\n - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference]\n\nAudioBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - artist: string [we://artist]\n - audioUrl: string (required) [we://audio_url]\n - duration: number [we://duration]\n - albumArt: string [we://album_art]\n - version: number [we://version]\n\nCalloutBlock extends WeNode:\n Fields:\n - text: string [we://text]\n - variant: string = info [we://variant]\n - icon: string [we://icon]\n - version: number [we://version]\n\nChatMessage extends WeNode:\n Fields:\n - role: string [we://role]\n - content: string [we://content]\n\nChatSession extends WeNode:\n Fields:\n - name: string [we://name]\n - templateId: string [we://template_id]\n Relations:\n - messages: HasMany → ChatMessage [we://chat_message]\n\nCodeBlock extends WeNode:\n Fields:\n - code: string (required) [we://code]\n - language: string [we://language]\n - title: string [we://title]\n - version: number [we://version]\n\nCollectionBlock extends WeNode:\n Fields:\n - editorState: string = null [we://editor_state]\n - type: string [we://type]\n - kind: string [we://kind]\n - mode: string [we://mode]\n - title: string [we://title]\n - description: string [we://description]\n - version: number [we://version]\n - textContent: string [we://text_content]\n Relations:\n - children: HasMany [we://children]\n\nDividerBlock extends WeNode:\n Fields:\n - style: string = solid [we://style]\n - version: number [we://version]\n\nEmbedBlock extends WeNode:\n Fields:\n - url: string [we://url]\n - target: string [we://target]\n - targetType: string [we://target_type]\n - displayMode: string = card [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - startDate: string (required) [we://start_date]\n - endDate: string [we://end_date]\n - location: string [we://location]\n - allDay: boolean = false [we://all_day]\n - version: number [we://version]\n\nFileBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - name: string (required) [we://name]\n - url: string (required) [we://url]\n - mimeType: string [we://mime_type]\n - size: number [we://size]\n - version: number [we://version]\n\nImageBlock extends WeNode:\n Fields:\n - src: string (required) [we://src]\n - altText: string [we://altText]\n - width: number [we://width]\n - height: number [we://height]\n - version: number [we://version]\n\nLinkBlock extends WeNode:\n Fields:\n - url: string (required) [we://url]\n - title: string [we://title]\n - description: string [we://description]\n - thumbnail: string [we://thumbnail]\n - version: number [we://version]\n\nLocationBlock extends WeNode:\n Fields:\n - name: string [we://name]\n - latitude: number (required) [we://latitude]\n - longitude: number (required) [we://longitude]\n - address: string [we://address]\n - city: string [we://city]\n - countryCode: string [we://country_code]\n - country: string [we://country]\n - version: number [we://version]\n\nMutedAgent extends WeNode:\n Fields:\n - did: string [we://did]\n - description: string [we://description]\n\nReadMarker extends WeNode:\n Fields:\n - nodeId: string [we://node_id]\n - spaceUuid: string [we://space_uuid]\n - lastReadAt: string [we://last_read_at]\n\nSignal extends Ad4mModel:\n Fields:\n - signalTypeId: string [we://signal_type_id]\n - value: number [we://value]\n\nSignalType extends WeNode:\n Fields:\n - name: string [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - iconSecondary: string [we://icon_secondary]\n - step: number = 1 [we://step]\n - rangeMin: number [we://range_min]\n - rangeMax: number = 1 [we://range_max]\n - mode: SignalMode = 'toggle' [we://mode]\n - aggregate: SignalAggregate = 'count' [we://aggregate]\n - semantic: SignalSemantic = 'custom' [we://semantic]\n - allowChange: boolean = true [we://allow_change]\n - valueType: string = 'numeric' [we://signal_value_type]\n - schemaVersion: number = 1 [we://schema_version]\n\nSpace extends WeNode:\n Fields:\n - uuid: string [we://uuid]\n - url: string [we://url]\n - name: string (required) [we://name]\n - description: string (required) [we://description]\n - discovery: string = 'hidden' [we://discovery]\n - avatar: string [we://image]\n - coverImage: string [we://thumbnail]\n - defaultTemplateId: string [we://default_template_id]\n - defaultThemeId: string [we://default_theme_id]\n - enabledModules: string [we://enabled_modules]\n Relations:\n - location: HasOne [we://location]\n\nSpacePreference extends WeNode:\n Fields:\n - spaceUuid: string [we://space_uuid]\n - mutedModules: string [we://muted_modules]\n - templateId: string [we://template_id]\n - themeId: string [we://theme_id]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - type: string [we://type]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - textFormat: number [we://textFormat]\n - textStyle: string [we://textStyle]\n - listType: string [we://listType]\n - start: number [we://start]\n - tag: string [we://tag]\n - text: string [we://text]\n - version: number [we://version]\n\nTheme extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - slug: string [we://slug]\n - version: number = 1 [we://version]\n - css: string = null [we://stylesheet]\n - overrides: string = null [we://token_overrides]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nVideoBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - url: string (required) [we://url]\n - duration: number [we://duration]\n - thumbnail: string [we://thumbnail]\n - provider: string [we://provider]\n - version: number [we://version]\n\nWeNode extends Ad4mModel:\n Relations:\n - comments: HasMany [we://comment]\n - signals: HasMany → Signal [we://signal]\n - participants: HasMany [we://participants]\n - calls: HasMany [we://call]\n - mentions: HasMany [we://mention]\n\n---\n\n## Stores\n\nStores provide state (readable values) and actions (methods) for dynamic logic in schemas.\nAccess state with $store and call actions with $action.\nFor ephemeral/form state, use $localState/$local/$setLocal instead of stores (see Dynamic Logic).\n\nAccountStore:\n- State:\n - canManageAccounts: boolean — the host can manage local accounts (false on web). Gate every account control on this\n - accounts: Account[] — local accounts (id, name, avatar, active, hasAgent, sharedWithLauncher). id is the data directory; hasAgent is false for one scaffolded but never set up\n - activeAccount: Account | undefined — the account this app instance is running against. Correct at first paint: the list is seeded from a synchronous cache\n - hasOtherAccounts: boolean — true when there is somewhere else to switch to\n - accountsLoaded: boolean — the host has answered. Without it an empty list reads as a first run and flashes a welcome at a returning user\n - isFirstRun: boolean — nothing has ever been set up on this machine: the host has answered and no account holds an identity yet\n - busy: boolean — a mutation is in flight; a successful one ends in a relaunch\n - switchingTo: Account | null — the account being switched to, from the click until the process goes away\n - creating: boolean — true from the moment a create is requested until the process goes away\n - error: string — the last account error, for display\n - pendingRemoval: Account | null — the account a removal was requested for, awaiting confirmation\n- Actions:\n - refresh(): re-reads the account list from the host\n - createAccount(): creates an account under a provisional name and switches into it — the setup screen names it. Does not return on success\n - syncDisplay({ name?, avatar? }): mirrors the profile onto the running account, so the locked sign-in screen has a name and picture. Never throws\n - switchAccount(id: string): switches to another account. Does not return on success\n - removeAccount(id: string): deletes an account and its data. Refuses the active one\n - requestRemoval(id: string): opens the removal confirmation for that account\n - cancelRemoval(): closes the removal confirmation without deleting\n - confirmRemoval(): deletes the account awaiting confirmation\n - clearError(): clears the error slot\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: unknown\n - activeAppId: string | null — id of the currently active app, or null if none\n- Actions:\n - activateApp(id: string): activates an app and switches to its view\n - deactivateApp(): deactivates the current app and returns to the template view\n - provideInstalledModules(): unknown\n\nDatasetStore:\n- State:\n - datasets: array of dataset handles (all joined datasets; AD4M perspectives in this backend)\n - orderedDatasets: datasets sorted by user-defined sidebar order, system datasets excluded\n - currentDataset: dataset handle | null (the dataset currently being viewed)\n - currentDatasetUri: unknown\n - currentDatasetCid: string | undefined — the neighbourhood CID of the current dataset (prefix stripped)\n - currentDatasetModels: ModelManifestEntry[] (non-WE SHACL models from the current dataset; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current dataset is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign dataset, e.g. one synced in from Flux)\n - joinedSpaceCids: string[] — CIDs of every joined shared dataset\n - datasetsLoaded: boolean — the backend has answered with the dataset list. An empty list is otherwise indistinguishable from \"not fetched yet\", so anything asking \"have I joined this?\" reads the boot frame as \"no\". The same reason accountStore.accountsLoaded exists\n - systemDatasetUuids: string[] — uuids of the we-root/we-test system datasets\n - rootDataset: dataset handle | null — the agent's personal root dataset (we-root models live here)\n - testDataset: unknown\n - globalDataset: dataset handle | null — the seed-configured global discovery space, once joined\n - marketplaceDataset: dataset handle | null — the seed-configured marketplace, once joined\n - agentSettings: unknown\n - globalSpaceConfigured: boolean — the seed declares a global space\n - globalSpaceId: string | null — the dataset id of the seed-configured global discovery space, or null when it is not configured or not joined. Compare a route segment against it to tell \"the user is in the global space\" from \"the user is in a space of their own\"\n - marketplaceConfigured: boolean — the seed declares a marketplace\n - marketplaceId: unknown\n - marketplaceJoined: boolean — the marketplace dataset is joined locally\n - getDatasetOrder: unknown\n- Actions:\n - switchDataset(uuid: string): switches to a dataset by UUID, registers its SHACL models as dynamic model classes, and populates currentDatasetModels\n - reorderDatasets(newOrder: string[]): reorders the sidebar items by UUID array\n - removeDataset(): unknown\n - updateAgentSettings(updates: Partial): merges and persists root-dataset agent settings\n - clearCurrentDataset(): unknown\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a space that accumulated duplicate SDNA installs — removes the redundant duplicate link copies. Defaults to the current dataset. Returns a display-ready summary string naming how many links were removed and the DIDs that authored them (your own DID annotated with \"(you)\"), or an empty string if nothing needed cleaning up\n - trackDataset(): unknown\n - onDatasetRemoved(): unknown\n - initSystemDatasets(): unknown\n - loadDatasets(): unknown\n - subscribeToChanges(): unknown\n\nEditorStore:\n- State:\n - messages: unknown\n - isOpen: unknown\n - isStreaming: unknown\n - streamingContent: unknown\n - apiKeyConfigured: unknown\n - templateName: unknown\n - templateIcon: unknown\n - isReadOnly: unknown\n - hasPendingChanges: unknown\n - pickerOpen: unknown\n - pickerAction: unknown\n - pickerDefaultName: unknown\n - pickerDefaultIcon: unknown\n - pickerShowDestination: unknown\n - sessions: unknown\n - activeSessionId: unknown\n - contentMode: unknown\n - schemaJson: unknown\n - canUndo: boolean (true when there are schema edits that can be undone)\n - canRedo: boolean (true when there are undone schema edits that can be redone)\n - isEditingTemplate: unknown\n - editAction: unknown\n - codePanelOpen: unknown\n - themePanelOpen: unknown\n - visualPanelOpen: unknown\n - isEditingTheme: unknown\n - aiPanelWidth: unknown\n - codePanelWidth: unknown\n - themePanelWidth: unknown\n - visualPanelWidth: unknown\n- Actions:\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setContentMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n - pushSnapshot(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - enterTemplateEditing(): unknown\n - exitTemplateEditing(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - open(): unknown\n - close(): unknown\n - toggleCodePanel(): unknown\n - openCodePanel(): unknown\n - closeCodePanel(): unknown\n - toggleThemePanel(): unknown\n - openThemePanel(): unknown\n - closeThemePanel(): unknown\n - toggleVisualPanel(): unknown\n - enterThemeEditing(): unknown\n - exitThemeEditing(): unknown\n - toggleThemeEditing(): unknown\n - setAiPanelWidth(): unknown\n - setCodePanelWidth(): unknown\n - setThemePanelWidth(): unknown\n - setVisualPanelWidth(): unknown\n - sendMessage(): unknown\n - clearHistory(): unknown\n - setApiKey(): unknown\n\nPresenceStore:\n- State:\n - peers: unknown\n - online: unknown\n - onlineHere: unknown\n - calls: unknown\n - available: unknown\n - focusDepth: unknown\n- Actions:\n - setFocusDepth(): unknown\n - setAvailability(): unknown\n - setActivity(): unknown\n - clearActivity(): unknown\n\nProfileStore:\n- State:\n - profiles: AgentProfileSummary[] — cache of all fetched profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownProfile: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from the cache)\n - pendingAvatar: unknown\n- Actions:\n - setPendingAvatar(file: File): holds a picture chosen before an agent exists; uploaded by completeAccountSetup\n - completeAccountSetup(name: string, password: string): the whole of first-run setup — creates the agent, then publishes the name and picture, then lets the app appear\n - fetchProfile(did: string): fetches and caches an agent's profile from their public dataset\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to the public dataset\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads the image and publishes its expression URL to the public dataset\n - clearProfileImage(field: \"avatar\" | \"coverImage\"): removes that image from the published profile\n - updateOwnLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges the location update into the cache and publishes to the public dataset\n\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n - params: Record — the URL's query parameters, reactive; read one as { $store: 'routeStore.params.' }. Prefer $localState with syncParam for fields a view owns; read params directly only for parameters something else writes\n- Actions:\n - setNavigateFunction(): unknown\n - setCurrentPath(): unknown\n - navigate(to: string, options?): navigates to a route (a bare path restores that route's remembered query string)\n - setParam(name: string, value: string | null, options?: { push?: boolean }): writes one query parameter (null removes); replaceState by default, push: true for changes that deserve a Back entry. Prefer $localState syncParam over calling this directly\n\nRuntimeStore:\n- State:\n - canAdminister: boolean — this backend exposes runtime administration at all\n - canManageTrust: boolean — gate the trusted-agents section on this\n - canManageNetwork: boolean — gate the peer-network section on this\n - canManageApps: boolean — gate the authorized-apps section on this\n - canManageLanguages: boolean — gate the languages section on this\n - canManageAi: boolean — gate the AI section on this\n - canConfigureAi: boolean — the models can be changed, not just listed. False for a guest on somebody else's node, where AD4M grants AI READ but refuses UPDATE/DELETE. Gate add/edit/remove/set-default controls on this and the section itself on canManageAi\n - canConfigureExecutor: boolean — this host starts the backend, so how it starts it can be changed. False on web\n - aiModels: AiModelView[] — installed models, each carrying its display strings (kindLabel, sourceLabel, detail, statusText, ready) alongside id/name/kind/source/isDefault. Empty until loadAiModels() runs\n - aiTasks: AiTask[] — named prompts apps registered against a model (id, name, modelId, systemPrompt)\n - aiForm: AiModelForm | null — the model form while it is open, null when closed. One flat field per input; read with runtimeStore.aiForm.\n - aiPresetOptions: { label, value }[] — model names the backend can fetch itself, for the open form kind\n - aiFormComplete: boolean — the open form has every field its chosen source needs\n - languages: InstalledLanguage[] — language plugins installed in this backend (address, name, system). Empty until loadLanguages() runs\n - trustedAgents: string[] — trusted peer ids. Empty until loadTrustedAgents() runs\n - authorizedApps: AuthorizedApp[] — external apps holding credentials (id, name, description, url, iconUrl, capabilities, revoked). Empty until loadAuthorizedApps() runs\n - networkMetrics: string — backend diagnostic blob, displayed verbatim. Empty until requested\n - peerInfos: string[] — this node peer-discovery records, for out-of-band exchange\n - loading: boolean — true while any runtime call is in flight\n - error: string — the last runtime error, for display\n - canBackUp: boolean — a database export/import can be offered: the backend writes the file and the host can name one. False on web\n - logLevels: { crate, level }[] — per-crate log levels the user has set, sorted. Empty means the backend own defaults are in use\n - backupStatus: string — what the last export or import did, for display. Empty until one runs\n - mcpEnabled: boolean — whether the backend serves MCP on its next start\n - mcpPort: number — the port MCP is served on\n - executorRestartPending: boolean — settings were changed that the running backend has not picked up\n - pendingConsent: ConsentRequest | null — a request awaiting the user's decision (kind: 'capability' | 'trust', title, message, app, peerId)\n - consentSecret: string — a code an approval returned, to be relayed to the asking app\n- Actions:\n - loadAiModels(): fetches the installed AI models and their load status\n - loadAiTasks(): fetches the prompts apps registered against a model\n - newAiModel(): opens the model form empty, for a new model\n - editAiModel(id: string): opens the model form on an existing model\n - setAiFormField(field: string, value: string | boolean): sets one field of the open model form. Takes the field name so one action serves every input\n - closeAiForm(): closes the model form, discarding it\n - saveAiModel(): saves the open form — adds or updates depending on whether it has an id\n - removeAiModel(id: string): deletes a model\n - setDefaultAiModel(id: string): makes this the model apps get when they ask for its kind\n - removeAiTask(id: string): deletes a registered prompt\n - loadLanguages(): fetches the installed languages\n - installLanguage(address: string): installs a language by content address, then reloads the list\n - removeLanguage(address: string): removes an installed language. Refuses the backend own system languages\n - loadTrustedAgents(): fetches the trusted-agent list\n - trustAgent(id: string): trusts a peer, then reloads the list\n - untrustAgent(id: string): untrusts a peer, then reloads the list\n - loadAuthorizedApps(): fetches apps holding credentials against this agent\n - revokeApp(id: string): invalidates an app's tokens, keeping the grant listed\n - removeApp(id: string): forgets the grant entirely\n - loadNetworkMetrics(): fetches the diagnostic blob\n - restartNetwork(): restarts the peer-networking layer\n - loadPeerInfos(): fetches this node peer-discovery records\n - addPeerInfos(text: string): adds pasted peer records (JSON array or one per line)\n - setMcpEnabled(enabled: boolean): turns MCP on or off for the backend next start\n - setLogLevel(crate: string, level: string): sets one crate log level — adds it when not already set, so there is no separate add. Levels: error, warn, info, debug, trace\n - removeLogLevel(crate: string): drops an override, returning that crate to the backend default\n - exportDatabase(): asks for a file, then has the backend write everything to it\n - importDatabase(): asks for a file, then has the backend read it back in\n - setMcpPort(port: number): sets the MCP port. The host refuses one outside 1024-65535\n - restartExecutor(): starts the backend over so written settings take effect. Does not return\n - approveConsent(): grants the pending request\n - denyConsent(): declines the pending request\n - dismissConsentSecret(): clears the confirmation code display\n\nSessionStore:\n- State:\n - bootState: string — 'initialising' | 'login' | 'createAgent' | 'finishing' | 'ready' | 'error'\n - bootError: string — why the boot failed, when bootState is 'error'. Empty otherwise\n - passwordError: boolean — true after a failed unlock attempt\n - loginLoading: boolean\n - createAgentError: string — the backend message from a failed agent creation, or empty\n - createAgentLoading: boolean\n - client: the backend client handle | undefined\n - agentSession: unknown\n - lifecycle: unknown\n - backendPorts: unknown\n - me: Agent | undefined — the authenticated identity; prefer the $me token in schemas\n - port: unknown\n - token: unknown\n - serverUrl: unknown\n - host: BackendHostInfo | undefined — the node this session runs against when it is somebody's hosting rather than this machine (id, name, description, imageUrl, location, url, computeSpecs, aiModels, rates). Undefined on desktop and on a local executor, so its presence is also the answer to \"am I a guest here?\" — gate any \"connected to\" UI on it. `aiModels` comes from the host directory and needs no capability, so it answers \"can this node transcribe?\" even where the executor refuses to list its models\n - hostAccount: BackendAccountInfo | undefined — this agent's account with that node (email, remainingCredits, walletAddress, freeAccess). Check freeAccess before showing a balance: on a free node the credit figure means nothing and \"0\" reads as an account that has run dry\n - isDevelopment: unknown\n - ephemeralPort: unknown\n- Actions:\n - login(password: string): unlocks the agent and loads user data\n - createAgent(password: string): creates the agent, loads user data, and lands on the 'finishing' boot state (not 'ready')\n - clearPasswordError(): clears the failed-unlock flag. Chain it after the password field's $setLocal — the verdict was on the submitted password, so editing that password retracts it and a stale \"Incorrect password\" should not sit over the correction\n - finishSetup(): leaves 'finishing' for the running app — sets bootState to 'ready'\n - logout(): locks the agent and returns to the login screen\n - retryBoot(): starts the whole boot again from the failure screen, by reloading. A failed boot can have got anywhere before it threw, so retrying in place would race the remains of the first attempt\n - refreshMe(): unknown\n - markReady(): unknown\n - onSessionUnlocked(): unknown\n\nShellStore:\n- State:\n - activeShellView: string | null — id of the currently open shell overlay ('profile' | 'settings' | 'schema-tests' | 'landing-page'), or null\n - takePendingPath: unknown\n - createSpaceOpen: unknown\n - dockGeometry: unknown\n - contentInset: unknown\n - dockResizing: unknown\n- Actions:\n - openShellView(id: string, path?: string): opens a shell overlay by id, optionally at a route inside it — the overlay keeps its own memory router, so this never touches the browser URL\n - closeShellView(): closes the currently open shell overlay\n - setCreateSpaceOpen(open: boolean): opens or closes the create-space modal. Shell state rather than a page’s $localState because more than one place opens it — the settings page and the sidebar’s spaces group — and a page-scoped flag could only be set from inside that page\n - scrollToId(id: string): smooth-scrolls the element with that DOM id into view\n - beginDockResize(): unknown\n - resizeDock(): unknown\n - endDockResize(): unknown\n\nSpaceStore:\n- State:\n - memberDids: string[] — DIDs of all members in the current space (includes own DID)\n - members: AgentProfileSummary[] — cached profiles for all memberDids\n - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active)\n - spaceDefaultThemeId: string — the current space's default theme ID (empty string when no space is active). The counterpart to spaceDefaultTemplateId; compare against it to mark which theme a space is currently on\n - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt)\n - mySpaces: array of Space objects — every space the agent holds, across all joined datasets\n - personalSpaces: array of Space objects (local/personal spaces; all Space fields)\n - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields)\n - spaceList: { uuid, name, description, avatar, kind: 'shared' | 'personal' | 'foreign', isWeSpace, canAdminister }[] — one row per joined dataset the agent can act on, ordered like the sidebar and excluding the system datasets. Includes datasets that are not WE spaces (kind 'foreign', isWeSpace false), which are waiting to be initialized. `uuid` is the dataset id, so it keys navigation and settings whether or not a Space record exists\n - routeSpaceUnjoined: boolean — the current route points at a space this agent has not joined, as a settled fact. What a join gate should read: `currentDataset` being null is also true for the first frames of a refresh, so gating on that flashes a join prompt at someone already inside. False while the answer is still unknown\n - creatingSpace: boolean (true while a new space is being created)\n - joiningSpace: string — the shared id of the space a join is running for, '' when none is. The id rather than a flag so a list can spin only the row being joined; a gate compares it against its own route segment. Stays set for the whole join, which outlives the network call that starts it\n - joinSlow: boolean — that join has been going long enough to be worth mentioning. Joining a shared space has to fetch and install it before it exists anywhere, so a first join routinely takes a minute; pair with joiningSpace to say so instead of spinning in silence\n - joinError: { spaceId, message } | null — the last join failure, ready to display. Carries the space so a gate can tell whether the failure is its own: compare joinError.spaceId against the route segment, or a bare message follows the user to the next unjoined space they open\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the \"Initialize as WE space\" gate; null once the perspective is a WE space or no recognized foreign model is found\n - enabledModules: string[] — ids of the feature modules THIS SPACE has turned on: the community’s decision, shared with every member. An unset value means \"not decided\", not \"none\": it falls back to every registered module, so spaces predating the setting keep the chrome they had\n - templateOverrideOptions: { label, value }[] — options for the per-space template override picker: \"Use the space’s default\" (space-default), \"Use my default\" (agent-default), then every template. Each of the first two names what it resolves to. Pre-built because a schema can $map a store array into options but cannot prepend one, and without those entries overriding would be one-way\n - themeOverrideOptions: { label, value }[] — the same, for themes\n - installedModules: string[] — ids of the feature modules THIS AGENT wants available anywhere. Personal, held in the root dataset; unset means \"not decided\" and falls back to every registered module\n - requiredModules: string[] — module ids the template on screen mounts components from, derived by walking the schema rather than read from meta.components (which no template fills in). What makes uninstalling a capability module refusable\n - missingModules: string[] — of those, the ones this agent has not installed. Non-empty means the template is mounting a component nothing provides, so part of the page silently renders nothing. Empty in the ordinary case\n - activeModules: string[] — what actually renders here for this agent: registered ∩ installed ∩ enabled, less the modules muted in this space. Module chrome and the launcher rail gate on this; enabledModules alone is not sufficient\n - moduleInstallSettings: { id, name, description, icon, installed, surface, switchable }[] — every registered module and whether this agent wants it anywhere. The global Settings → Modules list, and the only place an 'app' or 'capability' module is decided about: a contribution is gated at the layer where it renders, and only 'chrome' renders inside a space. `surface` is derived from what the module contributes. Its per-space counterpart is `modules` on each spaceList row, which carries enabled/installed/visible/active together and lists chrome modules only\n - moduleLaunchers: { id, icon, label, active }[] — launchers for the modules enabled here and available in this space; what the host module rail renders. Pair with { $action: \"spaceStore.launchModule\", args: [\"$mod.id\"] }\n - mutedDids: unknown\n - mutedAgents: unknown\n - readMarkers: unknown\n- Actions:\n - createSpace(name, description, access: 'personal' | 'shared', discovery: 'hidden' | 'listed', avatarFile?, coverImageFile?, location?): creates a new space with full setup\n - joinSpace(id: string, focus = true): joins a shared space by share link, neighbourhood URL or CID, or focuses it if already joined. Pass focus: false to join without navigating there — for a caller that needs the dataset present rather than open, which is how the marketplace reads its own dataset without moving you out of the space you are in. Rejects when the join could not be completed, so onSuccess means what it says; watch joiningSpace/joinSlow/joinError for what to show while it runs. A join whose network call times out keeps going: the backend usually finishes anyway, and this waits for that before believing the failure\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native dataset (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the dataset is already a published neighbourhood\n - removeSpace(uuid: string): removes a space — clears its global-discovery listing (when authored by this agent) and removes the backing dataset\n - createPost(editorState: unknown): creates a new post\n - updatePost(postId: string, editorState: unknown): reconciles an edited post against its existing blocks — updates/reuses blocks whose id survived the edit, creates new ones, deletes ones no longer present\n - moveChild(): unknown\n - setAttending(): unknown\n - setAgentMuted(): unknown\n - markRead(): unknown\n - deleteCollection(collectionId: string): permanently deletes a CollectionBlock and everything inside it, recursively. Kind-agnostic — a post, a call record and a notes collection are the same shape, so this is the one delete for all of them\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File, spaceUuid?): uploads and sets the space avatar or cover image\n - updateSpaceMeta(updates: { name?, description?, discovery?, location? }, spaceUuid?): updates the space everyone sees. Omit spaceUuid to target the space on screen; pass one to configure a space from the spaces list without navigating to it\n - setSpaceDefaultTemplate(templateId: string, spaceUuid?): sets the template members see when they enter that space. Only repaints the app when the target is the space currently on screen\n - setSpaceDefaultTheme(themeId: string, spaceUuid?): sets the theme members see when they enter that space\n - setModuleEnabled(moduleId: string, enabled: boolean, spaceUuid?): turns a feature module on or off for a space; writes the resolved list, so the first toggle also pins whatever was on by fallback. Omit spaceUuid for the space on screen\n - setModuleInstalled(moduleId: string, installed: boolean): turns a module on or off for this agent in every space. Personal — writes AgentSettings.installedModules in the root dataset, so no other member sees it\n - setModuleVisible(moduleId: string, visible: boolean, spaceUuid?): shows or hides a module for this agent in one space, without changing what the community runs. Private: written to the root dataset, never to the space. Phrased positively so a switch can pass `$event.detail` bare — wrapping it in an operator such as `$not` would evaluate at render time and send a constant\n - setSpaceTemplateOverride(templateId: string, spaceUuid?): sets the template THIS AGENT sees in one space, overriding the community's default. Three values: 'space-default' follows the space, 'agent-default' follows your own global default (tracking later changes to it), or a concrete template id pins that one. Private, and applied immediately when that space is the one on screen. Note the sentinels are named values, not '' — the ORM skips empty strings on update, so '' cannot clear a property\n - setSpaceThemeOverride(themeId: string, spaceUuid?): sets the theme THIS AGENT sees in one space. Same three values as setSpaceTemplateOverride. Private\n - launchModule(moduleId: string): invokes that module's declared launcher action. Takes an id rather than a path because $action resolves a literal string, so a rail iterating over modules cannot build modules.. itself\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - upsertSignal(nodeId: string, signalTypeId: string, value: number): adds or updates a signal on a node; value=0 deletes it\n - navigateToSpace(spaceId: string, view?: string): navigates to a space — accepts a perspective UUID or a neighbourhood CID (sharedUrl without the neighbourhood:// prefix); pre-loads space templates before switching so the template and data arrive together\n - canAdministerSpace(uuid: string): whether this agent may change what every member of that space sees — true for a personal space, and for a shared one they authored. A UI affordance for deciding whether to offer the controls, NOT enforcement: a shared space is a neighbourhood every member can write to. Ask by name rather than comparing author to $me.did, so the answer can grow (multiple admins, roles) without every template changing\n - copyShareLink(uuid: string): copies that space's share link to the clipboard, with a toast either way. No-op for a personal space, which has no global id and so no shareable link — read `spaceList[].shareLink` to decide whether to offer the control at all\n - getSubgroupMessages(subgroupId: string): messages belonging to one of Flux's conversation subgroups, fetched on demand. A dialect query against a foreign schema rather than a WE model, so it goes through the backend's interop surface instead of $query — which is why it is a store method and not a relation you can drill into\n - removeSpaceFromGlobal(): unknown\n - updateSpaceInCache(): unknown\n - loadSpaces(): unknown\n\nTemplateStore:\n- State:\n - personalTemplates: array of TemplateSchema objects — core templates plus user's installed custom templates (excludes space templates)\n - spaceTemplates: array of TemplateSchema objects — templates loaded from the current space perspective\n - builtInTemplates: array of TemplateSchema objects — built-in system templates (always available)\n - myTemplates: array of TemplateSchema objects — user's installed custom templates only (excludes built-in and space templates)\n - allTemplates: array of TemplateSchema objects — union of built-in + personal + space templates\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n - currentTemplate: TemplateSchema (the active template)\n - loading: unknown\n - defaultTemplateId: unknown\n - operationLoading: unknown\n- Actions:\n - provideSpaceLookup(): unknown\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - replaceTemplate(): unknown\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - deleteTemplate(): unknown\n - installTemplate(): unknown\n - uninstallTemplate(): unknown\n - installFromMarketplace(): unknown\n - installToSpace(marketplaceTemplateId: string): copies a marketplace template into the current space, so every member of that community gets it — as opposed to installing it for yourself. Pair with templateStore.operationLoading to show progress on the row being installed\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - saveTemplate(name: string): saves the current template\n - saveTemplateAs(): unknown\n - publishToSpace(): unknown\n - deleteMarketplaceTemplate(): unknown\n - publishToMarketplace(): unknown\n - persistCurrentTemplate(): unknown\n - preloadSpaceTemplates(): unknown\n - loadSpaceTemplates(): unknown\n - refreshSpaceTemplates(): unknown\n - clearSpaceTemplates(): unknown\n - isBuiltInTemplate(): unknown\n - isInstalled(): unknown\n - getTemplateModel(): unknown\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - installedThemes: array of ThemeData objects — user-installed themes from root perspective (origin: \"custom\" | \"marketplace\")\n - spaceThemes: array of ThemeData objects — themes stored in the current space perspective (origin: \"custom\")\n - allThemes: array of ThemeData objects — union of builtInThemes + visible installedThemes + spaceThemes (hidden themes filtered out)\n - currentThemeId: string — id of the currently active theme\n - currentTheme: ThemeData — the currently active theme object (id, name, icon, origin)\n - defaultThemeId: string — id of the user's preferred default theme (used for bootscreen, shell, and future space-override). Persisted to AgentSettings.defaultThemeId\n - themeManagementList: ThemeManagementItem[] — flat list of all themes (built-in + all custom) with management metadata (id, name, icon, isBuiltIn, isInstalled, isDefault)\n - editingTheme: unknown\n - operationLoading: string | null — the id of the theme operation currently in flight, namespaced by kind (e.g. 'marketplace-install:'), or null when idle. A key rather than a boolean so one row's spinner does not appear on every row — compare it against the row you are rendering\n - themeScope: unknown\n - themeScopePreference: unknown\n - themeScopeGlobal: unknown\n - themeScopePreviewing: unknown\n - useTemplateTheme: unknown\n - activeTemplateTheme: unknown\n - saveEditingTheme: unknown\n- Actions:\n - registerHistoryCallbacks(): unknown\n - applySnapshot(): unknown\n - setCurrentTheme(themeId: string): sets and persists the active theme\n - setDefaultTheme(themeId: string): sets the preferred default theme (persists to AgentSettings.defaultThemeId)\n - toggleThemeInstalled(themeId: string): toggles a custom theme visible/hidden in pickers; does not delete the theme\n - previewThemeScope(scope: 'global' | 'scoped' | null): previews a scope for the current theme-editing session without writing the preference; null drops the preview. Cleared when editing ends\n - setThemeScopeGlobal(global: boolean): persists whether a space's theme covers the whole window (true) or only the space's own content (false, the default). Takes a boolean because a switch emits one and a schema cannot map it to a string — `$if` in an action's args resolves at render time, before the event exists\n - setUseTemplateTheme(): unknown\n - replaceTheme(): unknown\n - restorePersonalTheme(): unknown\n - clearSpaceTheme(): unknown\n - startEditing(): unknown\n - changeBasePreset(): unknown\n - updateEditingOverrides(): unknown\n - updateEditingCss(): unknown\n - updateEditingMeta(): unknown\n - cancelEditing(): unknown\n - createAndStartEditing(): unknown\n - saveEditingThemeAs(): unknown\n - deleteTheme(themeId: string): permanently deletes a custom theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteMarketplaceTheme(): unknown\n - publishToMarketplace(): unknown\n - publishToSpace(): unknown\n - loadInstalledThemes(): unknown\n - refreshSpaceThemes(): unknown\n\nModel:\n- State:\n- Actions:\n - create(): unknown\n - update(): unknown\n - delete(): unknown\n\n---\n\n## Store Usage Patterns\n\nReading state:\n{ \"$store\": \"storeName.property\" }\nExample: { \"$store\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nFeature-module stores:\n{ \"$store\": \"modules..\" } and { \"$action\": \"modules..\" }\nEach installed feature module publishes its store under its own id — modules.call.tiles,\nmodules.notes.open, modules.transcribe.pending. Which ids exist depends on the deployment's seed,\nso these are not listed in the Stores section below and are never checked against a known-member\nlist. A reference to a module that is not installed simply resolves to nothing.\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"spaceStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$space.uuid\"] }] }\n },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"initials\": \"$space.name\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"children\": [\"$space.name\"] }\n ]\n }\n ]\n}\n\nConditional rendering from store:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$eq\": [{ \"$store\": \"routeStore.currentPath\" }, \"/\"] },\n \"then\": { \"type\": \"we-text\", \"children\": [\"Home\"] },\n \"else\": { \"type\": \"we-text\", \"children\": [\"Not home\"] }\n }\n}\n\nDeriving options from store:\n{\n \"$map\": {\n \"items\": { \"$store\": \"templateStore.templates\" },\n \"select\": { \"name\": \"$item.meta.name\", \"icon\": \"$item.meta.icon\" }\n }\n}\n\nQuerying model data:\n{\n \"$query\": { \"entity\": \"TaskBlock\", \"where\": { \"status\": \"todo\" } }\n}\n\nEager-loading relations with include (most common relational pattern):\nWhen you need related data displayed alongside a list, use include to hydrate relations in one query.\n\nExample — Channel list with conversation count and latest conversation:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Channel\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"$conversationCount\": { \"from\": \"conversations\", \"count\": true },\n \"$latestConversation\": { \"from\": \"conversations\", \"order\": { \"createdAt\": \"desc\" }, \"limit\": 1 }\n }\n }\n },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"we-text\", \"children\": [\"$channel.name\"] },\n { \"type\": \"we-text\", \"children\": [\"$channel.$conversationCount\"] }\n ]\n }]\n}\n\nExample — Nested include (Conversations with their messages):\n{\n \"$query\": {\n \"entity\": \"Conversation\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"messages\": {\n \"order\": { \"createdAt\": \"desc\" },\n \"limit\": 20\n }\n }\n }\n}\nEach conversation in the result has a messages array of hydrated Message instances.\nNesting works to any depth: \"include\": { \"messages\": { \"include\": { \"reactions\": true } } }\n\nRelational drill-down (master-detail navigation across entity relations):\nUse routes + a $query `scope` when you navigate to a detail route and need only that record's children.\nscope.anchor is the parent entity type; scope.via is its HasMany relation (see externalModels) whose targets\nare the query's entity; scope.anchorId is the parent record's id. The adapter resolves the relation to a\nbackend handle, so no protocol details live in the template.\nrouteStore.segments.N extracts the Nth dynamic path segment (segments splits currentPath by \"/\").\n\nExample — Channel list → Conversation list:\n{\n \"routes\": [\n {\n \"path\": \"/\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": { \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/channels/\", \"$channel.id\"] }] }\n },\n \"children\": [\"$channel.name\"]\n }]\n }]\n },\n {\n \"path\": \"/channels/:channelId\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Conversation\",\n \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": { \"$store\": \"routeStore.segments.1\" } },\n \"dataset\": \"$currentDataset\"\n }\n },\n \"as\": \"convo\"\n },\n \"children\": [{\n \"type\": \"we-text\",\n \"children\": [\"$convo.conversationName\"]\n }]\n }]\n }\n ]\n}\nNotes:\n- Use include when you need related data displayed inline (e.g. a post with its comments, a channel with its conversation count).\n- Use a scope drill-down when you're on a detail route and want only children belonging to the current record.\n- dataset must point to the dataset that holds the data. For external apps (e.g. Flux) opened as a WE space, use \"$currentDataset\".\n- The relation name (in include, or scope.via) is the HasMany field name on the parent entity.\n\nLocal state (form with validation):\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"name\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [{ \"rule\": \"required\" }, { \"rule\": \"minLength\", \"value\": 2 }]\n },\n \"loading\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$local\": \"loading\" },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\nThe button is disabled only while the submit is in flight. Disabling it on { \"$not\": { \"$formValid\": \"$scope\" } }\ninstead contradicts the { \"$touch\": \"$all\" } beneath it — the button is unclickable in exactly the state that\nguard exists to report. See the \"Typical form pattern\" section for the full rationale and the two valid shapes.\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"neutral-0\", \"r\": \"400\", \"border\": \"1px solid neutral-200\", \"p\": \"400\", \"gap\": \"300\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"initials\": \"$post.author\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\" }, \"children\": [\"$post.author\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [\"$post.title\"] },\n { \"type\": \"we-text\", \"children\": [\"$post.text\"] }\n ]\n }\n ]\n}\n\nUse $query or $store for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"spaceStore.posts\" }, \"as\": \"post\" }, \"children\": [...] }\n\nPer-item customization inside $each:\nTo style or highlight specific items, add a data flag to those items and use $if on the flag inside the template. Do NOT use $eq: [\"$index\", N] comparisons — they are fragile, repetitive, and break when items are reordered.\nExample: add \"highlighted\": true to one item's data, then use $if on \"$post.highlighted\" in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": \"$post.highlighted\", \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor conditional props (e.g. different bg on highlighted items):\n{ \"bg\": { \"$if\": { \"condition\": \"$post.highlighted\", \"then\": \"primary-50\", \"else\": \"neutral-0\" } } }\n\nBoolean toggle (show/hide, expand/collapse):\n{\n \"type\": \"Column\",\n \"$localState\": { \"showDetails\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocal\": \"showDetails\" } }, \"children\": [\"Toggle Details\"] },\n { \"type\": \"$if\", \"props\": { \"condition\": { \"$local\": \"showDetails\" }, \"then\": { \"type\": \"we-text\", \"children\": [\"Details content here\"] } } }\n ]\n}\n\nSignal types (community-specific reactions/votes):\nSignal types are created per-community by the user. Never hardcode signal type UUIDs in schemas.\nResolve them by slug from a hoisted $queries subscription on the node.\n\nThere is no store accessor for this. spaceStore.signalTypesBySlug existed once and was removed;\nschemas still referencing it filtered on undefined — a like count that silently counted the wrong\nthing. Query the SignalType entity instead, and look the slug up with $find.\n\nALWAYS ask the user: \"What slug should I use? (e.g. 'like', 'upvote', 'star')\"\nThen use that slug in the pattern below.\n\nPattern — live wired SignalControl (one hoisted query, reused by the projection and the control):\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"signals\": true,\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$find\": { \"items\": { \"$local\": \"signalTypes\" }, \"where\": { \"slug\": \"like\" }, \"select\": \"id\" } }\n },\n \"count\": true\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } },\n \"then\": {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [\n {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": \"$sig\",\n \"signals\": { \"$filter\": { \"items\": \"$item.signals\", \"where\": { \"signalTypeId\": \"$sig.id\" } } },\n \"myDid\": \"$me.did\",\n \"onSignal\": { \"$action\": \"spaceStore.upsertSignal\", \"args\": [\"$item.id\", \"$sig.id\", \"$arg\"] }\n }\n }\n ]\n }\n }\n }\n ]\n }\n ]\n}\n\nNotes:\n- $queries and $localState share one $local namespace, so { \"$local\": \"signalTypes\" } reads the\n subscription from any descendant — the projection above and the controls below stay in agreement\n about which type a slug means.\n- The $count guard renders nothing until the community has created a signal type.\n- Iterating signalTypes renders every type the community defined; use $find with a slug only where\n one specific type is meant (e.g. a like count).\n- Replace \"like\" with the user's slug.\n- $query include adds $totalLikeCount as a computed property on each item.\n- signalType prop accepts the full SignalType object (provides icon, mode, range to the UI component).\n\nPreview / mockup mode (static, no store wiring):\n{\n \"type\": \"SignalControl\",\n \"props\": {\n \"preview\": true,\n \"signalType\": { \"icon\": \"❤️\", \"mode\": \"toggle\", \"rangeMin\": 0, \"rangeMax\": 1 }\n }\n}\nUse preview: true when sketching a layout without real data. Remove it (and add the full wiring above) when going live.\n\n---\n\n## Common Patterns (copy these shapes)\n\nThese are the shapes WE's own templates use. Prefer them over inventing a new arrangement — they\ncarry decisions (loading behaviour, empty states, accessibility) that are easy to omit and hard to\nnotice missing. Copy the JSON and change the words; every one of them is ordinary nodes you can\nthen edit freely.\n\n### Empty state — what a list shows when it has nothing to show\n\n**A list must always have one.** An empty `$each` renders nothing at all, so a page with no content\nlooks identical to a page still loading, and the reader cannot tell which.\n\n```json\n{\n \"type\": \"$animate\",\n \"props\": { \"enterTransition\": { \"type\": \"fade\", \"duration\": 200, \"delay\": 400 } },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"ax\": \"center\", \"ay\": \"center\", \"gap\": \"200\", \"p\": \"600\", \"width\": \"100%\" },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"newspaper\", \"size\": \"lg\", \"color\": \"neutral-400\" } },\n {\n \"type\": \"we-text\",\n \"props\": { \"color\": \"neutral-400\", \"textAlign\": \"center\" },\n \"children\": [\"This space doesn't have any posts.\"]\n }\n ]\n }\n ]\n}\n```\n\nThe `$animate` wrapper is not decoration. A query-backed list is empty on its first frame and fills\na moment later, so without the delayed fade the placeholder blinks on every load and states\nsomething false while it does. Drop the wrapper only when emptiness is known synchronously (a store\narray, a missing model).\n\n**If the list filters on a search box**, say so instead of claiming the space is empty:\n\n```json\n{ \"$if\": { \"condition\": { \"$local\": \"searchText\" },\n \"then\": \"No posts match your search.\",\n \"else\": \"This space doesn't have any posts.\" } }\n```\n\n### A list with its empty state — hoist the query so the count is readable\n\n```json\n{\n \"type\": \"Column\",\n \"props\": { \"width\": \"100%\" },\n \"$queries\": { \"postRows\": { \"entity\": \"CollectionBlock\", \"where\": { \"type\": \"root\" }, \"limit\": 20 } },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$count\": { \"items\": { \"$local\": \"postRows\" } } },\n \"then\": {\n \"type\": \"Grid\",\n \"props\": { \"columns\": 1, \"gap\": \"400\", \"width\": \"100%\" },\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"postRows\" }, \"as\": \"post\" },\n \"children\": [{ \"type\": \"Card\", \"children\": [\"…\"] }]\n }\n ]\n },\n \"else\": { \"…\": \"the empty state above\" }\n }\n }\n ]\n}\n```\n\nHoisting into `$queries` rather than leaving the query on the `$each` is what makes the count\nreadable from outside the loop, and it means one subscription answers both branches — so the\nplaceholder and the grid can never disagree about how many rows there are.\n\n### Gate / prompt page — an icon, what this is, and what to do about it\n\n```json\n{\n \"type\": \"Column\",\n \"props\": { \"flex\": \"1\", \"height\": \"100%\", \"ax\": \"center\", \"ay\": \"center\", \"gap\": \"400\", \"p\": \"600\" },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"lock\", \"size\": \"xl\", \"gradient\": \"primary\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-md\", \"textAlign\": \"center\" }, \"children\": [\"Join this Space\"] },\n {\n \"type\": \"we-text\",\n \"props\": { \"variant\": \"body\", \"textAlign\": \"center\", \"maxWidth\": \"var(--we-layout-xs)\" },\n \"children\": [\"You haven't joined this space yet.\"]\n },\n { \"type\": \"we-button\", \"props\": { \"variant\": \"primary\", \"onClick\": { \"$action\": \"…\" } }, \"children\": [\"Join\"] }\n ]\n}\n```\n\nUse `gradient` on the icon when there is something to do, and a flat `color` (`neutral-300`,\nor `warning`) when there is not — the two read apart at a glance, and a dead end that looks like\nan invitation is worse than one that looks like a dead end.\n\n### Confirm dialog\n\n```json\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"confirmDeleteOpen\" },\n \"then\": {\n \"type\": \"we-modal\",\n \"props\": { \"close\": { \"$setLocal\": \"confirmDeleteOpen\", \"value\": false } },\n \"children\": [\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"semibold\" }, \"children\": [\"Delete post?\"] },\n { \"type\": \"we-text\", \"children\": [\"This cannot be undone.\"] },\n {\n \"type\": \"Row\",\n \"props\": { \"ax\": \"end\", \"gap\": \"200\" },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$setLocal\": \"confirmDeleteOpen\", \"value\": false } }, \"children\": [\"Cancel\"] },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"danger\",\n \"onClick\": { \"$action\": \"spaceStore.deleteCollection\", \"args\": [\"$post.id\"],\n \"onSuccess\": [{ \"$setLocal\": \"confirmDeleteOpen\", \"value\": false }] }\n },\n \"children\": [\"Delete\"]\n }\n ]\n }\n ]\n }\n }\n}\n```\n\nThe flag must be declared by an ancestor of **the button that opens it**, not merely of the modal.\nUndeclared, `$setLocal` warns and no-ops: the button renders, takes the click, and does nothing.\n\nIf the action is slow (a recursive delete walks its whole collection), add a `busy` boolean set\nbefore it and cleared in `onFinally`, and bind the confirm button's `loading` and `disabled` to it.\n\n### Composing a post — the BlockComposer save handshake\n\n`BlockComposer` is **pull-based**. Its `onSave` does *not* fire when the user types or when a modal\ncloses — it fires when somebody calls the composer's own `save()`, which it hands out exactly once\nthrough `onReady`. So the sequence is: `onReady` stores that function in a **`function`-typed**\n`$localState` field, the button calls it with `$callLocal`, `save()` serializes the tree, and\n`onSave` runs the action with the tree as `$arg`.\n\n```json\n{\n \"type\": \"we-modal\",\n \"props\": { \"close\": { \"$setLocal\": \"composeOpen\", \"value\": false } },\n \"$localState\": {\n \"savePost\": { \"type\": \"function\", \"initial\": null },\n \"submitting\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"BlockComposer\",\n \"props\": {\n \"perspective\": { \"$store\": \"datasetStore.currentDataset.handle\" },\n \"onReady\": { \"$setLocal\": \"savePost\", \"from\": \"$event.save\" },\n \"onSave\": [\n { \"$setLocal\": \"submitting\", \"value\": true },\n {\n \"$action\": \"spaceStore.createPost\",\n \"args\": [\"$arg\"],\n \"onSuccess\": [{ \"$setLocal\": \"composeOpen\", \"value\": false }],\n \"onFinally\": [{ \"$setLocal\": \"submitting\", \"value\": false }]\n }\n ]\n }\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"primary\",\n \"loading\": { \"$local\": \"submitting\" },\n \"disabled\": { \"$local\": \"submitting\" },\n \"onClick\": { \"$callLocal\": \"savePost\" }\n },\n \"children\": [\"Post\"]\n }\n ]\n}\n```\n\n**Do not** wire the button straight to the action against a `draft` local the composer was expected\nto fill in. That spelling typechecks, validates, renders — and posts `null`, surfacing as\n`Cannot read properties of null (reading 'type')` from inside `persistNode`, several frames from\nthe cause. And because `onReady` is optional, omitting it makes the composer render a floppy-disk\nsave button of its own, so the screen ends up with two buttons and only the unexpected one works.\n(`we-validate-schemas` rejects `onSave` without `onReady`.)\n\n`$arg` goes wherever the action wants it — first for `createPost(json, options)`, second for\n`updatePost(postId, json)`.\n\n**Prefer `composerModal` from `@we/template-kit`**, which owns all of the above; write it out by\nhand only when the modal itself needs a different shape.\n\n### Form field\n\n```json\n{\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [\n {\n \"type\": \"we-input\",\n \"props\": {\n \"placeholder\": \"Space name…\",\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" }\n }\n }\n ]\n}\n```\n\n`$error` is already empty until the field is touched, so it needs no `$if` around it. Which event\ncarries the value depends on the control: `we-input`/`we-textarea` emit `onInput` with\n`$event.detail`, `we-select` emits `onChange` with `$event.detail`, and `Search` calls back\nwith the value itself as `$arg`.\n\n### Author byline\n\n```json\n{\n \"type\": \"$agent\",\n \"props\": { \"did\": \"$post.author\", \"as\": \"author\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"ay\": \"center\", \"gap\": \"300\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"size\": \"sm\", \"image\": \"$author.avatar\", \"hash\": \"$author.did\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"semibold\" }, \"children\": [\"$author.name\"] },\n { \"type\": \"we-timestamp\", \"props\": { \"value\": \"$post.createdAt\", \"relative\": true, \"color\": \"neutral-500\" } }\n ]\n }\n ]\n}\n```\n\nAlways set `hash` as well as `image`, never as a fallback for it: `hash` seeds a generated avatar\nthat is stable per agent, so somebody whose profile has not arrived is still visually distinct from\neverybody else whose profile has not arrived. A real picture wins where there is one.\n\n### A group of faces with a count\n\n```json\n{\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\", \"minHeight\": \"32px\" },\n \"children\": [\n {\n \"type\": \"AvatarStack\",\n \"props\": {\n \"avatars\": { \"$map\": { \"items\": { \"$store\": \"spaceStore.members\" },\n \"select\": { \"image\": \"$item.avatar\", \"hash\": \"$item.did\" } } },\n \"max\": 5, \"size\": \"sm\", \"ring\": \"0 0 0 2px var(--we-ring-color)\"\n }\n },\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"100\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-number\", \"props\": { \"value\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"shorten\": true } },\n { \"type\": \"we-text\", \"children\": [{ \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }] }\n ]\n }\n ]\n}\n```\n\n**When the items are bare DIDs rather than profiles**, join each to its profile — and note the trap:\ninside a `$map` `select`, a string is substituted only when it starts with `$item.`. A bare\n`\"$item\"` is a **literal**, so every generated face comes out identical. Wrap it in a token object:\n\n```json\n\"select\": {\n \"image\": { \"$find\": { \"items\": { \"$store\": \"profileStore.profiles\" }, \"where\": { \"did\": \"$item\" }, \"select\": \"avatar\" } },\n \"hash\": { \"$concat\": [\"$item\"] }\n}\n```\n\n`minHeight` on the row is worth keeping: `AvatarStack` has no height with no avatars, and people\nresolve later than the record they belong to, so without a floor the row collapses and then pushes\neverything below it down a second time.\n\n### Page shell — a route's outer box\n\n```json\n{\n \"type\": \"Column\",\n \"props\": { \"width\": \"100%\", \"ax\": \"center\" },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"width\": \"100%\", \"maxWidth\": \"var(--we-layout-lg)\", \"gap\": \"500\", \"px\": \"400\", \"py\": \"500\" },\n \"children\": [\"…\"]\n }\n ]\n}\n```\n\nTwo Columns, because centring and constraining are different jobs: the outer spans the viewport so\nthe route's background reaches the edges, the inner holds the measure.\n\n### Titled section on a card\n\n```json\n{\n \"type\": \"Card\",\n \"props\": { \"bg\": \"neutral-100\", \"border\": \"1px solid neutral-200\" },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"gap\": \"100\" },\n \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-md\" }, \"children\": [\"About this space\"] },\n { \"type\": \"we-text\", \"children\": [\"Manage how this space appears to others.\"] }\n ]\n },\n \"…\"\n ]\n}\n```\n\n### Labelled attribute with an optional control\n\n```json\n{\n \"type\": \"Row\",\n \"props\": { \"ay\": \"center\", \"ax\": \"between\", \"wrap\": true },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"ay\": \"center\", \"gap\": \"400\", \"py\": \"100\" },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"globe\", \"color\": \"primary-600\" } },\n {\n \"type\": \"Column\",\n \"props\": { \"gap\": \"100\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\" },\n \"children\": [\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"bold\", \"color\": \"neutral-700\" }, \"children\": [\"Discovery:\"] },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"bold\" }, \"children\": [\"Listed\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"body\" }, \"children\": [\"Appears on the WE discovery globe\"] }\n ]\n }\n ]\n },\n { \"type\": \"we-switch\", \"props\": { \"checked\": true, \"onChange\": { \"$action\": \"…\" } } }\n ]\n}\n```\n\nDrop the outer `Row` and the control for the read-only form.\n\n---\n\n## Routing Structure\n\nDefine nested routes using the \"routes\" array at the root node of the schema.\nEach route object describes a path and the UI node to render when that path is active.\nRoutes can be nested to support sub-pages and layouts.\n\nRoute objects follow the same structure as schema nodes, with an additional \"path\" property.\n\n- The \"routes\" array MUST be placed on the ROOT template node (or on a route node for nested routing). The router only reads routes from these positions — placing routes on an arbitrary child node means the router will never find them and nothing will render.\n- Use \"path: '*'\" or \"path: '/*'\" for catch-all/not-found routes.\n- Use \":paramName\" for dynamic route parameters (e.g. \"/space/:spaceId\").\n- Use nested \"routes\" arrays for sub-pages and layouts.\n- Use { \"type\": \"$routes\" } in children to indicate where nested routes should render. The $routes outlet can be deeply nested — only the routes array placement matters.\n- EVERY { \"type\": \"$routes\" } outlet MUST have a \"routes\" array defined on the same node or an ancestor node. A $routes outlet without a routes array is invalid and will fail validation.\n- NEVER duplicate a route path — every route in the same \"routes\" array MUST have a unique path.\n- When using tabs, each tab's key and navigate path MUST have a matching route. Ensure a 1:1 correspondence between tabs and routes.\n\n### Tabs + Routing\n\nIMPORTANT: we-tabs only manages visual selection — clicking a tab does NOT navigate automatically.\nEach we-tab MUST have an onClick with { \"$action\": \"routeStore.navigate\" } to trigger route changes.\nBind we-tabs selectedKey to the matching route segment so the active tab stays in sync.\n(Alternatively, a single onChange on we-tabs can replace per-tab onClick — see onChange pattern below.)\n\nRecommended pattern — header above tabs (routes on ROOT, $routes outlet nested inside):\n{\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"Select a tab\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Posts content\"] }] },\n { \"path\": \"/articles\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Articles content\"] }] }\n ],\n \"children\": [\n { \"type\": \"Row\", \"props\": { \"p\": \"300\", \"ax\": \"between\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-lg\" }, \"children\": [\"My App\"] }\n ]},\n {\n \"type\": \"we-tabs\",\n \"props\": { \"selectedKey\": { \"$store\": \"routeStore.segments.0\" } },\n \"children\": [\n { \"type\": \"we-tab\", \"props\": { \"key\": \"posts\", \"label\": \"Posts\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/posts\"] } } },\n { \"type\": \"we-tab\", \"props\": { \"key\": \"articles\", \"label\": \"Articles\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/articles\"] } } }\n ]\n },\n { \"type\": \"$routes\" }\n ]\n}\nNote: \"routes\" is on the root Column, NOT on a child. The $routes outlet is a child — that's fine. Only the routes array placement matters.\n\nWRONG — two common mistakes that produce empty tabs (validator will catch both):\n{\n // MISTAKE 1: routes defined on an inner child node, not the root.\n // The router never inspects children for routes arrays — this routes array is invisible.\n \"type\": \"Column\",\n \"children\": [\n { \"type\": \"we-tabs\", \"children\": [\"...tabs...\"] },\n {\n \"type\": \"Column\",\n \"routes\": [ // ← WRONG: router never reads this\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [\"...\"] }\n ],\n \"children\": [{ \"type\": \"$routes\" }] // ← outlet here does nothing without a live routes array\n }\n ]\n}\n\n{\n // MISTAKE 2: using { type: \"$routes\" } as a route entry's component type.\n // $routes is an outlet slot marker — as a leaf route entry it has no children injected,\n // so it returns null. Every tab navigates to a route that renders nothing.\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/posts\", \"type\": \"$routes\" } // ← WRONG: renders null, use a real component\n ],\n \"children\": [{ \"type\": \"$routes\" }]\n}\n\nAlternative: single onChange on we-tabs (fires with $event.detail.value = selected key):\n{ \"onChange\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/\", \"$arg.detail.value\"] }] } }\nThis replaces all per-tab onClick handlers but requires $concat to build the path.\n\nNested routing example:\n{\n \"routes\": [\n { \"path\": \"*\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Page not found\"] }] },\n { \"path\": \"/\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Home page\"] }] },\n {\n \"path\": \"/space/:spaceId\",\n \"type\": \"Row\",\n \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Space page not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"About sub-page\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Post not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"No posts selected\"] },\n { \"path\": \"/1\", \"type\": \"we-text\", \"children\": [\"Post 1 page\"] }\n ]\n }\n ]\n }\n ]\n}\n\n---\n\n## Rules & Best Practices\n\n- Always use the correct prop names and value types for each component.\n- Never use null as a value in any children array. Only use valid schema nodes or strings.\n- Each item in a children array must be either a valid schema node object or a string.\n- Use design tokens for spacing, color, radius, etc. (do not use raw CSS except in styles).\n- Use the styles prop for custom inline CSS (e.g., { \"width\": \"100px\" }).\n- Use hoverProps for hover state overrides, activeProps for pressed state, focusProps for keyboard-focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row). focusProps fires on `:focus-visible` (keyboard), not on mouse click. Do not add a focus ring by hand — `we-button` and `we-input` already have one, themeable via the `ringColor` theme key.\n- Use dynamic logic tokens ($store, $if, $action, etc.) for reactivity and conditional behavior.\n- Nest components using children or slots as needed.\n- For routes, use the routes array with path and child nodes.\n- Do not invent new components or props — use only those listed in the component registry.\n- Do not set props to their default/inherited values — omit them. fontSize and fontWeight inherit from parents (~16px / normal), so only set them when you need a different value.\n- Omit empty `props` and `children` — both are optional. Do not write `props: {}` or `children: []`.\n- Do not use `as const` on schema node `type` fields — `SchemaNode.type` is `string`, so it is never needed.\n- For icon-only buttons, nest a `we-icon` child inside `we-button` rather than using a `text` prop with a Unicode character. **Omit the `size` prop on `we-icon` when nesting inside sized primitives** (`we-button`, `we-input`, `we-badge`, `we-textarea`) — these components auto-size nested icons via `--we-context-icon-size` (xs→12px, sm→16px, md→24px, lg→32px, xl→40px). Only set an explicit icon `size` if you need to override the automatic sizing. Example: `{ type: 'we-button', props: { variant: 'ghost', size: 'sm' }, children: [{ type: 'we-icon', props: { name: 'x' } }] }`.\n- NEVER pass a bare number like \"16\" as a size or dimension prop — it is not valid CSS. Always check the component's declared prop type: if it's a string union, use one of the listed values; if it accepts arbitrary strings, include a CSS unit (e.g. \"16px\", \"2rem\").\n- For interactive list items and selectable options, use `we-button` with variant switching (e.g., `secondary` when selected, `ghost` when not) instead of manually styling `Row` with cursor, bg, and onClick. Buttons provide hover, focus, and active states for free.\n- To make a block of content clickable **without any button appearance**, use `we-button` with `variant: 'bare'` — never a `Column`/`Row` with an `onClick`. `bare` is the appearance-free variant: no background, no hover, no padding, no radius, inherited colour — but still a real `