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/.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.
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/apps/we-preview/.gitignore b/apps/we-preview/.gitignore
new file mode 100644
index 000000000..b56f79a7e
--- /dev/null
+++ b/apps/we-preview/.gitignore
@@ -0,0 +1,2 @@
+dist
+shots/
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..0532d3aed
--- /dev/null
+++ b/apps/we-preview/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ WE Preview
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/we-preview/package.json b/apps/we-preview/package.json
new file mode 100644
index 000000000..50f8c3150
--- /dev/null
+++ b/apps/we-preview/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@we/app-preview",
+ "version": "0.1.0",
+ "description": "WE over an in-memory backend \u2014 the host the screenshot harness drives",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "serve": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "shoot": "node scripts/shoot.mjs"
+ },
+ "dependencies": {
+ "@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/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)}`);
diff --git a/apps/we-preview/scripts/shoot.mjs b/apps/we-preview/scripts/shoot.mjs
new file mode 100644
index 000000000..7d2e5be79
--- /dev/null
+++ b/apps/we-preview/scripts/shoot.mjs
@@ -0,0 +1,223 @@
+/**
+ * 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/PreviewBootstrap.tsx b/apps/we-preview/src/PreviewBootstrap.tsx
new file mode 100644
index 000000000..31263509e
--- /dev/null
+++ b/apps/we-preview/src/PreviewBootstrap.tsx
@@ -0,0 +1,58 @@
+import { useDatasetStore, useRouteStore, useSessionStore, useShellStore, useThemeStore } 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();
+ const themeStore = useThemeStore();
+
+ 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();
+ // A space's theme covers only the space's own content by default, which is right for the app —
+ // your chrome should not restyle itself every time you visit somebody's community. It is wrong
+ // for a host whose whole output is a photograph of one template: shell chrome in the *agent's*
+ // theme puts a second design in the frame, and the palette sampled off that frame then averages
+ // two themes together.
+ themeStore.setThemeScopeGlobal(true);
+ })();
+ });
+
+ return null;
+}
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..5db9f7ffc
--- /dev/null
+++ b/apps/we-preview/src/index.tsx
@@ -0,0 +1,59 @@
+/* @refresh reload */
+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, pathFor } from '@we/template-fixtures';
+import { render } from 'solid-js/web';
+
+import rootSeed from '../../../we-seed.json';
+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.
+ *
+ * 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,
+};
+
+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
new file mode 100644
index 000000000..54f09c58e
--- /dev/null
+++ b/apps/we-preview/src/platform/inMemoryConnector.ts
@@ -0,0 +1,91 @@
+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, type Fixture, type FixtureId, FIXTURES } 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.
+ *
+ * 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 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.
+ // 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,
+ datasetId,
+ path: applied.path,
+ nodes: applied.nodes,
+ };
+
+ 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/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 `