diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index 8f96bf2df..d9c2a9861 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -2317,7 +2317,7 @@ EditorStore: - isOpen: boolean — the AI chat panel is open - isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far - streamingContent: string — the partial assistant reply while isStreaming, empty otherwise - - apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it + - apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it - templateName: string — the name of the template being edited, for the editor’s own header - templateIcon: string — its icon - isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8f96bf2df..d9c2a9861 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2317,7 +2317,7 @@ EditorStore: - isOpen: boolean — the AI chat panel is open - isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far - streamingContent: string — the partial assistant reply while isStreaming, empty otherwise - - apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it + - apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it - templateName: string — the name of the template being edited, for the editor’s own header - templateIcon: string — its icon - isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row diff --git a/AGENTS.md b/AGENTS.md index 8f96bf2df..d9c2a9861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2317,7 +2317,7 @@ EditorStore: - isOpen: boolean — the AI chat panel is open - isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far - streamingContent: string — the partial assistant reply while isStreaming, empty otherwise - - apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it + - apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it - templateName: string — the name of the template being edited, for the editor’s own header - templateIcon: string — its icon - isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row diff --git a/CLAUDE.md b/CLAUDE.md index 8f96bf2df..d9c2a9861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2317,7 +2317,7 @@ EditorStore: - isOpen: boolean — the AI chat panel is open - isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far - streamingContent: string — the partial assistant reply while isStreaming, empty otherwise - - apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it + - apiKeyConfigured: boolean — the active provider has enough configuration to send requests. Gate the composer on it - templateName: string — the name of the template being edited, for the editor’s own header - templateIcon: string — its icon - isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row diff --git a/packages/ai-context/src/fragments/stores.ts b/packages/ai-context/src/fragments/stores.ts index d65b6f96d..3e3d1c67a 100644 --- a/packages/ai-context/src/fragments/stores.ts +++ b/packages/ai-context/src/fragments/stores.ts @@ -1237,7 +1237,15 @@ export function generateStoresText(entries: StoreEntry[]): string { isStreaming: 'boolean — an assistant reply is arriving; streamingContent holds what has arrived so far', streamingContent: 'string — the partial assistant reply while isStreaming, empty otherwise', apiKeyConfigured: - 'boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it', + 'boolean — the active provider has enough configuration to send requests. Gate the composer on it', + providers: + 'AiProvider[] — all configured AI providers (id, name, baseUrl, apiKey, model, protocol, isBuiltIn). Persisted to localStorage', + activeProvider: + 'AiProvider | undefined — the currently selected provider, derived from providers + activeProviderId', + activeProviderId: "string — id of the selected provider ('anthropic' by default)", + healthStatus: 'string (‘unknown’ | ‘checking’ | ‘ok’ | ‘error’) — reachability of the active provider', + healthError: 'string — human-readable error when healthStatus = error, empty otherwise', + availableModels: 'string[] — model IDs returned by the active provider during health check', templateName: 'string — the name of the template being edited, for the editor’s own header', templateIcon: 'string — its icon', isReadOnly: @@ -1274,6 +1282,14 @@ export function generateStoresText(entries: StoreEntry[]): string { redo: '(): redoes the last undone schema edit', open: '(): opens the AI chat panel', close: '(): closes it', + setActiveProvider: '(id: string): switches the active AI provider by id', + updateProvider: + '(id: string, changes: Partial): updates fields on an existing provider (e.g. apiKey, model, baseUrl)', + addProvider: '(provider: Omit): adds a custom (non-built-in) provider', + removeProvider: + '(id: string): removes a custom provider; built-in providers cannot be removed. Falls back to anthropic if the removed provider was active', + checkHealth: + '(): probes the active provider to verify reachability, auth, and model availability without spending tokens', newChat: '(): starts a new AI session for this template and switches to it', switchSession: '(sessionId: string): shows another saved session', deleteSession: '(sessionId: string): deletes a saved session and its messages', diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index 97b8f585c..ce1166cc5 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 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 = '', label: 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 = '', label: 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: size: 'sm' | 'md' | 'lg' | 'fullscreen' = 'md', hideclosebutton: boolean = false, close: () => void\n- we-move-handle (LayoutElement)\n Props: step: number = 24, dragging: boolean = false, label: string = 'Move'\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 = '', label: string = '', placeholder: 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 = '', label: 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', line: 'auto' | 'none' = 'auto', step: number = 16, dragging: boolean = false\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = '', pin: '' | 'end' = ''\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, fit: boolean = false, name: string = '', label: 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 = '', label: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — A drop zone whose items can be picked up, reordered, and moved to other zones.\n\n#### One element, not two\n\nA zone *is* the container and its children *are* the items, which is what makes nesting free: a\nsortable inside an item of another sortable is simply a zone inside a zone, with no special case\nanywhere. A separate `we-drag-item` would buy nothing and cost boilerplate at every call site.\n\n#### It emits intent, it never mutates\n\nA drop fires SortableMoveDetail — \"this item moved from there to here, at this index\" —\nand nothing else. What that *means* is the consumer's business, and it differs: a kanban route\nkeyed on `status` writes a scalar, a board built from containment relinks two `children` edges, an\noutline reparents a node. A primitive that assumed one of those would be useless to the others.\n\nThis is also why the element does not reorder its own DOM. The list is rendered from data; the\ndata changes; the list re-renders. A primitive that moved nodes itself would fight whatever\nrenders them.\n\n#### Nesting, and why cycles are not a problem\n\nThe hard part of nested drag-and-drop is refusing to drop a container into its own descendant.\nBecause nesting here is expressed *in the DOM*, that check is `dragged.contains(zone)` — correct\nby construction, needing no knowledge of the consumer's data shape. The innermost matching zone\nunder the pointer wins, so dropping into a nested list does not also count as dropping into its\nparent.\n\n#### Keyboard\n\nSpace or Enter picks up the focused item; the arrow keys move it, along the list and across\nzones; Space drops and Escape cancels. Built in rather than added later, because a board that can\nonly be operated by dragging is a board some people cannot operate at all — and because the\nevents are identical, a consumer gets it for nothing.\n\n#### Items that contain form controls: `[data-we-handle]`\n\nBy default the whole item is the grab area, which is right for a card or a nav row. It is wrong\nthe moment an item contains a text field: dragging to select text would start a drag, and — worse\n— the keyboard pickup would read a **space typed into an input** as \"pick this up\", so the field\ncould not accept spaces at all.\n\nSo two rules, both no-ops for an item without form controls:\n\n- Mark one or more descendants `data-we-handle`, and only a press that begins inside a handle\n starts a drag. An item with no handle keeps dragging from anywhere, so existing consumers are\n unaffected.\n- A Space or Enter that originates in a text-entry element (`input`, `textarea`, `select`,\n `contenteditable`, including inside a component's shadow root) is typing, never a pickup. This\n applies whether or not the item declares handles, because an unfocusable-by-design input that\n swallows spaces is a bug in every consumer that could hit it.\n\nMake the handle itself focusable (a `we-button` will do) so the keyboard path stays open: Space\non a focused handle picks the row up exactly as it does on a plain item.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = '', zone: string = '', group: string = '', locked: boolean = false\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 = '', label: 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 = '', label: 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?: EditorStateInput, perspective?: unknown, onSave?: ((document: ContentDocument) => void), onReady?: ((api: { save: () => void; }) => void), onDirtyChange?: ((dirty: boolean) => void), mentions?: MentionCandidate[], collaborate?: string\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: EditorStateInput, perspective?: unknown, 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, content?: ContentBlock[]\n- CollectionInput\n Props: layout?: string, columnCount?: number, gap?: string, 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, label?: string, thumbnail?: string, onOpenRef?: ((ref: string) => void)\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: value?: string, events?: CalendarEvent[], onSelect?: ((date: string) => void), styles?: Record\n- Card (DesignSystemElement)\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), maxHeight?: string, 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, onSelect?: ((item: DropdownMenuAction) => void), placement?: Placement, triggerLabel?: string, triggerIcon?: string, triggerVariant?: \"primary\" | \"danger\" | \"secondary\" | \"ghost\" | \"outline\" | \"bare\", triggerTitle?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", itemSize?: \"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, rows?: string, childAspect?: string | number, onMeasure?: ((box: { width: number; height: number; }) => void), onArrange?: ((tiling: Tiling) => void)\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- 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, revision?: string | number | boolean, live?: boolean, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], reified?: Record, width?: string, height?: string, bg?: string, showStatus?: boolean, empty?: string, emptyIcon?: string, showControls?: boolean, controls?: string[], onNodeClick?: ((node: GraphNode & { recordId?: string; recordType?: string; fields: { name: string; value: string; }[]; }) => void), expandRequest?: { id: string; expanders?: string[]; direction?: \"in\" | \"out\" | \"both\"; } | null, onNodeDoubleClick?: ((node: GraphNode & { recordId?: string; recordType?: string; }) => void), onEdgeClick?: ((edge: GraphEdge & { recordId?: string; recordType?: string; }) => void), onEdgeCreate?: ((payload: { source: GraphNode; target: GraphNode; sourceId: string; sourceType: string; targetId: string; targetType: string; sourceLabel: string; targetLabel: string; }) => void), onCanvasDoubleClick?: ((payload: { x: number; y: number; }) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; recordId?: string; recordType?: string; }) => void), onNodeResize?: ((payload: { id: string; x: number; y: number; width: number; height: number; recordId?: string; recordType?: string; }) => void), nodeActions?: NodeAction[], onNodeAction?: ((payload: { action: string; id: string; recordId?: string; recordType?: string; }) => 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- `board` — A container's contents at the positions somebody put them. Membership is ordinary containment, so a card composed onto the board is found like any child; position comes from Placement records parented to the same board, which is why the same note can sit on two boards in two places. Pair with layout: manual and drag-node { pin: true }, and persist a drop through recordStore.placeOnBoard. Loads nothing until a board is chosen.\n - board: string — Record id of the board (required).\n - contains: string[] — Types the board may hold beyond whatever its placements name — one query each. Defaults to the block vocabulary; anything *placed* is loaded whether or not it is listed.\n - via: string — Relation holding the contents. Defaults to \"children\".\n - connections: string — Reified relation entity to draw as lines between the cards — e.g. \"Relationship\". Only pairs whose two ends are both on the board are drawn, since a line to something elsewhere would leave the canvas. Each line carries the record it stands for, so clicking one can open it. Omit for a board with no connections.\n - typeStyles: string — Entity holding this board's colour per kind of thing — WE passes \"TypeStyle\". Read onto every node as `boardTypeColor`, for a style rule to pick up with `{ from: \"data.boardTypeColor\" }`. This is what a board's key writes.\n - pending: string[] — Record ids whose card stands for a suggestion nobody has agreed to yet — an extraction pass can stage a whole record, so it is on the board and answers every query the accepted ones do. Read onto the matching node as `data.pending`, for a style rule or a node action to pick up with `{ when: { \"data.pending\": true } }` — the `data.` prefix is required, since a bare key reads a node field rather than seeded data, and matches nothing here. Ids rather than a query because only the capability that staged them knows which they are.\n - limit: number — Rows per type. Default 200.\n - Example: `{ \"source\": \"board\", \"options\": { \"board\": { \"$\": \"local.boardId\" } } }`\n- `dataset` — Seeds a single node for the current space — the starting point for exploring outward.\n - label: string — What the node is called. Defaults to the space name.\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\" — Which way the tree grows from its roots. Default \"down\".\n - levelGap: number — Distance between one rank and the next.\n - siblingGap: number — Distance between neighbours on the same rank.\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 — Distance between one ring and the next.\n - Example: `{ \"type\": \"radial\" }`\n- `grid` — Uniform grid, optionally ordered by a node data field. Honest default when edges say little.\n - columns: number — How many columns. Derived from the node count when omitted.\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- `content` — Node style, cards only. Names a host-supplied component to draw INSIDE the card instead of a text label — WE registers `block`, which renders a CollectionBlock's composed content the way a post card does. A label can only ever be the first line, so a card holding an image and three paragraphs shows sixty characters and gives no sign the rest exists. Clipped, not scrolled: a card is a preview, and what does not fit is reached by opening it. Falls back to the label when the host supplies no component by that name.\n - Example: `\"nodeStyle\": [{ \"style\": { \"shape\": \"card\", \"width\": 180, \"content\": \"block\" } }]`\n- `contentMinZoom` — Node style. Hides card content below this zoom and falls back to the label. The sibling of labelMinZoom, and the thing that decides whether rich cards scale: a hundred documents rendered at once is a hundred component trees, and at the zoom where a board reads as coloured rectangles none of them is legible anyway.\n - Example: `\"nodeStyle\": [{ \"style\": { \"shape\": \"card\", \"content\": \"block\", \"contentMinZoom\": 0.5 } }]`\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 claims a press on empty canvas, and dispatch stops at the first behaviour that claims — so anything after it never sees a background press. Listed before `select`, clicking empty canvas silently stops clearing the selection.\n - Example: `\"behaviours\": [\"select\", \"expand-on-double-click\", \"pan-zoom\"]`\n- `select` — Click to select, shift-click to extend, background to clear. Emits onNodeClick, and onSelectionChange with an empty list when a background click clears it. Must be listed BEFORE pan-zoom, which claims the background press it needs to see.\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- `connect-nodes` — Drag from one node to another to connect them, emitting onEdgeCreate with both ends. Writes nothing — what a connection means is the template's decision, so it answers by creating whatever record it thinks the connection is. List it BEFORE drag-node: both claim a press on a node and the first wins. Arm it from a control the user can see rather than a modifier key, which is undiscoverable and absent on a touchscreen.\n - armed: boolean — Whether the gesture is live. Default true. Disarmed, the press falls through to drag-node.\n - Example: `\"behaviours\": [{ \"type\": \"connect-nodes\", \"options\": { \"armed\": { \"$\": \"local.connecting\" } } }, \"select\", { \"type\": \"drag-node\" }, \"pan-zoom\"]`\n- `node-double-click` — Double-click a node to emit onNodeDoubleClick, with the record it stands for resolved onto the payload. Writes nothing — what opening a node means is the template's decision. Pairs with canvas-double-click, which handles the same gesture on empty canvas; list both and exactly one fires. Do NOT list it alongside expand-on-double-click, which claims the same gesture to do something else.\n - Example: `\"behaviours\": [\"node-double-click\", \"canvas-double-click\", \"pan-zoom\", \"select\"]`\n- `canvas-double-click` — Double-click empty canvas to emit onCanvasDoubleClick with the world point. Writes nothing — what gets made there is the template's decision. List it BEFORE pan-zoom, which is the background fallback. Claims only the background, so it composes with expand-on-double-click: a double-click on a node opens it, one beside a node creates.\n - Example: `\"behaviours\": [\"canvas-double-click\", \"pan-zoom\", \"select\", { \"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\" — Which way relations are followed when the node opens. Default \"both\".\n - Example: `{ \"type\": \"expand-on-double-click\", \"options\": { \"direction\": \"out\" } }`\n- `expand-on-click` — Single click expands — for maps meant purely for exploring, where selection is not needed.\n - direction: \"in\" | \"out\" | \"both\" — Which way relations are followed when the node opens. Default \"both\".\n - Example: `{ \"type\": \"expand-on-click\", \"options\": { \"direction\": \"out\" } }`\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 | A **role** — see the table below — or a scale position \"{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). **Prefer a role.** |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length). Also five *theme-family* names that follow the theme instead of naming a size — see \"Theme families\" below. Prefer them on an `EditableImage`, a `Card`, or a raw element standing in for one: a pinned \"full\" or \"pill\" cannot follow a theme's shape settings. Note \"full\" is 50%, so it is an ellipse on any box that is not square; reach for \"pill\" on wide boxes. |\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### Theme families — for `r`, `p` and `gap`, the counterpart of a colour role\n\nA colour role says what a colour is *for*. A **family** says what kind of thing a box *is*, so the\ntheme can decide its shape and density: buttons are rounded like this, sheets like that. Naming one\nis how a box follows a theme's `surfaceRadius` or `surfacePadding` instead of pinning a number.\n\n| Name | `r` | `p` | `gap` | For |\n|---|---|---|---|---|\n| `control` | ✓ | | ✓ | Buttons, badges, tags — anything pressed. |\n| `surface` | ✓ | ✓ | ✓ | Cards, modals, sheets — **and anything inset inside one**. |\n| `input` | ✓ | | | Fields, selects, pickers. |\n| `avatar` | ✓ | | | Anything square that reads as a profile picture. |\n| `media` | ✓ | | | A **full-bleed** banner, video or embed spanning an edge. |\n\n`surface` and `media` read the same theme variable and differ only in what they fall back to when a\ntheme sets nothing: `surface` is rounded like a card, `media` is **square**. Pick by whether the box\nis inset in something rounded or spans the edge — a cover image inside a modal is `surface`, the\nsame image as a page-width header is `media`.\n\n```json\n{ \"type\": \"Column\", \"props\": { \"bg\": \"surface\", \"r\": \"surface\", \"p\": \"surface\", \"gap\": \"surface\" } }\n```\n\n**The blanks are constraints, not gaps.** A family only takes `p` when its theme value is a single\nlength: `control`'s padding is horizontal-only (the vertical comes from the control's height, per\nsize) and `input`'s is a full shorthand, and padding is assembled as four values in one declaration,\nso either would produce an invalid rule.\n\n**Only these props.** A family is meaningless on a margin or an offset — it says how much room a box\nputs *inside* itself, which answers nothing about the space between it and its neighbour. `m:\n\"surface\"` resolves to nothing and warns.\n\n### Semantic Colour Roles — reach for these before a scale position\n\nA scale position says *which grey*. A role says *what the colour is for*, and that is what a theme\ncan redesign. Some relationships invert between light and dark — a raised surface gets **lighter**\nin dark rather than casting a shadow — and a scale position cannot express that, because the whole\nscale flips together. Templates written with roles restyle correctly under any theme; templates\nwritten with `neutral-100` are frozen into one theme's idea of what that grey meant.\n\nTwo naming conventions run through the table. A bare noun is a **fill or a foreground in its own\nright** (`surface`, `accent`, `text`). `on` is a foreground that sits **on** a\nspecific fill and exists to contrast with it (`on-accent`, `on-inverse`) — so `accent-text`\nis the accent *used as* text, and `on-accent` is the text *placed on* the accent. They are\ndifferent colours and the prefix is what tells you which you want.\n\n**Use a role for every `bg`, `color` and border colour.** Reach for a scale position only when the\ncolour is a *palette* rather than a meaning — a graph's node colours by category, a chart series,\na user-chosen swatch.\n\n| Role | Use for |\n|---|---|\n| `page` | The app/route background behind everything. Set it on a template's root node. |\n| `surface` | A card, panel or sheet sitting on the page. |\n| `surface-raised` | Something floating above the page — a popover, a floating bar, a docked rail with a shadow. |\n| `surface-sunken` | A well recessed into a surface — an inset box, a code block, an input trough. |\n| `surface-hover` / `surface-active` | Row and item feedback — something sitting **on** a surface. Use inside `hoverProps` / `activeProps`. |\n| `surface-sunken-hover` | A **well** lifted — an input, a textarea, a picker trigger. Hovering one with `surface-hover` lands it at about surface level and it stops looking recessed, so use this wherever the resting fill is `surface-sunken`. One state, not a hover/pressed pair: a field is clicked *into* rather than pushed, so hover, press and focus all resolve here and the ring is what says \"focused\". |\n| `control-surface` | The filled neutral of a *control* — a slider or switch track, a progress trough, a scrollbar thumb, a secondary button, a count chip. Not a surface and not a state. |\n| `text` | Primary body and heading text. |\n| `text-muted` | Secondary text — captions, labels, metadata. |\n| `text-faint` | Tertiary text — placeholders, disabled labels, decorative icons. |\n| `surface-inverse` | A surface deliberately opposite to the page — a tooltip. Holds a fixed lightness, so it does *not* flip with the theme. |\n| `on-inverse` | Text or an icon **on top of** `surface-inverse` — a tooltip's own text. **Not** for text on the accent, which is `on-accent`. |\n| `border` | Default borders and dividers. |\n| `border-strong` | Emphasised separation — two regions that are genuinely apart. Not a hover state. |\n| `border-hover` | The edge of an interactive box under the pointer. Sits between `border` and `border-strong`, which are three ramp steps apart; borrowing the latter for hover makes an outline jump rather than acknowledge. |\n| `accent` | An accent *fill* — a primary button, a selected disc. |\n| `accent-hover` / `accent-active` | Hover and pressed states of an accent fill. |\n| `on-accent` | Text or an icon **on top of** an accent fill. |\n| `on-accent-muted` | Secondary text on an accent fill — a caption under a heading on an accent panel, or on `gradient-primary`. The `text-muted` of fills, and the **only** correct choice there: `text-muted` and `text-faint` are measured against the *page*, so on a fill they are measured against the wrong thing and can vanish entirely. |\n| `accent-text` | The accent used **as text** — an accented heading or icon on an ordinary surface, where `accent` is often too light to read. |\n| `accent-muted` | An accent-tinted fill — a selected row, a subtle highlight. |\n| `focus` | The focus ring. Rarely set directly; `--we-ring-color` already resolves to it. |\n| `danger-text` / `success-text` / `warning-text` | Status as a **foreground** — an error message, a warning icon, a \"connected\" tick. |\n| `danger-surface` / `success-surface` / `warning-surface` | The tinted **panel** behind status content. |\n| `overlay` | The scrim behind a modal or drawer. Carries its own alpha. |\n| `shadow-color` | The colour shadows are built from. |\n\n```json\n{ \"type\": \"Column\", \"props\": { \"bg\": \"surface\", \"border\": \"1px solid border\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-md\", \"color\": \"text\" }, \"children\": [\"Title\"] },\n { \"type\": \"we-text\", \"props\": { \"color\": \"text-muted\" }, \"children\": [\"Supporting line\"] }\n]}\n```\n\nRoles work anywhere a colour token does, including inside a border shorthand\n(`\"1px solid border\"`) and behind a ternary\n(`{ \"$\": \"row.selected ? 'accent-muted' : 'surface-sunken'\" }`).\n\n**Not `$if` in a prop.** `$if` is a *node* type and, in a value position, resolves to a handler —\nso the colour resolver is handed a function, paints nothing, and warns about nothing. The validator\ndoes not catch it either. A condition that chooses a value is a ternary, which is what the\nexpression language has one for.\n\n**Always kebab-case: `\"surface-sunken\"`, never `\"surfaceSunken\"`.** The camelCase spelling is the\nTypeScript key of a `ThemeRole`; a schema writes the CSS spelling. Getting it wrong fails silently —\nthe value resolves to a variable that does not exist and the element paints nothing at all — so the\nvalidator rejects it with the right spelling rather than letting it through.\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| flexShrink | number \\| string | `flex-shrink` alone, for the common \"just don't let it shrink\" case (`0`) without committing to a grow and a basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" \\| \"overlay\" | Overflow behavior, both axes |\n| overflowX | \"hidden\" \\| \"auto\" \\| \"overlay\" | Horizontal overflow alone — a nav strip or tab bar that scrolls sideways instead of pushing the page wide |\n| overflowY | \"hidden\" \\| \"auto\" \\| \"overlay\" | Vertical overflow alone |\n| scrollbarWidth | \"auto\" \\| \"thin\" \\| \"none\" | How much room the scrollbar takes. `none` for a strip in fixed-height chrome, where a gutter would not fit. **Use `none` or leave it unset — never `thin` or `auto`:** Chromium reads this property as \"use the platform scrollbar\" and drops the app's own styling for that element, so it becomes the one scroll region that does not match the rest (a different colour, square corners, and stepper arrows on Linux). `none` is safe because a hidden bar has nothing to style. |\n| scrollbarGutter | \"auto\" \\| \"stable\" \\| \"stable both-edges\" | Reserve the gutter whether or not it scrolls, so content does not shift when a scrollbar appears |\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**A row that overflows is a row where nobody said who gives up space.** Inside a `Row`, a child's\n`maxWidth` is not a promise: a flex item's automatic minimum size is its *content*, so an item whose\ncontent cannot narrow — a strip of `we-button`s, which set `white-space: nowrap` — refuses every\nrequest to compress. Flexbox then takes the whole deficit out of whichever sibling *can* shrink\n(usually a run of text, which folds onto two lines) and pushes the rest past the container. Where a\ntemplate is mounted in a scrolling box, that reads as the entire page sliding sideways.\n\nSay who does what, and the row cannot overflow:\n\n```json\n{ \"type\": \"Row\", \"props\": { \"ay\": \"center\" }, \"children\": [\n { \"type\": \"Row\", \"props\": { \"flex\": \"1 1 auto\", \"minWidth\": \"0\", \"overflowX\": \"auto\", \"scrollbarWidth\": \"none\" },\n \"children\": [\"…the strip that gives up space and scrolls instead…\"] },\n { \"type\": \"Row\", \"props\": { \"flex\": \"0 0 auto\" },\n \"children\": [\"…the ornament that never absorbs somebody else's overflow…\"] }\n]}\n```\n\n`minWidth: '0'` is the half that gets forgotten. Without it `overflowX` has nothing to do, because\nthe item is never asked to be narrower than its content in the first place.\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. Durations may be animation tokens (`'0'`–`'500'`): `'width 300 ease-in-out'`. Prefer the token — a theme's animationSpeed preset overrides those, so `300` respects a reduced-motion setting where `300ms` overrides it. Use for a property whose *value* changes in place (a width bound to a local); for something appearing and disappearing use `$if`/`$animate` transitions instead |\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| whiteSpace | \"normal\" \\| \"nowrap\" \\| \"pre\" \\| \"pre-wrap\" \\| \"pre-line\" \\| \"break-spaces\" | How whitespace and line breaks in the source text are treated |\n| overflowWrap | \"normal\" \\| \"break-word\" \\| \"anywhere\" | Where a line may break inside a word too long to fit. **Defaults to `anywhere`** — see below |\n\n**Text that cannot break is text that breaks the page.** `overflowWrap` defaults to `anywhere` on\nevery typography component and on `Column`/`Row`/`Grid`/`Card`, so a URL, a DID, or a transcriber's\nrun-together output wraps instead of stretching its card off the screen. **Do not set it, and do not\nreach for `styles: { 'word-break': ... }` — that is the patch this default replaced.**\n\nSet `overflowWrap: 'normal'` only to deliberately opt a box *out* of breaking. Note `'break-word'` is\nthe value that looks right and is not: it breaks in the same places as `anywhere` but does not\nreduce the element's min-content width, and a flex item and a `1fr` grid track are both sized by\nmin-content — so under it the long string still pushes its container wider than the viewport.\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### Responsive — adapting to the space available\n\n| Prop | Type | Description |\n|------|------|-------------|\n| smUpProps | Partial\\ | Values that take over from 640px up |\n| mdUpProps | Partial\\ | …from 900px up |\n| lgUpProps | Partial\\ | …from 1200px up |\n\nA partial prop bag applying above a width, exactly like `hoverProps` applies in a state:\n\n```json\n{ \"type\": \"Column\", \"props\": { \"gap\": \"300\", \"px\": \"300\", \"mdUpProps\": { \"gap\": \"500\", \"px\": \"400\" } } }\n```\n\n**Measured against the nearest surface, not the window.** A template renders inside a docked panel,\nan editor preview pane and a phone, so the viewport is the wrong subject in two of those. The host\ndeclares a surface wherever it mounts a schema tree; a template can declare its own with `$surface`\n(see Block-level Dynamic Structures) when a pane should adapt to itself rather than to the page.\n\n**Write the narrow value at base and grow.** Every tier is min-width — there is no `smDownProps` —\nso the unqualified value is what a phone gets and each tier adds room as it appears. Tiers cascade\nthrough: something set only in `smUpProps` still applies at `lg`.\n\n`mdUpProps`, not `mdProps`: `md` is already a size value on ~15 primitives (`size=\"md\"`), and `Up`\nsettles whether a tier means at-this-width or below it. The validator suggests the right spelling.\n\nStates and tiers do not cross — there is no `mdUpHoverProps`. A tier sets base values at that width;\n`hoverProps` applies at every width.\n\n### Which mechanism to reach for\n\nThree ways to respond to size, and they are not interchangeable:\n\n| Need | Use | Why |\n|---|---|---|\n| Different **values** — padding, gap, width, font size | `*UpProps` | Pure CSS. Nothing remounts. |\n| A different **tree** — a pane becomes a drawer, two panes become one | `$surface` + `$if` on `surface.tier` | Only a branch can swap DOM. |\n| Same-shaped things **filling a box** — video tiles, a photo wall | `Grid` with `childAspect` | Needs both axes and an argmax; CSS cannot express it. |\n\n**Prefer `*UpProps` for anything that is a value.** `$if` on the tier works and is tempting, because\nbranching is the familiar tool — but it **unmounts and rebuilds the subtree** every time the surface\ncrosses a threshold. That loses scroll position, half-typed input, and anything holding a live\nresource: it is why a video call laid out that way goes black when its panel is resized. Reserve it\nfor genuine structural change.\n\n**Prefer intrinsic sizing over either, where it works.** A wrapping `Row` whose children have a flex\nbasis, or a `Grid` with `minChildWidth`, adapts continuously at every width instead of at three\nthresholds, needs no surface, and cannot be got wrong. Reach for a breakpoint when the layout must\ngenuinely change its mind, not merely stretch.\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\nbreakpoint: 'sm', 'md', 'lg'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'polarity', 'lightnessFloor', 'lightnessCeiling', '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', 'mono'\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\nRAMP: 'light', 'dark'\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\nSTATE_STEPS: 'light', 'dark'\n\nzIndex: 'dropdown', 'sticky', 'chrome', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nA `json` field is a stored blob rather than a queryable value. `TextBlock.marks` is the one worth\nknowing: it holds inline structure over `text` as standoff annotations — a JSON array of\n`{ start, end, type, ...data }` ranges, offsets in Unicode **code points** — with types `strong`,\n`em`, `underline`, `strike`, `code`, `link` (`href`), `nodeLink` and `mention` (`did`). A block\nwith `text` and no `marks` is one unmarked span, which is why a transcriber can write a\nwell-formed block without knowing marks exist. Render from it; never filter on it — anything\nqueryable is written beside it as a relation (a mention is also a `we://mention` link on the root,\nwhich is where \"who is named in this post\" is answered).\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 - systemLightThemeId: string [we://system_light_theme]\n - systemDarkThemeId: string [we://system_dark_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 - moduleSettings: string [we://module_settings]\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\nCallExtraction extends WeNode:\n Fields:\n - callId: string [we://call_id]\n - entities: string [we://extraction_targets]\n - auto: string [we://auto_interpret]\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 - label: string [we://title]\n - thumbnail: string [we://thumbnail]\n - displayMode: string = 'card' [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - occurrence: string [we://occurrence]\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\nPlacement extends Ad4mModel:\n Fields:\n - nodeType: string [we://node_type]\n - x: number [we://x]\n - y: number [we://y]\n - width: number [we://width]\n - height: number [we://height]\n - contentScale: number [we://content_scale]\n - color: string [we://color]\n - cardShape: string [we://card_shape]\n Relations:\n - node: HasOne [we://placed_node]\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\nRelationship extends WeNode:\n Fields:\n - connection: string [we://connection]\n - relationshipTypeId: string [we://relationship_type_id]\n - label: string [we://title]\n - description: string [we://description]\n - sourceType: string [we://source_type]\n - targetType: string [we://target_type]\n Relations:\n - source: HasOne [we://relationship_source]\n - target: HasOne [we://relationship_target]\n\nRelationshipType extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - color: string [we://color]\n - inverseName: string [we://inverse_name]\n - directed: boolean = true [we://directed]\n - schemaVersion: number = 1 [we://schema_version]\n\nShape extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - shapeId: string [we://shape_id]\n - version: number = 1 [we://version]\n - forkedFrom: string [we://forked_from]\n - definition: string = null [we://shape_definition]\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 - retired: boolean = false [we://retired]\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 - enabledViews: string [we://enabled_views]\n - extractionTargets: string [we://extraction_targets]\n - autoInterpret: boolean = true [we://auto_interpret]\n - moduleSettings: string [we://module_settings]\n - shareExtractionDetail: boolean = false [we://share_extraction_detail]\n Relations:\n - location: HasOne → LocationBlock [we://location]\n\nSpacePreference extends WeNode:\n Fields:\n - spaceUuid: string [we://space_uuid]\n - mutedModules: string [we://muted_modules]\n - moduleSettings: string [we://module_settings]\n - hiddenViews: string [we://hidden_views]\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 - role: string [we://template_role]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - style: string = 'normal' [we://style]\n - listItem: string [we://list_item]\n - level: number [we://level]\n - checked: boolean = false [we://checked]\n - align: string [we://align]\n - direction: string [we://direction]\n - text: string [we://text]\n - marks: json [we://marks]\n - version: number [we://version]\n\nTopic extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - color: string [we://color]\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\nTypeStyle extends Ad4mModel:\n Fields:\n - nodeType: string [we://node_type]\n - color: string [we://color]\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.\nRead state in an expression ({ \"$\": \"storeName.member\" }) and call actions with $action.\nFor ephemeral/form state, use $localState with local.* reads and $setLocal writes 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 - 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: RegisteredApp[] — the apps list with a WE entry prepended, for an app switcher that offers the way back to templates as one more row. Prepended here because a schema can map a list but cannot add to it\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\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: string | undefined — the shared URL of the current dataset with its scheme (neighbourhood://…), or undefined for a personal one. Prefer currentDatasetCid for comparisons; this is the form a share link carries\n - currentDatasetCid: string | undefined — the neighbourhood CID of the current dataset (prefix stripped)\n - currentDatasetEntities: EntityManifestEntry[] (non-WE SHACL models from the current dataset; injected as externalEntities 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 - globalDataset: dataset handle | null — the seed-configured global discovery space, once joined\n - marketplaceDataset: dataset handle | null — the seed-configured marketplace, once joined\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: string | null — the dataset id of the seed-configured marketplace, or null when it is not configured or not joined. The marketplace counterpart of globalSpaceId\n - marketplaceJoined: boolean — the marketplace dataset is joined locally\n- Actions:\n - switchDataset(uuid: string): switches to a dataset by UUID, registers its SHACL models as dynamic model classes, and populates currentDatasetEntities\n - reorderDatasets(newOrder: string[]): reorders the sidebar items by UUID array\n - removeDataset(uuid: string): removes a dataset from the backend and from local state. The low-level half of spaceStore.removeSpace, which also clears the global-discovery listing — call that from a template, and this only for a dataset that is not a space\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\nEditorStore:\n- State:\n - messages: ChatMessage[] — the active AI session's messages (id, role: 'user' | 'assistant' | 'system', content, createdAt, status). Empty for a new chat\n - isOpen: boolean — the AI chat panel is open\n - isStreaming: boolean — an assistant reply is arriving; streamingContent holds what has arrived so far\n - streamingContent: string — the partial assistant reply while isStreaming, empty otherwise\n - apiKeyConfigured: boolean — the agent has an API key set, so sendMessage can work. Gate the composer on it and say what is missing rather than hiding it\n - templateName: string — the name of the template being edited, for the editor’s own header\n - templateIcon: string — its icon\n - isReadOnly: boolean — the template on screen cannot be saved in place (a built-in, or somebody else's). Edits buffer as pending changes; offer Fork rather than Save. Answers for the template rendered, so do not use it to gate per-row controls in a list — switcherGroups carries `editable` per row\n - hasPendingChanges: boolean — buffered edits exist against a read-only template, waiting for a fork to land in\n - pickerOpen: boolean — the fork/fresh naming dialog is showing\n - pickerAction: 'fork' | 'fresh' — which the open picker is for\n - pickerDefaultName: string — what the picker’s name field starts with\n - pickerDefaultIcon: string — what its icon field starts with\n - pickerShowDestination: boolean — the picker offers a personal-or-space destination, which it can only do while a space is open to save into\n - sessions: ChatSession[] — this template's saved AI sessions (id, name, templateId), newest first\n - activeSessionId: string | null — the session whose messages are shown\n - contentMode: 'preview' | 'visual' — whether the editor shows the rendered template or the visual editing surface\n - schemaJson: string — the template being edited, serialised — what the code panel shows and edits\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: boolean — a template editing session is open\n - editAction: 'edit' | 'fork' | 'fresh' | null — how the current template session began, null outside one\n - codePanelOpen: boolean — the code panel is open\n - themePanelOpen: boolean — the theme panel is open\n - visualPanelOpen: boolean — the visual properties panel is open\n - isEditingTheme: boolean — a theme editing session is open, independently of template editing\n - aiDockEdge: DockEdge — where the AI panel opens ('left' | 'right' | 'top' | 'bottom'), or null while it is closed. An opening bid: the shell remembers wherever the user drags it\n - codeDockEdge: DockEdge — the same, for the code panel\n - themeDockEdge: DockEdge — the same, for the theme panel\n - visualDockEdge: DockEdge — the same, for the visual panel\n - editorDockSize: DockSize — the opening size every editor panel shares ('sm' | 'md' | 'lg' | 'full')\n - editorDockFloat: boolean — editor panels open floating over the content rather than pushing it aside\n- Actions:\n - newChat(): starts a new AI session for this template and switches to it\n - switchSession(sessionId: string): shows another saved session\n - deleteSession(sessionId: string): deletes a saved session and its messages\n - setContentMode(mode: 'preview' | 'visual'): switches the editor between the rendered preview and the visual editing surface\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n - startFork(): opens the picker to copy the current template into one you own\n - startFresh(): opens the picker to start an empty template\n - confirmPicker(name: string, icon: string, destination: 'personal' | 'space'): creates the fork or fresh template and enters editing on it\n - cancelPicker(): closes the picker without creating anything\n - enterTemplateEditing(action?: 'edit' | 'fork' | 'fresh'): opens a template editing session on the template on screen. Omit action to edit in place; a read-only template buffers its edits\n - exitTemplateEditing(): ends the template session. Buffered changes to a read-only template are dropped\n - toggle(): toggles the AI chat panel open/closed\n - open(): opens the AI chat panel\n - close(): closes it\n - toggleCodePanel(): opens or closes the code panel\n - openCodePanel(): opens the code panel\n - closeCodePanel(): closes it\n - toggleThemePanel(): opens or closes the theme panel\n - openThemePanel(): opens the theme panel. Pair with themeStore.focusRole to land on a role\n - closeThemePanel(): closes it\n - toggleVisualPanel(): opens or closes the visual properties panel\n - enterThemeEditing(): opens a theme editing session on the current theme, and the theme panel with it\n - exitThemeEditing(): ends the theme session, discarding an unsaved draft\n - toggleThemeEditing(): enters or exits theme editing — what a single button in chrome should call\n - sendMessage(text: string): sends a message to the assistant. Patches it proposes are applied to the template and land in undo history; the reply streams into streamingContent\n - clearHistory(): deletes the active session's messages\n\nInterpretationStore:\n- State:\n - activity: InterpretationActivityView[] — every extraction pass this agent knows about, its own and its peers’, running first then most recent. Each row carries display-ready strings: `label` is a whole clause (\"Anna is waiting on the model\", \"Extracted 3 records\"), `elapsed` is `m:ss` while running and empty once settled, `name`/`avatar`/`runner` identify who is running it, and `mine` says whether it is this agent’s. Only a row with `mine` can carry `prompt`/`response` — the exchange never left the runner’s machine — so gate a details affordance on `hasDetail` and explain the refusal rather than hiding it\n - runningCount: number — how many passes are still in flight. What a collapsed \"N extractions running\" summary counts\n - hasActivity: boolean — whether there is anything to show at all. Counts settled rows too, so a bar gated on it does not vanish the instant a pass finishes and take its result with it\n - runningPasses: InterpretationActivityView[] — the passes still in flight, for a readout that lists them\n - settledPasses: InterpretationActivityView[] — the passes that have finished, newest first\n - settledCount: number — how many have finished. What a collapsed \"N extractions processed\" line counts\n - detailWithheld: boolean — a peer's settled pass is on screen whose exchange this agent cannot open, because the space does not share it. Gate a footnote explaining the absence on this rather than on a row's own hasDetail, which is false for a pass that simply has not reached the model yet\n - capable: boolean — whether this node can interpret AT ALL, as distinct from being able to and having no model configured. Answered by asking the backend rather than by testing the client library, so it is false against a node whose executor predates the extraction stack. False means no fix exists from inside the app — say so rather than offering a control that cannot work\n- Actions:\n - dismissSettled(): forgets every finished row, leaving anything still running. A running pass is not this agent’s to dismiss\n\nPresenceStore:\n- State:\n - peers: PresentAgent[] — every peer known in the current space, offline included, each joined to its cached profile (did, name, avatar, tone, focus, activities, availability). Sorted by liveness\n - online: PresentAgent[] — peers in the current space who are not offline — the \"who is here\" list\n - onlineHere: PresentAgent[] — peers at this agent's exact route path, for a per-page presence strip\n - calls: Map — the calls running in this space right now and who is in each\n - available: boolean — a presence transport exists. False in a personal space, where there is nobody to be present to; gate presence UI on it rather than rendering an empty roster\n - focusDepth: FocusDepth — how much of this agent's location peers are shown: the space only, the section, or the exact path\n- Actions:\n - setFocusDepth(depth: FocusDepth): sets how much of your location peers see\n - setAvailability(availability: 'available' | 'busy' | 'away' | 'invisible'): sets the status published with your presence. 'invisible' stops publishing entirely rather than asking peers not to look\n - setActivity(activity: Activity): adds or replaces a published activity — a call, an edit, a work claim — keyed by its type and id\n - clearActivity(type: string, id?: string): withdraws a published activity; omit id to withdraw every one of that type\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). Note `name` is assembled for display and falls back to \"Anonymous\", so it is never empty — test firstName/lastName/handle to ask whether somebody has a name\n - ownProfileLoaded: boolean — the own-profile fetch has answered. An empty profile is otherwise indistinguishable from an unfetched one, so anything asking \"has this person set a name?\" reads every boot frame as \"no\". Same reason as datasetStore.datasetsLoaded\n - needsName: boolean — this agent has no name of any kind and has not waved the question away this session, as a settled fact (false until the app is ready and the profile fetch has answered). What the name prompt mounts on; also the right gate for any \"finish setting up\" nudge of your own\n - pendingAvatar: File | null — a picture chosen before the agent exists, held until completeAccountSetup uploads it. Read it to preview the choice on the setup screen; null once uploaded or when nothing was picked\n- Actions:\n - setPendingAvatar(file: File): holds a picture chosen before an agent exists; uploaded by completeAccountSetup\n - saveNameFromPrompt(name: string): sets the name and stops asking. Dismisses before publishing, so a failed write cannot re-raise the prompt on top of the toast explaining it — which is why this exists rather than calling updateOwnProfile from the schema\n - dismissNamePrompt(): stops asking for a name until the next launch. Not persisted: a nameless agent degrades every other member's experience, so the only permanent exit is setting a name\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\nRecordStore:\n- State:\n - 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\n - 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\n - 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`\n - 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\n - recordErrors: string[] — validation errors from the last save attempt, plus any backend failure\n - savingRecord: boolean — a create is in flight\n - 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\n - pendingLink: the two records a pending connection joins ({ sourceId, sourceType, sourceLabel, targetId, targetType, targetLabel }), or null when the open form is an ordinary one. Read it to name what is being connected\n - relationshipKind: string — which named RelationshipType the pending connection is, or empty for one carrying only a label. Held beside the draft because the kinds are a list to pick from, which a generated form cannot render\n- Actions:\n - openRecordForm(entity?): opens the create form — on that model, or on the first offered one. Clears any pending connection\n - connectNodes(link): opens the form on a Relationship joining two records. Takes the graph's onEdgeCreate payload as it arrives\n - setRecordEntity(entity): switches which model is being created, discarding what was typed\n - setRecordField(name, value): sets one field. Takes the field name, so one action serves every control — which is the only shape that works when the fields come from data\n - setRelationshipKind(id): sets which named kind the pending connection is; an empty value clears it\n - cancelRecordForm(): closes the form, discarding it\n - saveRecord(): validates and creates. Errors land in recordErrors and the form stays open holding what was typed; success closes it and sets lastCreatedId\n - placeOnBoard(board: string, nodeId: string, nodeType: string, x: number, y: number): puts a record at a position on a board, or moves one already there. An upsert, so dragging twice leaves one coordinate. Pair with the graph’s onNodeDragEnd\n - removeFromBoard(board: string, nodeId: string): takes a record off a board, leaving the record itself alone. A card the board owns survives as an unplaced one in the tray\n - resizeOnBoard(board: string, payload): resizes a card on a board. Takes the graph's onNodeResize payload as it arrives; the size lives on the placement, so the same post on another board is unaffected\n - setCardStyle(board: string, nodeId: string, field: string, value): sets one presentation property of one card on one board — 'color', 'cardShape', 'contentScale'. Takes the field name so one action serves a swatch, a picker and a slider. Undone by taking the card off the board\n - previewCardStyle(nodeId: string, field: string, value): shows a presentation change without writing it — for a slider that reports while it moves. Pair with setCardStyle on release; both go through the same pending map so the card never jumps\n - setTypeColor(board: string, nodeType: string, color): sets the colour every card of one type is drawn in, on one board — the board's key, made writable. An empty colour clears it\n - createOnBoard(board: string, x?: number, y?: number): opens the create form and places whatever it makes onto that board, at the point given. Pair with the graph’s onCanvasDoubleClick\n - createCardOnBoard(editorState, { board, at? }): composes a card onto a board and records where it sits, as one write. Without `at` the card lands in the board's tray. The composer's counterpart to createOnBoard\n\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n - templateSegments: string[] — the segments BELOW the space prefix, which is a template's own coordinate space. A template mounted at /space/ reading its own route params wants this: at /space/abc/photo/xyz it is [\"photo\", \"xyz\"], so a `/photo/:postId` route reads templateSegments[1] and keeps reading it wherever the host mounts the template. Reading `segments` by index pins a template to the host's prefix and breaks when it moves.\n - params: Record — the URL's query parameters, reactive; read one as { $: 'routeStore.params.' }. Prefer $localState with syncParam for fields a view owns; read params directly only for parameters something else writes\n- Actions:\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 - back(): goes back one entry, the browser's own way. Use it on a page reached from several places — a record page is opened from a list, from a search and from a link somebody sent, and only one of those has a parent worth guessing. Does nothing at the start of the session's history\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 - aiFormDirty: boolean — the open form has been edited since it opened. What a discard guard reads; compared against a snapshot taken on open, so looking at a model's settings and closing again asks nothing\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 - me: Agent | undefined — the authenticated identity; prefer the $me token in schemas\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 - isGuest: boolean — this identity was minted for somebody who arrived on a guest invite link rather than chosen by them. NOT the same question as `host`: an ordinary member of a hosted deployment has a host and is not a guest. Read it where the app explains itself to the person using it — why it is asking for a name, what \"log out\" would mean for an identity with no other way back\n - isDevelopment: boolean — whether this is a development build. A fact about the build. Do NOT gate developer-only UI on it; gate on devTools, which is the same answer plus a switch\n - devTools: boolean — whether developer affordances should be VISIBLE. True in a development build unless a developer has thrown the Settings → Developer switch to see what a shipped app looks like. Reactive, so a control gated on it appears and disappears on the press. Gate any developer-only control on this — a schema-test page, a fixture toggle — and wrap it in $if rather than hiding it, since a hidden row is still in the accessibility tree and still found by find-in-page. Never true in a production build, whatever the switch says\n- Actions:\n - setDevTools(on: boolean): shows or hides developer affordances for this session. Takes the value the control shows, so a we-switch can pass `event.detail` bare. Cannot turn developer UI on in a production build\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\nShapeStore:\n- State:\n - spaceShapes: SpaceShapeView[] — the content models THIS SPACE defines (id, name, description, icon, shapeId, version, forkedFrom, propertyCount, problems). A shape with a non-empty problems array failed validation or adoption and its entity is not queryable; render the problems rather than hiding the row\n - shapesLoaded: boolean — the space has been asked for its shapes. An empty list is otherwise indistinguishable from \"not fetched yet\"; gate empty states on it\n - shapeDraft: the model wizard's draft (name, description, icon, classHint, identityMember, members[]) or null while the wizard is closed — its non-nullness is what mounts the wizard modal. Each member is { rowId, kind: 'property' | 'relationship', name, … }: a property carries type/required/hint/options/defaultValue, a relationship carries target/many. Form state lives here rather than $localState because rows are structured and validated as a whole, and the LLM flow fills the same draft\n - editingShapeId: string | null — the Shape record being edited; null means the draft is a new model\n - draftErrors: string[] — wizard-facing validation errors from the last save attempt\n - savingShape: boolean — a save is in flight\n - aiAvailable: boolean — AI model generation is available (the agent has a Claude API key configured)\n - generating: boolean — an AI generation is in flight\n - hintEntities: { entity, source: 'core' | 'shape' }[] — entities offering AI-hint tuning in this space: core interpretable vocabulary (TaskBlock, EventBlock) plus the space's own shapes\n - extractionCandidates: string[] — entity names an extraction pass COULD write here: core vocabulary that declares itself extractable, plus every adopted shape that does. Candidacy, not a decision — which of these a call actually looks for is two layers down (spaceStore.extractionTargets, then the call's own participants). Read it to offer a choice, and to display findings: a card should show a record somebody extracted an hour ago even if the target has since been switched off\n - relationshipTargets: { label, value }[] — what a relationship may point at here, ready for a we-select: this space's own models, then block types, then other apps' models. Core infrastructure entities are deliberately absent\n - identityOptions: { label, value }[] — \"None\" plus every named property of the open draft, for the identity picker. Built in the store because a schema can map options but cannot prepend one\n - hintEditor: the hint editor state ({ entity, classHint, defaultClassHint, rows: { name, predicate, hint, defaultHint }[], customized }) or null while closed — non-nullness mounts the hint editor modal\n - hintBusy: boolean — the hint editor is loading or saving\n - extractionNeedsIdentity: boolean — the open draft would be extracted into and has no field to recognise what it already wrote, so every pass duplicates everything. A warning to put beside the switch, not a refusal: the wizard saves either way\n - memberOptions: { rowId, options }[] — each member's default-value picker entries. Read with find(shapeStore.memberOptions, { rowId: member.rowId }) rather than off member: rows are mutated in place while typing, so values hanging off the row cannot be reactive\n - expandedMembers: string[] — rowIds whose detail panel is open. Read with { $: 'member.rowId in shapeStore.expandedMembers' }; a new row and any row an error names open themselves. Generation leaves rows closed — a collapsed row shows its hint, so what was generated is readable without opening anything\n - generateIntent: 'none' | 'generate' | 'regenerate' | 'replace' — what the generate button would do right now, given what the draft holds. Label it \"Regenerate\" on 'regenerate' and 'replace' and \"Generate\" otherwise — 'none' is an empty draft, which has nothing to re-run, so testing for 'generate' alone labels a fresh form wrongly. Disable only on 'none', and route the click through requestGenerateFields, which decides whether to ask first\n - confirmReplaceFields: boolean — the \"replace the fields below?\" confirmation is showing. Only ever raised for a generation over hand-written rows; a generated proposal nobody touched re-runs on the click\n - confirmDiscard: boolean — the \"discard this model?\" confirmation is showing\n - hintEditorDirty: whether the open hint editor holds edits that closing would lose. What a discard guard reads: the rows come from the model’s declaration, so a schema has no set of local names it could test. Compares against the state the editor opened in, so an editor somebody only read closes without a question\n- Actions:\n - openShapeWizard(shapeRecordId?): opens the model wizard — empty for a new model, or pre-filled from a stored shape to edit it\n - cancelShapeWizard(): closes the wizard, discarding the draft\n - setShapeField(field: 'name' | 'description' | 'icon' | 'classHint', value): sets one top-level draft field\n - setIdentityMember(rowId): chooses which member identifies duplicates for AI extraction; 'none' clears it. At most one, which is why it is a picker rather than a per-row flag\n - setExtractable(on: boolean): allows or refuses an AI extraction pass writing instances of the open draft. Its own action rather than a setShapeField case, because the value is a boolean and that field takes strings\n - addProperty(): appends an empty property (scalar field) row to the draft\n - addRelationship(): appends an empty relationship (edge to another model) row to the draft\n - removeMember(rowId): removes one member row\n - setMemberField(rowId, field, value): sets one field of one member row. 'options' takes the comma-separated string as typed\n - reorderMembers(rowIds: string[]): applies a drag-reorder. Pair with we-sortable's onReorder and pass { $: \"arg.detail\" } — order is the stored declaration order, not decoration\n - toggleMemberExpanded(rowId): opens or closes one member's detail panel (hint, default, allowed values)\n - commitDraft(): publishes in-place edits to the draft signal. Typed fields are mutated without touching it so inputs keep focus, which leaves derived values stale — pair with onBlur on a field something else is computed from\n - replaceDraft(draft): replaces the whole draft — how the LLM flow hands a generated model to the same review path\n - generateShapeDraft(description: string): generates a draft from a plain-language description and lands it in the open wizard for review. Proposes only — nothing is stored until the user saves. Gate the control on aiAvailable\n - generateShapeFields(): generates the draft's fields from what the author actually wrote — the name, description and AI hint they typed — and answers whatever they left blank, including anything a previous run had filled in (that being the machine's own output, which would otherwise steer the next prompt and return a model half about the last subject). Replaces the member list wholesale, so call requestGenerateFields from a button instead, and this from the confirmation it raises\n - requestGenerateFields(): the generate button's own entry point — generates now, or raises confirmReplaceFields when the click would discard hand-written rows. The store makes that choice because only it can tell a proposal nobody touched from rows somebody wrote\n - cancelReplaceFields(): dismisses the replace confirmation, keeping the fields as they are\n - requestCloseWizard(): closes the wizard, asking first when there is work to lose. Wire the modal's own close to this so a backdrop click is guarded too\n - cancelDiscard(): dismisses the discard confirmation and keeps the wizard open\n - saveShapeDraft(): validates, stores and adopts the draft. Errors land in draftErrors; success closes the wizard and the new entity becomes queryable via $query in this space\n - deleteShape(shapeRecordId): removes a model definition from the space. Existing entries keep their data; only the definition goes\n - openHintEditor(entity): opens per-space AI-hint tuning for an entity (core or space-defined)\n - closeHintEditor(): closes the hint editor, discarding unsaved edits. Pair it with hintEditorDirty in a discardGuard rather than wiring it to a modal’s close directly\n - setHintDraft(key, value): sets one hint in the open editor — key is 'class' or a property predicate\n - saveHintEditor(): writes the hints to this space and marks them customized, so schema refreshes stop reverting them\n - resetHintEditor(): back to the declaration's hints; release improvements flow again\n\nShellStore:\n- State:\n - activeShellView: string | null — id of the currently open shell overlay ('profile' | 'settings' | 'schema-tests' | 'landing-page'), or null\n - createSpaceOpen: boolean — the create-space modal is open. Shell state because more than one place opens it; bind the modal’s open prop to this and close it with setCreateSpaceOpen\n - pendingDestructive: the destructive action a space template just asked for ({ path, title, body }), or null. The host raises its own confirmation in front of every one of them — a space template arrives from a stranger, so whether it asks before deleting is not the stranger's decision. Host chrome renders it; a template writing its own dialog for a destructive store action would be a second question about one click\n - spaceSettingsOpen: boolean — the space-settings panel is open. It configures whichever space is open, so it needs no id; bind a launcher’s active state to this\n - spaceSettingsTab: string — the tab the space-settings panel opens on ('about' | 'features' | 'vocabulary'). A starting position read once as the panel mounts, not a controlled value: somebody who then walks to another tab stays there. Set it by passing a tab to openSpaceSettings\n - dockGeometry: Record — every registered panel's resolved box (top, left, width, height, edge, mode). Read a field as { $: \"shellStore.dockGeometry[''].\" } — by index, since a dock id holds a colon; the frame a panel is wrapped in binds its geometry this way so a move rewrites props rather than remounting\n - contentInset: { top, right, bottom, left } in pixels — what the content viewport gives up to panels that displace it. Read it to keep your own fixed chrome clear of docked panels\n - coveredInset: { top, right, bottom, left } in pixels — what FLOATING panels are covering. They take no room, so they leave contentInset at zero while still sitting over the content: this is the part of your own box the reader cannot see. Read it to keep something in the clear where contentInset would say there is nothing in the way\n - dockResizing: boolean — a panel is being dragged or resized right now. Suspend transitions while it is true so the edge tracks the cursor\n - panelMaximised: boolean — some panel covers the whole window. The app's own chrome — sidebar, module rail — hides while it is true; a template's fixed chrome should too\n - dockPlacement: Record — where each panel is parked, for its frame to read: which of the eight snaps it is at, whether it displaces content, and whether it may. The state a position menu ticks; dockGeometry is the resulting box\n - movingDock: string | null — the id of the panel being dragged, or null. What mounts the snap-target overlay\n - activeSnap: SnapPoint | null — the snap the moving panel would take if dropped now ('top-left' | 'top' | … | 'left'), so that target can light up\n - snapTargets: { id, top, left, width, height }[] — every snap target’s box while a panel is being dragged, measured against the room left for it. A place a card is already parked at is left out: the question there has stopped being whether and started being where among what is there, which the insert slots answer. Empty otherwise\n - insertSlots: { key, index, edge, lane, mode: 'band' | 'lane' | 'tab', top, left, width, height }[] — every place a dragged panel could land, while one is being dragged. 'band' offers a new lane at that distance inboard, 'lane' a new seat beside the panels in the lane it names, 'tab' the seat itself, to stack behind whatever is showing there. A 'tab' slot naming no edge is a floating panel offered as somewhere to stack. Empty otherwise\n - activeInsert: string | null — the slot a drop would take right now, as that slot's `key`. Compare it against slot.key rather than rebuilding the string, which names four things\n - dragGhost: { top, left, width, height, title } | null — the outline following the cursor while one TAB is dragged out of a stack. A panel is moved instead, and so is a whole stack — whose other tabs ride along hidden — so this is null for both of those and between drags\n - layoutPinned: Record keyed by panel id — whether that panel has been dragged away from where meta.panels declared it. False for a panel no layout mentions, since there is nothing to go back to. Gate a \"reset to layout\" affordance on it rather than on a placement merely existing\n - layoutDirty: boolean — the interface on screen has been rearranged: one of its panels moved, resized or closed. What a whole-arrangement \"reset layout\" control is gated on, and not the same question as any layoutPinned entry — a closed panel has no placement, and a panel declared for another route is not among the docks at all. False for an interface declaring no panels\n - panelSupplied: Record — modules whose panel this interface supplies itself, by declaring a `meta.panels` entry that names the module and carries a `node`. What a module's dock frame asks before drawing its own contents; the module still owns whether the panel is open and how big it is\n - layoutNames: string[] — the arrangements saved for the interface on screen, by name, sorted. The three-rung chain has one user slot; these are how to keep more than one — a “recording” and a “reviewing” for the same template. Empty for an interface with none\n - activeLayout: string — the saved layout the arrangement on screen is, or '' once anything has been moved since. Mark the matching row as selected\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 - confirmDestructive(): runs the destructive action the host is asking about. Host chrome only, for the reason pendingDestructive is: an action able to answer its own confirmation is the confirmation being skipped\n - cancelDestructive(): refuses it. The waiting action resolves as though it had been blocked\n - toggleSpaceSettings(): opens or closes the settings panel for the space on screen. What a gear in chrome should call — a control that is always present toggles, so a second press puts back what the first press changed\n - openSpaceSettings(): opens that panel without closing it again. For a control that sits on the very fields it leads to (the About view’s pencil), where a toggle would break the promise to show them\n - closeSpaceSettings(): closes the space-settings panel\n - scrollToId(id: string): smooth-scrolls the element with that DOM id into view\n - beginDockResize(id: string): remembers a panel's current size so the drag that follows is measured from it. Wire it to we-resize-handle's resizestart. For a divider between lane-mates it first makes every member's stored size what is on screen, so the boundary can then travel the whole lane\n - resizeDock(id: string, side: 'left' | 'right' | 'top' | 'bottom' | 'top-left' | …, dx: number, dy: number): applies a resize drag from that side or corner, in screen pixels since it began. Wire it to resize with { $: 'arg.detail.delta' }\n - resizeColumn(id, delta): moves the boundary between this panel and the next one in its lane, giving one what the other loses. What the earlier panel's trailing grip calls when it has a lane-mate — its bottom in a side lane, its right-hand edge in a top or bottom one. A boundary belongs to both panels, so only one of them draws it\n - endDockResize(): ends the drag and persists the size\n - fitDock(id: string): shrinks a panel to the shape its content wants, keeping the width the user chose — only when the module declares an aspect for its panel\n - beginDockMove(id: string, pointerX: number, pointerY: number): begins moving a panel, remembering where it and the pointer started. A maximised panel shrinks back under the cursor\n - raiseDock(id: string): brings a panel in front of the others — what a pointer landing on its frame does, and what a drag or maximising does on its own. The most recently raised panel is the one on top; nothing else decides stacking\n - moveDock(id: string, dx: number, dy: number): applies a move, in pixels from where beginDockMove was called\n - beginTabDrag(id: string, pointerX: number, pointerY: number): a press on one tab of a stack. Records only — a tab is a click until the pointer travels, and the panel does not leave its seat until the drop\n - moveTab(id: string, dx: number, dy: number): past the drag threshold, shows where the tab would land. The tab itself stays put; only the guides follow\n - endTabDrag(id: string, pointerX: number, pointerY: number): a press that went nowhere brings the tab forward; one that travelled lands it, or leaves it as a card under the pointer\n - endDockMove(id: string): drops the panel — onto the snap or insert slot it is over, or where it is if that is nowhere\n - resetDockToLayout(panelId: string): puts a panel back where meta.panels asked for it, forgetting where it was dragged. Forgets rather than rewrites, so the panel keeps following the layout afterwards — including when the template changes it. Pair with layoutPinned\n - closeTemplatePanel(panelId: string): dismisses a panel the interface declared in meta.panels, by that panel's id. What its titlebar's close button calls\n - openTemplatePanel(panelId: string): puts a closed one back. The only way back to a panel that has been closed — it has no titlebar left to ask from — so a template offering a close should offer this too\n - resetTemplateLayout(): puts every panel of the interface on screen back the way meta.panels declared them, and reopens the ones that were closed. The whole-arrangement counterpart of resetDockToLayout, and the only way back for a closed panel, which has no titlebar to reset itself from. Scoped to the template rather than the route, so a declaration that varies by route is reset once. Pair with layoutDirty\n - saveLayout(name: string): saves the arrangement on screen under a name — its panels’ placements, which tab of each seat is showing, and which panels are closed. Scoped to the template, as placements are. Replaces a layout of that name\n - applyLayout(name: string): puts the arrangement back to a saved layout. What the layout does not mention returns to what meta.panels declared, exactly as a reset would leave it\n - deleteLayout(name: string): forgets a saved layout\n - snapDock(id: string, snap: SnapPoint): parks a panel at one of the eight positions from a menu — the keyboard's way to move it\n - insertDock(id: string, edge: 'left' | 'right' | 'top' | 'bottom', position: number, mode?: 'band' | 'lane' | 'tab', lane?: number | 'float'): puts a panel on that edge, renumbering what it lands among — what a drop does. 'band' opens a lane of its own at that distance inboard; 'lane' takes a new seat at that position along the lane named by `lane` (a distance inboard, or 'float' for the floating one); 'tab' joins the seat at that position, stacking behind whatever is showing there\n - toggleMaximiseDock(id: string): covers the content region with the panel, or goes back to being a card. Nothing about where the panel was is overwritten while it is on\n - toggleDockDisplace(id: string): makes the panel push the content aside, or stop. A toggle rather than a setter because a menu item reports only that it was clicked\n - toggleCollapseDock(id: string): folds a panel down to its titlebar, or opens it again. It keeps its place in its lane and its lane-mates take the room; the content is hidden, never unmounted. Refused where there is nowhere for that room to go — a sidebar alone on its edge, or the last open member of a lane. Read dockPlacement[id].canCollapse\n - breakOut(panelId: string, x?: number, y?: number): takes a section out of the template and makes it a panel — floating under the pointer when given one, else at the snap its meta.panels entry named. Refused for a section declared `fixed`. Takes the panel's own id, not the dock id\n - returnHome(panelId: string): puts a broken-out section back in the template at the outlet it came from. What the placeholder’s \"Bring back\" and the position menu’s \"Return to page\" call\n - stackDockstackDock(id: string, position: number): stacks a panel onto a floating one so the two share a seat and a tab strip — what a drop into the middle of a float does. Two panels in open space are in no lane, so `position` indexes the floating panels a drop could land on rather than naming one\n - insertHome(id: string, lane: string, position: number): drops a panel into a home lane at that position along it, renumbering the lane. Only a template's own sections land in one, and only where the lane's `accepts` allows\n - saveArrangementAsTemplate(): saves the arrangement on screen as a template of your own — a copy of the schema with the resolved placements written into its meta.panels and nothing else changed. The explicit bridge from arranging to authoring. Resolves true on success. Pair with layoutDirty\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 - spacePath: string — the path a space's own pages hang off (`/space/`), empty outside a space. What a link to one record is built from: an href has to be absolute, since a browser resolves a relative one against the current URL rather than against the route tree, and the segment is the neighbourhood CID for a shared space and the dataset id for a personal one — only the URL says which\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 - spaceThemePinned: boolean — this agent has pinned a theme for the space on screen that differs from what would otherwise apply, so there is something for a reset to undo. False outside a space, and false for a pin that happens to name what the space resolves to anyway. Gate a \"pinned here / reset\" affordance on it rather than on the pin merely existing\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 - spaceViews: ResolvedView[] — this space's sections resolved: which view renders at which segment, in the space's order, each carrying its schema. The host builds the route tree from it; a nav strip reads viewNav, which is this without the payload\n - routableViews: ResolvedView[] — every view that could render here, at its permanent segment — what routes are built from. Separate from spaceViews because it changes when a view is installed, not when a switch is flicked\n - enabledViewIds: string[] — ids of the sections the community has turned on here. What a route body is gated on; not the nav list, which also drops this agent's hidden ones — hiding a section for yourself must not make its URL refuse you\n - viewNav: { id, segment, label, icon, path }[] — the sections as a nav strip reads them: enabled by the community, minus those this agent hid, in order. One source with the routes, so nav and routes cannot disagree\n - mutedDids: string[] — DIDs this agent has muted, everywhere. Private, held in the root dataset. A feed filters on it before rendering: { $: '!(post.author in spaceStore.mutedDids)' }. Hides on this screen only — a neighbourhood is writable by every member, so nothing here removes anything for anyone else\n - mutedAgents: MutedAgent[] — the full mute records (did, description), for a settings list that wants the note as well as the DID\n - readMarkers: { nodeId, lastReadAt }[] — when this agent last read each node. No row means never read, so everything is unread. Read with find(spaceStore.readMarkers, { nodeId: item.id }); a keyed map would not be indexable by a row\n - unreadNodeIds: string[] — ids of the containers in this space holding something newer than this agent's marker for them, or never read. What an unread dot reads: { $: 'channel.id in spaceStore.unreadNodeIds' }. Ids rather than counts, since a count needs every child's timestamp\n - myMentions: { id, author, createdAt }[] — nodes in this space that mention this agent, newest first. createdAt is the backend’s comparable timestamp. Filtered client-side, so right for a space and wrong for an inbox across many\n - spaceModuleSettings: SettingRow[] — what each capability that declares settings is set to FOR THIS COMMUNITY, as rows a screen renders directly: { group, groupLabel, key, label, description, type, options, value, source, set, locked, lockedBy }. `value` is already resolved across every level that had an opinion; `source` names the level that decided it ('default' when nobody did); `set` says whether THIS level holds an opinion, so a reset has something to undo; `locked` says a level that BINDS this one has forced it and the control must be disabled rather than springing back — a member's private refusal does not bind the community, so it never locks this list. Built from what modules declare, so a module that adds a setting gets a control with nothing to register\n - myModuleSettings: SettingRow[] — the same rows, for what THIS AGENT has decided in THIS space. Private, held in the root dataset. The most specific of the four levels\n - agentModuleSettings: SettingRow[] — the same rows, for what THIS AGENT has decided everywhere. Private. Render it in global settings, where the question is what you want in every space\n - autoInterpret: boolean — whether this space has calls interpreted (extracted into records) as they happen. A community decision, off by default. Readable by every member; writing it is space-settings\n - shareExtractionDetail: boolean — whether extraction passes in this space broadcast their prompt and response to every member, so interpretationStore.activity rows carry detail for everyone. A community decision, off by default\n - extractionTargets: string[] — the models a call in this space starts out extracting. The middle of three layers: shapeStore.extractionCandidates says what COULD be extracted, this says which of them a call begins with, and the call's own participants add or remove from there (modules.transcribe.extractionTargets). Unset falls back to the two classes that were hardcoded before the setting existed, so no space silently stops extracting. Writing it is space-settings\n - canAdministerCurrentSpace: boolean — whether this agent may change what every member of the space on screen sees. The readable form of canAdministerSpace, which an expression cannot call. Gate an admin-only control on this rather than on `x.author == me.did`, which asks who made the row and not who runs the space\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(childId: string, fromId: string, toId: string): moves a child between two collections — a card between kanban columns. Relinks the two children edges; the child itself is untouched\n - setAttending(nodeId: string, attending: boolean): joins or leaves a node's participant roster — an RSVP. Writes only this agent's own entry, so the roster stays conflict-free. Boolean, so a switch can pass `event.detail` bare\n - setAgentMuted(did: string, muted: boolean, description?: string): mutes or unmutes an agent for this agent everywhere, with an optional note. Positively phrased so a switch can pass `event.detail` bare\n - markRead(nodeId: string, spaceUuid?): marks a node read as of now, so it leaves unreadNodeIds. Silent on failure — a lost marker is a stale dot, not an error\n - uploadFile(file: File, name?: string): stores a file and returns the URL to reference it by, or null. Images are compressed on the way through. For a template doing its own media UI — without it, only the block composer could accept an upload\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 - setSpaceModuleSetting(group: string, key: string, value?, spaceUuid?): sets one of a capability's settings for everyone in a space — `group` is the module id and `key` the setting's key, both off the row. **Omit `value` to clear it**, which returns the level to having no opinion: a stored value that happens to equal the default goes on overruling everything less specific while its control reads as untouched. Omit spaceUuid for the space on screen\n - setMyModuleSetting(group: string, key: string, value?, spaceUuid?): the same, for this agent in one space. Private — written to the root dataset, never to the space. Omitting `value` clears it\n - setAgentModuleSetting(group: string, key: string, value?): the same, for this agent in every space. Private, and global, so there is no space to name. Omitting `value` clears it\n - autoInterpretForCall(collectionId): whether ONE CALL is extracted as it happens — its participants' answer if they gave one, else the space's. A function rather than a value because the answer is per call, like canAdministerSpace\n - setAutoInterpretForCall(collectionId, on) => turns automatic extraction on or off for ONE CALL, for everyone in it. A participant's decision, unlike setAutoInterpret, which administers the space — and it leaves the space's default alone. Does not stop a pass already running: those tokens are spent\n - setAutoInterpret(enabled: boolean, spaceUuid?): turns automatic call interpretation on or off for a space. Omit spaceUuid for the space on screen\n - setShareExtractionDetail(enabled: boolean, spaceUuid?): turns broadcasting of extraction prompts and responses on or off for a space. Omit spaceUuid for the space on screen\n - setExtractionTarget(entity: string, on: boolean, spaceUuid?): adds or removes one model from what this space's calls start out extracting. Writes the resolved list, so the first toggle also pins whatever was on by fallback. The community's decision; a call's participants override it per call\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 another token would evaluate at render time and send a constant\n - setViewEnabled(viewId: string, enabled: boolean, spaceUuid?): adds or removes a section from a space. The community’s decision — every member sees it. Omit spaceUuid for the space on screen\n - reorderViews(viewIds: string[], spaceUuid?): sets the whole section order at once — what a drag-reorder writes. Pair with we-sortable's onReorder\n - setViewVisible(viewId: string, visible: boolean, spaceUuid?): shows or hides a section for this agent in one space, without changing what the community has. Private. Positively phrased so a switch can pass `event.detail` bare\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. The sentinels are named values rather than '' because they are three distinct meanings, and only one of them is no value at all\n - setSpaceThemeOverride(themeId: string, spaceUuid?): sets the theme THIS AGENT sees in one space. Same three values as setSpaceTemplateOverride. Private\n - applyTheme(themeId: string): applies a theme where the agent is — pinned to the space on screen, or set as their global default when there is no space. What a theme picker in chrome should call: it persists, where setCurrentTheme only sets a signal that the next resolution overwrites. Which of the two it does is decided at click time, against state a schema cannot see\n - clearSpaceThemePin(): drops this agent’s theme pin for the space on screen, returning it to whatever would otherwise apply. The way back out of applyTheme, so the picker need not spell the FOLLOW_SPACE sentinel as a literal. Pair with spaceThemePinned\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 - createRelationshipType(config: Partial): names a kind of connection this community makes — \"contradicts\", \"came out of\". The counterpart to createSignalType; slug derived from name if blank\n - setSignalTypeRetired(signalTypeId: string, retired: boolean): withdraws a signal type from use, or brings it back. Never deletes the signals given with it — a signal names its type by record id while templates resolve it by slug, so DELETING a type strands every reaction ever given and re-creating one with the same slug does not restore them. Retiring is the reversible version: the type stops being offered, existing counts keep working, and un-retiring brings everything back. Filter the offered list with OFFERED_SIGNAL_TYPES from @we/template-kit; leave find()-by-slug unfiltered so history still resolves\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 - openRecordRef(ref: string): goes to whatever a record reference names — the space, and the record's own page within it. Takes the whole `we:…` reference rather than its parts, so nothing outside the host restates where a record's page lives. A reference naming only a dataset opens the space; a relative one (`we:./…`) resolves against the space on screen; a person has no page, so nothing happens\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 - copyGuestLink(uuid: string): copies that space's guest invite link — a URL that creates an account on the space's host and joins, with no sign-up and no download. Empty, and the control hidden, unless BOTH this app's origin and the node's URL are addresses a recipient could reach: a loopback address on either half resolves to the reader's own machine. Read `spaceList[].guestLink` to decide whether to offer it; `shareLink` is the one for somebody who already has WE\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 - exportCallTranscript(callId: string): writes the call's transcript to a .txt file (one line per utterance: name, timestamp, text) and downloads it. Read-only and client-side — it reads the shared record and writes to the caller's own device\n - removeSpaceFromGlobal(spaceUuid: string): withdraws a space's listing from the global discovery space, leaving the space itself alone. Only its author may; removeSpace does this for you\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, editable }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use filter(group.items, { name: { contains: local.search } }) for search since items have a flat name field. `editable` says whether editing THAT row would open a session that can be saved — gate a per-row edit control on it rather than on editorStore.isReadOnly, which answers for whichever template is currently rendered and so gives every row the same verdict.\n - currentSwitcherId: string — the id the template switcher should show as selected. The switcher's own spelling of the current template: it differs from currentTemplate.id while a space override or a preview is in effect\n - currentTemplate: TemplateSchema (the active template)\n - loading: boolean — the template lists are still being read. Gate empty states on it\n - defaultTemplateId: string — id of the agent's preferred default template, used where no space or override decides. Persisted to AgentSettings.defaultTemplateId\n - pendingInstall: the template an install dialog is showing ({ marketplaceId, destination, name, icon, version, capabilities, blocked }), or null when none is open. `capabilities` is already in the words a person reads. Host chrome renders it: a dialog vouching for a template must not be drawn by a template\n - operationLoading: string | null — the id of the template operation in flight, namespaced by kind ('marketplace-install:', 'space-install:'), or null. A key rather than a boolean so one row's spinner does not appear on every row\n- Actions:\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - deleteTemplate(templateId: string): permanently deletes a custom template from the library\n - installTemplate(templateId: string): marks an installed custom template visible in the pickers\n - uninstallTemplate(templateId: string): hides a custom template from the pickers without deleting it. The counterpart of installTemplate\n - installFromMarketplace(marketplaceTemplateId: string): copies a marketplace template into your own library. A personal act — use installToSpace to give the community a template. Asks first: the template is fetched and inspected, and the host raises a dialog naming what it will be able to do. Nothing is written until that is confirmed, so treat this as \"start an install\", not \"install\"\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. Asks first, exactly as installFromMarketplace does. Pair with templateStore.operationLoading to show progress on the row being installed\n - confirmInstall(): installs what the dialog is showing. Host chrome only, for the same reason pendingInstall is: a template able to call this is the disclosure being skipped\n - cancelInstall(): closes the install dialog without installing\n - toggleInstalled(templateId: string): installs or uninstalls by id — what the settings list’s switch calls. Prefer installTemplate/uninstallTemplate where the switch can pass its value\n - setDefaultTemplate(templateId: string): sets the agent's preferred default template (persists to AgentSettings.defaultTemplateId)\n - saveTemplate(name: string): saves the current template\n - saveTemplateAs(schema: TemplateSchema, destination?: 'root' | 'space'): saves a schema as a new template in your library or in the current space. Resolves true on success. The editor's fork path; prefer editorStore.startFork from chrome\n - publishToSpace(perspectiveUuid: string, spaceName: string): copies the current template into that space, so its members get it. Resolves true on success\n - deleteMarketplaceTemplate(templateId: string): removes a template this agent published from the marketplace. Only its author may\n - publishToMarketplace(options: { name, description, icon?, themeId?, slug?, screenshots: File[] }): publishes the current template to the marketplace under those details. Resolves true on success\n - refreshSpaceTemplates(): re-reads the current space's templates. The list follows the space on its own; call this after a publish the subscription might have missed\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - automaticThemes: array of ThemeData objects — modes that *resolve to* a theme rather than being one, currently just \"Follow system\". Listed separately because they carry no parameters: the id is answered at the point of use (by asking the OS) and resolves to one of the built-ins. Render them under their own heading, after the themes\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: EditingTheme | null — the theme being edited (id, name, icon, overrides, css, basePreset) or null when no theme editing session is open. Its non-nullness is what mounts the theme editor\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 - focusedRole: string — the kebab-case role the theme editor should scroll to ('surface-sunken'), or empty. Set by whatever sent somebody to the panel — the inspector's role readout — and re-announced on every set, so pressing the same chip twice scrolls twice\n - systemThemes: { light, dark, resolved } — which two themes 'Follow system' chooses between. light/dark are the ids as chosen, empty for a side left at the built-in; resolved is 'light' | 'dark', whichever the OS is asking for now\n - systemThemeOptions: { label, value }[] — options for either side of the Follow-system pair, with a \"Built-in\" entry a schema could not prepend itself\n - themeScope: 'global' | 'scoped' — what actually applies right now: the theme editor's session preview if one is active, else the agent's preference\n - themeScopePreference: 'global' | 'scoped' — the agent's persisted choice\n - themeScopeGlobal: boolean — the preference as a boolean, for a switch to bind to\n - themeScopePreviewing: boolean — a theme being edited is previewing a different scope, so the preference is temporarily masked. Worth saying so beside the setting\n - templateThemePending: boolean — the template's suggested theme cannot be resolved yet and might still arrive. Hold what is on screen rather than painting a fallback while it is true\n - useTemplateTheme: boolean — whether a template may bring the theme it was designed for (meta.themeId). Defaults on; one condition in the resolver rather than a setting to unwind, so turning it off restores whatever would otherwise apply immediately\n - activeTemplateTheme: ThemeData | null — the theme the scoped template wrapper renders: the theme being edited if there is one, else the space's. Null in global mode, where the template inherits the document's theme\n- Actions:\n - setCurrentTheme(themeId: string): sets and persists the active theme\n - setDefaultTheme(themeId: string): sets the preferred default theme (persists to AgentSettings.defaultThemeId)\n - focusRole(role: string): asks the theme editor to reveal a role, kebab-case as a schema spells it. Pair with editorStore.openThemePanel so there is a panel to scroll\n - setSystemTheme(polarity: 'light' | 'dark', themeId: string): sets one side of the Follow-system pair. An empty id returns that side to the built-in\n - setThemeInstalled(themeId: string, visible: boolean): shows or hides a custom theme in the pickers; does not delete it. Takes the value rather than toggling, so a `we-switch` can pass `event.detail` straight through\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; an expression over event in the args is evaluated when the switch fires, so pass `event.detail` bare\n - setUseTemplateTheme(enabled: boolean): persists whether templates may bring their own theme. Boolean, so a switch can pass `event.detail` bare\n - startEditing(themeId?: string): opens a theme editing session on that theme, or on the current one. Prefer editorStore.enterThemeEditing, which also opens the panel\n - changeBasePreset(preset: string | undefined): while editing, swaps the base preset the theme builds on — takes its polarity and lightness range and repopulates the controls from the preset's computed CSS\n - updateEditingOverrides(overrides: Partial): while editing, merges parameter changes (hues, saturation, lightness range, role pins) into the draft. Applied live\n - updateEditingCss(css: string): while editing, replaces the draft’s raw CSS layer. Applied live\n - updateEditingMeta(fields: { name?, icon? }): while editing, renames or re-icons the draft\n - cancelEditing(): ends the editing session and discards the draft, restoring what was applied before. Note the theme *panel* autosaves on unmount, so closing the panel keeps the draft — this is the explicit throw-away, and the only path that does\n - createAndStartEditing(name: string, icon: string, sourceId?: string, destination?: 'personal' | 'space'): creates a new theme — copied from sourceId when given — and opens an editing session on it. Resolves true on success\n - saveEditingTheme(): writes the draft over the theme being edited and keeps editing. Resolves to the saved theme, or null when nothing was being edited\n - saveEditingThemeAs(name: string, icon: string): writes the draft as a new theme under that name, leaving the original untouched\n - deleteTheme(themeId: string): permanently deletes a custom theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into your own library (installedThemes). A personal act — use installToSpace to give the community a theme\n - installToSpace(marketplaceThemeId: string): copies a marketplace theme into the current space, so every member of that community gets it. The counterpart to templateStore.installToSpace. Pair with themeStore.operationLoading to show progress on the row being installed\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteMarketplaceTheme(themeId: string): removes a theme this agent published from the marketplace. Only its author may\n - publishToMarketplace(options: { name, description, icon?, slug?, screenshots: File[] }): publishes the current theme to the marketplace under those details. Resolves true on success\n - publishToSpace(perspectiveUuid: string, spaceName: string): copies the current theme into that space, so its members get it. Resolves true on success\n - refreshSpaceThemes(): re-reads the current space's themes. The list follows the space on its own; call this after a publish the subscription might have missed\n\nRecord:\n- State:\n- Actions:\n - create(entity: string, fields: object, options?: { perspective?: string }): creates a record in the current space, or in the dataset a store path names ('datasetStore.rootDataset' for we-root entities). See \"Record mutations via $action\" above\n - update(entity: string, id: string, fields: object, options?: { perspective?: string }): updates the named fields of one record, leaving the rest\n - delete(entity: string, id: string, options?: { perspective?: string }): deletes one record. Irreversible\n\n---\n\n## Store Usage Patterns\n\nReading state — an expression naming the store:\n{ \"$\": \"storeName.property\" }\nExample: { \"$\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nFeature-module stores:\n{ \"$\": \"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\": { \"$\": \"spaceStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$\": \"`/space/${space.uuid}`\" }] }\n },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": { \"$\": \"space.avatar\" }, \"hash\": { \"$\": \"space.uuid\" }, \"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\": { \"$\": \"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{ \"$\": \"templateStore.templates.map(t, { name: t.meta.name, icon: t.meta.icon })\" }\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 externalEntities) 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\": [{ \"$\": \"`/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\": { \"$\": \"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\", \"value\": { \"$\": \"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()\" }, \"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 { \"$\": \"!formValid()\" }\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\": \"surface\", \"r\": \"400\", \"border\": \"1px solid border\", \"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\" }, \"hash\": { \"$\": \"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 a store read for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$\": \"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 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 the flag in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": { \"$\": \"post.highlighted\" }, \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor a conditional PROP, use a ternary in an expression — NOT a prop-level $if, which is a node type\nand resolves to a handler in a value position:\n{ \"bg\": { \"$\": \"post.highlighted ? 'accent-muted' : 'surface'\" } }\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(local.signalTypes, { slug: 'like' }).id\" }\n },\n \"count\": true\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"count(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(item.signals, { 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\": \"textFaint\" } },\n {\n \"type\": \"we-text\",\n \"props\": { \"color\": \"textFaint\", \"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{ \"$\": \"local.searchText ? 'No posts match your search.' : \"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(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` (`text-faint`,\nor `warning-text`) when there is not — the two read apart at a glance, and a dead end that looks\nlike an invitation is worse than one that looks like a dead end.\n\nThis line used to recommend `neutral-300`, and every gate prompt in the repo copied it. A scale\nposition is not frozen — it follows the theme's hue, saturation and polarity — but it cannot follow\nwhat a theme *decides* a faint foreground is, and the contrast corrections at apply time skip it\nentirely, so nothing ever measures it against what is behind it. Guidance that names a step\nreproduces that in every template written from it.\n\n### How wide is a modal — always `size`, never a pixel width\n\n`we-modal` sizes itself from a `size` prop, and **every modal should set one**:\n\n| `size` | Measure | For |\n|---|---|---|\n| `sm` | 420px | A confirmation, or one or two fields. |\n| `md` | 640px | **The default.** A form. |\n| `lg` | 900px | A workspace — a composer, a wizard, a card opened out to be read. |\n| `fullscreen` | The viewport, less a gutter | A lightbox, where the content is the size. |\n\nEach keeps a gutter between itself and the edge of the screen, so a modal on a phone is never\nedge-to-edge. Do **not** write `\"width\": \"100%\"` beside a `\"maxWidth\"` — that is what `size`\nreplaced, and `100%` of a viewport-wide host is the viewport.\n\nWithout a size, `[part='base']` shrink-wraps to its widest line of text: a short confirmation comes\nout too narrow to read and a wordy one too wide, from the same rule. `width`/`maxWidth` still\noverride `size` for the rare modal that genuinely needs its own number.\n\n### Confirm dialog\n\n**Use `confirmModal` from `@we/template-kit`.** Every \"are you sure?\" in WE goes through it, so\nthey share an icon, a heading, a width and a button row:\n\n```ts\nconfirmModal({\n open: { $: 'local.confirmDeleteOpen' },\n close: { $setLocal: 'confirmDeleteOpen', value: false },\n title: 'Delete post?',\n body: 'This will permanently delete the post and everything inside it. This cannot be undone.',\n confirmLabel: 'Delete',\n confirm: { $action: 'spaceStore.deleteCollection', args: [{ $: 'post.id' }] },\n})\n```\n\nIt returns the `$if` as well as the modal, and clears `open` from all three exits — the backdrop,\nCancel, and the action's `onSuccess`.\n\n- `open` and `close` are **expressions**, so a dialog gated on a store flag\n (`{ $: 'shapeStore.confirmDiscard' }`) or on a string id works the same way.\n- `cancel` for a cancel button that does more than close — \"Keep editing\" dismisses the question\n and leaves the wizard behind it open.\n- `tone: 'primary'` for a question with no casualty; the default `danger` picks a warning icon and\n a danger confirm button.\n- `detail` for a quieter second line, `children` for a `we-alert` naming a surprising consequence.\n- `busyLocal` if the action is not instant — a recursive delete walks its whole collection, and\n without a spinner the button absorbs the click and invites a second one. `busy` instead when a\n store already owns the flag.\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\n### A form in a modal\n\n**Use `formModal` from `@we/template-kit`** — title, fields, Cancel and Save:\n\n```ts\nformModal({\n open: { $: 'local.composerOpen' },\n close: { $setLocal: 'composerOpen', value: false },\n title: 'New task',\n size: 'sm',\n localState: { draftTitle: { type: 'string', initial: '' } },\n children: [field({ name: 'draftTitle', label: 'What needs doing?', placeholder: 'Ship the docs' })],\n disabled: { $: '!local.draftTitle' },\n submitLabel: 'Add task',\n submit: { $action: 'record.create', args: ['TaskBlock', { title: { $: 'local.draftTitle' } }] },\n})\n```\n\n- **Declare the draft in `localState`, not on the page.** The modal is mounted only while open, so\n the draft resets when it closes — for free. A draft declared higher up has to be cleared by hand\n in `onSuccess`, and the field somebody forgets is the one that re-opens holding last time's value.\n- `disabled` is the **precondition** only (\"a task needs a title\"); the in-flight flag is OR-ed\n in for you, so the Save button cannot start a second save.\n- It uses the header and footer slots, so a long form scrolls its fields and never its Save button.\n\nReach past it only for a form with real `validate` rules and a `{ \"$touch\": \"$all\" }` submit guard\n— that shape deliberately keeps the button clickable, and is written out by hand.\n\n### Don't lose what somebody typed — the discard guard\n\nA modal closes on a backdrop click and on Escape. Both are easy to hit by accident, and neither is\nrecoverable: the modal is `$if`-mounted, so closing unmounts the draft with it. **Any modal a\nperson can type into must ask before throwing that away.**\n\n| Writing | How |\n|---|---|\n| `formModal` | `discardWhen: ` |\n| `composerModal` | Nothing — on by default (`guardDraft: false` turns it off) |\n| A hand-written `we-modal` | `discardGuard({ dirty, close })` |\n\n`discardGuard` returns three pieces, because a modal cannot be guarded from outside it:\n\n```ts\nconst guard = discardGuard({\n dirty: { $: 'local.name || local.description' },\n close: { $action: 'shellStore.setCreateSpaceOpen', args: [false] },\n title: 'Discard this space?',\n body: 'The name, description and images you have entered will be lost.',\n});\n\n{ type: 'we-modal',\n props: { size: 'md', close: guard.close },\n $localState: { ...myFields, ...guard.localState },\n children: [ …the form…, guard.node ] }\n```\n\nWire the Cancel button to `guard.close` as well — one way out of a modal, not two that disagree.\n\n**Writing `dirty` is the part that goes wrong**, and always in one direction: a guard that fires\nwhen there is nothing to lose. A dialog people learn to click through is worse than no dialog.\n\n- **Test only what the person typed.** A field with a default and a picker — a status, a mode, a\n colour — is set from the first frame, so including it makes the guard fire on an untouched form.\n- **A form seeded from a record asks whether it _changed_**, not whether it is filled in:\n `{ \"$\": \"local.titleDraft != call.title\" }`, not `{ \"$\": \"local.titleDraft\" }`.\n- **Where the fields are not known in advance, a store answers** — `recordStore.recordDraftDirty`,\n `runtimeStore.aiFormDirty`.\n- **Leave it off a single-field form** (\"name this board\"). The guard costs more attention than one\n word is worth.\n\n**Tall modals — pin the title and buttons.** A modal whose content can outgrow the viewport (a\nlong form, a settings editor) scrolls its *content*, never its own title or its action buttons.\nGive the title node `\"slot\": \"header\"` and the button row `\"slot\": \"footer\"`: both are pinned\noutside the scroll region, sharing the modal's padding and gap, while the default slot scrolls.\n`confirmModal` and `formModal` already do this; write it out only for a modal that is neither.\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\": { \"size\": \"lg\", \"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\": { \"$\": \"datasetStore.currentDataset.handle\" },\n \"onReady\": { \"$setLocal\": \"savePost\", \"value\": { \"$\": \"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\", \"value\": { \"$\": \"event.detail\" } }\n }\n }\n ]\n}\n```\n\n`error()` is already empty until the field is touched, so it needs no condition 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\": \"text-muted\" } }\n ]\n }\n ]\n}\n```\n\nAlways set `hash` as well as `image`, never as a fallback for it — and seed it with an **id**, never\na name. `hash` is the stable thing a row *is*: a DID for a person, a uuid for a space. It does two\njobs. It colours the generated initials, so the colour survives a rename; and it draws an identicon\nwhen there are no initials to draw, which is the case it exists for — somebody whose profile has not\narrived has no name yet, and two unresolved peers must not be two identical blank discs.\n\nWhat `we-avatar` draws, in order: **a picture, else letters, else a generated pattern, else a\nglyph.** Letters outrank the pattern, so a row that has both shows its initials on a colour seeded\nfrom the hash. Seeding `hash` with a *name* is the mistake to avoid: it makes the colour change when\nsomebody renames the thing, which is identity art contradicting the identity.\n\n### A record of any type — rendering from the declaration\n\nA community can define a model this morning and record one this afternoon; the feed that lists it\nwas written before either. So a card cannot name the fields. It reads how the model asks to be\nshown — `recordStore.displays`, derived from the same declaration the form comes from — and draws\nwhatever is there:\n\n```json\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$query\": { \"entity\": \"Sighting\" } }, \"as\": \"row\" },\n \"children\": [\n {\n \"type\": \"Card\",\n \"$localState\": { \"display\": { \"type\": \"object\", \"initial\": { \"$\": \"recordStore.displays['Sighting']\" } } },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"local.display.media\" },\n \"then\": { \"type\": \"we-image\", \"props\": { \"src\": { \"$\": \"row[local.display.media]\" }, \"fit\": \"cover\", \"r\": \"media\" } }\n }\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [{ \"$\": \"row[local.display.title]\" }] },\n { \"type\": \"we-text\", \"props\": { \"color\": \"text-muted\" }, \"children\": [{ \"$\": \"row[local.display.summary]\" }] },\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$\": \"local.display.fields.filter(f, f.role == 'detail')\" }, \"as\": \"field\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\", \"color\": \"text-muted\" }, \"children\": [{ \"$\": \"field.label\" }] },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"field.kind == 'datetime' || field.kind == 'date'\" },\n \"then\": { \"type\": \"we-timestamp\", \"props\": { \"value\": { \"$\": \"row[field.name]\" }, \"relative\": true } },\n \"else\": {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"field.kind == 'boolean'\" },\n \"then\": { \"type\": \"we-badge\", \"children\": [{ \"$\": \"row[field.name] ? 'Yes' : 'No'\" }] },\n \"else\": { \"type\": \"we-text\", \"children\": [{ \"$\": \"row[field.name]\" }] }\n }\n }\n }\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n}\n```\n\nNothing here names a property of `Sighting`. `row[local.display.title]` reads whichever property the\ndeclaration (or the derivation) says is the title; the detail rows switch on `field.kind`, which is\nresolved once in the store so a template switches on one word. Add branches for `image`, `url`,\n`color` and `longText` as a layout needs them — the kinds are listed under `recordStore.displays`.\n\nThe `$localState` holding the display is a convenience: `recordStore.displays['Sighting']` could be\nread in place each time. For a feed of *mixed* types, index by the row instead —\n`recordStore.displays[row.type]` — and the same card draws every kind of record the space holds.\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\": { \"$\": \"spaceStore.members.map(m, { image: m.avatar, hash: m.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(spaceStore.members)\" }, \"shorten\": true } },\n { \"type\": \"we-text\", \"children\": [{ \"$\": \"plural(count(spaceStore.members), 'Member', 'Members')\" }] }\n ]\n }\n ]\n}\n```\n\n**When the items are bare DIDs rather than profiles**, join each to its profile inside the\ncomprehension — the variable is the DID itself:\n\n```json\n\"avatars\": { \"$\": \"spaceStore.memberDids.map(did, { image: find(profileStore.profiles, { did: did }).avatar, hash: did })\" }\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\": \"surfaceSunken\", \"border\": \"1px solid border\" },\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\": \"accentText\" } },\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\": \"text\" }, \"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### A rail that opens on hover — collapsed navigation\n\nThe shape of WE's shell sidebar: a narrow strip of icons that widens when pointed at, with the\nlabels opening sideways beside them.\n\nThree pieces of state do all of it, and none of it needs a component. The shell owns `expanded`\nand writes it from `onMouseEnter`/`onMouseLeave`; every label reads it. Groups hold their\ncollapsed ids in one `array` field rather than a boolean each, which is what lets the groups\nthemselves come from a `$query`.\n\n```json\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"expanded\": { \"type\": \"boolean\", \"initial\": false, \"persist\": \"shell.sidebarExpanded\" },\n \"collapsedGroups\": { \"type\": \"array\", \"initial\": [] }\n },\n \"props\": {\n \"width\": { \"$\": \"local.expanded ? '240px' : '80px'\" },\n \"transition\": \"width 300 ease-in-out\",\n \"height\": \"100%\",\n \"overflow\": \"hidden\",\n \"position\": \"fixed\",\n \"bg\": \"page\",\n \"onMouseEnter\": { \"$setLocal\": \"expanded\", \"value\": true },\n \"onMouseLeave\": { \"$setLocal\": \"expanded\", \"value\": false }\n },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": { \"variant\": \"ghost\", \"width\": \"100%\", \"ax\": \"start\", \"gap\": \"300\", \"p\": \"300\" },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"user\" } },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$\": \"local.expanded\" },\n \"enterTransition\": [{ \"type\": \"reveal\", \"axis\": \"inline\", \"duration\": 250 }, { \"type\": \"fade\", \"duration\": 150 }],\n \"exitTransition\": [{ \"type\": \"reveal\", \"axis\": \"inline\", \"duration\": 250 }, { \"type\": \"fade\", \"duration\": 150 }],\n \"then\": { \"type\": \"we-text\", \"props\": { \"truncate\": true }, \"children\": [\"Profile\"] }\n }\n }\n ]\n }\n ]\n}\n```\n\nThe label is mounted only while the rail is open rather than narrowed to nothing: at collapsed width\nthere is no room for it, and a hidden-but-present label is still in the accessibility tree and still\nfound by find-in-page.\n\nA group heading toggles its own id in the set, and its body reveals on the block axis:\n\n```json\n{\n \"type\": \"we-button\",\n \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocalIn\": \"collapsedGroups\", \"value\": \"spaces\" } },\n \"children\": [\n {\n \"type\": \"we-icon\",\n \"props\": { \"name\": { \"$\": \"'spaces' in local.collapsedGroups ? 'caret-right' : 'caret-down'\" } }\n },\n { \"type\": \"we-text\", \"children\": [\"Spaces\"] }\n ]\n}\n```\n\n**Prefer `railShell` / `railGroup` / `railItem` from `@we/template-kit`**, which own all\nof the above. `railItem` and `railGroup` read `expanded` from the shell above them, so they\nare only valid inside a `railShell`.\n\nFor drag-to-reorder, wrap a group's items in `we-sortable` and give each row a `data-we-id` on\na **native element** — a web component's props are assigned as DOM properties, so the attribute\n`we-sortable` looks for would never exist on a `we-button`. Listen with `onReorder` and read\nthe new order from `{ \"$\": \"arg.detail\" }`.\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\": { \"$\": \"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\": [{ \"$\": \"`/${arg.detail.value}`\" }] } }\nThis replaces all per-tab onClick handlers but requires an interpolation 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## Panels\n\nA **panel** is a floating or docked surface the host places over a template's content: it can be\nmoved, resized, closed, and it survives navigation. A template declares which panels its interface\nhas and where each one starts, in `meta.panels`.\n\n### When something should be a panel\n\n**A region is a panel if any of these is true:**\n\n- it must float over other content\n- the reader must be able to move or resize it, and have that remembered\n- it must be closable\n- it must survive navigation\n- it competes with other panels for a screen edge\n\n**Otherwise it is ordinary layout** — `Column` / `Row` / `Grid`, in flow, inside the route,\narranged by its parent.\n\nThis matters more than it looks. A dashboard where nothing overlaps, nothing is dragged and nothing\npersists position is a `Grid` of cards, and building it from panels instead gives something\nbusier, harder to read and worse on a phone. **Do not reach for a panel because somebody said the\nword \"panel\"** — reach for one when a region needs to move, close, float or outlive the route.\n\n### Declaring them\n\n```json\n{\n \"meta\": {\n \"name\": \"Workshop\",\n \"description\": \"…\",\n \"icon\": \"…\",\n \"panels\": [\n { \"id\": \"transcript\", \"module\": \"transcribe\", \"snap\": \"left\", \"order\": 0, \"size\": \"sm\", \"grow\": 1 },\n { \"id\": \"notes\", \"node\": { \"type\": \"Column\", \"children\": [\"…\"] }, \"title\": \"Notes\", \"snap\": \"left\", \"order\": 1, \"grow\": 0 },\n { \"id\": \"inspector\", \"node\": { \"…\": \"…\" }, \"snap\": \"right\", \"size\": \"md\" }\n ]\n }\n}\n```\n\nTwo kinds of entry, one list:\n\n- **`module`** places a panel a feature module already contributes, and opens it. The module still\n owns what is inside it.\n- **`node`** supplies the content itself. Use `title` to name it in the titlebar.\n\n| Field | What it means |\n| ---------- | ---------------------------------------------------------------------------------------------- |\n| `id` | **Stable, and yours to choose.** Where the reader drags a panel is remembered per id. |\n| `snap` | One of `top-left` `top` `top-right` `left` `right` `bottom-left` `bottom` `bottom-right`. |\n| `order` | Position *along* the edge among the panels sharing its lane — lower is nearer the start. |\n| `band` | Which lane, counting inward from the edge. `displace` only. Absent means a lane of its own. |\n| `size` | `sm` `md` `lg` `full`. Named, never pixels: only the host can see the viewport. |\n| `grow` | Share of the *spare* room in a lane, relative to lane-mates. Absent means 1; 0 pins a size. |\n| `displace` | Push the content aside instead of covering it. Edge snaps only — ignored on a corner. |\n| `tab` | Position in a seat shared with others — entries with the same lane and `order` are tabs. |\n| `home` | Start **in the template**, at the `$panels` outlet of this name. See \"Home lanes\" below. |\n| `fixed` | Not promotable: no break-out grip, no drop can move it. For a section with no standalone value. |\n| `min` | `{ width?, height? }` in pixels — the smallest box the content is usable in. A fact, not a size. |\n| `route` | Only while one of these segments is in the path — a segment or a list. Absent means every route. |\n| `open` | Whether to open it as well as place it. Absent means yes — see the warning below. |\n\n**`open: false` when a module's launcher does more than open a panel.** Placing a `module` panel\ninvokes the action its launcher declares, and that is not always \"open a panel\" — the call module's\nis `goToCall`, which *joins a call* when there is not one. Declaring the call window without\n`open: false` would start a call the moment somebody entered the space.\n\n**Never write pixels.** A template cannot see the viewport, and a guessed pixel is wrong on a\ndisplay it never ran on. That is what `size` and `grow` are for.\n\n### An edge is two axes\n\n**`band` is how far inboard, `order` is how far along.** A **lane** is a band across the edge;\nthe panels sharing one divide it along its length. Between them the two numbers say everything an\nedge can hold:\n\n```\n lanes of one panel each one lane of two a lane of one, then a lane of two\n ┌────┬────┬──────────┐ ┌─────────┬──────────┐ ┌────┬─────┬──────────┐\n │ │ │ │ │ A │ │ │ │ B │ │\n │ A │ B │ content │ ├─────────┤ content │ │ A ├─────┤ content │\n │ │ │ │ │ B │ │ │ │ C │ │\n └────┴────┴──────────┘ └─────────┴──────────┘ └────┴─────┴──────────┘\n band 0 band 1 band 0, order 0/1 band 0 band 1, order 0/1\n```\n\n- **`band` is for panels that `displace`.** Two that name the same band are one sidebar cut into\n pieces: they share a width, meet flush, and cost the content that width **once**. Two that name\n different bands stack inward and cost it both.\n- **Absent `band` means a lane of its own**, after every lane that named one. So a declaration that\n says nothing about lanes gets the old behaviour — panels stacking inward — rather than silently\n halving each other.\n- **A floating panel has no band.** It takes no room, so there is nothing to be inboard of: every\n float on an edge already shares one lane, and `order` divides it. Two panels snapped `left`\n share the height, one above the other.\n\nA lane divides by base size and `grow`: spare room goes out by grow ratio. \"The transcript takes\nmost of the height and the panel under it keeps its own\" is a large panel with `grow` and a small\none with `grow: 0`.\n\n**Seats.** Two entries in one lane with the same explicit `order` share a seat: one shows, the rest\nstack behind it as tabs, and the one showing carries a strip naming them all. `tab` orders the\nstrip. Absent `order` is a seat of its own, so nothing that never said `order` starts sharing.\nA seat has **one size**: joining one means taking it, and resizing one member resizes all of them, so\nreading along the strip never reshapes the panel.\n\nA stack the reader drags into open space, or into a corner, stays a stack — it just stops being a\nplace and starts being a name, since there is no lane left to imply membership from. That is a\nreader's doing and not something a template can declare: **declare seats with `order`, in a lane.**\n\nBelow 900px of window width nothing displaces and a floating lane becomes one seat — every member a\nfull-bleed sheet, tabbed, since two narrow cards over content leave nothing of either.\n\n### Home lanes — sections that start in the template\n\nPicture-in-picture, for any region of a page. A **home lane** is a lane in the template's own flow:\nan outlet in the tree says where it is, and `meta.panels` entries say which sections start there.\n\n```json\n{\n \"meta\": {\n \"panels\": [\n { \"id\": \"feed\", \"node\": { \"…\": \"…\" }, \"home\": \"main\", \"order\": 0, \"fixed\": true },\n { \"id\": \"trending\", \"node\": { \"…\": \"…\" }, \"home\": \"right\", \"order\": 0, \"title\": \"Trending\" },\n { \"id\": \"people\", \"node\": { \"…\": \"…\" }, \"home\": \"right\", \"order\": 1, \"title\": \"Who to follow\" }\n ]\n },\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"$panels\", \"props\": { \"lane\": \"left\", \"width\": \"280px\" } },\n { \"type\": \"$panels\", \"props\": { \"lane\": \"main\", \"flex\": \"1\" } },\n { \"type\": \"$panels\", \"props\": { \"lane\": \"right\", \"width\": \"320px\", \"accepts\": \"trending,people\" } }\n ]\n}\n```\n\nA section renders inline, with no frame, indistinguishable from the layout around it — until the\nreader hovers it, when a grip appears in its corner. Dragging the grip breaks the section out into\na panel under the pointer; clicking it breaks it out to its declared `snap`. It is then an\nordinary panel: it can be docked, folded, stacked, saved in a layout — and its position menu offers\n\"Return to page\". While it is away the outlet shows a placeholder in its place with the same offer.\n\n- **`$panels` is a layout node.** It takes `lane` (required), `direction` (`column` by default, or\n `row`), `accepts` (section ids it will take, comma-separated; empty means any), and the ordinary\n layout props — `width`, `flex`, `minWidth` — which is how a template holds room for an empty lane.\n- **Reordering within a lane is arrangement, not editing.** Dragging one section above another\n rewrites `order` on the reader's placement; nothing touches the tree, and \"Reset layout\" restores\n the author's order. Dragging a section to another lane changes `home`; to an edge, `snap`.\n- **A lane holds sections; a section does not hold a lane.** A `$panels` inside a panel's node is\n refused. Fixed depth is what keeps every position a few integers, which is what keeps a\n template's declaration a suggestion a drag can overrule.\n- **Not every region should be a section.** The test is standalone value: would somebody want it\n beside a *different* page? Trending, yes; the compose box, no — say `fixed: true`, or leave it as\n ordinary layout. A template where every region grows a corner grip reads as a widget grid. And\n `$each` over collections is content, never lanes: a kanban's columns are data.\n- **A lane is not a Column.** Reach for one when a region should be movable by the reader. Two\n Columns side by side that nobody will ever rearrange are two Columns.\n\n### Placing a module's own pieces\n\nA module publishes **named parts** and composes its own panel out of them, so an interface that\nwants them arranged differently places the pieces rather than copying them:\n\n```json\n{ \"type\": \"$part\", \"props\": { \"id\": \"transcribe.transcriptFeed\" } }\n```\n\n`subject` points a part at a different record from the one its module is about — a transcript feed\nover a call somebody opened from a link rather than the one being recorded:\n\n```json\n{ \"type\": \"$part\", \"props\": { \"id\": \"transcribe.transcriptFeed\", \"subject\": { \"$\": \"routeStore.params.call\" } } }\n```\n\nA part naming a module nobody has installed renders nothing and reports itself, the same way a\ncontribution to an unprovided anchor does. Placing the module's *whole* panel is still\n`{ \"module\": \"\" }` in `meta.panels`; parts are for building something else out of it.\n\n**Supplying a module's panel yourself.** A `meta.panels` entry carrying **both** `module` and\n`node` means \"that module's panel, arranged here\": the module goes on deciding whether the surface\nis up and how big it is, and the entry decides what is inside. Without it, an interface that wrote\nits own version got *two* panels — the module opens its own when its state says so, and it had no\nway to know somebody else was already showing it.\n\n### It is a suggestion, not a setting\n\n`meta.panels` is the middle rung of three. Whatever the reader last dragged a panel to wins; then\nthe template's declaration; then the module's own opening bid. The declaration is resolved live and\nnever written, so switching template or section is non-destructive.\n\nA shell that routes itself — every showcase template does — scopes a declaration with `route`\ninstead, since it has no sections to hang one on. `route` says **whether**, never **where**: a\npanel that changed position from one page to the next would work until the reader dragged it once,\nsince a stored placement is keyed by template and panel rather than by route and outranks every\ndeclaration. A page that genuinely needs its own arrangement wants to be a view.\n\nA **section** (`meta.role: 'view'`) may declare panels too, and should when the layout is about\nthat section rather than the whole interface — a graph wants a transcript beside it and an inbox\ndoes not. The shell's declaration wins on a collision of `id`.\n\n### Fixed chrome\n\nIf a shell pins its own bar or nav strip over the content, declare the band it occupies so floating\npanels clear it:\n\n```json\n{ \"meta\": { \"chromeReserve\": { \"top\": 56, \"width\": 420 } } }\n```\n\nReport the height it has when **collapsed**. Chrome that grows as somebody opens a disclosure would\notherwise shove a floating panel down the screen mid-read.\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 expressions ({ \"$\": \"…\" }) for anything computed and handler tokens ($action, $setLocal, …) for anything that happens on an event; a plain string is always text.\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 `