diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index dd29f1ac..72ecf264 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - 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) - 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 - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dd29f1ac..72ecf264 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - 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) - 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 - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/CLAUDE.md b/CLAUDE.md index dd29f1ac..72ecf264 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - 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) - 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 - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/eslint.config.js b/eslint.config.js index 5610e04a..c7952e58 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,115 @@ import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; import prettierConfig from './.prettierrc.json' with { type: 'json' }; +/** + * CSS properties (in both camelCase and kebab-case spellings) that have a design-system + * prop of the same meaning. Sourced from the DS prop layers in + * `packages/design-system/utils/src/index.ts`. + * + * Properties with no DS equivalent — white-space, filter, clip-path, backdrop-filter, + * mix-blend-mode, grid-template-*, object-fit — are deliberately absent: `styles` is the + * correct tool for those. + */ +const DS_PROP_EQUIVALENTS = [ + 'width', + 'height', + 'minWidth', + 'min-width', + 'maxWidth', + 'max-width', + 'minHeight', + 'min-height', + 'maxHeight', + 'max-height', + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'zIndex', + 'z-index', + 'display', + 'overflow', + 'flex', + 'alignSelf', + 'align-self', + 'margin', + 'marginTop', + 'margin-top', + 'marginRight', + 'margin-right', + 'marginBottom', + 'margin-bottom', + 'marginLeft', + 'margin-left', + 'padding', + 'paddingTop', + 'padding-top', + 'paddingRight', + 'padding-right', + 'paddingBottom', + 'padding-bottom', + 'paddingLeft', + 'padding-left', + 'gap', + 'background', + 'backgroundColor', + 'background-color', + 'color', + 'opacity', + 'border', + 'borderColor', + 'border-color', + 'borderRadius', + 'border-radius', + 'boxShadow', + 'box-shadow', + 'cursor', + 'pointerEvents', + 'pointer-events', + 'transform', + 'transition', + 'fontSize', + 'font-size', + 'fontWeight', + 'font-weight', + 'lineHeight', + 'line-height', + 'letterSpacing', + 'letter-spacing', + 'textAlign', + 'text-align', + 'textDecoration', + 'text-decoration', + 'textTransform', + 'text-transform', + 'fontFamily', + 'font-family', + 'flexDirection', + 'flex-direction', + 'alignItems', + 'align-items', + 'justifyContent', + 'justify-content', +]; + +/** Elements that accept DS props: `we-*` primitives and the layout/composite components. */ +const DS_ELEMENTS = '^(we-|Column$|Row$|Grid$|Card$)'; + +function dsPropSelectors() { + const props = `^(${DS_PROP_EQUIVALENTS.join('|')})$`; + const message = + 'This CSS property has a design-system prop — use it instead of the style/styles escape hatch ' + + '(e.g. width="130px", maxHeight="250px"). Reserve styles for CSS with no DS equivalent.'; + // Object keys appear as identifiers (width) or string literals ('max-height'). + return ['key.name', 'key.value'].map((keyPath) => ({ + selector: + `JSXOpeningElement[name.name=/${DS_ELEMENTS}/] > JSXAttribute[name.name=/^styles?$/] ` + + `> JSXExpressionContainer > ObjectExpression > Property[${keyPath}=/${props}/]`, + message, + })); +} + export default [ { ignores: [ @@ -85,4 +194,39 @@ export default [ '@typescript-eslint/no-empty-object-type': 'off', }, }, + { + // Design-system props over the style/styles escape hatch. + // + // CLAUDE.md ("Using the Design System in TypeScript Components") says to use `styles` + // only for CSS with no DS equivalent — but that only held while people remembered it, + // and the editor panels drifted a long way. This makes the common half mechanical: + // if a CSS property has a DS prop of the same meaning, writing it through style/styles + // is an error. + // + // Scoped deliberately: + // - only elements that accept DS props (we-* primitives, Column/Row/Grid/Card). + // A raw
has no DS prop to use instead, so it is out of scope. + // - only DS-consuming packages. @we/components and @we/widgets *implement* DS props + // and legitimately write raw CSS; the React playgrounds have no DS at all. + name: 'design-system/prefer-ds-props', + files: ['packages/app-framework/**/*.tsx', 'packages/block-system/**/*.tsx', 'packages/schema-system/**/*.tsx'], + rules: { + 'no-restricted-syntax': ['error', ...dsPropSelectors()], + }, + }, + { + // Pre-existing violations, to be swept file by file. Delete entries as they are + // cleaned — the list only ever shrinks, and new files get the rule from birth. + name: 'design-system/prefer-ds-props-debt', + files: [ + 'packages/app-framework/src/frameworks/solid/components/editor/AiPanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/DesignToolbar.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/RightPanelContainer.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/ThemePanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/marketplace/TemplateCard.tsx', + 'packages/block-system/frameworks/solid/src/components/VideoBlock/VideoDisplay.tsx', + ], + rules: { 'no-restricted-syntax': 'off' }, + }, ]; diff --git a/packages/ai-context/context.json b/packages/ai-context/context.json index 0a8881e8..07a55fae 100644 --- a/packages/ai-context/context.json +++ b/packages/ai-context/context.json @@ -5335,11 +5335,11 @@ }, "personalSpaces": { "type": "array", - "properties": ["uuid", "name", "description", "url", "visibility"] + "model": "Space" }, "sharedSpaces": { "type": "array", - "properties": ["uuid", "name", "description", "url", "visibility"] + "model": "Space" }, "bootState": { "type": "string" @@ -5512,7 +5512,7 @@ }, "currentSpace": { "type": "object", - "properties": ["uuid", "name", "description", "avatar", "defaultTemplateId"] + "model": "Space" }, "foreignSpacePrefill": { "type": "object", @@ -5520,6 +5520,7 @@ }, "signalTypes": { "type": "array", + "model": "SignalType", "properties": [ "id", "name", diff --git a/packages/ai-context/src/contextData.ts b/packages/ai-context/src/contextData.ts index c86764da..74c42ba9 100644 --- a/packages/ai-context/src/contextData.ts +++ b/packages/ai-context/src/contextData.ts @@ -2019,8 +2019,8 @@ export const contextData: ContextData = { currentPerspective: { type: 'object', properties: ['uuid', 'name', 'sharedUrl'] }, currentPerspectiveModels: { type: 'array' }, isWeSpace: { type: 'boolean' }, - personalSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, - sharedSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, + personalSpaces: { type: 'array', model: 'Space' }, + sharedSpaces: { type: 'array', model: 'Space' }, bootState: { type: 'string' }, passwordError: { type: 'string' }, loginLoading: { type: 'boolean' }, @@ -2120,10 +2120,11 @@ export const contextData: ContextData = { properties: ['did', 'firstName', 'lastName', 'handle', 'bio', 'avatar', 'coverImage', 'location'], }, spaceDefaultTemplateId: { type: 'string' }, - currentSpace: { type: 'object', properties: ['uuid', 'name', 'description', 'avatar', 'defaultTemplateId'] }, + currentSpace: { type: 'object', model: 'Space' }, foreignSpacePrefill: { type: 'object', properties: ['name', 'description', 'avatar'] }, signalTypes: { type: 'array', + model: 'SignalType', properties: [ 'id', 'name', diff --git a/packages/ai-context/src/fragments/stores.ts b/packages/ai-context/src/fragments/stores.ts index d9827834..d5a2cf8c 100644 --- a/packages/ai-context/src/fragments/stores.ts +++ b/packages/ai-context/src/fragments/stores.ts @@ -17,8 +17,8 @@ export const storeEntries: StoreEntry[] = [ currentPerspective: { type: 'object', properties: ['uuid', 'name', 'sharedUrl'] }, currentPerspectiveModels: { type: 'array' }, isWeSpace: { type: 'boolean' }, - personalSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, - sharedSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, + personalSpaces: { type: 'array', model: 'Space' }, + sharedSpaces: { type: 'array', model: 'Space' }, bootState: { type: 'string' }, passwordError: { type: 'string' }, loginLoading: { type: 'boolean' }, @@ -124,10 +124,11 @@ export const storeEntries: StoreEntry[] = [ properties: ['did', 'firstName', 'lastName', 'handle', 'bio', 'avatar', 'coverImage', 'location'], }, spaceDefaultTemplateId: { type: 'string' }, - currentSpace: { type: 'object', properties: ['uuid', 'name', 'description', 'avatar', 'defaultTemplateId'] }, + currentSpace: { type: 'object', model: 'Space' }, foreignSpacePrefill: { type: 'object', properties: ['name', 'description', 'avatar'] }, signalTypes: { type: 'array', + model: 'SignalType', properties: [ 'id', 'name', @@ -243,8 +244,8 @@ function generateStoresText(entries: StoreEntry[]): string { 'ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)', isWeSpace: "boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)", - personalSpaces: 'array of Space objects (local/personal spaces)', - sharedSpaces: 'array of Space objects (shared/neighbourhood spaces)', + personalSpaces: 'array of Space objects (local/personal spaces; all Space fields)', + sharedSpaces: 'array of Space objects (shared/neighbourhood spaces; all Space fields)', bootState: 'string', passwordError: 'string | undefined', loginLoading: 'boolean', @@ -347,7 +348,8 @@ function generateStoresText(entries: StoreEntry[]): string { members: 'AgentProfileSummary[] — cached profiles for all memberDids', spaceDefaultTemplateId: "string — the current space's default template ID (empty string when no space is active)", - currentSpace: 'Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId)', + 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)', 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', signalTypes: 'array of SignalType objects (community-created reaction/vote types)', diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index c78347c0..af8cf3be 100644 --- a/packages/ai-context/src/schemaContext.ts +++ b/packages/ai-context/src/schemaContext.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED by packages/ai-context/src/generate.ts // Do not edit manually. Run: pnpm --filter @we/ai-context generate-context -export const schemaContext = "## Schema Structure\n\nA schema is a tree of nodes. Each node can have:\n- type: The component to render (string, e.g. \"we-button\", \"Column\")\n- props: An object of props for the component\n- children: An array of child nodes (or strings for text), or token objects like { $store: '...' } or { $concat: [...] }.\n- slots: Named slots for advanced composition (optional)\n- slot: The name of the slot this node should be rendered into (optional)\n- routes: For routing components, an array of nestable route objects (optional)\n- styles: Raw CSS escape hatch — Record applied as inline styles on a **wrapper div** that surrounds the component. Use only for CSS that must live on a wrapper: filter, clip-path, backdrop-filter, mix-blend-mode. When present the wrapper participates in layout (no display:contents), so CSS effects apply correctly. **Important:** this is NOT the same as props.styles. If you want to apply custom CSS to a Column, Row, or Grid's own element (e.g. a background image), put it in props.styles instead — node-level styles go on a wrapper div around the component and will be hidden behind the component's own background.\n\nExample node:\n{\n \"type\": \"we-button\",\n \"props\": {\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"house\" } },\n { \"type\": \"we-text\", \"props\": { \"size\": \"600\" }, \"children\": [\"Home\"] }\n ]\n}\n\n## Prop-level Dynamic Logic & Expressions\n\nSpecial tokens in props enable dynamic, reactive, or computed behavior.\n\nStore reference:\n{ \"$store\": \"storeName.property.path\" }\nResolves a value from a named store, supporting nested paths.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nSupports async lifecycle callbacks — fired after the store method's Promise resolves/rejects:\n onSuccess: [...actions] — fired on resolve; '$result' (and '$result.') in args refers to the resolved value\n onError: [...actions] — fired on reject; '$result.message' etc. refers to the error object\n onFinally: [...actions] — fired regardless of outcome\nNon-promise (synchronous) methods are unaffected — lifecycle keys are ignored.\nExample — close modal after async submission:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$result.uuid\"] }] }] }\n\nModel mutations via $action (use these for creating/updating/deleting model instances):\nmodel.create — creates a model instance in the current perspective (default) or a specified one:\n{ \"$action\": \"model.create\", \"args\": [\"ModelName\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nmodel.update — updates a model instance:\n{ \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'adamStore.rootPerspective' for we-root models (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped models (Space, Signal, etc.).\n\nConditional logic:\n{ \"$if\": { \"condition\": ..., \"then\": ..., \"else\": ... } }\nEvaluates condition; if truthy, returns then, else returns else.\n\nMap/iterate:\n{ \"$map\": { \"items\": { \"$store\": \"templateStore.templates\" }, \"select\": { ... } } }\nIterates over an array, mapping each item to a new object using the select mapping.\n\nPick:\n{ \"$pick\": { \"from\": { \"$store\": \"userStore.profile\" }, \"props\": [\"name\", \"email\"] } }\nPicks specific properties from an object.\n\nConcat (string building):\n{ \"$concat\": [\"part1\", \"$context.value\", \"part2\"] }\nJoins multiple parts into a single string.\n\nContext references:\nStrings starting with \"$\" followed by a context key resolve to context values.\nExample: \"$space.name\" resolves to the name property of the space context variable.\nDot paths supported: \"$item.profile.avatar\".\n\nEquality / inequality checks:\n{ \"$eq\": [a, b] } — strict equality\n{ \"$ne\": [a, b] } — strict inequality\n\nNumeric comparisons:\n{ \"$lt\": [a, b] } — a < b (less than)\n{ \"$gt\": [a, b] } — a > b (greater than)\nExample: { \"$gt\": [{ \"$count\": { \"items\": { \"$store\": \"listStore.items\" } } }, 0] }\n\nSet membership:\n{ \"$in\": [value, array] } — true if array contains value (false if second operand is not an array)\nExample: { \"$in\": [{ \"$store\": \"spaceStore.uuid\" }, { \"$store\": \"adamStore.systemPerspectiveUuids\" }] }\nExample: { \"$in\": [\"$item.role\", [\"admin\", \"moderator\"]] }\n\nBoolean logic:\n{ \"$and\": [a, b, ...] } — all truthy\n{ \"$or\": [a, b, ...] } — any truthy\n{ \"$not\": a } — negation\n\nArray operators:\n{ \"$filter\": { \"items\": , \"where\": { \"field\": \"value\", ... } } }\nFilters an array to items where all where conditions match. Mirrors the $query where operator set:\n\n { \"field\": \"value\" } — strict equality\n { \"field\": { \"not\": \"value\" } } — inequality; array form excludes multiple values\n { \"field\": { \"contains\": \"text\" } } — case-insensitive substring match (strings only)\n { \"field\": { \"exists\": true } } — non-null / non-undefined presence check\n { \"field\": { \"exists\": false } } — null or undefined check\n\nWhere values (including those inside operator objects) are resolved through the prop system,\nso $store, $local, and context refs like { \"$local\": \"searchText\" } all work.\n\n$query-only logical combinators (OR / AND / NOT) — NOT supported in $filter, only in $query's where:\n { \"OR\": [ { \"field\": \"value\" }, { \"field2\": \"value2\" } ] } — matches if ANY branch matches\n { \"AND\": [ { ... }, { ... } ] } — matches if ALL branches match (sibling keys at the\n same level are already implicitly ANDed — use AND\n to group a set of conditions alongside an OR/NOT)\n { \"NOT\": { \"field\": \"value\" } } — matches if the branch does NOT match\nBranches are full where-clause objects (can contain multiple fields, and can nest OR/AND/NOT inside each other).\nSibling keys alongside OR/AND/NOT at the same level are implicitly ANDed with it.\nExample — case-insensitive search across two fields:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"where\": {\n \"OR\": [\n { \"name\": { \"contains\": { \"$local\": \"searchText\" } } },\n { \"description\": { \"contains\": { \"$local\": \"searchText\" } } }\n ]\n }\n }\n}\nNote: using OR/AND/NOT disables the SPARQL-level sort/pagination pushdown (see count-projection and\nrelation-property ordering below) — those orderings silently stop working if combined with OR/AND/NOT in the\nsame query's where clause, because the fallback sort runs before the projection/relation data is attached.\n\nExamples:\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"role\": \"admin\" } } }\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"location\": { \"exists\": true }, \"handle\": { \"contains\": { \"$local\": \"searchText\" } } } } }\n\n{ \"$count\": { \"items\": } }\nReturns the length of an array.\nExample: { \"badge\": { \"$count\": { \"items\": { \"$store\": \"notificationStore.unread\" } } } }\n\n{ \"$find\": { \"items\": , \"where\"?: { ... }, \"select\"?: \"fieldName\" } }\nFinds the first matching item. where is optional (returns first item if omitted). select plucks a single field.\nExample: { \"$find\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"id\": \"$item.creatorId\" }, \"select\": \"name\" } }\n\n{ \"$plural\": { \"count\": , \"one\": \"singular\", \"other\": \"plural\" } }\nReturns \"one\" when count === 1, otherwise \"other\". Use in children arrays for count-noun labels.\ncount is resolved through the prop system — any numeric expression ($count, $store, context ref) works.\nExample: { \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }\nCompose with we-number for a full \"N Members\" display:\n we-number (value: { \"$count\": ... }, shorten: true) + we-text (children: [{ \"$plural\": { \"count\": { \"$count\": ... }, \"one\": \"Member\", \"other\": \"Members\" } }])\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"ModelName\", \"where\": { \"field\": \"value\" }, \"limit\": 10, \"order\": { \"field\": \"asc\" } } }\nQueries the current dataset for entity instances. Always returns an array.\nOptions: entity (required), where, order, limit, offset, include, scope, dataset, subscribe.\nsubscribe defaults to true — reactive live updates. Set subscribe: false to do a one-time fetch.\nBy default $query targets the current dataset ($currentDataset). Use dataset to query a different dataset —\nrequired when reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } }\n\nBackend-neutral identity & dataset refs — prefer these over adamStore.* 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- $me — the current agent's identity object. Use $me.did for their DID (ownership checks, author filters, e.g. { \"$eq\": [\"$post.author\", \"$me.did\"] }); $me.handle / $me.avatar for profile fields once loaded.\n\nEager-loading relations with include (most common relational pattern):\ninclude hydrates related model instances in the same query — no extra fetches needed.\nRelation names come from the HasMany relations listed for each model in externalModels.\n\nSimple include — hydrate all related instances:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": true } } }\nEach item in the result will have a conversations array of hydrated Conversation objects.\n\nSub-query include — filter, sort, or limit the related records:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"order\": { \"createdAt\": \"desc\" }, \"limit\": 10 } } } }\n\nNested include — hydrate relations of relations:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"include\": { \"messages\": true } } } } }\nNesting can go as deep as needed. Each level adds one batched fetch (not N+1).\n\nCount projection — add a derived numeric field:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } } } }\nThe $-prefixed key becomes a new field on each result item (e.g. item.$likeCount = 42).\n\nSorting by a count projection — order can reference a $-prefixed count key directly, sorting by the aggregate:\n{\n \"$query\": {\n \"entity\": \"Post\",\n \"limit\": 20,\n \"order\": { \"$likeCount\": \"desc\" },\n \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } }\n }\n}\nRequirements: only a single order key is supported when it targets a projection (mixing it with a second sort key falls back\nto a plain property sort), and the query must also specify limit or offset — without one the count isn't computed yet at\nsort time and the order silently has no effect. Always pair count-projection ordering with a limit.\nCombine with $if for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": {\n \"$if\": {\n \"condition\": { \"$eq\": [{ \"$local\": \"sortField\" }, \"likes\"] },\n \"then\": { \"$likeCount\": { \"$local\": \"sortDirection\" } },\n \"else\": { \"createdAt\": { \"$local\": \"sortDirection\" } }\n }\n }\n}\n\nSorting by a related model property — order can reference a dotted \"relation.property\" path for a HasOne/HasMany\nrelation declared on the model, sorting by a scalar property on the related instance:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"limit\": 20,\n \"order\": { \"location.country\": \"asc\" },\n \"include\": { \"location\": true }\n }\n}\nSame requirements as count-projection ordering above: only a single order key, and pair with limit/offset — without\none the relation data isn't attached yet at sort time and the order silently has no effect. include isn't required\nfor the sort itself (the relation is resolved from the model's declared shape), but you'll usually want it anyway to\nread the field in the UI (e.g. \"$space.location.country\").\nCombine with $if the same way as count-projection ordering to let the user toggle between sort fields.\n\nSingle-item projection — add a derived field that resolves to one instance or null:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$myLike\": { \"from\": \"likes\", \"where\": { \"author\": \"$me.did\" }, \"limit\": 1 } } } }\nWith limit: 1 the field unwraps to T | null instead of an array.\n\ninclude only works with typed relations — ones where the target model class is known.\nFor WE models this is always the case. For external models, check the externalModels listing:\nrelations marked \"→ ModelName\" are typed (safe for include); relations marked \"parent query only\"\nare untyped and will crash at runtime if used with include — use a scope drill-down instead.\n\nRelational queries — fetch a parent record's children (drill-down navigation):\n{ \"$query\": { \"entity\": \"Conversation\", \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": \"$channel.id\" } } }\nscope.anchor is the parent entity type; scope.via is its relation whose targets are this query's entity (the\nHasMany relation listed for that entity in externalModels); scope.anchorId is the parent record's id (typically\nfrom a $each context variable or a route segment). The adapter resolves the relation to a backend handle —\nno protocol details live in the template.\nUse this pattern when navigating to a detail route and loading only that record's children.\nFor external-app datasets, always add dataset: \"$currentDataset\".\n\nLocal state (scoped ephemeral state):\nDeclare on any node: \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\" } }\nSupported types: \"string\", \"boolean\", \"number\", \"function\", \"object\".\nRead: { \"$local\": \"name\" } — returns the signal value (reactive).\n { \"$local\": \"name.nested.path\" } — dot-notation reads into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"from\": \"$event.target.value\" } — event handler that updates the signal.\n { \"$setLocal\": \"name\", \"value\": \"literal\" } — sets to a literal value (string, number, boolean, null, object).\n { \"$setLocal\": \"name\", \"merge\": { \"field\": \"$event.detail\" } } — shallow-merges fields into an object-typed signal. Values are resolved as event paths (e.g. \"$event.detail\") or passed as literals. Use for partial updates to object state.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nCall function: { \"$callLocal\": \"fieldName\" } — event handler that calls the function stored in a function-typed local field.\n Used when a child component needs to trigger a callback passed in via $localState.\n The field must be declared as type: 'function' and set via $setLocal.\n Example: { \"onClick\": { \"$callLocal\": \"onConfirm\" } }\nState is created on mount and destroyed on unmount. Nested $localState declarations merge, inner fields shadow outer.\n$local values can be used in $action args: { \"$action\": \"store.method\", \"args\": [{ \"$local\": \"name\" }] }\n\nObject-typed local state (consolidating related scalar fields):\nWhen several related fields share a common condition on their initial values (e.g. all null/empty when a store value is absent), prefer a single \"object\" field seeded from the store, then read sub-fields with dot-notation and write with merge.\nExample — location object (replaces 5 separate scalar fields with $if guards):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$store\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$local\": \"location.latitude\" }, { \"$local\": \"location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"from\": \"$event.detail\" }\n Write (partial edit): { \"$setLocal\": \"location\", \"merge\": { \"city\": \"$event.detail\" } }\n Write (clear): { \"$setLocal\": \"location\", \"value\": null }\n Condition (has location): { \"$local\": \"location\" }\nUse \"object\" whenever you would otherwise write 3+ related scalar fields each needing $if on their initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results in $local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available for $if conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into $local as read-only reactive arrays, accessible via { \"$local\": \"signalTypes\" }.\nQuery options are identical to $each's $query prop (entity, where, order, limit, include, dataset, subscribe).\n$queries and $localState share the same $local namespace — avoid duplicate names across both.\n$setLocal will warn and no-op on $queries entries (they are read-only).\nUse with $count + $gt for conditional visibility:\n{ \"condition\": { \"$gt\": [{ \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } }, 0] } }\nExample:\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [...]\n }\n ]\n}\n\nBoolean toggle pattern (show/hide comments, expand/collapse sections, etc.):\n{\n \"$localState\": { \"showComments\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$toggleLocal\": \"showComments\" }\n },\n \"children\": [{ \"type\": \"we-icon\", \"props\": { \"name\": \"chat-circle\" } }]\n },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"showComments\" },\n \"then\": { \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Comments visible\"] }] }\n }\n }\n ]\n}\n\nForm validation (extends $localState):\nDeclare validation rules on fields:\n\"$localState\": {\n \"email\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [\n { \"rule\": \"required\", \"message\": \"Email is required\" },\n { \"rule\": \"pattern\", \"value\": \"^[^@]+@[^@]+$\", \"message\": \"Invalid email\" }\n ]\n }\n}\n\nBuilt-in rules: required, minLength (value: N), maxLength (value: N), min (value: N), max (value: N), pattern (value: regex string), match (field: otherFieldName). All accept optional \"message\" override.\n\nRead tokens:\n{ \"$error\": \"fieldName\" } — first validation error message (only shown after field is touched), or \"\".\n{ \"$valid\": \"fieldName\" } — true if all rules pass (regardless of touched state).\n{ \"$touched\": \"fieldName\" } — true after the field has been blurred/touched.\n{ \"$formValid\": \"$scope\" } — true if ALL validated fields in the current $localState scope pass.\n\nAction tokens:\n{ \"$touch\": \"fieldName\" } — marks a single field as touched (use in onBlur).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. Non-function entries (e.g. $if with false condition) are skipped.\nPrefer onSuccess over a bare $setLocal before the $action — the bare form closes the modal immediately (losing the loading spinner); onSuccess waits for the Promise to resolve.\n\nTypical form pattern:\n{\n \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] } },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a context key. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nConditional rendering:\n{ \"type\": \"$if\", \"props\": { \"condition\": ..., \"then\": { ... }, \"else\": { ... } } }\nRenders \"then\" node if condition is truthy, else renders \"else\" node.\nSupports enterTransition / exitTransition for CSS animations when the node mounts/unmounts.\nTransitionConfig = TransitionEffect | TransitionEffect[]\nTransitionEffect = { type: 'fade'|'slide'|'scale'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string }\nfade controls opacity only; slide/scale control transform only. pulse is a persistent looping animation (not a one-shot transition) — starts once entered, stops on exit; direction/distance don't apply (default duration 1200ms, easing 'ease-in-out'). Compose fade/slide/scale together in an array; pulse is typically used alone.\nExample: enterTransition: [{ type: 'fade', duration: 300 }, { type: 'slide', direction: 'up', distance: '40px', duration: 400 }]\nExample (pulse): enterTransition: { type: 'pulse', duration: 1500 }\n\nViewport / mount animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"scrollReveal\"?: true | number, \"scrollLeave\"?: true | number, \"scrollPast\"?: string, \"enterTransition\"?: TransitionConfig, \"exitTransition\"?: TransitionConfig }, \"children\": [] }\nThe child is always mounted. fade/slide/scale are CSS transitions (opacity/transform); pulse is a real CSS @keyframes loop — use this for scroll-reveal effects.\nDo NOT use $animate when the child should be absent from the DOM. Use $if for conditional DOM presence.\nscrollReveal: true fires enterTransition when the element enters the viewport.\nscrollReveal: -100 fires 100px before the element would enter (negative = earlier reveal).\nscrollLeave fires exitTransition when the element leaves the viewport.\nscrollPast: \"element-id\" observes a sentinel element (by DOM id) instead of the $animate element itself.\n enterTransition fires when the sentinel leaves the viewport (user scrolled past it).\n exitTransition fires when the sentinel returns (user scrolled back up).\n Use this for sticky headers: place a zero-height sentinel div at the bottom of the non-sticky header section,\n then wrap the mini-profile in $animate with scrollPast pointing to that sentinel's id.\n scrollPast is mutually exclusive with scrollReveal/scrollLeave.\nWithout any scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (scroll-reveal):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollReveal\": -100,\n \"enterTransition\": [\n { \"type\": \"fade\", \"duration\": 600, \"easing\": \"ease-in-out\" },\n { \"type\": \"slide\", \"direction\": \"left\", \"distance\": \"200px\", \"duration\": 1000, \"easing\": \"ease-in-out\" }\n ]\n },\n \"children\": [{ \"type\": \"SomeCard\", \"children\": [] }]\n}\nExample (sticky header mini-profile):\nPlace a sentinel at the bottom of the header, reference it in the sticky nav:\n{ \"type\": \"div\", \"props\": { \"id\": \"header-sentinel\" }, \"styles\": { \"height\": \"0px\", \"pointerEvents\": \"none\" } }\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollPast\": \"header-sentinel\",\n \"enterTransition\": { \"type\": \"fade\", \"duration\": 250 },\n \"exitTransition\": { \"type\": \"fade\", \"duration\": 200 }\n },\n \"children\": [{ \"type\": \"Row\", \"props\": { \"ay\": \"center\", \"gap\": \"300\" }, \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"600\" }, \"children\": [\"$space.name\"] }\n ]}]\n}\n\nSingle model item (load one record, render children with it in context):\n{\n \"type\": \"$single\",\n \"props\": {\n \"item\": { \"$query\": { \"entity\": \"ModelName\", \"params\": { ... }, \"subscribe\": true } },\n \"as\": \"profile\" // context key for children — default: 'item'\n },\n \"children\": [{ \"type\": \"we-text\", \"children\": [\"$profile.username\"] }]\n}\nRenders nothing until a matching record is found. Like $each but for a single result.\nquery options (entity, params, include, dataset, subscribe) work identically to $query.\n\nRoute outlet:\n{ \"type\": \"$routes\" }\nIndicates where nested routes should render within a layout.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', dismissible: boolean = false\n- we-audio (LayoutVisualElement)\n Props: src: string = '', controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', selected: boolean = false, online: boolean = false, initials: string = '', icon: string = '', size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | '{css-length}' | undefined, clickable: boolean = false\n- we-badge (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-blockquote (DesignSystemElement)\n- we-button (DesignSystemElement)\n Props: variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', text?: string | undefined, href?: string | undefined, disabled: boolean = false, loading: boolean = false, gradient: boolean = false, square: boolean = false\n- we-checkbox (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-code (DesignSystemElement)\n Props: block: boolean = false\n- we-color-picker (DesignSystemElement)\n Props: value: string = '#000000', disabled: boolean = false, name: string = '', palette: array = [ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc', ]\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', placeholder: string = 'Select date', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-divider (LayoutElement)\n Props: orientation: 'horizontal' | 'vertical' = 'horizontal', variant: 'solid' | 'dashed' | 'dotted' = 'solid', color?: string | undefined, thickness?: string | undefined\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-file-upload (DesignSystemElement)\n Props: accept: string = '', multiple: boolean = false, disabled: boolean = false, name: string = ''\n- we-form-field (DesignSystemElement)\n Props: label: string = '', description: string = '', error: string = '', required: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-html (DesignSystemElement) — Renders a raw HTML string safely via DOMPurify sanitization.\n\nUse this instead of `we-text` when content is stored as HTML (e.g. rich-text\neditor output such as Flux messages). The `content` prop accepts any HTML\nfragment; it is sanitized before rendering so XSS payloads are stripped.\n Props: content: string = ''\n- we-icon (LayoutElement)\n Props: name: string = '', color: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '{css-length}' = '', weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' = 'regular', gradient: string = ''\n- we-icon-picker (DesignSystemElement)\n Props: value: string = '', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', placeholder: string = 'Pick icon'\n- we-iframe (LayoutVisualElement)\n Props: src: string = '', title: string = 'Embedded content', allow: string = '', sandbox?: string | undefined\n- we-image (LayoutVisualElement)\n Props: src: string | File = '', alt: string = '', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', loading: 'eager' | 'lazy' = 'eager', gradient: string = '', objectPosition: string = ''\n- we-input (DesignSystemElement)\n Props: value: string = '', max: string = '', min: string = '', maxlength: unknown = Infinity, minlength: number = 0, pattern: string = '', name: string = '', step: string = '', placeholder: string = '', autocomplete: string = '', autofocus: boolean = false, disabled: boolean = false, required: boolean = false, readonly: boolean = false, type: string = 'text', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-number (DesignSystemElement) — Displays a number, optionally abbreviated (1 200 → 1.2K, 1 500 000 → 1.5M).\n Props: value: number = 0, shorten: boolean = false, precision: number = 1, locale: string = 'en', formattedValue: string\n- we-number-input (DesignSystemElement)\n Props: value: number = 0, min: number = -Infinity, max: unknown = Infinity, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-pagination (DesignSystemElement)\n Props: page: number = 1, total: number = 1, siblings: number = 1, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-popover (LayoutElement) — Low-level floating panel anchored to a trigger element.\nUse DropdownMenu component for dropdown menus.\n Props: open: boolean = false, placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom', popoverElement: HTMLElement, triggerElement: HTMLElement\n- we-progress-bar (DesignSystemElement)\n Props: value: number = 0, max: number = 100, variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-radio (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = ''\n- we-select (DesignSystemElement)\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-select (DesignSystemElement) — Pick a single value from a list of options. Custom-rendered dropdown.\nUse for form fields, settings, filters. Set searchable=true for type-to-filter.\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-skeleton (DesignSystemElement)\n Props: width: string = '100%', height: string = '20px', animation: 'pulse' | 'wave' = 'pulse'\n- we-slider (DesignSystemElement)\n Props: value: number = 0, min: number = 0, max: number = 100, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — Drag-to-reorder container primitive.\n\nUsage: wrap a list of elements that each have a `data-we-id` attribute.\nFires a `we-reorder` CustomEvent on drop with the new ordered\narray of IDs.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = ''\n- we-spinner (LayoutElement)\n Props: size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) = 'md', color: string = ''\n- we-switch (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', labelOff: string = '', labelOn: string = ''\n- we-tab (DesignSystemElement)\n Props: key: string = '', selected: boolean = false, label?: string | undefined, selectedProps?: Partial | undefined\n- we-tabs (DesignSystemElement)\n Props: selectedKey: string = ''\n- we-tag (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', dismissible: boolean = false\n- we-text (DesignSystemElement)\n Props: text?: string | undefined, variant: '' | 'body' | 'label' | 'footnote' | 'subheading' | 'ingress' | 'heading-sm' | 'heading-md' | 'heading-lg' | 'heading-xl' = '', tag: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small' | 'b' | 'i' | 'label' | 'div' = 'span', inline: boolean = false, uppercase: boolean = false, italic: boolean = false, truncate: boolean = false, gradient: string = ''\n- we-textarea (DesignSystemElement)\n Props: value: string = '', name: string = '', placeholder: string = '', rows: number = 3, maxlength: unknown = Infinity, minlength: number = 0, disabled: boolean = false, required: boolean = false, readonly: boolean = false, resize: 'none' | 'vertical' | 'horizontal' | 'both' = 'vertical', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-timestamp (DesignSystemElement) — Displays a formatted or relative timestamp that self-updates each minute\nwhen `relative` is enabled.\n Props: value: string = '', relative: boolean = false, locale: string = 'en', dateStyle: Intl.DateTimeFormatOptions['dateStyle'] | null = null, timeStyle: Intl.DateTimeFormatOptions['timeStyle'] | null = null, weekday: Intl.DateTimeFormatOptions['weekday'] | null = null, year: Intl.DateTimeFormatOptions['year'] | null = null, month: Intl.DateTimeFormatOptions['month'] | null = null, day: Intl.DateTimeFormatOptions['day'] | null = null, hour: Intl.DateTimeFormatOptions['hour'] | null = null, minute: Intl.DateTimeFormatOptions['minute'] | null = null, second: Intl.DateTimeFormatOptions['second'] | null = null, timeZone: string | null = null, hourCycle: Intl.DateTimeFormatOptions['hourCycle'] | null = null, formattedTime: string\n- we-tooltip (LayoutElement)\n Props: open: boolean = false, title: string = '', placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'top', tooltipEl: HTMLElement, triggerEl: HTMLElement, arrowEl: HTMLElement\n- we-video (LayoutVisualElement)\n Props: src: string = '', poster?: string | undefined, controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\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?: any, perspective?: PerspectiveProxy | null, onSave?: ((json: SerializedBlockNode) => void), onReady?: ((api: { save: () => void; }) => void)\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, rootClass?: string\n- BlockToolbar\n Props: placement?: BlockToolbarPlacement, children: JSX.Element, stopPropagation?: boolean\n- CalloutDisplay\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined\n- CalloutInput\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CodeDisplay\n Props: code: string | undefined, language: string | undefined, title: string | undefined\n- CodeInput\n Props: code: string | undefined, language: string | undefined, title: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CollectionDisplay\n Props: layout?: string, columnCount?: number, gap?: string, childEditorState?: any\n- CollectionInput\n Props: nodeKey: string, layout?: string, columnCount?: number, gap?: string, childEditorState?: any, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- DividerDisplay\n Props: style: \"solid\" | \"dashed\" | \"dotted\" | undefined\n- DividerInput\n Props: style: DividerVariant | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EmbedDisplay\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined\n- EmbedInput\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EventDisplay\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined\n- EventInput\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- FileDisplay\n Props: title: string | undefined, name: string | undefined, url: string | undefined, mimeType: string | undefined, size: number | undefined\n- FileInput\n Props: title: string | undefined, name: string | undefined, url: string | FileData | undefined, mimeType: string | undefined, size: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- ImageDisplay\n Props: src: string | undefined, altText: string | undefined, width: number | undefined, height: number | undefined\n- ImageInput\n Props: src: string | FileData | undefined, altText: string | undefined, width: number | undefined, height: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LinkDisplay\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined\n- LinkInput\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LocationDisplay\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined\n- LocationInput\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TagDisplay\n Props: name: string | undefined, color: string | undefined\n- TagInput\n Props: name: string | undefined, color: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TaskDisplay\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined\n- TaskInput\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- VideoDisplay\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined\n- VideoInput\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- Accordion\n Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record\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- Breadcrumbs\n Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record\n- Calendar\n Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record\n- Card (DesignSystemElement)\n- CircleButton\n Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record\n- CollapsedContent\n Props: collapsed: boolean, onExpandClick?: (() => void), showToggle?: boolean, icon?: string, maxHeight?: string, fadeColor?: string, children?: JSX.Element, class?: string, styles?: Record\n- Column (DesignSystemElement)\n- Combobox (DesignSystemElement)\n Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.\n Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\n- IconLabelButton\n Props: icon: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, label: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, selected?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, iconWeight?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, onClick?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>, class?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, styles?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>\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- List\n Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record\n- PostCard\n Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record\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- Stepper\n Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: \"horizontal\" | \"vertical\", styles?: Record\n- Table\n Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record\n- Timeline\n Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], 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- CesiumGlobe — 3D globe widget using CesiumJS with a modular layer system.\nLayers are injected via factory functions (planet surface + background).\nRequires a layer factory registry mapping string names to factory functions.\nNot schema-renderable — used directly in application code.\n Props: ionAccessToken?: string, planetLayers?: LayerConfig[], backgroundLayers?: LayerConfig[], layerFactoryRegistry: Record>\n- CollapsibleSidebar\n Props: header?: JSX.Element, footer?: JSX.Element, items: CollapsibleSidebarItem[], footerItems?: CollapsibleSidebarItem[], side?: \"left\" | \"right\", position?: \"static\" | \"absolute\" | \"fixed\", zIndex?: number, collapsedWidth?: string, expandedWidth?: string, defaultExpanded?: boolean, expandOnHover?: boolean, transitionDuration?: number, bg?: string, border?: string, padding?: string, gap?: string, centerItems?: boolean, itemColor?: string, itemColorHover?: string, itemColorActive?: string, itemBg?: string, itemBgHover?: string, itemBgActive?: string, itemPadding?: string, itemGap?: string, badgeBg?: string, badgeColor?: string, iconSize?: IconSize, onItemClick?: ((item: CollapsibleSidebarItem) => void), onExpandedChange?: ((expanded: boolean) => void)\n- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering.\nDisplays typed nodes (user, space, post) and edges (follows, member-of, etc.)\nwith configurable styling, layout forces, and interaction handlers.\n Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig\n- SpaceSidebarWidget\n Props: name: string, description?: string, class?: string, style?: Record\n\n---\n\n## Design System Props\n\nMost @we/primitives inherit **all** layers below. Props use design token values — not raw CSS.\n\n### Token Value Reference\n\n| Token Type | Valid Values |\n|---|---|\n| SpaceValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length e.g. \"16px\") |\n| ColorValue | \"{hue}-{shade}\" where hue = neutral, primary, success, warning, danger and shade = 0, 25, 50, 75, 100, 200–900, 1000. Also \"white\", \"black\". (or CSS color) |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length) |\n| ShadowValue | \"sm\", \"md\", \"lg\", \"xl\" |\n| FontSizeValue | \"base\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length) |\n| FontFamilyValue | \"base\" (or CSS font-family) |\n| LineHeightValue | \"none\", \"tight\", \"snug\", \"normal\", \"relaxed\", \"loose\" (or CSS value) |\n| LetterSpacingValue | \"tighter\", \"tight\", \"normal\", \"wide\", \"wider\", \"widest\" (or CSS value) |\n| FontWeightValue | Named tokens: \"regular\" (400), \"medium\" (500), \"semibold\" (600), \"bold\" (700). Numeric: \"100\"–\"900\". CSS pass-through: \"light\", \"normal\", \"bolder\". |\n\n**Layout-only primitives** — these accept only Layout props (not Visual, Flex, Typography, or State):\nwe-divider, we-icon, we-menu-group, we-popover, we-spinner, we-tooltip\n\n### Layout\n\n| Prop | Type | Description |\n|------|------|-------------|\n| width | string | Element width |\n| height | string | Element height |\n| minWidth | string | Minimum width |\n| minHeight | string | Minimum height |\n| maxWidth | string | Maximum width |\n| maxHeight | string | Maximum height |\n| position | \"relative\" \\| \"absolute\" \\| \"fixed\" \\| \"sticky\" | CSS position |\n| top | string | Top offset |\n| right | string | Right offset |\n| bottom | string | Bottom offset |\n| left | string | Left offset |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" | Overflow behavior |\n| m | SpaceValue | Margin (all sides) |\n| mx | SpaceValue | Margin left + right |\n| my | SpaceValue | Margin top + bottom |\n| mt | SpaceValue | Margin top |\n| mr | SpaceValue | Margin right |\n| mb | SpaceValue | Margin bottom |\n| ml | SpaceValue | Margin left |\n\n### Visual\n\n| Prop | Type | Description |\n|------|------|-------------|\n| bg | ColorValue | Background color (token) |\n| bgImage | string | Background image URL — sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\n| r | RadiusValue | Border radius (all corners) |\n| rt | RadiusValue | Border radius top |\n| rb | RadiusValue | Border radius bottom |\n| rl | RadiusValue | Border radius left |\n| rr | RadiusValue | Border radius right |\n| rtl | RadiusValue | Border radius top-left |\n| rtr | RadiusValue | Border radius top-right |\n| rbr | RadiusValue | Border radius bottom-right |\n| rbl | RadiusValue | Border radius bottom-left |\n\n### Flex (Container)\n\n| Prop | Type | Description |\n|------|------|-------------|\n| direction | \"row\" \\| \"row-reverse\" \\| \"column\" \\| \"column-reverse\" | Flex direction |\n| ax | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Main-axis alignment |\n| ay | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Cross-axis alignment |\n| wrap | boolean | Enable flex wrap |\n| gap | SpaceValue | Gap between children (token) |\n| p | SpaceValue | Padding (all sides) |\n| px | SpaceValue | Padding left + right |\n| py | SpaceValue | Padding top + bottom |\n| pt | SpaceValue | Padding top |\n| pr | SpaceValue | Padding right |\n| pb | SpaceValue | Padding bottom |\n| pl | SpaceValue | Padding left |\n\n### Typography\n\n| Prop | Type | Description |\n|------|------|-------------|\n| textAlign | \"left\" \\| \"center\" \\| \"right\" \\| \"justify\" | Text alignment |\n| fontFamily | \"base\" \\| {css-font-family} | Font family token |\n| fontWeight | \"regular\" \\| \"medium\" \\| \"semibold\" \\| \"bold\" (named tokens) or \"100\"–\"900\" (numeric) or \"light\" \\| \"normal\" \\| \"bolder\" (CSS pass-through) | Font weight |\n| fontSize | \"base\" \\| \"100\"–\"1000\" \\| {css-length} | Font size token |\n| lineHeight | \"none\" \\| \"tight\" \\| \"snug\" \\| \"normal\" \\| \"relaxed\" \\| \"loose\" | Line height token |\n| letterSpacing | \"tighter\" \\| \"tight\" \\| \"normal\" \\| \"wide\" \\| \"wider\" \\| \"widest\" | Letter spacing token |\n| textDecoration | \"underline\" \\| \"line-through\" \\| \"overline\" \\| \"none\" | Text decoration |\n| textTransform | \"uppercase\" \\| \"lowercase\" \\| \"capitalize\" \\| \"none\" | Text transform |\n\n**Typography defaults:** fontSize and fontWeight have **no built-in defaults** — omitting them inherits from parent elements (browser default is ~16px / normal weight). Do not set fontSize or fontWeight unless you need a non-default value. For example, `fontSize: '300'` (16px) and `fontWeight: '500'` (normal) are the inherited defaults — omit them.\n\n`we-text` variants (set via the `variant` prop) bundle typography presets. Always pair with a semantic `tag` prop for correct HTML structure:\nbody (300, tag: p/span), label (200 + medium, tag: span), footnote (100, tag: span), subheading (400 + medium, tag: h5/p), ingress (400 + lineHeight 1.6, tag: p), heading-sm (500 + bold, tag: h4), heading-md (600 + bold, tag: h3), heading-lg (700 + bold, tag: h2), heading-xl (800 + bold, tag: h1).\nVariants set size and weight only — color is always inherited or set explicitly. For muted footnote text add `color=\"neutral-400\"` explicitly.\n\n### State\n\n| Prop | Type | Description |\n|------|------|-------------|\n| hoverProps | Partial\\ | Styles on :hover |\n| activeProps | Partial\\ | Styles on :active |\n| focusProps | Partial\\ | Styles on :focus |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'multiplier', 'subtractor', 'saturation', 'neutralSaturation'\n\ncolor.hues: 'neutral', 'primary', 'success', 'warning', 'danger'\n\ncolor.lightness: '0', '25', '50', '75', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\ncomponent.scrollbar: 'width', 'backgroundImage', 'background', 'cornerBackground', 'thumbBoxShadow', 'thumbBorderRadius', 'thumbBackground'\n\ncomponentHeight: 'xs', 'sm', 'md', 'lg', 'xl'\n\neffect.depth: '100', '200', '300', '400', '500', 'none'\n\nfont.family: 'base', 'mozilla', 'boldonse'\n\nfont.letterSpacing: 'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'\n\nfont.lineHeight: 'none', 'tight', 'snug', 'normal', 'relaxed', 'loose'\n\nfont.size: '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000', 'base'\n\nfont.weight: '100', '200', '300', '400', '500', '600', '700', '800', '900', 'regular', 'medium', 'semibold', 'bold'\n\nlayout: 'xs', 'sm', 'md', 'lg'\n\nradius: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'pill', 'full'\n\nshadow: 'sm', 'md', 'lg', 'xl'\n\nsize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nspace: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\nzIndex: 'dropdown', 'sticky', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nAgentSettings extends Ad4mModel:\n Fields:\n - currentTemplateId: string = 'default' [we://current_template]\n - defaultTemplateId: string = 'default' [we://default_template]\n - currentThemeId: string = 'default' [we://current_theme]\n - defaultThemeId: string = 'default' [we://default_theme]\n - claudeApiKey: string [we://claude_api_key]\n - perspectiveOrder: string [we://perspective_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 Relations:\n - installedTemplates: HasMany → Template [we://installed_template]\n - installedThemes: HasMany → Theme [we://installed_theme]\n - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference]\n\nAudioBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - artist: string [we://artist]\n - audioUrl: string (required) [we://audio_url]\n - duration: number [we://duration]\n - albumArt: string [we://album_art]\n - version: number [we://version]\n\nCalloutBlock extends WeNode:\n Fields:\n - text: string [we://text]\n - variant: string = info [we://variant]\n - icon: string [we://icon]\n - version: number [we://version]\n\nChatMessage extends WeNode:\n Fields:\n - role: string [we://role]\n - content: string [we://content]\n\nChatSession extends WeNode:\n Fields:\n - name: string [we://name]\n - templateId: string [we://template_id]\n Relations:\n - messages: HasMany → ChatMessage [we://chat_message]\n\nCodeBlock extends WeNode:\n Fields:\n - code: string (required) [we://code]\n - language: string [we://language]\n - title: string [we://title]\n - version: number [we://version]\n\nCollectionBlock extends WeNode:\n Fields:\n - editorState: string = null [we://editor_state]\n - type: string [we://type]\n - display: string [we://display]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - columns: number [we://columns]\n - gap: string [we://gap]\n - version: number [we://version]\n - textContent: string [we://text_content]\n Relations:\n - children: HasMany [we://children]\n\nDividerBlock extends WeNode:\n Fields:\n - style: string = solid [we://style]\n - version: number [we://version]\n\nEmbedBlock extends WeNode:\n Fields:\n - url: string [we://url]\n - target: string [we://target]\n - targetType: string [we://target_type]\n - displayMode: string = card [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - startDate: string (required) [we://start_date]\n - endDate: string [we://end_date]\n - location: string [we://location]\n - allDay: boolean = false [we://all_day]\n - version: number [we://version]\n\nFileBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - name: string (required) [we://name]\n - url: string (required) [we://url]\n - mimeType: string [we://mime_type]\n - size: number [we://size]\n - version: number [we://version]\n\nImageBlock extends WeNode:\n Fields:\n - src: string (required) [we://src]\n - altText: string [we://altText]\n - width: number [we://width]\n - height: number [we://height]\n - version: number [we://version]\n\nLinkBlock extends WeNode:\n Fields:\n - url: string (required) [we://url]\n - title: string [we://title]\n - description: string [we://description]\n - thumbnail: string [we://thumbnail]\n - version: number [we://version]\n\nLocationBlock extends WeNode:\n Fields:\n - name: string [we://name]\n - latitude: number (required) [we://latitude]\n - longitude: number (required) [we://longitude]\n - address: string [we://address]\n - city: string [we://city]\n - countryCode: string [we://country_code]\n - country: string [we://country]\n - version: number [we://version]\n\nSignal extends Ad4mModel:\n Fields:\n - signalTypeId: string [we://signal_type_id]\n - value: number [we://value]\n\nSignalType extends WeNode:\n Fields:\n - name: string [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - iconSecondary: string [we://icon_secondary]\n - step: number = 1 [we://step]\n - rangeMin: number [we://range_min]\n - rangeMax: number = 1 [we://range_max]\n - mode: SignalMode = 'toggle' [we://mode]\n - aggregate: SignalAggregate = 'count' [we://aggregate]\n - semantic: SignalSemantic = 'custom' [we://semantic]\n - allowChange: boolean = true [we://allow_change]\n - valueType: string = 'numeric' [we://signal_value_type]\n - schemaVersion: number = 1 [we://schema_version]\n\nSpace extends WeNode:\n Fields:\n - uuid: string [we://uuid]\n - url: string [we://url]\n - name: string (required) [we://name]\n - description: string (required) [we://description]\n - access: string = 'personal' [we://access]\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 Relations:\n - location: HasOne [we://location]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - type: string [we://type]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - textFormat: number [we://textFormat]\n - textStyle: string [we://textStyle]\n - listType: string [we://listType]\n - start: number [we://start]\n - tag: string [we://tag]\n - text: string [we://text]\n - version: number [we://version]\n\nTheme extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - slug: string [we://slug]\n - version: number = 1 [we://version]\n - css: string = null [we://stylesheet]\n - overrides: string = null [we://token_overrides]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nVideoBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - url: string (required) [we://url]\n - duration: number [we://duration]\n - thumbnail: string [we://thumbnail]\n - provider: string [we://provider]\n - version: number [we://version]\n\nWeNode extends Ad4mModel:\n Relations:\n - comments: HasMany [we://comment]\n - signals: HasMany → Signal [we://signal]\n\n---\n\n## Stores\n\nStores provide state (readable values) and actions (methods) for dynamic logic in schemas.\nAccess state with $store and call actions with $action.\nFor ephemeral/form state, use $localState/$local/$setLocal instead of stores (see Dynamic Logic).\n\nAdamStore:\n- State:\n - adamClient: Ad4mClient | undefined\n - me: Agent | undefined\n - allPerspectives: array of PerspectiveProxy objects (all AD4M perspectives)\n - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed)\n - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)\n - personalSpaces: array of Space objects (local/personal spaces)\n - sharedSpaces: array of Space objects (shared/neighbourhood spaces)\n - bootState: string\n - passwordError: string | undefined\n - loginLoading: boolean\n - creatingSpace: boolean (true while a new space is being created)\n - agents: AgentProfileSummary[] — cache of all fetched agent profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownAgent: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from agents cache)\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n- Actions:\n - navigate(to: string, options?): navigates to a route\n - addNewSpace(space: Space): adds a new space\n - createSpace(name: string, description: string, shared: boolean, imageFile?: File): creates a new space with full setup\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native perspective (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the perspective is already a published neighbourhood\n - switchPerspective(uuid: string): switches to a perspective by UUID, registers its SHACL models as dynamic model classes, and populates currentPerspectiveModels\n - removePerspective(uuid: string): removes a perspective by UUID\n - reorderPerspectives(newOrder: string[]): reorders the sidebar items by UUID array\n - login(password: string): logs in the agent with password\n - logout(): locks the agent and returns to login screen\n - fetchAgent(did: string): fetches and caches an agent's profile from their public AD4M perspective\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to public perspective\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads image to FILE_STORAGE_LANGUAGE and publishes expression URL to public perspective\n - updateAgentLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges location update into cache and publishes to public perspective\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a perspective that accumulated duplicate SDNA installs (e.g. from before joinSpace checked for existing SDNA before installing) — removes the redundant duplicate link copies. Defaults to the current perspective. 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\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n- Actions:\n - navigate(to: string, options?): navigates to a route\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - installedThemes: array of ThemeData objects — user-installed themes from root perspective (origin: \"custom\" | \"marketplace\")\n - spaceThemes: array of ThemeData objects — themes stored in the current space perspective (origin: \"custom\")\n - allThemes: array of ThemeData objects — union of builtInThemes + visible installedThemes + spaceThemes (hidden themes filtered out)\n - currentThemeId: string — id of the currently active theme\n - currentTheme: ThemeData — the currently active theme object (id, name, icon, origin)\n - defaultThemeId: string — id of the user's preferred default theme (used for bootscreen, shell, and future space-override). Persisted to AgentSettings.defaultThemeId\n - themeManagementList: ThemeManagementItem[] — flat list of all themes (built-in + all custom) with management metadata (id, name, icon, isBuiltIn, isInstalled, isDefault)\n- 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 - toggleThemeInstalled(themeId: string): toggles a custom theme visible/hidden in pickers; does not delete the theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteTheme(themeId: string): permanently deletes a custom theme\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 - shellTemplates: array of TemplateSchema objects (static system pages: profile, settings, tests)\n - currentTemplate: TemplateSchema (the active template)\n - operationLoading: unknown\n - activeShellView: string | null (id of the currently open shell overlay: 'profile' | 'settings' | 'schema-tests' | 'landing-page' | null)\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n- Actions:\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - saveTemplate(name: string): saves the current template\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - deleteTemplate(): unknown\n - openShellView(id: string): opens a shell overlay by id ('profile' | 'settings' | 'schema-tests' | 'landing-page')\n - closeShellView(): closes the currently open shell overlay\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 - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId)\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 - signalTypes: array of SignalType objects (community-created reaction/vote types)\n - signalTypesBySlug: Record — computed map; access via { $store: \"spaceStore.signalTypesBySlug.\" }; use .id for the UUID\n- Actions:\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 - deletePost(postId: string): permanently deletes a post and all of its contained blocks (recursive, atomic)\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads and sets the space avatar or cover image\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - upsertSignal(nodeId: string, signalTypeId: string, value: number): adds or updates a signal on a node; value=0 deletes it\n - navigateToSpace(spaceId: string, view?: string): navigates to a space — accepts a perspective UUID or a neighbourhood CID (sharedUrl without the neighbourhood:// prefix); pre-loads space templates before switching so the template and data arrive together\n\nAiStore:\n- State:\n - models: array of Model objects\n - tasks: array of AITask objects\n - isOpen: unknown\n - messages: unknown\n - isStreaming: unknown\n - streamingContent: unknown\n - apiKeyConfigured: unknown\n - templateName: unknown\n - templateIcon: unknown\n - isReadOnly: unknown\n - hasPendingChanges: unknown\n - pickerOpen: unknown\n - pickerAction: unknown\n - pickerDefaultName: unknown\n - pickerDefaultIcon: unknown\n - pickerShowDestination: unknown\n - sessions: unknown\n - activeSessionId: unknown\n - panelMode: unknown\n - schemaJson: unknown\n - operationLoading: unknown\n - canUndo: boolean (true when there are schema edits that can be undone)\n - canRedo: boolean (true when there are undone schema edits that can be redone)\n- Actions:\n - handleSchemaPrompt(prompt: string): generates a schema from a prompt\n - sendMessage(): unknown\n - close(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - setApiKey(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setPanelMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: unknown\n - activeAppId: string | null — id of the currently active app, or null if none\n- Actions:\n - activateApp(id: string): activates an app and switches to its view\n - deactivateApp(): deactivates the current app and returns to the template view\n\n---\n\n## Store Usage Patterns\n\nReading state:\n{ \"$store\": \"storeName.property\" }\nExample: { \"$store\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"adamStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"CircleButton\",\n \"props\": {\n \"label\": \"$space.name\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$space.uuid\"] }] }\n }\n }\n ]\n}\n\nConditional rendering from store:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$eq\": [{ \"$store\": \"routeStore.currentPath\" }, \"/\"] },\n \"then\": { \"type\": \"we-text\", \"children\": [\"Home\"] },\n \"else\": { \"type\": \"we-text\", \"children\": [\"Not home\"] }\n }\n}\n\nDeriving options from store:\n{\n \"$map\": {\n \"items\": { \"$store\": \"templateStore.templates\" },\n \"select\": { \"name\": \"$item.meta.name\", \"icon\": \"$item.meta.icon\" }\n }\n}\n\nQuerying model data:\n{\n \"$query\": { \"entity\": \"TaskBlock\", \"where\": { \"status\": \"todo\" } }\n}\n\nEager-loading relations with include (most common relational pattern):\nWhen you need related data displayed alongside a list, use include to hydrate relations in one query.\n\nExample — Channel list with conversation count and latest conversation:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Channel\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"$conversationCount\": { \"from\": \"conversations\", \"count\": true },\n \"$latestConversation\": { \"from\": \"conversations\", \"order\": { \"createdAt\": \"desc\" }, \"limit\": 1 }\n }\n }\n },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"we-text\", \"children\": [\"$channel.name\"] },\n { \"type\": \"we-text\", \"children\": [\"$channel.$conversationCount\"] }\n ]\n }]\n}\n\nExample — Nested include (Conversations with their messages):\n{\n \"$query\": {\n \"entity\": \"Conversation\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"messages\": {\n \"order\": { \"createdAt\": \"desc\" },\n \"limit\": 20\n }\n }\n }\n}\nEach conversation in the result has a messages array of hydrated Message instances.\nNesting works to any depth: \"include\": { \"messages\": { \"include\": { \"reactions\": true } } }\n\nRelational drill-down (master-detail navigation across entity relations):\nUse routes + a $query `scope` when you navigate to a detail route and need only that record's children.\nscope.anchor is the parent entity type; scope.via is its HasMany relation (see externalModels) whose targets\nare the query's entity; scope.anchorId is the parent record's id. The adapter resolves the relation to a\nbackend handle, so no protocol details live in the template.\nrouteStore.segments.N extracts the Nth dynamic path segment (segments splits currentPath by \"/\").\n\nExample — Channel list → Conversation list:\n{\n \"routes\": [\n {\n \"path\": \"/\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": { \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/channels/\", \"$channel.id\"] }] }\n },\n \"children\": [\"$channel.name\"]\n }]\n }]\n },\n {\n \"path\": \"/channels/:channelId\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Conversation\",\n \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": { \"$store\": \"routeStore.segments.1\" } },\n \"dataset\": \"$currentDataset\"\n }\n },\n \"as\": \"convo\"\n },\n \"children\": [{\n \"type\": \"we-text\",\n \"children\": [\"$convo.conversationName\"]\n }]\n }]\n }\n ]\n}\nNotes:\n- Use include when you need related data displayed inline (e.g. a post with its comments, a channel with its conversation count).\n- Use a scope drill-down when you're on a detail route and want only children belonging to the current record.\n- dataset must point to the dataset that holds the data. For external apps (e.g. Flux) opened as a WE space, use \"$currentDataset\".\n- The relation name (in include, or scope.via) is the HasMany field name on the parent entity.\n\nLocal state (form with validation):\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"name\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [{ \"rule\": \"required\" }, { \"rule\": \"minLength\", \"value\": 2 }]\n },\n \"loading\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"neutral-0\", \"r\": \"400\", \"border\": \"1px solid neutral-200\", \"p\": \"400\", \"gap\": \"300\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"initials\": \"$post.author\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\" }, \"children\": [\"$post.author\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [\"$post.title\"] },\n { \"type\": \"we-text\", \"children\": [\"$post.text\"] }\n ]\n }\n ]\n}\n\nUse $query or $store for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"spaceStore.posts\" }, \"as\": \"post\" }, \"children\": [...] }\n\nPer-item customization inside $each:\nTo style or highlight specific items, add a data flag to those items and use $if on the flag inside the template. Do NOT use $eq: [\"$index\", N] comparisons — they are fragile, repetitive, and break when items are reordered.\nExample: add \"highlighted\": true to one item's data, then use $if on \"$post.highlighted\" in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": \"$post.highlighted\", \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor conditional props (e.g. different bg on highlighted items):\n{ \"bg\": { \"$if\": { \"condition\": \"$post.highlighted\", \"then\": \"primary-50\", \"else\": \"neutral-0\" } } }\n\nBoolean toggle (show/hide, expand/collapse):\n{\n \"type\": \"Column\",\n \"$localState\": { \"showDetails\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocal\": \"showDetails\" } }, \"children\": [\"Toggle Details\"] },\n { \"type\": \"$if\", \"props\": { \"condition\": { \"$local\": \"showDetails\" }, \"then\": { \"type\": \"we-text\", \"children\": [\"Details content here\"] } } }\n ]\n}\n\nSignal types (community-specific reactions/votes):\nSignal types are created per-community by the user. Never hardcode signal type UUIDs in schemas.\nInstead reference them by slug through spaceStore.signalTypesBySlug.\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 (inside a $each over a model with $query include):\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": { \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" } },\n \"count\": true\n },\n \"$myLikeSignal\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" },\n \"author\": \"$me.did\"\n },\n \"limit\": 1\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"then\": {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"myValue\": \"$item.$myLikeSignal.value\",\n \"aggregate\": \"$item.$totalLikeCount\",\n \"onSignal\": {\n \"$action\": \"spaceStore.upsertSignal\",\n \"args\": [\"$item.id\", { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" }, \"$arg\"]\n }\n }\n }\n }\n }\n ]\n}\n\nNotes:\n- The $if guard hides SignalControl if the community hasn't created a signal type with that slug.\n- Replace \"like\" with the user's slug throughout (in $store paths and args).\n- $query include adds $totalLikeCount and $myLikeSignal as computed properties 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## Routing Structure\n\nDefine nested routes using the \"routes\" array at the root node of the schema.\nEach route object describes a path and the UI node to render when that path is active.\nRoutes can be nested to support sub-pages and layouts.\n\nRoute objects follow the same structure as schema nodes, with an additional \"path\" property.\n\n- The \"routes\" array MUST be placed on the ROOT template node (or on a route node for nested routing). The router only reads routes from these positions — placing routes on an arbitrary child node means the router will never find them and nothing will render.\n- Use \"path: '*'\" or \"path: '/*'\" for catch-all/not-found routes.\n- Use \":paramName\" for dynamic route parameters (e.g. \"/space/:spaceId\").\n- Use nested \"routes\" arrays for sub-pages and layouts.\n- Use { \"type\": \"$routes\" } in children to indicate where nested routes should render. The $routes outlet can be deeply nested — only the routes array placement matters.\n- EVERY { \"type\": \"$routes\" } outlet MUST have a \"routes\" array defined on the same node or an ancestor node. A $routes outlet without a routes array is invalid and will fail validation.\n- NEVER duplicate a route path — every route in the same \"routes\" array MUST have a unique path.\n- When using tabs, each tab's key and navigate path MUST have a matching route. Ensure a 1:1 correspondence between tabs and routes.\n\n### Tabs + Routing\n\nIMPORTANT: we-tabs only manages visual selection — clicking a tab does NOT navigate automatically.\nEach we-tab MUST have an onClick with { \"$action\": \"routeStore.navigate\" } to trigger route changes.\nBind we-tabs selectedKey to the matching route segment so the active tab stays in sync.\n(Alternatively, a single onChange on we-tabs can replace per-tab onClick — see onChange pattern below.)\n\nRecommended pattern — header above tabs (routes on ROOT, $routes outlet nested inside):\n{\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"Select a tab\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Posts content\"] }] },\n { \"path\": \"/articles\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Articles content\"] }] }\n ],\n \"children\": [\n { \"type\": \"Row\", \"props\": { \"p\": \"300\", \"ax\": \"between\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-lg\" }, \"children\": [\"My App\"] }\n ]},\n {\n \"type\": \"we-tabs\",\n \"props\": { \"selectedKey\": { \"$store\": \"routeStore.segments.0\" } },\n \"children\": [\n { \"type\": \"we-tab\", \"props\": { \"key\": \"posts\", \"label\": \"Posts\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/posts\"] } } },\n { \"type\": \"we-tab\", \"props\": { \"key\": \"articles\", \"label\": \"Articles\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/articles\"] } } }\n ]\n },\n { \"type\": \"$routes\" }\n ]\n}\nNote: \"routes\" is on the root Column, NOT on a child. The $routes outlet is a child — that's fine. Only the routes array placement matters.\n\nWRONG — two common mistakes that produce empty tabs (validator will catch both):\n{\n // MISTAKE 1: routes defined on an inner child node, not the root.\n // The router never inspects children for routes arrays — this routes array is invisible.\n \"type\": \"Column\",\n \"children\": [\n { \"type\": \"we-tabs\", \"children\": [\"...tabs...\"] },\n {\n \"type\": \"Column\",\n \"routes\": [ // ← WRONG: router never reads this\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [\"...\"] }\n ],\n \"children\": [{ \"type\": \"$routes\" }] // ← outlet here does nothing without a live routes array\n }\n ]\n}\n\n{\n // MISTAKE 2: using { type: \"$routes\" } as a route entry's component type.\n // $routes is an outlet slot marker — as a leaf route entry it has no children injected,\n // so it returns null. Every tab navigates to a route that renders nothing.\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/posts\", \"type\": \"$routes\" } // ← WRONG: renders null, use a real component\n ],\n \"children\": [{ \"type\": \"$routes\" }]\n}\n\nAlternative: single onChange on we-tabs (fires with $event.detail.value = selected key):\n{ \"onChange\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/\", \"$arg.detail.value\"] }] } }\nThis replaces all per-tab onClick handlers but requires $concat to build the path.\n\nNested routing example:\n{\n \"routes\": [\n { \"path\": \"*\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Page not found\"] }] },\n { \"path\": \"/\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Home page\"] }] },\n {\n \"path\": \"/space/:spaceId\",\n \"type\": \"Row\",\n \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Space page not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"About sub-page\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Post not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"No posts selected\"] },\n { \"path\": \"/1\", \"type\": \"we-text\", \"children\": [\"Post 1 page\"] }\n ]\n }\n ]\n }\n ]\n}\n\n---\n\n## Rules & Best Practices\n\n- Always use the correct prop names and value types for each component.\n- Never use null as a value in any children array. Only use valid schema nodes or strings.\n- Each item in a children array must be either a valid schema node object or a string.\n- Use design tokens for spacing, color, radius, etc. (do not use raw CSS except in styles).\n- Use the styles prop for custom inline CSS (e.g., { \"width\": \"100px\" }).\n- Use hoverProps for hover state overrides, activeProps for pressed state, focusProps for focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row).\n- Use dynamic logic tokens ($store, $if, $action, etc.) for reactivity and conditional behavior.\n- Nest components using children or slots as needed.\n- For routes, use the routes array with path and child nodes.\n- Do not invent new components or props — use only those listed in the component registry.\n- Do not set props to their default/inherited values — omit them. fontSize and fontWeight inherit from parents (~16px / normal), so only set them when you need a different value.\n- Omit empty `props` and `children` — both are optional. Do not write `props: {}` or `children: []`.\n- Do not use `as const` on schema node `type` fields — `SchemaNode.type` is `string`, so it is never needed.\n- For icon-only buttons, nest a `we-icon` child inside `we-button` rather than using a `text` prop with a Unicode character. **Omit the `size` prop on `we-icon` when nesting inside sized primitives** (`we-button`, `we-input`, `we-badge`, `we-textarea`) — these components auto-size nested icons via `--we-context-icon-size` (xs→12px, sm→16px, md→24px, lg→32px, xl→40px). Only set an explicit icon `size` if you need to override the automatic sizing. Example: `{ type: 'we-button', props: { variant: 'ghost', size: 'sm' }, children: [{ type: 'we-icon', props: { name: 'x' } }] }`.\n- NEVER pass a bare number like \"16\" as a size or dimension prop — it is not valid CSS. Always check the component's declared prop type: if it's a string union, use one of the listed values; if it accepts arbitrary strings, include a CSS unit (e.g. \"16px\", \"2rem\").\n- For interactive list items and selectable options, use `we-button` with variant switching (e.g., `secondary` when selected, `ghost` when not) instead of manually styling `Row` with cursor, bg, and onClick. Buttons provide hover, focus, and active states for free.\n- For card-like layouts, compose from `Column` with DS props (bg, r, border, p, gap). This gives full control over spacing and appearance.\n- When rendering lists of similar items (posts, cards, users, etc.), ALWAYS use `$each` with a single template child — never duplicate the same node structure multiple times. Use literal arrays in `items` for static data, or `$store`/`$query` for dynamic data.\n\n### Icon Names (Phosphor Icons)\n\nwe-icon uses **Phosphor Icons** (v2.1). Do NOT use Heroicons, Material, or FontAwesome names.\nPhosphor names are lowercase-kebab-case. The `weight` prop controls style: \"regular\" (default), \"bold\", \"fill\", \"light\", \"thin\", \"duotone\".\n\nCommon Phosphor icon names (use these, NOT Heroicons equivalents):\n- Navigation: house, arrow-left, arrow-right, caret-left, caret-right, caret-down, caret-up, arrows-clockwise\n- Actions: plus, minus, x, check, pencil-simple, trash, copy, download, upload, share, link, magnifying-glass, funnel, sliders-horizontal\n- Communication: chat-circle, chat-dots, envelope-simple, paper-plane-tilt, bell, megaphone\n- Social: heart, thumbs-up, thumbs-down, star, share-network, users, user, user-plus\n- Media: image, camera, play, pause, stop, microphone, speaker-high, video-camera\n- Files: file, file-text, folder, folder-open, clipboard-text, note\n- UI: list, squares-four, gear, dots-three, dots-three-vertical, warning, info, question, check-circle, x-circle, eye, eye-slash\n- Misc: lightning, rocket, globe, map-pin, calendar, clock, tag, bookmark, flag, lock, shield-check\n\nWRONG icon names (Heroicons/Material — do NOT use):\n- \"chat-bubble-left\" → use \"chat-circle\"\n- \"chevron-right\" → use \"caret-right\"\n- \"cog\" / \"settings\" → use \"gear\"\n- \"trash-can\" → use \"trash\"\n- \"magnifying-glass-circle\" → use \"magnifying-glass\"\n- \"home\" → use \"house\"\n- \"favorite\" → use \"heart\"\n- \"delete\" → use \"trash\"\n- \"search\" → use \"magnifying-glass\"\n- \"close\" → use \"x\"\n- \"menu\" → use \"list\"\n- All schemas must be valid JSON with property names and string values in double quotes.\n- The meta property at the root is required: { \"meta\": { \"name\": \"...\", \"description\": \"...\", \"icon\": \"...\" } }\n- Always set `bg: 'neutral-50'` on root-level schema nodes (templates, pages). This ensures proper background in all themes — without it, dark mode renders white backgrounds.\n\nMost @we/primitives inherit all Design System Props documented above (layout, visual, flex, typography, state).\nSome layout-only primitives (we-avatar, we-icon, we-image, we-spinner, etc.) only accept Layout props — see the Design System Props section for the full list.\n\nNative HTML elements (lowercase tags render directly without registry entries):\n- Layout: div, section, article, aside, main, nav, header, footer\n- Text: p, span, h1-h6, pre, code, blockquote\n- Lists: ul, ol, li\n- Forms: form, input, button, label, select, textarea\n- Media: img, video, audio, canvas, figure, figcaption\n- Other: a, table, tr, td, th, details, summary, dialog\n\n## Schema Validation\n\nRun `we-validate-schemas` (or `node packages/schema-system/shared/dist/cli/we-validate-schemas.js`) from the monorepo root to validate all `.schema.ts` files.\nFor a specific file: `we-validate-schemas packages/app-framework/src/shared/schemas/MyTemplate.schema.ts`\n\nAfter creating or modifying a `.schema.ts` file, always run validation to catch:\n- Unknown component types (typos, missing registry entries)\n- Invalid or misspelled props (with \"did you mean?\" suggestions)\n- Prop type mismatches (e.g., number where string expected)\n- Missing required `meta` field on root TemplateSchema nodes\n- `$routes` outlet without a `routes` array on an ancestor\n- Orphan `$local` / `$setLocal` references without a `$localState` ancestor\n- DS layer consistency (mixing props from layers the component doesn't support)"; +export const schemaContext = "## Schema Structure\n\nA schema is a tree of nodes. Each node can have:\n- type: The component to render (string, e.g. \"we-button\", \"Column\")\n- props: An object of props for the component\n- children: An array of child nodes (or strings for text), or token objects like { $store: '...' } or { $concat: [...] }.\n- slots: Named slots for advanced composition (optional)\n- slot: The name of the slot this node should be rendered into (optional)\n- routes: For routing components, an array of nestable route objects (optional)\n- styles: Raw CSS escape hatch — Record applied as inline styles on a **wrapper div** that surrounds the component. Use only for CSS that must live on a wrapper: filter, clip-path, backdrop-filter, mix-blend-mode. When present the wrapper participates in layout (no display:contents), so CSS effects apply correctly. **Important:** this is NOT the same as props.styles. If you want to apply custom CSS to a Column, Row, or Grid's own element (e.g. a background image), put it in props.styles instead — node-level styles go on a wrapper div around the component and will be hidden behind the component's own background.\n\nExample node:\n{\n \"type\": \"we-button\",\n \"props\": {\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"house\" } },\n { \"type\": \"we-text\", \"props\": { \"size\": \"600\" }, \"children\": [\"Home\"] }\n ]\n}\n\n## Prop-level Dynamic Logic & Expressions\n\nSpecial tokens in props enable dynamic, reactive, or computed behavior.\n\nStore reference:\n{ \"$store\": \"storeName.property.path\" }\nResolves a value from a named store, supporting nested paths.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nSupports async lifecycle callbacks — fired after the store method's Promise resolves/rejects:\n onSuccess: [...actions] — fired on resolve; '$result' (and '$result.') in args refers to the resolved value\n onError: [...actions] — fired on reject; '$result.message' etc. refers to the error object\n onFinally: [...actions] — fired regardless of outcome\nNon-promise (synchronous) methods are unaffected — lifecycle keys are ignored.\nExample — close modal after async submission:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$result.uuid\"] }] }] }\n\nModel mutations via $action (use these for creating/updating/deleting model instances):\nmodel.create — creates a model instance in the current perspective (default) or a specified one:\n{ \"$action\": \"model.create\", \"args\": [\"ModelName\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nmodel.update — updates a model instance:\n{ \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'adamStore.rootPerspective' for we-root models (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped models (Space, Signal, etc.).\n\nConditional logic:\n{ \"$if\": { \"condition\": ..., \"then\": ..., \"else\": ... } }\nEvaluates condition; if truthy, returns then, else returns else.\n\nMap/iterate:\n{ \"$map\": { \"items\": { \"$store\": \"templateStore.templates\" }, \"select\": { ... } } }\nIterates over an array, mapping each item to a new object using the select mapping.\n\nPick:\n{ \"$pick\": { \"from\": { \"$store\": \"userStore.profile\" }, \"props\": [\"name\", \"email\"] } }\nPicks specific properties from an object.\n\nConcat (string building):\n{ \"$concat\": [\"part1\", \"$context.value\", \"part2\"] }\nJoins multiple parts into a single string.\n\nContext references:\nStrings starting with \"$\" followed by a context key resolve to context values.\nExample: \"$space.name\" resolves to the name property of the space context variable.\nDot paths supported: \"$item.profile.avatar\".\n\nEquality / inequality checks:\n{ \"$eq\": [a, b] } — strict equality\n{ \"$ne\": [a, b] } — strict inequality\n\nNumeric comparisons:\n{ \"$lt\": [a, b] } — a < b (less than)\n{ \"$gt\": [a, b] } — a > b (greater than)\nExample: { \"$gt\": [{ \"$count\": { \"items\": { \"$store\": \"listStore.items\" } } }, 0] }\n\nSet membership:\n{ \"$in\": [value, array] } — true if array contains value (false if second operand is not an array)\nExample: { \"$in\": [{ \"$store\": \"spaceStore.uuid\" }, { \"$store\": \"adamStore.systemPerspectiveUuids\" }] }\nExample: { \"$in\": [\"$item.role\", [\"admin\", \"moderator\"]] }\n\nBoolean logic:\n{ \"$and\": [a, b, ...] } — all truthy\n{ \"$or\": [a, b, ...] } — any truthy\n{ \"$not\": a } — negation\n\nArray operators:\n{ \"$filter\": { \"items\": , \"where\": { \"field\": \"value\", ... } } }\nFilters an array to items where all where conditions match. Mirrors the $query where operator set:\n\n { \"field\": \"value\" } — strict equality\n { \"field\": { \"not\": \"value\" } } — inequality; array form excludes multiple values\n { \"field\": { \"contains\": \"text\" } } — case-insensitive substring match (strings only)\n { \"field\": { \"exists\": true } } — non-null / non-undefined presence check\n { \"field\": { \"exists\": false } } — null or undefined check\n\nWhere values (including those inside operator objects) are resolved through the prop system,\nso $store, $local, and context refs like { \"$local\": \"searchText\" } all work.\n\n$query-only logical combinators (OR / AND / NOT) — NOT supported in $filter, only in $query's where:\n { \"OR\": [ { \"field\": \"value\" }, { \"field2\": \"value2\" } ] } — matches if ANY branch matches\n { \"AND\": [ { ... }, { ... } ] } — matches if ALL branches match (sibling keys at the\n same level are already implicitly ANDed — use AND\n to group a set of conditions alongside an OR/NOT)\n { \"NOT\": { \"field\": \"value\" } } — matches if the branch does NOT match\nBranches are full where-clause objects (can contain multiple fields, and can nest OR/AND/NOT inside each other).\nSibling keys alongside OR/AND/NOT at the same level are implicitly ANDed with it.\nExample — case-insensitive search across two fields:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"where\": {\n \"OR\": [\n { \"name\": { \"contains\": { \"$local\": \"searchText\" } } },\n { \"description\": { \"contains\": { \"$local\": \"searchText\" } } }\n ]\n }\n }\n}\nNote: using OR/AND/NOT disables the SPARQL-level sort/pagination pushdown (see count-projection and\nrelation-property ordering below) — those orderings silently stop working if combined with OR/AND/NOT in the\nsame query's where clause, because the fallback sort runs before the projection/relation data is attached.\n\nExamples:\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"role\": \"admin\" } } }\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"location\": { \"exists\": true }, \"handle\": { \"contains\": { \"$local\": \"searchText\" } } } } }\n\n{ \"$count\": { \"items\": } }\nReturns the length of an array.\nExample: { \"badge\": { \"$count\": { \"items\": { \"$store\": \"notificationStore.unread\" } } } }\n\n{ \"$find\": { \"items\": , \"where\"?: { ... }, \"select\"?: \"fieldName\" } }\nFinds the first matching item. where is optional (returns first item if omitted). select plucks a single field.\nExample: { \"$find\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"id\": \"$item.creatorId\" }, \"select\": \"name\" } }\n\n{ \"$plural\": { \"count\": , \"one\": \"singular\", \"other\": \"plural\" } }\nReturns \"one\" when count === 1, otherwise \"other\". Use in children arrays for count-noun labels.\ncount is resolved through the prop system — any numeric expression ($count, $store, context ref) works.\nExample: { \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }\nCompose with we-number for a full \"N Members\" display:\n we-number (value: { \"$count\": ... }, shorten: true) + we-text (children: [{ \"$plural\": { \"count\": { \"$count\": ... }, \"one\": \"Member\", \"other\": \"Members\" } }])\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"ModelName\", \"where\": { \"field\": \"value\" }, \"limit\": 10, \"order\": { \"field\": \"asc\" } } }\nQueries the current dataset for entity instances. Always returns an array.\nOptions: entity (required), where, order, limit, offset, include, scope, dataset, subscribe.\nsubscribe defaults to true — reactive live updates. Set subscribe: false to do a one-time fetch.\nBy default $query targets the current dataset ($currentDataset). Use dataset to query a different dataset —\nrequired when reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } }\n\nBackend-neutral identity & dataset refs — prefer these over adamStore.* 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- $me — the current agent's identity object. Use $me.did for their DID (ownership checks, author filters, e.g. { \"$eq\": [\"$post.author\", \"$me.did\"] }); $me.handle / $me.avatar for profile fields once loaded.\n\nEager-loading relations with include (most common relational pattern):\ninclude hydrates related model instances in the same query — no extra fetches needed.\nRelation names come from the HasMany relations listed for each model in externalModels.\n\nSimple include — hydrate all related instances:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": true } } }\nEach item in the result will have a conversations array of hydrated Conversation objects.\n\nSub-query include — filter, sort, or limit the related records:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"order\": { \"createdAt\": \"desc\" }, \"limit\": 10 } } } }\n\nNested include — hydrate relations of relations:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"include\": { \"messages\": true } } } } }\nNesting can go as deep as needed. Each level adds one batched fetch (not N+1).\n\nCount projection — add a derived numeric field:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } } } }\nThe $-prefixed key becomes a new field on each result item (e.g. item.$likeCount = 42).\n\nSorting by a count projection — order can reference a $-prefixed count key directly, sorting by the aggregate:\n{\n \"$query\": {\n \"entity\": \"Post\",\n \"limit\": 20,\n \"order\": { \"$likeCount\": \"desc\" },\n \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } }\n }\n}\nRequirements: only a single order key is supported when it targets a projection (mixing it with a second sort key falls back\nto a plain property sort), and the query must also specify limit or offset — without one the count isn't computed yet at\nsort time and the order silently has no effect. Always pair count-projection ordering with a limit.\nCombine with $if for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": {\n \"$if\": {\n \"condition\": { \"$eq\": [{ \"$local\": \"sortField\" }, \"likes\"] },\n \"then\": { \"$likeCount\": { \"$local\": \"sortDirection\" } },\n \"else\": { \"createdAt\": { \"$local\": \"sortDirection\" } }\n }\n }\n}\n\nSorting by a related model property — order can reference a dotted \"relation.property\" path for a HasOne/HasMany\nrelation declared on the model, sorting by a scalar property on the related instance:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"limit\": 20,\n \"order\": { \"location.country\": \"asc\" },\n \"include\": { \"location\": true }\n }\n}\nSame requirements as count-projection ordering above: only a single order key, and pair with limit/offset — without\none the relation data isn't attached yet at sort time and the order silently has no effect. include isn't required\nfor the sort itself (the relation is resolved from the model's declared shape), but you'll usually want it anyway to\nread the field in the UI (e.g. \"$space.location.country\").\nCombine with $if the same way as count-projection ordering to let the user toggle between sort fields.\n\nSingle-item projection — add a derived field that resolves to one instance or null:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$myLike\": { \"from\": \"likes\", \"where\": { \"author\": \"$me.did\" }, \"limit\": 1 } } } }\nWith limit: 1 the field unwraps to T | null instead of an array.\n\ninclude only works with typed relations — ones where the target model class is known.\nFor WE models this is always the case. For external models, check the externalModels listing:\nrelations marked \"→ ModelName\" are typed (safe for include); relations marked \"parent query only\"\nare untyped and will crash at runtime if used with include — use a scope drill-down instead.\n\nRelational queries — fetch a parent record's children (drill-down navigation):\n{ \"$query\": { \"entity\": \"Conversation\", \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": \"$channel.id\" } } }\nscope.anchor is the parent entity type; scope.via is its relation whose targets are this query's entity (the\nHasMany relation listed for that entity in externalModels); scope.anchorId is the parent record's id (typically\nfrom a $each context variable or a route segment). The adapter resolves the relation to a backend handle —\nno protocol details live in the template.\nUse this pattern when navigating to a detail route and loading only that record's children.\nFor external-app datasets, always add dataset: \"$currentDataset\".\n\nLocal state (scoped ephemeral state):\nDeclare on any node: \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\" } }\nSupported types: \"string\", \"boolean\", \"number\", \"function\", \"object\".\nRead: { \"$local\": \"name\" } — returns the signal value (reactive).\n { \"$local\": \"name.nested.path\" } — dot-notation reads into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"from\": \"$event.target.value\" } — event handler that updates the signal.\n { \"$setLocal\": \"name\", \"value\": \"literal\" } — sets to a literal value (string, number, boolean, null, object).\n { \"$setLocal\": \"name\", \"merge\": { \"field\": \"$event.detail\" } } — shallow-merges fields into an object-typed signal. Values are resolved as event paths (e.g. \"$event.detail\") or passed as literals. Use for partial updates to object state.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nCall function: { \"$callLocal\": \"fieldName\" } — event handler that calls the function stored in a function-typed local field.\n Used when a child component needs to trigger a callback passed in via $localState.\n The field must be declared as type: 'function' and set via $setLocal.\n Example: { \"onClick\": { \"$callLocal\": \"onConfirm\" } }\nState is created on mount and destroyed on unmount. Nested $localState declarations merge, inner fields shadow outer.\n$local values can be used in $action args: { \"$action\": \"store.method\", \"args\": [{ \"$local\": \"name\" }] }\n\nObject-typed local state (consolidating related scalar fields):\nWhen several related fields share a common condition on their initial values (e.g. all null/empty when a store value is absent), prefer a single \"object\" field seeded from the store, then read sub-fields with dot-notation and write with merge.\nExample — location object (replaces 5 separate scalar fields with $if guards):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$store\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$local\": \"location.latitude\" }, { \"$local\": \"location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"from\": \"$event.detail\" }\n Write (partial edit): { \"$setLocal\": \"location\", \"merge\": { \"city\": \"$event.detail\" } }\n Write (clear): { \"$setLocal\": \"location\", \"value\": null }\n Condition (has location): { \"$local\": \"location\" }\nUse \"object\" whenever you would otherwise write 3+ related scalar fields each needing $if on their initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results in $local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available for $if conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into $local as read-only reactive arrays, accessible via { \"$local\": \"signalTypes\" }.\nQuery options are identical to $each's $query prop (entity, where, order, limit, include, dataset, subscribe).\n$queries and $localState share the same $local namespace — avoid duplicate names across both.\n$setLocal will warn and no-op on $queries entries (they are read-only).\nUse with $count + $gt for conditional visibility:\n{ \"condition\": { \"$gt\": [{ \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } }, 0] } }\nExample:\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [...]\n }\n ]\n}\n\nBoolean toggle pattern (show/hide comments, expand/collapse sections, etc.):\n{\n \"$localState\": { \"showComments\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$toggleLocal\": \"showComments\" }\n },\n \"children\": [{ \"type\": \"we-icon\", \"props\": { \"name\": \"chat-circle\" } }]\n },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"showComments\" },\n \"then\": { \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Comments visible\"] }] }\n }\n }\n ]\n}\n\nForm validation (extends $localState):\nDeclare validation rules on fields:\n\"$localState\": {\n \"email\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [\n { \"rule\": \"required\", \"message\": \"Email is required\" },\n { \"rule\": \"pattern\", \"value\": \"^[^@]+@[^@]+$\", \"message\": \"Invalid email\" }\n ]\n }\n}\n\nBuilt-in rules: required, minLength (value: N), maxLength (value: N), min (value: N), max (value: N), pattern (value: regex string), match (field: otherFieldName). All accept optional \"message\" override.\n\nRead tokens:\n{ \"$error\": \"fieldName\" } — first validation error message (only shown after field is touched), or \"\".\n{ \"$valid\": \"fieldName\" } — true if all rules pass (regardless of touched state).\n{ \"$touched\": \"fieldName\" } — true after the field has been blurred/touched.\n{ \"$formValid\": \"$scope\" } — true if ALL validated fields in the current $localState scope pass.\n\nAction tokens:\n{ \"$touch\": \"fieldName\" } — marks a single field as touched (use in onBlur).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. Non-function entries (e.g. $if with false condition) are skipped.\nPrefer onSuccess over a bare $setLocal before the $action — the bare form closes the modal immediately (losing the loading spinner); onSuccess waits for the Promise to resolve.\n\nTypical form pattern:\n{\n \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] } },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a context key. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nConditional rendering:\n{ \"type\": \"$if\", \"props\": { \"condition\": ..., \"then\": { ... }, \"else\": { ... } } }\nRenders \"then\" node if condition is truthy, else renders \"else\" node.\nSupports enterTransition / exitTransition for CSS animations when the node mounts/unmounts.\nTransitionConfig = TransitionEffect | TransitionEffect[]\nTransitionEffect = { type: 'fade'|'slide'|'scale'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string }\nfade controls opacity only; slide/scale control transform only. pulse is a persistent looping animation (not a one-shot transition) — starts once entered, stops on exit; direction/distance don't apply (default duration 1200ms, easing 'ease-in-out'). Compose fade/slide/scale together in an array; pulse is typically used alone.\nExample: enterTransition: [{ type: 'fade', duration: 300 }, { type: 'slide', direction: 'up', distance: '40px', duration: 400 }]\nExample (pulse): enterTransition: { type: 'pulse', duration: 1500 }\n\nViewport / mount animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"scrollReveal\"?: true | number, \"scrollLeave\"?: true | number, \"scrollPast\"?: string, \"enterTransition\"?: TransitionConfig, \"exitTransition\"?: TransitionConfig }, \"children\": [] }\nThe child is always mounted. fade/slide/scale are CSS transitions (opacity/transform); pulse is a real CSS @keyframes loop — use this for scroll-reveal effects.\nDo NOT use $animate when the child should be absent from the DOM. Use $if for conditional DOM presence.\nscrollReveal: true fires enterTransition when the element enters the viewport.\nscrollReveal: -100 fires 100px before the element would enter (negative = earlier reveal).\nscrollLeave fires exitTransition when the element leaves the viewport.\nscrollPast: \"element-id\" observes a sentinel element (by DOM id) instead of the $animate element itself.\n enterTransition fires when the sentinel leaves the viewport (user scrolled past it).\n exitTransition fires when the sentinel returns (user scrolled back up).\n Use this for sticky headers: place a zero-height sentinel div at the bottom of the non-sticky header section,\n then wrap the mini-profile in $animate with scrollPast pointing to that sentinel's id.\n scrollPast is mutually exclusive with scrollReveal/scrollLeave.\nWithout any scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (scroll-reveal):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollReveal\": -100,\n \"enterTransition\": [\n { \"type\": \"fade\", \"duration\": 600, \"easing\": \"ease-in-out\" },\n { \"type\": \"slide\", \"direction\": \"left\", \"distance\": \"200px\", \"duration\": 1000, \"easing\": \"ease-in-out\" }\n ]\n },\n \"children\": [{ \"type\": \"SomeCard\", \"children\": [] }]\n}\nExample (sticky header mini-profile):\nPlace a sentinel at the bottom of the header, reference it in the sticky nav:\n{ \"type\": \"div\", \"props\": { \"id\": \"header-sentinel\" }, \"styles\": { \"height\": \"0px\", \"pointerEvents\": \"none\" } }\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollPast\": \"header-sentinel\",\n \"enterTransition\": { \"type\": \"fade\", \"duration\": 250 },\n \"exitTransition\": { \"type\": \"fade\", \"duration\": 200 }\n },\n \"children\": [{ \"type\": \"Row\", \"props\": { \"ay\": \"center\", \"gap\": \"300\" }, \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"600\" }, \"children\": [\"$space.name\"] }\n ]}]\n}\n\nSingle model item (load one record, render children with it in context):\n{\n \"type\": \"$single\",\n \"props\": {\n \"item\": { \"$query\": { \"entity\": \"ModelName\", \"params\": { ... }, \"subscribe\": true } },\n \"as\": \"profile\" // context key for children — default: 'item'\n },\n \"children\": [{ \"type\": \"we-text\", \"children\": [\"$profile.username\"] }]\n}\nRenders nothing until a matching record is found. Like $each but for a single result.\nquery options (entity, params, include, dataset, subscribe) work identically to $query.\n\nRoute outlet:\n{ \"type\": \"$routes\" }\nIndicates where nested routes should render within a layout.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', dismissible: boolean = false\n- we-audio (LayoutVisualElement)\n Props: src: string = '', controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', selected: boolean = false, online: boolean = false, initials: string = '', icon: string = '', size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | '{css-length}' | undefined, clickable: boolean = false\n- we-badge (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-blockquote (DesignSystemElement)\n- we-button (DesignSystemElement)\n Props: variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', text?: string | undefined, href?: string | undefined, disabled: boolean = false, loading: boolean = false, gradient: boolean = false, square: boolean = false\n- we-checkbox (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-code (DesignSystemElement)\n Props: block: boolean = false\n- we-color-picker (DesignSystemElement)\n Props: value: string = '#000000', disabled: boolean = false, name: string = '', palette: array = [ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc', ]\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', placeholder: string = 'Select date', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-divider (LayoutElement)\n Props: orientation: 'horizontal' | 'vertical' = 'horizontal', variant: 'solid' | 'dashed' | 'dotted' = 'solid', color?: string | undefined, thickness?: string | undefined\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-file-upload (DesignSystemElement)\n Props: accept: string = '', multiple: boolean = false, disabled: boolean = false, name: string = ''\n- we-form-field (DesignSystemElement)\n Props: label: string = '', description: string = '', error: string = '', required: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-html (DesignSystemElement) — Renders a raw HTML string safely via DOMPurify sanitization.\n\nUse this instead of `we-text` when content is stored as HTML (e.g. rich-text\neditor output such as Flux messages). The `content` prop accepts any HTML\nfragment; it is sanitized before rendering so XSS payloads are stripped.\n Props: content: string = ''\n- we-icon (LayoutElement)\n Props: name: string = '', color: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '{css-length}' = '', weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' = 'regular', gradient: string = ''\n- we-icon-picker (DesignSystemElement)\n Props: value: string = '', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', placeholder: string = 'Pick icon'\n- we-iframe (LayoutVisualElement)\n Props: src: string = '', title: string = 'Embedded content', allow: string = '', sandbox?: string | undefined\n- we-image (LayoutVisualElement)\n Props: src: string | File = '', alt: string = '', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', loading: 'eager' | 'lazy' = 'eager', gradient: string = '', objectPosition: string = ''\n- we-input (DesignSystemElement)\n Props: value: string = '', max: string = '', min: string = '', maxlength: unknown = Infinity, minlength: number = 0, pattern: string = '', name: string = '', step: string = '', placeholder: string = '', autocomplete: string = '', autofocus: boolean = false, disabled: boolean = false, required: boolean = false, readonly: boolean = false, type: string = 'text', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-number (DesignSystemElement) — Displays a number, optionally abbreviated (1 200 → 1.2K, 1 500 000 → 1.5M).\n Props: value: number = 0, shorten: boolean = false, precision: number = 1, locale: string = 'en', formattedValue: string\n- we-number-input (DesignSystemElement)\n Props: value: number = 0, min: number = -Infinity, max: unknown = Infinity, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-pagination (DesignSystemElement)\n Props: page: number = 1, total: number = 1, siblings: number = 1, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-popover (LayoutElement) — Low-level floating panel anchored to a trigger element.\nUse DropdownMenu component for dropdown menus.\n Props: open: boolean = false, placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom', popoverElement: HTMLElement, triggerElement: HTMLElement\n- we-progress-bar (DesignSystemElement)\n Props: value: number = 0, max: number = 100, variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-radio (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = ''\n- we-select (DesignSystemElement)\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-select (DesignSystemElement) — Pick a single value from a list of options. Custom-rendered dropdown.\nUse for form fields, settings, filters. Set searchable=true for type-to-filter.\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-skeleton (DesignSystemElement)\n Props: width: string = '100%', height: string = '20px', animation: 'pulse' | 'wave' = 'pulse'\n- we-slider (DesignSystemElement)\n Props: value: number = 0, min: number = 0, max: number = 100, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — Drag-to-reorder container primitive.\n\nUsage: wrap a list of elements that each have a `data-we-id` attribute.\nFires a `we-reorder` CustomEvent on drop with the new ordered\narray of IDs.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = ''\n- we-spinner (LayoutElement)\n Props: size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) = 'md', color: string = ''\n- we-switch (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', labelOff: string = '', labelOn: string = ''\n- we-tab (DesignSystemElement)\n Props: key: string = '', selected: boolean = false, label?: string | undefined, selectedProps?: Partial | undefined\n- we-tabs (DesignSystemElement)\n Props: selectedKey: string = ''\n- we-tag (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', dismissible: boolean = false\n- we-text (DesignSystemElement)\n Props: text?: string | undefined, variant: '' | 'body' | 'label' | 'footnote' | 'subheading' | 'ingress' | 'heading-sm' | 'heading-md' | 'heading-lg' | 'heading-xl' = '', tag: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small' | 'b' | 'i' | 'label' | 'div' = 'span', inline: boolean = false, uppercase: boolean = false, italic: boolean = false, truncate: boolean = false, gradient: string = ''\n- we-textarea (DesignSystemElement)\n Props: value: string = '', name: string = '', placeholder: string = '', rows: number = 3, maxlength: unknown = Infinity, minlength: number = 0, disabled: boolean = false, required: boolean = false, readonly: boolean = false, resize: 'none' | 'vertical' | 'horizontal' | 'both' = 'vertical', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-timestamp (DesignSystemElement) — Displays a formatted or relative timestamp that self-updates each minute\nwhen `relative` is enabled.\n Props: value: string = '', relative: boolean = false, locale: string = 'en', dateStyle: Intl.DateTimeFormatOptions['dateStyle'] | null = null, timeStyle: Intl.DateTimeFormatOptions['timeStyle'] | null = null, weekday: Intl.DateTimeFormatOptions['weekday'] | null = null, year: Intl.DateTimeFormatOptions['year'] | null = null, month: Intl.DateTimeFormatOptions['month'] | null = null, day: Intl.DateTimeFormatOptions['day'] | null = null, hour: Intl.DateTimeFormatOptions['hour'] | null = null, minute: Intl.DateTimeFormatOptions['minute'] | null = null, second: Intl.DateTimeFormatOptions['second'] | null = null, timeZone: string | null = null, hourCycle: Intl.DateTimeFormatOptions['hourCycle'] | null = null, formattedTime: string\n- we-tooltip (LayoutElement)\n Props: open: boolean = false, title: string = '', placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'top', tooltipEl: HTMLElement, triggerEl: HTMLElement, arrowEl: HTMLElement\n- we-video (LayoutVisualElement)\n Props: src: string = '', poster?: string | undefined, controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\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?: any, perspective?: PerspectiveProxy | null, onSave?: ((json: SerializedBlockNode) => void), onReady?: ((api: { save: () => void; }) => void)\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, rootClass?: string\n- BlockToolbar\n Props: placement?: BlockToolbarPlacement, children: JSX.Element, stopPropagation?: boolean\n- CalloutDisplay\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined\n- CalloutInput\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CodeDisplay\n Props: code: string | undefined, language: string | undefined, title: string | undefined\n- CodeInput\n Props: code: string | undefined, language: string | undefined, title: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CollectionDisplay\n Props: layout?: string, columnCount?: number, gap?: string, childEditorState?: any\n- CollectionInput\n Props: nodeKey: string, layout?: string, columnCount?: number, gap?: string, childEditorState?: any, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- DividerDisplay\n Props: style: \"solid\" | \"dashed\" | \"dotted\" | undefined\n- DividerInput\n Props: style: DividerVariant | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EmbedDisplay\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined\n- EmbedInput\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EventDisplay\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined\n- EventInput\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- FileDisplay\n Props: title: string | undefined, name: string | undefined, url: string | undefined, mimeType: string | undefined, size: number | undefined\n- FileInput\n Props: title: string | undefined, name: string | undefined, url: string | FileData | undefined, mimeType: string | undefined, size: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- ImageDisplay\n Props: src: string | undefined, altText: string | undefined, width: number | undefined, height: number | undefined\n- ImageInput\n Props: src: string | FileData | undefined, altText: string | undefined, width: number | undefined, height: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LinkDisplay\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined\n- LinkInput\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LocationDisplay\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined\n- LocationInput\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TagDisplay\n Props: name: string | undefined, color: string | undefined\n- TagInput\n Props: name: string | undefined, color: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TaskDisplay\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined\n- TaskInput\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- VideoDisplay\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined\n- VideoInput\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- Accordion\n Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record\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- Breadcrumbs\n Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record\n- Calendar\n Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record\n- Card (DesignSystemElement)\n- CircleButton\n Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record\n- CollapsedContent\n Props: collapsed: boolean, onExpandClick?: (() => void), showToggle?: boolean, icon?: string, maxHeight?: string, fadeColor?: string, children?: JSX.Element, class?: string, styles?: Record\n- Column (DesignSystemElement)\n- Combobox (DesignSystemElement)\n Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.\n Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\n- IconLabelButton\n Props: icon: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, label: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, selected?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, iconWeight?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, onClick?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>, class?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, styles?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>\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- List\n Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record\n- PostCard\n Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record\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- Stepper\n Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: \"horizontal\" | \"vertical\", styles?: Record\n- Table\n Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record\n- Timeline\n Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], 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- CesiumGlobe — 3D globe widget using CesiumJS with a modular layer system.\nLayers are injected via factory functions (planet surface + background).\nRequires a layer factory registry mapping string names to factory functions.\nNot schema-renderable — used directly in application code.\n Props: ionAccessToken?: string, planetLayers?: LayerConfig[], backgroundLayers?: LayerConfig[], layerFactoryRegistry: Record>\n- CollapsibleSidebar\n Props: header?: JSX.Element, footer?: JSX.Element, items: CollapsibleSidebarItem[], footerItems?: CollapsibleSidebarItem[], side?: \"left\" | \"right\", position?: \"static\" | \"absolute\" | \"fixed\", zIndex?: number, collapsedWidth?: string, expandedWidth?: string, defaultExpanded?: boolean, expandOnHover?: boolean, transitionDuration?: number, bg?: string, border?: string, padding?: string, gap?: string, centerItems?: boolean, itemColor?: string, itemColorHover?: string, itemColorActive?: string, itemBg?: string, itemBgHover?: string, itemBgActive?: string, itemPadding?: string, itemGap?: string, badgeBg?: string, badgeColor?: string, iconSize?: IconSize, onItemClick?: ((item: CollapsibleSidebarItem) => void), onExpandedChange?: ((expanded: boolean) => void)\n- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering.\nDisplays typed nodes (user, space, post) and edges (follows, member-of, etc.)\nwith configurable styling, layout forces, and interaction handlers.\n Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig\n- SpaceSidebarWidget\n Props: name: string, description?: string, class?: string, style?: Record\n\n---\n\n## Design System Props\n\nMost @we/primitives inherit **all** layers below. Props use design token values — not raw CSS.\n\n### Token Value Reference\n\n| Token Type | Valid Values |\n|---|---|\n| SpaceValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length e.g. \"16px\") |\n| ColorValue | \"{hue}-{shade}\" where hue = neutral, primary, success, warning, danger and shade = 0, 25, 50, 75, 100, 200–900, 1000. Also \"white\", \"black\". (or CSS color) |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length) |\n| ShadowValue | \"sm\", \"md\", \"lg\", \"xl\" |\n| FontSizeValue | \"base\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length) |\n| FontFamilyValue | \"base\" (or CSS font-family) |\n| LineHeightValue | \"none\", \"tight\", \"snug\", \"normal\", \"relaxed\", \"loose\" (or CSS value) |\n| LetterSpacingValue | \"tighter\", \"tight\", \"normal\", \"wide\", \"wider\", \"widest\" (or CSS value) |\n| FontWeightValue | Named tokens: \"regular\" (400), \"medium\" (500), \"semibold\" (600), \"bold\" (700). Numeric: \"100\"–\"900\". CSS pass-through: \"light\", \"normal\", \"bolder\". |\n\n**Layout-only primitives** — these accept only Layout props (not Visual, Flex, Typography, or State):\nwe-divider, we-icon, we-menu-group, we-popover, we-spinner, we-tooltip\n\n### Layout\n\n| Prop | Type | Description |\n|------|------|-------------|\n| width | string | Element width |\n| height | string | Element height |\n| minWidth | string | Minimum width |\n| minHeight | string | Minimum height |\n| maxWidth | string | Maximum width |\n| maxHeight | string | Maximum height |\n| position | \"relative\" \\| \"absolute\" \\| \"fixed\" \\| \"sticky\" | CSS position |\n| top | string | Top offset |\n| right | string | Right offset |\n| bottom | string | Bottom offset |\n| left | string | Left offset |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" | Overflow behavior |\n| m | SpaceValue | Margin (all sides) |\n| mx | SpaceValue | Margin left + right |\n| my | SpaceValue | Margin top + bottom |\n| mt | SpaceValue | Margin top |\n| mr | SpaceValue | Margin right |\n| mb | SpaceValue | Margin bottom |\n| ml | SpaceValue | Margin left |\n\n### Visual\n\n| Prop | Type | Description |\n|------|------|-------------|\n| bg | ColorValue | Background color (token) |\n| bgImage | string | Background image URL — sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\n| r | RadiusValue | Border radius (all corners) |\n| rt | RadiusValue | Border radius top |\n| rb | RadiusValue | Border radius bottom |\n| rl | RadiusValue | Border radius left |\n| rr | RadiusValue | Border radius right |\n| rtl | RadiusValue | Border radius top-left |\n| rtr | RadiusValue | Border radius top-right |\n| rbr | RadiusValue | Border radius bottom-right |\n| rbl | RadiusValue | Border radius bottom-left |\n\n### Flex (Container)\n\n| Prop | Type | Description |\n|------|------|-------------|\n| direction | \"row\" \\| \"row-reverse\" \\| \"column\" \\| \"column-reverse\" | Flex direction |\n| ax | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Main-axis alignment |\n| ay | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Cross-axis alignment |\n| wrap | boolean | Enable flex wrap |\n| gap | SpaceValue | Gap between children (token) |\n| p | SpaceValue | Padding (all sides) |\n| px | SpaceValue | Padding left + right |\n| py | SpaceValue | Padding top + bottom |\n| pt | SpaceValue | Padding top |\n| pr | SpaceValue | Padding right |\n| pb | SpaceValue | Padding bottom |\n| pl | SpaceValue | Padding left |\n\n### Typography\n\n| Prop | Type | Description |\n|------|------|-------------|\n| textAlign | \"left\" \\| \"center\" \\| \"right\" \\| \"justify\" | Text alignment |\n| fontFamily | \"base\" \\| {css-font-family} | Font family token |\n| fontWeight | \"regular\" \\| \"medium\" \\| \"semibold\" \\| \"bold\" (named tokens) or \"100\"–\"900\" (numeric) or \"light\" \\| \"normal\" \\| \"bolder\" (CSS pass-through) | Font weight |\n| fontSize | \"base\" \\| \"100\"–\"1000\" \\| {css-length} | Font size token |\n| lineHeight | \"none\" \\| \"tight\" \\| \"snug\" \\| \"normal\" \\| \"relaxed\" \\| \"loose\" | Line height token |\n| letterSpacing | \"tighter\" \\| \"tight\" \\| \"normal\" \\| \"wide\" \\| \"wider\" \\| \"widest\" | Letter spacing token |\n| textDecoration | \"underline\" \\| \"line-through\" \\| \"overline\" \\| \"none\" | Text decoration |\n| textTransform | \"uppercase\" \\| \"lowercase\" \\| \"capitalize\" \\| \"none\" | Text transform |\n\n**Typography defaults:** fontSize and fontWeight have **no built-in defaults** — omitting them inherits from parent elements (browser default is ~16px / normal weight). Do not set fontSize or fontWeight unless you need a non-default value. For example, `fontSize: '300'` (16px) and `fontWeight: '500'` (normal) are the inherited defaults — omit them.\n\n`we-text` variants (set via the `variant` prop) bundle typography presets. Always pair with a semantic `tag` prop for correct HTML structure:\nbody (300, tag: p/span), label (200 + medium, tag: span), footnote (100, tag: span), subheading (400 + medium, tag: h5/p), ingress (400 + lineHeight 1.6, tag: p), heading-sm (500 + bold, tag: h4), heading-md (600 + bold, tag: h3), heading-lg (700 + bold, tag: h2), heading-xl (800 + bold, tag: h1).\nVariants set size and weight only — color is always inherited or set explicitly. For muted footnote text add `color=\"neutral-400\"` explicitly.\n\n### State\n\n| Prop | Type | Description |\n|------|------|-------------|\n| hoverProps | Partial\\ | Styles on :hover |\n| activeProps | Partial\\ | Styles on :active |\n| focusProps | Partial\\ | Styles on :focus |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'multiplier', 'subtractor', 'saturation', 'neutralSaturation'\n\ncolor.hues: 'neutral', 'primary', 'success', 'warning', 'danger'\n\ncolor.lightness: '0', '25', '50', '75', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\ncomponent.scrollbar: 'width', 'backgroundImage', 'background', 'cornerBackground', 'thumbBoxShadow', 'thumbBorderRadius', 'thumbBackground'\n\ncomponentHeight: 'xs', 'sm', 'md', 'lg', 'xl'\n\neffect.depth: '100', '200', '300', '400', '500', 'none'\n\nfont.family: 'base', 'mozilla', 'boldonse'\n\nfont.letterSpacing: 'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'\n\nfont.lineHeight: 'none', 'tight', 'snug', 'normal', 'relaxed', 'loose'\n\nfont.size: '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000', 'base'\n\nfont.weight: '100', '200', '300', '400', '500', '600', '700', '800', '900', 'regular', 'medium', 'semibold', 'bold'\n\nlayout: 'xs', 'sm', 'md', 'lg'\n\nradius: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'pill', 'full'\n\nshadow: 'sm', 'md', 'lg', 'xl'\n\nsize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nspace: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\nzIndex: 'dropdown', 'sticky', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nAgentSettings extends Ad4mModel:\n Fields:\n - currentTemplateId: string = 'default' [we://current_template]\n - defaultTemplateId: string = 'default' [we://default_template]\n - currentThemeId: string = 'default' [we://current_theme]\n - defaultThemeId: string = 'default' [we://default_theme]\n - claudeApiKey: string [we://claude_api_key]\n - perspectiveOrder: string [we://perspective_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 Relations:\n - installedTemplates: HasMany → Template [we://installed_template]\n - installedThemes: HasMany → Theme [we://installed_theme]\n - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference]\n\nAudioBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - artist: string [we://artist]\n - audioUrl: string (required) [we://audio_url]\n - duration: number [we://duration]\n - albumArt: string [we://album_art]\n - version: number [we://version]\n\nCalloutBlock extends WeNode:\n Fields:\n - text: string [we://text]\n - variant: string = info [we://variant]\n - icon: string [we://icon]\n - version: number [we://version]\n\nChatMessage extends WeNode:\n Fields:\n - role: string [we://role]\n - content: string [we://content]\n\nChatSession extends WeNode:\n Fields:\n - name: string [we://name]\n - templateId: string [we://template_id]\n Relations:\n - messages: HasMany → ChatMessage [we://chat_message]\n\nCodeBlock extends WeNode:\n Fields:\n - code: string (required) [we://code]\n - language: string [we://language]\n - title: string [we://title]\n - version: number [we://version]\n\nCollectionBlock extends WeNode:\n Fields:\n - editorState: string = null [we://editor_state]\n - type: string [we://type]\n - display: string [we://display]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - columns: number [we://columns]\n - gap: string [we://gap]\n - version: number [we://version]\n - textContent: string [we://text_content]\n Relations:\n - children: HasMany [we://children]\n\nDividerBlock extends WeNode:\n Fields:\n - style: string = solid [we://style]\n - version: number [we://version]\n\nEmbedBlock extends WeNode:\n Fields:\n - url: string [we://url]\n - target: string [we://target]\n - targetType: string [we://target_type]\n - displayMode: string = card [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - startDate: string (required) [we://start_date]\n - endDate: string [we://end_date]\n - location: string [we://location]\n - allDay: boolean = false [we://all_day]\n - version: number [we://version]\n\nFileBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - name: string (required) [we://name]\n - url: string (required) [we://url]\n - mimeType: string [we://mime_type]\n - size: number [we://size]\n - version: number [we://version]\n\nImageBlock extends WeNode:\n Fields:\n - src: string (required) [we://src]\n - altText: string [we://altText]\n - width: number [we://width]\n - height: number [we://height]\n - version: number [we://version]\n\nLinkBlock extends WeNode:\n Fields:\n - url: string (required) [we://url]\n - title: string [we://title]\n - description: string [we://description]\n - thumbnail: string [we://thumbnail]\n - version: number [we://version]\n\nLocationBlock extends WeNode:\n Fields:\n - name: string [we://name]\n - latitude: number (required) [we://latitude]\n - longitude: number (required) [we://longitude]\n - address: string [we://address]\n - city: string [we://city]\n - countryCode: string [we://country_code]\n - country: string [we://country]\n - version: number [we://version]\n\nSignal extends Ad4mModel:\n Fields:\n - signalTypeId: string [we://signal_type_id]\n - value: number [we://value]\n\nSignalType extends WeNode:\n Fields:\n - name: string [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - iconSecondary: string [we://icon_secondary]\n - step: number = 1 [we://step]\n - rangeMin: number [we://range_min]\n - rangeMax: number = 1 [we://range_max]\n - mode: SignalMode = 'toggle' [we://mode]\n - aggregate: SignalAggregate = 'count' [we://aggregate]\n - semantic: SignalSemantic = 'custom' [we://semantic]\n - allowChange: boolean = true [we://allow_change]\n - valueType: string = 'numeric' [we://signal_value_type]\n - schemaVersion: number = 1 [we://schema_version]\n\nSpace extends WeNode:\n Fields:\n - uuid: string [we://uuid]\n - url: string [we://url]\n - name: string (required) [we://name]\n - description: string (required) [we://description]\n - access: string = 'personal' [we://access]\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 Relations:\n - location: HasOne [we://location]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - type: string [we://type]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - textFormat: number [we://textFormat]\n - textStyle: string [we://textStyle]\n - listType: string [we://listType]\n - start: number [we://start]\n - tag: string [we://tag]\n - text: string [we://text]\n - version: number [we://version]\n\nTheme extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - slug: string [we://slug]\n - version: number = 1 [we://version]\n - css: string = null [we://stylesheet]\n - overrides: string = null [we://token_overrides]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nVideoBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - url: string (required) [we://url]\n - duration: number [we://duration]\n - thumbnail: string [we://thumbnail]\n - provider: string [we://provider]\n - version: number [we://version]\n\nWeNode extends Ad4mModel:\n Relations:\n - comments: HasMany [we://comment]\n - signals: HasMany → Signal [we://signal]\n\n---\n\n## Stores\n\nStores provide state (readable values) and actions (methods) for dynamic logic in schemas.\nAccess state with $store and call actions with $action.\nFor ephemeral/form state, use $localState/$local/$setLocal instead of stores (see Dynamic Logic).\n\nAdamStore:\n- State:\n - adamClient: Ad4mClient | undefined\n - me: Agent | undefined\n - allPerspectives: array of PerspectiveProxy objects (all AD4M perspectives)\n - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed)\n - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)\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 - bootState: string\n - passwordError: string | undefined\n - loginLoading: boolean\n - creatingSpace: boolean (true while a new space is being created)\n - agents: AgentProfileSummary[] — cache of all fetched agent profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownAgent: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from agents cache)\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n- Actions:\n - navigate(to: string, options?): navigates to a route\n - addNewSpace(space: Space): adds a new space\n - createSpace(name: string, description: string, shared: boolean, imageFile?: File): creates a new space with full setup\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native perspective (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the perspective is already a published neighbourhood\n - switchPerspective(uuid: string): switches to a perspective by UUID, registers its SHACL models as dynamic model classes, and populates currentPerspectiveModels\n - removePerspective(uuid: string): removes a perspective by UUID\n - reorderPerspectives(newOrder: string[]): reorders the sidebar items by UUID array\n - login(password: string): logs in the agent with password\n - logout(): locks the agent and returns to login screen\n - fetchAgent(did: string): fetches and caches an agent's profile from their public AD4M perspective\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to public perspective\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads image to FILE_STORAGE_LANGUAGE and publishes expression URL to public perspective\n - updateAgentLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges location update into cache and publishes to public perspective\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a perspective that accumulated duplicate SDNA installs (e.g. from before joinSpace checked for existing SDNA before installing) — removes the redundant duplicate link copies. Defaults to the current perspective. 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\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n- Actions:\n - navigate(to: string, options?): navigates to a route\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - installedThemes: array of ThemeData objects — user-installed themes from root perspective (origin: \"custom\" | \"marketplace\")\n - spaceThemes: array of ThemeData objects — themes stored in the current space perspective (origin: \"custom\")\n - allThemes: array of ThemeData objects — union of builtInThemes + visible installedThemes + spaceThemes (hidden themes filtered out)\n - currentThemeId: string — id of the currently active theme\n - currentTheme: ThemeData — the currently active theme object (id, name, icon, origin)\n - defaultThemeId: string — id of the user's preferred default theme (used for bootscreen, shell, and future space-override). Persisted to AgentSettings.defaultThemeId\n - themeManagementList: ThemeManagementItem[] — flat list of all themes (built-in + all custom) with management metadata (id, name, icon, isBuiltIn, isInstalled, isDefault)\n- 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 - toggleThemeInstalled(themeId: string): toggles a custom theme visible/hidden in pickers; does not delete the theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteTheme(themeId: string): permanently deletes a custom theme\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 - shellTemplates: array of TemplateSchema objects (static system pages: profile, settings, tests)\n - currentTemplate: TemplateSchema (the active template)\n - operationLoading: unknown\n - activeShellView: string | null (id of the currently open shell overlay: 'profile' | 'settings' | 'schema-tests' | 'landing-page' | null)\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n- Actions:\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - saveTemplate(name: string): saves the current template\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - deleteTemplate(): unknown\n - openShellView(id: string): opens a shell overlay by id ('profile' | 'settings' | 'schema-tests' | 'landing-page')\n - closeShellView(): closes the currently open shell overlay\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 - 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 - 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 - signalTypes: array of SignalType objects (community-created reaction/vote types)\n - signalTypesBySlug: Record — computed map; access via { $store: \"spaceStore.signalTypesBySlug.\" }; use .id for the UUID\n- Actions:\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 - deletePost(postId: string): permanently deletes a post and all of its contained blocks (recursive, atomic)\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads and sets the space avatar or cover image\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - upsertSignal(nodeId: string, signalTypeId: string, value: number): adds or updates a signal on a node; value=0 deletes it\n - navigateToSpace(spaceId: string, view?: string): navigates to a space — accepts a perspective UUID or a neighbourhood CID (sharedUrl without the neighbourhood:// prefix); pre-loads space templates before switching so the template and data arrive together\n\nAiStore:\n- State:\n - models: array of Model objects\n - tasks: array of AITask objects\n - isOpen: unknown\n - messages: unknown\n - isStreaming: unknown\n - streamingContent: unknown\n - apiKeyConfigured: unknown\n - templateName: unknown\n - templateIcon: unknown\n - isReadOnly: unknown\n - hasPendingChanges: unknown\n - pickerOpen: unknown\n - pickerAction: unknown\n - pickerDefaultName: unknown\n - pickerDefaultIcon: unknown\n - pickerShowDestination: unknown\n - sessions: unknown\n - activeSessionId: unknown\n - panelMode: unknown\n - schemaJson: unknown\n - operationLoading: unknown\n - canUndo: boolean (true when there are schema edits that can be undone)\n - canRedo: boolean (true when there are undone schema edits that can be redone)\n- Actions:\n - handleSchemaPrompt(prompt: string): generates a schema from a prompt\n - sendMessage(): unknown\n - close(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - setApiKey(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setPanelMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: unknown\n - activeAppId: string | null — id of the currently active app, or null if none\n- Actions:\n - activateApp(id: string): activates an app and switches to its view\n - deactivateApp(): deactivates the current app and returns to the template view\n\n---\n\n## Store Usage Patterns\n\nReading state:\n{ \"$store\": \"storeName.property\" }\nExample: { \"$store\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"adamStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"CircleButton\",\n \"props\": {\n \"label\": \"$space.name\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$space.uuid\"] }] }\n }\n }\n ]\n}\n\nConditional rendering from store:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$eq\": [{ \"$store\": \"routeStore.currentPath\" }, \"/\"] },\n \"then\": { \"type\": \"we-text\", \"children\": [\"Home\"] },\n \"else\": { \"type\": \"we-text\", \"children\": [\"Not home\"] }\n }\n}\n\nDeriving options from store:\n{\n \"$map\": {\n \"items\": { \"$store\": \"templateStore.templates\" },\n \"select\": { \"name\": \"$item.meta.name\", \"icon\": \"$item.meta.icon\" }\n }\n}\n\nQuerying model data:\n{\n \"$query\": { \"entity\": \"TaskBlock\", \"where\": { \"status\": \"todo\" } }\n}\n\nEager-loading relations with include (most common relational pattern):\nWhen you need related data displayed alongside a list, use include to hydrate relations in one query.\n\nExample — Channel list with conversation count and latest conversation:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Channel\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"$conversationCount\": { \"from\": \"conversations\", \"count\": true },\n \"$latestConversation\": { \"from\": \"conversations\", \"order\": { \"createdAt\": \"desc\" }, \"limit\": 1 }\n }\n }\n },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"we-text\", \"children\": [\"$channel.name\"] },\n { \"type\": \"we-text\", \"children\": [\"$channel.$conversationCount\"] }\n ]\n }]\n}\n\nExample — Nested include (Conversations with their messages):\n{\n \"$query\": {\n \"entity\": \"Conversation\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"messages\": {\n \"order\": { \"createdAt\": \"desc\" },\n \"limit\": 20\n }\n }\n }\n}\nEach conversation in the result has a messages array of hydrated Message instances.\nNesting works to any depth: \"include\": { \"messages\": { \"include\": { \"reactions\": true } } }\n\nRelational drill-down (master-detail navigation across entity relations):\nUse routes + a $query `scope` when you navigate to a detail route and need only that record's children.\nscope.anchor is the parent entity type; scope.via is its HasMany relation (see externalModels) whose targets\nare the query's entity; scope.anchorId is the parent record's id. The adapter resolves the relation to a\nbackend handle, so no protocol details live in the template.\nrouteStore.segments.N extracts the Nth dynamic path segment (segments splits currentPath by \"/\").\n\nExample — Channel list → Conversation list:\n{\n \"routes\": [\n {\n \"path\": \"/\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": { \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/channels/\", \"$channel.id\"] }] }\n },\n \"children\": [\"$channel.name\"]\n }]\n }]\n },\n {\n \"path\": \"/channels/:channelId\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Conversation\",\n \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": { \"$store\": \"routeStore.segments.1\" } },\n \"dataset\": \"$currentDataset\"\n }\n },\n \"as\": \"convo\"\n },\n \"children\": [{\n \"type\": \"we-text\",\n \"children\": [\"$convo.conversationName\"]\n }]\n }]\n }\n ]\n}\nNotes:\n- Use include when you need related data displayed inline (e.g. a post with its comments, a channel with its conversation count).\n- Use a scope drill-down when you're on a detail route and want only children belonging to the current record.\n- dataset must point to the dataset that holds the data. For external apps (e.g. Flux) opened as a WE space, use \"$currentDataset\".\n- The relation name (in include, or scope.via) is the HasMany field name on the parent entity.\n\nLocal state (form with validation):\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"name\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [{ \"rule\": \"required\" }, { \"rule\": \"minLength\", \"value\": 2 }]\n },\n \"loading\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"neutral-0\", \"r\": \"400\", \"border\": \"1px solid neutral-200\", \"p\": \"400\", \"gap\": \"300\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"initials\": \"$post.author\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\" }, \"children\": [\"$post.author\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [\"$post.title\"] },\n { \"type\": \"we-text\", \"children\": [\"$post.text\"] }\n ]\n }\n ]\n}\n\nUse $query or $store for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"spaceStore.posts\" }, \"as\": \"post\" }, \"children\": [...] }\n\nPer-item customization inside $each:\nTo style or highlight specific items, add a data flag to those items and use $if on the flag inside the template. Do NOT use $eq: [\"$index\", N] comparisons — they are fragile, repetitive, and break when items are reordered.\nExample: add \"highlighted\": true to one item's data, then use $if on \"$post.highlighted\" in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": \"$post.highlighted\", \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor conditional props (e.g. different bg on highlighted items):\n{ \"bg\": { \"$if\": { \"condition\": \"$post.highlighted\", \"then\": \"primary-50\", \"else\": \"neutral-0\" } } }\n\nBoolean toggle (show/hide, expand/collapse):\n{\n \"type\": \"Column\",\n \"$localState\": { \"showDetails\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocal\": \"showDetails\" } }, \"children\": [\"Toggle Details\"] },\n { \"type\": \"$if\", \"props\": { \"condition\": { \"$local\": \"showDetails\" }, \"then\": { \"type\": \"we-text\", \"children\": [\"Details content here\"] } } }\n ]\n}\n\nSignal types (community-specific reactions/votes):\nSignal types are created per-community by the user. Never hardcode signal type UUIDs in schemas.\nInstead reference them by slug through spaceStore.signalTypesBySlug.\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 (inside a $each over a model with $query include):\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": { \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" } },\n \"count\": true\n },\n \"$myLikeSignal\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" },\n \"author\": \"$me.did\"\n },\n \"limit\": 1\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"then\": {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"myValue\": \"$item.$myLikeSignal.value\",\n \"aggregate\": \"$item.$totalLikeCount\",\n \"onSignal\": {\n \"$action\": \"spaceStore.upsertSignal\",\n \"args\": [\"$item.id\", { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" }, \"$arg\"]\n }\n }\n }\n }\n }\n ]\n}\n\nNotes:\n- The $if guard hides SignalControl if the community hasn't created a signal type with that slug.\n- Replace \"like\" with the user's slug throughout (in $store paths and args).\n- $query include adds $totalLikeCount and $myLikeSignal as computed properties 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## Routing Structure\n\nDefine nested routes using the \"routes\" array at the root node of the schema.\nEach route object describes a path and the UI node to render when that path is active.\nRoutes can be nested to support sub-pages and layouts.\n\nRoute objects follow the same structure as schema nodes, with an additional \"path\" property.\n\n- The \"routes\" array MUST be placed on the ROOT template node (or on a route node for nested routing). The router only reads routes from these positions — placing routes on an arbitrary child node means the router will never find them and nothing will render.\n- Use \"path: '*'\" or \"path: '/*'\" for catch-all/not-found routes.\n- Use \":paramName\" for dynamic route parameters (e.g. \"/space/:spaceId\").\n- Use nested \"routes\" arrays for sub-pages and layouts.\n- Use { \"type\": \"$routes\" } in children to indicate where nested routes should render. The $routes outlet can be deeply nested — only the routes array placement matters.\n- EVERY { \"type\": \"$routes\" } outlet MUST have a \"routes\" array defined on the same node or an ancestor node. A $routes outlet without a routes array is invalid and will fail validation.\n- NEVER duplicate a route path — every route in the same \"routes\" array MUST have a unique path.\n- When using tabs, each tab's key and navigate path MUST have a matching route. Ensure a 1:1 correspondence between tabs and routes.\n\n### Tabs + Routing\n\nIMPORTANT: we-tabs only manages visual selection — clicking a tab does NOT navigate automatically.\nEach we-tab MUST have an onClick with { \"$action\": \"routeStore.navigate\" } to trigger route changes.\nBind we-tabs selectedKey to the matching route segment so the active tab stays in sync.\n(Alternatively, a single onChange on we-tabs can replace per-tab onClick — see onChange pattern below.)\n\nRecommended pattern — header above tabs (routes on ROOT, $routes outlet nested inside):\n{\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"Select a tab\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Posts content\"] }] },\n { \"path\": \"/articles\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Articles content\"] }] }\n ],\n \"children\": [\n { \"type\": \"Row\", \"props\": { \"p\": \"300\", \"ax\": \"between\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-lg\" }, \"children\": [\"My App\"] }\n ]},\n {\n \"type\": \"we-tabs\",\n \"props\": { \"selectedKey\": { \"$store\": \"routeStore.segments.0\" } },\n \"children\": [\n { \"type\": \"we-tab\", \"props\": { \"key\": \"posts\", \"label\": \"Posts\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/posts\"] } } },\n { \"type\": \"we-tab\", \"props\": { \"key\": \"articles\", \"label\": \"Articles\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/articles\"] } } }\n ]\n },\n { \"type\": \"$routes\" }\n ]\n}\nNote: \"routes\" is on the root Column, NOT on a child. The $routes outlet is a child — that's fine. Only the routes array placement matters.\n\nWRONG — two common mistakes that produce empty tabs (validator will catch both):\n{\n // MISTAKE 1: routes defined on an inner child node, not the root.\n // The router never inspects children for routes arrays — this routes array is invisible.\n \"type\": \"Column\",\n \"children\": [\n { \"type\": \"we-tabs\", \"children\": [\"...tabs...\"] },\n {\n \"type\": \"Column\",\n \"routes\": [ // ← WRONG: router never reads this\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [\"...\"] }\n ],\n \"children\": [{ \"type\": \"$routes\" }] // ← outlet here does nothing without a live routes array\n }\n ]\n}\n\n{\n // MISTAKE 2: using { type: \"$routes\" } as a route entry's component type.\n // $routes is an outlet slot marker — as a leaf route entry it has no children injected,\n // so it returns null. Every tab navigates to a route that renders nothing.\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/posts\", \"type\": \"$routes\" } // ← WRONG: renders null, use a real component\n ],\n \"children\": [{ \"type\": \"$routes\" }]\n}\n\nAlternative: single onChange on we-tabs (fires with $event.detail.value = selected key):\n{ \"onChange\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/\", \"$arg.detail.value\"] }] } }\nThis replaces all per-tab onClick handlers but requires $concat to build the path.\n\nNested routing example:\n{\n \"routes\": [\n { \"path\": \"*\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Page not found\"] }] },\n { \"path\": \"/\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Home page\"] }] },\n {\n \"path\": \"/space/:spaceId\",\n \"type\": \"Row\",\n \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Space page not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"About sub-page\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Post not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"No posts selected\"] },\n { \"path\": \"/1\", \"type\": \"we-text\", \"children\": [\"Post 1 page\"] }\n ]\n }\n ]\n }\n ]\n}\n\n---\n\n## Rules & Best Practices\n\n- Always use the correct prop names and value types for each component.\n- Never use null as a value in any children array. Only use valid schema nodes or strings.\n- Each item in a children array must be either a valid schema node object or a string.\n- Use design tokens for spacing, color, radius, etc. (do not use raw CSS except in styles).\n- Use the styles prop for custom inline CSS (e.g., { \"width\": \"100px\" }).\n- Use hoverProps for hover state overrides, activeProps for pressed state, focusProps for focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row).\n- Use dynamic logic tokens ($store, $if, $action, etc.) for reactivity and conditional behavior.\n- Nest components using children or slots as needed.\n- For routes, use the routes array with path and child nodes.\n- Do not invent new components or props — use only those listed in the component registry.\n- Do not set props to their default/inherited values — omit them. fontSize and fontWeight inherit from parents (~16px / normal), so only set them when you need a different value.\n- Omit empty `props` and `children` — both are optional. Do not write `props: {}` or `children: []`.\n- Do not use `as const` on schema node `type` fields — `SchemaNode.type` is `string`, so it is never needed.\n- For icon-only buttons, nest a `we-icon` child inside `we-button` rather than using a `text` prop with a Unicode character. **Omit the `size` prop on `we-icon` when nesting inside sized primitives** (`we-button`, `we-input`, `we-badge`, `we-textarea`) — these components auto-size nested icons via `--we-context-icon-size` (xs→12px, sm→16px, md→24px, lg→32px, xl→40px). Only set an explicit icon `size` if you need to override the automatic sizing. Example: `{ type: 'we-button', props: { variant: 'ghost', size: 'sm' }, children: [{ type: 'we-icon', props: { name: 'x' } }] }`.\n- NEVER pass a bare number like \"16\" as a size or dimension prop — it is not valid CSS. Always check the component's declared prop type: if it's a string union, use one of the listed values; if it accepts arbitrary strings, include a CSS unit (e.g. \"16px\", \"2rem\").\n- For interactive list items and selectable options, use `we-button` with variant switching (e.g., `secondary` when selected, `ghost` when not) instead of manually styling `Row` with cursor, bg, and onClick. Buttons provide hover, focus, and active states for free.\n- For card-like layouts, compose from `Column` with DS props (bg, r, border, p, gap). This gives full control over spacing and appearance.\n- When rendering lists of similar items (posts, cards, users, etc.), ALWAYS use `$each` with a single template child — never duplicate the same node structure multiple times. Use literal arrays in `items` for static data, or `$store`/`$query` for dynamic data.\n\n### Icon Names (Phosphor Icons)\n\nwe-icon uses **Phosphor Icons** (v2.1). Do NOT use Heroicons, Material, or FontAwesome names.\nPhosphor names are lowercase-kebab-case. The `weight` prop controls style: \"regular\" (default), \"bold\", \"fill\", \"light\", \"thin\", \"duotone\".\n\nCommon Phosphor icon names (use these, NOT Heroicons equivalents):\n- Navigation: house, arrow-left, arrow-right, caret-left, caret-right, caret-down, caret-up, arrows-clockwise\n- Actions: plus, minus, x, check, pencil-simple, trash, copy, download, upload, share, link, magnifying-glass, funnel, sliders-horizontal\n- Communication: chat-circle, chat-dots, envelope-simple, paper-plane-tilt, bell, megaphone\n- Social: heart, thumbs-up, thumbs-down, star, share-network, users, user, user-plus\n- Media: image, camera, play, pause, stop, microphone, speaker-high, video-camera\n- Files: file, file-text, folder, folder-open, clipboard-text, note\n- UI: list, squares-four, gear, dots-three, dots-three-vertical, warning, info, question, check-circle, x-circle, eye, eye-slash\n- Misc: lightning, rocket, globe, map-pin, calendar, clock, tag, bookmark, flag, lock, shield-check\n\nWRONG icon names (Heroicons/Material — do NOT use):\n- \"chat-bubble-left\" → use \"chat-circle\"\n- \"chevron-right\" → use \"caret-right\"\n- \"cog\" / \"settings\" → use \"gear\"\n- \"trash-can\" → use \"trash\"\n- \"magnifying-glass-circle\" → use \"magnifying-glass\"\n- \"home\" → use \"house\"\n- \"favorite\" → use \"heart\"\n- \"delete\" → use \"trash\"\n- \"search\" → use \"magnifying-glass\"\n- \"close\" → use \"x\"\n- \"menu\" → use \"list\"\n- All schemas must be valid JSON with property names and string values in double quotes.\n- The meta property at the root is required: { \"meta\": { \"name\": \"...\", \"description\": \"...\", \"icon\": \"...\" } }\n- Always set `bg: 'neutral-50'` on root-level schema nodes (templates, pages). This ensures proper background in all themes — without it, dark mode renders white backgrounds.\n\nMost @we/primitives inherit all Design System Props documented above (layout, visual, flex, typography, state).\nSome layout-only primitives (we-avatar, we-icon, we-image, we-spinner, etc.) only accept Layout props — see the Design System Props section for the full list.\n\nNative HTML elements (lowercase tags render directly without registry entries):\n- Layout: div, section, article, aside, main, nav, header, footer\n- Text: p, span, h1-h6, pre, code, blockquote\n- Lists: ul, ol, li\n- Forms: form, input, button, label, select, textarea\n- Media: img, video, audio, canvas, figure, figcaption\n- Other: a, table, tr, td, th, details, summary, dialog\n\n## Schema Validation\n\nRun `we-validate-schemas` (or `node packages/schema-system/shared/dist/cli/we-validate-schemas.js`) from the monorepo root to validate all `.schema.ts` files.\nFor a specific file: `we-validate-schemas packages/app-framework/src/shared/schemas/MyTemplate.schema.ts`\n\nAfter creating or modifying a `.schema.ts` file, always run validation to catch:\n- Unknown component types (typos, missing registry entries)\n- Invalid or misspelled props (with \"did you mean?\" suggestions)\n- Prop type mismatches (e.g., number where string expected)\n- Missing required `meta` field on root TemplateSchema nodes\n- `$routes` outlet without a `routes` array on an ancestor\n- Orphan `$local` / `$setLocal` references without a `$localState` ancestor\n- DS layer consistency (mixing props from layers the component doesn't support)"; diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx new file mode 100644 index 00000000..cfe492c6 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx @@ -0,0 +1,326 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ComparisonOperator, ConditionExpr, ConditionOperand, ScopeGroup } from '@we/schema-shared'; +import { isUnaryOperator, parseCondition, serializeCondition } from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, For, Show, untrack } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { OperandInput, operandValueType } from './ValueRefPicker'; + +/** + * Row-based editor for a condition token ($if conditions today; `disabled`/`hidden` + * style props next). + * + * Conditions the grammar can't represent exactly fall back to the JSON editor rather + * than being approximated — see `conditionModel.ts`. The JSON editor is also always + * reachable from the toggle, so the builder never becomes a ceiling. + */ + +const OPERATOR_LABELS: Record = { + truthy: 'is set / true', + falsy: 'is not set / false', + eq: 'equals', + ne: 'does not equal', + gt: 'is greater than', + lt: 'is less than', + in: 'is one of', + nin: 'is not one of', +}; + +const OPERATOR_OPTIONS = (Object.keys(OPERATOR_LABELS) as ComparisonOperator[]).map((value) => ({ + label: OPERATOR_LABELS[value], + value, +})); + +/** A reference with no path chosen yet, or an unfilled literal, isn't ready to emit. */ +function operandComplete(operand: ConditionOperand | undefined): boolean { + if (!operand) return false; + switch (operand.kind) { + case 'list': + return operand.value.length > 0; + case 'literal': + return operand.value !== ''; + case 'count': + return operandComplete(operand.items); + case 'formState': + return operand.field.trim() !== ''; + default: + return operand.path.trim() !== ''; + } +} + +function exprComplete(expr: ConditionExpr): boolean { + if (expr.type === 'group') return expr.children.length > 0 && expr.children.every(exprComplete); + if (!operandComplete(expr.left)) return false; + return isUnaryOperator(expr.operator) ? true : operandComplete(expr.right); +} + +function emptyRow(): ConditionExpr { + return { type: 'comparison', operator: 'truthy', left: { kind: 'context', path: '' } }; +} + +export function ConditionEditor(props: { + condition: unknown; + scope: ScopeGroup[]; + onChange: (token: unknown) => void; + /** Label above the editor — e.g. "Show when". */ + label?: string; +}) { + const [draft, setDraft] = createSignal(parseCondition(props.condition)); + const [rawMode, setRawMode] = createSignal(false); + + // Adopt external edits (undo/redo, AI changes, selecting another node) but ignore the + // echo of our own writes, which would otherwise clobber an in-progress row. + createEffect(() => { + const incoming = props.condition; + const current = untrack(draft); + if (current && JSON.stringify(serializeCondition(current)) === JSON.stringify(incoming)) return; + setDraft(parseCondition(incoming)); + }); + + /** True when a condition exists but the builder can't represent it exactly. */ + const unsupported = createMemo(() => props.condition !== undefined && parseCondition(props.condition) === null); + + const update = (next: ConditionExpr) => { + setDraft(next); + if (exprComplete(next)) props.onChange(serializeCondition(next)); + }; + + const rows = createMemo(() => { + const expr = draft(); + if (!expr) return []; + return expr.type === 'group' ? expr.children : [expr]; + }); + + const groupOperator = () => { + const expr = draft(); + return expr?.type === 'group' ? expr.operator : 'and'; + }; + + const replaceRow = (index: number, next: ConditionExpr | null) => { + const expr = draft(); + if (!expr) return; + if (expr.type !== 'group') { + if (next) update(next); + else { + setDraft(null); + props.onChange(null); + } + return; + } + const children = [...expr.children]; + if (next) children[index] = next; + else children.splice(index, 1); + + if (children.length === 0) { + setDraft(null); + props.onChange(null); + return; + } + // Collapse a one-child group back to a bare comparison so the token stays idiomatic. + update(children.length === 1 ? children[0] : { ...expr, children }); + }; + + const addRow = () => { + const expr = draft(); + const next = emptyRow(); + if (!expr) { + setDraft(next); + return; + } + if (expr.type === 'group') setDraft({ ...expr, children: [...expr.children, next] }); + else setDraft({ type: 'group', operator: 'and', children: [expr, next] }); + }; + + const setGroupOperator = (operator: 'and' | 'or') => { + const expr = draft(); + if (expr?.type !== 'group') return; + update({ ...expr, operator }); + }; + + return ( + + + + {props.label ?? 'Condition'} + + + + setRawMode((v) => !v)} aria-label="Edit as JSON"> + + + + + + + + + + + + Custom expression — edit as JSON + + + + + props.onChange(JSON.parse(json))} + /> + + + } + > + + 1}> + + + Match + + setGroupOperator(e.detail === 'or' ? 'or' : 'and')} + /> + + of these + + + + + + {(row, index) => ( + replaceRow(index(), next)} + onRemove={() => replaceRow(index(), null)} + removable={rows().length > 1 || draft() !== null} + /> + )} + + + + + + {rows().length === 0 ? 'Add condition' : 'Add another'} + + + + + + ); +} + +/** + * A single comparison row. Nested groups render read-only here — the builder edits one + * level of grouping; anything deeper stays in JSON (see MAX_CONDITION_DEPTH). + */ +function ConditionRow(props: { + expr: ConditionExpr; + scope: ScopeGroup[]; + onChange: (next: ConditionExpr) => void; + onRemove: () => void; + removable: boolean; +}) { + const comparison = () => (props.expr.type === 'comparison' ? props.expr : null); + + // Type the literal side from whichever side holds a known reference. + const literalType = () => { + const cmp = comparison(); + if (!cmp) return 'unknown' as const; + const left = operandValueType(cmp.left, props.scope); + return left === 'unknown' ? operandValueType(cmp.right, props.scope) : left; + }; + + const setOperator = (operator: ComparisonOperator) => { + const cmp = comparison(); + if (!cmp) return; + if (isUnaryOperator(operator)) { + props.onChange({ type: 'comparison', operator, left: cmp.left }); + return; + } + // `is one of` takes a list; every other binary operator takes a single value. + const right = + operator === 'in' || operator === 'nin' + ? cmp.right?.kind === 'list' + ? cmp.right + : ({ kind: 'list', value: [] } as ConditionOperand) + : (cmp.right ?? ({ kind: 'literal', value: '' } as ConditionOperand)); + props.onChange({ type: 'comparison', operator, left: cmp.left, right }); + }; + + return ( + + + + Grouped condition — edit as JSON + + + } + > + {(cmp) => ( + + + + {/* No text entry on the left: this side names what is being tested, and a + literal here makes the condition constant. The right side is where a + fixed value belongs — that's what you're comparing against. */} + props.onChange({ ...cmp(), left })} + valueType={operandValueType(cmp().right, props.scope)} + allowCount + allowText={false} + placeholder="Select a value" + /> + + + + + + + + + + + setOperator(e.detail as ComparisonOperator)} + /> + + + props.onChange({ ...cmp(), right })} + valueType={literalType()} + list={cmp().operator === 'in' || cmp().operator === 'nin'} + allowCount + placeholder="Value" + /> + + + )} + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx new file mode 100644 index 00000000..5056dd04 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx @@ -0,0 +1,237 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ContentShape, ScopeGroup, ValueIf } from '@we/schema-shared'; +import { + classifyContent, + contentAsText, + parseValue, + parseValueIf, + serializeValue, + serializeValueIf, +} from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, on, Show } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { ValueEditor } from './ValueEditor'; +import { OperandInput } from './ValueRefPicker'; + +/** + * Editor for a node's `children` when it holds content rather than child nodes. + * + * Content has three authoring shapes — plain text, a value bound to data, and a + * conditional between two values — and the mode selector converts between them in both + * directions. Conversions carry the existing content across where they can: turning text + * into a conditional seeds the "then" branch with that text, which is also the natural + * way to author one (write the common case, then add the condition). + */ + +const MODE_LABELS: Record = { + text: 'Text', + value: 'Data', + conditional: 'Conditional', + custom: 'Custom', +}; + +/** + * Shown while a mode is switched but not yet usable. Switching mode never destroys the + * existing content — the schema keeps rendering it until the new form is complete — so + * this says so rather than leaving the panel looking like the change was lost. + */ +function PendingHint(props: { children: string }) { + return ( + + + + {props.children} + + + ); +} + +export function ContentEditor(props: { + /** The node's `children` array. Not named `children` — Solid special-cases that in JSX. */ + content: unknown[] | undefined; + scope: ScopeGroup[]; + /** Node id — resets the mode when the selection changes. */ + nodeId: string | undefined; + onTextChange: (value: string) => void; + onTokenChange: (value: unknown) => void; + onChildrenChange: (children: unknown[]) => void; +}) { + const [modeOverride, setModeOverride] = createSignal(null); + // Working copy of a conditional while it is still incomplete. Until the condition is + // set there is no valid token to write, and writing a half-built $if would break the + // render — so the schema keeps its old content until the conditional is usable. + const [ifDraft, setIfDraft] = createSignal | null>(null); + + createEffect( + on( + () => props.nodeId, + () => { + setModeOverride(null); + setIfDraft(null); + }, + { defer: true }, + ), + ); + + const token = createMemo(() => props.content?.[0]); + const mode = createMemo(() => modeOverride() ?? classifyContent(props.content)); + + const modeOptions = createMemo(() => { + const modes: ContentShape[] = ['text', 'value', 'conditional']; + // "Custom" is a state you can leave but not choose. + if (mode() === 'custom') modes.push('custom'); + return modes.map((m) => ({ label: MODE_LABELS[m], value: m })); + }); + + function switchMode(next: ContentShape) { + if (next === mode()) return; + setModeOverride(next); + + if (next === 'text') { + // The seed is already a complete value, so converting is a single click. + setIfDraft(null); + props.onTextChange(contentAsText(token())); + return; + } + if (next === 'conditional') { + // Carry the current content into the "then" branch rather than discarding it. + const existing = parseValueIf(token()); + setIfDraft(existing ?? { condition: undefined, then: token() ?? '' }); + return; + } + setIfDraft(null); + } + + // ── Conditional mode ────────────────────────────────────────────────────── + + const currentIf = createMemo>(() => ifDraft() ?? parseValueIf(token()) ?? { then: '' }); + + function updateIf(patch: Partial) { + const next = { ...currentIf(), ...patch }; + setIfDraft(next); + // Only write once the conditional would actually render something. + if (next.condition !== undefined && next.condition !== null && next.then !== undefined && next.then !== '') { + props.onTokenChange(serializeValueIf(next as ValueIf)); + setIfDraft(null); + } + } + + const conditionalIncomplete = () => { + const value = currentIf(); + return value.condition === undefined || value.condition === null || value.then === undefined || value.then === ''; + }; + + // ── Data mode ───────────────────────────────────────────────────────────── + + const boundValue = createMemo(() => { + if (typeof token() === 'string') return undefined; + return parseValue(token()) ?? undefined; + }); + + return ( + + + + Content + + switchMode(e.detail as ContentShape)} + /> + + + + ) => props.onTextChange(e.detail)} + /> + + + + + props.onTokenChange(serializeValue(operand))} + valueType="string" + allowCount + placeholder="Bind to a value" + /> + + Pick a value to apply this — the current content stays until then. + + + + + + + updateIf({ condition })} + /> + + + + Then show + + updateIf({ then })} + placeholder="Value when true" + /> + + + + Otherwise show + + updateIf({ else: otherwise === '' ? undefined : otherwise })} + placeholder="Value when false" + /> + + + + + + + Set a condition and a value to apply this — the current content stays until then. + + + + + + + + + + Custom expression — edit as JSON, or switch to another mode to replace it. + + + props.onChildrenChange(JSON.parse(json))} + /> + + + + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx index faf0cc35..ff646c89 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx @@ -4,13 +4,15 @@ import { contextData } from '@we/ai-context'; import { Column, Combobox, type ComboboxOption, Grid, Row } from '@we/components/solid'; import { tokenVar } from '@we/design-utils'; import { compressImageToFileData, ImageBlock } from '@we/models'; -import type { ComponentMeta, PropLayer, PropMeta, SchemaNode, TemplateSchema } from '@we/schema-shared'; -import { findNodeById, getComponentMeta, mergeNode } from '@we/schema-shared'; +import type { ComponentMeta, PropLayer, PropMeta, SchemaNode, ScopeGroup, TemplateSchema } from '@we/schema-shared'; +import { findNodeById, getComponentMeta, getScopeAtNode, mergeNode } from '@we/schema-shared'; import { useVisualEditor } from '@we/schema-solid'; import type { JSX } from 'solid-js'; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; -import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { ContentEditor } from './ContentEditor'; +import { ValueEditor } from './ValueEditor'; // ----------------------------------------------------------------------- // Schema helpers @@ -492,15 +494,15 @@ export function InspectorPanel() { } } - function handleContentChange(value: string) { + /** Replace `children` wholesale. `null` deletes the key (mergeNode treats null as delete). */ + function setChildren(children: unknown[] | null) { const id = visualEditor.selectedId(); if (!id) return; try { const clone = deepClone(templateStore.currentTemplate) as TemplateSchema; const found = findNodeById(clone, id); if (!found) return; - const patch = value === '' ? { children: null } : { children: [value] }; - const patched = mergeNode(found.node, patch); + const patched = mergeNode(found.node, { children }); const updated = replaceNodeInTree(clone as SchemaNode, found.node, patched) as TemplateSchema; aiStore.pushSnapshot(); templateStore.updateTemplate(updated); @@ -510,6 +512,15 @@ export function InspectorPanel() { } } + function handleContentChange(value: string) { + setChildren(value === '' ? null : [value]); + } + + function handleContentTokenChange(value: unknown) { + // Clearing a bound value leaves the node empty rather than printing "". + setChildren(value === '' || value === null || value === undefined ? null : [value]); + } + function onDividerMouseDown(e: MouseEvent) { e.preventDefault(); const startY = e.clientY; @@ -601,7 +612,13 @@ export function InspectorPanel() { } > {(node) => ( - + setChildren(children.length === 0 ? null : children)} + /> )} @@ -617,26 +634,23 @@ function NodeProperties(props: { node: SchemaNode; onPropChange: (key: string, value: unknown) => void; onContentChange: (value: string) => void; + /** Replace the single token in `children` (data binding, value-level conditional). */ + onContentTokenChange: (value: unknown) => void; + /** Replace the whole `children` array (raw JSON escape hatch). */ + onChildrenChange: (children: unknown[]) => void; }) { + const templateStore = useTemplateStore(); const meta = createMemo(() => getComponentMeta(props.node.type ?? '', contextData)); - // Content — editable `children` text. Shown when children is a single string (or - // empty) so authors can view/edit the common `children: ["some text"]` shape without - // needing the (rarely used) `text` prop. - const hasSimpleStringChildren = createMemo(() => { - const children = props.node.children; - return !children || children.length === 0 || (children.length === 1 && typeof children[0] === 'string'); - }); + // Content — `children` holds either child nodes (owned by the Layers tree) or content + // the ContentEditor owns: text, a bound value, or a value-level conditional. + const hasChildNodes = createMemo(() => !!props.node.children?.some((c) => isPropsSchemaNode(c))); + const showContent = createMemo(() => { - if (!hasSimpleStringChildren()) return false; - const children = props.node.children; - if (children && children.length === 1) return true; + if (hasChildNodes()) return false; + if (props.node.children && props.node.children.length > 0) return true; return TEXT_CONTENT_TYPES.has(props.node.type ?? ''); }); - const contentValue = createMemo(() => { - const children = props.node.children; - return children && children.length === 1 && typeof children[0] === 'string' ? children[0] : ''; - }); // Current prop values set on this node const currentProps = createMemo(() => props.node.props ?? {}); @@ -654,11 +668,32 @@ function NodeProperties(props: { return used; }); - // Complex (non-primitive) props — read-only preview + // Props the Logic section owns — excluded from the raw JSON list below so a condition + // isn't editable in two places at once. + const logicProps = createMemo(() => (props.node.type === '$if' ? new Set(['condition']) : new Set())); + + // Complex (non-primitive) props — raw JSON escape hatch. + // SchemaNode-valued props ($if's then/else, slot content) are excluded: they're whole + // subtrees, already navigable and editable through the Layers tree above, and showing + // them here as JSON blobs buries the props that are only editable here. const complexProps = createMemo(() => - Object.entries(currentProps()).filter(([, v]) => { + Object.entries(currentProps()).filter(([k, v]) => { const t = typeof v; - return t !== 'string' && t !== 'boolean' && t !== 'number'; + if (t === 'string' || t === 'boolean' || t === 'number') return false; + if (logicProps().has(k)) return false; + if (isPropsSchemaNode(v)) return false; + if (Array.isArray(v) && v.length > 0 && v.every(isPropsSchemaNode)) return false; + return true; + }), + ); + + // Everything this node's props can refer to — drives the value pickers in the Logic + // section. Recomputed from the live template so newly added $localState or $each + // ancestors show up without reselecting the node. + const scope = createMemo(() => + getScopeAtNode(templateStore.currentTemplate as SchemaNode, props.node.id ?? '', { + storeEntries: contextData.storeEntries, + models: contextData.models, }), ); @@ -690,16 +725,28 @@ function NodeProperties(props: { {/* Scrollable content */} - {/* Content — editable text children, shown for text-bearing nodes */} + {/* Content — text, a bound value, or a value-level conditional */} - Content - ) => props.onContentChange(e.detail)} + + + + + {/* Logic — condition builder for $if, in place of raw JSON */} + + + props.onPropChange('condition', token)} /> @@ -776,27 +823,22 @@ function NodeProperties(props: { - {/* Complex / dynamic props */} + {/* Complex / dynamic props — ValueEditor picks the right control per token and + falls back to the JSON editor for the ones with no row equivalent */} 0}> Dynamic props - - {([key, value]) => ( + key)}> + {(key) => ( {key} - - props.onPropChange(key, JSON.parse(json))} - /> - + props.onPropChange(key, value)} + /> )} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx new file mode 100644 index 00000000..f8d0cb99 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx @@ -0,0 +1,154 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ScopeGroup } from '@we/schema-shared'; +import { parseValue, parseValueIf, serializeValue, serializeValueIf } from '@we/schema-shared'; +import { createMemo, createSignal, Match, Switch } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { OperandInput } from './ValueRefPicker'; + +/** + * Editor for any single value in a schema — a `children` entry or a value-producing prop. + * + * Picks the narrowest editor the token allows: a reference/literal picker for plain + * values, a nested condition + two branches for the prop-level `$if`, and the raw JSON + * editor for expressions with no direct equivalent ($concat, $map, $plural, $action). + */ + +/** Depth cap for nested `$if` branches — past this the JSON editor is clearer. */ +const MAX_VALUE_IF_DEPTH = 1; + +export function ValueEditor(props: { + value: unknown; + scope: ScopeGroup[]; + onChange: (value: unknown) => void; + /** Nesting level, incremented for the branches of a value-level `$if`. */ + depth?: number; + placeholder?: string; +}) { + const depth = () => props.depth ?? 0; + + /** + * Set when the author asks to replace a custom expression with a picked value. + * Without it the JSON editor is a one-way door: an expression the grammar can't + * represent has no path back to a picker. Nothing is written until something is + * picked, so the existing expression survives a change of mind. + */ + const [replacing, setReplacing] = createSignal(false); + + const emit = (value: unknown) => { + setReplacing(false); + props.onChange(value); + }; + + const valueIf = createMemo(() => (replacing() || depth() >= MAX_VALUE_IF_DEPTH ? null : parseValueIf(props.value))); + const operand = createMemo(() => (replacing() || valueIf() ? null : parseValue(props.value))); + const mode = createMemo<'conditional' | 'operand' | 'replacing' | 'custom'>(() => { + if (valueIf()) return 'conditional'; + if (operand()) return 'operand'; + return replacing() ? 'replacing' : 'custom'; + }); + + return ( + + + emit(serializeValue(next))} + valueType="string" + allowCount + placeholder={props.placeholder} + /> + + + {/* Replacing a custom expression — nothing is written until something is picked */} + + emit(serializeValue(next))} + valueType="string" + allowCount + placeholder={props.placeholder ?? 'Pick a replacement value'} + /> + + + + + + + + + Custom expression — edit as JSON + + + + setReplacing(true)} + aria-label="Pick a value from data" + > + + + + + + emit(JSON.parse(json))} /> + + + + + + {(branch) => ( + + props.onChange(serializeValueIf({ ...branch(), condition }))} + /> + + + + Then show + + props.onChange(serializeValueIf({ ...branch(), then }))} + placeholder="Value when true" + /> + + + + Otherwise show + + + props.onChange( + serializeValueIf({ + condition: branch().condition, + then: branch().then, + // An empty branch means "render nothing" — drop the key rather than + // writing an empty string the renderer would print. + else: otherwise === '' ? undefined : otherwise, + }), + ) + } + placeholder="Value when false" + /> + + + + )} + + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx new file mode 100644 index 00000000..0f568cd1 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx @@ -0,0 +1,520 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ConditionOperand, FormStateToken, ScopeGroup, ScopeRef, ScopeValueType } from '@we/schema-shared'; +import { inferRefKind } from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from 'solid-js'; + +/** + * Pickers for a single value in the logic editors. + * + * `ValueRefPicker` is the grouped, searchable list of everything in scope at a node + * (iteration variables, page state, store members, context refs). `OperandInput` wraps it + * with a literal-value mode so one control can express both "compare against this data" + * and "compare against this fixed value". + */ + +// ── Display helpers ───────────────────────────────────────────────────────── + +const KIND_ICONS: Record = { + item: 'list', + local: 'note', + store: 'database', + context: 'globe', +}; + +const FORM_STATE_LABELS: Record string> = { + formValid: () => 'all fields are valid', + valid: (field) => `${field} is valid`, + touched: (field) => `${field} was edited`, + error: (field) => `${field} error message`, +}; + +export function operandLabel(operand: ConditionOperand | undefined): string { + if (!operand) return ''; + switch (operand.kind) { + case 'store': + case 'local': + case 'context': + return operand.path; + case 'list': + return operand.value.join(', '); + case 'count': + return `count of ${operandLabel(operand.items) || '…'}`; + case 'formState': + return FORM_STATE_LABELS[operand.token](operand.field); + case 'literal': + if (operand.value === null) return 'null'; + if (operand.value === '') return ''; + return String(operand.value); + } +} + +function operandIcon(operand: ConditionOperand | undefined): string { + if (!operand) return 'plus'; + if (operand.kind === 'literal' || operand.kind === 'list') return 'text-aa'; + if (operand.kind === 'count') return 'hash'; + if (operand.kind === 'formState') return 'check-circle'; + return KIND_ICONS[operand.kind]; +} + +/** The referenced path, or undefined for operands that aren't a plain reference. */ +function refPath(operand: ConditionOperand | undefined): string | undefined { + if (!operand) return undefined; + return operand.kind === 'store' || operand.kind === 'local' || operand.kind === 'context' ? operand.path : undefined; +} + +/** Map a scope ref onto the operand kind that serializes to the same token. */ +function refToOperand(ref: ScopeRef): ConditionOperand { + if (ref.kind === 'store') return { kind: 'store', path: ref.path }; + if (ref.kind === 'local') return { kind: 'local', path: ref.path }; + return { kind: 'context', path: ref.path }; +} + +/** The declared type of the picked reference, used to type the opposite side's literal input. */ +export function operandValueType(operand: ConditionOperand | undefined, scope: ScopeGroup[]): ScopeValueType { + if (!operand) return 'unknown'; + if (operand.kind === 'literal') { + if (typeof operand.value === 'boolean') return 'boolean'; + if (typeof operand.value === 'number') return 'number'; + return 'string'; + } + if (operand.kind === 'list') return 'array'; + if (operand.kind === 'count') return 'number'; + if (operand.kind === 'formState') return operand.token === 'error' ? 'string' : 'boolean'; + for (const group of scope) { + for (const ref of group.refs) { + if (ref.path === operand.path) return ref.valueType; + } + } + return 'unknown'; +} + +// ── ValueRefPicker ────────────────────────────────────────────────────────── + +export function ValueRefPicker(props: { + scope: ScopeGroup[]; + value?: ConditionOperand; + onSelect: (operand: ConditionOperand) => void; + /** Offer a "count of a list" entry that wraps a reference in $count. */ + allowCount?: boolean; + placeholder?: string; +}) { + const [open, setOpen] = createSignal(false); + const [search, setSearch] = createSignal(''); + let ref!: HTMLDivElement; + + createEffect(() => { + if (!open()) return; + const handler = (e: MouseEvent) => { + if (!ref.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + onCleanup(() => document.removeEventListener('mousedown', handler)); + }); + + const filtered = createMemo(() => { + const term = search().trim().toLowerCase(); + if (!term) return props.scope; + return props.scope + .map((group) => ({ + ...group, + refs: group.refs.filter((r) => r.path.toLowerCase().includes(term) || group.label.toLowerCase().includes(term)), + })) + .filter((group) => group.refs.length > 0); + }); + + /** + * The typed text as a reference, when it resolves to a known store/local/context root + * and isn't already offered in the list. Lets an author reach a property the registry + * doesn't describe without dropping to the JSON editor. + */ + const customPath = createMemo<{ kind: 'store' | 'local' | 'context'; path: string } | null>(() => { + const path = search().trim(); + if (!path) return null; + if (props.scope.some((g) => g.refs.some((r) => r.path === path))) return null; + const kind = inferRefKind(path, props.scope); + return kind && kind !== 'item' ? { kind, path } : null; + }); + + const choose = (operand: ConditionOperand) => { + props.onSelect(operand); + setOpen(false); + setSearch(''); + }; + + const label = () => operandLabel(props.value) || (props.placeholder ?? 'Select a value'); + + return ( +
+ setOpen((v) => !v)}> + + + + {label()} + + + + + + + + + + ) => setSearch(e.detail)} + /> + + {/* The registry describes stores by hand and so is never quite complete. + Rather than dead-ending on a path it doesn't list, offer the typed one — + but only when its first segment resolves to a known store, local field or + context ref, so an unresolvable path can't be created by typo. */} + + {(custom) => ( + choose(custom())}> + + + + Use “{custom().path}” + + + + )} + + + + + + {(group) => ( + + + {group.label} + + + {(scopeRef) => ( + choose(refToOperand(scopeRef))} + > + + + + {scopeRef.label} + + + + {scopeRef.valueType} + + + + + )} + + + )} + + + + + Nothing in scope matches. + + + + + + + + choose({ kind: 'count', items: { kind: 'context', path: '' } })}> + + + Count of a list… + + + + + + + + +
+ ); +} + +// ── OperandInput ──────────────────────────────────────────────────────────── + +/** + * One side of a comparison: either a reference picked from scope, or a literal typed + * inline. The literal control is typed by `valueType` — which the caller derives from + * the *other* side, so comparing a boolean store member offers true/false rather than + * free text. + */ +export function OperandInput(props: { + scope: ScopeGroup[]; + value?: ConditionOperand; + onChange: (operand: ConditionOperand) => void; + valueType?: ScopeValueType; + /** Accept a comma-separated list — used for the `is one of` operator. */ + list?: boolean; + /** Offer wrapping a reference in `$count`. */ + allowCount?: boolean; + /** + * Offer plain-text entry. False for the left side of a comparison, where a literal + * makes a constant condition — across the built-in templates the left operand is a + * reference 1823 times and a literal 10, while the right is a literal 965 times. + */ + allowText?: boolean; + placeholder?: string; +}) { + /** + * Which editor to show is UI state, not something derivable from the value. + * + * Half-made choices have no valid serialized form: an empty reference writes `''`, + * which reads back as an empty *literal*, and `{ $count: { items: '' } }` reads back as + * a count over an empty literal. Callers that write through on every change (ValueEditor + * does) would bounce straight out of the mode that was just asked for — which is exactly + * how the switch buttons ended up doing nothing and how a new count landed in raw JSON. + * + * Holding the intent here means nothing is written until the choice is complete, so the + * node keeps its current value while a mode is being explored. + */ + const [refMode, setRefMode] = createSignal(false); + const [textMode, setTextMode] = createSignal(false); + const [pendingCount, setPendingCount] = createSignal | null>(null); + + /** A reference is only usable once it actually points somewhere. */ + const isResolved = (operand: ConditionOperand | undefined): boolean => + !!operand && (operand.kind === 'store' || operand.kind === 'local' || operand.kind === 'context') + ? (operand as { path: string }).path.trim() !== '' + : false; + + const clearOverrides = () => { + setRefMode(false); + setTextMode(false); + setPendingCount(null); + }; + + const pick = (operand: ConditionOperand) => { + if (operand.kind === 'count' && !isResolved(operand.items)) { + setRefMode(false); + setTextMode(false); + setPendingCount(operand); + return; + } + clearOverrides(); + props.onChange(operand); + }; + + const literalControl = () => { + if (props.list) { + const current = props.value?.kind === 'list' ? props.value.value.join(', ') : ''; + return ( + ) => + props.onChange({ + kind: 'list', + value: e.detail + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + ); + } + + const literal = props.value?.kind === 'literal' ? props.value.value : ''; + + if (props.valueType === 'boolean') { + return ( + props.onChange({ kind: 'literal', value: e.detail === 'true' })} + /> + ); + } + + if (props.valueType === 'number') { + return ( + ) => { + const parsed = Number(e.detail); + props.onChange({ kind: 'literal', value: isNaN(parsed) ? e.detail : parsed }); + }} + /> + ); + } + + return ( + ) => props.onChange({ kind: 'literal', value: e.detail })} + /> + ); + }; + + /** + * The two ways to express a value, always both offered. Data opens the picker (whose + * footer also reaches list counts); Text goes straight to plain entry. Two orthogonal + * one-click affordances rather than routing text entry through the picker, so no mode + * is ever more than a click from either. + * + * Neither writes anything: the current value stands until something is picked or typed, + * so changing your mind costs nothing. + */ + const modeButtons = () => ( + + + + { + setRefMode(false); + setPendingCount(null); + setTextMode(true); + }} + aria-label="Use a fixed value" + > + + + + + + { + setTextMode(false); + setPendingCount(null); + setRefMode(true); + }} + aria-label="Pick a value from data" + > + + + + + ); + + // `$count` wraps another reference, so it renders as a labelled row containing a + // nested picker rather than as a leaf control. A pending count (list not yet chosen) + // renders the same way, so the row appears the moment it is asked for. + const countValue = () => pendingCount() ?? (props.value?.kind === 'count' ? props.value : null); + + // Validation-state readers name a field from the surrounding $localState, which is + // exactly the `local` group of the scope — so the field list comes for free. + const formStateValue = () => (props.value?.kind === 'formState' ? props.value : null); + const localFieldOptions = () => { + const fields = props.scope.filter((g) => g.kind === 'local').flatMap((g) => g.refs.map((r) => r.path)); + return [{ label: 'the whole form', value: '$scope' }, ...fields.map((f) => ({ label: f, value: f }))]; + }; + + const mode = createMemo<'literal' | 'composite' | 'picker'>(() => { + if (textMode()) return 'literal'; + if (refMode()) return 'picker'; + if (countValue() || formStateValue()) return 'composite'; + if (props.value?.kind === 'literal' || props.value?.kind === 'list') return 'literal'; + return 'picker'; + }); + + return ( + + + + {literalControl()} + + + + + {(count) => ( + <> + + count of + + + { + clearOverrides(); + props.onChange({ kind: 'count', items }); + }} + placeholder="Select a list" + /> + + + )} + + + {(formState) => ( + <> + + {formState().token === 'error' + ? 'error of' + : formState().token === 'touched' + ? 'edited' + : 'valid'} + + + props.onChange({ kind: 'formState', token: formState().token, field: e.detail as string }) + } + /> + + )} + + + + + + + + + + {modeButtons()} + + ); +} diff --git a/packages/schema-system/shared/src/conditionModel.test.ts b/packages/schema-system/shared/src/conditionModel.test.ts new file mode 100644 index 00000000..22bfb701 --- /dev/null +++ b/packages/schema-system/shared/src/conditionModel.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; + +import type { ConditionExpr } from './conditionModel'; +import { + classifyContent, + contentAsText, + emptyComparison, + isBlankComparison, + parseCondition, + parseValue, + parseValueIf, + serializeCondition, + serializeValue, + serializeValueIf, +} from './conditionModel'; + +/** Parse then serialize — the token must come back byte-identical. */ +function roundTrip(token: unknown): unknown { + const parsed = parseCondition(token); + expect(parsed, `expected ${JSON.stringify(token)} to be representable`).not.toBeNull(); + return serializeCondition(parsed as ConditionExpr); +} + +describe('parseCondition', () => { + it('reads a bare store reference as a truthy check', () => { + expect(parseCondition({ $store: 'adamStore.isWeSpace' })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'store', path: 'adamStore.isWeSpace' }, + }); + }); + + it('reads a bare local reference as a truthy check', () => { + expect(parseCondition({ $local: 'showComments' })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'local', path: 'showComments' }, + }); + }); + + it('reads a context reference string as a truthy check', () => { + expect(parseCondition('$post.highlighted')).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'context', path: '$post.highlighted' }, + }); + }); + + it('reads $not over a reference as a falsy check', () => { + expect(parseCondition({ $not: { $store: 'userStore.isLoggedIn' } })).toEqual({ + type: 'comparison', + operator: 'falsy', + left: { kind: 'store', path: 'userStore.isLoggedIn' }, + }); + }); + + it('reads binary comparisons with mixed operand kinds', () => { + expect(parseCondition({ $eq: [{ $store: 'userStore.role' }, 'admin'] })).toEqual({ + type: 'comparison', + operator: 'eq', + left: { kind: 'store', path: 'userStore.role' }, + right: { kind: 'literal', value: 'admin' }, + }); + }); + + it('reads $in with a literal list on the right', () => { + const parsed = parseCondition({ $in: ['$item.role', ['admin', 'moderator']] }); + expect(parsed).toEqual({ + type: 'comparison', + operator: 'in', + left: { kind: 'context', path: '$item.role' }, + right: { kind: 'list', value: ['admin', 'moderator'] }, + }); + }); + + it('reads a group of comparisons', () => { + const parsed = parseCondition({ + $and: [{ $store: 'userStore.isAdmin' }, { $not: { $store: 'appStore.isLocked' } }], + }); + expect(parsed).toMatchObject({ type: 'group', operator: 'and' }); + expect((parsed as { children: unknown[] }).children).toHaveLength(2); + }); + + it('reads one level of nested grouping', () => { + const parsed = parseCondition({ + $or: [ + { $eq: [{ $store: 'userStore.role' }, 'admin'] }, + { $and: [{ $store: 'userStore.isVerified' }, { $eq: [{ $store: 'userStore.role' }, 'editor'] }] }, + ], + }); + expect(parsed).not.toBeNull(); + expect((parsed as { children: ConditionExpr[] }).children[1].type).toBe('group'); + }); +}); + +describe('parseCondition — count and validation-state operands', () => { + it('reads a bare $count as a truthy check', () => { + expect(parseCondition({ $count: { items: { $local: 'signalTypes' } } })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'count', items: { kind: 'local', path: 'signalTypes' } }, + }); + }); + + it('reads $count on either side of a comparison', () => { + expect(parseCondition({ $gt: [{ $count: { items: { $store: 'spaceStore.members' } } }, 0] })).toEqual({ + type: 'comparison', + operator: 'gt', + left: { kind: 'count', items: { kind: 'store', path: 'spaceStore.members' } }, + right: { kind: 'literal', value: 0 }, + }); + }); + + it('reads the validation-state readers', () => { + expect(parseCondition({ $formValid: '$scope' })).toMatchObject({ + left: { kind: 'formState', token: 'formValid', field: '$scope' }, + }); + expect(parseCondition({ $touched: 'email' })).toMatchObject({ + left: { kind: 'formState', token: 'touched', field: 'email' }, + }); + expect(parseCondition({ $not: { $valid: 'email' } })).toMatchObject({ + operator: 'falsy', + left: { kind: 'formState', token: 'valid', field: 'email' }, + }); + }); + + it('normalises the legacy $formValid: true spelling to $scope', () => { + expect(parseCondition({ $formValid: true })).toMatchObject({ + left: { kind: 'formState', token: 'formValid', field: '$scope' }, + }); + }); +}); + +describe('parseCondition — outside the grammar', () => { + const unsupported: [string, unknown][] = [ + ['$find', { $find: { items: { $store: 'spaceStore.members' }, where: { id: '$item.id' } } }], + ['$count over a literal', { $count: { items: 3 } }], + ['$count with extra keys', { $count: { items: { $local: 'x' }, extra: 1 } }], + ['$concat operand', { $eq: [{ $concat: ['a', 'b'] }, 'ab'] }], + ['$filter', { $filter: { items: { $store: 'spaceStore.members' }, where: { role: 'admin' } } }], + ['$not over a comparison', { $not: { $eq: [{ $store: 'a.b' }, 1] } }], + ['three levels of grouping', { $and: [{ $or: [{ $and: [{ $store: 'a.b' }] }] }] }], + ['bare literal', 'just a string'], + ['malformed $eq', { $eq: [{ $store: 'a.b' }] }], + ['undefined', undefined], + ]; + + it.each(unsupported)('returns null for %s so the raw editor takes over', (_label, token) => { + expect(parseCondition(token)).toBeNull(); + }); +}); + +describe('serializeCondition', () => { + const tokens: [string, unknown][] = [ + ['bare store ref', { $store: 'adamStore.isWeSpace' }], + ['bare local ref', { $local: 'showComments' }], + ['context ref', '$post.highlighted'], + ['$not', { $not: { $store: 'userStore.isLoggedIn' } }], + ['$eq with literal', { $eq: [{ $store: 'userStore.role' }, 'admin'] }], + ['$ne with two refs', { $ne: [{ $store: 'a.b' }, { $local: 'c' }] }], + ['$gt with number', { $gt: [{ $store: 'listStore.itemCount' }, 0] }], + ['$lt with number', { $lt: [{ $local: 'count' }, 5] }], + ['$in with list', { $in: ['$item.role', ['admin', 'moderator']] }], + ['$not over $in', { $not: { $in: [{ $local: 'contentType' }, ['posts', 'users']] } }], + ['bare $count', { $count: { items: { $local: 'signalTypes' } } }], + ['$gt over $count', { $gt: [{ $count: { items: { $store: 'spaceStore.members' } } }, 0] }], + ['$formValid', { $formValid: '$scope' }], + ['$error', { $error: 'email' }], + ['$not over $valid', { $not: { $valid: 'email' } }], + ['$and mixing count and validation state', { $and: [{ $count: { items: { $local: 'x' } } }, { $touched: 'y' }] }], + ['$eq with boolean', { $eq: [{ $local: 'flag' }, true] }], + ['$eq with null', { $eq: [{ $store: 'a.b' }, null] }], + ['$and group', { $and: [{ $store: 'userStore.isAdmin' }, { $not: { $store: 'appStore.isLocked' } }] }], + [ + 'nested $or/$and', + { + $or: [ + { $eq: [{ $store: 'userStore.role' }, 'admin'] }, + { $and: [{ $store: 'userStore.isVerified' }, { $eq: [{ $store: 'userStore.role' }, 'editor'] }] }, + ], + }, + ], + ]; + + it.each(tokens)('round-trips %s unchanged', (_label, token) => { + expect(roundTrip(token)).toEqual(token); + }); +}); + +describe('value positions', () => { + it('parses the tokens that appear in children', () => { + expect(parseValue({ $store: 'spaceStore.currentSpace.name' })).toEqual({ + kind: 'store', + path: 'spaceStore.currentSpace.name', + }); + expect(parseValue('$agent.firstName')).toEqual({ kind: 'context', path: '$agent.firstName' }); + expect(parseValue('Shared')).toEqual({ kind: 'literal', value: 'Shared' }); + }); + + it('returns null for expressions with no picker equivalent', () => { + expect(parseValue({ $concat: ['a', 'b'] })).toBeNull(); + expect(parseValue({ $plural: { count: 1, one: 'x', other: 'y' } })).toBeNull(); + }); + + it('round-trips value tokens', () => { + for (const token of [{ $store: 'a.b' }, { $local: 'x' }, '$item.name', 'plain', 42, true, null]) { + expect(serializeValue(parseValue(token)!)).toEqual(token); + } + }); + + it('recognises a prop-level $if in a value position', () => { + const token = { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.access' }, 'shared'] }, + then: 'Shared', + else: 'Personal', + }, + }; + const parsed = parseValueIf(token); + expect(parsed).toEqual({ condition: token.$if.condition, then: 'Shared', else: 'Personal' }); + expect(serializeValueIf(parsed!)).toEqual(token); + }); + + it('omits an absent else rather than writing undefined', () => { + const token = { $if: { condition: { $local: 'open' }, then: 'Yes' } }; + expect(serializeValueIf(parseValueIf(token)!)).toEqual(token); + }); + + it('rejects node-level $if shapes so the raw editor keeps them', () => { + // Transitions only exist on the renderer operator, not the value form. + expect(parseValueIf({ $if: { condition: true, then: 'a', enterTransition: { type: 'fade' } } })).toBeNull(); + expect(parseValueIf({ $if: { condition: true } })).toBeNull(); + expect(parseValueIf({ type: '$if', props: { condition: true, then: {} } })).toBeNull(); + }); +}); + +describe('content shapes', () => { + const cases: [string, unknown[] | undefined, string][] = [ + ['no children', undefined, 'text'], + ['empty children', [], 'text'], + ['a plain string', ['About this space'], 'text'], + ['a store binding', [{ $store: 'spaceStore.currentSpace.name' }], 'value'], + ['a context ref', ['$agent.firstName'], 'text'], + ['a value-level $if', [{ $if: { condition: { $local: 'x' }, then: 'a', else: 'b' } }], 'conditional'], + ['a $concat', [{ $concat: ['a', 'b'] }], 'custom'], + ['several parts', ['Hello ', { $store: 'a.b' }], 'custom'], + ]; + + it.each(cases)('classifies %s', (_label, children, expected) => { + expect(classifyContent(children)).toBe(expected); + }); + + it('reads a context ref string as text, since that is how it is authored', () => { + // '$agent.firstName' is a bare string in children — the text field round-trips it. + expect(contentAsText('$agent.firstName')).toBe('$agent.firstName'); + }); + + it('collapses a conditional to its then branch when converting to text', () => { + expect(contentAsText({ $if: { condition: { $local: 'x' }, then: 'Shared', else: 'Personal' } })).toBe('Shared'); + }); + + it('has no text reading for a binding or a custom expression', () => { + expect(contentAsText({ $store: 'a.b' })).toBe(''); + expect(contentAsText({ $concat: ['a'] })).toBe(''); + expect(contentAsText({ $if: { condition: true, then: { $store: 'a.b' } } })).toBe(''); + }); +}); + +describe('editing helpers', () => { + it('marks a fresh comparison as blank', () => { + expect(isBlankComparison(emptyComparison())).toBe(true); + }); + + it('does not mark a populated comparison as blank', () => { + expect(isBlankComparison(parseCondition({ $local: 'open' }) as ConditionExpr)).toBe(false); + }); +}); diff --git a/packages/schema-system/shared/src/conditionModel.ts b/packages/schema-system/shared/src/conditionModel.ts new file mode 100644 index 00000000..1105adb5 --- /dev/null +++ b/packages/schema-system/shared/src/conditionModel.ts @@ -0,0 +1,327 @@ +/** + * Condition model — a small, lossless editing grammar over the boolean operator tokens. + * + * `parseCondition` converts a condition token into a flat comparison/group structure the + * visual editor can render as rows; `serializeCondition` converts it back. Parsing is + * deliberately strict: anything the grammar can't represent exactly (`$count`, `$concat`, + * `$formValid`, deeper nesting than {@link MAX_CONDITION_DEPTH}) returns `null`, and the + * editor falls back to the raw JSON editor for that condition. That keeps the round-trip + * honest — the builder never silently rewrites an expression it didn't fully understand. + */ + +// ── Public types ──────────────────────────────────────────────────────────── + +export type ComparisonOperator = 'eq' | 'ne' | 'gt' | 'lt' | 'in' | 'nin' | 'truthy' | 'falsy'; + +/** Operators that take no right-hand operand. */ +export const UNARY_OPERATORS: ComparisonOperator[] = ['truthy', 'falsy']; + +/** Validation-state readers, which take a field name rather than a value. */ +export type FormStateToken = 'formValid' | 'valid' | 'touched' | 'error'; + +export type ConditionOperand = + | { kind: 'store'; path: string } + | { kind: 'local'; path: string } + /** A context reference string — `$item.name`, `$me.did`. */ + | { kind: 'context'; path: string } + | { kind: 'literal'; value: string | number | boolean | null } + /** A literal list, only valid as the right-hand side of `in`. */ + | { kind: 'list'; value: (string | number | boolean)[] } + /** `$count` over a list-valued reference — "how many X are there". */ + | { kind: 'count'; items: ConditionOperand } + /** `$formValid` / `$valid` / `$touched` / `$error` over a field name. */ + | { kind: 'formState'; token: FormStateToken; field: string }; + +export interface ConditionComparison { + type: 'comparison'; + operator: ComparisonOperator; + left: ConditionOperand; + /** Absent for unary operators. */ + right?: ConditionOperand; +} + +export interface ConditionGroup { + type: 'group'; + operator: 'and' | 'or'; + children: ConditionExpr[]; +} + +export type ConditionExpr = ConditionComparison | ConditionGroup; + +/** + * How many levels of grouping the builder represents: a top-level group whose children + * may themselves be groups, but no deeper. Real templates nest at most this far; beyond + * it the row-based UI stops being clearer than the JSON. + */ +export const MAX_CONDITION_DEPTH = 2; + +const BINARY_TOKEN_OPS: Record = { + $eq: 'eq', + $ne: 'ne', + $gt: 'gt', + $lt: 'lt', + $in: 'in', +}; + +const FORM_STATE_TOKENS: Record = { + $formValid: 'formValid', + $valid: 'valid', + $touched: 'touched', + $error: 'error', +}; + +const OPERATOR_TOKENS: Record = { + eq: '$eq', + ne: '$ne', + gt: '$gt', + lt: '$lt', + in: '$in', + nin: '$in', // wrapped in $not by serializeCondition + truthy: '', + falsy: '$not', +}; + +// ── Parsing ───────────────────────────────────────────────────────────────── + +function parseOperand(value: unknown): ConditionOperand | null { + if (value === null) return { kind: 'literal', value: null }; + + if (typeof value === 'string') { + // A `$`-prefixed string is a context reference; anything else is a plain literal. + return value.startsWith('$') ? { kind: 'context', path: value } : { kind: 'literal', value }; + } + if (typeof value === 'number' || typeof value === 'boolean') return { kind: 'literal', value }; + + if (Array.isArray(value)) { + const primitives = value.every((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'); + return primitives ? { kind: 'list', value: value as (string | number | boolean)[] } : null; + } + + if (typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length !== 1) return null; + if (typeof obj.$store === 'string') return { kind: 'store', path: obj.$store }; + if (typeof obj.$local === 'string') return { kind: 'local', path: obj.$local }; + + // `$count` over a list — the only wrapping operator the builder represents, because + // "how many of these are there" is how most list conditions are actually written. + if (obj.$count && typeof obj.$count === 'object' && !Array.isArray(obj.$count)) { + const countKeys = Object.keys(obj.$count as object); + if (countKeys.length !== 1 || countKeys[0] !== 'items') return null; + const items = parseOperand((obj.$count as Record).items); + if (!items || items.kind === 'list' || items.kind === 'literal') return null; + return { kind: 'count', items }; + } + + // Validation-state readers. `$formValid: '$scope'` is the idiomatic whole-form check; + // the others name a single field. + const [tokenKey] = keys; + const formToken = FORM_STATE_TOKENS[tokenKey]; + if (formToken) { + const field = obj[tokenKey]; + if (typeof field === 'string') return { kind: 'formState', token: formToken, field }; + if (formToken === 'formValid' && field === true) return { kind: 'formState', token: formToken, field: '$scope' }; + return null; + } + } + return null; +} + +function parseExpr(token: unknown, depth: number): ConditionExpr | null { + if (typeof token === 'object' && token !== null && !Array.isArray(token)) { + const obj = token as Record; + const keys = Object.keys(obj); + + if (keys.length === 1) { + const [key] = keys; + const value = obj[key]; + + // Logical groups + if ((key === '$and' || key === '$or') && Array.isArray(value)) { + if (depth + 1 > MAX_CONDITION_DEPTH) return null; + const children: ConditionExpr[] = []; + for (const child of value) { + const parsed = parseExpr(child, depth + 1); + if (!parsed) return null; + children.push(parsed); + } + if (children.length === 0) return null; + return { type: 'group', operator: key === '$and' ? 'and' : 'or', children }; + } + + // Binary comparisons + const operator = BINARY_TOKEN_OPS[key]; + if (operator && Array.isArray(value) && value.length === 2) { + const left = parseOperand(value[0]); + const right = parseOperand(value[1]); + if (!left || !right) return null; + // A list only makes sense as the right-hand side of `in`. + if (left.kind === 'list') return null; + if (right.kind === 'list' && operator !== 'in') return null; + return { type: 'comparison', operator, left, right }; + } + + if (key === '$not') { + // `$not` over `$in` is the "is not one of" row — `$in` is the one comparison with + // no negated counterpart, so this round-trips exactly rather than approximating. + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const inner = value as Record; + if (Object.keys(inner).length === 1 && Array.isArray(inner.$in) && inner.$in.length === 2) { + const left = parseOperand(inner.$in[0]); + const right = parseOperand(inner.$in[1]); + if (left && right && left.kind !== 'list') return { type: 'comparison', operator: 'nin', left, right }; + return null; + } + } + // `$not` over a plain reference reads as "is falsy". `$not` wrapping any other + // comparison stays raw — folding it into `ne` would change the token on save. + const operand = parseOperand(value); + if (!operand || operand.kind === 'list') return null; + return { type: 'comparison', operator: 'falsy', left: operand }; + } + } + } + + // A bare reference used as a condition — `condition: { $local: 'showComments' }`. + const operand = parseOperand(token); + if (operand && operand.kind !== 'list' && operand.kind !== 'literal') { + return { type: 'comparison', operator: 'truthy', left: operand }; + } + return null; +} + +/** + * Parse a condition token into the editing model. + * Returns null when the token is outside the grammar — callers should fall back to raw JSON. + */ +export function parseCondition(token: unknown): ConditionExpr | null { + if (token === undefined) return null; + return parseExpr(token, 0); +} + +// ── Serializing ───────────────────────────────────────────────────────────── + +function serializeOperand(operand: ConditionOperand): unknown { + switch (operand.kind) { + case 'store': + return { $store: operand.path }; + case 'local': + return { $local: operand.path }; + case 'context': + return operand.path; + case 'literal': + return operand.value; + case 'list': + return operand.value; + case 'count': + return { $count: { items: serializeOperand(operand.items) } }; + case 'formState': + return { [`$${operand.token}`]: operand.field }; + } +} + +export function serializeCondition(expr: ConditionExpr): unknown { + if (expr.type === 'group') { + return { [expr.operator === 'and' ? '$and' : '$or']: expr.children.map(serializeCondition) }; + } + if (expr.operator === 'truthy') return serializeOperand(expr.left); + if (expr.operator === 'falsy') return { $not: serializeOperand(expr.left) }; + const right: ConditionOperand = expr.right ?? { kind: 'literal', value: null }; + const comparison = { [OPERATOR_TOKENS[expr.operator]]: [serializeOperand(expr.left), serializeOperand(right)] }; + return expr.operator === 'nin' ? { $not: comparison } : comparison; +} + +// ── Values (as opposed to conditions) ─────────────────────────────────────── + +/** + * A value position — a `children` entry, or a prop that resolves to a value rather than + * a boolean. Parses to an operand so the editor can offer the same reference picker it + * uses inside conditions; returns null for expressions it can't represent ($concat, + * $map, $plural, …), which fall back to raw JSON. + */ +export function parseValue(token: unknown): ConditionOperand | null { + return parseOperand(token); +} + +export function serializeValue(operand: ConditionOperand): unknown { + return serializeOperand(operand); +} + +/** The prop-level `$if` form — resolves to a value, unlike the node-level `$if` operator. */ +export interface ValueIf { + condition: unknown; + then: unknown; + else?: unknown; +} + +/** + * Recognise `{ $if: { condition, then, else? } }` used in a value position — including + * inside `children`, where it renders one of two strings. + */ +export function parseValueIf(token: unknown): ValueIf | null { + if (typeof token !== 'object' || token === null || Array.isArray(token)) return null; + const obj = token as Record; + if (Object.keys(obj).length !== 1 || !obj.$if) return null; + + const inner = obj.$if; + if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) return null; + const { condition, then, else: otherwise, ...rest } = inner as Record; + // Transitions et al. belong to the node-level operator; if they're present this isn't + // a plain value conditional and the raw editor should handle it. + if (Object.keys(rest).length > 0) return null; + if (condition === undefined || then === undefined) return null; + + return otherwise === undefined ? { condition, then } : { condition, then, else: otherwise }; +} + +export function serializeValueIf(value: ValueIf): unknown { + const inner: Record = { condition: value.condition, then: value.then }; + if (value.else !== undefined) inner.else = value.else; + return { $if: inner }; +} + +/** + * The authoring shapes a node's `children` can take when it holds content rather than + * child nodes. Drives which editor the inspector shows, and what converting between + * them means. + */ +export type ContentShape = 'text' | 'value' | 'conditional' | 'custom'; + +export function classifyContent(children: unknown[] | undefined): ContentShape { + if (!children || children.length === 0) return 'text'; + // Several entries concatenated in place have no single-control equivalent. + if (children.length > 1) return 'custom'; + const [only] = children; + if (typeof only === 'string') return 'text'; + if (parseValueIf(only)) return 'conditional'; + if (parseValue(only)) return 'value'; + return 'custom'; +} + +/** + * The plain-text reading of a content token, used when converting to text. A conditional + * collapses to its `then` branch — the branch that renders in the common case. + */ +export function contentAsText(token: unknown): string { + if (typeof token === 'string') return token; + const branch = parseValueIf(token); + if (branch && typeof branch.then === 'string') return branch.then; + return ''; +} + +// ── Editing helpers ───────────────────────────────────────────────────────── + +export function isUnaryOperator(operator: ComparisonOperator): boolean { + return UNARY_OPERATORS.includes(operator); +} + +/** An empty comparison row, used when adding a condition to a node that has none. */ +export function emptyComparison(): ConditionComparison { + return { type: 'comparison', operator: 'truthy', left: { kind: 'literal', value: '' } }; +} + +/** True when a comparison is still a blank template row (no reference chosen yet). */ +export function isBlankComparison(expr: ConditionExpr): boolean { + return expr.type === 'comparison' && expr.left.kind === 'literal' && expr.left.value === ''; +} diff --git a/packages/schema-system/shared/src/contextTypes.ts b/packages/schema-system/shared/src/contextTypes.ts index 22996dec..683da88f 100644 --- a/packages/schema-system/shared/src/contextTypes.ts +++ b/packages/schema-system/shared/src/contextTypes.ts @@ -60,6 +60,12 @@ export interface StateMemberMeta { type: 'array' | 'object' | 'string' | 'boolean' | 'number'; /** Known properties on the value (for objects) or on array items (for arrays) */ properties?: string[]; + /** + * The model this member holds instances of, when it holds model instances. + * Preferred over spelling out `properties` by hand: the model's fields are generated + * from `@we/models`, so they stay complete as the model changes. Consumers union both. + */ + model?: string; } /** A store with its state properties and action methods */ diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index 9af14527..94b28d17 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -89,6 +89,33 @@ export type { SectionEntry, StoredTemplate, FindNodeResult, PatchError } from '. export { createStoredTemplate, listSections, getSection, updateSection } from './sections'; export { getComponentMeta } from './componentMeta'; export type { ComponentMeta, PropMeta, PropLayer } from './componentMeta'; +export { findNodeChain, findScopeRef, getScopeAtNode, inferRefKind, scopeRefToToken } from './scope'; +export type { ScopeGroup, ScopeOptions, ScopeRef, ScopeRefKind, ScopeValueType } from './scope'; +export { + classifyContent, + contentAsText, + emptyComparison, + isBlankComparison, + isUnaryOperator, + MAX_CONDITION_DEPTH, + parseCondition, + parseValue, + parseValueIf, + serializeCondition, + serializeValue, + serializeValueIf, + UNARY_OPERATORS, +} from './conditionModel'; +export type { + ComparisonOperator, + ConditionComparison, + ConditionExpr, + ConditionGroup, + ConditionOperand, + ContentShape, + FormStateToken, + ValueIf, +} from './conditionModel'; export type { DatasetHandle, QueryOptions, diff --git a/packages/schema-system/shared/src/propResolvers/conditional.test.ts b/packages/schema-system/shared/src/propResolvers/conditional.test.ts new file mode 100644 index 00000000..9da3851e --- /dev/null +++ b/packages/schema-system/shared/src/propResolvers/conditional.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProp } from './dispatcher'; +import { REACTIVE_ACCESSOR } from './reactive'; +import type { Memo } from './types'; + +/** + * A non-caching stand-in for the framework's memo: it hands back the thunk itself, so a + * test can invoke it repeatedly and observe what the resolver re-reads on each pass. + * Solid's createMemo would cache, hiding exactly the staleness these tests are about. + */ +const rerunMemo = ((fn: () => unknown) => fn) as unknown as Memo; + +function accessor(resolved: unknown): () => unknown { + expect(typeof resolved).toBe('function'); + expect(REACTIVE_ACCESSOR in (resolved as object)).toBe(true); + return resolved as () => unknown; +} + +describe('$if prop resolution — re-reads the schema', () => { + // The visual editor renders from a reactive store, so an edit to a $if mutates the + // token in place. Anything the resolver destructures once is invisible to that edit: + // the branch kept rendering its old value until the subtree remounted. + it('picks up an edited `then` branch', () => { + const token = { $if: { condition: true, then: 'Shared', else: 'Personal' } }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Shared'); + + token.$if.then = 'Public'; + expect(read()).toBe('Public'); + }); + + it('picks up an edited `else` branch', () => { + const token = { $if: { condition: false, then: 'Shared', else: 'Personal' } }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Personal'); + + token.$if.else = 'Private'; + expect(read()).toBe('Private'); + }); + + it('picks up an edited condition', () => { + const token: { $if: { condition: unknown; then: string; else: string } } = { + $if: { condition: false, then: 'Shared', else: 'Personal' }, + }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Personal'); + + token.$if.condition = true; + expect(read()).toBe('Shared'); + }); + + it('picks up a condition swapped for a different operator shape', () => { + const stores = { spaceStore: { access: 'shared' } }; + const token: { $if: { condition: unknown; then: string; else: string } } = { + $if: { condition: { $eq: [{ $store: 'spaceStore.access' }, 'shared'] }, then: 'Shared', else: 'Personal' }, + }; + const read = accessor(resolveProp(token, stores, {}, rerunMemo)); + expect(read()).toBe('Shared'); + + token.$if.condition = { $eq: [{ $store: 'spaceStore.access' }, 'personal'] }; + expect(read()).toBe('Personal'); + }); + + it('still resolves store-backed conditions', () => { + const stores = { spaceStore: { access: 'personal' } }; + const token = { + $if: { condition: { $eq: [{ $store: 'spaceStore.access' }, 'shared'] }, then: 'Shared', else: 'Personal' }, + }; + expect(accessor(resolveProp(token, stores, {}, rerunMemo))()).toBe('Personal'); + }); + + it('resolves branches that are themselves tokens', () => { + const stores = { spaceStore: { name: 'Home' } }; + const token = { $if: { condition: true, then: { $store: 'spaceStore.name' }, else: 'none' } }; + expect(accessor(resolveProp(token, stores, {}, rerunMemo))()).toBe('Home'); + }); +}); + +describe('$if with $arg — evaluated per call', () => { + it('reads the branch at call time', () => { + const calls: string[] = []; + const stores = { s: { hit: (v: string) => calls.push(v) } }; + const token = { + $if: { + condition: { $eq: ['$arg.kind', 'a'] }, + then: { $action: 's.hit', args: ['then-branch'] }, + else: { $action: 's.hit', args: ['else-branch'] }, + }, + }; + const handler = resolveProp(token, stores, {}, rerunMemo) as (arg: unknown) => void; + + handler({ kind: 'a' }); + expect(calls).toEqual(['then-branch']); + + // Editing the branch must affect the next invocation, not just the next mount. + token.$if.then = { $action: 's.hit', args: ['edited'] }; + handler({ kind: 'a' }); + expect(calls).toEqual(['then-branch', 'edited']); + + handler({ kind: 'b' }); + expect(calls).toEqual(['then-branch', 'edited', 'else-branch']); + }); +}); diff --git a/packages/schema-system/shared/src/propResolvers/conditional.ts b/packages/schema-system/shared/src/propResolvers/conditional.ts index 8a9727fb..7cd7bcdc 100644 --- a/packages/schema-system/shared/src/propResolvers/conditional.ts +++ b/packages/schema-system/shared/src/propResolvers/conditional.ts @@ -37,16 +37,16 @@ export function resolveIfProp( memo: Memo, resolvePropFn: typeof resolveProp, ): unknown { - const { condition, then: thenValue, else: elseValue } = (value as { $if: IfProp }).$if; - - // Pre-wrap array branches into a single dispatch function so the memo always returns - // a function (or primitive/undefined), never a raw array. - const thenBranch = wrapArrayBranch(thenValue, stores, context, memo, resolvePropFn); - const elseBranch = wrapArrayBranch(elseValue, stores, context, memo, resolvePropFn); + // Read the spec lazily everywhere below. The schema is reactive in the visual editor, + // so a token edited in place must be picked up on the next evaluation — destructuring + // once here is what made edited branches invisible until the subtree remounted. + const spec = () => (value as { $if: IfProp }).$if; // Check if condition contains $arg tokens - if so, we need to return a function - // that evaluates the condition on each call with access to callback arguments - const conditionStr = JSON.stringify(condition) ?? ''; + // that evaluates the condition on each call with access to callback arguments. + // This shape decision is made once: whether a condition uses $arg is a property of how + // the template was authored, not something an edit is expected to flip mid-session. + const conditionStr = JSON.stringify(spec().condition) ?? ''; if (conditionStr.includes('$arg')) { // Return a function that evaluates the conditional when called return (...callArgs: unknown[]) => { @@ -78,12 +78,15 @@ export function resolveIfProp( }; // Resolve condition with $arg tokens replaced + const { condition, then: thenValue, else: elseValue } = spec(); const resolvedCondition = resolveWithArg(condition); const conditionResult = resolvePropFn(resolvedCondition, stores, context, memo); const conditionMet = isReactiveAccessor(conditionResult) ? conditionResult() : conditionResult; - // Resolve the appropriate branch (already array-wrapped if needed) - const branchResult = resolvePropFn(conditionMet ? thenBranch : elseBranch, stores, context, memo); + // Pre-wrap array branches into a single dispatch function so the result is always a + // function (or primitive/undefined), never a raw array. + const branch = wrapArrayBranch(conditionMet ? thenValue : elseValue, stores, context, memo, resolvePropFn); + const branchResult = resolvePropFn(branch, stores, context, memo); // If branch result is a function (e.g., an action or wrapped array), call it with the arguments if (typeof branchResult === 'function') { @@ -97,6 +100,17 @@ export function resolveIfProp( // Standard $if without $arg - wrap in memo to reactively re-evaluate when condition changes return markReactive( memo(() => { + // Re-read condition/then/else from `value` on every evaluation rather than closing + // over them. When the schema itself is reactive (the visual editor renders from a + // Solid store), destructuring once meant an edit to a branch was never picked up — + // the memo re-ran only when the *data* behind the condition changed, so an edited + // then/else only appeared after the subtree remounted. + const { condition, then: thenValue, else: elseValue } = spec(); + // Pre-wrap array branches into a single dispatch function so the memo always returns + // a function (or primitive/undefined), never a raw array. + const thenBranch = wrapArrayBranch(thenValue, stores, context, memo, resolvePropFn); + const elseBranch = wrapArrayBranch(elseValue, stores, context, memo, resolvePropFn); + const conditionResult = resolvePropFn(condition, stores, context, memo); // Unwrap reactive accessor if condition resolved to one (e.g. signal from $store) const conditionMet = isReactiveAccessor(conditionResult) ? conditionResult() : conditionResult; diff --git a/packages/schema-system/shared/src/scope.test.ts b/packages/schema-system/shared/src/scope.test.ts new file mode 100644 index 00000000..58dee7d1 --- /dev/null +++ b/packages/schema-system/shared/src/scope.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from 'vitest'; + +import type { ModelEntry, StoreEntry } from './contextTypes'; +import { findNodeChain, findScopeRef, getScopeAtNode, inferRefKind, scopeRefToToken } from './scope'; +import type { SchemaNode } from './types'; + +const storeEntries: StoreEntry[] = [ + { + name: 'adamStore', + state: { + isWeSpace: { type: 'boolean' }, + me: { type: 'object', properties: ['did', 'handle'] }, + personalSpaces: { type: 'array', properties: ['uuid', 'name'] }, + }, + actions: ['navigate'], + }, +]; + +const models: ModelEntry[] = [ + { + name: 'Space', + className: 'Space', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: true }, + { name: 'description', type: 'string', predicate: 'we://description', required: false }, + ], + relations: [{ name: 'location', kind: 'HasOne', predicate: 'we://location' }], + }, +]; + +function groupLabels(node: SchemaNode, id: string) { + return getScopeAtNode(node, id, { storeEntries, models }).map((g) => g.label); +} + +describe('findNodeChain', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + children: [ + { + id: 'if', + type: '$if', + props: { + condition: { $local: 'open' }, + then: { id: 'target', type: 'we-text' }, + }, + }, + ], + }; + + it('walks into SchemaNodes embedded in props', () => { + expect(findNodeChain(tree, 'target')?.map((n) => n.id)).toEqual(['root', 'if', 'target']); + }); + + it('returns null for an unknown id', () => { + expect(findNodeChain(tree, 'nope')).toBeNull(); + }); +}); + +describe('getScopeAtNode', () => { + it('exposes $localState declared on an ancestor', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { showComments: { type: 'boolean', initial: false } }, + children: [{ id: 'child', type: 'we-text' }], + }; + const local = getScopeAtNode(tree, 'child', { storeEntries }).find((g) => g.kind === 'local'); + expect(local?.refs).toEqual([ + { id: 'local:showComments', kind: 'local', path: 'showComments', label: 'showComments', valueType: 'boolean' }, + ]); + }); + + it("exposes a node's own $localState — the renderer resolves its props against it", () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { draft: { type: 'string', initial: '' } }, + }; + const local = getScopeAtNode(tree, 'root', { storeEntries }).find((g) => g.kind === 'local'); + expect(local?.refs.map((r) => r.path)).toEqual(['draft']); + }); + + it('does not leak $localState from a sibling branch', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + children: [ + { id: 'a', type: 'Column', $localState: { hidden: { type: 'boolean', initial: false } } }, + { id: 'b', type: 'we-text' }, + ], + }; + expect(getScopeAtNode(tree, 'b', { storeEntries }).some((g) => g.kind === 'local')).toBe(false); + }); + + it('exposes $queries results with the entity’s fields', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $queries: { spaces: { entity: 'Space' } }, + children: [{ id: 'child', type: 'we-text' }], + }; + const local = getScopeAtNode(tree, 'child', { storeEntries, models }).find((g) => g.kind === 'local'); + expect(local?.refs[0]).toMatchObject({ path: 'spaces', valueType: 'array' }); + expect(local?.refs[0].properties).toContain('name'); + }); + + it('infers $each item fields from a $query source', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $query: { entity: 'Space' } }, as: 'space' }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries, models }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toContain('$space.name'); + expect(item?.refs.map((r) => r.path)).toContain('$space.location'); + }); + + it('infers $each item fields from a store array source', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $store: 'adamStore.personalSpaces' } }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries, models }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toEqual(['$item', '$item.uuid', '$item.name']); + }); + + it('infers $each item fields from a literal array', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: [{ title: 'a', author: 'b' }], as: 'post' }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toEqual(['$post', '$post.title', '$post.author']); + }); + + it('shadows an outer $each that reuses the same `as` name', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $store: 'adamStore.personalSpaces' } }, + children: [ + { + id: 'inner', + type: '$each', + props: { items: [{ label: 'x' }] }, + children: [{ id: 'leaf', type: 'we-text' }], + }, + ], + }; + const itemGroups = getScopeAtNode(tree, 'leaf', { storeEntries }).filter((g) => g.kind === 'item'); + expect(itemGroups).toHaveLength(1); + expect(itemGroups[0].refs.map((r) => r.path)).toEqual(['$item', '$item.label']); + }); + + it('lists one group per store, plus context, ordered nearest-scope-first', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: [{ a: 1 }] }, + $localState: { open: { type: 'boolean', initial: false } }, + children: [{ id: 'leaf', type: 'we-text' }], + }; + expect(groupLabels(tree, 'leaf')).toEqual(['item — literal list', 'Page state', 'adamStore', 'Context']); + }); + + it('drills into object-typed store members but not array members', () => { + const tree: SchemaNode = { id: 'root', type: 'Column' }; + const store = getScopeAtNode(tree, 'root', { storeEntries }).find((g) => g.label === 'adamStore'); + const paths = store?.refs.map((r) => r.path); + expect(paths).toContain('adamStore.me.did'); + expect(paths).not.toContain('adamStore.personalSpaces.uuid'); + }); + + it('returns stores and context even when the node id is unknown', () => { + expect(groupLabels({ id: 'root', type: 'Column' }, 'missing')).toEqual(['adamStore', 'Context']); + }); +}); + +describe('store members that hold model instances', () => { + const entries: StoreEntry[] = [ + { name: 'spaceStore', state: { currentSpace: { type: 'object', model: 'Space' } }, actions: [] }, + ]; + + it('takes properties from the model registry rather than a hand-written list', () => { + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: entries, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + // `description` is a declared Space field; a hand-maintained list had omitted fields + // like this, so the picker could not offer them at all. + expect(paths).toContain('spaceStore.currentSpace.description'); + expect(paths).toContain('spaceStore.currentSpace.location'); + }); + + it('includes the base fields every model instance carries', () => { + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: entries, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + for (const base of ['id', 'author', 'createdAt', 'updatedAt']) { + expect(paths).toContain(`spaceStore.currentSpace.${base}`); + } + }); + + it('unions model fields with explicitly declared ones', () => { + const withComputed: StoreEntry[] = [ + { + name: 'spaceStore', + state: { currentSpace: { type: 'object', model: 'Space', properties: ['$memberCount'] } }, + actions: [], + }, + ]; + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: withComputed, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + expect(paths).toContain('spaceStore.currentSpace.name'); + expect(paths).toContain('spaceStore.currentSpace.$memberCount'); + }); + + it('falls back to declared properties when the model is unknown', () => { + const unknown: StoreEntry[] = [ + { name: 'spaceStore', state: { thing: { type: 'object', model: 'Nope', properties: ['a'] } }, actions: [] }, + ]; + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: unknown, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + expect(paths).toContain('spaceStore.thing.a'); + }); +}); + +describe('inferRefKind', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { draft: { type: 'string', initial: '' } }, + }; + const groups = getScopeAtNode(tree, 'root', { storeEntries }); + + it('resolves paths the listed scope does not contain', () => { + // The whole point: a store member whose metadata is incomplete is still reachable. + expect(inferRefKind('adamStore.somethingUndocumented.deep', groups)).toBe('store'); + expect(inferRefKind('draft.nested', groups)).toBe('local'); + expect(inferRefKind('$post.title', groups)).toBe('context'); + }); + + it('refuses paths whose first segment matches nothing known', () => { + expect(inferRefKind('mysteryStore.value', groups)).toBeNull(); + expect(inferRefKind('notAField', groups)).toBeNull(); + expect(inferRefKind(' ', groups)).toBeNull(); + }); +}); + +describe('token conversion', () => { + it('builds the right token per ref kind', () => { + expect(scopeRefToToken({ kind: 'store', path: 'a.b' })).toEqual({ $store: 'a.b' }); + expect(scopeRefToToken({ kind: 'local', path: 'draft' })).toEqual({ $local: 'draft' }); + expect(scopeRefToToken({ kind: 'item', path: '$post.name' })).toBe('$post.name'); + expect(scopeRefToToken({ kind: 'context', path: '$me.did' })).toBe('$me.did'); + }); + + it('matches a token back to its scope ref', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { open: { type: 'boolean', initial: false } }, + }; + const groups = getScopeAtNode(tree, 'root', { storeEntries }); + expect(findScopeRef(groups, { $local: 'open' })?.id).toBe('local:open'); + expect(findScopeRef(groups, { $store: 'adamStore.isWeSpace' })?.id).toBe('store:adamStore.isWeSpace'); + expect(findScopeRef(groups, '$me.did')?.id).toBe('context:$me.did'); + }); + + it('returns null for tokens that are not plain references', () => { + const groups = getScopeAtNode({ id: 'root', type: 'Column' }, 'root', { storeEntries }); + expect(findScopeRef(groups, { $concat: ['a'] })).toBeNull(); + expect(findScopeRef(groups, 'plain text')).toBeNull(); + expect(findScopeRef(groups, { $store: 'unknownStore.x' })).toBeNull(); + }); +}); diff --git a/packages/schema-system/shared/src/scope.ts b/packages/schema-system/shared/src/scope.ts new file mode 100644 index 00000000..b8adef7b --- /dev/null +++ b/packages/schema-system/shared/src/scope.ts @@ -0,0 +1,425 @@ +/** + * Scope resolution — which data references are available at a given node. + * + * Answers "what can this node's props refer to?" by walking from the root down to the + * node and collecting every reference the renderer would have in context there: + * store state, `$local` fields contributed by `$localState` / `$queries` ancestors, + * `$each` / `$single` iteration variables, and the backend-neutral context refs. + * + * Lives here rather than in the editor because it encodes renderer semantics (what is + * actually in context at a node), not UI concerns. The visual editor's value pickers + * are the first consumer; the validator's orphan-`$local` check answers a subset of + * the same question and could be rebased on this later. + */ + +import type { ModelEntry, StateMemberMeta, StoreEntry } from './contextTypes'; +import type { SchemaNode } from './types'; + +// ── Public types ──────────────────────────────────────────────────────────── + +export type ScopeRefKind = 'store' | 'local' | 'item' | 'context'; + +export type ScopeValueType = 'string' | 'boolean' | 'number' | 'array' | 'object' | 'function' | 'unknown'; + +/** A single addressable value available at a node. */ +export interface ScopeRef { + /** Stable identity within a scope — `${kind}:${path}`. Used to round-trip picker selections. */ + id: string; + kind: ScopeRefKind; + /** + * The path as it appears inside the emitted token: + * store → `adamStore.me.did`, local → `searchText`, item/context → `$post.name`. + */ + path: string; + /** Display label, relative to its group (e.g. `me.did` inside the `adamStore` group). */ + label: string; + valueType: ScopeValueType; + /** Known sub-properties: an object's own keys, or an array's *item* keys. */ + properties?: string[]; + /** Where the ref came from, for disambiguation in the UI (e.g. 'from $each over Space'). */ + hint?: string; +} + +export interface ScopeGroup { + label: string; + kind: ScopeRefKind; + refs: ScopeRef[]; +} + +export interface ScopeOptions { + storeEntries?: StoreEntry[]; + /** Model registry — used to infer item fields for `$each` over a `$query`. */ + models?: ModelEntry[]; +} + +// ── Context refs (backend-neutral, always in scope) ────────────────────────── + +const CONTEXT_REFS: ScopeRef[] = [ + { + id: 'context:$me.did', + kind: 'context', + path: '$me.did', + label: '$me.did', + valueType: 'string', + hint: 'current agent identity', + }, + { id: 'context:$me.handle', kind: 'context', path: '$me.handle', label: '$me.handle', valueType: 'string' }, + { id: 'context:$me.avatar', kind: 'context', path: '$me.avatar', label: '$me.avatar', valueType: 'string' }, + { + id: 'context:$currentDataset', + kind: 'context', + path: '$currentDataset', + label: '$currentDataset', + valueType: 'object', + hint: 'the active dataset', + }, +]; + +// ── Ancestor walk ─────────────────────────────────────────────────────────── + +/** Returns true if val is a SchemaNode embedded as a prop value (e.g. `$if.props.then`). */ +function isPropsSchemaNode(val: unknown): val is SchemaNode { + if (typeof val !== 'object' || val === null || Array.isArray(val)) return false; + const type = (val as Record).type; + if (typeof type !== 'string') return false; + return /^[A-Z$]/.test(type) || type.includes('-'); +} + +function isSchemaChild(child: unknown): child is SchemaNode { + if (typeof child !== 'object' || child === null || Array.isArray(child)) return false; + if ('type' in child || 'id' in child) return true; + return !Object.keys(child).some((k) => k.startsWith('$')); +} + +/** + * Find the chain of nodes from `root` down to the node with `nodeId`, inclusive. + * Traverses the same edges the renderer does: children, routes, slots, and + * SchemaNodes embedded in props. + */ +export function findNodeChain(root: SchemaNode, nodeId: string): SchemaNode[] | null { + if (!nodeId) return null; + + function search(node: SchemaNode, trail: SchemaNode[]): SchemaNode[] | null { + const chain = [...trail, node]; + if (node.id === nodeId) return chain; + + if (node.children) { + for (const child of node.children) { + if (!isSchemaChild(child)) continue; + const found = search(child, chain); + if (found) return found; + } + } + if (node.routes) { + for (const route of node.routes) { + const found = search(route as SchemaNode, chain); + if (found) return found; + } + } + if (node.slots) { + for (const slotNode of Object.values(node.slots)) { + const found = search(slotNode, chain); + if (found) return found; + } + } + if (node.props) { + for (const val of Object.values(node.props)) { + if (Array.isArray(val)) { + for (const item of val) { + if (!isPropsSchemaNode(item)) continue; + const found = search(item, chain); + if (found) return found; + } + } else if (isPropsSchemaNode(val)) { + const found = search(val, chain); + if (found) return found; + } + } + } + return null; + } + + return search(root, []); +} + +// ── Item field inference ──────────────────────────────────────────────────── + +/** + * Fields every model instance carries from Ad4mModel, which aren't declared per-model. + * Templates use these constantly (`$post.author`, `$space.createdAt`), so leaving them + * out makes the picker look broken for the most common ownership/ordering conditions. + */ +const MODEL_BASE_FIELDS = ['id', 'author', 'createdAt', 'updatedAt']; + +function modelProperties(models: ModelEntry[] | undefined, entity: string): string[] | undefined { + const model = models?.find((m) => m.name === entity || m.className === entity); + if (!model) return undefined; + return [...MODEL_BASE_FIELDS, ...model.fields.map((f) => f.name), ...model.relations.map((r) => r.name)]; +} + +/** + * The properties of a store state member: the model's fields when it holds model + * instances, unioned with any explicitly declared ones (a store may expose computed + * fields the model doesn't have). + */ +function memberProperties(meta: StateMemberMeta, models: ModelEntry[] | undefined): string[] | undefined { + const fromModel = meta.model ? modelProperties(models, meta.model) : undefined; + if (!fromModel) return meta.properties; + if (!meta.properties) return fromModel; + return [...new Set([...fromModel, ...meta.properties])]; +} + +function storeMemberMeta( + storeEntries: StoreEntry[] | undefined, + path: string, +): { meta: StateMemberMeta; store: StoreEntry } | undefined { + const dot = path.indexOf('.'); + if (dot === -1) return undefined; + const store = storeEntries?.find((s) => s.name === path.slice(0, dot)); + if (!store) return undefined; + const meta = store.state[path.slice(dot + 1)]; + return meta ? { meta, store } : undefined; +} + +/** + * Best-effort: infer the property names of items produced by an `items` expression. + * Returns undefined when the shape can't be determined — the picker then offers a + * free-text path instead of a list, which is still usable. + */ +function inferItemProperties( + items: unknown, + options: ScopeOptions, + localRefs: Map, +): { properties?: string[]; hint?: string } { + if (Array.isArray(items)) { + const first = items.find((i) => typeof i === 'object' && i !== null && !Array.isArray(i)); + if (first) return { properties: Object.keys(first as object), hint: 'literal list' }; + return {}; + } + if (typeof items !== 'object' || items === null) return {}; + const token = items as Record; + + if (token.$query && typeof token.$query === 'object') { + const entity = (token.$query as Record).entity; + if (typeof entity === 'string') { + return { properties: modelProperties(options.models, entity), hint: `${entity} records` }; + } + return {}; + } + if (typeof token.$store === 'string') { + const found = storeMemberMeta(options.storeEntries, token.$store); + return { properties: found && memberProperties(found.meta, options.models), hint: token.$store }; + } + if (typeof token.$local === 'string') { + const ref = localRefs.get(token.$local); + return { properties: ref?.properties, hint: `$local.${token.$local}` }; + } + // Array operators pass their source's item shape through unchanged. + if (token.$filter && typeof token.$filter === 'object') { + return inferItemProperties((token.$filter as Record).items, options, localRefs); + } + if (token.$map && typeof token.$map === 'object') { + const select = (token.$map as Record).select; + if (select && typeof select === 'object') return { properties: Object.keys(select), hint: '$map projection' }; + } + return {}; +} + +// ── Scope assembly ────────────────────────────────────────────────────────── + +function localValueType(fieldType: unknown): ScopeValueType { + switch (fieldType) { + case 'string': + case 'boolean': + case 'number': + case 'object': + case 'function': + return fieldType; + default: + return 'unknown'; + } +} + +/** + * Collect every reference available to the props of the node with `nodeId`. + * + * Groups are ordered by how close they are to the node — iteration variables first, + * then page state, then stores, then always-available context refs. That ordering + * mirrors how often each appears in real templates. + * + * A node's own `$localState` / `$queries` are included: the renderer resolves a node's + * props against the context it just extended, so those fields do resolve there. + */ +export function getScopeAtNode(root: SchemaNode, nodeId: string, options: ScopeOptions = {}): ScopeGroup[] { + const chain = findNodeChain(root, nodeId) ?? []; + + const localRefs = new Map(); + const itemGroups: ScopeGroup[] = []; + + for (const node of chain) { + // $localState — scoped signals; inner declarations shadow outer ones of the same name. + if (node.$localState) { + for (const [name, field] of Object.entries(node.$localState)) { + localRefs.set(name, { + id: `local:${name}`, + kind: 'local', + path: name, + label: name, + valueType: localValueType((field as { type?: unknown }).type), + }); + } + } + + // $queries — read-only reactive arrays injected into the same $local namespace. + if (node.$queries) { + for (const [name, query] of Object.entries(node.$queries)) { + const entity = (query as { entity?: unknown }).entity; + localRefs.set(name, { + id: `local:${name}`, + kind: 'local', + path: name, + label: name, + valueType: 'array', + properties: typeof entity === 'string' ? modelProperties(options.models, entity) : undefined, + hint: typeof entity === 'string' ? `${entity} query results` : 'query results', + }); + } + } + + // $each / $single — iteration variables, addressed as context reference strings. + if (node.type === '$each' || node.type === '$single') { + const asKey = typeof node.props?.as === 'string' ? node.props.as : 'item'; + const source = node.type === '$each' ? node.props?.items : node.props?.item; + const { properties, hint } = inferItemProperties(source, options, localRefs); + const refs: ScopeRef[] = [ + { + id: `item:$${asKey}`, + kind: 'item', + path: `$${asKey}`, + label: `$${asKey}`, + valueType: 'object', + properties, + hint, + }, + ...(properties ?? []).map((prop) => ({ + id: `item:$${asKey}.${prop}`, + kind: 'item', + path: `$${asKey}.${prop}`, + label: `$${asKey}.${prop}`, + valueType: 'unknown', + })), + ]; + // A nested $each reusing an outer `as` name shadows it — drop the outer group. + const shadowed = itemGroups.findIndex((g) => g.refs[0]?.path === `$${asKey}`); + if (shadowed !== -1) itemGroups.splice(shadowed, 1); + itemGroups.push({ label: hint ? `${asKey} — ${hint}` : asKey, kind: 'item', refs }); + } + } + + const groups: ScopeGroup[] = [...itemGroups]; + + if (localRefs.size > 0) { + groups.push({ label: 'Page state', kind: 'local', refs: [...localRefs.values()] }); + } + + for (const store of options.storeEntries ?? []) { + const refs: ScopeRef[] = []; + for (const [member, meta] of Object.entries(store.state)) { + const path = `${store.name}.${member}`; + const properties = memberProperties(meta, options.models); + refs.push({ + id: `store:${path}`, + kind: 'store', + path, + label: member, + valueType: meta.type, + properties, + }); + // Objects can be drilled into directly; an array's `properties` describe its + // *items*, which are only reachable through $each — so they aren't listed here. + if (meta.type === 'object' && properties) { + for (const prop of properties) { + refs.push({ + id: `store:${path}.${prop}`, + kind: 'store', + path: `${path}.${prop}`, + label: `${member}.${prop}`, + valueType: 'unknown', + }); + } + } + } + if (refs.length > 0) groups.push({ label: store.name, kind: 'store', refs }); + } + + groups.push({ label: 'Context', kind: 'context', refs: CONTEXT_REFS }); + + return groups; +} + +// ── Token conversion ──────────────────────────────────────────────────────── + +/** Build the schema token that reads a scope reference. */ +export function scopeRefToToken(ref: Pick): unknown { + switch (ref.kind) { + case 'store': + return { $store: ref.path }; + case 'local': + return { $local: ref.path }; + case 'item': + case 'context': + return ref.path; + } +} + +/** + * Work out what kind of reference a hand-typed path is, so a picker can offer paths that + * aren't in the listed scope — a store member whose metadata is incomplete, or a deeper + * nesting than the registry describes. + * + * Returns null when the path's first segment matches nothing known, rather than guessing: + * an unresolvable path would serialize to a token that silently reads `undefined`. + */ +export function inferRefKind(path: string, groups: ScopeGroup[]): ScopeRefKind | null { + const trimmed = path.trim(); + if (!trimmed) return null; + // Context refs and iteration variables are both `$`-prefixed strings at the token level. + if (trimmed.startsWith('$')) return 'context'; + + const [head] = trimmed.split('.'); + for (const group of groups) { + if (group.kind === 'store' && group.label === head) return 'store'; + if (group.kind === 'local' && group.refs.some((r) => r.path === head)) return 'local'; + } + return null; +} + +/** Find the scope ref a token reads, or null if it isn't a plain reference. */ +export function findScopeRef(groups: ScopeGroup[], token: unknown): ScopeRef | null { + let kind: ScopeRefKind | null = null; + let path: string | null = null; + + if (typeof token === 'string' && token.startsWith('$')) { + path = token; + } else if (typeof token === 'object' && token !== null) { + const obj = token as Record; + if (typeof obj.$store === 'string') { + kind = 'store'; + path = obj.$store; + } else if (typeof obj.$local === 'string') { + kind = 'local'; + path = obj.$local; + } + } + if (path === null) return null; + + for (const group of groups) { + for (const ref of group.refs) { + if (ref.path !== path) continue; + if (kind && ref.kind !== kind) continue; + if (!kind && ref.kind !== 'item' && ref.kind !== 'context') continue; + return ref; + } + } + return null; +}