From be2e707bfff74388026b67ab75adf4955f6a9f07 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 7 Sep 2026 16:03:46 +0100 Subject: [PATCH 01/86] Pin the ad4m build whose model layer the rest of this branch takes up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coasys/ad4m#927 merged, completing the eight-PR model-layer series: relation correctness, a getter relation that can name its target class, the where-clause compiler, relation quantifiers, subjectClassesOf, polymorphic include, and CRDT ordering in two halves. Five of the eight are Rust, so the executor matters as much as the npm package here. `@coasys/ad4m` moves to 0.13.0-test-model-layer, hand-published from dev at 54a3fd956 under npm's `dev` tag so `latest` keeps pointing at a build everyone else is already on. `@coasys/ad4m-connect` deliberately stays where it is: it carries no dependency on core, so republishing it would have said something untrue about what changed. VERIFIED_AGAINST_AD4M moves in the same commit, which is the whole point of it existing — the pin moving alone is the silent case it was written to catch. Its docstring now names the SHA as well as the version, because a hand-published version corresponds to no git tag, and because the executor binary is never published at all: the Rust half is pinned by that SHA and by nothing else. Nothing yet takes up any of the new affordances. The capability profile still declares relationFilters: false and still carries both sort degradations; those are claims about the executor and are checked against a running one, not against a changelog, so they move later in the branch on evidence rather than here. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- .../backend-system/ad4m/src/ad4mAdapter.ts | 11 +++++- pnpm-lock.yaml | 35 +++++++++++-------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 4f724a9b0..869794939 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, "pnpm": { "overrides": { - "@coasys/ad4m": "0.13.0-test-interpretation-2", + "@coasys/ad4m": "0.13.0-test-model-layer", "@coasys/ad4m-connect": "0.13.0-test-interpretation-2", "prettier": "3.9.6", "@xmldom/xmldom": ">=0.9.12", diff --git a/packages/backend-system/ad4m/src/ad4mAdapter.ts b/packages/backend-system/ad4m/src/ad4mAdapter.ts index 30f23fcbf..91da61725 100644 --- a/packages/backend-system/ad4m/src/ad4mAdapter.ts +++ b/packages/backend-system/ad4m/src/ad4mAdapter.ts @@ -183,8 +183,17 @@ export function createAd4mDataBindings( * * The pin itself is currently a test tag rather than a release, which is worth knowing when reading * "verified": what was verified was that build. + * + * This one was hand-published from `dev` at **54a3fd956** — the merge of coasys/ad4m#927, which + * completes the eight-PR model-layer series — under npm's `dev` tag rather than `latest`. The SHA + * matters more here than usual: a hand-published version corresponds to no git tag, so it is the + * only thing tying this string to a build. And the executor binary is never published at all (WE + * runs the one at `ad4m/target/release/`, per `seed-runtime.json`), so the Rust half — which is + * where five of those eight PRs live — is pinned by that SHA and by nothing else. A core built + * from this commit against an executor built from another is exactly the skew this constant exists + * to make visible, and npm cannot catch it. */ -export const VERIFIED_AGAINST_AD4M = '0.13.0-test-interpretation-2'; +export const VERIFIED_AGAINST_AD4M = '0.13.0-test-model-layer'; export const ad4mCapabilities: AdapterCapabilities = { operators: ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin', 'contains', 'exists'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 306edf5ab..16c717135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@coasys/ad4m': 0.13.0-test-interpretation-2 + '@coasys/ad4m': 0.13.0-test-model-layer '@coasys/ad4m-connect': 0.13.0-test-interpretation-2 prettier: 3.9.6 '@xmldom/xmldom': '>=0.9.12' @@ -247,8 +247,8 @@ importers: version: 14.0.2 devDependencies: '@coasys/ad4m': - specifier: 0.13.0-test-interpretation-2 - version: 0.13.0-test-interpretation-2 + specifier: 0.13.0-test-model-layer + version: 0.13.0-test-model-layer '@types/node': specifier: ^26.4.0 version: 26.4.0 @@ -338,8 +338,8 @@ importers: apps/we-tauri: dependencies: '@coasys/ad4m': - specifier: 0.13.0-test-interpretation-2 - version: 0.13.0-test-interpretation-2 + specifier: 0.13.0-test-model-layer + version: 0.13.0-test-model-layer '@tauri-apps/api': specifier: 2.11.1 version: 2.11.1 @@ -384,8 +384,8 @@ importers: apps/we-web: dependencies: '@coasys/ad4m': - specifier: 0.13.0-test-interpretation-2 - version: 0.13.0-test-interpretation-2 + specifier: 0.13.0-test-model-layer + version: 0.13.0-test-model-layer '@coasys/ad4m-connect': specifier: 0.13.0-test-interpretation-2 version: 0.13.0-test-interpretation-2 @@ -455,8 +455,8 @@ importers: packages/app-shell: dependencies: '@coasys/ad4m': - specifier: 0.13.0-test-interpretation-2 - version: 0.13.0-test-interpretation-2 + specifier: 0.13.0-test-model-layer + version: 0.13.0-test-model-layer '@solidjs/router': specifier: ^0.16.3 version: 0.16.3(solid-js@1.9.15) @@ -619,8 +619,8 @@ importers: version: link:../../entities devDependencies: '@coasys/ad4m': - specifier: 0.13.0-test-interpretation-2 - version: 0.13.0-test-interpretation-2 + specifier: 0.13.0-test-model-layer + version: 0.13.0-test-model-layer '@types/node': specifier: ^26.4.0 version: 26.4.0 @@ -1903,8 +1903,8 @@ packages: '@coasys/ad4m-connect@0.13.0-test-interpretation-2': resolution: {integrity: sha512-EBk8nrAaLh6Lvp1Fa50yCBxd4advPMQqNydoJ/w/tMDZc+z7YvUSaqvgpLScVXOQKMNzElD5A/8Zh/RT7fILXQ==} - '@coasys/ad4m@0.13.0-test-interpretation-2': - resolution: {integrity: sha512-/A3hDUPuBb7Y9Pq2rkO2/FSeYz6FvvxDogIR0+9rBHEP5tS/j/FOqCImYw4TDdFY2WwUKtgUWMM9hTWztMqXHQ==} + '@coasys/ad4m@0.13.0-test-model-layer': + resolution: {integrity: sha512-k0dNph9h2Hqip4BQTKX4WJ2ZRE4VCX8W2P4lXMaqMrnhOCkk6uaILbsxaP/lUOGzlTzu/jd9k+G2avI+LlM+qw==} '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -5036,6 +5036,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + pako@3.0.1: resolution: {integrity: sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==} @@ -6522,11 +6525,11 @@ snapshots: auto-bind: 5.0.1 lit: 2.8.0 - '@coasys/ad4m@0.13.0-test-interpretation-2': + '@coasys/ad4m@0.13.0-test-model-layer': dependencies: '@holochain/client': 0.16.0 base64-js: 1.5.1 - pako: 3.0.1 + pako: 2.2.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -9648,6 +9651,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.2.0: {} + pako@3.0.1: {} parent-module@1.0.1: From 94df2a45ddf9d3cee03dbe1d6a5249137b778f90 Mon Sep 17 00:00:00 2001 From: jhweir Date: Mon, 7 Sep 2026 17:35:30 +0100 Subject: [PATCH 02/86] Tell a suggestion what it is, and let somebody fix it before keeping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extraction panel's review list showed each staged suggestion as one run-on line of `field: value` pairs, in a column that could not scroll, and offered two buttons. Every part of that is fixed here, and one of them turned out to be a backend bug rather than a layout one. A proposal's values arrive as `[predicate, value]` pairs with no class attached, so the adapter had to read a predicate back to a property name with nothing to index by. That mapping is one-to-many — predicates are shared across models on purpose, which is what lets `?node we://title ?t` span every kind of block — so it kept whichever name registered first. `Relationship` is third in SPACE_MODELS, so `we://title` resolved to `label` for every proposal in the app: a task's title arrived under a relationship's field name. The visible symptom was somewhere else entirely — the review list leads with `title`, which then matched nothing, so cards led with their description instead. `subjectClassesOf` (in the ad4m build this branch already pins) answers which model a base belongs to, so the name tables are built per model and indexed by the class the base actually is. It has to skip `InterpretationOverlay`, which the machinery instantiates over the same base URI and which ranks by required triples rather than by anything anybody chose. With the model known, the card can say what it is — icon and name from `recordStore.displays` — and draw a title, a state and a description instead of a string. `DisplayField` now carries `options`, the values a field is allowed to hold where the declaration closes the set. `kindFor` deliberately flattens those to `text`, which is right for drawing one and loses the only thing that lets a caller do better: a badge cannot tell a state from a free sentence, and an edit control cannot offer the choices, so it offers a text box and somebody types "pending" into a field whose model knows three words. The section scrolls now. It could not before because a Column cannot shrink below its content unless told it may, so flexbox took the whole deficit out of the results list underneath — which collapsed to nothing while the review list ran off the bottom. `minHeight: 0` is what makes it shrinkable; the scroll area is what the shrinking then does. The cards are a grid rather than a column, so widening the panel reflows them instead of stretching each one into a strip. Editing accepts first and writes second, which is not an implementation detail: accepting makes the model's staged value the real one and deletes the overlay, so a write made first is one the accept then silently overwrites. Ordering it this way also lands after the overlay is gone, which is what stops a later pass overwriting what was typed — deleting the overlay is the lock. The draft lives in the store because the fields come from the model and there is no set of names a schema could declare, which is the same reason `recordStore` holds its own. That needed a space-scoped `updateEntity`. `linkEntity` deferred the question and asked for an answer on concurrent writers; the answer is that there is none to give, because this is the same last-write-wins-per-field write `record.update` already performs and that is reachable from any template. It is not a widening of what can happen to a record, only of where it can be reached from. It stays wrong for lists, which is why `linkEntity` is add-one and remains so. Pending records are also excluded from the results list below. A staged `create` is a fully written record — the engine writes real values whenever no human owns them and keeps the overlay only as provenance — so every suggestion appeared twice: once as a decision, and again among the settled results, indistinguishable from something already agreed to. Co-Authored-By: Claude Opus 5 (1M context) --- .cursor/rules/we-schema.mdc | 2 +- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- packages/ai-context/src/fragments/stores.ts | 2 +- packages/ai-context/src/schemaContext.ts | 2 +- .../solid/providers/TemplateProvider.tsx | 15 + .../shared/registries/moduleHostServices.ts | 11 + .../src/shared/shapes/recordDisplay.ts | 17 + .../ad4m/src/interpretationAdapter.ts | 136 ++++-- .../ad4m/tests/interpretationNaming.test.ts | 137 ++++++ .../shared/src/interpretation.ts | 25 ++ packages/module-system/shared/src/module.ts | 38 +- .../transcribe/src/Panel.schema.ts | 411 ++++++++++++++++-- .../transcribe/src/store.test.ts | 141 +++++- .../module-system/transcribe/src/store.ts | 188 +++++++- 16 files changed, 1035 insertions(+), 96 deletions(-) create mode 100644 packages/backend-system/ad4m/tests/interpretationNaming.test.ts diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index ea16989a8..e28e224a4 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -2492,7 +2492,7 @@ RecordStore: - creatableEntities: { label, value, icon, group }[] — models a person can create an instance of here, ready for a we-select: this space's own models first, then WE's built-in content types. A model appears here by declaring `authoring` in the manifest, or by being a shape this community defined - recordDraft: the open form's draft ({ entity, label, icon, fields[] }) or null while closed — its non-nullness is what mounts the modal. Each field is { name, label, control, required, options, placeholder, value }, derived from the model's own declaration, so a form exists for a model nobody wrote a form for - recordDraftDirty: boolean — the open form holds something worth keeping. What a discard guard reads: the fields come from the model, so a shape this community defined has properties no schema was written against and there is no set of local names an expression could test. Pass it to discardGuard's `dirty` - - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns + - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role, options }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. `options` is the values a field is allowed to hold where the model closes the set (a task's status), empty otherwise — count() it to tell a state worth drawing as a we-badge from free text, and map it into a we-select rather than offering a text box that accepts a word the model does not know. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns - recordErrors: string[] — validation errors from the last save attempt, plus any backend failure - savingRecord: boolean — a create is in flight - lastCreatedId: string — the id of the last record created, empty before the first. Read it to act on what was just made; kept in the store because an $action's onSuccess can read a store and cannot hold a value diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ea16989a8..e28e224a4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2492,7 +2492,7 @@ RecordStore: - creatableEntities: { label, value, icon, group }[] — models a person can create an instance of here, ready for a we-select: this space's own models first, then WE's built-in content types. A model appears here by declaring `authoring` in the manifest, or by being a shape this community defined - recordDraft: the open form's draft ({ entity, label, icon, fields[] }) or null while closed — its non-nullness is what mounts the modal. Each field is { name, label, control, required, options, placeholder, value }, derived from the model's own declaration, so a form exists for a model nobody wrote a form for - recordDraftDirty: boolean — the open form holds something worth keeping. What a discard guard reads: the fields come from the model, so a shape this community defined has properties no schema was written against and there is no set of local names an expression could test. Pass it to discardGuard's `dirty` - - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns + - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role, options }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. `options` is the values a field is allowed to hold where the model closes the set (a task's status), empty otherwise — count() it to tell a state worth drawing as a we-badge from free text, and map it into a we-select rather than offering a text box that accepts a word the model does not know. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns - recordErrors: string[] — validation errors from the last save attempt, plus any backend failure - savingRecord: boolean — a create is in flight - lastCreatedId: string — the id of the last record created, empty before the first. Read it to act on what was just made; kept in the store because an $action's onSuccess can read a store and cannot hold a value diff --git a/AGENTS.md b/AGENTS.md index ea16989a8..e28e224a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2492,7 +2492,7 @@ RecordStore: - creatableEntities: { label, value, icon, group }[] — models a person can create an instance of here, ready for a we-select: this space's own models first, then WE's built-in content types. A model appears here by declaring `authoring` in the manifest, or by being a shape this community defined - recordDraft: the open form's draft ({ entity, label, icon, fields[] }) or null while closed — its non-nullness is what mounts the modal. Each field is { name, label, control, required, options, placeholder, value }, derived from the model's own declaration, so a form exists for a model nobody wrote a form for - recordDraftDirty: boolean — the open form holds something worth keeping. What a discard guard reads: the fields come from the model, so a shape this community defined has properties no schema was written against and there is no set of local names an expression could test. Pass it to discardGuard's `dirty` - - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns + - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role, options }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. `options` is the values a field is allowed to hold where the model closes the set (a task's status), empty otherwise — count() it to tell a state worth drawing as a we-badge from free text, and map it into a we-select rather than offering a text box that accepts a word the model does not know. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns - recordErrors: string[] — validation errors from the last save attempt, plus any backend failure - savingRecord: boolean — a create is in flight - lastCreatedId: string — the id of the last record created, empty before the first. Read it to act on what was just made; kept in the store because an $action's onSuccess can read a store and cannot hold a value diff --git a/CLAUDE.md b/CLAUDE.md index ea16989a8..e28e224a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2492,7 +2492,7 @@ RecordStore: - creatableEntities: { label, value, icon, group }[] — models a person can create an instance of here, ready for a we-select: this space's own models first, then WE's built-in content types. A model appears here by declaring `authoring` in the manifest, or by being a shape this community defined - recordDraft: the open form's draft ({ entity, label, icon, fields[] }) or null while closed — its non-nullness is what mounts the modal. Each field is { name, label, control, required, options, placeholder, value }, derived from the model's own declaration, so a form exists for a model nobody wrote a form for - recordDraftDirty: boolean — the open form holds something worth keeping. What a discard guard reads: the fields come from the model, so a shape this community defined has properties no schema was written against and there is no set of local names an expression could test. Pass it to discardGuard's `dirty` - - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns + - displays: Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role, options }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. `options` is the values a field is allowed to hold where the model closes the set (a task's status), empty otherwise — count() it to tell a state worth drawing as a we-badge from free text, and map it into a we-select rather than offering a text box that accepts a word the model does not know. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see "A record of any type" in the patterns - recordErrors: string[] — validation errors from the last save attempt, plus any backend failure - savingRecord: boolean — a create is in flight - lastCreatedId: string — the id of the last record created, empty before the first. Read it to act on what was just made; kept in the store because an $action's onSuccess can read a store and cannot hold a value diff --git a/packages/ai-context/src/fragments/stores.ts b/packages/ai-context/src/fragments/stores.ts index b3c22f715..818b66a99 100644 --- a/packages/ai-context/src/fragments/stores.ts +++ b/packages/ai-context/src/fragments/stores.ts @@ -1096,7 +1096,7 @@ export function generateStoresText(entries: StoreEntry[]): string { creatableEntities: "{ label, value, icon, group }[] — models a person can create an instance of here, ready for a we-select: this space's own models first, then WE's built-in content types. A model appears here by declaring `authoring` in the manifest, or by being a shape this community defined", displays: - "Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see \"A record of any type\" in the patterns", + "Record — how to show an instance of each creatable model, keyed by entity name and derived from its declaration: { entity, label, icon, title, summary, media, fields[] }, where title/summary/media name the properties playing those roles ('' when none does) and each field is { name, label, kind, role, options }. kind is one of text, longText, number, boolean, date, datetime, color, url, image, file, json; role is title, summary, media or detail. `options` is the values a field is allowed to hold where the model closes the set (a task's status), empty otherwise — count() it to tell a state worth drawing as a we-badge from free text, and map it into a we-select rather than offering a text box that accepts a word the model does not know. Index it by a row's type — { $: 'recordStore.displays[row.type]' } — and render the fields with $each; see \"A record of any type\" in the patterns", recordDraft: "the open form's draft ({ entity, label, icon, fields[] }) or null while closed — its non-nullness is what mounts the modal. Each field is { name, label, control, required, options, placeholder, value }, derived from the model's own declaration, so a form exists for a model nobody wrote a form for", recordDraftDirty: diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index 972584634..00f5c0cda 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, strings for text, or expressions like { \"$\": \"post.title\" } (rendered as text).\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- $localState / $queries: ephemeral state and hoisted subscriptions declared on the node (optional; see Dynamic Logic)\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\nThe ROOT node carries one more, and it is required:\n\n- meta: { name, description, icon } — what the template is called and how it is listed. Optional\n keys: role: 'view' for a section rather than a shell (absent means shell), themeId for a theme the\n template was designed with, panels for the surfaces the interface has (see Panels), and\n chromeReserve for a band the shell pins over the content. A root node without meta is refused.\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\nTwo kinds of token go in props: an EXPRESSION, { \"$\": \"…\" }, for anything computed (a store read, a\ncondition, a label, a list), and a HANDLER ($action, $setLocal, …) for anything that happens on an\nevent. A plain string is always text. There are no other value tokens.\n\nStore reference:\n{ \"$\": \"storeName.property.path\" }\nReads a value from a named store, supporting nested paths — reactive, so the prop follows the store.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nIMPORTANT — omitting \"args\" does NOT call the method with no arguments: the handler's own arguments are\nforwarded, so a click handler passes the DOM event as the first parameter. That is deliberate (it is how\n{ \"onChange\": { \"$action\": \"store.method\" } } passes a value straight through), but it means a method whose\nfirst parameter is OPTIONAL receives a PointerEvent from a button written the obvious way. Pass the argument\nyou mean explicitly when the method has an optional leading parameter — note \"args\": [] does not help, since\nan empty list is treated as \"no args given\" and forwards the event too.\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\": [{ \"$\": \"`/space/${result.uuid}`\" }] }] }\n\nRecord mutations via $action (use these for creating/updating/deleting records):\nA RECORD is one stored thing; an ENTITY is its type. Every one of these takes the entity name first\nand acts on a record of it.\n\nrecord.create — creates a record in the current perspective (default) or a specified one:\n{ \"$action\": \"record.create\", \"args\": [\"EntityName\", { \"field\": \"value\" }, { \"perspective\": \"datasetStore.rootDataset\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nrecord.update — updates one record:\n{ \"$action\": \"record.update\", \"args\": [\"EntityName\", { \"$\": \"item.id\" }, { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"record.update\", \"args\": [\"EntityName\", { \"$\": \"item.id\" }, { \"field\": \"value\" }, { \"perspective\": \"datasetStore.rootDataset\" }] }\n\nrecord.delete — deletes one record:\n{ \"$action\": \"record.delete\", \"args\": [\"EntityName\", { \"$\": \"item.id\" }] }\n\nUse perspective: 'datasetStore.rootDataset' for we-root entities (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped entities (Space, Signal, etc.).\n\nrecord.* writes directly; recordStore is the form surface over the same job — it derives a form from\nthe entity's own declaration, so a community's newest entity is creatable with no schema written for\nit. Reach for record.create when the template knows the fields, recordStore when a person is filling\nthem in.\n\nExpressions:\n{ \"$\": \"\" }\nEvery computed value — a condition, a label, a number, a filtered list — is one expression string in a\n{ \"$\": … } token. This is the value layer's whole vocabulary. The node layer ($each, node-level $if,\n$routes, $animate…), queries ($query, $queries) and handlers ($action, $setLocal, $toggleLocal…) stay\nas tokens; an expression goes anywhere a VALUE goes — a prop, a condition, $each's items, a children\narray (rendered as text), a where value, an $action argument.\n\nReferences — what a name starts from:\n spaceStore.members a store member (any store in the Stores section; modules.. for a module)\n local.searchText a $localState or $queries field; dot paths read into object fields\n post.title a name bound by $each / $single / $agent through \"as\" — the default is item\n index, prev $each's row position, and the previous row\n me.did, currentDataset the current agent, and the active dataset\n surface.tier, surface.width the responsive boundary\n event, arg, result the callback argument inside a handler, and a settled $action's value\nA plain string in an expression is ALWAYS a literal: 'item.name' is five words, item.name is a read.\nA plain string in a PROP or in children is text too: \"$item.name\" renders those ten characters. A\nreference is always written { \"$\": \"item.name\" }; the validator rejects the old string spelling.\nA store's actions are unreachable — spaceStore.createPost reads as nothing; only $action calls.\n\nOperators, in JavaScript's spelling and precedence:\n == != strict equality\n < > <= >= numeric comparison\n in list membership: item.role in ['admin', 'moderator']\n ! && || boolean logic. && and || ANSWER WITH A BOOLEAN, never with an operand\n ?? the fallback-value idiom: local.name ?? 'Untitled'\n test ? a : b conditional value\n + - * / % arithmetic; + joins strings when either side is one; / by 0 is 0\n `…${expr}…` interpolation\n a.name a[i] property and index reads; a missing path is undefined, never an error\n [a, b] { key: value } list and object literals — the where-object below is one\n\nComprehensions — the one place a name is bound, over a list:\n items.filter(x, x.done) items.map(x, x.name) items.find(x, x.id == local.selected)\n items.exists(x, x.role == 'admin') items.all(x, x.read)\nOver something that is not a list: filter and map give [], find gives undefined, exists false, all true.\n\nFunctions — the library. f(a, b) and a.f(b) are the same call; a value's own methods are never callable.\nNothing is ever added to the grammar above: a new capability is a function here, or one the host\nregisters (listed last). Wrong-typed input answers with the empty value of its kind, never an error.\n Lists:\n count(items) — How many entries a list has. Anything that is not a list counts as 0. e.g. count(spaceStore.members)\n filter(items, where, limit?) — The entries matching a where-object — the same grammar $query takes. `limit` keeps the first N. Prefer the comprehension `items.filter(x, …)` when the test is not a where-object. e.g. filter(spaceStore.members, { role: 'admin' }, 5)\n find(items, where?) — The first entry matching a where-object, or undefined. Without `where`, the first entry. Read a field off the result directly: `find(…).id` is undefined when nothing matched. e.g. find(local.signalTypes, { slug: 'like' }).id\n first(items) — The first entry of a list, or undefined when it is empty. e.g. first(local.posts).title\n join(items, separator?) — The entries of a list as one string, separated by `separator` (default ', '). e.g. join(item.tags, ' · ')\n last(items) — The last entry of a list, or undefined when it is empty. e.g. last(item.messages).text\n Text:\n contains(text, needle) — Whether the text contains `needle`, ignoring case — the same test the where-object `contains` makes. e.g. contains(item.name, local.search)\n endsWith(text, suffix) — Whether the text ends with `suffix`, case-sensitively. e.g. endsWith(item.url, '.png')\n lower(text) — The text in lower case. e.g. lower(item.handle)\n plural(count, one, other) — `one` when count is exactly 1, otherwise `other`. e.g. plural(count(spaceStore.members), 'Member', 'Members')\n startsWith(text, prefix) — Whether the text starts with `prefix`, case-sensitively — for structured strings such as an ISO date or a URI. e.g. startsWith(item.startDate, '2026-08')\n trim(text) — The text without leading and trailing whitespace. e.g. trim(local.search) != ''\n upper(text) — The text in upper case. e.g. upper(item.code)\n Numbers:\n max(...values) — The largest of the numbers given. Non-numbers count as 0. e.g. max(local.page - 1, 0)\n min(...values) — The smallest of the numbers given. Non-numbers count as 0. e.g. min(count(local.rows), 20)\n round(value, digits?) — The number rounded to `digits` decimal places (default 0). Non-numbers round to 0. e.g. round(item.progress * 100)\n Objects:\n pick(object, keys) — A new object holding only the named keys of `object`. Anything that is not an object gives `{}`. e.g. pick(profileStore.ownProfile, ['handle', 'avatar'])\n Form state:\n error(field) — A field's first validation message, once it has been touched; empty otherwise. The field is named as a string. e.g. error('email')\n formValid() — Whether every validated field in the enclosing $localState scope passes. e.g. formValid()\n touched(field) — Whether the field has been blurred or marked with $touch. e.g. touched('email')\n valid(field) — Whether every validation rule on the field passes, touched or not. True for a field with no rules. e.g. valid('email')\n Host functions (this deployment registers them):\n calendarMonth(options?) — The days of a month as rows — { date, day, inMonth, isToday, weekday } — padded to whole weeks. Options: month (YYYY-MM-DD, default today), offset (months from it), weekStartsOn (0 Sunday … 6), fixedWeeks (six rows, default on). e.g. calendarMonth({ offset: local.monthOffset, weekStartsOn: 1 })\n calendarMonths(options?) — The twelve months of the year an offset lands in — { label, month, year, offset, isThisMonth, isShown } — each carrying its own offset from today, for a jump-to-month picker. e.g. calendarMonths({ offset: local.monthOffset })\n monthLabel(options?) — The month a calendar is showing, as \"August 2026\" in the viewer’s language. Same options as calendarMonth. e.g. monthLabel({ offset: local.monthOffset })\n yearLabel(options?) — The year a calendar is showing, on its own. Same options as calendarMonth. e.g. yearLabel({ offset: local.monthOffset })\n\nThe where-object — one grammar shared by filter(), find(), and $query's where. Keys are field names;\nvalues may be expressions (in an expression) or tokens (in a $query):\n\n { field: 'value' } — strict equality\n { field: ['a', 'b'] } — set membership (IN); matches any of them\n { field: { not: 'value' } } — inequality; a list excludes several values\n { field: { contains: 'text' } } — case-insensitive substring match (strings only)\n { field: { startsWith: 'text' } } — anchored prefix match, case-SENSITIVE\n { field: { endsWith: 'text' } } — anchored suffix match, case-SENSITIVE\n { field: { exists: true } } — non-null / non-undefined presence check\n { field: { exists: false } } — null or undefined check\n { OR: [ {…}, {…} ] } { AND: [ … ] } { NOT: {…} } — combinators; sibling keys are implicitly ANDed\n\nA bare list is the positive counterpart of \"not\" with a list, and the way to fetch a known set:\n{ id: ['id1', 'id2', 'id3'] }. Native on the AD4M backend, where it pushes down to a SPARQL VALUES\nclause. An empty list matches nothing, which is what \"none of these\" should mean.\n\nAn ABSENT property is the trap worth knowing, and \"not\" is where the two backends disagree.\n\nA record that never had a property written carries no value for it — on AD4M a property is a link,\nso it is simply not there. Three cases, and the middle one differs by backend:\n\n { field: 'x' } — does NOT match an absent value. Both agree.\n { field: { not: 'x' } } — MATCHES an absent value inside filter() and on the in-memory\n backend (undefined !== 'x'), and does NOT match on AD4M, where\n != over an unbound variable excludes the row, exactly as SQL's\n three-valued logic excludes NULL. A $query where written with \"not\"\n can therefore pass every test and come back empty in production.\n { field: { exists: false } } — means absent, unambiguously, on both.\n\nA declared \"default\" does not rescue this. The manifest's default is applied when a record is\nCONSTRUCTED, so anything created normally does carry it — but a field added to an entity after\nsome records already existed reads as absent on every one of them, and the query layer never\nconsults the default when filtering.\n\nSay \"absent counts as the default\" explicitly when you mean it:\n\n { OR: [ { retired: false }, { retired: { exists: false } } ] }\n\nNative on AD4M — \"exists\" and the combinators are both supported — but the OR costs this query's\nsort pushdown (see below), so pair it with a plain sort or none. Where the set is small and already\nin hand, filtering client-side with filter() sidesteps the whole question.\n\nstartsWith/endsWith are case-sensitive where contains is not: they match structured strings against\na known prefix (an ISO date, an id out of a URI). They are NOT native to the AD4M backend, so a $query\nusing one is refused — use contains there; inside filter() they are evaluated client-side.\n\nNote: OR/AND/NOT in a $query's where disables the SPARQL-level sort/pagination pushdown (see\ncount-projection and relation-property ordering below) — those orderings silently stop working in the\nsame query's where clause, because the fallback sort runs before the projection data is attached.\n\nExamples:\n{ \"$\": \"filter(spaceStore.members, { role: 'admin' })\" }\n{ \"$\": \"filter(spaceStore.members, { location: { exists: true }, handle: { contains: local.searchText } })\" }\n{ \"$\": \"filter(local.dayEvents, { startDate: { startsWith: cell.date } }, 2)\" } — the first two only\n{ \"$\": \"find(local.signalTypes, { slug: 'like' }).id\" } — undefined when nothing matches\n{ \"$\": \"count(local.rows) > 0 && local.searchText != ''\" }\n{ \"$\": \"item.author == me.did ? 'mine' : 'theirs'\" }\n{ \"$\": \"`${count(spaceStore.members)} ${plural(count(spaceStore.members), 'Member', 'Members')}`\" }\n{ \"$\": \"spaceStore.members.filter(m, m.did != me.did).map(m, m.handle).join(', ')\" }\n{ \"$\": \"post.author in spaceStore.mutedDids\" }\n\nRules:\n- An expression naming event/arg/result at the TOP LEVEL of an $action's args, or as a $setLocal\n \"value\", is evaluated when the handler fires. Nested inside another token it is evaluated at render\n time against no event and becomes a constant — the validator rejects that.\n- The validator reports every mistake with a column: an unknown name (with \"did you mean\"), an unknown\n store member, an undeclared local, an unknown function, a wrong argument count, prototype access.\n- No new value operators will be added and no new syntax. Computation the library lacks is a function\n the host registers, catalogued under \"Host functions\" above.\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"EntityName\", \"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. Use dataset to query a different dataset — required\nwhen reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": { \"$\": \"currentDataset\" } } }\n\nentity may be an expression rather than a literal name — what lets a list render records of a type\nthe template was not written for. Put the query inside an $each over a list of model names and read\nthe row:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$\": \"shapeStore.extractionTargets\" }, \"as\": \"target\" },\n \"children\": [{\n \"type\": \"Column\",\n \"$queries\": { \"found\": { \"entity\": { \"$\": \"target\" }, \"order\": { \"createdAt\": \"asc\" } } },\n \"children\": [\"…one group per model, each with its own subscription…\"]\n }]\n}\nPair it with recordStore.displays[target] to draw the rows, and the group renders a model a\ncommunity defined this morning with no template change (see \"A record of any type\").\nUSE A LITERAL WHEREVER THE TYPE IS KNOWN. The validator cannot check a name it only sees at\nruntime, so a typo fails as a silently empty list rather than as an error — and a name that has not\nresolved yet reads as \"not ready\", so the query simply waits. Note the counts of such a set cannot\nbe totalled: each group is its own subscription and a schema cannot sum a list of queries whose\nlength it does not know, so put a count inside each group rather than above them.\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\": { \"$\": \"datasetStore.currentDataset.handle\" } }.\n- me — the current agent's identity object. Use me.did for their DID (ownership checks, author filters, e.g. { \"$\": \"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 externalEntities.\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 a ternary for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": { \"$\": \"local.sortField == 'likes' ? { $likeCount: local.sortDirection } : { createdAt: local.sortDirection }\" }\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 a ternary 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 externalEntities listing:\nrelations marked \"→ EntityName\" 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 externalEntities); 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\", \"array\".\n\"array\" is a set of values — the type $toggleLocalIn writes and `in` reads. Use it for per-row state\n(which rows are open, which are selected) where the rows come from data.\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\" } — the signal value (reactive).\n { \"$\": \"local.name.nested.path\" } — dot paths read into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"value\": { \"$\": \"event.target.value\" } } — event handler that sets what the expression computes when it fires, with event in scope.\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; each field is a literal or an expression. Use for partial updates to object state.\n { \"$setLocal\": \"name\", \"value\": { \"$\": \"local.name + 20\" } } — arithmetic on the current value, for paging and counters.\nNote \"value\" is a LITERAL unless it is an expression: any other token object inside it is stored as the object, not as what it would resolve to.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nToggle one of many: { \"$toggleLocalIn\": \"fieldName\", \"value\": { \"$\": \"group.id\" } } — adds the value to an\n array-typed field, or removes it if already there. Read it back with `in`:\n { \"$\": \"group.id in local.collapsedGroups\" }.\n This is how PER-ROW state works when the rows come from data. $localState field names are fixed\n when the template is written, so \"expanded?\" cannot be a boolean per row for rows that come from a\n $query or a store — there is no name to give them. Hold the ids instead:\n \"$localState\": { \"collapsedGroups\": { \"type\": \"array\", \"initial\": [] } }\n A fixed, known-in-advance set of sections is still better served by one boolean each.\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.\nLocal 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 conditional initials):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$\": \"local.location.latitude\" }, { \"$\": \"local.location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"value\": { \"$\": \"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 a conditional initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results under local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available to conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into local as read-only reactive arrays, read as { \"$\": \"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 result set (or\nerror) arrives, then true for good. Gate a loading skeleton on it so the empty state only ever\nasserts \"loaded and empty\", never \"not answered yet\":\n{ \"type\": \"$if\", \"props\": { \"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).\nA $query cannot be read inside an expression — a question for the backend is hoisted here and read\nback through local. Use count() for conditional visibility:\n{ \"condition\": { \"$\": \"count(local.signalTypes)\" } }\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 functions (in an expression):\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()\" } — 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()\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. { \"$if\": { \"condition\", \"then\", \"else\" } } in a handler position runs one side or the\nother when the event fires — its condition may read event. It is the one place $if is a token rather than a node.\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\", \"value\": { \"$\": \"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()\" }, \"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 { \"$\": \"!formValid()\" }.\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\": { \"$\": \"!formValid()\" }, 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\": { \"$\": \"!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\": { \"$\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a name expressions read — { \"$\": \"itemName.title\" }. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nEach row also gets two names describing its position in the list:\n- index — the 0-based position.\n- prev — the previous item, absent on the first row. Read fields off it like any name: { \"$\": \"prev.author\" }.\n\nprev is what makes **grouping** expressible — collapsing consecutive rows by the same author so\na run of messages shows one avatar and byline instead of repeating them. Without it a row can only\nask about itself, and the compact form is unreachable by any prop or theme:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"message.author == prev.author\" },\n \"then\": { \"...\": \"compact row — no avatar, no byline\" },\n \"else\": { \"...\": \"full row\" }\n }\n}\nThe first row has no prev at all, so the condition is false there and it keeps its byline —\nwhich is what a feed wants, and why absent must not read as \"same as the last item\".\n\nBoth shadow in a nested $each, exactly as the item does: the inner index restarts at 0.\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'|'reveal'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string, axis?: 'block'|'inline' }\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\nreveal — opening and closing in place:\nreveal is the size axis the others lack: it eases the element open to the size its content actually\nwants, and closed again. Use it for anything that opens in place — a disclosure, an accordion\nsection, a \"show more\", a sidebar label appearing as the rail expands. axis: 'block' (the default)\nopens downward; axis: 'inline' opens sideways.\nExample (disclosure): enterTransition: [{ \"type\": \"reveal\", \"duration\": 300 }, { \"type\": \"fade\", \"duration\": 180 }]\nExample (label beside an icon): enterTransition: { \"type\": \"reveal\", \"axis\": \"inline\", \"duration\": 250 }\nDo NOT hand-roll this with maxHeight and a transition string. A guessed maxHeight applies the easing\ncurve to the guess rather than to the real height, so most of the duration is spent crossing space\nthat isn't there, and it breaks silently the day the content grows past the guess.\nreveal composes with fade exactly as slide does. Pair them: opening a box that is fully opaque from\nthe first frame reads as a jump, however smooth the size change is.\nA reveal in an exitTransition also decides when the node unmounts — the node stays mounted for the\nlongest effect in the config, so the collapse finishes before the content is removed.\n\nViewport / mount / condition animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"condition\"?: SchemaProp, \"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); reveal animates the element's own size; 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.\nFor an open/close that must NOT destroy what is inside it — a section holding a scroll position, a\nhalf-typed field, or a live subscription a collapsed row shouldn't tear down — use $animate with\ncondition rather than $if, which unmounts the content when closed.\ncondition works like $if's condition (an expression), except the child\nis never unmounted: only enterTransition/exitTransition replay as it changes. The initial render\nalready matches whatever the condition is at mount — a node that starts open does not flash\nclosed-then-open, and one that starts closed does not briefly show its content first.\nWhen exitTransition is omitted, closing reuses enterTransition (mirrored), the same fallback $if uses.\nA reveal clips a closed section to zero size, so it is already unreachable; without one (a plain fade),\nthe wrapper's pointer-events follow the condition too, so a fully transparent closed section can't be\nclicked through.\ncondition is mutually exclusive with the scroll triggers below and, when present, takes over as the\nsole trigger — scrollReveal/scrollLeave/scrollPast are ignored on that node.\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\" watches a sentinel element (by DOM id) go past the $animate element itself.\n enterTransition fires once the sentinel has scrolled above the $animate element's top edge (or out of\n the viewport) — so a sentinel sliding under a sticky bar counts as gone the moment it does.\n exitTransition fires when the sentinel comes back below it (user scrolled back up).\n The sentinel may mount later than the $animate node; it is picked up when it appears.\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 condition or a scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (condition-driven disclosure — a collapsible group whose rows hold live store bindings and\nmust not resubscribe every time it opens):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"condition\": { \"$\": \"!local.sectionCollapsed\" },\n \"enterTransition\": { \"type\": \"reveal\", \"duration\": 250 }\n },\n \"children\": [{ \"type\": \"$each\", \"props\": { \"items\": { \"$\": \"listStore.items\" }, \"as\": \"item\" },\n \"children\": [{ \"type\": \"we-text\", \"children\": [{ \"$\": \"item.name\" }] }] }]\n}\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\": \"EntityName\", \"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\nResponsive boundary:\n{ \"type\": \"$surface\", \"props\": { \"as\": \"pane\" }, \"children\": [ ... ] }\nA box the content inside it measures itself against. Everything inside it — `*UpProps` on any\ndescendant, and `surface.tier` read in an expression — is answered by THIS box rather than by the\nwindow or the page.\n\nThe host already puts one wherever it mounts a schema tree (the template area, the shell overlays,\nevery docked module panel), so an ordinary template needs none: `mdUpProps` works out of the box.\nDeclare one when a *part* of your layout should adapt to itself — a two-pane workspace whose right\npane is narrow while the page is wide. Nesting works; the innermost surface wins.\n\n`as` names the context key (default `surface`), so a nested one can be addressed separately.\nRead it with `surface.tier` (`base` | `sm` | `md` | `lg`) or `surface.width` (px):\n\n{ \"type\": \"$if\", \"props\": { \"condition\": { \"$\": \"surface.tier == 'base'\" }, \"then\": , \"else\": } }\n\nUSE THIS SPARINGLY, and only for a genuinely different tree. `$if` unmounts and rebuilds its\nsubtree when the condition changes, which loses scroll position, half-typed input and any live\nresource inside it. For different *values* — padding, gap, width, font size — use `*UpProps`, which\nis pure CSS and remounts nothing. See \"Which mechanism to reach for\" in the Design System Props\nsection.\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', appearance: 'soft' | 'accent' = 'soft', 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, stream?: MediaStream | null | undefined\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', 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', appearance: 'soft' | 'solid' = 'soft', 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, label: string = '', 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 = '', label: 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', ], tokens: boolean = false, alpha: boolean = false\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', showTime: boolean = false, placeholder: string = 'Select date', disabled: boolean = false, name: string = '', label: 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-draggable (LayoutElement) — Makes whatever is inside it something that can be picked up and carried somewhere else.\n\n#### Why this exists as a primitive\n\nA post card, a member row and a space in the sidebar are rendered by **templates**, which are\ndata. If making one draggable were a code change, every future draggable surface would be a code\nchange too, and the contribution ladder says arrangement stays data. This is the same rung\n`we-sortable` occupies: two custom elements and an existing `$action`, with no new prop resolver,\nno new operator, and nothing added to the expression grammar.\n\n```json\n{ \"type\": \"we-draggable\",\n \"props\": { \"entity\": \"CollectionBlock\", \"recordId\": { \"$\": \"post.id\" }, \"label\": { \"$\": \"post.title\" } },\n \"children\": [ \"…the card…\" ] }\n```\n\n#### What it carries\n\nA **reference** — `{ dataset?, entity, id }` — never DOM, and never the row object. `dataset` is\ndeliberately left empty here: a card fragment cannot name its own dataset without reading a\nstore, and portable fragments name no store by construction. The receiver stamps it, from\nwhichever dataset was current when the drop happened.\n\n#### `display: contents`\n\nThe wrapper must not exist as a box. A card inside a grid track, a row inside a flex column: a\nreal element in between would take the track and leave the card laid out against the wrapper\ninstead of the grid. What is dragged is therefore the *child*, which is also what the ghost and\nthe geometry are measured from.\n Props: entity: string = '', recordId: string = '', datasetKey: string = '', label: string = '', icon: string = '', preview?: { thumbnail?: string; content?: string; author?: string; date?: string } | undefined, origin?: unknown | undefined, effect: 'move' | 'copy' | 'link' = 'copy', disabled: boolean = false\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, label: string = '', close: () => void\n- we-drop-zone (LayoutElement) — Anything a `we-draggable` can be dropped into.\n\nThe receiving half of the pair, and the same rung: two custom elements and an existing `$action`,\nso a template can make a region a drop target without a code change.\n\n```json\n{ \"type\": \"we-drop-zone\",\n \"props\": { \"accepts\": \"CollectionBlock,Space,Agent\",\n \"onDropped\": { \"$action\": \"modules.pocket.gather\", \"args\": [{ \"$\": \"event.detail\" }] } },\n \"children\": [ \"…the panel…\" ] }\n```\n\n#### It emits intent, it never mutates\n\nExactly `we-sortable`'s rule, and for the same reason: what a drop *means* differs. A panel writes\na record, a composer inserts a block, a board records a position. A primitive that assumed one of\nthose would be useless to the others.\n\n#### `accepts` is a list of entity names, as a string\n\nA comma-separated string rather than an array because that is what an HTML attribute is, and\nbecause a schema writing `\"accepts\": \"CollectionBlock,Space\"` needs no expression. Empty means\n\"anything\", which is right for a general-purpose tray and wrong for a composer — say what you\ntake.\n\n#### Zones nest, and the innermost one wins\n\nA folder inside a panel, a card inside a board. Hit-testing picks the innermost accepting zone\nand fires exactly one drop — and these events **do not bubble**, so that decision survives the\nDOM. See `_zone` for what happened when they did.\n\nGive every nested zone `noArm`, so picking something up speaks once about the container rather\nthan once about every row inside it.\n Props: accepts: string = '', disabled: boolean = false, noArm: boolean = false, noSelf: boolean = false\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\n#### SVG animation: CSS keyframes, not SMIL\n\nInline SVG passes through, and so does animation written as CSS — a `