diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index dd29f1ac..cf7c0ef7 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -799,7 +799,7 @@ when `relative` is enabled. - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string - IconLabelButton - 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> + Props: icon: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox @@ -1048,6 +1048,15 @@ AgentSettings extends Ad4mModel: - installedThemes: HasMany → Theme [we://installed_theme] - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference] +Assistant extends WeNode: + Fields: + - name: string [we://name] + - modelId: string [we://model_id] + - systemPrompt: string [we://system_prompt] + - personalityIds: string [we://personality_ids] + - skillIds: string [we://skill_ids] + - mcpServerIds: string [we://mcp_server_ids] + AudioBlock extends WeNode: Fields: - title: string (required) [we://title] @@ -1157,6 +1166,28 @@ LocationBlock extends WeNode: - country: string [we://country] - version: number [we://version] +McpServer extends WeNode: + Fields: + - name: string [we://name] + - transport: string = 'stdio' [we://transport] + - url: string [we://url] + - command: string [we://command] + - auth: string [we://auth] + +Message extends WeNode: + Fields: + - threadId: string [we://thread_id] + - role: string [we://role] + - content: string [we://content] + - toolCalls: string [we://tool_calls] + - ts: string [we://ts] + - status: string [we://status] + +Personality extends WeNode: + Fields: + - name: string [we://name] + - body: string [we://body] + Signal extends Ad4mModel: Fields: - signalTypeId: string [we://signal_type_id] @@ -1179,6 +1210,12 @@ SignalType extends WeNode: - valueType: string = 'numeric' [we://signal_value_type] - schemaVersion: number = 1 [we://schema_version] +Skill extends WeNode: + Fields: + - name: string [we://name] + - description: string [we://description] + - body: string [we://body] + Space extends WeNode: Fields: - uuid: string [we://uuid] @@ -1255,6 +1292,16 @@ Theme extends WeNode: Relations: - screenshots: HasMany → ImageBlock [we://screenshot] +Thread extends WeNode: + Fields: + - title: string [we://title] + - assistantId: string [we://assistant_id] + - modelId: string [we://model_id] + - createdAt: string [we://created_at] + - updatedAt: string [we://updated_at] + Relations: + - messages: HasMany → Message [we://message] + VideoBlock extends WeNode: Fields: - title: string [we://title] diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dd29f1ac..cf7c0ef7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -799,7 +799,7 @@ when `relative` is enabled. - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string - IconLabelButton - 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> + Props: icon: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox @@ -1048,6 +1048,15 @@ AgentSettings extends Ad4mModel: - installedThemes: HasMany → Theme [we://installed_theme] - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference] +Assistant extends WeNode: + Fields: + - name: string [we://name] + - modelId: string [we://model_id] + - systemPrompt: string [we://system_prompt] + - personalityIds: string [we://personality_ids] + - skillIds: string [we://skill_ids] + - mcpServerIds: string [we://mcp_server_ids] + AudioBlock extends WeNode: Fields: - title: string (required) [we://title] @@ -1157,6 +1166,28 @@ LocationBlock extends WeNode: - country: string [we://country] - version: number [we://version] +McpServer extends WeNode: + Fields: + - name: string [we://name] + - transport: string = 'stdio' [we://transport] + - url: string [we://url] + - command: string [we://command] + - auth: string [we://auth] + +Message extends WeNode: + Fields: + - threadId: string [we://thread_id] + - role: string [we://role] + - content: string [we://content] + - toolCalls: string [we://tool_calls] + - ts: string [we://ts] + - status: string [we://status] + +Personality extends WeNode: + Fields: + - name: string [we://name] + - body: string [we://body] + Signal extends Ad4mModel: Fields: - signalTypeId: string [we://signal_type_id] @@ -1179,6 +1210,12 @@ SignalType extends WeNode: - valueType: string = 'numeric' [we://signal_value_type] - schemaVersion: number = 1 [we://schema_version] +Skill extends WeNode: + Fields: + - name: string [we://name] + - description: string [we://description] + - body: string [we://body] + Space extends WeNode: Fields: - uuid: string [we://uuid] @@ -1255,6 +1292,16 @@ Theme extends WeNode: Relations: - screenshots: HasMany → ImageBlock [we://screenshot] +Thread extends WeNode: + Fields: + - title: string [we://title] + - assistantId: string [we://assistant_id] + - modelId: string [we://model_id] + - createdAt: string [we://created_at] + - updatedAt: string [we://updated_at] + Relations: + - messages: HasMany → Message [we://message] + VideoBlock extends WeNode: Fields: - title: string [we://title] diff --git a/CLAUDE.md b/CLAUDE.md index dd29f1ac..cf7c0ef7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -799,7 +799,7 @@ when `relative` is enabled. - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string - IconLabelButton - 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> + Props: icon: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox @@ -1048,6 +1048,15 @@ AgentSettings extends Ad4mModel: - installedThemes: HasMany → Theme [we://installed_theme] - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference] +Assistant extends WeNode: + Fields: + - name: string [we://name] + - modelId: string [we://model_id] + - systemPrompt: string [we://system_prompt] + - personalityIds: string [we://personality_ids] + - skillIds: string [we://skill_ids] + - mcpServerIds: string [we://mcp_server_ids] + AudioBlock extends WeNode: Fields: - title: string (required) [we://title] @@ -1157,6 +1166,28 @@ LocationBlock extends WeNode: - country: string [we://country] - version: number [we://version] +McpServer extends WeNode: + Fields: + - name: string [we://name] + - transport: string = 'stdio' [we://transport] + - url: string [we://url] + - command: string [we://command] + - auth: string [we://auth] + +Message extends WeNode: + Fields: + - threadId: string [we://thread_id] + - role: string [we://role] + - content: string [we://content] + - toolCalls: string [we://tool_calls] + - ts: string [we://ts] + - status: string [we://status] + +Personality extends WeNode: + Fields: + - name: string [we://name] + - body: string [we://body] + Signal extends Ad4mModel: Fields: - signalTypeId: string [we://signal_type_id] @@ -1179,6 +1210,12 @@ SignalType extends WeNode: - valueType: string = 'numeric' [we://signal_value_type] - schemaVersion: number = 1 [we://schema_version] +Skill extends WeNode: + Fields: + - name: string [we://name] + - description: string [we://description] + - body: string [we://body] + Space extends WeNode: Fields: - uuid: string [we://uuid] @@ -1255,6 +1292,16 @@ Theme extends WeNode: Relations: - screenshots: HasMany → ImageBlock [we://screenshot] +Thread extends WeNode: + Fields: + - title: string [we://title] + - assistantId: string [we://assistant_id] + - modelId: string [we://model_id] + - createdAt: string [we://created_at] + - updatedAt: string [we://updated_at] + Relations: + - messages: HasMany → Message [we://message] + VideoBlock extends WeNode: Fields: - title: string [we://title] diff --git a/docs/assistant-ui/config-mcp.png b/docs/assistant-ui/config-mcp.png new file mode 100644 index 00000000..998c45d3 Binary files /dev/null and b/docs/assistant-ui/config-mcp.png differ diff --git a/docs/assistant-ui/config-skills.png b/docs/assistant-ui/config-skills.png new file mode 100644 index 00000000..4dbd73f9 Binary files /dev/null and b/docs/assistant-ui/config-skills.png differ diff --git a/docs/assistant-ui/surface.png b/docs/assistant-ui/surface.png new file mode 100644 index 00000000..d7d32d30 Binary files /dev/null and b/docs/assistant-ui/surface.png differ diff --git a/docs/assistant-ui/thread-tool-open.png b/docs/assistant-ui/thread-tool-open.png new file mode 100644 index 00000000..ce241b4c Binary files /dev/null and b/docs/assistant-ui/thread-tool-open.png differ diff --git a/packages/ai-context/context.json b/packages/ai-context/context.json index 0a8881e8..c778cd5f 100644 --- a/packages/ai-context/context.json +++ b/packages/ai-context/context.json @@ -3212,37 +3212,37 @@ "props": [ { "name": "icon", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor", "optional": false }, { "name": "label", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor", "optional": false }, { "name": "selected", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor", "optional": true }, { "name": "iconWeight", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor", "optional": true }, { "name": "onClick", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>", "optional": true }, { "name": "class", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor", "optional": true }, { "name": "styles", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>", + "type": "import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>", "optional": true } ], @@ -3958,6 +3958,50 @@ } ] }, + { + "name": "Assistant", + "className": "Assistant", + "extends": "WeNode", + "fields": [ + { + "name": "name", + "type": "string", + "predicate": "we://name", + "required": false + }, + { + "name": "modelId", + "type": "string", + "predicate": "we://model_id", + "required": false + }, + { + "name": "systemPrompt", + "type": "string", + "predicate": "we://system_prompt", + "required": false + }, + { + "name": "personalityIds", + "type": "string", + "predicate": "we://personality_ids", + "required": false + }, + { + "name": "skillIds", + "type": "string", + "predicate": "we://skill_ids", + "required": false + }, + { + "name": "mcpServerIds", + "type": "string", + "predicate": "we://mcp_server_ids", + "required": false + } + ], + "relations": [] + }, { "name": "AudioBlock", "className": "AudioBlock", @@ -4476,6 +4520,109 @@ ], "relations": [] }, + { + "name": "McpServer", + "className": "McpServer", + "extends": "WeNode", + "fields": [ + { + "name": "name", + "type": "string", + "predicate": "we://name", + "required": false + }, + { + "name": "transport", + "type": "string", + "predicate": "we://transport", + "required": false, + "default": "'stdio'" + }, + { + "name": "url", + "type": "string", + "predicate": "we://url", + "required": false + }, + { + "name": "command", + "type": "string", + "predicate": "we://command", + "required": false + }, + { + "name": "auth", + "type": "string", + "predicate": "we://auth", + "required": false + } + ], + "relations": [] + }, + { + "name": "Message", + "className": "Message", + "extends": "WeNode", + "fields": [ + { + "name": "threadId", + "type": "string", + "predicate": "we://thread_id", + "required": false + }, + { + "name": "role", + "type": "string", + "predicate": "we://role", + "required": false + }, + { + "name": "content", + "type": "string", + "predicate": "we://content", + "required": false + }, + { + "name": "toolCalls", + "type": "string", + "predicate": "we://tool_calls", + "required": false + }, + { + "name": "ts", + "type": "string", + "predicate": "we://ts", + "required": false + }, + { + "name": "status", + "type": "string", + "predicate": "we://status", + "required": false + } + ], + "relations": [] + }, + { + "name": "Personality", + "className": "Personality", + "extends": "WeNode", + "fields": [ + { + "name": "name", + "type": "string", + "predicate": "we://name", + "required": false + }, + { + "name": "body", + "type": "string", + "predicate": "we://body", + "required": false + } + ], + "relations": [] + }, { "name": "Signal", "className": "Signal", @@ -4596,6 +4743,32 @@ ], "relations": [] }, + { + "name": "Skill", + "className": "Skill", + "extends": "WeNode", + "fields": [ + { + "name": "name", + "type": "string", + "predicate": "we://name", + "required": false + }, + { + "name": "description", + "type": "string", + "predicate": "we://description", + "required": false + }, + { + "name": "body", + "type": "string", + "predicate": "we://body", + "required": false + } + ], + "relations": [] + }, { "name": "Space", "className": "Space", @@ -4975,6 +5148,51 @@ } ] }, + { + "name": "Thread", + "className": "Thread", + "extends": "WeNode", + "fields": [ + { + "name": "title", + "type": "string", + "predicate": "we://title", + "required": false + }, + { + "name": "assistantId", + "type": "string", + "predicate": "we://assistant_id", + "required": false + }, + { + "name": "modelId", + "type": "string", + "predicate": "we://model_id", + "required": false + }, + { + "name": "createdAt", + "type": "string", + "predicate": "we://created_at", + "required": false + }, + { + "name": "updatedAt", + "type": "string", + "predicate": "we://updated_at", + "required": false + } + ], + "relations": [ + { + "name": "messages", + "kind": "HasMany", + "predicate": "we://message", + "target": "Message" + } + ] + }, { "name": "VideoBlock", "className": "VideoBlock", diff --git a/packages/ai-context/src/contextData.ts b/packages/ai-context/src/contextData.ts index c86764da..0dadbd0f 100644 --- a/packages/ai-context/src/contextData.ts +++ b/packages/ai-context/src/contextData.ts @@ -1168,37 +1168,37 @@ export const contextData: ContextData = { props: [ { name: 'icon', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor', optional: false, }, { name: 'label', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor', optional: false, }, { name: 'selected', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor', optional: true, }, { name: 'iconWeight', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor', optional: true, }, { name: 'onClick', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>', optional: true, }, { name: 'class', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor', optional: true, }, { name: 'styles', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined>', + type: 'import("/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined>', optional: true, }, ], @@ -1476,6 +1476,20 @@ export const contextData: ContextData = { }, ], }, + { + name: 'Assistant', + className: 'Assistant', + extends: 'WeNode', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: false }, + { name: 'modelId', type: 'string', predicate: 'we://model_id', required: false }, + { name: 'systemPrompt', type: 'string', predicate: 'we://system_prompt', required: false }, + { name: 'personalityIds', type: 'string', predicate: 'we://personality_ids', required: false }, + { name: 'skillIds', type: 'string', predicate: 'we://skill_ids', required: false }, + { name: 'mcpServerIds', type: 'string', predicate: 'we://mcp_server_ids', required: false }, + ], + relations: [], + }, { name: 'AudioBlock', className: 'AudioBlock', @@ -1646,6 +1660,43 @@ export const contextData: ContextData = { ], relations: [], }, + { + name: 'McpServer', + className: 'McpServer', + extends: 'WeNode', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: false }, + { name: 'transport', type: 'string', predicate: 'we://transport', required: false, default: "'stdio'" }, + { name: 'url', type: 'string', predicate: 'we://url', required: false }, + { name: 'command', type: 'string', predicate: 'we://command', required: false }, + { name: 'auth', type: 'string', predicate: 'we://auth', required: false }, + ], + relations: [], + }, + { + name: 'Message', + className: 'Message', + extends: 'WeNode', + fields: [ + { name: 'threadId', type: 'string', predicate: 'we://thread_id', required: false }, + { name: 'role', type: 'string', predicate: 'we://role', required: false }, + { name: 'content', type: 'string', predicate: 'we://content', required: false }, + { name: 'toolCalls', type: 'string', predicate: 'we://tool_calls', required: false }, + { name: 'ts', type: 'string', predicate: 'we://ts', required: false }, + { name: 'status', type: 'string', predicate: 'we://status', required: false }, + ], + relations: [], + }, + { + name: 'Personality', + className: 'Personality', + extends: 'WeNode', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: false }, + { name: 'body', type: 'string', predicate: 'we://body', required: false }, + ], + relations: [], + }, { name: 'Signal', className: 'Signal', @@ -1690,6 +1741,17 @@ export const contextData: ContextData = { ], relations: [], }, + { + name: 'Skill', + className: 'Skill', + extends: 'WeNode', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: false }, + { name: 'description', type: 'string', predicate: 'we://description', required: false }, + { name: 'body', type: 'string', predicate: 'we://body', required: false }, + ], + relations: [], + }, { name: 'Space', className: 'Space', @@ -1795,6 +1857,19 @@ export const contextData: ContextData = { ], relations: [{ name: 'screenshots', kind: 'HasMany', predicate: 'we://screenshot', target: 'ImageBlock' }], }, + { + name: 'Thread', + className: 'Thread', + extends: 'WeNode', + fields: [ + { name: 'title', type: 'string', predicate: 'we://title', required: false }, + { name: 'assistantId', type: 'string', predicate: 'we://assistant_id', required: false }, + { name: 'modelId', type: 'string', predicate: 'we://model_id', required: false }, + { name: 'createdAt', type: 'string', predicate: 'we://created_at', required: false }, + { name: 'updatedAt', type: 'string', predicate: 'we://updated_at', required: false }, + ], + relations: [{ name: 'messages', kind: 'HasMany', predicate: 'we://message', target: 'Message' }], + }, { name: 'VideoBlock', className: 'VideoBlock', diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index c78347c0..8cb8869c 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/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor, label: import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor, selected?: import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor, iconWeight?: import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor, onClick?: import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>, class?: import(\"/home/josh/workspaces/coasys/we/packages/design-system/utils/dist/solid\").MaybeAccessor, styles?: import(\"/home/josh/workspaces/coasys/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\nAssistant extends WeNode:\n Fields:\n - name: string [we://name]\n - modelId: string [we://model_id]\n - systemPrompt: string [we://system_prompt]\n - personalityIds: string [we://personality_ids]\n - skillIds: string [we://skill_ids]\n - mcpServerIds: string [we://mcp_server_ids]\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\nMcpServer extends WeNode:\n Fields:\n - name: string [we://name]\n - transport: string = 'stdio' [we://transport]\n - url: string [we://url]\n - command: string [we://command]\n - auth: string [we://auth]\n\nMessage extends WeNode:\n Fields:\n - threadId: string [we://thread_id]\n - role: string [we://role]\n - content: string [we://content]\n - toolCalls: string [we://tool_calls]\n - ts: string [we://ts]\n - status: string [we://status]\n\nPersonality extends WeNode:\n Fields:\n - name: string [we://name]\n - body: string [we://body]\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\nSkill extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - body: string [we://body]\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\nThread extends WeNode:\n Fields:\n - title: string [we://title]\n - assistantId: string [we://assistant_id]\n - modelId: string [we://model_id]\n - createdAt: string [we://created_at]\n - updatedAt: string [we://updated_at]\n Relations:\n - messages: HasMany → Message [we://message]\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)"; diff --git a/packages/app-framework/package.json b/packages/app-framework/package.json index e07bd96d..329076af 100644 --- a/packages/app-framework/package.json +++ b/packages/app-framework/package.json @@ -56,11 +56,14 @@ "zod": "^4.1.11" }, "devDependencies": { + "@solidjs/testing-library": "^0.8.10", "@types/node": "^22.10.2", "@types/three": "^0.176.0", "@vitest/coverage-v8": "^4.0.15", "@we/cli": "workspace:*", + "happy-dom": "^20.10.6", "tsup": "^8.5.1", + "vite-plugin-solid": "^2.11.12", "vitest": "^4.0.15" } } diff --git a/packages/app-framework/src/frameworks/solid/components/assistant/AssistantConfigPanel.tsx b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantConfigPanel.tsx new file mode 100644 index 00000000..6ca427fa --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantConfigPanel.tsx @@ -0,0 +1,523 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { Assistant } from '@we/models'; +import { createEffect, createSignal, For, Show } from 'solid-js'; + +import { parseIdList, useAssistantStore } from '../../stores/AssistantStore'; + +type Tab = 'assistants' | 'personalities' | 'skills' | 'mcp'; + +/** + * Right pane of the AI-assistant surface: manage the personal assistant configuration + * (assistants + their model, system prompt and granted personalities/skills/MCP servers) + * and the reusable libraries (personalities, skills, MCP servers). All data lives in the + * personal we-root perspective. + */ +export function AssistantConfigPanel() { + const [tab, setTab] = createSignal('assistants'); + + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: 'assistants', label: 'Assistants', icon: 'sparkle' }, + { id: 'personalities', label: 'Personalities', icon: 'mask-happy' }, + { id: 'skills', label: 'Skills', icon: 'lightning' }, + { id: 'mcp', label: 'MCP', icon: 'plugs' }, + ]; + + return ( + + {/* Tab bar */} + + + {(t) => ( + setTab(t.id)} + styles={{ 'flex-shrink': '0' }} + > + + + {t.label} + + + )} + + + + + + + + + + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- Assistants + +function AssistantsSection() { + const store = useAssistantStore(); + const [selectedId, setSelectedId] = createSignal(null); + + const selected = () => store.assistants().find((a) => a.id === selectedId()) ?? store.activeAssistant(); + + // Editable buffers, re-seeded when the selected assistant changes. + const [name, setName] = createSignal(''); + const [modelId, setModelId] = createSignal(''); + const [systemPrompt, setSystemPrompt] = createSignal(''); + createEffect(() => { + const a = selected(); + setName(a?.name ?? ''); + setModelId(a?.modelId ?? ''); + setSystemPrompt(a?.systemPrompt ?? ''); + }); + + async function addAssistant() { + const id = await store.createAssistant({ name: 'New assistant' }); + if (id) setSelectedId(id); + } + + async function save() { + const a = selected(); + if (!a) return; + await store.updateAssistant(a.id, { name: name(), modelId: modelId(), systemPrompt: systemPrompt() }); + } + + return ( + + + + Assistants + + + + New + + + + {/* Assistant picker */} + 0} + fallback={} + > + + + + {(a) => ( + + + setName(e.detail as string)} /> + + + + setModelId(e.currentTarget.value)} + style={inputStyle()} + /> + + {(m) => + + + + + setSystemPrompt(e.detail as string)} + /> + + + + + Save changes + + void store.deleteAssistant(a().id)}> + + + Delete + + + + + {/* Grants */} + + + + + )} + + + + ); +} + +function GrantGroup(props: { + title: string; + field: 'personalityIds' | 'skillIds' | 'mcpServerIds'; + assistant: Assistant; + items: { id: string; name: string }[]; +}) { + const store = useAssistantStore(); + // Read reactively from the live assistant record so toggles reflect immediately. + const grantedSet = () => new Set(parseIdList(store.assistants().find((a) => a.id === props.assistant.id)?.[props.field])); + + return ( + + + {props.title} + + 0} fallback={{`No ${props.title.toLowerCase()} defined`}}> + + + {(item) => { + const on = () => grantedSet().has(item.id); + return ( + void store.toggleGrant(props.assistant.id, props.field, item.id)} + > + + + {item.name || 'Untitled'} + + + ); + }} + + + + + ); +} + +// --------------------------------------------------------------------------- Personalities + +function PersonalitySection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [body, setBody] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setBody(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim() && !body().trim()) return; + const id = editingId(); + if (id) await store.updatePersonality(id, { name: name(), body: body() }); + else await store.createPersonality({ name: name(), body: body() }); + reset(); + } + + return ( + + + Personalities + + + Reusable guidance blocks that can be granted to an assistant. + + + 0} fallback={}> + + + {(p) => ( + { + setEditingId(p.id); + setName(p.name); + setBody(p.body); + }} + onDelete={() => void store.deletePersonality(p.id)} + /> + )} + + + + + + setName(e.detail as string)} /> + setBody(e.detail as string)} /> + + + + ); +} + +// --------------------------------------------------------------------------- Skills + +function SkillSection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [description, setDescription] = createSignal(''); + const [body, setBody] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setDescription(''); + setBody(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim()) return; + const id = editingId(); + if (id) await store.updateSkill(id, { name: name(), description: description(), body: body() }); + else await store.createSkill({ name: name(), description: description(), body: body() }); + reset(); + } + + return ( + + + Skills + + + Named capabilities the AD4M backend can equip an assistant with. + + + 0} fallback={}> + + + {(s) => ( + { + setEditingId(s.id); + setName(s.name); + setDescription(s.description); + setBody(s.body); + }} + onDelete={() => void store.deleteSkill(s.id)} + /> + )} + + + + + + setName(e.detail as string)} /> + setDescription(e.detail as string)} /> + setBody(e.detail as string)} /> + + + + ); +} + +// --------------------------------------------------------------------------- MCP servers + +function McpSection() { + const store = useAssistantStore(); + const [name, setName] = createSignal(''); + const [transport, setTransport] = createSignal('stdio'); + const [url, setUrl] = createSignal(''); + const [command, setCommand] = createSignal(''); + const [auth, setAuth] = createSignal(''); + const [editingId, setEditingId] = createSignal(null); + + function reset() { + setName(''); + setTransport('stdio'); + setUrl(''); + setCommand(''); + setAuth(''); + setEditingId(null); + } + + async function submit() { + if (!name().trim()) return; + const data = { name: name(), transport: transport(), url: url(), command: command(), auth: auth() }; + const id = editingId(); + if (id) await store.updateMcpServer(id, data); + else await store.createMcpServer(data); + reset(); + } + + const isStdio = () => transport() === 'stdio'; + + return ( + + + MCP servers + + + Model Context Protocol servers the backend connects to on an assistant's behalf. + + + 0} fallback={}> + + + {(m) => ( + { + setEditingId(m.id); + setName(m.name); + setTransport(m.transport || 'stdio'); + setUrl(m.url); + setCommand(m.command); + setAuth(m.auth); + }} + onDelete={() => void store.deleteMcpServer(m.id)} + /> + )} + + + + + + setName(e.detail as string)} /> + + + + setUrl(e.detail as string)} />} + > + setCommand(e.detail as string)} /> + + setAuth(e.detail as string)} /> + + + + ); +} + +// --------------------------------------------------------------------------- Shared UI bits + +function Field(props: { label: string; children: unknown }) { + return ( + + + {props.label} + + {props.children as never} + + ); +} + +function ListRow(props: { title: string; subtitle?: string; onEdit: () => void; onDelete: () => void }) { + return ( + + + + {props.title || 'Untitled'} + + + + {props.subtitle} + + + + + + + + + + + ); +} + +function EditorCard(props: { title: string; children: unknown }) { + return ( + + + {props.title} + + {props.children as never} + + ); +} + +function EditorActions(props: { editing: boolean; onSubmit: () => void; onCancel: () => void }) { + return ( + + + {props.editing ? 'Save' : 'Add'} + + + + Cancel + + + + ); +} + +function Empty(props: { text: string }) { + return ( + + {props.text} + + ); +} + +function inputStyle(): Record { + return { + 'font-size': '13px', + padding: '6px 10px', + 'border-radius': '6px', + border: `1px solid ${tokenVar('color', 'ui-200')}`, + background: tokenVar('color', 'neutral-0'), + color: tokenVar('color', 'neutral-800'), + width: '100%', + }; +} diff --git a/packages/app-framework/src/frameworks/solid/components/assistant/AssistantMessage.tsx b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantMessage.tsx new file mode 100644 index 00000000..da9a647c --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantMessage.tsx @@ -0,0 +1,174 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { Message } from '@we/models'; +import { createSignal, For, Show } from 'solid-js'; + +import { parseToolCalls, type ToolCall } from '../../stores/AssistantStore'; + +/** + * Renders a single assistant-thread message. Handles the four roles + * (user / assistant / tool / system), markdown content, a live streaming + * indicator, and collapsible tool-call invocation/result blocks. + */ +export function AssistantMessage(props: { message: Message }) { + const role = () => props.message.role || 'assistant'; + const isUser = () => role() === 'user'; + const isTool = () => role() === 'tool'; + const isSystem = () => role() === 'system'; + const isStreaming = () => props.message.status === 'streaming'; + const toolCalls = () => parseToolCalls(props.message.toolCalls); + const hasContent = () => !!props.message.content?.trim(); + + return ( + + + {props.message.content} + + + } + > + + {/* Role label for assistant/tool turns */} + + + + + {isTool() ? 'Tool' : 'Assistant'} + + + + streaming… + + + + + + {/* Streaming placeholder before any tokens have arrived */} + + + Thinking… + + + + {/* Message body */} + + } + > + + + + + {/* Tool calls */} + 0}> + + {(call) => } + + + + + + The assistant reported an error. + + + + + ); +} + +/** A collapsible tool invocation with its arguments and (once available) result. */ +function ToolCallBlock(props: { call: ToolCall }) { + const [open, setOpen] = createSignal(false); + const status = () => props.call.status ?? (props.call.result !== undefined ? 'complete' : 'pending'); + const statusColor = () => + status() === 'error' ? 'danger-500' : status() === 'complete' ? 'success-500' : 'warning-500'; + + return ( + + setOpen((v) => !v)} + > + + + + {props.call.name || 'tool'} + + + {status()} + + + + + + Arguments + + + + + Result + + + + + + + ); +} + +function CodeBlock(props: { text: string }) { + return ( +
+ {props.text} +
+ ); +} + +function formatJson(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadList.tsx b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadList.tsx new file mode 100644 index 00000000..fb0c6167 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadList.tsx @@ -0,0 +1,107 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { For, Show } from 'solid-js'; + +import { useAssistantStore } from '../../stores/AssistantStore'; + +/** + * Left pane of the AI-assistant surface: the thread list for the current neighbourhood, + * plus a new-chat action. Many threads per neighbourhood; selecting one drives the + * centre thread view. + */ +export function AssistantThreadList() { + const store = useAssistantStore(); + + async function newChat() { + await store.createThread(); + } + + return ( + + {/* Header */} + + + Chats + + + + + + + + + {/* Thread list */} + + 0} + fallback={ + + + No chats yet + + + + New chat + + + } + > + + {(thread) => { + const active = () => thread.id === store.activeThreadId(); + return ( + store.selectThread(thread.id)} + > + + + {thread.title || 'Untitled'} + + { + e.stopPropagation(); + void store.deleteThread(thread.id); + }} + > + + + + ); + }} + + + + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadView.tsx b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadView.tsx new file mode 100644 index 00000000..567f04ff --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/assistant/AssistantThreadView.tsx @@ -0,0 +1,155 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import { createEffect, createSignal, For, Show } from 'solid-js'; + +import { useAssistantStore } from '../../stores/AssistantStore'; +import { AssistantMessage } from './AssistantMessage'; + +/** + * Centre pane of the AI-assistant surface: the active thread's message list (with live + * streaming + tool-call rendering) and the composer. Sending writes a user Message into + * the thread's perspective; the assistant's reply arrives from the AD4M backend via the + * store's live subscription. + */ +export function AssistantThreadView() { + const store = useAssistantStore(); + const [input, setInput] = createSignal(''); + let endRef: HTMLDivElement | undefined; + + // Auto-scroll to the latest message / streaming update. + createEffect(() => { + void store.messages().length; + void store.messages().find((m) => m.status === 'streaming')?.content; + // Optional call — scrollIntoView is unavailable in some environments (e.g. jsdom under test). + requestAnimationFrame(() => endRef?.scrollIntoView?.({ behavior: 'smooth' })); + }); + + function handleSend() { + const text = input().trim(); + if (!text || !store.activeThreadId()) return; + void store.sendMessage(text); + setInput(''); + } + + const effectiveModel = () => { + const thread = store.activeThread(); + return thread?.modelId || store.activeAssistant()?.modelId || ''; + }; + + return ( + + + + No conversation selected + + + Create a new chat to talk to an assistant. + + + } + > + {(thread) => ( + + {/* Header */} + + + + {thread().title || 'Untitled'} + + + {store.activeAssistant()?.name ?? 'No assistant'} + + + + {/* Per-thread model override */} + + + + + + + {/* Messages */} + + 0} + fallback={ + + + Send a message to begin. + + + Replies are produced by the AD4M assistant backend. + + + } + > + {(msg) => } + +
+ + + {/* Composer */} + + setInput(e.detail as string)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + styles={{ 'overflow-y': 'auto' }} + /> + + + + + + )} + + ); +} + +function selectStyle(): Record { + return { + 'font-size': '12px', + padding: '4px 8px', + 'border-radius': '6px', + border: `1px solid ${tokenVar('color', 'ui-200')}`, + background: tokenVar('color', 'neutral-0'), + color: tokenVar('color', 'neutral-700'), + 'max-width': '220px', + cursor: 'pointer', + }; +} diff --git a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx index 2640a096..b475620d 100644 --- a/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx +++ b/packages/app-framework/src/frameworks/solid/layouts/TemplateLayout.tsx @@ -21,6 +21,7 @@ */ import { isValidThemeKey } from '@shared/registries/themeRegistry'; import { + aiAssistantTemplate, landingPageTemplate, marketplaceTemplate, profileTemplate, @@ -90,6 +91,7 @@ type ShellViewEntry = { const shellViews: Record = { 'landing-page': { schema: landingPageTemplate }, marketplace: { schema: marketplaceTemplate }, + 'ai-assistant': { schema: aiAssistantTemplate }, profile: { schema: profileTemplate }, settings: { schema: settingsTemplate }, 'schema-tests': { diff --git a/packages/app-framework/src/frameworks/solid/providers/StoreProvider.tsx b/packages/app-framework/src/frameworks/solid/providers/StoreProvider.tsx index 3ef42caf..850939b2 100644 --- a/packages/app-framework/src/frameworks/solid/providers/StoreProvider.tsx +++ b/packages/app-framework/src/frameworks/solid/providers/StoreProvider.tsx @@ -2,6 +2,7 @@ import { AdamStoreProvider, AiStoreProvider, AppStoreProvider, + AssistantStoreProvider, RouteStoreProvider, SpaceStoreProvider, TemplateStoreProvider, @@ -17,7 +18,9 @@ export default function StoreProvider(props: ParentProps) { - {props.children} + + {props.children} + diff --git a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx index 82d8c408..ec7e3233 100644 --- a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx +++ b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx @@ -8,6 +8,7 @@ import { useAdamStore, useAiStore, useAppStore, + useAssistantStore, useRouteStore, useSpaceStore, useTemplateStore, @@ -30,6 +31,7 @@ export default function TemplateProvider() { const adamStore = useAdamStore(); const aiStore = useAiStore(); const appStore = useAppStore(); + const assistantStore = useAssistantStore(); const spaceStore = useSpaceStore(); const themeStore = useThemeStore(); const templateStore = useTemplateStore(); @@ -72,6 +74,7 @@ export default function TemplateProvider() { adamStore, aiStore, appStore, + assistantStore, spaceStore, themeStore, templateStore, diff --git a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx index 20aa4fc9..cb6b561e 100644 --- a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx +++ b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx @@ -54,6 +54,9 @@ import type { ComponentRegistry } from '@we/schema-solid'; import { CesiumGlobe, CollapsibleSidebar, GraphWidget, mockGraphData, SpaceSidebarWidget } from '@we/widgets/solid'; import WeCube from '../components/3d/WeCube'; +import { AssistantConfigPanel } from '../components/assistant/AssistantConfigPanel'; +import { AssistantThreadList } from '../components/assistant/AssistantThreadList'; +import { AssistantThreadView } from '../components/assistant/AssistantThreadView'; import { AiPanel } from '../components/editor/AiPanel'; import { DesignToolbar } from '../components/editor/DesignToolbar'; import { RightPanelContainer } from '../components/editor/RightPanelContainer'; @@ -128,6 +131,11 @@ export const componentRegistry: ComponentRegistry = { // Shell DesignToolbar, + // AI assistant surface + AssistantThreadList, + AssistantThreadView, + AssistantConfigPanel, + // Testing RerenderLog, diff --git a/packages/app-framework/src/frameworks/solid/stores/AssistantStore.tsx b/packages/app-framework/src/frameworks/solid/stores/AssistantStore.tsx new file mode 100644 index 00000000..dd5f7257 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/stores/AssistantStore.tsx @@ -0,0 +1,668 @@ +/** + * AssistantStore — state + actions for the AD4M AI-assistant surface. + * + * This store is the front end for AI assistants whose replies are produced by the + * AD4M backend, NOT by any in-browser model call. The flow is: + * + * 1. The user writes a `Message` (role 'user') into the active thread's perspective. + * 2. The AD4M backend observes the perspective, runs the assistant, and writes back + * an assistant `Message` — creating it with `status: 'streaming'` and appending to + * its `content` (and `toolCalls`) as tokens arrive, then flipping `status` to + * 'complete'. Tool results may arrive as additional `role: 'tool'` messages. + * 3. This store subscribes to the thread's messages via the subject-class ORM + * (`Model.query(p).subscribe(cb)`), so the UI re-renders on every write — the token + * stream is simply the assistant message's `content` growing under an open + * subscription. Nothing here calls an LLM. + * + * Data lives in two perspectives: + * - Personal config (we-root): Assistant, Personality, Skill, McpServer. + * - Neighbourhood (current space, falling back to we-root): Thread, Message. + */ +import { Assistant, McpServer, Message, Personality, Skill, Thread } from '@we/models'; +import { + Accessor, + createContext, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + ParentProps, + useContext, +} from 'solid-js'; + +import { useAdamStore } from './AdamStore'; + +/** Minimal structural view of an AD4M `ModelQueryBuilder` — the three methods this store uses. */ +interface LiveQueryBuilder { + subscribe(cb: (rows: T[]) => void): Promise; + get(): Promise; + dispose(): void; +} + +/** A parsed tool call, as rendered by the thread view. Serialised into `Message.toolCalls` (JSON). */ +export interface ToolCall { + id?: string; + name: string; + /** Tool input arguments (any JSON value). */ + input?: unknown; + /** Tool result once the backend has run it (any JSON value). */ + result?: unknown; + /** 'pending' while running, 'complete' when a result is in, 'error' on failure. */ + status?: 'pending' | 'complete' | 'error'; +} + +export interface AssistantStore { + // --- Threads (current neighbourhood) --- + threads: Accessor; + activeThreadId: Accessor; + activeThread: Accessor; + selectThread: (id: string) => void; + createThread: (title?: string, assistantId?: string) => Promise; + deleteThread: (id: string) => Promise; + renameThread: (id: string, title: string) => Promise; + setThreadModel: (id: string, modelId: string) => Promise; + + // --- Messages (active thread) --- + messages: Accessor; + streamingMessageId: Accessor; + sendMessage: (text: string) => Promise; + + // --- Assistants (personal config) --- + assistants: Accessor; + activeAssistant: Accessor; + createAssistant: (data: { name: string; modelId?: string; systemPrompt?: string }) => Promise; + updateAssistant: ( + id: string, + updates: Partial<{ name: string; modelId: string; systemPrompt: string }>, + ) => Promise; + deleteAssistant: (id: string) => Promise; + /** Toggle a Personality / Skill / McpServer grant on an assistant (rewrites the JSON id list). */ + toggleGrant: (assistantId: string, field: 'personalityIds' | 'skillIds' | 'mcpServerIds', itemId: string) => Promise; + assistantHasGrant: (assistant: Assistant, field: 'personalityIds' | 'skillIds' | 'mcpServerIds', itemId: string) => boolean; + + // --- Personalities --- + personalities: Accessor; + createPersonality: (data: { name: string; body: string }) => Promise; + updatePersonality: (id: string, updates: Partial<{ name: string; body: string }>) => Promise; + deletePersonality: (id: string) => Promise; + + // --- Skills --- + skills: Accessor; + createSkill: (data: { name: string; description: string; body: string }) => Promise; + updateSkill: (id: string, updates: Partial<{ name: string; description: string; body: string }>) => Promise; + deleteSkill: (id: string) => Promise; + + // --- MCP servers --- + mcpServers: Accessor; + createMcpServer: (data: { + name: string; + transport: string; + url?: string; + command?: string; + auth?: string; + }) => Promise; + updateMcpServer: ( + id: string, + updates: Partial<{ name: string; transport: string; url: string; command: string; auth: string }>, + ) => Promise; + deleteMcpServer: (id: string) => Promise; + + // --- Models --- + models: Accessor; + refreshModels: () => Promise; +} + +/** Parse a JSON-encoded id list (used for Assistant grant fields). Tolerant of '' and bad JSON. */ +export function parseIdList(json: string | undefined): string[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } +} + +/** Parse a Message's toolCalls JSON into a ToolCall[] for rendering. Never throws. */ +export function parseToolCalls(json: string | undefined): ToolCall[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + if (Array.isArray(parsed)) return parsed as ToolCall[]; + return []; + } catch { + return []; + } +} + +/** + * Exported so tests and standalone harnesses can render assistant components with a mock + * store injected directly (``), without the full + * AD4M provider chain. + */ +export const AssistantContext = createContext(); + +export function AssistantStoreProvider(props: ParentProps) { + const adamStore = useAdamStore(); + + // Threads + messages live in the active neighbourhood, falling back to the personal + // root perspective so assistants work before any space is opened. + const threadPerspective = () => adamStore.currentPerspective() ?? adamStore.rootPerspective(); + // Assistant configuration is personal — always the root perspective. + const configPerspective = () => adamStore.rootPerspective(); + + const [threads, setThreads] = createSignal([]); + const [activeThreadId, setActiveThreadId] = createSignal(null); + const [messages, setMessages] = createSignal([]); + const [assistants, setAssistants] = createSignal([]); + const [personalities, setPersonalities] = createSignal([]); + const [skills, setSkills] = createSignal([]); + const [mcpServers, setMcpServers] = createSignal([]); + const [models, setModels] = createSignal([]); + + /** + * Open a live subscription. Returns a disposer. Falls back to a single `.get()` if the + * executor rejects the live subscription (e.g. live queries unavailable), so the UI still + * populates once even without push updates. + */ + function liveQuery(make: () => LiveQueryBuilder, onRows: (rows: T[]) => void): () => void { + let disposed = false; + const builder = make(); + builder + .subscribe((rows) => { + if (!disposed) onRows(rows); + }) + .catch((err) => { + console.warn('[AssistantStore] live subscribe failed; falling back to one-shot query', err); + if (disposed) return; + make() + .get() + .then((rows) => { + if (!disposed) onRows(rows); + }) + .catch((e) => console.warn('[AssistantStore] fallback query failed', e)); + }); + return () => { + disposed = true; + try { + builder.dispose(); + } catch { + /* dispose is best-effort */ + } + }; + } + + // Threads for the active neighbourhood, newest activity first. + createEffect(() => { + const p = threadPerspective(); + if (!p) { + setThreads([]); + return; + } + const dispose = liveQuery( + () => Thread.query(p).order({ updatedAt: 'DESC' }) as unknown as LiveQueryBuilder, + setThreads, + ); + onCleanup(dispose); + }); + + // Messages for the active thread, chronological. + createEffect(() => { + const p = threadPerspective(); + const tid = activeThreadId(); + if (!p || !tid) { + setMessages([]); + return; + } + const dispose = liveQuery( + () => Message.query(p).where({ threadId: tid }).order({ ts: 'ASC' }) as unknown as LiveQueryBuilder, + setMessages, + ); + onCleanup(dispose); + }); + + // Personal config: assistants, personalities, skills, MCP servers. + createEffect(() => { + const p = configPerspective(); + if (!p) { + setAssistants([]); + setPersonalities([]); + setSkills([]); + setMcpServers([]); + return; + } + const disposers = [ + liveQuery( + () => Assistant.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setAssistants, + ), + liveQuery( + () => Personality.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setPersonalities, + ), + liveQuery(() => Skill.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, setSkills), + liveQuery( + () => McpServer.query(p).order({ name: 'ASC' }) as unknown as LiveQueryBuilder, + setMcpServers, + ), + ]; + onCleanup(() => disposers.forEach((d) => d())); + }); + + // Keep an active thread selected: adopt the newest when none is chosen, and recover if the + // active thread is deleted out from under us. + createEffect(() => { + const list = threads(); + const active = activeThreadId(); + if (!active && list.length > 0) { + setActiveThreadId(list[0].id); + } else if (active && !list.some((t) => t.id === active)) { + setActiveThreadId(list[0]?.id ?? null); + } + }); + + const activeThread = createMemo(() => threads().find((t) => t.id === activeThreadId()) ?? null); + + const activeAssistant = createMemo(() => { + const thread = activeThread(); + const list = assistants(); + if (thread?.assistantId) { + const match = list.find((a) => a.id === thread.assistantId); + if (match) return match; + } + return list[0] ?? null; + }); + + const streamingMessageId = createMemo(() => messages().find((m) => m.status === 'streaming')?.id ?? null); + + // ---------------------------------------------------------------- Thread actions + + function selectThread(id: string) { + setActiveThreadId(id); + } + + async function createThread(title?: string, assistantId?: string): Promise { + const p = threadPerspective(); + if (!p) return null; + const now = new Date().toISOString(); + const aId = assistantId ?? activeAssistant()?.id ?? assistants()[0]?.id ?? ''; + try { + const thread = await Thread.create(p, { + title: title?.trim() || 'New chat', + assistantId: aId, + modelId: '', + createdAt: now, + updatedAt: now, + }); + setActiveThreadId(thread.id); + return thread.id; + } catch (err) { + console.error('[AssistantStore] createThread failed', err); + return null; + } + } + + async function deleteThread(id: string): Promise { + const p = threadPerspective(); + if (!p) return; + try { + const msgs = await Message.findAll(p, { where: { threadId: id } }); + for (const m of msgs) await m.delete().catch((e) => console.warn('[AssistantStore] delete message failed', e)); + const thread = threads().find((t) => t.id === id); + if (thread) await thread.delete(); + if (activeThreadId() === id) setActiveThreadId(null); + } catch (err) { + console.error('[AssistantStore] deleteThread failed', err); + } + } + + async function renameThread(id: string, title: string): Promise { + const thread = threads().find((t) => t.id === id); + if (!thread) return; + try { + thread.title = title.trim() || thread.title; + thread.updatedAt = new Date().toISOString(); + await thread.save(); + } catch (err) { + console.error('[AssistantStore] renameThread failed', err); + } + } + + async function setThreadModel(id: string, modelId: string): Promise { + const thread = threads().find((t) => t.id === id); + if (!thread) return; + try { + thread.modelId = modelId; + await thread.save(); + } catch (err) { + console.error('[AssistantStore] setThreadModel failed', err); + } + } + + // ---------------------------------------------------------------- Messaging + + /** + * Write a user message into the active thread's perspective. This is the ONLY write the UI + * makes on send — the assistant's reply comes from the AD4M backend, which observes the + * perspective and writes the response back (picked up by the messages subscription). + */ + async function sendMessage(text: string): Promise { + const p = threadPerspective(); + const tid = activeThreadId(); + const content = text.trim(); + if (!p || !tid || !content) return; + const now = new Date().toISOString(); + try { + await Message.create( + p, + { threadId: tid, role: 'user', content, ts: now, status: 'complete', toolCalls: '' }, + { parent: { model: Thread, id: tid } }, + ); + // Bump the thread's updatedAt so it sorts to the top and the backend has a clear "last active". + const thread = threads().find((t) => t.id === tid); + if (thread) { + thread.updatedAt = now; + await thread.save().catch((e) => console.warn('[AssistantStore] thread bump failed', e)); + } + } catch (err) { + console.error('[AssistantStore] sendMessage failed', err); + } + } + + // ---------------------------------------------------------------- Assistant CRUD + + async function createAssistant(data: { + name: string; + modelId?: string; + systemPrompt?: string; + }): Promise { + const p = configPerspective(); + if (!p) return null; + try { + const assistant = await Assistant.create(p, { + name: data.name.trim() || 'Assistant', + modelId: data.modelId ?? models()[0] ?? '', + systemPrompt: data.systemPrompt ?? '', + personalityIds: '[]', + skillIds: '[]', + mcpServerIds: '[]', + }); + return assistant.id; + } catch (err) { + console.error('[AssistantStore] createAssistant failed', err); + return null; + } + } + + async function updateAssistant( + id: string, + updates: Partial<{ name: string; modelId: string; systemPrompt: string }>, + ): Promise { + const assistant = assistants().find((a) => a.id === id); + if (!assistant) return; + try { + Object.assign(assistant, updates); + await assistant.save(); + } catch (err) { + console.error('[AssistantStore] updateAssistant failed', err); + } + } + + async function deleteAssistant(id: string): Promise { + const assistant = assistants().find((a) => a.id === id); + if (!assistant) return; + try { + await assistant.delete(); + } catch (err) { + console.error('[AssistantStore] deleteAssistant failed', err); + } + } + + function assistantHasGrant( + assistant: Assistant, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ): boolean { + return parseIdList(assistant[field]).includes(itemId); + } + + async function toggleGrant( + assistantId: string, + field: 'personalityIds' | 'skillIds' | 'mcpServerIds', + itemId: string, + ): Promise { + const assistant = assistants().find((a) => a.id === assistantId); + if (!assistant) return; + const current = parseIdList(assistant[field]); + const next = current.includes(itemId) ? current.filter((x) => x !== itemId) : [...current, itemId]; + try { + assistant[field] = JSON.stringify(next); + await assistant.save(); + } catch (err) { + console.error('[AssistantStore] toggleGrant failed', err); + } + } + + // ---------------------------------------------------------------- Personality CRUD + + async function createPersonality(data: { name: string; body: string }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await Personality.create(p, { name: data.name.trim() || 'Personality', body: data.body }); + } catch (err) { + console.error('[AssistantStore] createPersonality failed', err); + } + } + + async function updatePersonality(id: string, updates: Partial<{ name: string; body: string }>): Promise { + const item = personalities().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updatePersonality failed', err); + } + } + + async function deletePersonality(id: string): Promise { + const item = personalities().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deletePersonality failed', err); + } + } + + // ---------------------------------------------------------------- Skill CRUD + + async function createSkill(data: { name: string; description: string; body: string }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await Skill.create(p, { + name: data.name.trim() || 'Skill', + description: data.description, + body: data.body, + }); + } catch (err) { + console.error('[AssistantStore] createSkill failed', err); + } + } + + async function updateSkill( + id: string, + updates: Partial<{ name: string; description: string; body: string }>, + ): Promise { + const item = skills().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updateSkill failed', err); + } + } + + async function deleteSkill(id: string): Promise { + const item = skills().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deleteSkill failed', err); + } + } + + // ---------------------------------------------------------------- MCP server CRUD + + async function createMcpServer(data: { + name: string; + transport: string; + url?: string; + command?: string; + auth?: string; + }): Promise { + const p = configPerspective(); + if (!p) return; + try { + await McpServer.create(p, { + name: data.name.trim() || 'MCP server', + transport: data.transport || 'stdio', + url: data.url ?? '', + command: data.command ?? '', + auth: data.auth ?? '', + }); + } catch (err) { + console.error('[AssistantStore] createMcpServer failed', err); + } + } + + async function updateMcpServer( + id: string, + updates: Partial<{ name: string; transport: string; url: string; command: string; auth: string }>, + ): Promise { + const item = mcpServers().find((x) => x.id === id); + if (!item) return; + try { + Object.assign(item, updates); + await item.save(); + } catch (err) { + console.error('[AssistantStore] updateMcpServer failed', err); + } + } + + async function deleteMcpServer(id: string): Promise { + const item = mcpServers().find((x) => x.id === id); + if (!item) return; + try { + await item.delete(); + } catch (err) { + console.error('[AssistantStore] deleteMcpServer failed', err); + } + } + + // ---------------------------------------------------------------- Models + + /** + * Discover model ids from the AD4M backend's OpenAI-compatible `/v1/models` endpoint. + * Best-effort: on web the executor may not be directly reachable, so on any failure we fall + * back to the union of model ids already referenced by assistants. The UI additionally lets a + * user type a model id, so an empty list never blocks configuration. + */ + async function refreshModels(): Promise { + const port = adamStore.ad4mPort(); + const token = adamStore.ad4mToken(); + if (port) { + try { + const res = await fetch(`http://localhost:${port}/v1/models`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }); + if (res.ok) { + const json = (await res.json()) as { data?: Array<{ id?: string }>; models?: Array<{ id?: string; name?: string }> }; + const ids = Array.isArray(json?.data) + ? json.data.map((m) => m.id).filter((x): x is string => !!x) + : Array.isArray(json?.models) + ? json.models.map((m) => m.id ?? m.name).filter((x): x is string => !!x) + : []; + if (ids.length) { + setModels(ids); + return; + } + } + } catch (err) { + console.warn('[AssistantStore] /v1/models unavailable; using referenced models', err); + } + } + const used = Array.from(new Set(assistants().map((a) => a.modelId).filter((x): x is string => !!x))); + setModels(used); + } + + let modelFallbackTried = false; + onMount(() => { + void refreshModels(); + }); + // Once assistants load, retry discovery a single time if we still have no models — handles + // the executor port not being ready at mount. Guarded so a persistently empty /v1/models + // (which re-sets an empty array each call) can't spin this into a refetch loop. + createEffect(() => { + if (!modelFallbackTried && models().length === 0 && assistants().length > 0) { + modelFallbackTried = true; + void refreshModels(); + } + }); + + const store: AssistantStore = { + threads, + activeThreadId, + activeThread, + selectThread, + createThread, + deleteThread, + renameThread, + setThreadModel, + + messages, + streamingMessageId, + sendMessage, + + assistants, + activeAssistant, + createAssistant, + updateAssistant, + deleteAssistant, + toggleGrant, + assistantHasGrant, + + personalities, + createPersonality, + updatePersonality, + deletePersonality, + + skills, + createSkill, + updateSkill, + deleteSkill, + + mcpServers, + createMcpServer, + updateMcpServer, + deleteMcpServer, + + models, + refreshModels, + }; + + return {props.children}; +} + +export function useAssistantStore(): AssistantStore { + const ctx = useContext(AssistantContext); + if (!ctx) throw new Error('useAssistantStore must be used within an AssistantStoreProvider'); + return ctx; +} + +export default AssistantStoreProvider; diff --git a/packages/app-framework/src/frameworks/solid/stores/index.ts b/packages/app-framework/src/frameworks/solid/stores/index.ts index dc986ddc..f6785d72 100644 --- a/packages/app-framework/src/frameworks/solid/stores/index.ts +++ b/packages/app-framework/src/frameworks/solid/stores/index.ts @@ -4,5 +4,13 @@ export { type ThemeStore, type EditingTheme, useThemeStore, ThemeStoreProvider } export { type TemplateStore, useTemplateStore, TemplateStoreProvider } from './TemplateStore'; export { type RouteStore, useRouteStore, RouteStoreProvider } from './RouteStore'; export { type AiStore, useAiStore, AiStoreProvider } from './AiStore'; +export { + type AssistantStore, + type ToolCall, + useAssistantStore, + AssistantStoreProvider, + parseIdList, + parseToolCalls, +} from './AssistantStore'; export { type AppStore, useAppStore, AppStoreProvider } from './AppStore'; export { useShellRouteStore, ShellRouteStoreProvider, ShellRouterRoot } from './ShellRouteStore'; diff --git a/packages/app-framework/src/frameworks/solid/types.ts b/packages/app-framework/src/frameworks/solid/types.ts index a73d2e0a..7064b02e 100644 --- a/packages/app-framework/src/frameworks/solid/types.ts +++ b/packages/app-framework/src/frameworks/solid/types.ts @@ -1,5 +1,14 @@ import type { Ad4mModel } from '@coasys/ad4m'; -import type { AdamStore, AiStore, AppStore, RouteStore, SpaceStore, TemplateStore, ThemeStore } from '@solid/stores'; +import type { + AdamStore, + AiStore, + AppStore, + AssistantStore, + RouteStore, + SpaceStore, + TemplateStore, + ThemeStore, +} from '@solid/stores'; import type { QueryAdapter, RendererStores } from '@we/schema-shared'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -43,6 +52,7 @@ export interface Stores extends RendererStores { adamStore: AdamStore; aiStore: AiStore; appStore: AppStore; + assistantStore: AssistantStore; spaceStore: SpaceStore; themeStore: ThemeStore; templateStore: TemplateStore; diff --git a/packages/app-framework/src/shared/schemas/index.ts b/packages/app-framework/src/shared/schemas/index.ts index fabe9804..a7a26ffa 100644 --- a/packages/app-framework/src/shared/schemas/index.ts +++ b/packages/app-framework/src/shared/schemas/index.ts @@ -4,6 +4,7 @@ export { twitterTemplate } from './TwitterTemplate.schema'; // Shell export { + aiAssistantTemplate, bootScreen, createTestStore, landingPageTemplate, diff --git a/packages/app-framework/src/shared/schemas/shell/AiAssistant.schema.ts b/packages/app-framework/src/shared/schemas/shell/AiAssistant.schema.ts new file mode 100644 index 00000000..3b6fb499 --- /dev/null +++ b/packages/app-framework/src/shared/schemas/shell/AiAssistant.schema.ts @@ -0,0 +1,49 @@ +/** + * AI Assistant — shell view for the AD4M AI-assistant surface. + * + * Composed declaratively from three registered components (all backed by `assistantStore`): + * - AssistantThreadList — threads in the current neighbourhood + create-thread + * - AssistantThreadView — active thread: messages, tool calls, live stream, composer + * - AssistantConfigPanel — assistants, model selector, personalities, skills, MCP servers + * + * Opened via `templateStore.openShellView('ai-assistant')` (see TemplateLayout's shellViews + * registry and the sidebar entry). Assistant replies are written into the perspective by the + * AD4M backend and surface here through the store's live subscriptions — this UI never calls + * a model itself. + */ +import type { TemplateSchema } from '@we/schema-shared'; + +export const aiAssistantTemplate: TemplateSchema = { + meta: { name: 'AI Assistant', description: 'Chat with AD4M AI assistants', icon: 'sparkle' }, + type: 'Column', + props: { width: '100%', height: '100%', bg: 'neutral-0' }, + children: [ + // Header + { + type: 'Row', + props: { + ay: 'center', + gap: '200', + px: '400', + py: '300', + borderBottom: '1px solid neutral-200', + styles: { 'flex-shrink': '0' }, + }, + children: [ + { type: 'we-icon', props: { name: 'sparkle', size: 'md' } }, + { type: 'we-text', props: { variant: 'heading-sm', fontWeight: '600' }, children: ['AI Assistant'] }, + ], + }, + + // Body: thread list | thread view | config panel + { + type: 'Row', + props: { flex: '1', width: '100%', styles: { 'min-height': '0' } }, + children: [ + { type: 'AssistantThreadList' }, + { type: 'AssistantThreadView' }, + { type: 'AssistantConfigPanel' }, + ], + }, + ], +}; diff --git a/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts b/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts index 9a39d8c7..08b92540 100644 --- a/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts +++ b/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts @@ -71,6 +71,18 @@ export const sidebar: SchemaNode = { ], }, + // AI Assistant + { + id: 'ai-assistant', + icon: 'sparkle', + label: 'AI Assistant', + active: { $eq: [{ $store: 'templateStore.activeShellView' }, 'ai-assistant'] }, + onClick: [ + { $action: 'appStore.deactivateApp' }, + { $action: 'templateStore.openShellView', args: ['ai-assistant'] }, + ], + }, + // Spaces { type: 'group', diff --git a/packages/app-framework/src/shared/schemas/shell/index.ts b/packages/app-framework/src/shared/schemas/shell/index.ts index 416e8324..3fc9576b 100644 --- a/packages/app-framework/src/shared/schemas/shell/index.ts +++ b/packages/app-framework/src/shared/schemas/shell/index.ts @@ -1,3 +1,4 @@ +export { aiAssistantTemplate } from './AiAssistant.schema'; export { bootScreen } from './BootScreen.schema'; export { templateEditor } from './TemplateEditor.schema'; export { landingPageTemplate } from './about'; diff --git a/packages/app-framework/src/shared/sdnaModels.ts b/packages/app-framework/src/shared/sdnaModels.ts index 05fdd4fb..d86b9a90 100644 --- a/packages/app-framework/src/shared/sdnaModels.ts +++ b/packages/app-framework/src/shared/sdnaModels.ts @@ -1,6 +1,7 @@ import { Ad4mModel, type LinkExpression, LinkQuery, Literal, PerspectiveProxy } from '@coasys/ad4m'; import { AgentSettings, + Assistant, AudioBlock, CalloutBlock, ChatMessage, @@ -14,8 +15,12 @@ import { ImageBlock, LinkBlock, LocationBlock, + McpServer, + Message, + Personality, Signal, SignalType, + Skill, Space, SpaceTemplatePreference, TagBlock, @@ -23,6 +28,7 @@ import { Template, TextBlock, Theme, + Thread, VideoBlock, WeNode, } from '@we/models'; @@ -40,6 +46,16 @@ export const ROOT_MODELS = [ Template, Theme, LocationBlock, + // AI assistant configuration (personal): reusable assistants and the + // personalities / skills / MCP servers granted to them. + Assistant, + Personality, + Skill, + McpServer, + // Threads + messages so a personal (non-shared) perspective can also host + // assistant conversations, not only shared neighbourhoods. + Thread, + Message, ] as const; export function getModelTargetClass(m: typeof Ad4mModel): string | undefined { @@ -146,6 +162,9 @@ export const SPACE_MODELS = [ TaskBlock, TextBlock, VideoBlock, + // Assistant conversations live in the neighbourhood they were created in. + Thread, + Message, ] as const; /** diff --git a/packages/app-framework/tests/assistant/AssistantConfigPanel.test.tsx b/packages/app-framework/tests/assistant/AssistantConfigPanel.test.tsx new file mode 100644 index 00000000..ced39740 --- /dev/null +++ b/packages/app-framework/tests/assistant/AssistantConfigPanel.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { AssistantContext } from '@solid/stores/AssistantStore'; +import { AssistantConfigPanel } from '@solid/components/assistant/AssistantConfigPanel'; +import { cleanup, render, screen } from '@solidjs/testing-library'; +import type { JSX } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { makeMockStore } from './mockStore'; + +afterEach(cleanup); + +function renderPanel(store = makeMockStore()) { + return render(() => ( + + {() as JSX.Element} + + )); +} + +describe('AssistantConfigPanel', () => { + it('renders the four config tabs', () => { + renderPanel(); + for (const id of ['assistants', 'personalities', 'skills', 'mcp']) { + expect(screen.getByTestId(`config-tab-${id}`)).toBeTruthy(); + } + }); + + it('shows the assistant editor with model + grant groups on the default tab', () => { + const { container } = renderPanel(); + // Assistant picker lists both assistants. + const options = Array.from(container.querySelectorAll('select option')).map((o) => o.textContent); + expect(options).toContain('Research Assistant'); + expect(options).toContain('Coding Assistant'); + // All three grant groups render, each listing its grantable items by name. + expect(screen.getByText('MCP servers')).toBeTruthy(); // unique heading (tab label is "MCP") + expect(screen.getByText('Friendly')).toBeTruthy(); // personality grant row + expect(screen.getByText('web-search')).toBeTruthy(); // skill grant row + expect(screen.getByText('filesystem')).toBeTruthy(); // mcp server grant row + }); + + it('toggles a personality grant on the active assistant', () => { + const store = makeMockStore(); + renderPanel(store); + // The Friendly personality appears as a grantable row in the assistant editor. + (screen.getByText('Friendly') as HTMLElement).click(); + expect(store.toggleGrant).toHaveBeenCalledWith('a1', 'personalityIds', 'p1'); + }); + + it('switches to the Personalities tab and lists personalities', () => { + renderPanel(); + (screen.getByTestId('config-tab-personalities') as HTMLElement).click(); + expect(screen.getByText('Terse')).toBeTruthy(); + // Body preview text from the fixture. + expect(screen.getByText('Minimal words. No filler.')).toBeTruthy(); + }); + + it('switches to the Skills tab and lists skills', () => { + renderPanel(); + (screen.getByTestId('config-tab-skills') as HTMLElement).click(); + expect(screen.getByText('web-search')).toBeTruthy(); + expect(screen.getByText('calculator')).toBeTruthy(); + }); + + it('switches to the MCP tab and lists servers with a transport control', () => { + const { container } = renderPanel(); + (screen.getByTestId('config-tab-mcp') as HTMLElement).click(); + expect(screen.getByText('filesystem')).toBeTruthy(); + // Transport is present in the header. + expect(container.querySelector('select')).toBeTruthy(); + }); + + it('lists the discovered models in the per-thread override selector', () => { + const { container } = renderView(); + const options = Array.from(container.querySelectorAll('select option')).map((o) => o.textContent); + expect(options.some((t) => t?.includes('llama-3.1-8b'))).toBe(true); + expect(options.some((t) => t?.includes('qwen2.5-coder'))).toBe(true); + }); + + it('writes a user message when the composer is submitted', () => { + const store = makeMockStore(); + renderView(store); + const input = screen.getByTestId('composer-input'); + input.dispatchEvent(new CustomEvent('input', { detail: 'What is the forecast?', bubbles: true })); + (screen.getByTestId('composer-send') as HTMLElement).click(); + expect(store.sendMessage).toHaveBeenCalledWith('What is the forecast?'); + }); + + it('shows a placeholder when no thread is selected', () => { + const store = makeMockStore({ activeThread: () => null, activeThreadId: () => null }); + renderView(store); + expect(screen.getByTestId('thread-view-empty')).toBeTruthy(); + expect(screen.getByText('No conversation selected')).toBeTruthy(); + }); + + it('renders a streaming assistant message with a live indicator', () => { + const streaming = [ + sampleMessages[0], + { ...sampleMessages[3], id: 'sm', content: 'Working on it', status: 'streaming' }, + ]; + const store = makeMockStore({ + messages: () => streaming as typeof sampleMessages, + streamingMessageId: () => 'sm', + }); + renderView(store); + expect(screen.getByText('streaming…')).toBeTruthy(); + }); +}); diff --git a/packages/app-framework/tests/assistant/mockStore.ts b/packages/app-framework/tests/assistant/mockStore.ts new file mode 100644 index 00000000..78a41c77 --- /dev/null +++ b/packages/app-framework/tests/assistant/mockStore.ts @@ -0,0 +1,170 @@ +/** + * Shared mock AssistantStore + representative fixtures for the assistant-component tests + * and the screenshot harness. Plain objects cast to the model types — the components only + * read fields, so no real AD4M perspective/instances are needed. + */ +import type { AssistantStore } from '@solid/stores/AssistantStore'; +import { parseIdList } from '@solid/stores/AssistantStore'; +import type { Assistant, McpServer, Message, Personality, Skill, Thread } from '@we/models'; +import { vi } from 'vitest'; + +function model(o: Record): T { + return o as unknown as T; +} + +export const sampleThreads: Thread[] = [ + model({ + id: 't1', + title: 'Weather in Melbourne', + assistantId: 'a1', + modelId: '', + createdAt: '2026-07-28T01:00:00.000Z', + updatedAt: '2026-07-28T02:00:00.000Z', + }), + model({ + id: 't2', + title: 'Refactor ideas', + assistantId: 'a2', + modelId: 'qwen2.5-coder', + createdAt: '2026-07-27T01:00:00.000Z', + updatedAt: '2026-07-27T05:00:00.000Z', + }), +]; + +export const toolCallsJson = JSON.stringify([ + { + id: 'call_1', + name: 'get_weather', + input: { city: 'Melbourne', units: 'metric' }, + result: { tempC: 14, sky: 'cloudy', wind: '12 km/h' }, + status: 'complete', + }, +]); + +export const sampleMessages: Message[] = [ + model({ + id: 'm1', + threadId: 't1', + role: 'user', + content: 'What is the weather in Melbourne right now?', + toolCalls: '', + ts: '2026-07-28T02:00:01.000Z', + status: 'complete', + }), + model({ + id: 'm2', + threadId: 't1', + role: 'assistant', + content: 'Let me check the current conditions for you.', + toolCalls: toolCallsJson, + ts: '2026-07-28T02:00:02.000Z', + status: 'complete', + }), + model({ + id: 'm3', + threadId: 't1', + role: 'tool', + content: '{\n "tempC": 14,\n "sky": "cloudy",\n "wind": "12 km/h"\n}', + toolCalls: '', + ts: '2026-07-28T02:00:03.000Z', + status: 'complete', + }), + model({ + id: 'm4', + threadId: 't1', + role: 'assistant', + content: 'It is currently **14°C and cloudy** in Melbourne, with wind around 12 km/h.', + toolCalls: '', + ts: '2026-07-28T02:00:04.000Z', + status: 'complete', + }), +]; + +export const sampleAssistants: Assistant[] = [ + model({ + id: 'a1', + name: 'Research Assistant', + modelId: 'llama-3.1-8b', + systemPrompt: 'Answer concisely and cite sources.', + personalityIds: JSON.stringify(['p1']), + skillIds: JSON.stringify(['s1']), + mcpServerIds: JSON.stringify(['mcp1']), + }), + model({ + id: 'a2', + name: 'Coding Assistant', + modelId: 'qwen2.5-coder', + systemPrompt: '', + personalityIds: '[]', + skillIds: '[]', + mcpServerIds: '[]', + }), +]; + +export const samplePersonalities: Personality[] = [ + model({ id: 'p1', name: 'Friendly', body: 'Warm, encouraging, plain language.' }), + model({ id: 'p2', name: 'Terse', body: 'Minimal words. No filler.' }), +]; + +export const sampleSkills: Skill[] = [ + model({ id: 's1', name: 'web-search', description: 'Search the web for current info', body: '...' }), + model({ id: 's2', name: 'calculator', description: 'Evaluate arithmetic', body: '...' }), +]; + +export const sampleMcpServers: McpServer[] = [ + model({ + id: 'mcp1', + name: 'filesystem', + transport: 'stdio', + url: '', + command: 'npx -y @modelcontextprotocol/server-filesystem', + auth: '', + }), +]; + +export const sampleModels = ['llama-3.1-8b', 'qwen2.5-coder', 'gpt-oss-20b']; + +/** Build a mock AssistantStore. All accessors return fixtures; all actions are spies. */ +export function makeMockStore(overrides: Partial = {}): AssistantStore { + const base: AssistantStore = { + threads: () => sampleThreads, + activeThreadId: () => 't1', + activeThread: () => sampleThreads[0], + selectThread: vi.fn(), + createThread: vi.fn(async () => 't-new'), + deleteThread: vi.fn(async () => {}), + renameThread: vi.fn(async () => {}), + setThreadModel: vi.fn(async () => {}), + + messages: () => sampleMessages, + streamingMessageId: () => null, + sendMessage: vi.fn(async () => {}), + + assistants: () => sampleAssistants, + activeAssistant: () => sampleAssistants[0], + createAssistant: vi.fn(async () => 'a-new'), + updateAssistant: vi.fn(async () => {}), + deleteAssistant: vi.fn(async () => {}), + toggleGrant: vi.fn(async () => {}), + assistantHasGrant: (assistant, field, itemId) => parseIdList(assistant[field]).includes(itemId), + + personalities: () => samplePersonalities, + createPersonality: vi.fn(async () => {}), + updatePersonality: vi.fn(async () => {}), + deletePersonality: vi.fn(async () => {}), + + skills: () => sampleSkills, + createSkill: vi.fn(async () => {}), + updateSkill: vi.fn(async () => {}), + deleteSkill: vi.fn(async () => {}), + + mcpServers: () => sampleMcpServers, + createMcpServer: vi.fn(async () => {}), + updateMcpServer: vi.fn(async () => {}), + deleteMcpServer: vi.fn(async () => {}), + + models: () => sampleModels, + refreshModels: vi.fn(async () => {}), + }; + return { ...base, ...overrides }; +} diff --git a/packages/app-framework/vitest.config.ts b/packages/app-framework/vitest.config.ts index 71ae0dc1..031bd4b8 100644 --- a/packages/app-framework/vitest.config.ts +++ b/packages/app-framework/vitest.config.ts @@ -1,9 +1,51 @@ +import { fileURLToPath } from 'node:url'; + +import solidPlugin from 'vite-plugin-solid'; import { defineConfig } from 'vitest/config'; +const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + export default defineConfig({ test: { - globals: true, - include: ['tests/**/*.test.{ts,tsx}'], + // Two projects so the DOM/Solid setup needed to render components (browser resolve + // conditions, jsdom, the solid-js transform) is scoped to the assistant component tests + // and never touches the existing node/logic tests — whose seed code imports Node built-ins + // that the `browser` condition would otherwise externalize. + projects: [ + { + // Existing logic/seed tests — node resolution, no DOM. + test: { + name: 'node', + globals: true, + include: ['tests/**/*.test.{ts,tsx}'], + exclude: ['tests/assistant/**', '**/node_modules/**'], + }, + }, + { + // Assistant component tests — real Solid rendering in jsdom. + plugins: [solidPlugin()], + resolve: { + conditions: ['development', 'browser'], + // A single solid-js instance across the test-transformed source and the prebuilt + // design-system dist — otherwise Solid event delegation and reactive context break. + dedupe: ['solid-js', 'solid-js/web', 'solid-js/store'], + alias: { + '@shared': r('./src/shared'), + '@solid': r('./src/frameworks/solid'), + }, + }, + test: { + name: 'dom', + globals: true, + environment: 'jsdom', + include: ['tests/assistant/**/*.test.{ts,tsx}'], + // Transform the prebuilt @we/* + solid packages through vite (rather than loading them + // as external node deps) so they resolve the same deduped solid-js instance as the + // test-compiled source. + server: { deps: { inline: [/@we\//, /solid-js/, /@solidjs\//] } }, + }, + }, + ], coverage: { provider: 'v8', reporter: ['text', 'lcov'], diff --git a/packages/models/src/entities/Assistant.ts b/packages/models/src/entities/Assistant.ts new file mode 100644 index 00000000..a68326e4 --- /dev/null +++ b/packages/models/src/entities/Assistant.ts @@ -0,0 +1,43 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; + +/** + * An AI assistant configuration. Lives in the personal (we-root) perspective and + * is referenced by `Thread.assistantId` when a conversation is opened with it. + * + * `personalityIds`, `skillIds` and `mcpServerIds` are JSON-encoded `string[]`s of + * the ids of the granted {@link Personality} / {@link Skill} / {@link McpServer} + * records (all in the same perspective). JSON arrays — rather than AD4M HasMany + * link relations — keep the grant set a single atomic property that any backend + * can read/write in one write, mirroring how `AgentSettings.perspectiveOrder` + * stores an id list. Use the empty string or `"[]"` for "none". + */ +@Model({ name: 'Assistant' }) +export class Assistant extends WeNode { + @Flag({ through: 'we://flag', value: 'we://assistant' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + /** Model identifier the assistant runs on (e.g. an id from the backend's /v1/models). */ + @Property({ through: 'we://model_id' }) + modelId: string = ''; + + /** Optional base system prompt, prepended ahead of any granted personalities. */ + @Property({ through: 'we://system_prompt' }) + systemPrompt: string = ''; + + /** JSON-encoded string[] of granted Personality ids. */ + @Property({ through: 'we://personality_ids' }) + personalityIds: string = ''; + + /** JSON-encoded string[] of granted Skill ids. */ + @Property({ through: 'we://skill_ids' }) + skillIds: string = ''; + + /** JSON-encoded string[] of granted McpServer ids. */ + @Property({ through: 'we://mcp_server_ids' }) + mcpServerIds: string = ''; +} diff --git a/packages/models/src/entities/McpServer.ts b/packages/models/src/entities/McpServer.ts new file mode 100644 index 00000000..ba43906f --- /dev/null +++ b/packages/models/src/entities/McpServer.ts @@ -0,0 +1,35 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; + +/** + * An MCP (Model Context Protocol) server an AI assistant can be granted access + * to. The AD4M backend is responsible for connecting to it; this record only + * stores the connection descriptor. + * + * `transport` is one of 'stdio' | 'sse' | 'http' | 'websocket'. + * `url` is used for network transports (sse/http/websocket); `command` for + * 'stdio'. `auth` holds an optional JSON-encoded auth descriptor (e.g. a bearer + * token or header map) — kept as an opaque string so the shape stays open. + * Lives in the personal (we-root) perspective. + */ +@Model({ name: 'McpServer' }) +export class McpServer extends WeNode { + @Flag({ through: 'we://flag', value: 'we://mcp_server' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://transport' }) + transport: string = 'stdio'; + + @Property({ through: 'we://url' }) + url: string = ''; + + @Property({ through: 'we://command' }) + command: string = ''; + + @Property({ through: 'we://auth' }) + auth: string = ''; +} diff --git a/packages/models/src/entities/Message.ts b/packages/models/src/entities/Message.ts new file mode 100644 index 00000000..c98cd7b4 --- /dev/null +++ b/packages/models/src/entities/Message.ts @@ -0,0 +1,46 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; + +/** + * A single message in an assistant {@link Thread}. Belongs to the same + * (neighbourhood) perspective as its thread. + * + * `threadId` is the canonical join back to the owning Thread and is what the UI + * queries on — every message MUST carry it. The Thread also links messages via a + * HasMany relation for ORM navigation, but `threadId` is the source of truth for + * "which thread is this in", so a backend only needs to set this one scalar. + * + * `role` is 'user' | 'assistant' | 'tool' | 'system'. + * `toolCalls` is an optional JSON-encoded array describing tool invocations/results + * (see the AssistantStore doc for the shape the UI renders). + * `ts` is an ISO-8601 timestamp (lexicographically sortable → chronological order). + * `status` is '' | 'streaming' | 'complete' | 'error'. While an assistant reply is + * being produced the backend sets 'streaming' and appends to `content`; the UI + * subscription re-renders on each update, giving a live token stream. The backend + * flips it to 'complete' when done. + */ +@Model({ name: 'Message' }) +export class Message extends WeNode { + @Flag({ through: 'we://flag', value: 'we://message' }) + flag: string = ''; + + @Property({ through: 'we://thread_id' }) + threadId: string = ''; + + @Property({ through: 'we://role' }) + role: string = ''; + + @Property({ through: 'we://content' }) + content: string = ''; + + /** Optional JSON-encoded array of tool invocations/results. */ + @Property({ through: 'we://tool_calls' }) + toolCalls: string = ''; + + @Property({ through: 'we://ts' }) + ts: string = ''; + + @Property({ through: 'we://status' }) + status: string = ''; +} diff --git a/packages/models/src/entities/Personality.ts b/packages/models/src/entities/Personality.ts new file mode 100644 index 00000000..33d7ea6a --- /dev/null +++ b/packages/models/src/entities/Personality.ts @@ -0,0 +1,20 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; + +/** + * A reusable personality an AI assistant can adopt — a named block of guidance + * text merged into the assistant's system prompt. Lives in the personal + * (we-root) perspective and can be granted to any assistant. + */ +@Model({ name: 'Personality' }) +export class Personality extends WeNode { + @Flag({ through: 'we://flag', value: 'we://personality' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://body' }) + body: string = ''; +} diff --git a/packages/models/src/entities/Skill.ts b/packages/models/src/entities/Skill.ts new file mode 100644 index 00000000..09a10da1 --- /dev/null +++ b/packages/models/src/entities/Skill.ts @@ -0,0 +1,23 @@ +import { Flag, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; + +/** + * A named capability an AI assistant can be granted. `body` holds the skill's + * instructions/definition (interpreted by the AD4M backend); `description` is a + * short human summary. Lives in the personal (we-root) perspective. + */ +@Model({ name: 'Skill' }) +export class Skill extends WeNode { + @Flag({ through: 'we://flag', value: 'we://skill' }) + flag: string = ''; + + @Property({ through: 'we://name' }) + name: string = ''; + + @Property({ through: 'we://description' }) + description: string = ''; + + @Property({ through: 'we://body' }) + body: string = ''; +} diff --git a/packages/models/src/entities/Thread.ts b/packages/models/src/entities/Thread.ts new file mode 100644 index 00000000..de6b23dc --- /dev/null +++ b/packages/models/src/entities/Thread.ts @@ -0,0 +1,41 @@ +import { Flag, HasMany, HasManyMethods, Model, Property } from '@coasys/ad4m'; + +import { WeNode } from '../WeNode'; +import { Message } from './Message'; + +/** + * An assistant conversation. Belongs to a neighbourhood perspective — the space + * it was created in — so a neighbourhood can hold many threads. `assistantId` + * references an {@link Assistant} in the personal (we-root) perspective; + * `modelId` is an optional per-thread model override (falls back to the + * assistant's `modelId` when empty). + * + * Messages are queried by `Message.threadId` (the canonical join); the HasMany + * relation here exists for ORM navigation and parent-linked creation. + */ +@Model({ name: 'Thread' }) +export class Thread extends WeNode { + @Flag({ through: 'we://flag', value: 'we://thread' }) + flag: string = ''; + + @Property({ through: 'we://title' }) + title: string = ''; + + @Property({ through: 'we://assistant_id' }) + assistantId: string = ''; + + /** Optional per-thread model override; empty → use the assistant's modelId. */ + @Property({ through: 'we://model_id' }) + modelId: string = ''; + + @Property({ through: 'we://created_at' }) + createdAt: string = ''; + + @Property({ through: 'we://updated_at' }) + updatedAt: string = ''; + + @HasMany(() => Message, { through: 'we://message' }) + messages: Message[] = []; +} + +export interface Thread extends HasManyMethods<'messages'> {} diff --git a/packages/models/src/entities/index.ts b/packages/models/src/entities/index.ts index 2d78e883..8cb81d0a 100644 --- a/packages/models/src/entities/index.ts +++ b/packages/models/src/entities/index.ts @@ -1,7 +1,13 @@ export { AgentSettings } from './AgentSettings'; +export { Assistant } from './Assistant'; export { ChatMessage } from './ChatMessage'; export { ChatSession } from './ChatSession'; +export { McpServer } from './McpServer'; +export { Message } from './Message'; +export { Personality } from './Personality'; export { Signal } from './Signal'; +export { Skill } from './Skill'; +export { Thread } from './Thread'; export { SignalType } from './SignalType'; export type { SignalMode, SignalAggregate, SignalSemantic } from './SignalType'; export { Space } from './Space'; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index d3a83668..2875d8cb 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,14 +1,20 @@ export { WeNode } from './WeNode'; export { AgentSettings, + Assistant, ChatMessage, ChatSession, + McpServer, + Message, + Personality, Signal, SignalType, + Skill, Space, SpaceTemplatePreference, Template, Theme, + Thread, } from './entities'; export type { ThemeData } from './entities/Theme'; export { modelToThemeData } from './entities/Theme'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc38554a..84b2b56f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,9 @@ importers: specifier: ^4.1.11 version: 4.4.3 devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(@solidjs/router@0.15.4(solid-js@1.9.14))(solid-js@1.9.14) '@types/node': specifier: ^22.10.2 version: 22.20.0 @@ -490,9 +493,15 @@ importers: '@we/cli': specifier: workspace:* version: link:../cli + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.12 + version: 2.11.12(solid-js@1.9.14)(vite@7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) vitest: specifier: ^4.0.15 version: 4.1.10(@types/node@22.20.0)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) @@ -11794,6 +11803,19 @@ snapshots: transitivePeerDependencies: - supports-color + vite-plugin-solid@2.11.12(solid-js@1.9.14)(vite@7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@types/babel__core': 7.20.5 + babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.14) + merge-anything: 5.1.7 + solid-js: 1.9.14 + solid-refresh: 0.6.3(solid-js@1.9.14) + vite: 7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0) + vitefu: 1.1.3(vite@7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + vite-plugin-solid@2.11.12(solid-js@1.9.14)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.7 @@ -11883,6 +11905,10 @@ snapshots: optionalDependencies: vite: 6.4.3(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0) + vitefu@1.1.3(vite@7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)): + optionalDependencies: + vite: 7.3.6(@types/node@22.20.0)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0) + vitefu@1.1.3(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)): optionalDependencies: vite: 7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)