From bb647379e1dd3f19d560b29732acda15532463df Mon Sep 17 00:00:00 2001 From: jhweir Date: Sun, 26 Jul 2026 21:18:17 +0100 Subject: [PATCH 01/12] feat(schema-shared): scope resolution and a condition/value editing model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two framework-neutral pieces the visual editor needs to offer pickers instead of raw JSON. They live here rather than in the editor because they encode renderer semantics — what is in context at a node, and what the operator tokens mean — and so they can be unit-tested without Solid. scope.ts — getScopeAtNode() walks root→node and collects every reference the renderer would have in context there: $each/$single iteration variables, $local fields from $localState/$queries ancestors, store state, and the neutral context refs. Item fields are inferred from whatever backs `items`: a $query entity resolves through the model registry, a $store path through the store's declared properties, a literal array through its first object's keys. Groups are ordered nearest-scope-first, since $local and iteration variables are used about as often as $store in real templates. conditionModel.ts — a strict parse/serialize grammar over the boolean operators, plus the value-position helpers (parseValue / parseValueIf) for `children` entries and value-producing props. Strictness is the point: anything not representable exactly returns null so callers fall back to raw JSON, and the builder never silently rewrites an expression it only partly understood. Measured over every $if condition in the built-in templates, 1780/1831 (97.2%) are representable; the rest are $find with a where clause, $not over a comparison, and $concat operands. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared/src/conditionModel.test.ts | 243 +++++++++++ .../shared/src/conditionModel.ts | 298 ++++++++++++++ packages/schema-system/shared/src/index.ts | 24 ++ .../schema-system/shared/src/scope.test.ts | 211 ++++++++++ packages/schema-system/shared/src/scope.ts | 384 ++++++++++++++++++ 5 files changed, 1160 insertions(+) create mode 100644 packages/schema-system/shared/src/conditionModel.test.ts create mode 100644 packages/schema-system/shared/src/conditionModel.ts create mode 100644 packages/schema-system/shared/src/scope.test.ts create mode 100644 packages/schema-system/shared/src/scope.ts diff --git a/packages/schema-system/shared/src/conditionModel.test.ts b/packages/schema-system/shared/src/conditionModel.test.ts new file mode 100644 index 00000000..6299b390 --- /dev/null +++ b/packages/schema-system/shared/src/conditionModel.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; + +import type { ConditionExpr } from './conditionModel'; +import { + emptyComparison, + isBlankComparison, + parseCondition, + parseValue, + parseValueIf, + serializeCondition, + serializeValue, + serializeValueIf, +} from './conditionModel'; + +/** Parse then serialize — the token must come back byte-identical. */ +function roundTrip(token: unknown): unknown { + const parsed = parseCondition(token); + expect(parsed, `expected ${JSON.stringify(token)} to be representable`).not.toBeNull(); + return serializeCondition(parsed as ConditionExpr); +} + +describe('parseCondition', () => { + it('reads a bare store reference as a truthy check', () => { + expect(parseCondition({ $store: 'adamStore.isWeSpace' })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'store', path: 'adamStore.isWeSpace' }, + }); + }); + + it('reads a bare local reference as a truthy check', () => { + expect(parseCondition({ $local: 'showComments' })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'local', path: 'showComments' }, + }); + }); + + it('reads a context reference string as a truthy check', () => { + expect(parseCondition('$post.highlighted')).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'context', path: '$post.highlighted' }, + }); + }); + + it('reads $not over a reference as a falsy check', () => { + expect(parseCondition({ $not: { $store: 'userStore.isLoggedIn' } })).toEqual({ + type: 'comparison', + operator: 'falsy', + left: { kind: 'store', path: 'userStore.isLoggedIn' }, + }); + }); + + it('reads binary comparisons with mixed operand kinds', () => { + expect(parseCondition({ $eq: [{ $store: 'userStore.role' }, 'admin'] })).toEqual({ + type: 'comparison', + operator: 'eq', + left: { kind: 'store', path: 'userStore.role' }, + right: { kind: 'literal', value: 'admin' }, + }); + }); + + it('reads $in with a literal list on the right', () => { + const parsed = parseCondition({ $in: ['$item.role', ['admin', 'moderator']] }); + expect(parsed).toEqual({ + type: 'comparison', + operator: 'in', + left: { kind: 'context', path: '$item.role' }, + right: { kind: 'list', value: ['admin', 'moderator'] }, + }); + }); + + it('reads a group of comparisons', () => { + const parsed = parseCondition({ + $and: [{ $store: 'userStore.isAdmin' }, { $not: { $store: 'appStore.isLocked' } }], + }); + expect(parsed).toMatchObject({ type: 'group', operator: 'and' }); + expect((parsed as { children: unknown[] }).children).toHaveLength(2); + }); + + it('reads one level of nested grouping', () => { + const parsed = parseCondition({ + $or: [ + { $eq: [{ $store: 'userStore.role' }, 'admin'] }, + { $and: [{ $store: 'userStore.isVerified' }, { $eq: [{ $store: 'userStore.role' }, 'editor'] }] }, + ], + }); + expect(parsed).not.toBeNull(); + expect((parsed as { children: ConditionExpr[] }).children[1].type).toBe('group'); + }); +}); + +describe('parseCondition — count and validation-state operands', () => { + it('reads a bare $count as a truthy check', () => { + expect(parseCondition({ $count: { items: { $local: 'signalTypes' } } })).toEqual({ + type: 'comparison', + operator: 'truthy', + left: { kind: 'count', items: { kind: 'local', path: 'signalTypes' } }, + }); + }); + + it('reads $count on either side of a comparison', () => { + expect(parseCondition({ $gt: [{ $count: { items: { $store: 'spaceStore.members' } } }, 0] })).toEqual({ + type: 'comparison', + operator: 'gt', + left: { kind: 'count', items: { kind: 'store', path: 'spaceStore.members' } }, + right: { kind: 'literal', value: 0 }, + }); + }); + + it('reads the validation-state readers', () => { + expect(parseCondition({ $formValid: '$scope' })).toMatchObject({ + left: { kind: 'formState', token: 'formValid', field: '$scope' }, + }); + expect(parseCondition({ $touched: 'email' })).toMatchObject({ + left: { kind: 'formState', token: 'touched', field: 'email' }, + }); + expect(parseCondition({ $not: { $valid: 'email' } })).toMatchObject({ + operator: 'falsy', + left: { kind: 'formState', token: 'valid', field: 'email' }, + }); + }); + + it('normalises the legacy $formValid: true spelling to $scope', () => { + expect(parseCondition({ $formValid: true })).toMatchObject({ + left: { kind: 'formState', token: 'formValid', field: '$scope' }, + }); + }); +}); + +describe('parseCondition — outside the grammar', () => { + const unsupported: [string, unknown][] = [ + ['$find', { $find: { items: { $store: 'spaceStore.members' }, where: { id: '$item.id' } } }], + ['$count over a literal', { $count: { items: 3 } }], + ['$count with extra keys', { $count: { items: { $local: 'x' }, extra: 1 } }], + ['$concat operand', { $eq: [{ $concat: ['a', 'b'] }, 'ab'] }], + ['$filter', { $filter: { items: { $store: 'spaceStore.members' }, where: { role: 'admin' } } }], + ['$not over a comparison', { $not: { $eq: [{ $store: 'a.b' }, 1] } }], + ['three levels of grouping', { $and: [{ $or: [{ $and: [{ $store: 'a.b' }] }] }] }], + ['bare literal', 'just a string'], + ['malformed $eq', { $eq: [{ $store: 'a.b' }] }], + ['undefined', undefined], + ]; + + it.each(unsupported)('returns null for %s so the raw editor takes over', (_label, token) => { + expect(parseCondition(token)).toBeNull(); + }); +}); + +describe('serializeCondition', () => { + const tokens: [string, unknown][] = [ + ['bare store ref', { $store: 'adamStore.isWeSpace' }], + ['bare local ref', { $local: 'showComments' }], + ['context ref', '$post.highlighted'], + ['$not', { $not: { $store: 'userStore.isLoggedIn' } }], + ['$eq with literal', { $eq: [{ $store: 'userStore.role' }, 'admin'] }], + ['$ne with two refs', { $ne: [{ $store: 'a.b' }, { $local: 'c' }] }], + ['$gt with number', { $gt: [{ $store: 'listStore.itemCount' }, 0] }], + ['$lt with number', { $lt: [{ $local: 'count' }, 5] }], + ['$in with list', { $in: ['$item.role', ['admin', 'moderator']] }], + ['$not over $in', { $not: { $in: [{ $local: 'contentType' }, ['posts', 'users']] } }], + ['bare $count', { $count: { items: { $local: 'signalTypes' } } }], + ['$gt over $count', { $gt: [{ $count: { items: { $store: 'spaceStore.members' } } }, 0] }], + ['$formValid', { $formValid: '$scope' }], + ['$error', { $error: 'email' }], + ['$not over $valid', { $not: { $valid: 'email' } }], + ['$and mixing count and validation state', { $and: [{ $count: { items: { $local: 'x' } } }, { $touched: 'y' }] }], + ['$eq with boolean', { $eq: [{ $local: 'flag' }, true] }], + ['$eq with null', { $eq: [{ $store: 'a.b' }, null] }], + ['$and group', { $and: [{ $store: 'userStore.isAdmin' }, { $not: { $store: 'appStore.isLocked' } }] }], + [ + 'nested $or/$and', + { + $or: [ + { $eq: [{ $store: 'userStore.role' }, 'admin'] }, + { $and: [{ $store: 'userStore.isVerified' }, { $eq: [{ $store: 'userStore.role' }, 'editor'] }] }, + ], + }, + ], + ]; + + it.each(tokens)('round-trips %s unchanged', (_label, token) => { + expect(roundTrip(token)).toEqual(token); + }); +}); + +describe('value positions', () => { + it('parses the tokens that appear in children', () => { + expect(parseValue({ $store: 'spaceStore.currentSpace.name' })).toEqual({ + kind: 'store', + path: 'spaceStore.currentSpace.name', + }); + expect(parseValue('$agent.firstName')).toEqual({ kind: 'context', path: '$agent.firstName' }); + expect(parseValue('Shared')).toEqual({ kind: 'literal', value: 'Shared' }); + }); + + it('returns null for expressions with no picker equivalent', () => { + expect(parseValue({ $concat: ['a', 'b'] })).toBeNull(); + expect(parseValue({ $plural: { count: 1, one: 'x', other: 'y' } })).toBeNull(); + }); + + it('round-trips value tokens', () => { + for (const token of [{ $store: 'a.b' }, { $local: 'x' }, '$item.name', 'plain', 42, true, null]) { + expect(serializeValue(parseValue(token)!)).toEqual(token); + } + }); + + it('recognises a prop-level $if in a value position', () => { + const token = { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.access' }, 'shared'] }, + then: 'Shared', + else: 'Personal', + }, + }; + const parsed = parseValueIf(token); + expect(parsed).toEqual({ condition: token.$if.condition, then: 'Shared', else: 'Personal' }); + expect(serializeValueIf(parsed!)).toEqual(token); + }); + + it('omits an absent else rather than writing undefined', () => { + const token = { $if: { condition: { $local: 'open' }, then: 'Yes' } }; + expect(serializeValueIf(parseValueIf(token)!)).toEqual(token); + }); + + it('rejects node-level $if shapes so the raw editor keeps them', () => { + // Transitions only exist on the renderer operator, not the value form. + expect(parseValueIf({ $if: { condition: true, then: 'a', enterTransition: { type: 'fade' } } })).toBeNull(); + expect(parseValueIf({ $if: { condition: true } })).toBeNull(); + expect(parseValueIf({ type: '$if', props: { condition: true, then: {} } })).toBeNull(); + }); +}); + +describe('editing helpers', () => { + it('marks a fresh comparison as blank', () => { + expect(isBlankComparison(emptyComparison())).toBe(true); + }); + + it('does not mark a populated comparison as blank', () => { + expect(isBlankComparison(parseCondition({ $local: 'open' }) as ConditionExpr)).toBe(false); + }); +}); diff --git a/packages/schema-system/shared/src/conditionModel.ts b/packages/schema-system/shared/src/conditionModel.ts new file mode 100644 index 00000000..3a1de287 --- /dev/null +++ b/packages/schema-system/shared/src/conditionModel.ts @@ -0,0 +1,298 @@ +/** + * Condition model — a small, lossless editing grammar over the boolean operator tokens. + * + * `parseCondition` converts a condition token into a flat comparison/group structure the + * visual editor can render as rows; `serializeCondition` converts it back. Parsing is + * deliberately strict: anything the grammar can't represent exactly (`$count`, `$concat`, + * `$formValid`, deeper nesting than {@link MAX_CONDITION_DEPTH}) returns `null`, and the + * editor falls back to the raw JSON editor for that condition. That keeps the round-trip + * honest — the builder never silently rewrites an expression it didn't fully understand. + */ + +// ── Public types ──────────────────────────────────────────────────────────── + +export type ComparisonOperator = 'eq' | 'ne' | 'gt' | 'lt' | 'in' | 'nin' | 'truthy' | 'falsy'; + +/** Operators that take no right-hand operand. */ +export const UNARY_OPERATORS: ComparisonOperator[] = ['truthy', 'falsy']; + +/** Validation-state readers, which take a field name rather than a value. */ +export type FormStateToken = 'formValid' | 'valid' | 'touched' | 'error'; + +export type ConditionOperand = + | { kind: 'store'; path: string } + | { kind: 'local'; path: string } + /** A context reference string — `$item.name`, `$me.did`. */ + | { kind: 'context'; path: string } + | { kind: 'literal'; value: string | number | boolean | null } + /** A literal list, only valid as the right-hand side of `in`. */ + | { kind: 'list'; value: (string | number | boolean)[] } + /** `$count` over a list-valued reference — "how many X are there". */ + | { kind: 'count'; items: ConditionOperand } + /** `$formValid` / `$valid` / `$touched` / `$error` over a field name. */ + | { kind: 'formState'; token: FormStateToken; field: string }; + +export interface ConditionComparison { + type: 'comparison'; + operator: ComparisonOperator; + left: ConditionOperand; + /** Absent for unary operators. */ + right?: ConditionOperand; +} + +export interface ConditionGroup { + type: 'group'; + operator: 'and' | 'or'; + children: ConditionExpr[]; +} + +export type ConditionExpr = ConditionComparison | ConditionGroup; + +/** + * How many levels of grouping the builder represents: a top-level group whose children + * may themselves be groups, but no deeper. Real templates nest at most this far; beyond + * it the row-based UI stops being clearer than the JSON. + */ +export const MAX_CONDITION_DEPTH = 2; + +const BINARY_TOKEN_OPS: Record = { + $eq: 'eq', + $ne: 'ne', + $gt: 'gt', + $lt: 'lt', + $in: 'in', +}; + +const FORM_STATE_TOKENS: Record = { + $formValid: 'formValid', + $valid: 'valid', + $touched: 'touched', + $error: 'error', +}; + +const OPERATOR_TOKENS: Record = { + eq: '$eq', + ne: '$ne', + gt: '$gt', + lt: '$lt', + in: '$in', + nin: '$in', // wrapped in $not by serializeCondition + truthy: '', + falsy: '$not', +}; + +// ── Parsing ───────────────────────────────────────────────────────────────── + +function parseOperand(value: unknown): ConditionOperand | null { + if (value === null) return { kind: 'literal', value: null }; + + if (typeof value === 'string') { + // A `$`-prefixed string is a context reference; anything else is a plain literal. + return value.startsWith('$') ? { kind: 'context', path: value } : { kind: 'literal', value }; + } + if (typeof value === 'number' || typeof value === 'boolean') return { kind: 'literal', value }; + + if (Array.isArray(value)) { + const primitives = value.every((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'); + return primitives ? { kind: 'list', value: value as (string | number | boolean)[] } : null; + } + + if (typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length !== 1) return null; + if (typeof obj.$store === 'string') return { kind: 'store', path: obj.$store }; + if (typeof obj.$local === 'string') return { kind: 'local', path: obj.$local }; + + // `$count` over a list — the only wrapping operator the builder represents, because + // "how many of these are there" is how most list conditions are actually written. + if (obj.$count && typeof obj.$count === 'object' && !Array.isArray(obj.$count)) { + const countKeys = Object.keys(obj.$count as object); + if (countKeys.length !== 1 || countKeys[0] !== 'items') return null; + const items = parseOperand((obj.$count as Record).items); + if (!items || items.kind === 'list' || items.kind === 'literal') return null; + return { kind: 'count', items }; + } + + // Validation-state readers. `$formValid: '$scope'` is the idiomatic whole-form check; + // the others name a single field. + const [tokenKey] = keys; + const formToken = FORM_STATE_TOKENS[tokenKey]; + if (formToken) { + const field = obj[tokenKey]; + if (typeof field === 'string') return { kind: 'formState', token: formToken, field }; + if (formToken === 'formValid' && field === true) return { kind: 'formState', token: formToken, field: '$scope' }; + return null; + } + } + return null; +} + +function parseExpr(token: unknown, depth: number): ConditionExpr | null { + if (typeof token === 'object' && token !== null && !Array.isArray(token)) { + const obj = token as Record; + const keys = Object.keys(obj); + + if (keys.length === 1) { + const [key] = keys; + const value = obj[key]; + + // Logical groups + if ((key === '$and' || key === '$or') && Array.isArray(value)) { + if (depth + 1 > MAX_CONDITION_DEPTH) return null; + const children: ConditionExpr[] = []; + for (const child of value) { + const parsed = parseExpr(child, depth + 1); + if (!parsed) return null; + children.push(parsed); + } + if (children.length === 0) return null; + return { type: 'group', operator: key === '$and' ? 'and' : 'or', children }; + } + + // Binary comparisons + const operator = BINARY_TOKEN_OPS[key]; + if (operator && Array.isArray(value) && value.length === 2) { + const left = parseOperand(value[0]); + const right = parseOperand(value[1]); + if (!left || !right) return null; + // A list only makes sense as the right-hand side of `in`. + if (left.kind === 'list') return null; + if (right.kind === 'list' && operator !== 'in') return null; + return { type: 'comparison', operator, left, right }; + } + + if (key === '$not') { + // `$not` over `$in` is the "is not one of" row — `$in` is the one comparison with + // no negated counterpart, so this round-trips exactly rather than approximating. + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const inner = value as Record; + if (Object.keys(inner).length === 1 && Array.isArray(inner.$in) && inner.$in.length === 2) { + const left = parseOperand(inner.$in[0]); + const right = parseOperand(inner.$in[1]); + if (left && right && left.kind !== 'list') return { type: 'comparison', operator: 'nin', left, right }; + return null; + } + } + // `$not` over a plain reference reads as "is falsy". `$not` wrapping any other + // comparison stays raw — folding it into `ne` would change the token on save. + const operand = parseOperand(value); + if (!operand || operand.kind === 'list') return null; + return { type: 'comparison', operator: 'falsy', left: operand }; + } + } + } + + // A bare reference used as a condition — `condition: { $local: 'showComments' }`. + const operand = parseOperand(token); + if (operand && operand.kind !== 'list' && operand.kind !== 'literal') { + return { type: 'comparison', operator: 'truthy', left: operand }; + } + return null; +} + +/** + * Parse a condition token into the editing model. + * Returns null when the token is outside the grammar — callers should fall back to raw JSON. + */ +export function parseCondition(token: unknown): ConditionExpr | null { + if (token === undefined) return null; + return parseExpr(token, 0); +} + +// ── Serializing ───────────────────────────────────────────────────────────── + +function serializeOperand(operand: ConditionOperand): unknown { + switch (operand.kind) { + case 'store': + return { $store: operand.path }; + case 'local': + return { $local: operand.path }; + case 'context': + return operand.path; + case 'literal': + return operand.value; + case 'list': + return operand.value; + case 'count': + return { $count: { items: serializeOperand(operand.items) } }; + case 'formState': + return { [`$${operand.token}`]: operand.field }; + } +} + +export function serializeCondition(expr: ConditionExpr): unknown { + if (expr.type === 'group') { + return { [expr.operator === 'and' ? '$and' : '$or']: expr.children.map(serializeCondition) }; + } + if (expr.operator === 'truthy') return serializeOperand(expr.left); + if (expr.operator === 'falsy') return { $not: serializeOperand(expr.left) }; + const right: ConditionOperand = expr.right ?? { kind: 'literal', value: null }; + const comparison = { [OPERATOR_TOKENS[expr.operator]]: [serializeOperand(expr.left), serializeOperand(right)] }; + return expr.operator === 'nin' ? { $not: comparison } : comparison; +} + +// ── Values (as opposed to conditions) ─────────────────────────────────────── + +/** + * A value position — a `children` entry, or a prop that resolves to a value rather than + * a boolean. Parses to an operand so the editor can offer the same reference picker it + * uses inside conditions; returns null for expressions it can't represent ($concat, + * $map, $plural, …), which fall back to raw JSON. + */ +export function parseValue(token: unknown): ConditionOperand | null { + return parseOperand(token); +} + +export function serializeValue(operand: ConditionOperand): unknown { + return serializeOperand(operand); +} + +/** The prop-level `$if` form — resolves to a value, unlike the node-level `$if` operator. */ +export interface ValueIf { + condition: unknown; + then: unknown; + else?: unknown; +} + +/** + * Recognise `{ $if: { condition, then, else? } }` used in a value position — including + * inside `children`, where it renders one of two strings. + */ +export function parseValueIf(token: unknown): ValueIf | null { + if (typeof token !== 'object' || token === null || Array.isArray(token)) return null; + const obj = token as Record; + if (Object.keys(obj).length !== 1 || !obj.$if) return null; + + const inner = obj.$if; + if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) return null; + const { condition, then, else: otherwise, ...rest } = inner as Record; + // Transitions et al. belong to the node-level operator; if they're present this isn't + // a plain value conditional and the raw editor should handle it. + if (Object.keys(rest).length > 0) return null; + if (condition === undefined || then === undefined) return null; + + return otherwise === undefined ? { condition, then } : { condition, then, else: otherwise }; +} + +export function serializeValueIf(value: ValueIf): unknown { + const inner: Record = { condition: value.condition, then: value.then }; + if (value.else !== undefined) inner.else = value.else; + return { $if: inner }; +} + +// ── Editing helpers ───────────────────────────────────────────────────────── + +export function isUnaryOperator(operator: ComparisonOperator): boolean { + return UNARY_OPERATORS.includes(operator); +} + +/** An empty comparison row, used when adding a condition to a node that has none. */ +export function emptyComparison(): ConditionComparison { + return { type: 'comparison', operator: 'truthy', left: { kind: 'literal', value: '' } }; +} + +/** True when a comparison is still a blank template row (no reference chosen yet). */ +export function isBlankComparison(expr: ConditionExpr): boolean { + return expr.type === 'comparison' && expr.left.kind === 'literal' && expr.left.value === ''; +} diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index 9af14527..36434a92 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -89,6 +89,30 @@ export type { SectionEntry, StoredTemplate, FindNodeResult, PatchError } from '. export { createStoredTemplate, listSections, getSection, updateSection } from './sections'; export { getComponentMeta } from './componentMeta'; export type { ComponentMeta, PropMeta, PropLayer } from './componentMeta'; +export { findNodeChain, findScopeRef, getScopeAtNode, scopeRefToToken } from './scope'; +export type { ScopeGroup, ScopeOptions, ScopeRef, ScopeRefKind, ScopeValueType } from './scope'; +export { + emptyComparison, + isBlankComparison, + isUnaryOperator, + MAX_CONDITION_DEPTH, + parseCondition, + parseValue, + parseValueIf, + serializeCondition, + serializeValue, + serializeValueIf, + UNARY_OPERATORS, +} from './conditionModel'; +export type { + ComparisonOperator, + ConditionComparison, + ConditionExpr, + ConditionGroup, + ConditionOperand, + FormStateToken, + ValueIf, +} from './conditionModel'; export type { DatasetHandle, QueryOptions, diff --git a/packages/schema-system/shared/src/scope.test.ts b/packages/schema-system/shared/src/scope.test.ts new file mode 100644 index 00000000..f89a40d5 --- /dev/null +++ b/packages/schema-system/shared/src/scope.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; + +import type { ModelEntry, StoreEntry } from './contextTypes'; +import { findNodeChain, findScopeRef, getScopeAtNode, scopeRefToToken } from './scope'; +import type { SchemaNode } from './types'; + +const storeEntries: StoreEntry[] = [ + { + name: 'adamStore', + state: { + isWeSpace: { type: 'boolean' }, + me: { type: 'object', properties: ['did', 'handle'] }, + personalSpaces: { type: 'array', properties: ['uuid', 'name'] }, + }, + actions: ['navigate'], + }, +]; + +const models: ModelEntry[] = [ + { + name: 'Space', + className: 'Space', + fields: [ + { name: 'name', type: 'string', predicate: 'we://name', required: true }, + { name: 'description', type: 'string', predicate: 'we://description', required: false }, + ], + relations: [{ name: 'location', kind: 'HasOne', predicate: 'we://location' }], + }, +]; + +function groupLabels(node: SchemaNode, id: string) { + return getScopeAtNode(node, id, { storeEntries, models }).map((g) => g.label); +} + +describe('findNodeChain', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + children: [ + { + id: 'if', + type: '$if', + props: { + condition: { $local: 'open' }, + then: { id: 'target', type: 'we-text' }, + }, + }, + ], + }; + + it('walks into SchemaNodes embedded in props', () => { + expect(findNodeChain(tree, 'target')?.map((n) => n.id)).toEqual(['root', 'if', 'target']); + }); + + it('returns null for an unknown id', () => { + expect(findNodeChain(tree, 'nope')).toBeNull(); + }); +}); + +describe('getScopeAtNode', () => { + it('exposes $localState declared on an ancestor', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { showComments: { type: 'boolean', initial: false } }, + children: [{ id: 'child', type: 'we-text' }], + }; + const local = getScopeAtNode(tree, 'child', { storeEntries }).find((g) => g.kind === 'local'); + expect(local?.refs).toEqual([ + { id: 'local:showComments', kind: 'local', path: 'showComments', label: 'showComments', valueType: 'boolean' }, + ]); + }); + + it("exposes a node's own $localState — the renderer resolves its props against it", () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { draft: { type: 'string', initial: '' } }, + }; + const local = getScopeAtNode(tree, 'root', { storeEntries }).find((g) => g.kind === 'local'); + expect(local?.refs.map((r) => r.path)).toEqual(['draft']); + }); + + it('does not leak $localState from a sibling branch', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + children: [ + { id: 'a', type: 'Column', $localState: { hidden: { type: 'boolean', initial: false } } }, + { id: 'b', type: 'we-text' }, + ], + }; + expect(getScopeAtNode(tree, 'b', { storeEntries }).some((g) => g.kind === 'local')).toBe(false); + }); + + it('exposes $queries results with the entity’s fields', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $queries: { spaces: { entity: 'Space' } }, + children: [{ id: 'child', type: 'we-text' }], + }; + const local = getScopeAtNode(tree, 'child', { storeEntries, models }).find((g) => g.kind === 'local'); + expect(local?.refs[0]).toMatchObject({ path: 'spaces', valueType: 'array' }); + expect(local?.refs[0].properties).toContain('name'); + }); + + it('infers $each item fields from a $query source', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $query: { entity: 'Space' } }, as: 'space' }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries, models }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toContain('$space.name'); + expect(item?.refs.map((r) => r.path)).toContain('$space.location'); + }); + + it('infers $each item fields from a store array source', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $store: 'adamStore.personalSpaces' } }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries, models }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toEqual(['$item', '$item.uuid', '$item.name']); + }); + + it('infers $each item fields from a literal array', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: [{ title: 'a', author: 'b' }], as: 'post' }, + children: [{ id: 'card', type: 'Column' }], + }; + const item = getScopeAtNode(tree, 'card', { storeEntries }).find((g) => g.kind === 'item'); + expect(item?.refs.map((r) => r.path)).toEqual(['$post', '$post.title', '$post.author']); + }); + + it('shadows an outer $each that reuses the same `as` name', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: { $store: 'adamStore.personalSpaces' } }, + children: [ + { + id: 'inner', + type: '$each', + props: { items: [{ label: 'x' }] }, + children: [{ id: 'leaf', type: 'we-text' }], + }, + ], + }; + const itemGroups = getScopeAtNode(tree, 'leaf', { storeEntries }).filter((g) => g.kind === 'item'); + expect(itemGroups).toHaveLength(1); + expect(itemGroups[0].refs.map((r) => r.path)).toEqual(['$item', '$item.label']); + }); + + it('lists one group per store, plus context, ordered nearest-scope-first', () => { + const tree: SchemaNode = { + id: 'root', + type: '$each', + props: { items: [{ a: 1 }] }, + $localState: { open: { type: 'boolean', initial: false } }, + children: [{ id: 'leaf', type: 'we-text' }], + }; + expect(groupLabels(tree, 'leaf')).toEqual(['item — literal list', 'Page state', 'adamStore', 'Context']); + }); + + it('drills into object-typed store members but not array members', () => { + const tree: SchemaNode = { id: 'root', type: 'Column' }; + const store = getScopeAtNode(tree, 'root', { storeEntries }).find((g) => g.label === 'adamStore'); + const paths = store?.refs.map((r) => r.path); + expect(paths).toContain('adamStore.me.did'); + expect(paths).not.toContain('adamStore.personalSpaces.uuid'); + }); + + it('returns stores and context even when the node id is unknown', () => { + expect(groupLabels({ id: 'root', type: 'Column' }, 'missing')).toEqual(['adamStore', 'Context']); + }); +}); + +describe('token conversion', () => { + it('builds the right token per ref kind', () => { + expect(scopeRefToToken({ kind: 'store', path: 'a.b' })).toEqual({ $store: 'a.b' }); + expect(scopeRefToToken({ kind: 'local', path: 'draft' })).toEqual({ $local: 'draft' }); + expect(scopeRefToToken({ kind: 'item', path: '$post.name' })).toBe('$post.name'); + expect(scopeRefToToken({ kind: 'context', path: '$me.did' })).toBe('$me.did'); + }); + + it('matches a token back to its scope ref', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { open: { type: 'boolean', initial: false } }, + }; + const groups = getScopeAtNode(tree, 'root', { storeEntries }); + expect(findScopeRef(groups, { $local: 'open' })?.id).toBe('local:open'); + expect(findScopeRef(groups, { $store: 'adamStore.isWeSpace' })?.id).toBe('store:adamStore.isWeSpace'); + expect(findScopeRef(groups, '$me.did')?.id).toBe('context:$me.did'); + }); + + it('returns null for tokens that are not plain references', () => { + const groups = getScopeAtNode({ id: 'root', type: 'Column' }, 'root', { storeEntries }); + expect(findScopeRef(groups, { $concat: ['a'] })).toBeNull(); + expect(findScopeRef(groups, 'plain text')).toBeNull(); + expect(findScopeRef(groups, { $store: 'unknownStore.x' })).toBeNull(); + }); +}); diff --git a/packages/schema-system/shared/src/scope.ts b/packages/schema-system/shared/src/scope.ts new file mode 100644 index 00000000..dac44aaa --- /dev/null +++ b/packages/schema-system/shared/src/scope.ts @@ -0,0 +1,384 @@ +/** + * Scope resolution — which data references are available at a given node. + * + * Answers "what can this node's props refer to?" by walking from the root down to the + * node and collecting every reference the renderer would have in context there: + * store state, `$local` fields contributed by `$localState` / `$queries` ancestors, + * `$each` / `$single` iteration variables, and the backend-neutral context refs. + * + * Lives here rather than in the editor because it encodes renderer semantics (what is + * actually in context at a node), not UI concerns. The visual editor's value pickers + * are the first consumer; the validator's orphan-`$local` check answers a subset of + * the same question and could be rebased on this later. + */ + +import type { ModelEntry, StateMemberMeta, StoreEntry } from './contextTypes'; +import type { SchemaNode } from './types'; + +// ── Public types ──────────────────────────────────────────────────────────── + +export type ScopeRefKind = 'store' | 'local' | 'item' | 'context'; + +export type ScopeValueType = 'string' | 'boolean' | 'number' | 'array' | 'object' | 'function' | 'unknown'; + +/** A single addressable value available at a node. */ +export interface ScopeRef { + /** Stable identity within a scope — `${kind}:${path}`. Used to round-trip picker selections. */ + id: string; + kind: ScopeRefKind; + /** + * The path as it appears inside the emitted token: + * store → `adamStore.me.did`, local → `searchText`, item/context → `$post.name`. + */ + path: string; + /** Display label, relative to its group (e.g. `me.did` inside the `adamStore` group). */ + label: string; + valueType: ScopeValueType; + /** Known sub-properties: an object's own keys, or an array's *item* keys. */ + properties?: string[]; + /** Where the ref came from, for disambiguation in the UI (e.g. 'from $each over Space'). */ + hint?: string; +} + +export interface ScopeGroup { + label: string; + kind: ScopeRefKind; + refs: ScopeRef[]; +} + +export interface ScopeOptions { + storeEntries?: StoreEntry[]; + /** Model registry — used to infer item fields for `$each` over a `$query`. */ + models?: ModelEntry[]; +} + +// ── Context refs (backend-neutral, always in scope) ────────────────────────── + +const CONTEXT_REFS: ScopeRef[] = [ + { + id: 'context:$me.did', + kind: 'context', + path: '$me.did', + label: '$me.did', + valueType: 'string', + hint: 'current agent identity', + }, + { id: 'context:$me.handle', kind: 'context', path: '$me.handle', label: '$me.handle', valueType: 'string' }, + { id: 'context:$me.avatar', kind: 'context', path: '$me.avatar', label: '$me.avatar', valueType: 'string' }, + { + id: 'context:$currentDataset', + kind: 'context', + path: '$currentDataset', + label: '$currentDataset', + valueType: 'object', + hint: 'the active dataset', + }, +]; + +// ── Ancestor walk ─────────────────────────────────────────────────────────── + +/** Returns true if val is a SchemaNode embedded as a prop value (e.g. `$if.props.then`). */ +function isPropsSchemaNode(val: unknown): val is SchemaNode { + if (typeof val !== 'object' || val === null || Array.isArray(val)) return false; + const type = (val as Record).type; + if (typeof type !== 'string') return false; + return /^[A-Z$]/.test(type) || type.includes('-'); +} + +function isSchemaChild(child: unknown): child is SchemaNode { + if (typeof child !== 'object' || child === null || Array.isArray(child)) return false; + if ('type' in child || 'id' in child) return true; + return !Object.keys(child).some((k) => k.startsWith('$')); +} + +/** + * Find the chain of nodes from `root` down to the node with `nodeId`, inclusive. + * Traverses the same edges the renderer does: children, routes, slots, and + * SchemaNodes embedded in props. + */ +export function findNodeChain(root: SchemaNode, nodeId: string): SchemaNode[] | null { + if (!nodeId) return null; + + function search(node: SchemaNode, trail: SchemaNode[]): SchemaNode[] | null { + const chain = [...trail, node]; + if (node.id === nodeId) return chain; + + if (node.children) { + for (const child of node.children) { + if (!isSchemaChild(child)) continue; + const found = search(child, chain); + if (found) return found; + } + } + if (node.routes) { + for (const route of node.routes) { + const found = search(route as SchemaNode, chain); + if (found) return found; + } + } + if (node.slots) { + for (const slotNode of Object.values(node.slots)) { + const found = search(slotNode, chain); + if (found) return found; + } + } + if (node.props) { + for (const val of Object.values(node.props)) { + if (Array.isArray(val)) { + for (const item of val) { + if (!isPropsSchemaNode(item)) continue; + const found = search(item, chain); + if (found) return found; + } + } else if (isPropsSchemaNode(val)) { + const found = search(val, chain); + if (found) return found; + } + } + } + return null; + } + + return search(root, []); +} + +// ── Item field inference ──────────────────────────────────────────────────── + +function modelProperties(models: ModelEntry[] | undefined, entity: string): string[] | undefined { + const model = models?.find((m) => m.name === entity || m.className === entity); + if (!model) return undefined; + // `id` is present on every model instance but isn't declared as a field. + return ['id', ...model.fields.map((f) => f.name), ...model.relations.map((r) => r.name)]; +} + +function storeMemberMeta( + storeEntries: StoreEntry[] | undefined, + path: string, +): { meta: StateMemberMeta; store: StoreEntry } | undefined { + const dot = path.indexOf('.'); + if (dot === -1) return undefined; + const store = storeEntries?.find((s) => s.name === path.slice(0, dot)); + if (!store) return undefined; + const meta = store.state[path.slice(dot + 1)]; + return meta ? { meta, store } : undefined; +} + +/** + * Best-effort: infer the property names of items produced by an `items` expression. + * Returns undefined when the shape can't be determined — the picker then offers a + * free-text path instead of a list, which is still usable. + */ +function inferItemProperties( + items: unknown, + options: ScopeOptions, + localRefs: Map, +): { properties?: string[]; hint?: string } { + if (Array.isArray(items)) { + const first = items.find((i) => typeof i === 'object' && i !== null && !Array.isArray(i)); + if (first) return { properties: Object.keys(first as object), hint: 'literal list' }; + return {}; + } + if (typeof items !== 'object' || items === null) return {}; + const token = items as Record; + + if (token.$query && typeof token.$query === 'object') { + const entity = (token.$query as Record).entity; + if (typeof entity === 'string') { + return { properties: modelProperties(options.models, entity), hint: `${entity} records` }; + } + return {}; + } + if (typeof token.$store === 'string') { + const found = storeMemberMeta(options.storeEntries, token.$store); + return { properties: found?.meta.properties, hint: token.$store }; + } + if (typeof token.$local === 'string') { + const ref = localRefs.get(token.$local); + return { properties: ref?.properties, hint: `$local.${token.$local}` }; + } + // Array operators pass their source's item shape through unchanged. + if (token.$filter && typeof token.$filter === 'object') { + return inferItemProperties((token.$filter as Record).items, options, localRefs); + } + if (token.$map && typeof token.$map === 'object') { + const select = (token.$map as Record).select; + if (select && typeof select === 'object') return { properties: Object.keys(select), hint: '$map projection' }; + } + return {}; +} + +// ── Scope assembly ────────────────────────────────────────────────────────── + +function localValueType(fieldType: unknown): ScopeValueType { + switch (fieldType) { + case 'string': + case 'boolean': + case 'number': + case 'object': + case 'function': + return fieldType; + default: + return 'unknown'; + } +} + +/** + * Collect every reference available to the props of the node with `nodeId`. + * + * Groups are ordered by how close they are to the node — iteration variables first, + * then page state, then stores, then always-available context refs. That ordering + * mirrors how often each appears in real templates. + * + * A node's own `$localState` / `$queries` are included: the renderer resolves a node's + * props against the context it just extended, so those fields do resolve there. + */ +export function getScopeAtNode(root: SchemaNode, nodeId: string, options: ScopeOptions = {}): ScopeGroup[] { + const chain = findNodeChain(root, nodeId) ?? []; + + const localRefs = new Map(); + const itemGroups: ScopeGroup[] = []; + + for (const node of chain) { + // $localState — scoped signals; inner declarations shadow outer ones of the same name. + if (node.$localState) { + for (const [name, field] of Object.entries(node.$localState)) { + localRefs.set(name, { + id: `local:${name}`, + kind: 'local', + path: name, + label: name, + valueType: localValueType((field as { type?: unknown }).type), + }); + } + } + + // $queries — read-only reactive arrays injected into the same $local namespace. + if (node.$queries) { + for (const [name, query] of Object.entries(node.$queries)) { + const entity = (query as { entity?: unknown }).entity; + localRefs.set(name, { + id: `local:${name}`, + kind: 'local', + path: name, + label: name, + valueType: 'array', + properties: typeof entity === 'string' ? modelProperties(options.models, entity) : undefined, + hint: typeof entity === 'string' ? `${entity} query results` : 'query results', + }); + } + } + + // $each / $single — iteration variables, addressed as context reference strings. + if (node.type === '$each' || node.type === '$single') { + const asKey = typeof node.props?.as === 'string' ? node.props.as : 'item'; + const source = node.type === '$each' ? node.props?.items : node.props?.item; + const { properties, hint } = inferItemProperties(source, options, localRefs); + const refs: ScopeRef[] = [ + { + id: `item:$${asKey}`, + kind: 'item', + path: `$${asKey}`, + label: `$${asKey}`, + valueType: 'object', + properties, + hint, + }, + ...(properties ?? []).map((prop) => ({ + id: `item:$${asKey}.${prop}`, + kind: 'item', + path: `$${asKey}.${prop}`, + label: `$${asKey}.${prop}`, + valueType: 'unknown', + })), + ]; + // A nested $each reusing an outer `as` name shadows it — drop the outer group. + const shadowed = itemGroups.findIndex((g) => g.refs[0]?.path === `$${asKey}`); + if (shadowed !== -1) itemGroups.splice(shadowed, 1); + itemGroups.push({ label: hint ? `${asKey} — ${hint}` : asKey, kind: 'item', refs }); + } + } + + const groups: ScopeGroup[] = [...itemGroups]; + + if (localRefs.size > 0) { + groups.push({ label: 'Page state', kind: 'local', refs: [...localRefs.values()] }); + } + + for (const store of options.storeEntries ?? []) { + const refs: ScopeRef[] = []; + for (const [member, meta] of Object.entries(store.state)) { + const path = `${store.name}.${member}`; + refs.push({ + id: `store:${path}`, + kind: 'store', + path, + label: member, + valueType: meta.type, + properties: meta.properties, + }); + // Objects can be drilled into directly; an array's `properties` describe its + // *items*, which are only reachable through $each — so they aren't listed here. + if (meta.type === 'object' && meta.properties) { + for (const prop of meta.properties) { + refs.push({ + id: `store:${path}.${prop}`, + kind: 'store', + path: `${path}.${prop}`, + label: `${member}.${prop}`, + valueType: 'unknown', + }); + } + } + } + if (refs.length > 0) groups.push({ label: store.name, kind: 'store', refs }); + } + + groups.push({ label: 'Context', kind: 'context', refs: CONTEXT_REFS }); + + return groups; +} + +// ── Token conversion ──────────────────────────────────────────────────────── + +/** Build the schema token that reads a scope reference. */ +export function scopeRefToToken(ref: Pick): unknown { + switch (ref.kind) { + case 'store': + return { $store: ref.path }; + case 'local': + return { $local: ref.path }; + case 'item': + case 'context': + return ref.path; + } +} + +/** Find the scope ref a token reads, or null if it isn't a plain reference. */ +export function findScopeRef(groups: ScopeGroup[], token: unknown): ScopeRef | null { + let kind: ScopeRefKind | null = null; + let path: string | null = null; + + if (typeof token === 'string' && token.startsWith('$')) { + path = token; + } else if (typeof token === 'object' && token !== null) { + const obj = token as Record; + if (typeof obj.$store === 'string') { + kind = 'store'; + path = obj.$store; + } else if (typeof obj.$local === 'string') { + kind = 'local'; + path = obj.$local; + } + } + if (path === null) return null; + + for (const group of groups) { + for (const ref of group.refs) { + if (ref.path !== path) continue; + if (kind && ref.kind !== kind) continue; + if (!kind && ref.kind !== 'item' && ref.kind !== 'context') continue; + return ref; + } + } + return null; +} From 3fbabb172ed071ef837f21b12cd7eb1a579c144a Mon Sep 17 00:00:00 2001 From: jhweir Date: Sun, 26 Jul 2026 21:18:28 +0100 Subject: [PATCH 02/12] feat(editor): value picker, condition builder, and value editor components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three controls that replace raw JSON editing in the inspector. Not wired up yet — that follows in the next commit. ValueRefPicker — the grouped, searchable dropdown over everything getScopeAtNode reports, plus a literal mode and a "count of a list" mode. OperandInput composes it into one control that expresses both "compare against this data" and "compare against this fixed value", typing the literal input from whichever side holds a known reference. Built to be reused by the action editor next. ConditionEditor — comparison rows with AND/OR grouping over a condition token. Keeps a draft separate from the prop so an in-progress row survives, and adopts external edits (undo/redo, AI changes) without clobbering it. A JSON toggle stays available at all times, so the builder is never a ceiling; conditions outside the grammar open straight into JSON with a "custom expression" note. ValueEditor — picks the narrowest editor a value token allows: the reference/ literal picker for plain values, a nested condition plus two branches for the prop-level $if, and the JSON editor for expressions with no row equivalent ($concat, $map, $plural, $action). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/editor/ConditionEditor.tsx | 322 +++++++++++++ .../solid/components/editor/ValueEditor.tsx | 124 +++++ .../components/editor/ValueRefPicker.tsx | 425 ++++++++++++++++++ 3 files changed, 871 insertions(+) create mode 100644 packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx create mode 100644 packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx create mode 100644 packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx new file mode 100644 index 00000000..b45a5cee --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx @@ -0,0 +1,322 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ComparisonOperator, ConditionExpr, ConditionOperand, ScopeGroup } from '@we/schema-shared'; +import { isUnaryOperator, parseCondition, serializeCondition } from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, For, Show, untrack } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { OperandInput, operandValueType } from './ValueRefPicker'; + +/** + * Row-based editor for a condition token ($if conditions today; `disabled`/`hidden` + * style props next). + * + * Conditions the grammar can't represent exactly fall back to the JSON editor rather + * than being approximated — see `conditionModel.ts`. The JSON editor is also always + * reachable from the toggle, so the builder never becomes a ceiling. + */ + +const OPERATOR_LABELS: Record = { + truthy: 'is set / true', + falsy: 'is not set / false', + eq: 'equals', + ne: 'does not equal', + gt: 'is greater than', + lt: 'is less than', + in: 'is one of', + nin: 'is not one of', +}; + +const OPERATOR_OPTIONS = (Object.keys(OPERATOR_LABELS) as ComparisonOperator[]).map((value) => ({ + label: OPERATOR_LABELS[value], + value, +})); + +/** A reference with no path chosen yet, or an unfilled literal, isn't ready to emit. */ +function operandComplete(operand: ConditionOperand | undefined): boolean { + if (!operand) return false; + switch (operand.kind) { + case 'list': + return operand.value.length > 0; + case 'literal': + return operand.value !== ''; + case 'count': + return operandComplete(operand.items); + case 'formState': + return operand.field.trim() !== ''; + default: + return operand.path.trim() !== ''; + } +} + +function exprComplete(expr: ConditionExpr): boolean { + if (expr.type === 'group') return expr.children.length > 0 && expr.children.every(exprComplete); + if (!operandComplete(expr.left)) return false; + return isUnaryOperator(expr.operator) ? true : operandComplete(expr.right); +} + +function emptyRow(): ConditionExpr { + return { type: 'comparison', operator: 'truthy', left: { kind: 'context', path: '' } }; +} + +export function ConditionEditor(props: { + condition: unknown; + scope: ScopeGroup[]; + onChange: (token: unknown) => void; + /** Label above the editor — e.g. "Show when". */ + label?: string; +}) { + const [draft, setDraft] = createSignal(parseCondition(props.condition)); + const [rawMode, setRawMode] = createSignal(false); + + // Adopt external edits (undo/redo, AI changes, selecting another node) but ignore the + // echo of our own writes, which would otherwise clobber an in-progress row. + createEffect(() => { + const incoming = props.condition; + const current = untrack(draft); + if (current && JSON.stringify(serializeCondition(current)) === JSON.stringify(incoming)) return; + setDraft(parseCondition(incoming)); + }); + + /** True when a condition exists but the builder can't represent it exactly. */ + const unsupported = createMemo(() => props.condition !== undefined && parseCondition(props.condition) === null); + + const update = (next: ConditionExpr) => { + setDraft(next); + if (exprComplete(next)) props.onChange(serializeCondition(next)); + }; + + const rows = createMemo(() => { + const expr = draft(); + if (!expr) return []; + return expr.type === 'group' ? expr.children : [expr]; + }); + + const groupOperator = () => { + const expr = draft(); + return expr?.type === 'group' ? expr.operator : 'and'; + }; + + const replaceRow = (index: number, next: ConditionExpr | null) => { + const expr = draft(); + if (!expr) return; + if (expr.type !== 'group') { + if (next) update(next); + else { + setDraft(null); + props.onChange(null); + } + return; + } + const children = [...expr.children]; + if (next) children[index] = next; + else children.splice(index, 1); + + if (children.length === 0) { + setDraft(null); + props.onChange(null); + return; + } + // Collapse a one-child group back to a bare comparison so the token stays idiomatic. + update(children.length === 1 ? children[0] : { ...expr, children }); + }; + + const addRow = () => { + const expr = draft(); + const next = emptyRow(); + if (!expr) { + setDraft(next); + return; + } + if (expr.type === 'group') setDraft({ ...expr, children: [...expr.children, next] }); + else setDraft({ type: 'group', operator: 'and', children: [expr, next] }); + }; + + const setGroupOperator = (operator: 'and' | 'or') => { + const expr = draft(); + if (expr?.type !== 'group') return; + update({ ...expr, operator }); + }; + + return ( + + + + {props.label ?? 'Condition'} + + + + setRawMode((v) => !v)} aria-label="Edit as JSON"> + + + + + + + + + + + + Custom expression — edit as JSON + + + + + props.onChange(JSON.parse(json))} + /> + + + } + > + + 1}> + + + Match + + setGroupOperator(e.detail === 'or' ? 'or' : 'and')} + /> + + of these + + + + + + {(row, index) => ( + replaceRow(index(), next)} + onRemove={() => replaceRow(index(), null)} + removable={rows().length > 1 || draft() !== null} + /> + )} + + + + + + {rows().length === 0 ? 'Add condition' : 'Add another'} + + + + + + ); +} + +/** + * A single comparison row. Nested groups render read-only here — the builder edits one + * level of grouping; anything deeper stays in JSON (see MAX_CONDITION_DEPTH). + */ +function ConditionRow(props: { + expr: ConditionExpr; + scope: ScopeGroup[]; + onChange: (next: ConditionExpr) => void; + onRemove: () => void; + removable: boolean; +}) { + const comparison = () => (props.expr.type === 'comparison' ? props.expr : null); + + // Type the literal side from whichever side holds a known reference. + const literalType = () => { + const cmp = comparison(); + if (!cmp) return 'unknown' as const; + const left = operandValueType(cmp.left, props.scope); + return left === 'unknown' ? operandValueType(cmp.right, props.scope) : left; + }; + + const setOperator = (operator: ComparisonOperator) => { + const cmp = comparison(); + if (!cmp) return; + if (isUnaryOperator(operator)) { + props.onChange({ type: 'comparison', operator, left: cmp.left }); + return; + } + // `is one of` takes a list; every other binary operator takes a single value. + const right = + operator === 'in' || operator === 'nin' + ? cmp.right?.kind === 'list' + ? cmp.right + : ({ kind: 'list', value: [] } as ConditionOperand) + : (cmp.right ?? ({ kind: 'literal', value: '' } as ConditionOperand)); + props.onChange({ type: 'comparison', operator, left: cmp.left, right }); + }; + + return ( + + + + Grouped condition — edit as JSON + + + } + > + {(cmp) => ( + + +
+ props.onChange({ ...cmp(), left })} + valueType={operandValueType(cmp().right, props.scope)} + allowCount + placeholder="Select a value" + /> +
+ + + + + + + +
+ + setOperator(e.detail as ComparisonOperator)} + /> + + + props.onChange({ ...cmp(), right })} + valueType={literalType()} + list={cmp().operator === 'in' || cmp().operator === 'nin'} + allowCount + placeholder="Value" + /> + +
+ )} +
+ ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx new file mode 100644 index 00000000..05d68b70 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx @@ -0,0 +1,124 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ScopeGroup } from '@we/schema-shared'; +import { parseValue, parseValueIf, serializeValue, serializeValueIf } from '@we/schema-shared'; +import { createMemo, Show } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { OperandInput } from './ValueRefPicker'; + +/** + * Editor for any single value in a schema — a `children` entry or a value-producing prop. + * + * Picks the narrowest editor the token allows: a reference/literal picker for plain + * values, a nested condition + two branches for the prop-level `$if`, and the raw JSON + * editor for expressions with no direct equivalent ($concat, $map, $plural, $action). + */ + +/** Depth cap for nested `$if` branches — past this the JSON editor is clearer. */ +const MAX_VALUE_IF_DEPTH = 1; + +export function ValueEditor(props: { + value: unknown; + scope: ScopeGroup[]; + onChange: (value: unknown) => void; + /** Nesting level, incremented for the branches of a value-level `$if`. */ + depth?: number; + placeholder?: string; +}) { + const depth = () => props.depth ?? 0; + + const valueIf = createMemo(() => (depth() < MAX_VALUE_IF_DEPTH ? parseValueIf(props.value) : null)); + const operand = createMemo(() => (valueIf() ? null : parseValue(props.value))); + + return ( + + + + + Custom expression — edit as JSON + + + + props.onChange(JSON.parse(json))} + /> + + + } + > + {(value) => ( + props.onChange(serializeValue(next))} + valueType="string" + allowCount + placeholder={props.placeholder} + /> + )} + + } + > + {(branch) => ( + + props.onChange(serializeValueIf({ ...branch(), condition }))} + /> + + + + Then show + + props.onChange(serializeValueIf({ ...branch(), then }))} + placeholder="Value when true" + /> + + + + Otherwise show + + + props.onChange( + serializeValueIf({ + condition: branch().condition, + then: branch().then, + // An empty branch means "render nothing" — drop the key rather than + // writing an empty string the renderer would print. + else: otherwise === '' ? undefined : otherwise, + }), + ) + } + placeholder="Value when false" + /> + + + + )} + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx new file mode 100644 index 00000000..69882ea9 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx @@ -0,0 +1,425 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ConditionOperand, FormStateToken, ScopeGroup, ScopeRef, ScopeValueType } from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; + +/** + * Pickers for a single value in the logic editors. + * + * `ValueRefPicker` is the grouped, searchable list of everything in scope at a node + * (iteration variables, page state, store members, context refs). `OperandInput` wraps it + * with a literal-value mode so one control can express both "compare against this data" + * and "compare against this fixed value". + */ + +// ── Display helpers ───────────────────────────────────────────────────────── + +const KIND_ICONS: Record = { + item: 'list', + local: 'note', + store: 'database', + context: 'globe', +}; + +const FORM_STATE_LABELS: Record string> = { + formValid: () => 'all fields are valid', + valid: (field) => `${field} is valid`, + touched: (field) => `${field} was edited`, + error: (field) => `${field} error message`, +}; + +export function operandLabel(operand: ConditionOperand | undefined): string { + if (!operand) return ''; + switch (operand.kind) { + case 'store': + case 'local': + case 'context': + return operand.path; + case 'list': + return operand.value.join(', '); + case 'count': + return `count of ${operandLabel(operand.items) || '…'}`; + case 'formState': + return FORM_STATE_LABELS[operand.token](operand.field); + case 'literal': + if (operand.value === null) return 'null'; + if (operand.value === '') return ''; + return String(operand.value); + } +} + +function operandIcon(operand: ConditionOperand | undefined): string { + if (!operand) return 'plus'; + if (operand.kind === 'literal' || operand.kind === 'list') return 'text-aa'; + if (operand.kind === 'count') return 'hash'; + if (operand.kind === 'formState') return 'check-circle'; + return KIND_ICONS[operand.kind]; +} + +/** The referenced path, or undefined for operands that aren't a plain reference. */ +function refPath(operand: ConditionOperand | undefined): string | undefined { + if (!operand) return undefined; + return operand.kind === 'store' || operand.kind === 'local' || operand.kind === 'context' ? operand.path : undefined; +} + +/** Map a scope ref onto the operand kind that serializes to the same token. */ +function refToOperand(ref: ScopeRef): ConditionOperand { + if (ref.kind === 'store') return { kind: 'store', path: ref.path }; + if (ref.kind === 'local') return { kind: 'local', path: ref.path }; + return { kind: 'context', path: ref.path }; +} + +/** The declared type of the picked reference, used to type the opposite side's literal input. */ +export function operandValueType(operand: ConditionOperand | undefined, scope: ScopeGroup[]): ScopeValueType { + if (!operand) return 'unknown'; + if (operand.kind === 'literal') { + if (typeof operand.value === 'boolean') return 'boolean'; + if (typeof operand.value === 'number') return 'number'; + return 'string'; + } + if (operand.kind === 'list') return 'array'; + if (operand.kind === 'count') return 'number'; + if (operand.kind === 'formState') return operand.token === 'error' ? 'string' : 'boolean'; + for (const group of scope) { + for (const ref of group.refs) { + if (ref.path === operand.path) return ref.valueType; + } + } + return 'unknown'; +} + +// ── ValueRefPicker ────────────────────────────────────────────────────────── + +export function ValueRefPicker(props: { + scope: ScopeGroup[]; + value?: ConditionOperand; + onSelect: (operand: ConditionOperand) => void; + /** Offer a "use a fixed value" entry that switches the operand to literal mode. */ + allowLiteral?: boolean; + /** Offer a "count of a list" entry that wraps a reference in $count. */ + allowCount?: boolean; + placeholder?: string; +}) { + const [open, setOpen] = createSignal(false); + const [search, setSearch] = createSignal(''); + let ref!: HTMLDivElement; + + createEffect(() => { + if (!open()) return; + const handler = (e: MouseEvent) => { + if (!ref.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + onCleanup(() => document.removeEventListener('mousedown', handler)); + }); + + const filtered = createMemo(() => { + const term = search().trim().toLowerCase(); + if (!term) return props.scope; + return props.scope + .map((group) => ({ + ...group, + refs: group.refs.filter((r) => r.path.toLowerCase().includes(term) || group.label.toLowerCase().includes(term)), + })) + .filter((group) => group.refs.length > 0); + }); + + const choose = (operand: ConditionOperand) => { + props.onSelect(operand); + setOpen(false); + setSearch(''); + }; + + const label = () => operandLabel(props.value) || (props.placeholder ?? 'Select a value'); + + return ( +
+ setOpen((v) => !v)}> + + + + {label()} + + + + + + +
+ + + ) => setSearch(e.detail)} + /> + + + + + {(group) => ( + + + {group.label} + + + {(scopeRef) => ( + choose(refToOperand(scopeRef))} + > + + + + {scopeRef.label} + + + + {scopeRef.valueType} + + + + + )} + + + )} + + + + + Nothing in scope matches. + + + + + + + + + choose({ kind: 'count', items: { kind: 'context', path: '' } })}> + + + Count of a list… + + + + + choose({ kind: 'literal', value: '' })}> + + + Use a fixed value… + + + + + + + +
+
+
+ ); +} + +// ── OperandInput ──────────────────────────────────────────────────────────── + +/** + * One side of a comparison: either a reference picked from scope, or a literal typed + * inline. The literal control is typed by `valueType` — which the caller derives from + * the *other* side, so comparing a boolean store member offers true/false rather than + * free text. + */ +export function OperandInput(props: { + scope: ScopeGroup[]; + value?: ConditionOperand; + onChange: (operand: ConditionOperand) => void; + valueType?: ScopeValueType; + /** Accept a comma-separated list — used for the `is one of` operator. */ + list?: boolean; + /** Offer wrapping a reference in `$count`. */ + allowCount?: boolean; + placeholder?: string; +}) { + const isLiteral = () => props.value?.kind === 'literal' || props.value?.kind === 'list'; + + const literalControl = () => { + if (props.list) { + const current = props.value?.kind === 'list' ? props.value.value.join(', ') : ''; + return ( + ) => + props.onChange({ + kind: 'list', + value: e.detail + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + }) + } + /> + ); + } + + const literal = props.value?.kind === 'literal' ? props.value.value : ''; + + if (props.valueType === 'boolean') { + return ( + props.onChange({ kind: 'literal', value: e.detail === 'true' })} + /> + ); + } + + if (props.valueType === 'number') { + return ( + ) => { + const parsed = Number(e.detail); + props.onChange({ kind: 'literal', value: isNaN(parsed) ? e.detail : parsed }); + }} + /> + ); + } + + return ( + ) => props.onChange({ kind: 'literal', value: e.detail })} + /> + ); + }; + + /** Swap back to picking a reference, discarding the current composite/literal value. */ + const resetToRef = () => props.onChange({ kind: 'context', path: '' }); + + const resetButton = (title: string) => ( + + + + + + ); + + // `$count` wraps another reference, so it renders as a labelled row containing a + // nested picker rather than as a leaf control. + const countValue = () => (props.value?.kind === 'count' ? props.value : null); + + // Validation-state readers name a field from the surrounding $localState, which is + // exactly the `local` group of the scope — so the field list comes for free. + const formStateValue = () => (props.value?.kind === 'formState' ? props.value : null); + const localFieldOptions = () => { + const fields = props.scope.filter((g) => g.kind === 'local').flatMap((g) => g.refs.map((r) => r.path)); + return [{ label: 'the whole form', value: '$scope' }, ...fields.map((f) => ({ label: f, value: f }))]; + }; + + return ( + + + {(count) => ( + <> + + count of + +
+ props.onChange({ kind: 'count', items })} + placeholder="Select a list" + /> +
+ + )} +
+ + {(formState) => ( + <> + + {formState().token === 'error' ? 'error of' : formState().token === 'touched' ? 'edited' : 'valid'} + + + props.onChange({ kind: 'formState', token: formState().token, field: e.detail as string }) + } + /> + + )} + + {resetButton('Pick a different value')} + + } + > + + } + > + + {literalControl()} + + + + + + + +
+ ); +} From 2ba4a49618e4a4260f60c9574bd94823b99e1c4d Mon Sep 17 00:00:00 2001 From: jhweir Date: Sun, 26 Jul 2026 21:18:41 +0100 Subject: [PATCH 03/12] feat(editor): edit conditions and bound content without touching JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new controls into the inspector, replacing the "Dynamic props" JSON blobs that were the only way to see or change a node's logic. - $if nodes get a "Show when" section: the condition as comparison rows over the values actually in scope at that node, rather than a JSON token. - Content handles every `children` shape, not just a lone string. A token in children previously had no editor anywhere — it isn't a string, so the text field skipped it, and the props-only scan never saw it. So a we-text bound to { $store: 'spaceStore.currentSpace.name' } showed an empty panel, and a value-level { $if } in children showed nothing at all on the node that held it. Now: text → text area, a single token → value picker, a value-level $if → condition plus then/else rows, several tokens → labelled JSON. Across the built-in templates 74 nodes had token content and no editor; 35 now get a real control and the rest are at least visible. - Dynamic props route through the same ValueEditor, so a prop-level $if gets the builder too and only genuinely complex expressions stay as JSON. - SchemaNode-valued props ($if's then/else) no longer appear as JSON blobs: they are whole subtrees, already navigable in the Layers tree, and listing them twice buried the props that are only editable in the panel. Known gap: operator tokens inside `children` still don't appear in the Layers tree, since selection is id-based and ensureNodeIds only assigns ids to SchemaNodes. They are reachable through the parent node's Content section. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/editor/InspectorPanel.tsx | 170 ++++++++++++++---- 1 file changed, 133 insertions(+), 37 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx index faf0cc35..e4bc092c 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx @@ -4,13 +4,15 @@ import { contextData } from '@we/ai-context'; import { Column, Combobox, type ComboboxOption, Grid, Row } from '@we/components/solid'; import { tokenVar } from '@we/design-utils'; import { compressImageToFileData, ImageBlock } from '@we/models'; -import type { ComponentMeta, PropLayer, PropMeta, SchemaNode, TemplateSchema } from '@we/schema-shared'; -import { findNodeById, getComponentMeta, mergeNode } from '@we/schema-shared'; +import type { ComponentMeta, PropLayer, PropMeta, SchemaNode, ScopeGroup, TemplateSchema } from '@we/schema-shared'; +import { findNodeById, getComponentMeta, getScopeAtNode, mergeNode } from '@we/schema-shared'; import { useVisualEditor } from '@we/schema-solid'; import type { JSX } from 'solid-js'; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { ValueEditor } from './ValueEditor'; // ----------------------------------------------------------------------- // Schema helpers @@ -492,15 +494,15 @@ export function InspectorPanel() { } } - function handleContentChange(value: string) { + /** Replace `children` wholesale. `null` deletes the key (mergeNode treats null as delete). */ + function setChildren(children: unknown[] | null) { const id = visualEditor.selectedId(); if (!id) return; try { const clone = deepClone(templateStore.currentTemplate) as TemplateSchema; const found = findNodeById(clone, id); if (!found) return; - const patch = value === '' ? { children: null } : { children: [value] }; - const patched = mergeNode(found.node, patch); + const patched = mergeNode(found.node, { children }); const updated = replaceNodeInTree(clone as SchemaNode, found.node, patched) as TemplateSchema; aiStore.pushSnapshot(); templateStore.updateTemplate(updated); @@ -510,6 +512,15 @@ export function InspectorPanel() { } } + function handleContentChange(value: string) { + setChildren(value === '' ? null : [value]); + } + + function handleContentTokenChange(value: unknown) { + // Clearing a bound value leaves the node empty rather than printing "". + setChildren(value === '' || value === null || value === undefined ? null : [value]); + } + function onDividerMouseDown(e: MouseEvent) { e.preventDefault(); const startY = e.clientY; @@ -601,7 +612,13 @@ export function InspectorPanel() { } > {(node) => ( - + setChildren(children.length === 0 ? null : children)} + /> )} @@ -617,27 +634,43 @@ function NodeProperties(props: { node: SchemaNode; onPropChange: (key: string, value: unknown) => void; onContentChange: (value: string) => void; + /** Replace the single token in `children` (data binding, value-level conditional). */ + onContentTokenChange: (value: unknown) => void; + /** Replace the whole `children` array (raw JSON escape hatch). */ + onChildrenChange: (children: unknown[]) => void; }) { + const templateStore = useTemplateStore(); const meta = createMemo(() => getComponentMeta(props.node.type ?? '', contextData)); - // Content — editable `children` text. Shown when children is a single string (or - // empty) so authors can view/edit the common `children: ["some text"]` shape without - // needing the (rarely used) `text` prop. - const hasSimpleStringChildren = createMemo(() => { + // Content — what `children` holds, classified so each shape gets the right editor. + // Text is the common case, but a single token ({ $store }, a value-level { $if }, a + // $concat) is just as common in real templates and used to have no editor at all: it + // isn't a string, so the text field skipped it, and `props`-only scanning never saw it. + const contentKind = createMemo<'text' | 'token' | 'tokens' | 'nodes'>(() => { const children = props.node.children; - return !children || children.length === 0 || (children.length === 1 && typeof children[0] === 'string'); + if (!children || children.length === 0) return 'text'; + if (children.some((c) => isPropsSchemaNode(c))) return 'nodes'; + if (children.length === 1) return typeof children[0] === 'string' ? 'text' : 'token'; + return children.every((c) => typeof c === 'string') ? 'text' : 'tokens'; }); + const showContent = createMemo(() => { - if (!hasSimpleStringChildren()) return false; + const kind = contentKind(); + if (kind === 'nodes') return false; + if (kind !== 'text') return true; const children = props.node.children; if (children && children.length === 1) return true; return TEXT_CONTENT_TYPES.has(props.node.type ?? ''); }); + const contentValue = createMemo(() => { const children = props.node.children; return children && children.length === 1 && typeof children[0] === 'string' ? children[0] : ''; }); + /** The single token in `children`, for the token editor. */ + const contentToken = createMemo(() => props.node.children?.[0]); + // Current prop values set on this node const currentProps = createMemo(() => props.node.props ?? {}); @@ -654,11 +687,32 @@ function NodeProperties(props: { return used; }); - // Complex (non-primitive) props — read-only preview + // Props the Logic section owns — excluded from the raw JSON list below so a condition + // isn't editable in two places at once. + const logicProps = createMemo(() => (props.node.type === '$if' ? new Set(['condition']) : new Set())); + + // Complex (non-primitive) props — raw JSON escape hatch. + // SchemaNode-valued props ($if's then/else, slot content) are excluded: they're whole + // subtrees, already navigable and editable through the Layers tree above, and showing + // them here as JSON blobs buries the props that are only editable here. const complexProps = createMemo(() => - Object.entries(currentProps()).filter(([, v]) => { + Object.entries(currentProps()).filter(([k, v]) => { const t = typeof v; - return t !== 'string' && t !== 'boolean' && t !== 'number'; + if (t === 'string' || t === 'boolean' || t === 'number') return false; + if (logicProps().has(k)) return false; + if (isPropsSchemaNode(v)) return false; + if (Array.isArray(v) && v.length > 0 && v.every(isPropsSchemaNode)) return false; + return true; + }), + ); + + // Everything this node's props can refer to — drives the value pickers in the Logic + // section. Recomputed from the live template so newly added $localState or $each + // ancestors show up without reselecting the node. + const scope = createMemo(() => + getScopeAtNode(templateStore.currentTemplate as SchemaNode, props.node.id ?? '', { + storeEntries: contextData.storeEntries, + models: contextData.models, }), ); @@ -690,16 +744,63 @@ function NodeProperties(props: { {/* Scrollable content */} - {/* Content — editable text children, shown for text-bearing nodes */} + {/* Content — text, a bound value, or a value-level conditional */} - + Content - ) => props.onContentChange(e.detail)} + + + ) => props.onContentChange(e.detail)} + /> + + + {/* A single token — bound to data, or a conditional between two values */} + + + + + + + {/* Several tokens concatenated in place — no row equivalent, so raw JSON */} + + + + Multiple content parts — edit as JSON + + + props.onChildrenChange(JSON.parse(json))} + /> + + + + + + + {/* Logic — condition builder for $if, in place of raw JSON */} + + + props.onPropChange('condition', token)} /> @@ -776,27 +877,22 @@ function NodeProperties(props: { - {/* Complex / dynamic props */} + {/* Complex / dynamic props — ValueEditor picks the right control per token and + falls back to the JSON editor for the ones with no row equivalent */} 0}> Dynamic props - - {([key, value]) => ( + key)}> + {(key) => ( {key} - - props.onPropChange(key, JSON.parse(json))} - /> - + props.onPropChange(key, value)} + /> )} From 6e3f1b7fcd8a7ef86d4691867dac7a4d3c5e1bb5 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 14:58:52 +0100 Subject: [PATCH 04/12] feat(editor): switch content between text, data, and conditional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Content section could show each shape but never convert between them: a value bound to data could be turned back into text (the picker's "use a fixed value" entry did that incidentally), but a conditional had no way out and plain text had no way in to either mode. Authoring anything other than text meant hand-editing JSON. Adds a mode selector — Text / Data / Conditional — that converts in both directions, and moves the section into its own ContentEditor component now that it carries real state. Conversions preserve what they can rather than clearing the field. Text → Conditional seeds the "then" branch with the existing text, so the natural way to author one is to write the common case first and then add the condition; Conditional → Text collapses back to that same branch. Switching mode never destroys content. Text conversions apply immediately since the result is always complete, but a half-built binding or conditional has no valid token to write — so the schema keeps rendering the old content and the panel says so, instead of writing a broken $if or silently clearing the node. "Custom" ($concat, $plural and friends) is a mode you can leave but not choose: it appears in the selector only while active, so those nodes can be converted to one of the real modes without touching JSON. classifyContent / contentAsText move to @we/schema-shared with the other value helpers — they answer "what shape is this children array", which is schema semantics rather than UI, and they are worth testing directly. Co-Authored-By: Claude Opus 5 (1M context) --- .../solid/components/editor/ContentEditor.tsx | 237 ++++++++++++++++++ .../components/editor/InspectorPanel.tsx | 84 ++----- .../shared/src/conditionModel.test.ts | 34 +++ .../shared/src/conditionModel.ts | 29 +++ packages/schema-system/shared/src/index.ts | 3 + 5 files changed, 318 insertions(+), 69 deletions(-) create mode 100644 packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx new file mode 100644 index 00000000..5056dd04 --- /dev/null +++ b/packages/app-framework/src/frameworks/solid/components/editor/ContentEditor.tsx @@ -0,0 +1,237 @@ +import { Column, Row } from '@we/components/solid'; +import { tokenVar } from '@we/design-utils'; +import type { ContentShape, ScopeGroup, ValueIf } from '@we/schema-shared'; +import { + classifyContent, + contentAsText, + parseValue, + parseValueIf, + serializeValue, + serializeValueIf, +} from '@we/schema-shared'; +import { createEffect, createMemo, createSignal, on, Show } from 'solid-js'; + +import { CodeViewer } from './CodeViewer'; +import { ConditionEditor } from './ConditionEditor'; +import { ValueEditor } from './ValueEditor'; +import { OperandInput } from './ValueRefPicker'; + +/** + * Editor for a node's `children` when it holds content rather than child nodes. + * + * Content has three authoring shapes — plain text, a value bound to data, and a + * conditional between two values — and the mode selector converts between them in both + * directions. Conversions carry the existing content across where they can: turning text + * into a conditional seeds the "then" branch with that text, which is also the natural + * way to author one (write the common case, then add the condition). + */ + +const MODE_LABELS: Record = { + text: 'Text', + value: 'Data', + conditional: 'Conditional', + custom: 'Custom', +}; + +/** + * Shown while a mode is switched but not yet usable. Switching mode never destroys the + * existing content — the schema keeps rendering it until the new form is complete — so + * this says so rather than leaving the panel looking like the change was lost. + */ +function PendingHint(props: { children: string }) { + return ( + + + + {props.children} + + + ); +} + +export function ContentEditor(props: { + /** The node's `children` array. Not named `children` — Solid special-cases that in JSX. */ + content: unknown[] | undefined; + scope: ScopeGroup[]; + /** Node id — resets the mode when the selection changes. */ + nodeId: string | undefined; + onTextChange: (value: string) => void; + onTokenChange: (value: unknown) => void; + onChildrenChange: (children: unknown[]) => void; +}) { + const [modeOverride, setModeOverride] = createSignal(null); + // Working copy of a conditional while it is still incomplete. Until the condition is + // set there is no valid token to write, and writing a half-built $if would break the + // render — so the schema keeps its old content until the conditional is usable. + const [ifDraft, setIfDraft] = createSignal | null>(null); + + createEffect( + on( + () => props.nodeId, + () => { + setModeOverride(null); + setIfDraft(null); + }, + { defer: true }, + ), + ); + + const token = createMemo(() => props.content?.[0]); + const mode = createMemo(() => modeOverride() ?? classifyContent(props.content)); + + const modeOptions = createMemo(() => { + const modes: ContentShape[] = ['text', 'value', 'conditional']; + // "Custom" is a state you can leave but not choose. + if (mode() === 'custom') modes.push('custom'); + return modes.map((m) => ({ label: MODE_LABELS[m], value: m })); + }); + + function switchMode(next: ContentShape) { + if (next === mode()) return; + setModeOverride(next); + + if (next === 'text') { + // The seed is already a complete value, so converting is a single click. + setIfDraft(null); + props.onTextChange(contentAsText(token())); + return; + } + if (next === 'conditional') { + // Carry the current content into the "then" branch rather than discarding it. + const existing = parseValueIf(token()); + setIfDraft(existing ?? { condition: undefined, then: token() ?? '' }); + return; + } + setIfDraft(null); + } + + // ── Conditional mode ────────────────────────────────────────────────────── + + const currentIf = createMemo>(() => ifDraft() ?? parseValueIf(token()) ?? { then: '' }); + + function updateIf(patch: Partial) { + const next = { ...currentIf(), ...patch }; + setIfDraft(next); + // Only write once the conditional would actually render something. + if (next.condition !== undefined && next.condition !== null && next.then !== undefined && next.then !== '') { + props.onTokenChange(serializeValueIf(next as ValueIf)); + setIfDraft(null); + } + } + + const conditionalIncomplete = () => { + const value = currentIf(); + return value.condition === undefined || value.condition === null || value.then === undefined || value.then === ''; + }; + + // ── Data mode ───────────────────────────────────────────────────────────── + + const boundValue = createMemo(() => { + if (typeof token() === 'string') return undefined; + return parseValue(token()) ?? undefined; + }); + + return ( + + + + Content + + switchMode(e.detail as ContentShape)} + /> + + + + ) => props.onTextChange(e.detail)} + /> + + + + + props.onTokenChange(serializeValue(operand))} + valueType="string" + allowCount + placeholder="Bind to a value" + /> + + Pick a value to apply this — the current content stays until then. + + + + + + + updateIf({ condition })} + /> + + + + Then show + + updateIf({ then })} + placeholder="Value when true" + /> + + + + Otherwise show + + updateIf({ else: otherwise === '' ? undefined : otherwise })} + placeholder="Value when false" + /> + + + + + + + Set a condition and a value to apply this — the current content stays until then. + + + + + + + + + + Custom expression — edit as JSON, or switch to another mode to replace it. + + + props.onChildrenChange(JSON.parse(json))} + /> + + + + + ); +} diff --git a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx index e4bc092c..ff646c89 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx @@ -10,8 +10,8 @@ import { useVisualEditor } from '@we/schema-solid'; import type { JSX } from 'solid-js'; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; -import { CodeViewer } from './CodeViewer'; import { ConditionEditor } from './ConditionEditor'; +import { ContentEditor } from './ContentEditor'; import { ValueEditor } from './ValueEditor'; // ----------------------------------------------------------------------- @@ -642,35 +642,16 @@ function NodeProperties(props: { const templateStore = useTemplateStore(); const meta = createMemo(() => getComponentMeta(props.node.type ?? '', contextData)); - // Content — what `children` holds, classified so each shape gets the right editor. - // Text is the common case, but a single token ({ $store }, a value-level { $if }, a - // $concat) is just as common in real templates and used to have no editor at all: it - // isn't a string, so the text field skipped it, and `props`-only scanning never saw it. - const contentKind = createMemo<'text' | 'token' | 'tokens' | 'nodes'>(() => { - const children = props.node.children; - if (!children || children.length === 0) return 'text'; - if (children.some((c) => isPropsSchemaNode(c))) return 'nodes'; - if (children.length === 1) return typeof children[0] === 'string' ? 'text' : 'token'; - return children.every((c) => typeof c === 'string') ? 'text' : 'tokens'; - }); + // Content — `children` holds either child nodes (owned by the Layers tree) or content + // the ContentEditor owns: text, a bound value, or a value-level conditional. + const hasChildNodes = createMemo(() => !!props.node.children?.some((c) => isPropsSchemaNode(c))); const showContent = createMemo(() => { - const kind = contentKind(); - if (kind === 'nodes') return false; - if (kind !== 'text') return true; - const children = props.node.children; - if (children && children.length === 1) return true; + if (hasChildNodes()) return false; + if (props.node.children && props.node.children.length > 0) return true; return TEXT_CONTENT_TYPES.has(props.node.type ?? ''); }); - const contentValue = createMemo(() => { - const children = props.node.children; - return children && children.length === 1 && typeof children[0] === 'string' ? children[0] : ''; - }); - - /** The single token in `children`, for the token editor. */ - const contentToken = createMemo(() => props.node.children?.[0]); - // Current prop values set on this node const currentProps = createMemo(() => props.node.props ?? {}); @@ -746,50 +727,15 @@ function NodeProperties(props: { {/* Content — text, a bound value, or a value-level conditional */} - - Content - - - ) => props.onContentChange(e.detail)} - /> - - - {/* A single token — bound to data, or a conditional between two values */} - - - - - - - {/* Several tokens concatenated in place — no row equivalent, so raw JSON */} - - - - Multiple content parts — edit as JSON - - - props.onChildrenChange(JSON.parse(json))} - /> - - - + + diff --git a/packages/schema-system/shared/src/conditionModel.test.ts b/packages/schema-system/shared/src/conditionModel.test.ts index 6299b390..22bfb701 100644 --- a/packages/schema-system/shared/src/conditionModel.test.ts +++ b/packages/schema-system/shared/src/conditionModel.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import type { ConditionExpr } from './conditionModel'; import { + classifyContent, + contentAsText, emptyComparison, isBlankComparison, parseCondition, @@ -232,6 +234,38 @@ describe('value positions', () => { }); }); +describe('content shapes', () => { + const cases: [string, unknown[] | undefined, string][] = [ + ['no children', undefined, 'text'], + ['empty children', [], 'text'], + ['a plain string', ['About this space'], 'text'], + ['a store binding', [{ $store: 'spaceStore.currentSpace.name' }], 'value'], + ['a context ref', ['$agent.firstName'], 'text'], + ['a value-level $if', [{ $if: { condition: { $local: 'x' }, then: 'a', else: 'b' } }], 'conditional'], + ['a $concat', [{ $concat: ['a', 'b'] }], 'custom'], + ['several parts', ['Hello ', { $store: 'a.b' }], 'custom'], + ]; + + it.each(cases)('classifies %s', (_label, children, expected) => { + expect(classifyContent(children)).toBe(expected); + }); + + it('reads a context ref string as text, since that is how it is authored', () => { + // '$agent.firstName' is a bare string in children — the text field round-trips it. + expect(contentAsText('$agent.firstName')).toBe('$agent.firstName'); + }); + + it('collapses a conditional to its then branch when converting to text', () => { + expect(contentAsText({ $if: { condition: { $local: 'x' }, then: 'Shared', else: 'Personal' } })).toBe('Shared'); + }); + + it('has no text reading for a binding or a custom expression', () => { + expect(contentAsText({ $store: 'a.b' })).toBe(''); + expect(contentAsText({ $concat: ['a'] })).toBe(''); + expect(contentAsText({ $if: { condition: true, then: { $store: 'a.b' } } })).toBe(''); + }); +}); + describe('editing helpers', () => { it('marks a fresh comparison as blank', () => { expect(isBlankComparison(emptyComparison())).toBe(true); diff --git a/packages/schema-system/shared/src/conditionModel.ts b/packages/schema-system/shared/src/conditionModel.ts index 3a1de287..1105adb5 100644 --- a/packages/schema-system/shared/src/conditionModel.ts +++ b/packages/schema-system/shared/src/conditionModel.ts @@ -281,6 +281,35 @@ export function serializeValueIf(value: ValueIf): unknown { return { $if: inner }; } +/** + * The authoring shapes a node's `children` can take when it holds content rather than + * child nodes. Drives which editor the inspector shows, and what converting between + * them means. + */ +export type ContentShape = 'text' | 'value' | 'conditional' | 'custom'; + +export function classifyContent(children: unknown[] | undefined): ContentShape { + if (!children || children.length === 0) return 'text'; + // Several entries concatenated in place have no single-control equivalent. + if (children.length > 1) return 'custom'; + const [only] = children; + if (typeof only === 'string') return 'text'; + if (parseValueIf(only)) return 'conditional'; + if (parseValue(only)) return 'value'; + return 'custom'; +} + +/** + * The plain-text reading of a content token, used when converting to text. A conditional + * collapses to its `then` branch — the branch that renders in the common case. + */ +export function contentAsText(token: unknown): string { + if (typeof token === 'string') return token; + const branch = parseValueIf(token); + if (branch && typeof branch.then === 'string') return branch.then; + return ''; +} + // ── Editing helpers ───────────────────────────────────────────────────────── export function isUnaryOperator(operator: ComparisonOperator): boolean { diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index 36434a92..e2d3ac49 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -92,6 +92,8 @@ export type { ComponentMeta, PropMeta, PropLayer } from './componentMeta'; export { findNodeChain, findScopeRef, getScopeAtNode, scopeRefToToken } from './scope'; export type { ScopeGroup, ScopeOptions, ScopeRef, ScopeRefKind, ScopeValueType } from './scope'; export { + classifyContent, + contentAsText, emptyComparison, isBlankComparison, isUnaryOperator, @@ -110,6 +112,7 @@ export type { ConditionExpr, ConditionGroup, ConditionOperand, + ContentShape, FormStateToken, ValueIf, } from './conditionModel'; From 3aea7980d3cb04db31500fa989f2068e4767e102 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 14:59:30 +0100 Subject: [PATCH 05/12] fix(editor): use design-system props instead of the style escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md says to reach for `styles` only for CSS with no DS equivalent, but the editor panels had drifted far enough that copying the surrounding file actively worked against the rule — InspectorPanel alone carries ~124 raw-style uses. The new logic editors inherited that by imitation. Converts every DS-expressible use in them: style={{ width }} → width=, styles={{ 'max-height' }} → maxHeight=, style={{ flex: '1' }} → flex=, we-scroll-area's own maxHeight prop, and three wrapper divs promoted to Column with position/zIndex/top/left/mt/minWidth/maxWidth. What remains is deliberate: white-space (no DS equivalent) and one
the outside-click handler needs — no precedent exists in the repo. Adds an ESLint rule so this is mechanical rather than remembered. Core no-restricted-syntax with an esquery selector — no new plugin — flagging style/styles object keys that name a CSS property with a DS prop of the same meaning. Scoped so it does not cry wolf. The naive version flagged 521 sites repo-wide, almost all raw
where there is no DS prop to switch to; limiting it to elements that actually accept DS props (we-* primitives, Column/Row/Grid/Card) cuts that to 99 across 7 files. @we/components and @we/widgets are excluded — they *implement* DS props and legitimately write raw CSS — as are the React playgrounds. Error rather than warning: the repo lints with --max-warnings 0, so a warning would fail CI identically while reading as advisory. The 7 pre-existing files sit in an explicit debt override to be swept file by file; new files get the rule from birth. Verified the rule adds zero failures against the dev baseline. Known limit: a DS type union narrower than the CSS value space can make a hit unfixable — RightPanelContainer needs cursor: 'ew-resize' and the Cursor type has only four keywords. The fix there is to widen the DS type, not to disable the rule. Co-Authored-By: Claude Opus 5 (1M context) --- eslint.config.js | 144 ++++++++++++++++++ .../components/editor/ConditionEditor.tsx | 8 +- .../solid/components/editor/ValueEditor.tsx | 2 +- .../components/editor/ValueRefPicker.tsx | 28 ++-- 4 files changed, 158 insertions(+), 24 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 5610e04a..c7952e58 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,115 @@ import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; import prettierConfig from './.prettierrc.json' with { type: 'json' }; +/** + * CSS properties (in both camelCase and kebab-case spellings) that have a design-system + * prop of the same meaning. Sourced from the DS prop layers in + * `packages/design-system/utils/src/index.ts`. + * + * Properties with no DS equivalent — white-space, filter, clip-path, backdrop-filter, + * mix-blend-mode, grid-template-*, object-fit — are deliberately absent: `styles` is the + * correct tool for those. + */ +const DS_PROP_EQUIVALENTS = [ + 'width', + 'height', + 'minWidth', + 'min-width', + 'maxWidth', + 'max-width', + 'minHeight', + 'min-height', + 'maxHeight', + 'max-height', + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'zIndex', + 'z-index', + 'display', + 'overflow', + 'flex', + 'alignSelf', + 'align-self', + 'margin', + 'marginTop', + 'margin-top', + 'marginRight', + 'margin-right', + 'marginBottom', + 'margin-bottom', + 'marginLeft', + 'margin-left', + 'padding', + 'paddingTop', + 'padding-top', + 'paddingRight', + 'padding-right', + 'paddingBottom', + 'padding-bottom', + 'paddingLeft', + 'padding-left', + 'gap', + 'background', + 'backgroundColor', + 'background-color', + 'color', + 'opacity', + 'border', + 'borderColor', + 'border-color', + 'borderRadius', + 'border-radius', + 'boxShadow', + 'box-shadow', + 'cursor', + 'pointerEvents', + 'pointer-events', + 'transform', + 'transition', + 'fontSize', + 'font-size', + 'fontWeight', + 'font-weight', + 'lineHeight', + 'line-height', + 'letterSpacing', + 'letter-spacing', + 'textAlign', + 'text-align', + 'textDecoration', + 'text-decoration', + 'textTransform', + 'text-transform', + 'fontFamily', + 'font-family', + 'flexDirection', + 'flex-direction', + 'alignItems', + 'align-items', + 'justifyContent', + 'justify-content', +]; + +/** Elements that accept DS props: `we-*` primitives and the layout/composite components. */ +const DS_ELEMENTS = '^(we-|Column$|Row$|Grid$|Card$)'; + +function dsPropSelectors() { + const props = `^(${DS_PROP_EQUIVALENTS.join('|')})$`; + const message = + 'This CSS property has a design-system prop — use it instead of the style/styles escape hatch ' + + '(e.g. width="130px", maxHeight="250px"). Reserve styles for CSS with no DS equivalent.'; + // Object keys appear as identifiers (width) or string literals ('max-height'). + return ['key.name', 'key.value'].map((keyPath) => ({ + selector: + `JSXOpeningElement[name.name=/${DS_ELEMENTS}/] > JSXAttribute[name.name=/^styles?$/] ` + + `> JSXExpressionContainer > ObjectExpression > Property[${keyPath}=/${props}/]`, + message, + })); +} + export default [ { ignores: [ @@ -85,4 +194,39 @@ export default [ '@typescript-eslint/no-empty-object-type': 'off', }, }, + { + // Design-system props over the style/styles escape hatch. + // + // CLAUDE.md ("Using the Design System in TypeScript Components") says to use `styles` + // only for CSS with no DS equivalent — but that only held while people remembered it, + // and the editor panels drifted a long way. This makes the common half mechanical: + // if a CSS property has a DS prop of the same meaning, writing it through style/styles + // is an error. + // + // Scoped deliberately: + // - only elements that accept DS props (we-* primitives, Column/Row/Grid/Card). + // A raw
has no DS prop to use instead, so it is out of scope. + // - only DS-consuming packages. @we/components and @we/widgets *implement* DS props + // and legitimately write raw CSS; the React playgrounds have no DS at all. + name: 'design-system/prefer-ds-props', + files: ['packages/app-framework/**/*.tsx', 'packages/block-system/**/*.tsx', 'packages/schema-system/**/*.tsx'], + rules: { + 'no-restricted-syntax': ['error', ...dsPropSelectors()], + }, + }, + { + // Pre-existing violations, to be swept file by file. Delete entries as they are + // cleaned — the list only ever shrinks, and new files get the rule from birth. + name: 'design-system/prefer-ds-props-debt', + files: [ + 'packages/app-framework/src/frameworks/solid/components/editor/AiPanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/DesignToolbar.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/InspectorPanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/RightPanelContainer.tsx', + 'packages/app-framework/src/frameworks/solid/components/editor/ThemePanel.tsx', + 'packages/app-framework/src/frameworks/solid/components/marketplace/TemplateCard.tsx', + 'packages/block-system/frameworks/solid/src/components/VideoBlock/VideoDisplay.tsx', + ], + rules: { 'no-restricted-syntax': 'off' }, + }, ]; diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx index b45a5cee..29d53998 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx @@ -169,7 +169,7 @@ export function ConditionEditor(props: { border={`1px solid ${tokenVar('color', 'neutral-100')}`} r="200" overflow="hidden" - styles={{ 'max-height': '250px' }} + maxHeight="250px" > ( -
+ -
+
diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx index 05d68b70..9f8f6f25 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx @@ -50,7 +50,7 @@ export function ValueEditor(props: { border={`1px solid ${tokenVar('color', 'neutral-100')}`} r="200" overflow="hidden" - styles={{ 'max-height': '250px' }} + maxHeight="250px" > - setOpen((v) => !v)}> + setOpen((v) => !v)}> @@ -145,19 +145,9 @@ export function ValueRefPicker(props: { -
+ - + ) => setSearch(e.detail)} /> - + {(group) => ( @@ -237,7 +227,7 @@ export function ValueRefPicker(props: { -
+
); @@ -292,7 +282,7 @@ export function OperandInput(props: { if (props.valueType === 'boolean') { return ( count of -
+ props.onChange({ kind: 'count', items })} placeholder="Select a list" /> -
+ )} @@ -383,7 +373,7 @@ export function OperandInput(props: { {formState().token === 'error' ? 'error of' : formState().token === 'touched' ? 'edited' : 'valid'} Date: Fri, 31 Jul 2026 15:09:19 +0100 Subject: [PATCH 06/12] fix(schema-shared): take store property lists from the model registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The value picker offered spaceStore.currentSpace.uuid and .name but not .access, so a condition on it could not be rebuilt after being deleted — the only way back was hand-editing JSON. The cause was that StoreEntry.properties is hand-maintained, and had drifted: it listed 5 of Space's fields. access, discovery, url, coverImage, defaultThemeId and location were all missing, as were the base fields every Ad4mModel instance carries (id, author, createdAt, updatedAt) — despite templates using .author and .createdAt routinely. contextData.models already holds the full, generated Space definition, so the field list was being duplicated by hand next to a correct copy. Adds StateMemberMeta.model, naming the model a member holds instances of, and resolves properties from the model registry when it is set (unioned with any declared ones, since a store may expose computed fields the model doesn't have). Annotates the four model-typed members: spaceStore.currentSpace, adamStore's personal/sharedSpaces, and spaceStore.signalTypes. spaceStore.currentSpace now offers 15 properties instead of 5, and stays correct as Space changes. The prose descriptions are corrected to match, and the generated context files are regenerated alongside the fragment per the repo's convention. Co-Authored-By: Claude Opus 5 (1M context) --- .cursor/rules/we-schema.mdc | 6 +- .github/copilot-instructions.md | 6 +- CLAUDE.md | 6 +- packages/ai-context/context.json | 7 +- packages/ai-context/src/contextData.ts | 7 +- packages/ai-context/src/fragments/stores.ts | 14 ++-- packages/ai-context/src/schemaContext.ts | 2 +- .../schema-system/shared/src/contextTypes.ts | 6 ++ .../schema-system/shared/src/scope.test.ts | 70 ++++++++++++++++++- packages/schema-system/shared/src/scope.ts | 53 ++++++++++++-- 10 files changed, 148 insertions(+), 29 deletions(-) diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index dd29f1ac..72ecf264 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt) - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the "Initialize as WE space" gate; null once the perspective is a WE space or no recognized foreign model is found - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dd29f1ac..72ecf264 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt) - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the "Initialize as WE space" gate; null once the perspective is a WE space or no recognized foreign model is found - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/CLAUDE.md b/CLAUDE.md index dd29f1ac..72ecf264 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1285,8 +1285,8 @@ AdamStore: - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed) - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages) - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux) - - personalSpaces: array of Space objects (local/personal spaces) - - sharedSpaces: array of Space objects (shared/neighbourhood spaces) + - personalSpaces: array of Space objects (local/personal spaces; all Space fields) + - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields) - bootState: string - passwordError: string | undefined - loginLoading: boolean @@ -1364,7 +1364,7 @@ SpaceStore: - memberDids: string[] — DIDs of all members in the current space (includes own DID) - members: AgentProfileSummary[] — cached profiles for all memberDids - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active) - - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId) + - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt) - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the "Initialize as WE space" gate; null once the perspective is a WE space or no recognized foreign model is found - signalTypes: array of SignalType objects (community-created reaction/vote types) - signalTypesBySlug: Record — computed map; access via { $store: "spaceStore.signalTypesBySlug." }; use .id for the UUID diff --git a/packages/ai-context/context.json b/packages/ai-context/context.json index 0a8881e8..07a55fae 100644 --- a/packages/ai-context/context.json +++ b/packages/ai-context/context.json @@ -5335,11 +5335,11 @@ }, "personalSpaces": { "type": "array", - "properties": ["uuid", "name", "description", "url", "visibility"] + "model": "Space" }, "sharedSpaces": { "type": "array", - "properties": ["uuid", "name", "description", "url", "visibility"] + "model": "Space" }, "bootState": { "type": "string" @@ -5512,7 +5512,7 @@ }, "currentSpace": { "type": "object", - "properties": ["uuid", "name", "description", "avatar", "defaultTemplateId"] + "model": "Space" }, "foreignSpacePrefill": { "type": "object", @@ -5520,6 +5520,7 @@ }, "signalTypes": { "type": "array", + "model": "SignalType", "properties": [ "id", "name", diff --git a/packages/ai-context/src/contextData.ts b/packages/ai-context/src/contextData.ts index c86764da..74c42ba9 100644 --- a/packages/ai-context/src/contextData.ts +++ b/packages/ai-context/src/contextData.ts @@ -2019,8 +2019,8 @@ export const contextData: ContextData = { currentPerspective: { type: 'object', properties: ['uuid', 'name', 'sharedUrl'] }, currentPerspectiveModels: { type: 'array' }, isWeSpace: { type: 'boolean' }, - personalSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, - sharedSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, + personalSpaces: { type: 'array', model: 'Space' }, + sharedSpaces: { type: 'array', model: 'Space' }, bootState: { type: 'string' }, passwordError: { type: 'string' }, loginLoading: { type: 'boolean' }, @@ -2120,10 +2120,11 @@ export const contextData: ContextData = { properties: ['did', 'firstName', 'lastName', 'handle', 'bio', 'avatar', 'coverImage', 'location'], }, spaceDefaultTemplateId: { type: 'string' }, - currentSpace: { type: 'object', properties: ['uuid', 'name', 'description', 'avatar', 'defaultTemplateId'] }, + currentSpace: { type: 'object', model: 'Space' }, foreignSpacePrefill: { type: 'object', properties: ['name', 'description', 'avatar'] }, signalTypes: { type: 'array', + model: 'SignalType', properties: [ 'id', 'name', diff --git a/packages/ai-context/src/fragments/stores.ts b/packages/ai-context/src/fragments/stores.ts index d9827834..d5a2cf8c 100644 --- a/packages/ai-context/src/fragments/stores.ts +++ b/packages/ai-context/src/fragments/stores.ts @@ -17,8 +17,8 @@ export const storeEntries: StoreEntry[] = [ currentPerspective: { type: 'object', properties: ['uuid', 'name', 'sharedUrl'] }, currentPerspectiveModels: { type: 'array' }, isWeSpace: { type: 'boolean' }, - personalSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, - sharedSpaces: { type: 'array', properties: ['uuid', 'name', 'description', 'url', 'visibility'] }, + personalSpaces: { type: 'array', model: 'Space' }, + sharedSpaces: { type: 'array', model: 'Space' }, bootState: { type: 'string' }, passwordError: { type: 'string' }, loginLoading: { type: 'boolean' }, @@ -124,10 +124,11 @@ export const storeEntries: StoreEntry[] = [ properties: ['did', 'firstName', 'lastName', 'handle', 'bio', 'avatar', 'coverImage', 'location'], }, spaceDefaultTemplateId: { type: 'string' }, - currentSpace: { type: 'object', properties: ['uuid', 'name', 'description', 'avatar', 'defaultTemplateId'] }, + currentSpace: { type: 'object', model: 'Space' }, foreignSpacePrefill: { type: 'object', properties: ['name', 'description', 'avatar'] }, signalTypes: { type: 'array', + model: 'SignalType', properties: [ 'id', 'name', @@ -243,8 +244,8 @@ function generateStoresText(entries: StoreEntry[]): string { 'ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)', isWeSpace: "boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)", - personalSpaces: 'array of Space objects (local/personal spaces)', - sharedSpaces: 'array of Space objects (shared/neighbourhood spaces)', + personalSpaces: 'array of Space objects (local/personal spaces; all Space fields)', + sharedSpaces: 'array of Space objects (shared/neighbourhood spaces; all Space fields)', bootState: 'string', passwordError: 'string | undefined', loginLoading: 'boolean', @@ -347,7 +348,8 @@ function generateStoresText(entries: StoreEntry[]): string { members: 'AgentProfileSummary[] — cached profiles for all memberDids', spaceDefaultTemplateId: "string — the current space's default template ID (empty string when no space is active)", - currentSpace: 'Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId)', + currentSpace: + 'Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt)', foreignSpacePrefill: '{ name, description, avatar } | null — detected from a foreign app\'s own model (e.g. Flux\'s Community) for prefilling the "Initialize as WE space" gate; null once the perspective is a WE space or no recognized foreign model is found', signalTypes: 'array of SignalType objects (community-created reaction/vote types)', diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index c78347c0..af8cf3be 100644 --- a/packages/ai-context/src/schemaContext.ts +++ b/packages/ai-context/src/schemaContext.ts @@ -1,4 +1,4 @@ // AUTO-GENERATED by packages/ai-context/src/generate.ts // Do not edit manually. Run: pnpm --filter @we/ai-context generate-context -export const schemaContext = "## Schema Structure\n\nA schema is a tree of nodes. Each node can have:\n- type: The component to render (string, e.g. \"we-button\", \"Column\")\n- props: An object of props for the component\n- children: An array of child nodes (or strings for text), or token objects like { $store: '...' } or { $concat: [...] }.\n- slots: Named slots for advanced composition (optional)\n- slot: The name of the slot this node should be rendered into (optional)\n- routes: For routing components, an array of nestable route objects (optional)\n- styles: Raw CSS escape hatch — Record applied as inline styles on a **wrapper div** that surrounds the component. Use only for CSS that must live on a wrapper: filter, clip-path, backdrop-filter, mix-blend-mode. When present the wrapper participates in layout (no display:contents), so CSS effects apply correctly. **Important:** this is NOT the same as props.styles. If you want to apply custom CSS to a Column, Row, or Grid's own element (e.g. a background image), put it in props.styles instead — node-level styles go on a wrapper div around the component and will be hidden behind the component's own background.\n\nExample node:\n{\n \"type\": \"we-button\",\n \"props\": {\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"house\" } },\n { \"type\": \"we-text\", \"props\": { \"size\": \"600\" }, \"children\": [\"Home\"] }\n ]\n}\n\n## Prop-level Dynamic Logic & Expressions\n\nSpecial tokens in props enable dynamic, reactive, or computed behavior.\n\nStore reference:\n{ \"$store\": \"storeName.property.path\" }\nResolves a value from a named store, supporting nested paths.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nSupports async lifecycle callbacks — fired after the store method's Promise resolves/rejects:\n onSuccess: [...actions] — fired on resolve; '$result' (and '$result.') in args refers to the resolved value\n onError: [...actions] — fired on reject; '$result.message' etc. refers to the error object\n onFinally: [...actions] — fired regardless of outcome\nNon-promise (synchronous) methods are unaffected — lifecycle keys are ignored.\nExample — close modal after async submission:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$result.uuid\"] }] }] }\n\nModel mutations via $action (use these for creating/updating/deleting model instances):\nmodel.create — creates a model instance in the current perspective (default) or a specified one:\n{ \"$action\": \"model.create\", \"args\": [\"ModelName\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nmodel.update — updates a model instance:\n{ \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'adamStore.rootPerspective' for we-root models (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped models (Space, Signal, etc.).\n\nConditional logic:\n{ \"$if\": { \"condition\": ..., \"then\": ..., \"else\": ... } }\nEvaluates condition; if truthy, returns then, else returns else.\n\nMap/iterate:\n{ \"$map\": { \"items\": { \"$store\": \"templateStore.templates\" }, \"select\": { ... } } }\nIterates over an array, mapping each item to a new object using the select mapping.\n\nPick:\n{ \"$pick\": { \"from\": { \"$store\": \"userStore.profile\" }, \"props\": [\"name\", \"email\"] } }\nPicks specific properties from an object.\n\nConcat (string building):\n{ \"$concat\": [\"part1\", \"$context.value\", \"part2\"] }\nJoins multiple parts into a single string.\n\nContext references:\nStrings starting with \"$\" followed by a context key resolve to context values.\nExample: \"$space.name\" resolves to the name property of the space context variable.\nDot paths supported: \"$item.profile.avatar\".\n\nEquality / inequality checks:\n{ \"$eq\": [a, b] } — strict equality\n{ \"$ne\": [a, b] } — strict inequality\n\nNumeric comparisons:\n{ \"$lt\": [a, b] } — a < b (less than)\n{ \"$gt\": [a, b] } — a > b (greater than)\nExample: { \"$gt\": [{ \"$count\": { \"items\": { \"$store\": \"listStore.items\" } } }, 0] }\n\nSet membership:\n{ \"$in\": [value, array] } — true if array contains value (false if second operand is not an array)\nExample: { \"$in\": [{ \"$store\": \"spaceStore.uuid\" }, { \"$store\": \"adamStore.systemPerspectiveUuids\" }] }\nExample: { \"$in\": [\"$item.role\", [\"admin\", \"moderator\"]] }\n\nBoolean logic:\n{ \"$and\": [a, b, ...] } — all truthy\n{ \"$or\": [a, b, ...] } — any truthy\n{ \"$not\": a } — negation\n\nArray operators:\n{ \"$filter\": { \"items\": , \"where\": { \"field\": \"value\", ... } } }\nFilters an array to items where all where conditions match. Mirrors the $query where operator set:\n\n { \"field\": \"value\" } — strict equality\n { \"field\": { \"not\": \"value\" } } — inequality; array form excludes multiple values\n { \"field\": { \"contains\": \"text\" } } — case-insensitive substring match (strings only)\n { \"field\": { \"exists\": true } } — non-null / non-undefined presence check\n { \"field\": { \"exists\": false } } — null or undefined check\n\nWhere values (including those inside operator objects) are resolved through the prop system,\nso $store, $local, and context refs like { \"$local\": \"searchText\" } all work.\n\n$query-only logical combinators (OR / AND / NOT) — NOT supported in $filter, only in $query's where:\n { \"OR\": [ { \"field\": \"value\" }, { \"field2\": \"value2\" } ] } — matches if ANY branch matches\n { \"AND\": [ { ... }, { ... } ] } — matches if ALL branches match (sibling keys at the\n same level are already implicitly ANDed — use AND\n to group a set of conditions alongside an OR/NOT)\n { \"NOT\": { \"field\": \"value\" } } — matches if the branch does NOT match\nBranches are full where-clause objects (can contain multiple fields, and can nest OR/AND/NOT inside each other).\nSibling keys alongside OR/AND/NOT at the same level are implicitly ANDed with it.\nExample — case-insensitive search across two fields:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"where\": {\n \"OR\": [\n { \"name\": { \"contains\": { \"$local\": \"searchText\" } } },\n { \"description\": { \"contains\": { \"$local\": \"searchText\" } } }\n ]\n }\n }\n}\nNote: using OR/AND/NOT disables the SPARQL-level sort/pagination pushdown (see count-projection and\nrelation-property ordering below) — those orderings silently stop working if combined with OR/AND/NOT in the\nsame query's where clause, because the fallback sort runs before the projection/relation data is attached.\n\nExamples:\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"role\": \"admin\" } } }\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"location\": { \"exists\": true }, \"handle\": { \"contains\": { \"$local\": \"searchText\" } } } } }\n\n{ \"$count\": { \"items\": } }\nReturns the length of an array.\nExample: { \"badge\": { \"$count\": { \"items\": { \"$store\": \"notificationStore.unread\" } } } }\n\n{ \"$find\": { \"items\": , \"where\"?: { ... }, \"select\"?: \"fieldName\" } }\nFinds the first matching item. where is optional (returns first item if omitted). select plucks a single field.\nExample: { \"$find\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"id\": \"$item.creatorId\" }, \"select\": \"name\" } }\n\n{ \"$plural\": { \"count\": , \"one\": \"singular\", \"other\": \"plural\" } }\nReturns \"one\" when count === 1, otherwise \"other\". Use in children arrays for count-noun labels.\ncount is resolved through the prop system — any numeric expression ($count, $store, context ref) works.\nExample: { \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }\nCompose with we-number for a full \"N Members\" display:\n we-number (value: { \"$count\": ... }, shorten: true) + we-text (children: [{ \"$plural\": { \"count\": { \"$count\": ... }, \"one\": \"Member\", \"other\": \"Members\" } }])\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"ModelName\", \"where\": { \"field\": \"value\" }, \"limit\": 10, \"order\": { \"field\": \"asc\" } } }\nQueries the current dataset for entity instances. Always returns an array.\nOptions: entity (required), where, order, limit, offset, include, scope, dataset, subscribe.\nsubscribe defaults to true — reactive live updates. Set subscribe: false to do a one-time fetch.\nBy default $query targets the current dataset ($currentDataset). Use dataset to query a different dataset —\nrequired when reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } }\n\nBackend-neutral identity & dataset refs — prefer these over adamStore.* store paths inside $query and conditions:\n- $currentDataset — the currently active dataset (an AD4M perspective, in the AD4M backend). Use as a dataset value.\n- $me — the current agent's identity object. Use $me.did for their DID (ownership checks, author filters, e.g. { \"$eq\": [\"$post.author\", \"$me.did\"] }); $me.handle / $me.avatar for profile fields once loaded.\n\nEager-loading relations with include (most common relational pattern):\ninclude hydrates related model instances in the same query — no extra fetches needed.\nRelation names come from the HasMany relations listed for each model in externalModels.\n\nSimple include — hydrate all related instances:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": true } } }\nEach item in the result will have a conversations array of hydrated Conversation objects.\n\nSub-query include — filter, sort, or limit the related records:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"order\": { \"createdAt\": \"desc\" }, \"limit\": 10 } } } }\n\nNested include — hydrate relations of relations:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"include\": { \"messages\": true } } } } }\nNesting can go as deep as needed. Each level adds one batched fetch (not N+1).\n\nCount projection — add a derived numeric field:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } } } }\nThe $-prefixed key becomes a new field on each result item (e.g. item.$likeCount = 42).\n\nSorting by a count projection — order can reference a $-prefixed count key directly, sorting by the aggregate:\n{\n \"$query\": {\n \"entity\": \"Post\",\n \"limit\": 20,\n \"order\": { \"$likeCount\": \"desc\" },\n \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } }\n }\n}\nRequirements: only a single order key is supported when it targets a projection (mixing it with a second sort key falls back\nto a plain property sort), and the query must also specify limit or offset — without one the count isn't computed yet at\nsort time and the order silently has no effect. Always pair count-projection ordering with a limit.\nCombine with $if for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": {\n \"$if\": {\n \"condition\": { \"$eq\": [{ \"$local\": \"sortField\" }, \"likes\"] },\n \"then\": { \"$likeCount\": { \"$local\": \"sortDirection\" } },\n \"else\": { \"createdAt\": { \"$local\": \"sortDirection\" } }\n }\n }\n}\n\nSorting by a related model property — order can reference a dotted \"relation.property\" path for a HasOne/HasMany\nrelation declared on the model, sorting by a scalar property on the related instance:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"limit\": 20,\n \"order\": { \"location.country\": \"asc\" },\n \"include\": { \"location\": true }\n }\n}\nSame requirements as count-projection ordering above: only a single order key, and pair with limit/offset — without\none the relation data isn't attached yet at sort time and the order silently has no effect. include isn't required\nfor the sort itself (the relation is resolved from the model's declared shape), but you'll usually want it anyway to\nread the field in the UI (e.g. \"$space.location.country\").\nCombine with $if the same way as count-projection ordering to let the user toggle between sort fields.\n\nSingle-item projection — add a derived field that resolves to one instance or null:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$myLike\": { \"from\": \"likes\", \"where\": { \"author\": \"$me.did\" }, \"limit\": 1 } } } }\nWith limit: 1 the field unwraps to T | null instead of an array.\n\ninclude only works with typed relations — ones where the target model class is known.\nFor WE models this is always the case. For external models, check the externalModels listing:\nrelations marked \"→ ModelName\" are typed (safe for include); relations marked \"parent query only\"\nare untyped and will crash at runtime if used with include — use a scope drill-down instead.\n\nRelational queries — fetch a parent record's children (drill-down navigation):\n{ \"$query\": { \"entity\": \"Conversation\", \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": \"$channel.id\" } } }\nscope.anchor is the parent entity type; scope.via is its relation whose targets are this query's entity (the\nHasMany relation listed for that entity in externalModels); scope.anchorId is the parent record's id (typically\nfrom a $each context variable or a route segment). The adapter resolves the relation to a backend handle —\nno protocol details live in the template.\nUse this pattern when navigating to a detail route and loading only that record's children.\nFor external-app datasets, always add dataset: \"$currentDataset\".\n\nLocal state (scoped ephemeral state):\nDeclare on any node: \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\" } }\nSupported types: \"string\", \"boolean\", \"number\", \"function\", \"object\".\nRead: { \"$local\": \"name\" } — returns the signal value (reactive).\n { \"$local\": \"name.nested.path\" } — dot-notation reads into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"from\": \"$event.target.value\" } — event handler that updates the signal.\n { \"$setLocal\": \"name\", \"value\": \"literal\" } — sets to a literal value (string, number, boolean, null, object).\n { \"$setLocal\": \"name\", \"merge\": { \"field\": \"$event.detail\" } } — shallow-merges fields into an object-typed signal. Values are resolved as event paths (e.g. \"$event.detail\") or passed as literals. Use for partial updates to object state.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nCall function: { \"$callLocal\": \"fieldName\" } — event handler that calls the function stored in a function-typed local field.\n Used when a child component needs to trigger a callback passed in via $localState.\n The field must be declared as type: 'function' and set via $setLocal.\n Example: { \"onClick\": { \"$callLocal\": \"onConfirm\" } }\nState is created on mount and destroyed on unmount. Nested $localState declarations merge, inner fields shadow outer.\n$local values can be used in $action args: { \"$action\": \"store.method\", \"args\": [{ \"$local\": \"name\" }] }\n\nObject-typed local state (consolidating related scalar fields):\nWhen several related fields share a common condition on their initial values (e.g. all null/empty when a store value is absent), prefer a single \"object\" field seeded from the store, then read sub-fields with dot-notation and write with merge.\nExample — location object (replaces 5 separate scalar fields with $if guards):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$store\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$local\": \"location.latitude\" }, { \"$local\": \"location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"from\": \"$event.detail\" }\n Write (partial edit): { \"$setLocal\": \"location\", \"merge\": { \"city\": \"$event.detail\" } }\n Write (clear): { \"$setLocal\": \"location\", \"value\": null }\n Condition (has location): { \"$local\": \"location\" }\nUse \"object\" whenever you would otherwise write 3+ related scalar fields each needing $if on their initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results in $local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available for $if conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into $local as read-only reactive arrays, accessible via { \"$local\": \"signalTypes\" }.\nQuery options are identical to $each's $query prop (entity, where, order, limit, include, dataset, subscribe).\n$queries and $localState share the same $local namespace — avoid duplicate names across both.\n$setLocal will warn and no-op on $queries entries (they are read-only).\nUse with $count + $gt for conditional visibility:\n{ \"condition\": { \"$gt\": [{ \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } }, 0] } }\nExample:\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [...]\n }\n ]\n}\n\nBoolean toggle pattern (show/hide comments, expand/collapse sections, etc.):\n{\n \"$localState\": { \"showComments\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$toggleLocal\": \"showComments\" }\n },\n \"children\": [{ \"type\": \"we-icon\", \"props\": { \"name\": \"chat-circle\" } }]\n },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"showComments\" },\n \"then\": { \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Comments visible\"] }] }\n }\n }\n ]\n}\n\nForm validation (extends $localState):\nDeclare validation rules on fields:\n\"$localState\": {\n \"email\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [\n { \"rule\": \"required\", \"message\": \"Email is required\" },\n { \"rule\": \"pattern\", \"value\": \"^[^@]+@[^@]+$\", \"message\": \"Invalid email\" }\n ]\n }\n}\n\nBuilt-in rules: required, minLength (value: N), maxLength (value: N), min (value: N), max (value: N), pattern (value: regex string), match (field: otherFieldName). All accept optional \"message\" override.\n\nRead tokens:\n{ \"$error\": \"fieldName\" } — first validation error message (only shown after field is touched), or \"\".\n{ \"$valid\": \"fieldName\" } — true if all rules pass (regardless of touched state).\n{ \"$touched\": \"fieldName\" } — true after the field has been blurred/touched.\n{ \"$formValid\": \"$scope\" } — true if ALL validated fields in the current $localState scope pass.\n\nAction tokens:\n{ \"$touch\": \"fieldName\" } — marks a single field as touched (use in onBlur).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. Non-function entries (e.g. $if with false condition) are skipped.\nPrefer onSuccess over a bare $setLocal before the $action — the bare form closes the modal immediately (losing the loading spinner); onSuccess waits for the Promise to resolve.\n\nTypical form pattern:\n{\n \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] } },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a context key. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nConditional rendering:\n{ \"type\": \"$if\", \"props\": { \"condition\": ..., \"then\": { ... }, \"else\": { ... } } }\nRenders \"then\" node if condition is truthy, else renders \"else\" node.\nSupports enterTransition / exitTransition for CSS animations when the node mounts/unmounts.\nTransitionConfig = TransitionEffect | TransitionEffect[]\nTransitionEffect = { type: 'fade'|'slide'|'scale'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string }\nfade controls opacity only; slide/scale control transform only. pulse is a persistent looping animation (not a one-shot transition) — starts once entered, stops on exit; direction/distance don't apply (default duration 1200ms, easing 'ease-in-out'). Compose fade/slide/scale together in an array; pulse is typically used alone.\nExample: enterTransition: [{ type: 'fade', duration: 300 }, { type: 'slide', direction: 'up', distance: '40px', duration: 400 }]\nExample (pulse): enterTransition: { type: 'pulse', duration: 1500 }\n\nViewport / mount animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"scrollReveal\"?: true | number, \"scrollLeave\"?: true | number, \"scrollPast\"?: string, \"enterTransition\"?: TransitionConfig, \"exitTransition\"?: TransitionConfig }, \"children\": [] }\nThe child is always mounted. fade/slide/scale are CSS transitions (opacity/transform); pulse is a real CSS @keyframes loop — use this for scroll-reveal effects.\nDo NOT use $animate when the child should be absent from the DOM. Use $if for conditional DOM presence.\nscrollReveal: true fires enterTransition when the element enters the viewport.\nscrollReveal: -100 fires 100px before the element would enter (negative = earlier reveal).\nscrollLeave fires exitTransition when the element leaves the viewport.\nscrollPast: \"element-id\" observes a sentinel element (by DOM id) instead of the $animate element itself.\n enterTransition fires when the sentinel leaves the viewport (user scrolled past it).\n exitTransition fires when the sentinel returns (user scrolled back up).\n Use this for sticky headers: place a zero-height sentinel div at the bottom of the non-sticky header section,\n then wrap the mini-profile in $animate with scrollPast pointing to that sentinel's id.\n scrollPast is mutually exclusive with scrollReveal/scrollLeave.\nWithout any scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (scroll-reveal):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollReveal\": -100,\n \"enterTransition\": [\n { \"type\": \"fade\", \"duration\": 600, \"easing\": \"ease-in-out\" },\n { \"type\": \"slide\", \"direction\": \"left\", \"distance\": \"200px\", \"duration\": 1000, \"easing\": \"ease-in-out\" }\n ]\n },\n \"children\": [{ \"type\": \"SomeCard\", \"children\": [] }]\n}\nExample (sticky header mini-profile):\nPlace a sentinel at the bottom of the header, reference it in the sticky nav:\n{ \"type\": \"div\", \"props\": { \"id\": \"header-sentinel\" }, \"styles\": { \"height\": \"0px\", \"pointerEvents\": \"none\" } }\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollPast\": \"header-sentinel\",\n \"enterTransition\": { \"type\": \"fade\", \"duration\": 250 },\n \"exitTransition\": { \"type\": \"fade\", \"duration\": 200 }\n },\n \"children\": [{ \"type\": \"Row\", \"props\": { \"ay\": \"center\", \"gap\": \"300\" }, \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"600\" }, \"children\": [\"$space.name\"] }\n ]}]\n}\n\nSingle model item (load one record, render children with it in context):\n{\n \"type\": \"$single\",\n \"props\": {\n \"item\": { \"$query\": { \"entity\": \"ModelName\", \"params\": { ... }, \"subscribe\": true } },\n \"as\": \"profile\" // context key for children — default: 'item'\n },\n \"children\": [{ \"type\": \"we-text\", \"children\": [\"$profile.username\"] }]\n}\nRenders nothing until a matching record is found. Like $each but for a single result.\nquery options (entity, params, include, dataset, subscribe) work identically to $query.\n\nRoute outlet:\n{ \"type\": \"$routes\" }\nIndicates where nested routes should render within a layout.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', dismissible: boolean = false\n- we-audio (LayoutVisualElement)\n Props: src: string = '', controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', selected: boolean = false, online: boolean = false, initials: string = '', icon: string = '', size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | '{css-length}' | undefined, clickable: boolean = false\n- we-badge (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-blockquote (DesignSystemElement)\n- we-button (DesignSystemElement)\n Props: variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', text?: string | undefined, href?: string | undefined, disabled: boolean = false, loading: boolean = false, gradient: boolean = false, square: boolean = false\n- we-checkbox (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-code (DesignSystemElement)\n Props: block: boolean = false\n- we-color-picker (DesignSystemElement)\n Props: value: string = '#000000', disabled: boolean = false, name: string = '', palette: array = [ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc', ]\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', placeholder: string = 'Select date', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-divider (LayoutElement)\n Props: orientation: 'horizontal' | 'vertical' = 'horizontal', variant: 'solid' | 'dashed' | 'dotted' = 'solid', color?: string | undefined, thickness?: string | undefined\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-file-upload (DesignSystemElement)\n Props: accept: string = '', multiple: boolean = false, disabled: boolean = false, name: string = ''\n- we-form-field (DesignSystemElement)\n Props: label: string = '', description: string = '', error: string = '', required: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-html (DesignSystemElement) — Renders a raw HTML string safely via DOMPurify sanitization.\n\nUse this instead of `we-text` when content is stored as HTML (e.g. rich-text\neditor output such as Flux messages). The `content` prop accepts any HTML\nfragment; it is sanitized before rendering so XSS payloads are stripped.\n Props: content: string = ''\n- we-icon (LayoutElement)\n Props: name: string = '', color: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '{css-length}' = '', weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' = 'regular', gradient: string = ''\n- we-icon-picker (DesignSystemElement)\n Props: value: string = '', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', placeholder: string = 'Pick icon'\n- we-iframe (LayoutVisualElement)\n Props: src: string = '', title: string = 'Embedded content', allow: string = '', sandbox?: string | undefined\n- we-image (LayoutVisualElement)\n Props: src: string | File = '', alt: string = '', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', loading: 'eager' | 'lazy' = 'eager', gradient: string = '', objectPosition: string = ''\n- we-input (DesignSystemElement)\n Props: value: string = '', max: string = '', min: string = '', maxlength: unknown = Infinity, minlength: number = 0, pattern: string = '', name: string = '', step: string = '', placeholder: string = '', autocomplete: string = '', autofocus: boolean = false, disabled: boolean = false, required: boolean = false, readonly: boolean = false, type: string = 'text', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-number (DesignSystemElement) — Displays a number, optionally abbreviated (1 200 → 1.2K, 1 500 000 → 1.5M).\n Props: value: number = 0, shorten: boolean = false, precision: number = 1, locale: string = 'en', formattedValue: string\n- we-number-input (DesignSystemElement)\n Props: value: number = 0, min: number = -Infinity, max: unknown = Infinity, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-pagination (DesignSystemElement)\n Props: page: number = 1, total: number = 1, siblings: number = 1, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-popover (LayoutElement) — Low-level floating panel anchored to a trigger element.\nUse DropdownMenu component for dropdown menus.\n Props: open: boolean = false, placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom', popoverElement: HTMLElement, triggerElement: HTMLElement\n- we-progress-bar (DesignSystemElement)\n Props: value: number = 0, max: number = 100, variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-radio (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = ''\n- we-select (DesignSystemElement)\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-select (DesignSystemElement) — Pick a single value from a list of options. Custom-rendered dropdown.\nUse for form fields, settings, filters. Set searchable=true for type-to-filter.\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-skeleton (DesignSystemElement)\n Props: width: string = '100%', height: string = '20px', animation: 'pulse' | 'wave' = 'pulse'\n- we-slider (DesignSystemElement)\n Props: value: number = 0, min: number = 0, max: number = 100, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — Drag-to-reorder container primitive.\n\nUsage: wrap a list of elements that each have a `data-we-id` attribute.\nFires a `we-reorder` CustomEvent on drop with the new ordered\narray of IDs.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = ''\n- we-spinner (LayoutElement)\n Props: size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) = 'md', color: string = ''\n- we-switch (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', labelOff: string = '', labelOn: string = ''\n- we-tab (DesignSystemElement)\n Props: key: string = '', selected: boolean = false, label?: string | undefined, selectedProps?: Partial | undefined\n- we-tabs (DesignSystemElement)\n Props: selectedKey: string = ''\n- we-tag (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', dismissible: boolean = false\n- we-text (DesignSystemElement)\n Props: text?: string | undefined, variant: '' | 'body' | 'label' | 'footnote' | 'subheading' | 'ingress' | 'heading-sm' | 'heading-md' | 'heading-lg' | 'heading-xl' = '', tag: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small' | 'b' | 'i' | 'label' | 'div' = 'span', inline: boolean = false, uppercase: boolean = false, italic: boolean = false, truncate: boolean = false, gradient: string = ''\n- we-textarea (DesignSystemElement)\n Props: value: string = '', name: string = '', placeholder: string = '', rows: number = 3, maxlength: unknown = Infinity, minlength: number = 0, disabled: boolean = false, required: boolean = false, readonly: boolean = false, resize: 'none' | 'vertical' | 'horizontal' | 'both' = 'vertical', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-timestamp (DesignSystemElement) — Displays a formatted or relative timestamp that self-updates each minute\nwhen `relative` is enabled.\n Props: value: string = '', relative: boolean = false, locale: string = 'en', dateStyle: Intl.DateTimeFormatOptions['dateStyle'] | null = null, timeStyle: Intl.DateTimeFormatOptions['timeStyle'] | null = null, weekday: Intl.DateTimeFormatOptions['weekday'] | null = null, year: Intl.DateTimeFormatOptions['year'] | null = null, month: Intl.DateTimeFormatOptions['month'] | null = null, day: Intl.DateTimeFormatOptions['day'] | null = null, hour: Intl.DateTimeFormatOptions['hour'] | null = null, minute: Intl.DateTimeFormatOptions['minute'] | null = null, second: Intl.DateTimeFormatOptions['second'] | null = null, timeZone: string | null = null, hourCycle: Intl.DateTimeFormatOptions['hourCycle'] | null = null, formattedTime: string\n- we-tooltip (LayoutElement)\n Props: open: boolean = false, title: string = '', placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'top', tooltipEl: HTMLElement, triggerEl: HTMLElement, arrowEl: HTMLElement\n- we-video (LayoutVisualElement)\n Props: src: string = '', poster?: string | undefined, controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n\n@we/components:\n- AudioDisplay\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | undefined, duration: number | undefined, albumArt: string | undefined\n- AudioInput\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | FileData | undefined, duration: number | undefined, albumArt: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- BlockComposer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, onSave?: ((json: SerializedBlockNode) => void), onReady?: ((api: { save: () => void; }) => void)\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, rootClass?: string\n- BlockToolbar\n Props: placement?: BlockToolbarPlacement, children: JSX.Element, stopPropagation?: boolean\n- CalloutDisplay\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined\n- CalloutInput\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CodeDisplay\n Props: code: string | undefined, language: string | undefined, title: string | undefined\n- CodeInput\n Props: code: string | undefined, language: string | undefined, title: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CollectionDisplay\n Props: layout?: string, columnCount?: number, gap?: string, childEditorState?: any\n- CollectionInput\n Props: nodeKey: string, layout?: string, columnCount?: number, gap?: string, childEditorState?: any, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- DividerDisplay\n Props: style: \"solid\" | \"dashed\" | \"dotted\" | undefined\n- DividerInput\n Props: style: DividerVariant | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EmbedDisplay\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined\n- EmbedInput\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EventDisplay\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined\n- EventInput\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- FileDisplay\n Props: title: string | undefined, name: string | undefined, url: string | undefined, mimeType: string | undefined, size: number | undefined\n- FileInput\n Props: title: string | undefined, name: string | undefined, url: string | FileData | undefined, mimeType: string | undefined, size: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- ImageDisplay\n Props: src: string | undefined, altText: string | undefined, width: number | undefined, height: number | undefined\n- ImageInput\n Props: src: string | FileData | undefined, altText: string | undefined, width: number | undefined, height: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LinkDisplay\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined\n- LinkInput\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LocationDisplay\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined\n- LocationInput\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TagDisplay\n Props: name: string | undefined, color: string | undefined\n- TagInput\n Props: name: string | undefined, color: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TaskDisplay\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined\n- TaskInput\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- VideoDisplay\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined\n- VideoInput\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- Accordion\n Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record\n- AudioVisualiser\n Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string\n- AvatarStack\n Props: avatars: AvatarInfo[], max?: number, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxs\" | \"xxl\", overlap?: number, ring?: string, styles?: Record\n- Breadcrumbs\n Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record\n- Calendar\n Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record\n- Card (DesignSystemElement)\n- CircleButton\n Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record\n- CollapsedContent\n Props: collapsed: boolean, onExpandClick?: (() => void), showToggle?: boolean, icon?: string, maxHeight?: string, fadeColor?: string, children?: JSX.Element, class?: string, styles?: Record\n- Column (DesignSystemElement)\n- Combobox (DesignSystemElement)\n Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.\n Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\n- IconLabelButton\n Props: icon: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, label: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, selected?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, iconWeight?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, onClick?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>, class?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, styles?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>\n- ImageCrop\n Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void)\n- ImageLightbox\n Props: srcs: string[], initialIndex: number, onClose: () => void\n- List\n Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record\n- PostCard\n Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record\n- RerenderLog\n Props: location: string\n- Row (DesignSystemElement)\n- Search (DesignSystemElement)\n Props: placeholder?: string, value?: string, onSearch?: ((value: string) => void), debounce?: number\n- Select (DesignSystemElement)\n Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- SignalControl\n Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record\n- Stepper\n Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: \"horizontal\" | \"vertical\", styles?: Record\n- Table\n Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record\n- Timeline\n Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], styles?: Record\n- ToastContainer\n Props: position?: \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\" | \"top-center\" | \"bottom-center\", styles?: Record\n\n@we/widgets:\n- CesiumGlobe — 3D globe widget using CesiumJS with a modular layer system.\nLayers are injected via factory functions (planet surface + background).\nRequires a layer factory registry mapping string names to factory functions.\nNot schema-renderable — used directly in application code.\n Props: ionAccessToken?: string, planetLayers?: LayerConfig[], backgroundLayers?: LayerConfig[], layerFactoryRegistry: Record>\n- CollapsibleSidebar\n Props: header?: JSX.Element, footer?: JSX.Element, items: CollapsibleSidebarItem[], footerItems?: CollapsibleSidebarItem[], side?: \"left\" | \"right\", position?: \"static\" | \"absolute\" | \"fixed\", zIndex?: number, collapsedWidth?: string, expandedWidth?: string, defaultExpanded?: boolean, expandOnHover?: boolean, transitionDuration?: number, bg?: string, border?: string, padding?: string, gap?: string, centerItems?: boolean, itemColor?: string, itemColorHover?: string, itemColorActive?: string, itemBg?: string, itemBgHover?: string, itemBgActive?: string, itemPadding?: string, itemGap?: string, badgeBg?: string, badgeColor?: string, iconSize?: IconSize, onItemClick?: ((item: CollapsibleSidebarItem) => void), onExpandedChange?: ((expanded: boolean) => void)\n- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering.\nDisplays typed nodes (user, space, post) and edges (follows, member-of, etc.)\nwith configurable styling, layout forces, and interaction handlers.\n Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig\n- SpaceSidebarWidget\n Props: name: string, description?: string, class?: string, style?: Record\n\n---\n\n## Design System Props\n\nMost @we/primitives inherit **all** layers below. Props use design token values — not raw CSS.\n\n### Token Value Reference\n\n| Token Type | Valid Values |\n|---|---|\n| SpaceValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length e.g. \"16px\") |\n| ColorValue | \"{hue}-{shade}\" where hue = neutral, primary, success, warning, danger and shade = 0, 25, 50, 75, 100, 200–900, 1000. Also \"white\", \"black\". (or CSS color) |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length) |\n| ShadowValue | \"sm\", \"md\", \"lg\", \"xl\" |\n| FontSizeValue | \"base\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length) |\n| FontFamilyValue | \"base\" (or CSS font-family) |\n| LineHeightValue | \"none\", \"tight\", \"snug\", \"normal\", \"relaxed\", \"loose\" (or CSS value) |\n| LetterSpacingValue | \"tighter\", \"tight\", \"normal\", \"wide\", \"wider\", \"widest\" (or CSS value) |\n| FontWeightValue | Named tokens: \"regular\" (400), \"medium\" (500), \"semibold\" (600), \"bold\" (700). Numeric: \"100\"–\"900\". CSS pass-through: \"light\", \"normal\", \"bolder\". |\n\n**Layout-only primitives** — these accept only Layout props (not Visual, Flex, Typography, or State):\nwe-divider, we-icon, we-menu-group, we-popover, we-spinner, we-tooltip\n\n### Layout\n\n| Prop | Type | Description |\n|------|------|-------------|\n| width | string | Element width |\n| height | string | Element height |\n| minWidth | string | Minimum width |\n| minHeight | string | Minimum height |\n| maxWidth | string | Maximum width |\n| maxHeight | string | Maximum height |\n| position | \"relative\" \\| \"absolute\" \\| \"fixed\" \\| \"sticky\" | CSS position |\n| top | string | Top offset |\n| right | string | Right offset |\n| bottom | string | Bottom offset |\n| left | string | Left offset |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" | Overflow behavior |\n| m | SpaceValue | Margin (all sides) |\n| mx | SpaceValue | Margin left + right |\n| my | SpaceValue | Margin top + bottom |\n| mt | SpaceValue | Margin top |\n| mr | SpaceValue | Margin right |\n| mb | SpaceValue | Margin bottom |\n| ml | SpaceValue | Margin left |\n\n### Visual\n\n| Prop | Type | Description |\n|------|------|-------------|\n| bg | ColorValue | Background color (token) |\n| bgImage | string | Background image URL — sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\n| r | RadiusValue | Border radius (all corners) |\n| rt | RadiusValue | Border radius top |\n| rb | RadiusValue | Border radius bottom |\n| rl | RadiusValue | Border radius left |\n| rr | RadiusValue | Border radius right |\n| rtl | RadiusValue | Border radius top-left |\n| rtr | RadiusValue | Border radius top-right |\n| rbr | RadiusValue | Border radius bottom-right |\n| rbl | RadiusValue | Border radius bottom-left |\n\n### Flex (Container)\n\n| Prop | Type | Description |\n|------|------|-------------|\n| direction | \"row\" \\| \"row-reverse\" \\| \"column\" \\| \"column-reverse\" | Flex direction |\n| ax | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Main-axis alignment |\n| ay | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Cross-axis alignment |\n| wrap | boolean | Enable flex wrap |\n| gap | SpaceValue | Gap between children (token) |\n| p | SpaceValue | Padding (all sides) |\n| px | SpaceValue | Padding left + right |\n| py | SpaceValue | Padding top + bottom |\n| pt | SpaceValue | Padding top |\n| pr | SpaceValue | Padding right |\n| pb | SpaceValue | Padding bottom |\n| pl | SpaceValue | Padding left |\n\n### Typography\n\n| Prop | Type | Description |\n|------|------|-------------|\n| textAlign | \"left\" \\| \"center\" \\| \"right\" \\| \"justify\" | Text alignment |\n| fontFamily | \"base\" \\| {css-font-family} | Font family token |\n| fontWeight | \"regular\" \\| \"medium\" \\| \"semibold\" \\| \"bold\" (named tokens) or \"100\"–\"900\" (numeric) or \"light\" \\| \"normal\" \\| \"bolder\" (CSS pass-through) | Font weight |\n| fontSize | \"base\" \\| \"100\"–\"1000\" \\| {css-length} | Font size token |\n| lineHeight | \"none\" \\| \"tight\" \\| \"snug\" \\| \"normal\" \\| \"relaxed\" \\| \"loose\" | Line height token |\n| letterSpacing | \"tighter\" \\| \"tight\" \\| \"normal\" \\| \"wide\" \\| \"wider\" \\| \"widest\" | Letter spacing token |\n| textDecoration | \"underline\" \\| \"line-through\" \\| \"overline\" \\| \"none\" | Text decoration |\n| textTransform | \"uppercase\" \\| \"lowercase\" \\| \"capitalize\" \\| \"none\" | Text transform |\n\n**Typography defaults:** fontSize and fontWeight have **no built-in defaults** — omitting them inherits from parent elements (browser default is ~16px / normal weight). Do not set fontSize or fontWeight unless you need a non-default value. For example, `fontSize: '300'` (16px) and `fontWeight: '500'` (normal) are the inherited defaults — omit them.\n\n`we-text` variants (set via the `variant` prop) bundle typography presets. Always pair with a semantic `tag` prop for correct HTML structure:\nbody (300, tag: p/span), label (200 + medium, tag: span), footnote (100, tag: span), subheading (400 + medium, tag: h5/p), ingress (400 + lineHeight 1.6, tag: p), heading-sm (500 + bold, tag: h4), heading-md (600 + bold, tag: h3), heading-lg (700 + bold, tag: h2), heading-xl (800 + bold, tag: h1).\nVariants set size and weight only — color is always inherited or set explicitly. For muted footnote text add `color=\"neutral-400\"` explicitly.\n\n### State\n\n| Prop | Type | Description |\n|------|------|-------------|\n| hoverProps | Partial\\ | Styles on :hover |\n| activeProps | Partial\\ | Styles on :active |\n| focusProps | Partial\\ | Styles on :focus |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'multiplier', 'subtractor', 'saturation', 'neutralSaturation'\n\ncolor.hues: 'neutral', 'primary', 'success', 'warning', 'danger'\n\ncolor.lightness: '0', '25', '50', '75', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\ncomponent.scrollbar: 'width', 'backgroundImage', 'background', 'cornerBackground', 'thumbBoxShadow', 'thumbBorderRadius', 'thumbBackground'\n\ncomponentHeight: 'xs', 'sm', 'md', 'lg', 'xl'\n\neffect.depth: '100', '200', '300', '400', '500', 'none'\n\nfont.family: 'base', 'mozilla', 'boldonse'\n\nfont.letterSpacing: 'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'\n\nfont.lineHeight: 'none', 'tight', 'snug', 'normal', 'relaxed', 'loose'\n\nfont.size: '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000', 'base'\n\nfont.weight: '100', '200', '300', '400', '500', '600', '700', '800', '900', 'regular', 'medium', 'semibold', 'bold'\n\nlayout: 'xs', 'sm', 'md', 'lg'\n\nradius: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'pill', 'full'\n\nshadow: 'sm', 'md', 'lg', 'xl'\n\nsize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nspace: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\nzIndex: 'dropdown', 'sticky', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nAgentSettings extends Ad4mModel:\n Fields:\n - currentTemplateId: string = 'default' [we://current_template]\n - defaultTemplateId: string = 'default' [we://default_template]\n - currentThemeId: string = 'default' [we://current_theme]\n - defaultThemeId: string = 'default' [we://default_theme]\n - claudeApiKey: string [we://claude_api_key]\n - perspectiveOrder: string [we://perspective_order]\n - globalSpaceJoined: boolean = false [we://global_space_joined]\n - globalSpaceUrl: string [we://global_space_url]\n - useSpaceTemplate: boolean = true [we://use_space_template]\n Relations:\n - installedTemplates: HasMany → Template [we://installed_template]\n - installedThemes: HasMany → Theme [we://installed_theme]\n - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference]\n\nAudioBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - artist: string [we://artist]\n - audioUrl: string (required) [we://audio_url]\n - duration: number [we://duration]\n - albumArt: string [we://album_art]\n - version: number [we://version]\n\nCalloutBlock extends WeNode:\n Fields:\n - text: string [we://text]\n - variant: string = info [we://variant]\n - icon: string [we://icon]\n - version: number [we://version]\n\nChatMessage extends WeNode:\n Fields:\n - role: string [we://role]\n - content: string [we://content]\n\nChatSession extends WeNode:\n Fields:\n - name: string [we://name]\n - templateId: string [we://template_id]\n Relations:\n - messages: HasMany → ChatMessage [we://chat_message]\n\nCodeBlock extends WeNode:\n Fields:\n - code: string (required) [we://code]\n - language: string [we://language]\n - title: string [we://title]\n - version: number [we://version]\n\nCollectionBlock extends WeNode:\n Fields:\n - editorState: string = null [we://editor_state]\n - type: string [we://type]\n - display: string [we://display]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - columns: number [we://columns]\n - gap: string [we://gap]\n - version: number [we://version]\n - textContent: string [we://text_content]\n Relations:\n - children: HasMany [we://children]\n\nDividerBlock extends WeNode:\n Fields:\n - style: string = solid [we://style]\n - version: number [we://version]\n\nEmbedBlock extends WeNode:\n Fields:\n - url: string [we://url]\n - target: string [we://target]\n - targetType: string [we://target_type]\n - displayMode: string = card [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - startDate: string (required) [we://start_date]\n - endDate: string [we://end_date]\n - location: string [we://location]\n - allDay: boolean = false [we://all_day]\n - version: number [we://version]\n\nFileBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - name: string (required) [we://name]\n - url: string (required) [we://url]\n - mimeType: string [we://mime_type]\n - size: number [we://size]\n - version: number [we://version]\n\nImageBlock extends WeNode:\n Fields:\n - src: string (required) [we://src]\n - altText: string [we://altText]\n - width: number [we://width]\n - height: number [we://height]\n - version: number [we://version]\n\nLinkBlock extends WeNode:\n Fields:\n - url: string (required) [we://url]\n - title: string [we://title]\n - description: string [we://description]\n - thumbnail: string [we://thumbnail]\n - version: number [we://version]\n\nLocationBlock extends WeNode:\n Fields:\n - name: string [we://name]\n - latitude: number (required) [we://latitude]\n - longitude: number (required) [we://longitude]\n - address: string [we://address]\n - city: string [we://city]\n - countryCode: string [we://country_code]\n - country: string [we://country]\n - version: number [we://version]\n\nSignal extends Ad4mModel:\n Fields:\n - signalTypeId: string [we://signal_type_id]\n - value: number [we://value]\n\nSignalType extends WeNode:\n Fields:\n - name: string [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - iconSecondary: string [we://icon_secondary]\n - step: number = 1 [we://step]\n - rangeMin: number [we://range_min]\n - rangeMax: number = 1 [we://range_max]\n - mode: SignalMode = 'toggle' [we://mode]\n - aggregate: SignalAggregate = 'count' [we://aggregate]\n - semantic: SignalSemantic = 'custom' [we://semantic]\n - allowChange: boolean = true [we://allow_change]\n - valueType: string = 'numeric' [we://signal_value_type]\n - schemaVersion: number = 1 [we://schema_version]\n\nSpace extends WeNode:\n Fields:\n - uuid: string [we://uuid]\n - url: string [we://url]\n - name: string (required) [we://name]\n - description: string (required) [we://description]\n - access: string = 'personal' [we://access]\n - discovery: string = 'hidden' [we://discovery]\n - avatar: string [we://image]\n - coverImage: string [we://thumbnail]\n - defaultTemplateId: string [we://default_template_id]\n - defaultThemeId: string [we://default_theme_id]\n Relations:\n - location: HasOne [we://location]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - type: string [we://type]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - textFormat: number [we://textFormat]\n - textStyle: string [we://textStyle]\n - listType: string [we://listType]\n - start: number [we://start]\n - tag: string [we://tag]\n - text: string [we://text]\n - version: number [we://version]\n\nTheme extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - slug: string [we://slug]\n - version: number = 1 [we://version]\n - css: string = null [we://stylesheet]\n - overrides: string = null [we://token_overrides]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nVideoBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - url: string (required) [we://url]\n - duration: number [we://duration]\n - thumbnail: string [we://thumbnail]\n - provider: string [we://provider]\n - version: number [we://version]\n\nWeNode extends Ad4mModel:\n Relations:\n - comments: HasMany [we://comment]\n - signals: HasMany → Signal [we://signal]\n\n---\n\n## Stores\n\nStores provide state (readable values) and actions (methods) for dynamic logic in schemas.\nAccess state with $store and call actions with $action.\nFor ephemeral/form state, use $localState/$local/$setLocal instead of stores (see Dynamic Logic).\n\nAdamStore:\n- State:\n - adamClient: Ad4mClient | undefined\n - me: Agent | undefined\n - allPerspectives: array of PerspectiveProxy objects (all AD4M perspectives)\n - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed)\n - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)\n - personalSpaces: array of Space objects (local/personal spaces)\n - sharedSpaces: array of Space objects (shared/neighbourhood spaces)\n - bootState: string\n - passwordError: string | undefined\n - loginLoading: boolean\n - creatingSpace: boolean (true while a new space is being created)\n - agents: AgentProfileSummary[] — cache of all fetched agent profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownAgent: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from agents cache)\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n- Actions:\n - navigate(to: string, options?): navigates to a route\n - addNewSpace(space: Space): adds a new space\n - createSpace(name: string, description: string, shared: boolean, imageFile?: File): creates a new space with full setup\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native perspective (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the perspective is already a published neighbourhood\n - switchPerspective(uuid: string): switches to a perspective by UUID, registers its SHACL models as dynamic model classes, and populates currentPerspectiveModels\n - removePerspective(uuid: string): removes a perspective by UUID\n - reorderPerspectives(newOrder: string[]): reorders the sidebar items by UUID array\n - login(password: string): logs in the agent with password\n - logout(): locks the agent and returns to login screen\n - fetchAgent(did: string): fetches and caches an agent's profile from their public AD4M perspective\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to public perspective\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads image to FILE_STORAGE_LANGUAGE and publishes expression URL to public perspective\n - updateAgentLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges location update into cache and publishes to public perspective\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a perspective that accumulated duplicate SDNA installs (e.g. from before joinSpace checked for existing SDNA before installing) — removes the redundant duplicate link copies. Defaults to the current perspective. Returns a display-ready summary string naming how many links were removed and the DIDs that authored them (your own DID annotated with \"(you)\"), or an empty string if nothing needed cleaning up\n\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n- Actions:\n - navigate(to: string, options?): navigates to a route\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - installedThemes: array of ThemeData objects — user-installed themes from root perspective (origin: \"custom\" | \"marketplace\")\n - spaceThemes: array of ThemeData objects — themes stored in the current space perspective (origin: \"custom\")\n - allThemes: array of ThemeData objects — union of builtInThemes + visible installedThemes + spaceThemes (hidden themes filtered out)\n - currentThemeId: string — id of the currently active theme\n - currentTheme: ThemeData — the currently active theme object (id, name, icon, origin)\n - defaultThemeId: string — id of the user's preferred default theme (used for bootscreen, shell, and future space-override). Persisted to AgentSettings.defaultThemeId\n - themeManagementList: ThemeManagementItem[] — flat list of all themes (built-in + all custom) with management metadata (id, name, icon, isBuiltIn, isInstalled, isDefault)\n- Actions:\n - setCurrentTheme(themeId: string): sets and persists the active theme\n - setDefaultTheme(themeId: string): sets the preferred default theme (persists to AgentSettings.defaultThemeId)\n - toggleThemeInstalled(themeId: string): toggles a custom theme visible/hidden in pickers; does not delete the theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteTheme(themeId: string): permanently deletes a custom theme\n\nTemplateStore:\n- State:\n - personalTemplates: array of TemplateSchema objects — core templates plus user's installed custom templates (excludes space templates)\n - spaceTemplates: array of TemplateSchema objects — templates loaded from the current space perspective\n - builtInTemplates: array of TemplateSchema objects — built-in system templates (always available)\n - myTemplates: array of TemplateSchema objects — user's installed custom templates only (excludes built-in and space templates)\n - allTemplates: array of TemplateSchema objects — union of built-in + personal + space templates\n - shellTemplates: array of TemplateSchema objects (static system pages: profile, settings, tests)\n - currentTemplate: TemplateSchema (the active template)\n - operationLoading: unknown\n - activeShellView: string | null (id of the currently open shell overlay: 'profile' | 'settings' | 'schema-tests' | 'landing-page' | null)\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n- Actions:\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - saveTemplate(name: string): saves the current template\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - deleteTemplate(): unknown\n - openShellView(id: string): opens a shell overlay by id ('profile' | 'settings' | 'schema-tests' | 'landing-page')\n - closeShellView(): closes the currently open shell overlay\n\nSpaceStore:\n- State:\n - memberDids: string[] — DIDs of all members in the current space (includes own DID)\n - members: AgentProfileSummary[] — cached profiles for all memberDids\n - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active)\n - currentSpace: Space | null — the current space model (uuid, name, description, avatar, defaultTemplateId)\n - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the \"Initialize as WE space\" gate; null once the perspective is a WE space or no recognized foreign model is found\n - signalTypes: array of SignalType objects (community-created reaction/vote types)\n - signalTypesBySlug: Record — computed map; access via { $store: \"spaceStore.signalTypesBySlug.\" }; use .id for the UUID\n- Actions:\n - createPost(editorState: unknown): creates a new post\n - updatePost(postId: string, editorState: unknown): reconciles an edited post against its existing blocks — updates/reuses blocks whose id survived the edit, creates new ones, deletes ones no longer present\n - deletePost(postId: string): permanently deletes a post and all of its contained blocks (recursive, atomic)\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads and sets the space avatar or cover image\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - upsertSignal(nodeId: string, signalTypeId: string, value: number): adds or updates a signal on a node; value=0 deletes it\n - navigateToSpace(spaceId: string, view?: string): navigates to a space — accepts a perspective UUID or a neighbourhood CID (sharedUrl without the neighbourhood:// prefix); pre-loads space templates before switching so the template and data arrive together\n\nAiStore:\n- State:\n - models: array of Model objects\n - tasks: array of AITask objects\n - isOpen: unknown\n - messages: unknown\n - isStreaming: unknown\n - streamingContent: unknown\n - apiKeyConfigured: unknown\n - templateName: unknown\n - templateIcon: unknown\n - isReadOnly: unknown\n - hasPendingChanges: unknown\n - pickerOpen: unknown\n - pickerAction: unknown\n - pickerDefaultName: unknown\n - pickerDefaultIcon: unknown\n - pickerShowDestination: unknown\n - sessions: unknown\n - activeSessionId: unknown\n - panelMode: unknown\n - schemaJson: unknown\n - operationLoading: unknown\n - canUndo: boolean (true when there are schema edits that can be undone)\n - canRedo: boolean (true when there are undone schema edits that can be redone)\n- Actions:\n - handleSchemaPrompt(prompt: string): generates a schema from a prompt\n - sendMessage(): unknown\n - close(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - setApiKey(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setPanelMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: unknown\n - activeAppId: string | null — id of the currently active app, or null if none\n- Actions:\n - activateApp(id: string): activates an app and switches to its view\n - deactivateApp(): deactivates the current app and returns to the template view\n\n---\n\n## Store Usage Patterns\n\nReading state:\n{ \"$store\": \"storeName.property\" }\nExample: { \"$store\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"adamStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"CircleButton\",\n \"props\": {\n \"label\": \"$space.name\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$space.uuid\"] }] }\n }\n }\n ]\n}\n\nConditional rendering from store:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$eq\": [{ \"$store\": \"routeStore.currentPath\" }, \"/\"] },\n \"then\": { \"type\": \"we-text\", \"children\": [\"Home\"] },\n \"else\": { \"type\": \"we-text\", \"children\": [\"Not home\"] }\n }\n}\n\nDeriving options from store:\n{\n \"$map\": {\n \"items\": { \"$store\": \"templateStore.templates\" },\n \"select\": { \"name\": \"$item.meta.name\", \"icon\": \"$item.meta.icon\" }\n }\n}\n\nQuerying model data:\n{\n \"$query\": { \"entity\": \"TaskBlock\", \"where\": { \"status\": \"todo\" } }\n}\n\nEager-loading relations with include (most common relational pattern):\nWhen you need related data displayed alongside a list, use include to hydrate relations in one query.\n\nExample — Channel list with conversation count and latest conversation:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Channel\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"$conversationCount\": { \"from\": \"conversations\", \"count\": true },\n \"$latestConversation\": { \"from\": \"conversations\", \"order\": { \"createdAt\": \"desc\" }, \"limit\": 1 }\n }\n }\n },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"we-text\", \"children\": [\"$channel.name\"] },\n { \"type\": \"we-text\", \"children\": [\"$channel.$conversationCount\"] }\n ]\n }]\n}\n\nExample — Nested include (Conversations with their messages):\n{\n \"$query\": {\n \"entity\": \"Conversation\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"messages\": {\n \"order\": { \"createdAt\": \"desc\" },\n \"limit\": 20\n }\n }\n }\n}\nEach conversation in the result has a messages array of hydrated Message instances.\nNesting works to any depth: \"include\": { \"messages\": { \"include\": { \"reactions\": true } } }\n\nRelational drill-down (master-detail navigation across entity relations):\nUse routes + a $query `scope` when you navigate to a detail route and need only that record's children.\nscope.anchor is the parent entity type; scope.via is its HasMany relation (see externalModels) whose targets\nare the query's entity; scope.anchorId is the parent record's id. The adapter resolves the relation to a\nbackend handle, so no protocol details live in the template.\nrouteStore.segments.N extracts the Nth dynamic path segment (segments splits currentPath by \"/\").\n\nExample — Channel list → Conversation list:\n{\n \"routes\": [\n {\n \"path\": \"/\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": { \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/channels/\", \"$channel.id\"] }] }\n },\n \"children\": [\"$channel.name\"]\n }]\n }]\n },\n {\n \"path\": \"/channels/:channelId\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Conversation\",\n \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": { \"$store\": \"routeStore.segments.1\" } },\n \"dataset\": \"$currentDataset\"\n }\n },\n \"as\": \"convo\"\n },\n \"children\": [{\n \"type\": \"we-text\",\n \"children\": [\"$convo.conversationName\"]\n }]\n }]\n }\n ]\n}\nNotes:\n- Use include when you need related data displayed inline (e.g. a post with its comments, a channel with its conversation count).\n- Use a scope drill-down when you're on a detail route and want only children belonging to the current record.\n- dataset must point to the dataset that holds the data. For external apps (e.g. Flux) opened as a WE space, use \"$currentDataset\".\n- The relation name (in include, or scope.via) is the HasMany field name on the parent entity.\n\nLocal state (form with validation):\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"name\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [{ \"rule\": \"required\" }, { \"rule\": \"minLength\", \"value\": 2 }]\n },\n \"loading\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"neutral-0\", \"r\": \"400\", \"border\": \"1px solid neutral-200\", \"p\": \"400\", \"gap\": \"300\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"initials\": \"$post.author\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\" }, \"children\": [\"$post.author\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [\"$post.title\"] },\n { \"type\": \"we-text\", \"children\": [\"$post.text\"] }\n ]\n }\n ]\n}\n\nUse $query or $store for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"spaceStore.posts\" }, \"as\": \"post\" }, \"children\": [...] }\n\nPer-item customization inside $each:\nTo style or highlight specific items, add a data flag to those items and use $if on the flag inside the template. Do NOT use $eq: [\"$index\", N] comparisons — they are fragile, repetitive, and break when items are reordered.\nExample: add \"highlighted\": true to one item's data, then use $if on \"$post.highlighted\" in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": \"$post.highlighted\", \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor conditional props (e.g. different bg on highlighted items):\n{ \"bg\": { \"$if\": { \"condition\": \"$post.highlighted\", \"then\": \"primary-50\", \"else\": \"neutral-0\" } } }\n\nBoolean toggle (show/hide, expand/collapse):\n{\n \"type\": \"Column\",\n \"$localState\": { \"showDetails\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocal\": \"showDetails\" } }, \"children\": [\"Toggle Details\"] },\n { \"type\": \"$if\", \"props\": { \"condition\": { \"$local\": \"showDetails\" }, \"then\": { \"type\": \"we-text\", \"children\": [\"Details content here\"] } } }\n ]\n}\n\nSignal types (community-specific reactions/votes):\nSignal types are created per-community by the user. Never hardcode signal type UUIDs in schemas.\nInstead reference them by slug through spaceStore.signalTypesBySlug.\n\nALWAYS ask the user: \"What slug should I use? (e.g. 'like', 'upvote', 'star')\"\nThen use that slug in the pattern below.\n\nPattern — live wired SignalControl (inside a $each over a model with $query include):\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": { \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" } },\n \"count\": true\n },\n \"$myLikeSignal\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" },\n \"author\": \"$me.did\"\n },\n \"limit\": 1\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"then\": {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"myValue\": \"$item.$myLikeSignal.value\",\n \"aggregate\": \"$item.$totalLikeCount\",\n \"onSignal\": {\n \"$action\": \"spaceStore.upsertSignal\",\n \"args\": [\"$item.id\", { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" }, \"$arg\"]\n }\n }\n }\n }\n }\n ]\n}\n\nNotes:\n- The $if guard hides SignalControl if the community hasn't created a signal type with that slug.\n- Replace \"like\" with the user's slug throughout (in $store paths and args).\n- $query include adds $totalLikeCount and $myLikeSignal as computed properties on each item.\n- signalType prop accepts the full SignalType object (provides icon, mode, range to the UI component).\n\nPreview / mockup mode (static, no store wiring):\n{\n \"type\": \"SignalControl\",\n \"props\": {\n \"preview\": true,\n \"signalType\": { \"icon\": \"❤️\", \"mode\": \"toggle\", \"rangeMin\": 0, \"rangeMax\": 1 }\n }\n}\nUse preview: true when sketching a layout without real data. Remove it (and add the full wiring above) when going live.\n\n---\n\n## Routing Structure\n\nDefine nested routes using the \"routes\" array at the root node of the schema.\nEach route object describes a path and the UI node to render when that path is active.\nRoutes can be nested to support sub-pages and layouts.\n\nRoute objects follow the same structure as schema nodes, with an additional \"path\" property.\n\n- The \"routes\" array MUST be placed on the ROOT template node (or on a route node for nested routing). The router only reads routes from these positions — placing routes on an arbitrary child node means the router will never find them and nothing will render.\n- Use \"path: '*'\" or \"path: '/*'\" for catch-all/not-found routes.\n- Use \":paramName\" for dynamic route parameters (e.g. \"/space/:spaceId\").\n- Use nested \"routes\" arrays for sub-pages and layouts.\n- Use { \"type\": \"$routes\" } in children to indicate where nested routes should render. The $routes outlet can be deeply nested — only the routes array placement matters.\n- EVERY { \"type\": \"$routes\" } outlet MUST have a \"routes\" array defined on the same node or an ancestor node. A $routes outlet without a routes array is invalid and will fail validation.\n- NEVER duplicate a route path — every route in the same \"routes\" array MUST have a unique path.\n- When using tabs, each tab's key and navigate path MUST have a matching route. Ensure a 1:1 correspondence between tabs and routes.\n\n### Tabs + Routing\n\nIMPORTANT: we-tabs only manages visual selection — clicking a tab does NOT navigate automatically.\nEach we-tab MUST have an onClick with { \"$action\": \"routeStore.navigate\" } to trigger route changes.\nBind we-tabs selectedKey to the matching route segment so the active tab stays in sync.\n(Alternatively, a single onChange on we-tabs can replace per-tab onClick — see onChange pattern below.)\n\nRecommended pattern — header above tabs (routes on ROOT, $routes outlet nested inside):\n{\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"Select a tab\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Posts content\"] }] },\n { \"path\": \"/articles\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Articles content\"] }] }\n ],\n \"children\": [\n { \"type\": \"Row\", \"props\": { \"p\": \"300\", \"ax\": \"between\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-lg\" }, \"children\": [\"My App\"] }\n ]},\n {\n \"type\": \"we-tabs\",\n \"props\": { \"selectedKey\": { \"$store\": \"routeStore.segments.0\" } },\n \"children\": [\n { \"type\": \"we-tab\", \"props\": { \"key\": \"posts\", \"label\": \"Posts\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/posts\"] } } },\n { \"type\": \"we-tab\", \"props\": { \"key\": \"articles\", \"label\": \"Articles\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/articles\"] } } }\n ]\n },\n { \"type\": \"$routes\" }\n ]\n}\nNote: \"routes\" is on the root Column, NOT on a child. The $routes outlet is a child — that's fine. Only the routes array placement matters.\n\nWRONG — two common mistakes that produce empty tabs (validator will catch both):\n{\n // MISTAKE 1: routes defined on an inner child node, not the root.\n // The router never inspects children for routes arrays — this routes array is invisible.\n \"type\": \"Column\",\n \"children\": [\n { \"type\": \"we-tabs\", \"children\": [\"...tabs...\"] },\n {\n \"type\": \"Column\",\n \"routes\": [ // ← WRONG: router never reads this\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [\"...\"] }\n ],\n \"children\": [{ \"type\": \"$routes\" }] // ← outlet here does nothing without a live routes array\n }\n ]\n}\n\n{\n // MISTAKE 2: using { type: \"$routes\" } as a route entry's component type.\n // $routes is an outlet slot marker — as a leaf route entry it has no children injected,\n // so it returns null. Every tab navigates to a route that renders nothing.\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/posts\", \"type\": \"$routes\" } // ← WRONG: renders null, use a real component\n ],\n \"children\": [{ \"type\": \"$routes\" }]\n}\n\nAlternative: single onChange on we-tabs (fires with $event.detail.value = selected key):\n{ \"onChange\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/\", \"$arg.detail.value\"] }] } }\nThis replaces all per-tab onClick handlers but requires $concat to build the path.\n\nNested routing example:\n{\n \"routes\": [\n { \"path\": \"*\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Page not found\"] }] },\n { \"path\": \"/\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Home page\"] }] },\n {\n \"path\": \"/space/:spaceId\",\n \"type\": \"Row\",\n \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Space page not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"About sub-page\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Post not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"No posts selected\"] },\n { \"path\": \"/1\", \"type\": \"we-text\", \"children\": [\"Post 1 page\"] }\n ]\n }\n ]\n }\n ]\n}\n\n---\n\n## Rules & Best Practices\n\n- Always use the correct prop names and value types for each component.\n- Never use null as a value in any children array. Only use valid schema nodes or strings.\n- Each item in a children array must be either a valid schema node object or a string.\n- Use design tokens for spacing, color, radius, etc. (do not use raw CSS except in styles).\n- Use the styles prop for custom inline CSS (e.g., { \"width\": \"100px\" }).\n- Use hoverProps for hover state overrides, activeProps for pressed state, focusProps for focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row).\n- Use dynamic logic tokens ($store, $if, $action, etc.) for reactivity and conditional behavior.\n- Nest components using children or slots as needed.\n- For routes, use the routes array with path and child nodes.\n- Do not invent new components or props — use only those listed in the component registry.\n- Do not set props to their default/inherited values — omit them. fontSize and fontWeight inherit from parents (~16px / normal), so only set them when you need a different value.\n- Omit empty `props` and `children` — both are optional. Do not write `props: {}` or `children: []`.\n- Do not use `as const` on schema node `type` fields — `SchemaNode.type` is `string`, so it is never needed.\n- For icon-only buttons, nest a `we-icon` child inside `we-button` rather than using a `text` prop with a Unicode character. **Omit the `size` prop on `we-icon` when nesting inside sized primitives** (`we-button`, `we-input`, `we-badge`, `we-textarea`) — these components auto-size nested icons via `--we-context-icon-size` (xs→12px, sm→16px, md→24px, lg→32px, xl→40px). Only set an explicit icon `size` if you need to override the automatic sizing. Example: `{ type: 'we-button', props: { variant: 'ghost', size: 'sm' }, children: [{ type: 'we-icon', props: { name: 'x' } }] }`.\n- NEVER pass a bare number like \"16\" as a size or dimension prop — it is not valid CSS. Always check the component's declared prop type: if it's a string union, use one of the listed values; if it accepts arbitrary strings, include a CSS unit (e.g. \"16px\", \"2rem\").\n- For interactive list items and selectable options, use `we-button` with variant switching (e.g., `secondary` when selected, `ghost` when not) instead of manually styling `Row` with cursor, bg, and onClick. Buttons provide hover, focus, and active states for free.\n- For card-like layouts, compose from `Column` with DS props (bg, r, border, p, gap). This gives full control over spacing and appearance.\n- When rendering lists of similar items (posts, cards, users, etc.), ALWAYS use `$each` with a single template child — never duplicate the same node structure multiple times. Use literal arrays in `items` for static data, or `$store`/`$query` for dynamic data.\n\n### Icon Names (Phosphor Icons)\n\nwe-icon uses **Phosphor Icons** (v2.1). Do NOT use Heroicons, Material, or FontAwesome names.\nPhosphor names are lowercase-kebab-case. The `weight` prop controls style: \"regular\" (default), \"bold\", \"fill\", \"light\", \"thin\", \"duotone\".\n\nCommon Phosphor icon names (use these, NOT Heroicons equivalents):\n- Navigation: house, arrow-left, arrow-right, caret-left, caret-right, caret-down, caret-up, arrows-clockwise\n- Actions: plus, minus, x, check, pencil-simple, trash, copy, download, upload, share, link, magnifying-glass, funnel, sliders-horizontal\n- Communication: chat-circle, chat-dots, envelope-simple, paper-plane-tilt, bell, megaphone\n- Social: heart, thumbs-up, thumbs-down, star, share-network, users, user, user-plus\n- Media: image, camera, play, pause, stop, microphone, speaker-high, video-camera\n- Files: file, file-text, folder, folder-open, clipboard-text, note\n- UI: list, squares-four, gear, dots-three, dots-three-vertical, warning, info, question, check-circle, x-circle, eye, eye-slash\n- Misc: lightning, rocket, globe, map-pin, calendar, clock, tag, bookmark, flag, lock, shield-check\n\nWRONG icon names (Heroicons/Material — do NOT use):\n- \"chat-bubble-left\" → use \"chat-circle\"\n- \"chevron-right\" → use \"caret-right\"\n- \"cog\" / \"settings\" → use \"gear\"\n- \"trash-can\" → use \"trash\"\n- \"magnifying-glass-circle\" → use \"magnifying-glass\"\n- \"home\" → use \"house\"\n- \"favorite\" → use \"heart\"\n- \"delete\" → use \"trash\"\n- \"search\" → use \"magnifying-glass\"\n- \"close\" → use \"x\"\n- \"menu\" → use \"list\"\n- All schemas must be valid JSON with property names and string values in double quotes.\n- The meta property at the root is required: { \"meta\": { \"name\": \"...\", \"description\": \"...\", \"icon\": \"...\" } }\n- Always set `bg: 'neutral-50'` on root-level schema nodes (templates, pages). This ensures proper background in all themes — without it, dark mode renders white backgrounds.\n\nMost @we/primitives inherit all Design System Props documented above (layout, visual, flex, typography, state).\nSome layout-only primitives (we-avatar, we-icon, we-image, we-spinner, etc.) only accept Layout props — see the Design System Props section for the full list.\n\nNative HTML elements (lowercase tags render directly without registry entries):\n- Layout: div, section, article, aside, main, nav, header, footer\n- Text: p, span, h1-h6, pre, code, blockquote\n- Lists: ul, ol, li\n- Forms: form, input, button, label, select, textarea\n- Media: img, video, audio, canvas, figure, figcaption\n- Other: a, table, tr, td, th, details, summary, dialog\n\n## Schema Validation\n\nRun `we-validate-schemas` (or `node packages/schema-system/shared/dist/cli/we-validate-schemas.js`) from the monorepo root to validate all `.schema.ts` files.\nFor a specific file: `we-validate-schemas packages/app-framework/src/shared/schemas/MyTemplate.schema.ts`\n\nAfter creating or modifying a `.schema.ts` file, always run validation to catch:\n- Unknown component types (typos, missing registry entries)\n- Invalid or misspelled props (with \"did you mean?\" suggestions)\n- Prop type mismatches (e.g., number where string expected)\n- Missing required `meta` field on root TemplateSchema nodes\n- `$routes` outlet without a `routes` array on an ancestor\n- Orphan `$local` / `$setLocal` references without a `$localState` ancestor\n- DS layer consistency (mixing props from layers the component doesn't support)"; +export const schemaContext = "## Schema Structure\n\nA schema is a tree of nodes. Each node can have:\n- type: The component to render (string, e.g. \"we-button\", \"Column\")\n- props: An object of props for the component\n- children: An array of child nodes (or strings for text), or token objects like { $store: '...' } or { $concat: [...] }.\n- slots: Named slots for advanced composition (optional)\n- slot: The name of the slot this node should be rendered into (optional)\n- routes: For routing components, an array of nestable route objects (optional)\n- styles: Raw CSS escape hatch — Record applied as inline styles on a **wrapper div** that surrounds the component. Use only for CSS that must live on a wrapper: filter, clip-path, backdrop-filter, mix-blend-mode. When present the wrapper participates in layout (no display:contents), so CSS effects apply correctly. **Important:** this is NOT the same as props.styles. If you want to apply custom CSS to a Column, Row, or Grid's own element (e.g. a background image), put it in props.styles instead — node-level styles go on a wrapper div around the component and will be hidden behind the component's own background.\n\nExample node:\n{\n \"type\": \"we-button\",\n \"props\": {\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n },\n \"children\": [\n { \"type\": \"we-icon\", \"props\": { \"name\": \"house\" } },\n { \"type\": \"we-text\", \"props\": { \"size\": \"600\" }, \"children\": [\"Home\"] }\n ]\n}\n\n## Prop-level Dynamic Logic & Expressions\n\nSpecial tokens in props enable dynamic, reactive, or computed behavior.\n\nStore reference:\n{ \"$store\": \"storeName.property.path\" }\nResolves a value from a named store, supporting nested paths.\n\nAction/event:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nCalls a method on a store, optionally with arguments (which can themselves be tokens).\nSupports async lifecycle callbacks — fired after the store method's Promise resolves/rejects:\n onSuccess: [...actions] — fired on resolve; '$result' (and '$result.') in args refers to the resolved value\n onError: [...actions] — fired on reject; '$result.message' etc. refers to the error object\n onFinally: [...actions] — fired regardless of outcome\nNon-promise (synchronous) methods are unaffected — lifecycle keys are ignored.\nExample — close modal after async submission:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"adamStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$result.uuid\"] }] }] }\n\nModel mutations via $action (use these for creating/updating/deleting model instances):\nmodel.create — creates a model instance in the current perspective (default) or a specified one:\n{ \"$action\": \"model.create\", \"args\": [\"ModelName\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\nThe third argument is an options object. Omit it to use the current space perspective.\n\nmodel.update — updates a model instance:\n{ \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"newValue\" }] }\nTo target a non-current perspective: { \"$action\": \"model.update\", \"args\": [\"ModelName\", \"$item.id\", { \"field\": \"value\" }, { \"perspective\": \"adamStore.rootPerspective\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'adamStore.rootPerspective' for we-root models (AgentSettings, ChatSession, etc.).\nUse the default (no perspective) for space-scoped models (Space, Signal, etc.).\n\nConditional logic:\n{ \"$if\": { \"condition\": ..., \"then\": ..., \"else\": ... } }\nEvaluates condition; if truthy, returns then, else returns else.\n\nMap/iterate:\n{ \"$map\": { \"items\": { \"$store\": \"templateStore.templates\" }, \"select\": { ... } } }\nIterates over an array, mapping each item to a new object using the select mapping.\n\nPick:\n{ \"$pick\": { \"from\": { \"$store\": \"userStore.profile\" }, \"props\": [\"name\", \"email\"] } }\nPicks specific properties from an object.\n\nConcat (string building):\n{ \"$concat\": [\"part1\", \"$context.value\", \"part2\"] }\nJoins multiple parts into a single string.\n\nContext references:\nStrings starting with \"$\" followed by a context key resolve to context values.\nExample: \"$space.name\" resolves to the name property of the space context variable.\nDot paths supported: \"$item.profile.avatar\".\n\nEquality / inequality checks:\n{ \"$eq\": [a, b] } — strict equality\n{ \"$ne\": [a, b] } — strict inequality\n\nNumeric comparisons:\n{ \"$lt\": [a, b] } — a < b (less than)\n{ \"$gt\": [a, b] } — a > b (greater than)\nExample: { \"$gt\": [{ \"$count\": { \"items\": { \"$store\": \"listStore.items\" } } }, 0] }\n\nSet membership:\n{ \"$in\": [value, array] } — true if array contains value (false if second operand is not an array)\nExample: { \"$in\": [{ \"$store\": \"spaceStore.uuid\" }, { \"$store\": \"adamStore.systemPerspectiveUuids\" }] }\nExample: { \"$in\": [\"$item.role\", [\"admin\", \"moderator\"]] }\n\nBoolean logic:\n{ \"$and\": [a, b, ...] } — all truthy\n{ \"$or\": [a, b, ...] } — any truthy\n{ \"$not\": a } — negation\n\nArray operators:\n{ \"$filter\": { \"items\": , \"where\": { \"field\": \"value\", ... } } }\nFilters an array to items where all where conditions match. Mirrors the $query where operator set:\n\n { \"field\": \"value\" } — strict equality\n { \"field\": { \"not\": \"value\" } } — inequality; array form excludes multiple values\n { \"field\": { \"contains\": \"text\" } } — case-insensitive substring match (strings only)\n { \"field\": { \"exists\": true } } — non-null / non-undefined presence check\n { \"field\": { \"exists\": false } } — null or undefined check\n\nWhere values (including those inside operator objects) are resolved through the prop system,\nso $store, $local, and context refs like { \"$local\": \"searchText\" } all work.\n\n$query-only logical combinators (OR / AND / NOT) — NOT supported in $filter, only in $query's where:\n { \"OR\": [ { \"field\": \"value\" }, { \"field2\": \"value2\" } ] } — matches if ANY branch matches\n { \"AND\": [ { ... }, { ... } ] } — matches if ALL branches match (sibling keys at the\n same level are already implicitly ANDed — use AND\n to group a set of conditions alongside an OR/NOT)\n { \"NOT\": { \"field\": \"value\" } } — matches if the branch does NOT match\nBranches are full where-clause objects (can contain multiple fields, and can nest OR/AND/NOT inside each other).\nSibling keys alongside OR/AND/NOT at the same level are implicitly ANDed with it.\nExample — case-insensitive search across two fields:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"where\": {\n \"OR\": [\n { \"name\": { \"contains\": { \"$local\": \"searchText\" } } },\n { \"description\": { \"contains\": { \"$local\": \"searchText\" } } }\n ]\n }\n }\n}\nNote: using OR/AND/NOT disables the SPARQL-level sort/pagination pushdown (see count-projection and\nrelation-property ordering below) — those orderings silently stop working if combined with OR/AND/NOT in the\nsame query's where clause, because the fallback sort runs before the projection/relation data is attached.\n\nExamples:\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"role\": \"admin\" } } }\n{ \"$filter\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"location\": { \"exists\": true }, \"handle\": { \"contains\": { \"$local\": \"searchText\" } } } } }\n\n{ \"$count\": { \"items\": } }\nReturns the length of an array.\nExample: { \"badge\": { \"$count\": { \"items\": { \"$store\": \"notificationStore.unread\" } } } }\n\n{ \"$find\": { \"items\": , \"where\"?: { ... }, \"select\"?: \"fieldName\" } }\nFinds the first matching item. where is optional (returns first item if omitted). select plucks a single field.\nExample: { \"$find\": { \"items\": { \"$store\": \"spaceStore.members\" }, \"where\": { \"id\": \"$item.creatorId\" }, \"select\": \"name\" } }\n\n{ \"$plural\": { \"count\": , \"one\": \"singular\", \"other\": \"plural\" } }\nReturns \"one\" when count === 1, otherwise \"other\". Use in children arrays for count-noun labels.\ncount is resolved through the prop system — any numeric expression ($count, $store, context ref) works.\nExample: { \"$plural\": { \"count\": { \"$count\": { \"items\": { \"$store\": \"spaceStore.members\" } } }, \"one\": \"Member\", \"other\": \"Members\" } }\nCompose with we-number for a full \"N Members\" display:\n we-number (value: { \"$count\": ... }, shorten: true) + we-text (children: [{ \"$plural\": { \"count\": { \"$count\": ... }, \"one\": \"Member\", \"other\": \"Members\" } }])\n\nQuery (data retrieval):\n{ \"$query\": { \"entity\": \"ModelName\", \"where\": { \"field\": \"value\" }, \"limit\": 10, \"order\": { \"field\": \"asc\" } } }\nQueries the current dataset for entity instances. Always returns an array.\nOptions: entity (required), where, order, limit, offset, include, scope, dataset, subscribe.\nsubscribe defaults to true — reactive live updates. Set subscribe: false to do a one-time fetch.\nBy default $query targets the current dataset ($currentDataset). Use dataset to query a different dataset —\nrequired when reading entities from an external app (e.g. Flux) that is open as a WE space:\n{ \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } }\n\nBackend-neutral identity & dataset refs — prefer these over adamStore.* store paths inside $query and conditions:\n- $currentDataset — the currently active dataset (an AD4M perspective, in the AD4M backend). Use as a dataset value.\n- $me — the current agent's identity object. Use $me.did for their DID (ownership checks, author filters, e.g. { \"$eq\": [\"$post.author\", \"$me.did\"] }); $me.handle / $me.avatar for profile fields once loaded.\n\nEager-loading relations with include (most common relational pattern):\ninclude hydrates related model instances in the same query — no extra fetches needed.\nRelation names come from the HasMany relations listed for each model in externalModels.\n\nSimple include — hydrate all related instances:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": true } } }\nEach item in the result will have a conversations array of hydrated Conversation objects.\n\nSub-query include — filter, sort, or limit the related records:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"order\": { \"createdAt\": \"desc\" }, \"limit\": 10 } } } }\n\nNested include — hydrate relations of relations:\n{ \"$query\": { \"entity\": \"Channel\", \"include\": { \"conversations\": { \"include\": { \"messages\": true } } } } }\nNesting can go as deep as needed. Each level adds one batched fetch (not N+1).\n\nCount projection — add a derived numeric field:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } } } }\nThe $-prefixed key becomes a new field on each result item (e.g. item.$likeCount = 42).\n\nSorting by a count projection — order can reference a $-prefixed count key directly, sorting by the aggregate:\n{\n \"$query\": {\n \"entity\": \"Post\",\n \"limit\": 20,\n \"order\": { \"$likeCount\": \"desc\" },\n \"include\": { \"$likeCount\": { \"from\": \"likes\", \"count\": true } }\n }\n}\nRequirements: only a single order key is supported when it targets a projection (mixing it with a second sort key falls back\nto a plain property sort), and the query must also specify limit or offset — without one the count isn't computed yet at\nsort time and the order silently has no effect. Always pair count-projection ordering with a limit.\nCombine with $if for a user-togglable sort field (e.g. \"newest\" vs \"most liked\"):\n{\n \"order\": {\n \"$if\": {\n \"condition\": { \"$eq\": [{ \"$local\": \"sortField\" }, \"likes\"] },\n \"then\": { \"$likeCount\": { \"$local\": \"sortDirection\" } },\n \"else\": { \"createdAt\": { \"$local\": \"sortDirection\" } }\n }\n }\n}\n\nSorting by a related model property — order can reference a dotted \"relation.property\" path for a HasOne/HasMany\nrelation declared on the model, sorting by a scalar property on the related instance:\n{\n \"$query\": {\n \"entity\": \"Space\",\n \"limit\": 20,\n \"order\": { \"location.country\": \"asc\" },\n \"include\": { \"location\": true }\n }\n}\nSame requirements as count-projection ordering above: only a single order key, and pair with limit/offset — without\none the relation data isn't attached yet at sort time and the order silently has no effect. include isn't required\nfor the sort itself (the relation is resolved from the model's declared shape), but you'll usually want it anyway to\nread the field in the UI (e.g. \"$space.location.country\").\nCombine with $if the same way as count-projection ordering to let the user toggle between sort fields.\n\nSingle-item projection — add a derived field that resolves to one instance or null:\n{ \"$query\": { \"entity\": \"Post\", \"include\": { \"$myLike\": { \"from\": \"likes\", \"where\": { \"author\": \"$me.did\" }, \"limit\": 1 } } } }\nWith limit: 1 the field unwraps to T | null instead of an array.\n\ninclude only works with typed relations — ones where the target model class is known.\nFor WE models this is always the case. For external models, check the externalModels listing:\nrelations marked \"→ ModelName\" are typed (safe for include); relations marked \"parent query only\"\nare untyped and will crash at runtime if used with include — use a scope drill-down instead.\n\nRelational queries — fetch a parent record's children (drill-down navigation):\n{ \"$query\": { \"entity\": \"Conversation\", \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": \"$channel.id\" } } }\nscope.anchor is the parent entity type; scope.via is its relation whose targets are this query's entity (the\nHasMany relation listed for that entity in externalModels); scope.anchorId is the parent record's id (typically\nfrom a $each context variable or a route segment). The adapter resolves the relation to a backend handle —\nno protocol details live in the template.\nUse this pattern when navigating to a detail route and loading only that record's children.\nFor external-app datasets, always add dataset: \"$currentDataset\".\n\nLocal state (scoped ephemeral state):\nDeclare on any node: \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\" } }\nSupported types: \"string\", \"boolean\", \"number\", \"function\", \"object\".\nRead: { \"$local\": \"name\" } — returns the signal value (reactive).\n { \"$local\": \"name.nested.path\" } — dot-notation reads into object-typed fields (reactive).\nWrite: { \"$setLocal\": \"name\", \"from\": \"$event.target.value\" } — event handler that updates the signal.\n { \"$setLocal\": \"name\", \"value\": \"literal\" } — sets to a literal value (string, number, boolean, null, object).\n { \"$setLocal\": \"name\", \"merge\": { \"field\": \"$event.detail\" } } — shallow-merges fields into an object-typed signal. Values are resolved as event paths (e.g. \"$event.detail\") or passed as literals. Use for partial updates to object state.\nToggle: { \"$toggleLocal\": \"fieldName\" } — toggles a boolean field (equivalent to setting it to !current). Use for show/hide, open/close, expand/collapse patterns.\nCall function: { \"$callLocal\": \"fieldName\" } — event handler that calls the function stored in a function-typed local field.\n Used when a child component needs to trigger a callback passed in via $localState.\n The field must be declared as type: 'function' and set via $setLocal.\n Example: { \"onClick\": { \"$callLocal\": \"onConfirm\" } }\nState is created on mount and destroyed on unmount. Nested $localState declarations merge, inner fields shadow outer.\n$local values can be used in $action args: { \"$action\": \"store.method\", \"args\": [{ \"$local\": \"name\" }] }\n\nObject-typed local state (consolidating related scalar fields):\nWhen several related fields share a common condition on their initial values (e.g. all null/empty when a store value is absent), prefer a single \"object\" field seeded from the store, then read sub-fields with dot-notation and write with merge.\nExample — location object (replaces 5 separate scalar fields with $if guards):\n \"$localState\": { \"location\": { \"type\": \"object\", \"initial\": { \"$store\": \"spaceStore.currentSpace.location\" } } }\n Read: { \"$local\": \"location.latitude\" }, { \"$local\": \"location.city\" }\n Write (picker confirm): { \"$setLocal\": \"location\", \"from\": \"$event.detail\" }\n Write (partial edit): { \"$setLocal\": \"location\", \"merge\": { \"city\": \"$event.detail\" } }\n Write (clear): { \"$setLocal\": \"location\", \"value\": null }\n Condition (has location): { \"$local\": \"location\" }\nUse \"object\" whenever you would otherwise write 3+ related scalar fields each needing $if on their initial value.\n\nHoisted query state ($queries):\nDeclare on any node to run reactive subscriptions at the node root and expose results in $local.\nSolves two problems: avoids N duplicate subscriptions inside $each loops, and makes query results available for $if conditions.\n\"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } }\nResults are injected into $local as read-only reactive arrays, accessible via { \"$local\": \"signalTypes\" }.\nQuery options are identical to $each's $query prop (entity, where, order, limit, include, dataset, subscribe).\n$queries and $localState share the same $local namespace — avoid duplicate names across both.\n$setLocal will warn and no-op on $queries entries (they are read-only).\nUse with $count + $gt for conditional visibility:\n{ \"condition\": { \"$gt\": [{ \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } }, 0] } }\nExample:\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [...]\n }\n ]\n}\n\nBoolean toggle pattern (show/hide comments, expand/collapse sections, etc.):\n{\n \"$localState\": { \"showComments\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n {\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$toggleLocal\": \"showComments\" }\n },\n \"children\": [{ \"type\": \"we-icon\", \"props\": { \"name\": \"chat-circle\" } }]\n },\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$local\": \"showComments\" },\n \"then\": { \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Comments visible\"] }] }\n }\n }\n ]\n}\n\nForm validation (extends $localState):\nDeclare validation rules on fields:\n\"$localState\": {\n \"email\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [\n { \"rule\": \"required\", \"message\": \"Email is required\" },\n { \"rule\": \"pattern\", \"value\": \"^[^@]+@[^@]+$\", \"message\": \"Invalid email\" }\n ]\n }\n}\n\nBuilt-in rules: required, minLength (value: N), maxLength (value: N), min (value: N), max (value: N), pattern (value: regex string), match (field: otherFieldName). All accept optional \"message\" override.\n\nRead tokens:\n{ \"$error\": \"fieldName\" } — first validation error message (only shown after field is touched), or \"\".\n{ \"$valid\": \"fieldName\" } — true if all rules pass (regardless of touched state).\n{ \"$touched\": \"fieldName\" } — true after the field has been blurred/touched.\n{ \"$formValid\": \"$scope\" } — true if ALL validated fields in the current $localState scope pass.\n\nAction tokens:\n{ \"$touch\": \"fieldName\" } — marks a single field as touched (use in onBlur).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.submit\", \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] } } }] }\nArray entries execute sequentially. Non-function entries (e.g. $if with false condition) are skipped.\nPrefer onSuccess over a bare $setLocal before the $action — the bare form closes the modal immediately (losing the loading spinner); onSuccess waits for the Promise to resolve.\n\nTypical form pattern:\n{\n \"$localState\": { \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] } },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"storeName.arrayProperty\" }, \"as\": \"itemName\" }, \"children\": [ ... ] }\nRenders children once for each item. The \"as\" name becomes a context key. Defaults to \"item\" — omit \"as\" unless you need a different name.\n\nConditional rendering:\n{ \"type\": \"$if\", \"props\": { \"condition\": ..., \"then\": { ... }, \"else\": { ... } } }\nRenders \"then\" node if condition is truthy, else renders \"else\" node.\nSupports enterTransition / exitTransition for CSS animations when the node mounts/unmounts.\nTransitionConfig = TransitionEffect | TransitionEffect[]\nTransitionEffect = { type: 'fade'|'slide'|'scale'|'pulse', duration?: ms, easing?: string, delay?: ms, direction?: 'left'|'right'|'up'|'down', distance?: string }\nfade controls opacity only; slide/scale control transform only. pulse is a persistent looping animation (not a one-shot transition) — starts once entered, stops on exit; direction/distance don't apply (default duration 1200ms, easing 'ease-in-out'). Compose fade/slide/scale together in an array; pulse is typically used alone.\nExample: enterTransition: [{ type: 'fade', duration: 300 }, { type: 'slide', direction: 'up', distance: '40px', duration: 400 }]\nExample (pulse): enterTransition: { type: 'pulse', duration: 1500 }\n\nViewport / mount animation (child always in DOM):\n{ \"type\": \"$animate\", \"props\": { \"scrollReveal\"?: true | number, \"scrollLeave\"?: true | number, \"scrollPast\"?: string, \"enterTransition\"?: TransitionConfig, \"exitTransition\"?: TransitionConfig }, \"children\": [] }\nThe child is always mounted. fade/slide/scale are CSS transitions (opacity/transform); pulse is a real CSS @keyframes loop — use this for scroll-reveal effects.\nDo NOT use $animate when the child should be absent from the DOM. Use $if for conditional DOM presence.\nscrollReveal: true fires enterTransition when the element enters the viewport.\nscrollReveal: -100 fires 100px before the element would enter (negative = earlier reveal).\nscrollLeave fires exitTransition when the element leaves the viewport.\nscrollPast: \"element-id\" observes a sentinel element (by DOM id) instead of the $animate element itself.\n enterTransition fires when the sentinel leaves the viewport (user scrolled past it).\n exitTransition fires when the sentinel returns (user scrolled back up).\n Use this for sticky headers: place a zero-height sentinel div at the bottom of the non-sticky header section,\n then wrap the mini-profile in $animate with scrollPast pointing to that sentinel's id.\n scrollPast is mutually exclusive with scrollReveal/scrollLeave.\nWithout any scroll trigger, the enterTransition runs once on mount.\nOnly one child node is supported.\nExample (scroll-reveal):\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollReveal\": -100,\n \"enterTransition\": [\n { \"type\": \"fade\", \"duration\": 600, \"easing\": \"ease-in-out\" },\n { \"type\": \"slide\", \"direction\": \"left\", \"distance\": \"200px\", \"duration\": 1000, \"easing\": \"ease-in-out\" }\n ]\n },\n \"children\": [{ \"type\": \"SomeCard\", \"children\": [] }]\n}\nExample (sticky header mini-profile):\nPlace a sentinel at the bottom of the header, reference it in the sticky nav:\n{ \"type\": \"div\", \"props\": { \"id\": \"header-sentinel\" }, \"styles\": { \"height\": \"0px\", \"pointerEvents\": \"none\" } }\n{\n \"type\": \"$animate\",\n \"props\": {\n \"scrollPast\": \"header-sentinel\",\n \"enterTransition\": { \"type\": \"fade\", \"duration\": 250 },\n \"exitTransition\": { \"type\": \"fade\", \"duration\": 200 }\n },\n \"children\": [{ \"type\": \"Row\", \"props\": { \"ay\": \"center\", \"gap\": \"300\" }, \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"image\": \"$space.avatar\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"fontWeight\": \"600\" }, \"children\": [\"$space.name\"] }\n ]}]\n}\n\nSingle model item (load one record, render children with it in context):\n{\n \"type\": \"$single\",\n \"props\": {\n \"item\": { \"$query\": { \"entity\": \"ModelName\", \"params\": { ... }, \"subscribe\": true } },\n \"as\": \"profile\" // context key for children — default: 'item'\n },\n \"children\": [{ \"type\": \"we-text\", \"children\": [\"$profile.username\"] }]\n}\nRenders nothing until a matching record is found. Like $each but for a single result.\nquery options (entity, params, include, dataset, subscribe) work identically to $query.\n\nRoute outlet:\n{ \"type\": \"$routes\" }\nIndicates where nested routes should render within a layout.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', dismissible: boolean = false\n- we-audio (LayoutVisualElement)\n Props: src: string = '', controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n- we-avatar (LayoutVisualElement)\n Props: image: string = '', hash: string = '', selected: boolean = false, online: boolean = false, initials: string = '', icon: string = '', size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | '{css-length}' | undefined, clickable: boolean = false\n- we-badge (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-blockquote (DesignSystemElement)\n- we-button (DesignSystemElement)\n Props: variant: 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', text?: string | undefined, href?: string | undefined, disabled: boolean = false, loading: boolean = false, gradient: boolean = false, square: boolean = false\n- we-checkbox (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-code (DesignSystemElement)\n Props: block: boolean = false\n- we-color-picker (DesignSystemElement)\n Props: value: string = '#000000', disabled: boolean = false, name: string = '', palette: array = [ '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', '#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff', '#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc', ]\n- we-date-picker (DesignSystemElement)\n Props: value: string = '', placeholder: string = 'Select date', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-divider (LayoutElement)\n Props: orientation: 'horizontal' | 'vertical' = 'horizontal', variant: 'solid' | 'dashed' | 'dotted' = 'solid', color?: string | undefined, thickness?: string | undefined\n- we-drawer (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-file-upload (DesignSystemElement)\n Props: accept: string = '', multiple: boolean = false, disabled: boolean = false, name: string = ''\n- we-form-field (DesignSystemElement)\n Props: label: string = '', description: string = '', error: string = '', required: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-html (DesignSystemElement) — Renders a raw HTML string safely via DOMPurify sanitization.\n\nUse this instead of `we-text` when content is stored as HTML (e.g. rich-text\neditor output such as Flux messages). The `content` prop accepts any HTML\nfragment; it is sanitized before rendering so XSS payloads are stripped.\n Props: content: string = ''\n- we-icon (LayoutElement)\n Props: name: string = '', color: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '{css-length}' = '', weight: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' = 'regular', gradient: string = ''\n- we-icon-picker (DesignSystemElement)\n Props: value: string = '', disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', placeholder: string = 'Pick icon'\n- we-iframe (LayoutVisualElement)\n Props: src: string = '', title: string = 'Embedded content', allow: string = '', sandbox?: string | undefined\n- we-image (LayoutVisualElement)\n Props: src: string | File = '', alt: string = '', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', loading: 'eager' | 'lazy' = 'eager', gradient: string = '', objectPosition: string = ''\n- we-input (DesignSystemElement)\n Props: value: string = '', max: string = '', min: string = '', maxlength: unknown = Infinity, minlength: number = 0, pattern: string = '', name: string = '', step: string = '', placeholder: string = '', autocomplete: string = '', autofocus: boolean = false, disabled: boolean = false, required: boolean = false, readonly: boolean = false, type: string = 'text', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: hideclosebutton: boolean = false, close: () => void\n- we-number (DesignSystemElement) — Displays a number, optionally abbreviated (1 200 → 1.2K, 1 500 000 → 1.5M).\n Props: value: number = 0, shorten: boolean = false, precision: number = 1, locale: string = 'en', formattedValue: string\n- we-number-input (DesignSystemElement)\n Props: value: number = 0, min: number = -Infinity, max: unknown = Infinity, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-pagination (DesignSystemElement)\n Props: page: number = 1, total: number = 1, siblings: number = 1, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-popover (LayoutElement) — Low-level floating panel anchored to a trigger element.\nUse DropdownMenu component for dropdown menus.\n Props: open: boolean = false, placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom', popoverElement: HTMLElement, triggerElement: HTMLElement\n- we-progress-bar (DesignSystemElement)\n Props: value: number = 0, max: number = 100, variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-radio (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-scroll-area (DesignSystemElement)\n Props: maxHeight: string = '', maxWidth: string = ''\n- we-select (DesignSystemElement)\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-select (DesignSystemElement) — Pick a single value from a list of options. Custom-rendered dropdown.\nUse for form fields, settings, filters. Set searchable=true for type-to-filter.\n Props: options: SelectOption[] = [], value: string = '', placeholder: string = '', disabled: boolean = false, searchable: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-skeleton (DesignSystemElement)\n Props: width: string = '100%', height: string = '20px', animation: 'pulse' | 'wave' = 'pulse'\n- we-slider (DesignSystemElement)\n Props: value: number = 0, min: number = 0, max: number = 100, step: number = 1, disabled: boolean = false, name: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', showValue: boolean = false\n- we-sortable (DesignSystemElement) — Drag-to-reorder container primitive.\n\nUsage: wrap a list of elements that each have a `data-we-id` attribute.\nFires a `we-reorder` CustomEvent on drop with the new ordered\narray of IDs.\n Props: direction: 'vertical' | 'horizontal' = 'vertical', gap: string = ''\n- we-spinner (LayoutElement)\n Props: size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | (string & {}) = 'md', color: string = ''\n- we-switch (DesignSystemElement)\n Props: checked: boolean = false, disabled: boolean = false, name: string = '', value: string = '', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md', labelOff: string = '', labelOn: string = ''\n- we-tab (DesignSystemElement)\n Props: key: string = '', selected: boolean = false, label?: string | undefined, selectedProps?: Partial | undefined\n- we-tabs (DesignSystemElement)\n Props: selectedKey: string = ''\n- we-tag (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'neutral', dismissible: boolean = false\n- we-text (DesignSystemElement)\n Props: text?: string | undefined, variant: '' | 'body' | 'label' | 'footnote' | 'subheading' | 'ingress' | 'heading-sm' | 'heading-md' | 'heading-lg' | 'heading-xl' = '', tag: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small' | 'b' | 'i' | 'label' | 'div' = 'span', inline: boolean = false, uppercase: boolean = false, italic: boolean = false, truncate: boolean = false, gradient: string = ''\n- we-textarea (DesignSystemElement)\n Props: value: string = '', name: string = '', placeholder: string = '', rows: number = 3, maxlength: unknown = Infinity, minlength: number = 0, disabled: boolean = false, required: boolean = false, readonly: boolean = false, resize: 'none' | 'vertical' | 'horizontal' | 'both' = 'vertical', size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-timestamp (DesignSystemElement) — Displays a formatted or relative timestamp that self-updates each minute\nwhen `relative` is enabled.\n Props: value: string = '', relative: boolean = false, locale: string = 'en', dateStyle: Intl.DateTimeFormatOptions['dateStyle'] | null = null, timeStyle: Intl.DateTimeFormatOptions['timeStyle'] | null = null, weekday: Intl.DateTimeFormatOptions['weekday'] | null = null, year: Intl.DateTimeFormatOptions['year'] | null = null, month: Intl.DateTimeFormatOptions['month'] | null = null, day: Intl.DateTimeFormatOptions['day'] | null = null, hour: Intl.DateTimeFormatOptions['hour'] | null = null, minute: Intl.DateTimeFormatOptions['minute'] | null = null, second: Intl.DateTimeFormatOptions['second'] | null = null, timeZone: string | null = null, hourCycle: Intl.DateTimeFormatOptions['hourCycle'] | null = null, formattedTime: string\n- we-tooltip (LayoutElement)\n Props: open: boolean = false, title: string = '', placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'top', tooltipEl: HTMLElement, triggerEl: HTMLElement, arrowEl: HTMLElement\n- we-video (LayoutVisualElement)\n Props: src: string = '', poster?: string | undefined, controls: boolean = false, preload: 'none' | 'metadata' | 'auto' = 'metadata', autoplay: boolean = false, loop: boolean = false, muted: boolean = false\n\n@we/components:\n- AudioDisplay\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | undefined, duration: number | undefined, albumArt: string | undefined\n- AudioInput\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | FileData | undefined, duration: number | undefined, albumArt: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- BlockComposer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, onSave?: ((json: SerializedBlockNode) => void), onReady?: ((api: { save: () => void; }) => void)\n- BlockPlaceholder\n Props: icon: string, label: string, hint?: string, accept?: string, onFileDrop?: ((file: File) => void), onClick?: (() => void)\n- BlockRenderer (DesignSystemElement)\n Props: editorState?: any, perspective?: PerspectiveProxy | null, rootClass?: string\n- BlockToolbar\n Props: placement?: BlockToolbarPlacement, children: JSX.Element, stopPropagation?: boolean\n- CalloutDisplay\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined\n- CalloutInput\n Props: text: string | undefined, variant: string | undefined, icon: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CodeDisplay\n Props: code: string | undefined, language: string | undefined, title: string | undefined\n- CodeInput\n Props: code: string | undefined, language: string | undefined, title: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- CollectionDisplay\n Props: layout?: string, columnCount?: number, gap?: string, childEditorState?: any\n- CollectionInput\n Props: nodeKey: string, layout?: string, columnCount?: number, gap?: string, childEditorState?: any, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- DividerDisplay\n Props: style: \"solid\" | \"dashed\" | \"dotted\" | undefined\n- DividerInput\n Props: style: DividerVariant | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EmbedDisplay\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined\n- EmbedInput\n Props: url: string | undefined, target: string | undefined, targetType: string | undefined, displayMode: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- EventDisplay\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined\n- EventInput\n Props: title: string | undefined, description: string | undefined, startDate: string | undefined, endDate: string | undefined, location: string | undefined, allDay: boolean | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- FileDisplay\n Props: title: string | undefined, name: string | undefined, url: string | undefined, mimeType: string | undefined, size: number | undefined\n- FileInput\n Props: title: string | undefined, name: string | undefined, url: string | FileData | undefined, mimeType: string | undefined, size: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- ImageDisplay\n Props: src: string | undefined, altText: string | undefined, width: number | undefined, height: number | undefined\n- ImageInput\n Props: src: string | FileData | undefined, altText: string | undefined, width: number | undefined, height: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LinkDisplay\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined\n- LinkInput\n Props: url: string | undefined, title: string | undefined, description: string | undefined, thumbnail: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- LocationDisplay\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined\n- LocationInput\n Props: name: string | undefined, latitude: number | undefined, longitude: number | undefined, address: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TagDisplay\n Props: name: string | undefined, color: string | undefined\n- TagInput\n Props: name: string | undefined, color: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- TaskDisplay\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined\n- TaskInput\n Props: title: string | undefined, description: string | undefined, status: string | undefined, priority: string | undefined, dueDate: string | undefined, assignee: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- VideoDisplay\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined\n- VideoInput\n Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- Accordion\n Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record\n- AudioVisualiser\n Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string\n- AvatarStack\n Props: avatars: AvatarInfo[], max?: number, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"xxs\" | \"xxl\", overlap?: number, ring?: string, styles?: Record\n- Breadcrumbs\n Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record\n- Calendar\n Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record\n- Card (DesignSystemElement)\n- CircleButton\n Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record\n- CodeEditor\n Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record\n- CollapsedContent\n Props: collapsed: boolean, onExpandClick?: (() => void), showToggle?: boolean, icon?: string, maxHeight?: string, fadeColor?: string, children?: JSX.Element, class?: string, styles?: Record\n- Column (DesignSystemElement)\n- Combobox (DesignSystemElement)\n Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.\n Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\n- IconLabelButton\n Props: icon: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, label: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, selected?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, iconWeight?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, onClick?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>, class?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor, styles?: import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>\n- ImageCrop\n Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void)\n- ImageLightbox\n Props: srcs: string[], initialIndex: number, onClose: () => void\n- List\n Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record\n- PostCard\n Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record\n- RerenderLog\n Props: location: string\n- Row (DesignSystemElement)\n- Search (DesignSystemElement)\n Props: placeholder?: string, value?: string, onSearch?: ((value: string) => void), debounce?: number\n- Select (DesignSystemElement)\n Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", onChange?: ((value: string) => void)\n- SignalControl\n Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record\n- Stepper\n Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: \"horizontal\" | \"vertical\", styles?: Record\n- Table\n Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record\n- Timeline\n Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], styles?: Record\n- ToastContainer\n Props: position?: \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\" | \"top-center\" | \"bottom-center\", styles?: Record\n\n@we/widgets:\n- CesiumGlobe — 3D globe widget using CesiumJS with a modular layer system.\nLayers are injected via factory functions (planet surface + background).\nRequires a layer factory registry mapping string names to factory functions.\nNot schema-renderable — used directly in application code.\n Props: ionAccessToken?: string, planetLayers?: LayerConfig[], backgroundLayers?: LayerConfig[], layerFactoryRegistry: Record>\n- CollapsibleSidebar\n Props: header?: JSX.Element, footer?: JSX.Element, items: CollapsibleSidebarItem[], footerItems?: CollapsibleSidebarItem[], side?: \"left\" | \"right\", position?: \"static\" | \"absolute\" | \"fixed\", zIndex?: number, collapsedWidth?: string, expandedWidth?: string, defaultExpanded?: boolean, expandOnHover?: boolean, transitionDuration?: number, bg?: string, border?: string, padding?: string, gap?: string, centerItems?: boolean, itemColor?: string, itemColorHover?: string, itemColorActive?: string, itemBg?: string, itemBgHover?: string, itemBgActive?: string, itemPadding?: string, itemGap?: string, badgeBg?: string, badgeColor?: string, iconSize?: IconSize, onItemClick?: ((item: CollapsibleSidebarItem) => void), onExpandedChange?: ((expanded: boolean) => void)\n- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering.\nDisplays typed nodes (user, space, post) and edges (follows, member-of, etc.)\nwith configurable styling, layout forces, and interaction handlers.\n Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig\n- SpaceSidebarWidget\n Props: name: string, description?: string, class?: string, style?: Record\n\n---\n\n## Design System Props\n\nMost @we/primitives inherit **all** layers below. Props use design token values — not raw CSS.\n\n### Token Value Reference\n\n| Token Type | Valid Values |\n|---|---|\n| SpaceValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length e.g. \"16px\") |\n| ColorValue | \"{hue}-{shade}\" where hue = neutral, primary, success, warning, danger and shade = 0, 25, 50, 75, 100, 200–900, 1000. Also \"white\", \"black\". (or CSS color) |\n| RadiusValue | \"0\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"pill\", \"full\" (or CSS length) |\n| ShadowValue | \"sm\", \"md\", \"lg\", \"xl\" |\n| FontSizeValue | \"base\", \"100\", \"200\", \"300\", \"400\", \"500\", \"600\", \"700\", \"800\", \"900\", \"1000\" (or CSS length) |\n| FontFamilyValue | \"base\" (or CSS font-family) |\n| LineHeightValue | \"none\", \"tight\", \"snug\", \"normal\", \"relaxed\", \"loose\" (or CSS value) |\n| LetterSpacingValue | \"tighter\", \"tight\", \"normal\", \"wide\", \"wider\", \"widest\" (or CSS value) |\n| FontWeightValue | Named tokens: \"regular\" (400), \"medium\" (500), \"semibold\" (600), \"bold\" (700). Numeric: \"100\"–\"900\". CSS pass-through: \"light\", \"normal\", \"bolder\". |\n\n**Layout-only primitives** — these accept only Layout props (not Visual, Flex, Typography, or State):\nwe-divider, we-icon, we-menu-group, we-popover, we-spinner, we-tooltip\n\n### Layout\n\n| Prop | Type | Description |\n|------|------|-------------|\n| width | string | Element width |\n| height | string | Element height |\n| minWidth | string | Minimum width |\n| minHeight | string | Minimum height |\n| maxWidth | string | Maximum width |\n| maxHeight | string | Maximum height |\n| position | \"relative\" \\| \"absolute\" \\| \"fixed\" \\| \"sticky\" | CSS position |\n| top | string | Top offset |\n| right | string | Right offset |\n| bottom | string | Bottom offset |\n| left | string | Left offset |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| alignSelf | string | Override parent cross-axis alignment for this child |\n| overflow | \"hidden\" \\| \"auto\" | Overflow behavior |\n| m | SpaceValue | Margin (all sides) |\n| mx | SpaceValue | Margin left + right |\n| my | SpaceValue | Margin top + bottom |\n| mt | SpaceValue | Margin top |\n| mr | SpaceValue | Margin right |\n| mb | SpaceValue | Margin bottom |\n| ml | SpaceValue | Margin left |\n\n### Visual\n\n| Prop | Type | Description |\n|------|------|-------------|\n| bg | ColorValue | Background color (token) |\n| bgImage | string | Background image URL — sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\n| r | RadiusValue | Border radius (all corners) |\n| rt | RadiusValue | Border radius top |\n| rb | RadiusValue | Border radius bottom |\n| rl | RadiusValue | Border radius left |\n| rr | RadiusValue | Border radius right |\n| rtl | RadiusValue | Border radius top-left |\n| rtr | RadiusValue | Border radius top-right |\n| rbr | RadiusValue | Border radius bottom-right |\n| rbl | RadiusValue | Border radius bottom-left |\n\n### Flex (Container)\n\n| Prop | Type | Description |\n|------|------|-------------|\n| direction | \"row\" \\| \"row-reverse\" \\| \"column\" \\| \"column-reverse\" | Flex direction |\n| ax | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Main-axis alignment |\n| ay | \"start\" \\| \"center\" \\| \"end\" \\| \"between\" \\| \"around\" \\| \"even\" \\| \"stretch\" | Cross-axis alignment |\n| wrap | boolean | Enable flex wrap |\n| gap | SpaceValue | Gap between children (token) |\n| p | SpaceValue | Padding (all sides) |\n| px | SpaceValue | Padding left + right |\n| py | SpaceValue | Padding top + bottom |\n| pt | SpaceValue | Padding top |\n| pr | SpaceValue | Padding right |\n| pb | SpaceValue | Padding bottom |\n| pl | SpaceValue | Padding left |\n\n### Typography\n\n| Prop | Type | Description |\n|------|------|-------------|\n| textAlign | \"left\" \\| \"center\" \\| \"right\" \\| \"justify\" | Text alignment |\n| fontFamily | \"base\" \\| {css-font-family} | Font family token |\n| fontWeight | \"regular\" \\| \"medium\" \\| \"semibold\" \\| \"bold\" (named tokens) or \"100\"–\"900\" (numeric) or \"light\" \\| \"normal\" \\| \"bolder\" (CSS pass-through) | Font weight |\n| fontSize | \"base\" \\| \"100\"–\"1000\" \\| {css-length} | Font size token |\n| lineHeight | \"none\" \\| \"tight\" \\| \"snug\" \\| \"normal\" \\| \"relaxed\" \\| \"loose\" | Line height token |\n| letterSpacing | \"tighter\" \\| \"tight\" \\| \"normal\" \\| \"wide\" \\| \"wider\" \\| \"widest\" | Letter spacing token |\n| textDecoration | \"underline\" \\| \"line-through\" \\| \"overline\" \\| \"none\" | Text decoration |\n| textTransform | \"uppercase\" \\| \"lowercase\" \\| \"capitalize\" \\| \"none\" | Text transform |\n\n**Typography defaults:** fontSize and fontWeight have **no built-in defaults** — omitting them inherits from parent elements (browser default is ~16px / normal weight). Do not set fontSize or fontWeight unless you need a non-default value. For example, `fontSize: '300'` (16px) and `fontWeight: '500'` (normal) are the inherited defaults — omit them.\n\n`we-text` variants (set via the `variant` prop) bundle typography presets. Always pair with a semantic `tag` prop for correct HTML structure:\nbody (300, tag: p/span), label (200 + medium, tag: span), footnote (100, tag: span), subheading (400 + medium, tag: h5/p), ingress (400 + lineHeight 1.6, tag: p), heading-sm (500 + bold, tag: h4), heading-md (600 + bold, tag: h3), heading-lg (700 + bold, tag: h2), heading-xl (800 + bold, tag: h1).\nVariants set size and weight only — color is always inherited or set explicitly. For muted footnote text add `color=\"neutral-400\"` explicitly.\n\n### State\n\n| Prop | Type | Description |\n|------|------|-------------|\n| hoverProps | Partial\\ | Styles on :hover |\n| activeProps | Partial\\ | Styles on :active |\n| focusProps | Partial\\ | Styles on :focus |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\ncolor.base: 'white', 'black'\n\ncolor.config: 'multiplier', 'subtractor', 'saturation', 'neutralSaturation'\n\ncolor.hues: 'neutral', 'primary', 'success', 'warning', 'danger'\n\ncolor.lightness: '0', '25', '50', '75', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\ncomponent.scrollbar: 'width', 'backgroundImage', 'background', 'cornerBackground', 'thumbBoxShadow', 'thumbBorderRadius', 'thumbBackground'\n\ncomponentHeight: 'xs', 'sm', 'md', 'lg', 'xl'\n\neffect.depth: '100', '200', '300', '400', '500', 'none'\n\nfont.family: 'base', 'mozilla', 'boldonse'\n\nfont.letterSpacing: 'tighter', 'tight', 'normal', 'wide', 'wider', 'widest'\n\nfont.lineHeight: 'none', 'tight', 'snug', 'normal', 'relaxed', 'loose'\n\nfont.size: '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000', 'base'\n\nfont.weight: '100', '200', '300', '400', '500', '600', '700', '800', '900', 'regular', 'medium', 'semibold', 'bold'\n\nlayout: 'xs', 'sm', 'md', 'lg'\n\nradius: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', 'pill', 'full'\n\nshadow: 'sm', 'md', 'lg', 'xl'\n\nsize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nspace: '0', '100', '200', '300', '400', '500', '600', '700', '800', '900', '1000'\n\nzIndex: 'dropdown', 'sticky', 'modal', 'popover', 'toast', 'tooltip'\n\n---\n\n## Block & Entity Models\n\nAvailable data models for $query and store data:\n\nAgentSettings extends Ad4mModel:\n Fields:\n - currentTemplateId: string = 'default' [we://current_template]\n - defaultTemplateId: string = 'default' [we://default_template]\n - currentThemeId: string = 'default' [we://current_theme]\n - defaultThemeId: string = 'default' [we://default_theme]\n - claudeApiKey: string [we://claude_api_key]\n - perspectiveOrder: string [we://perspective_order]\n - globalSpaceJoined: boolean = false [we://global_space_joined]\n - globalSpaceUrl: string [we://global_space_url]\n - useSpaceTemplate: boolean = true [we://use_space_template]\n Relations:\n - installedTemplates: HasMany → Template [we://installed_template]\n - installedThemes: HasMany → Theme [we://installed_theme]\n - spaceTemplatePreferences: HasMany → SpaceTemplatePreference [we://space_template_preference]\n\nAudioBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - artist: string [we://artist]\n - audioUrl: string (required) [we://audio_url]\n - duration: number [we://duration]\n - albumArt: string [we://album_art]\n - version: number [we://version]\n\nCalloutBlock extends WeNode:\n Fields:\n - text: string [we://text]\n - variant: string = info [we://variant]\n - icon: string [we://icon]\n - version: number [we://version]\n\nChatMessage extends WeNode:\n Fields:\n - role: string [we://role]\n - content: string [we://content]\n\nChatSession extends WeNode:\n Fields:\n - name: string [we://name]\n - templateId: string [we://template_id]\n Relations:\n - messages: HasMany → ChatMessage [we://chat_message]\n\nCodeBlock extends WeNode:\n Fields:\n - code: string (required) [we://code]\n - language: string [we://language]\n - title: string [we://title]\n - version: number [we://version]\n\nCollectionBlock extends WeNode:\n Fields:\n - editorState: string = null [we://editor_state]\n - type: string [we://type]\n - display: string [we://display]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - columns: number [we://columns]\n - gap: string [we://gap]\n - version: number [we://version]\n - textContent: string [we://text_content]\n Relations:\n - children: HasMany [we://children]\n\nDividerBlock extends WeNode:\n Fields:\n - style: string = solid [we://style]\n - version: number [we://version]\n\nEmbedBlock extends WeNode:\n Fields:\n - url: string [we://url]\n - target: string [we://target]\n - targetType: string [we://target_type]\n - displayMode: string = card [we://display_mode]\n - version: number [we://version]\n\nEventBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - startDate: string (required) [we://start_date]\n - endDate: string [we://end_date]\n - location: string [we://location]\n - allDay: boolean = false [we://all_day]\n - version: number [we://version]\n\nFileBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - name: string (required) [we://name]\n - url: string (required) [we://url]\n - mimeType: string [we://mime_type]\n - size: number [we://size]\n - version: number [we://version]\n\nImageBlock extends WeNode:\n Fields:\n - src: string (required) [we://src]\n - altText: string [we://altText]\n - width: number [we://width]\n - height: number [we://height]\n - version: number [we://version]\n\nLinkBlock extends WeNode:\n Fields:\n - url: string (required) [we://url]\n - title: string [we://title]\n - description: string [we://description]\n - thumbnail: string [we://thumbnail]\n - version: number [we://version]\n\nLocationBlock extends WeNode:\n Fields:\n - name: string [we://name]\n - latitude: number (required) [we://latitude]\n - longitude: number (required) [we://longitude]\n - address: string [we://address]\n - city: string [we://city]\n - countryCode: string [we://country_code]\n - country: string [we://country]\n - version: number [we://version]\n\nSignal extends Ad4mModel:\n Fields:\n - signalTypeId: string [we://signal_type_id]\n - value: number [we://value]\n\nSignalType extends WeNode:\n Fields:\n - name: string [we://name]\n - slug: string [we://slug]\n - description: string [we://description]\n - icon: string [we://icon]\n - iconSecondary: string [we://icon_secondary]\n - step: number = 1 [we://step]\n - rangeMin: number [we://range_min]\n - rangeMax: number = 1 [we://range_max]\n - mode: SignalMode = 'toggle' [we://mode]\n - aggregate: SignalAggregate = 'count' [we://aggregate]\n - semantic: SignalSemantic = 'custom' [we://semantic]\n - allowChange: boolean = true [we://allow_change]\n - valueType: string = 'numeric' [we://signal_value_type]\n - schemaVersion: number = 1 [we://schema_version]\n\nSpace extends WeNode:\n Fields:\n - uuid: string [we://uuid]\n - url: string [we://url]\n - name: string (required) [we://name]\n - description: string (required) [we://description]\n - access: string = 'personal' [we://access]\n - discovery: string = 'hidden' [we://discovery]\n - avatar: string [we://image]\n - coverImage: string [we://thumbnail]\n - defaultTemplateId: string [we://default_template_id]\n - defaultThemeId: string [we://default_theme_id]\n Relations:\n - location: HasOne [we://location]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nTextBlock extends WeNode:\n Fields:\n - type: string [we://type]\n - direction: string [we://direction]\n - format: string [we://format]\n - indent: number [we://indent]\n - textFormat: number [we://textFormat]\n - textStyle: string [we://textStyle]\n - listType: string [we://listType]\n - start: number [we://start]\n - tag: string [we://tag]\n - text: string [we://text]\n - version: number [we://version]\n\nTheme extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - slug: string [we://slug]\n - version: number = 1 [we://version]\n - css: string = null [we://stylesheet]\n - overrides: string = null [we://token_overrides]\n Relations:\n - screenshots: HasMany → ImageBlock [we://screenshot]\n\nVideoBlock extends WeNode:\n Fields:\n - title: string [we://title]\n - url: string (required) [we://url]\n - duration: number [we://duration]\n - thumbnail: string [we://thumbnail]\n - provider: string [we://provider]\n - version: number [we://version]\n\nWeNode extends Ad4mModel:\n Relations:\n - comments: HasMany [we://comment]\n - signals: HasMany → Signal [we://signal]\n\n---\n\n## Stores\n\nStores provide state (readable values) and actions (methods) for dynamic logic in schemas.\nAccess state with $store and call actions with $action.\nFor ephemeral/form state, use $localState/$local/$setLocal instead of stores (see Dynamic Logic).\n\nAdamStore:\n- State:\n - adamClient: Ad4mClient | undefined\n - me: Agent | undefined\n - allPerspectives: array of PerspectiveProxy objects (all AD4M perspectives)\n - currentPerspective: PerspectiveProxy | null (the perspective currently being viewed)\n - currentPerspectiveModels: ModelManifestEntry[] (non-WE SHACL models from the current perspective; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current perspective is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign perspective, e.g. one synced in from Flux)\n - personalSpaces: array of Space objects (local/personal spaces; all Space fields)\n - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields)\n - bootState: string\n - passwordError: string | undefined\n - loginLoading: boolean\n - creatingSpace: boolean (true while a new space is being created)\n - agents: AgentProfileSummary[] — cache of all fetched agent profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownAgent: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from agents cache)\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n- Actions:\n - navigate(to: string, options?): navigates to a route\n - addNewSpace(space: Space): adds a new space\n - createSpace(name: string, description: string, shared: boolean, imageFile?: File): creates a new space with full setup\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native perspective (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the perspective is already a published neighbourhood\n - switchPerspective(uuid: string): switches to a perspective by UUID, registers its SHACL models as dynamic model classes, and populates currentPerspectiveModels\n - removePerspective(uuid: string): removes a perspective by UUID\n - reorderPerspectives(newOrder: string[]): reorders the sidebar items by UUID array\n - login(password: string): logs in the agent with password\n - logout(): locks the agent and returns to login screen\n - fetchAgent(did: string): fetches and caches an agent's profile from their public AD4M perspective\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to public perspective\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads image to FILE_STORAGE_LANGUAGE and publishes expression URL to public perspective\n - updateAgentLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges location update into cache and publishes to public perspective\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a perspective that accumulated duplicate SDNA installs (e.g. from before joinSpace checked for existing SDNA before installing) — removes the redundant duplicate link copies. Defaults to the current perspective. Returns a display-ready summary string naming how many links were removed and the DIDs that authored them (your own DID annotated with \"(you)\"), or an empty string if nothing needed cleaning up\n\nRouteStore:\n- State:\n - currentPath: string (the current route path)\n - segments: string[] (currentPath split by \"/\", e.g. [\"/foo/bar\"] → [\"foo\", \"bar\"])\n- Actions:\n - navigate(to: string, options?): navigates to a route\n\nThemeStore:\n- State:\n - builtInThemes: array of ThemeData objects — built-in registry themes (origin: \"built-in\", always available)\n - installedThemes: array of ThemeData objects — user-installed themes from root perspective (origin: \"custom\" | \"marketplace\")\n - spaceThemes: array of ThemeData objects — themes stored in the current space perspective (origin: \"custom\")\n - allThemes: array of ThemeData objects — union of builtInThemes + visible installedThemes + spaceThemes (hidden themes filtered out)\n - currentThemeId: string — id of the currently active theme\n - currentTheme: ThemeData — the currently active theme object (id, name, icon, origin)\n - defaultThemeId: string — id of the user's preferred default theme (used for bootscreen, shell, and future space-override). Persisted to AgentSettings.defaultThemeId\n - themeManagementList: ThemeManagementItem[] — flat list of all themes (built-in + all custom) with management metadata (id, name, icon, isBuiltIn, isInstalled, isDefault)\n- Actions:\n - setCurrentTheme(themeId: string): sets and persists the active theme\n - setDefaultTheme(themeId: string): sets the preferred default theme (persists to AgentSettings.defaultThemeId)\n - toggleThemeInstalled(themeId: string): toggles a custom theme visible/hidden in pickers; does not delete the theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteTheme(themeId: string): permanently deletes a custom theme\n\nTemplateStore:\n- State:\n - personalTemplates: array of TemplateSchema objects — core templates plus user's installed custom templates (excludes space templates)\n - spaceTemplates: array of TemplateSchema objects — templates loaded from the current space perspective\n - builtInTemplates: array of TemplateSchema objects — built-in system templates (always available)\n - myTemplates: array of TemplateSchema objects — user's installed custom templates only (excludes built-in and space templates)\n - allTemplates: array of TemplateSchema objects — union of built-in + personal + space templates\n - shellTemplates: array of TemplateSchema objects (static system pages: profile, settings, tests)\n - currentTemplate: TemplateSchema (the active template)\n - operationLoading: unknown\n - activeShellView: string | null (id of the currently open shell overlay: 'profile' | 'settings' | 'schema-tests' | 'landing-page' | null)\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n- Actions:\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - saveTemplate(name: string): saves the current template\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - deleteTemplate(): unknown\n - openShellView(id: string): opens a shell overlay by id ('profile' | 'settings' | 'schema-tests' | 'landing-page')\n - closeShellView(): closes the currently open shell overlay\n\nSpaceStore:\n- State:\n - memberDids: string[] — DIDs of all members in the current space (includes own DID)\n - members: AgentProfileSummary[] — cached profiles for all memberDids\n - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active)\n - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt)\n - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the \"Initialize as WE space\" gate; null once the perspective is a WE space or no recognized foreign model is found\n - signalTypes: array of SignalType objects (community-created reaction/vote types)\n - signalTypesBySlug: Record — computed map; access via { $store: \"spaceStore.signalTypesBySlug.\" }; use .id for the UUID\n- Actions:\n - createPost(editorState: unknown): creates a new post\n - updatePost(postId: string, editorState: unknown): reconciles an edited post against its existing blocks — updates/reuses blocks whose id survived the edit, creates new ones, deletes ones no longer present\n - deletePost(postId: string): permanently deletes a post and all of its contained blocks (recursive, atomic)\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads and sets the space avatar or cover image\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - upsertSignal(nodeId: string, signalTypeId: string, value: number): adds or updates a signal on a node; value=0 deletes it\n - navigateToSpace(spaceId: string, view?: string): navigates to a space — accepts a perspective UUID or a neighbourhood CID (sharedUrl without the neighbourhood:// prefix); pre-loads space templates before switching so the template and data arrive together\n\nAiStore:\n- State:\n - models: array of Model objects\n - tasks: array of AITask objects\n - isOpen: unknown\n - messages: unknown\n - isStreaming: unknown\n - streamingContent: unknown\n - apiKeyConfigured: unknown\n - templateName: unknown\n - templateIcon: unknown\n - isReadOnly: unknown\n - hasPendingChanges: unknown\n - pickerOpen: unknown\n - pickerAction: unknown\n - pickerDefaultName: unknown\n - pickerDefaultIcon: unknown\n - pickerShowDestination: unknown\n - sessions: unknown\n - activeSessionId: unknown\n - panelMode: unknown\n - schemaJson: unknown\n - operationLoading: unknown\n - canUndo: boolean (true when there are schema edits that can be undone)\n - canRedo: boolean (true when there are undone schema edits that can be redone)\n- Actions:\n - handleSchemaPrompt(prompt: string): generates a schema from a prompt\n - sendMessage(): unknown\n - close(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - setApiKey(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setPanelMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: unknown\n - activeAppId: string | null — id of the currently active app, or null if none\n- Actions:\n - activateApp(id: string): activates an app and switches to its view\n - deactivateApp(): deactivates the current app and returns to the template view\n\n---\n\n## Store Usage Patterns\n\nReading state:\n{ \"$store\": \"storeName.property\" }\nExample: { \"$store\": \"routeStore.currentPath\" }\n\nCalling actions:\n{ \"$action\": \"storeName.method\", \"args\": [...] }\nExample: { \"$action\": \"routeStore.navigate\", \"args\": [\"/home\"] }\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"adamStore.personalSpaces\" }, \"as\": \"space\" },\n \"children\": [\n {\n \"type\": \"CircleButton\",\n \"props\": {\n \"label\": \"$space.name\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/space/\", \"$space.uuid\"] }] }\n }\n }\n ]\n}\n\nConditional rendering from store:\n{\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$eq\": [{ \"$store\": \"routeStore.currentPath\" }, \"/\"] },\n \"then\": { \"type\": \"we-text\", \"children\": [\"Home\"] },\n \"else\": { \"type\": \"we-text\", \"children\": [\"Not home\"] }\n }\n}\n\nDeriving options from store:\n{\n \"$map\": {\n \"items\": { \"$store\": \"templateStore.templates\" },\n \"select\": { \"name\": \"$item.meta.name\", \"icon\": \"$item.meta.icon\" }\n }\n}\n\nQuerying model data:\n{\n \"$query\": { \"entity\": \"TaskBlock\", \"where\": { \"status\": \"todo\" } }\n}\n\nEager-loading relations with include (most common relational pattern):\nWhen you need related data displayed alongside a list, use include to hydrate relations in one query.\n\nExample — Channel list with conversation count and latest conversation:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Channel\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"$conversationCount\": { \"from\": \"conversations\", \"count\": true },\n \"$latestConversation\": { \"from\": \"conversations\", \"order\": { \"createdAt\": \"desc\" }, \"limit\": 1 }\n }\n }\n },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"Row\",\n \"children\": [\n { \"type\": \"we-text\", \"children\": [\"$channel.name\"] },\n { \"type\": \"we-text\", \"children\": [\"$channel.$conversationCount\"] }\n ]\n }]\n}\n\nExample — Nested include (Conversations with their messages):\n{\n \"$query\": {\n \"entity\": \"Conversation\",\n \"dataset\": \"$currentDataset\",\n \"include\": {\n \"messages\": {\n \"order\": { \"createdAt\": \"desc\" },\n \"limit\": 20\n }\n }\n }\n}\nEach conversation in the result has a messages array of hydrated Message instances.\nNesting works to any depth: \"include\": { \"messages\": { \"include\": { \"reactions\": true } } }\n\nRelational drill-down (master-detail navigation across entity relations):\nUse routes + a $query `scope` when you navigate to a detail route and need only that record's children.\nscope.anchor is the parent entity type; scope.via is its HasMany relation (see externalModels) whose targets\nare the query's entity; scope.anchorId is the parent record's id. The adapter resolves the relation to a\nbackend handle, so no protocol details live in the template.\nrouteStore.segments.N extracts the Nth dynamic path segment (segments splits currentPath by \"/\").\n\nExample — Channel list → Conversation list:\n{\n \"routes\": [\n {\n \"path\": \"/\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": { \"$query\": { \"entity\": \"Channel\", \"dataset\": \"$currentDataset\" } },\n \"as\": \"channel\"\n },\n \"children\": [{\n \"type\": \"we-button\",\n \"props\": {\n \"variant\": \"ghost\",\n \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/channels/\", \"$channel.id\"] }] }\n },\n \"children\": [\"$channel.name\"]\n }]\n }]\n },\n {\n \"path\": \"/channels/:channelId\",\n \"type\": \"Column\",\n \"props\": { \"gap\": \"300\", \"p\": \"400\" },\n \"children\": [{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"Conversation\",\n \"scope\": { \"anchor\": \"Channel\", \"via\": \"conversations\", \"anchorId\": { \"$store\": \"routeStore.segments.1\" } },\n \"dataset\": \"$currentDataset\"\n }\n },\n \"as\": \"convo\"\n },\n \"children\": [{\n \"type\": \"we-text\",\n \"children\": [\"$convo.conversationName\"]\n }]\n }]\n }\n ]\n}\nNotes:\n- Use include when you need related data displayed inline (e.g. a post with its comments, a channel with its conversation count).\n- Use a scope drill-down when you're on a detail route and want only children belonging to the current record.\n- dataset must point to the dataset that holds the data. For external apps (e.g. Flux) opened as a WE space, use \"$currentDataset\".\n- The relation name (in include, or scope.via) is the HasMany field name on the parent entity.\n\nLocal state (form with validation):\n{\n \"type\": \"Column\",\n \"$localState\": {\n \"name\": {\n \"type\": \"string\",\n \"initial\": \"\",\n \"validate\": [{ \"rule\": \"required\" }, { \"rule\": \"minLength\", \"value\": 2 }]\n },\n \"loading\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" },\n \"onBlur\": { \"$touch\": \"name\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"neutral-0\", \"r\": \"400\", \"border\": \"1px solid neutral-200\", \"p\": \"400\", \"gap\": \"300\" },\n \"children\": [\n {\n \"type\": \"Row\",\n \"props\": { \"gap\": \"300\", \"ay\": \"center\" },\n \"children\": [\n { \"type\": \"we-avatar\", \"props\": { \"initials\": \"$post.author\", \"size\": \"sm\" } },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"label\" }, \"children\": [\"$post.author\"] }\n ]\n },\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-sm\" }, \"children\": [\"$post.title\"] },\n { \"type\": \"we-text\", \"children\": [\"$post.text\"] }\n ]\n }\n ]\n}\n\nUse $query or $store for dynamic data (more common in production):\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$query\": { \"entity\": \"TextBlock\" } }, \"as\": \"post\" }, \"children\": [...] }\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$store\": \"spaceStore.posts\" }, \"as\": \"post\" }, \"children\": [...] }\n\nPer-item customization inside $each:\nTo style or highlight specific items, add a data flag to those items and use $if on the flag inside the template. Do NOT use $eq: [\"$index\", N] comparisons — they are fragile, repetitive, and break when items are reordered.\nExample: add \"highlighted\": true to one item's data, then use $if on \"$post.highlighted\" in the template:\n{ \"type\": \"$if\", \"props\": { \"condition\": \"$post.highlighted\", \"then\": { \"type\": \"we-badge\", \"props\": { \"variant\": \"primary\" }, \"children\": [\"Featured\"] } } }\nFor conditional props (e.g. different bg on highlighted items):\n{ \"bg\": { \"$if\": { \"condition\": \"$post.highlighted\", \"then\": \"primary-50\", \"else\": \"neutral-0\" } } }\n\nBoolean toggle (show/hide, expand/collapse):\n{\n \"type\": \"Column\",\n \"$localState\": { \"showDetails\": { \"type\": \"boolean\", \"initial\": false } },\n \"children\": [\n { \"type\": \"we-button\", \"props\": { \"variant\": \"ghost\", \"onClick\": { \"$toggleLocal\": \"showDetails\" } }, \"children\": [\"Toggle Details\"] },\n { \"type\": \"$if\", \"props\": { \"condition\": { \"$local\": \"showDetails\" }, \"then\": { \"type\": \"we-text\", \"children\": [\"Details content here\"] } } }\n ]\n}\n\nSignal types (community-specific reactions/votes):\nSignal types are created per-community by the user. Never hardcode signal type UUIDs in schemas.\nInstead reference them by slug through spaceStore.signalTypesBySlug.\n\nALWAYS ask the user: \"What slug should I use? (e.g. 'like', 'upvote', 'star')\"\nThen use that slug in the pattern below.\n\nPattern — live wired SignalControl (inside a $each over a model with $query include):\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": { \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" } },\n \"count\": true\n },\n \"$myLikeSignal\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" },\n \"author\": \"$me.did\"\n },\n \"limit\": 1\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"then\": {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": { \"$store\": \"spaceStore.signalTypesBySlug.like\" },\n \"myValue\": \"$item.$myLikeSignal.value\",\n \"aggregate\": \"$item.$totalLikeCount\",\n \"onSignal\": {\n \"$action\": \"spaceStore.upsertSignal\",\n \"args\": [\"$item.id\", { \"$store\": \"spaceStore.signalTypesBySlug.like.id\" }, \"$arg\"]\n }\n }\n }\n }\n }\n ]\n}\n\nNotes:\n- The $if guard hides SignalControl if the community hasn't created a signal type with that slug.\n- Replace \"like\" with the user's slug throughout (in $store paths and args).\n- $query include adds $totalLikeCount and $myLikeSignal as computed properties on each item.\n- signalType prop accepts the full SignalType object (provides icon, mode, range to the UI component).\n\nPreview / mockup mode (static, no store wiring):\n{\n \"type\": \"SignalControl\",\n \"props\": {\n \"preview\": true,\n \"signalType\": { \"icon\": \"❤️\", \"mode\": \"toggle\", \"rangeMin\": 0, \"rangeMax\": 1 }\n }\n}\nUse preview: true when sketching a layout without real data. Remove it (and add the full wiring above) when going live.\n\n---\n\n## Routing Structure\n\nDefine nested routes using the \"routes\" array at the root node of the schema.\nEach route object describes a path and the UI node to render when that path is active.\nRoutes can be nested to support sub-pages and layouts.\n\nRoute objects follow the same structure as schema nodes, with an additional \"path\" property.\n\n- The \"routes\" array MUST be placed on the ROOT template node (or on a route node for nested routing). The router only reads routes from these positions — placing routes on an arbitrary child node means the router will never find them and nothing will render.\n- Use \"path: '*'\" or \"path: '/*'\" for catch-all/not-found routes.\n- Use \":paramName\" for dynamic route parameters (e.g. \"/space/:spaceId\").\n- Use nested \"routes\" arrays for sub-pages and layouts.\n- Use { \"type\": \"$routes\" } in children to indicate where nested routes should render. The $routes outlet can be deeply nested — only the routes array placement matters.\n- EVERY { \"type\": \"$routes\" } outlet MUST have a \"routes\" array defined on the same node or an ancestor node. A $routes outlet without a routes array is invalid and will fail validation.\n- NEVER duplicate a route path — every route in the same \"routes\" array MUST have a unique path.\n- When using tabs, each tab's key and navigate path MUST have a matching route. Ensure a 1:1 correspondence between tabs and routes.\n\n### Tabs + Routing\n\nIMPORTANT: we-tabs only manages visual selection — clicking a tab does NOT navigate automatically.\nEach we-tab MUST have an onClick with { \"$action\": \"routeStore.navigate\" } to trigger route changes.\nBind we-tabs selectedKey to the matching route segment so the active tab stays in sync.\n(Alternatively, a single onChange on we-tabs can replace per-tab onClick — see onChange pattern below.)\n\nRecommended pattern — header above tabs (routes on ROOT, $routes outlet nested inside):\n{\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"Select a tab\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Posts content\"] }] },\n { \"path\": \"/articles\", \"type\": \"Column\", \"children\": [{ \"type\": \"we-text\", \"children\": [\"Articles content\"] }] }\n ],\n \"children\": [\n { \"type\": \"Row\", \"props\": { \"p\": \"300\", \"ax\": \"between\" }, \"children\": [\n { \"type\": \"we-text\", \"props\": { \"variant\": \"heading-lg\" }, \"children\": [\"My App\"] }\n ]},\n {\n \"type\": \"we-tabs\",\n \"props\": { \"selectedKey\": { \"$store\": \"routeStore.segments.0\" } },\n \"children\": [\n { \"type\": \"we-tab\", \"props\": { \"key\": \"posts\", \"label\": \"Posts\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/posts\"] } } },\n { \"type\": \"we-tab\", \"props\": { \"key\": \"articles\", \"label\": \"Articles\", \"onClick\": { \"$action\": \"routeStore.navigate\", \"args\": [\"/articles\"] } } }\n ]\n },\n { \"type\": \"$routes\" }\n ]\n}\nNote: \"routes\" is on the root Column, NOT on a child. The $routes outlet is a child — that's fine. Only the routes array placement matters.\n\nWRONG — two common mistakes that produce empty tabs (validator will catch both):\n{\n // MISTAKE 1: routes defined on an inner child node, not the root.\n // The router never inspects children for routes arrays — this routes array is invisible.\n \"type\": \"Column\",\n \"children\": [\n { \"type\": \"we-tabs\", \"children\": [\"...tabs...\"] },\n {\n \"type\": \"Column\",\n \"routes\": [ // ← WRONG: router never reads this\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [\"...\"] }\n ],\n \"children\": [{ \"type\": \"$routes\" }] // ← outlet here does nothing without a live routes array\n }\n ]\n}\n\n{\n // MISTAKE 2: using { type: \"$routes\" } as a route entry's component type.\n // $routes is an outlet slot marker — as a leaf route entry it has no children injected,\n // so it returns null. Every tab navigates to a route that renders nothing.\n \"type\": \"Column\",\n \"routes\": [\n { \"path\": \"/posts\", \"type\": \"$routes\" } // ← WRONG: renders null, use a real component\n ],\n \"children\": [{ \"type\": \"$routes\" }]\n}\n\nAlternative: single onChange on we-tabs (fires with $event.detail.value = selected key):\n{ \"onChange\": { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$concat\": [\"/\", \"$arg.detail.value\"] }] } }\nThis replaces all per-tab onClick handlers but requires $concat to build the path.\n\nNested routing example:\n{\n \"routes\": [\n { \"path\": \"*\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Page not found\"] }] },\n { \"path\": \"/\", \"type\": \"Column\", \"props\": { \"ax\": \"center\", \"p\": \"500\" }, \"children\": [{ \"type\": \"we-text\", \"children\": [\"Home page\"] }] },\n {\n \"path\": \"/space/:spaceId\",\n \"type\": \"Row\",\n \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Space page not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"About sub-page\"] },\n { \"path\": \"/posts\", \"type\": \"Column\", \"children\": [{ \"type\": \"$routes\" }],\n \"routes\": [\n { \"path\": \"/*\", \"type\": \"we-text\", \"children\": [\"Post not found\"] },\n { \"path\": \"/\", \"type\": \"we-text\", \"children\": [\"No posts selected\"] },\n { \"path\": \"/1\", \"type\": \"we-text\", \"children\": [\"Post 1 page\"] }\n ]\n }\n ]\n }\n ]\n}\n\n---\n\n## Rules & Best Practices\n\n- Always use the correct prop names and value types for each component.\n- Never use null as a value in any children array. Only use valid schema nodes or strings.\n- Each item in a children array must be either a valid schema node object or a string.\n- Use design tokens for spacing, color, radius, etc. (do not use raw CSS except in styles).\n- Use the styles prop for custom inline CSS (e.g., { \"width\": \"100px\" }).\n- Use hoverProps for hover state overrides, activeProps for pressed state, focusProps for focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row).\n- Use dynamic logic tokens ($store, $if, $action, etc.) for reactivity and conditional behavior.\n- Nest components using children or slots as needed.\n- For routes, use the routes array with path and child nodes.\n- Do not invent new components or props — use only those listed in the component registry.\n- Do not set props to their default/inherited values — omit them. fontSize and fontWeight inherit from parents (~16px / normal), so only set them when you need a different value.\n- Omit empty `props` and `children` — both are optional. Do not write `props: {}` or `children: []`.\n- Do not use `as const` on schema node `type` fields — `SchemaNode.type` is `string`, so it is never needed.\n- For icon-only buttons, nest a `we-icon` child inside `we-button` rather than using a `text` prop with a Unicode character. **Omit the `size` prop on `we-icon` when nesting inside sized primitives** (`we-button`, `we-input`, `we-badge`, `we-textarea`) — these components auto-size nested icons via `--we-context-icon-size` (xs→12px, sm→16px, md→24px, lg→32px, xl→40px). Only set an explicit icon `size` if you need to override the automatic sizing. Example: `{ type: 'we-button', props: { variant: 'ghost', size: 'sm' }, children: [{ type: 'we-icon', props: { name: 'x' } }] }`.\n- NEVER pass a bare number like \"16\" as a size or dimension prop — it is not valid CSS. Always check the component's declared prop type: if it's a string union, use one of the listed values; if it accepts arbitrary strings, include a CSS unit (e.g. \"16px\", \"2rem\").\n- For interactive list items and selectable options, use `we-button` with variant switching (e.g., `secondary` when selected, `ghost` when not) instead of manually styling `Row` with cursor, bg, and onClick. Buttons provide hover, focus, and active states for free.\n- For card-like layouts, compose from `Column` with DS props (bg, r, border, p, gap). This gives full control over spacing and appearance.\n- When rendering lists of similar items (posts, cards, users, etc.), ALWAYS use `$each` with a single template child — never duplicate the same node structure multiple times. Use literal arrays in `items` for static data, or `$store`/`$query` for dynamic data.\n\n### Icon Names (Phosphor Icons)\n\nwe-icon uses **Phosphor Icons** (v2.1). Do NOT use Heroicons, Material, or FontAwesome names.\nPhosphor names are lowercase-kebab-case. The `weight` prop controls style: \"regular\" (default), \"bold\", \"fill\", \"light\", \"thin\", \"duotone\".\n\nCommon Phosphor icon names (use these, NOT Heroicons equivalents):\n- Navigation: house, arrow-left, arrow-right, caret-left, caret-right, caret-down, caret-up, arrows-clockwise\n- Actions: plus, minus, x, check, pencil-simple, trash, copy, download, upload, share, link, magnifying-glass, funnel, sliders-horizontal\n- Communication: chat-circle, chat-dots, envelope-simple, paper-plane-tilt, bell, megaphone\n- Social: heart, thumbs-up, thumbs-down, star, share-network, users, user, user-plus\n- Media: image, camera, play, pause, stop, microphone, speaker-high, video-camera\n- Files: file, file-text, folder, folder-open, clipboard-text, note\n- UI: list, squares-four, gear, dots-three, dots-three-vertical, warning, info, question, check-circle, x-circle, eye, eye-slash\n- Misc: lightning, rocket, globe, map-pin, calendar, clock, tag, bookmark, flag, lock, shield-check\n\nWRONG icon names (Heroicons/Material — do NOT use):\n- \"chat-bubble-left\" → use \"chat-circle\"\n- \"chevron-right\" → use \"caret-right\"\n- \"cog\" / \"settings\" → use \"gear\"\n- \"trash-can\" → use \"trash\"\n- \"magnifying-glass-circle\" → use \"magnifying-glass\"\n- \"home\" → use \"house\"\n- \"favorite\" → use \"heart\"\n- \"delete\" → use \"trash\"\n- \"search\" → use \"magnifying-glass\"\n- \"close\" → use \"x\"\n- \"menu\" → use \"list\"\n- All schemas must be valid JSON with property names and string values in double quotes.\n- The meta property at the root is required: { \"meta\": { \"name\": \"...\", \"description\": \"...\", \"icon\": \"...\" } }\n- Always set `bg: 'neutral-50'` on root-level schema nodes (templates, pages). This ensures proper background in all themes — without it, dark mode renders white backgrounds.\n\nMost @we/primitives inherit all Design System Props documented above (layout, visual, flex, typography, state).\nSome layout-only primitives (we-avatar, we-icon, we-image, we-spinner, etc.) only accept Layout props — see the Design System Props section for the full list.\n\nNative HTML elements (lowercase tags render directly without registry entries):\n- Layout: div, section, article, aside, main, nav, header, footer\n- Text: p, span, h1-h6, pre, code, blockquote\n- Lists: ul, ol, li\n- Forms: form, input, button, label, select, textarea\n- Media: img, video, audio, canvas, figure, figcaption\n- Other: a, table, tr, td, th, details, summary, dialog\n\n## Schema Validation\n\nRun `we-validate-schemas` (or `node packages/schema-system/shared/dist/cli/we-validate-schemas.js`) from the monorepo root to validate all `.schema.ts` files.\nFor a specific file: `we-validate-schemas packages/app-framework/src/shared/schemas/MyTemplate.schema.ts`\n\nAfter creating or modifying a `.schema.ts` file, always run validation to catch:\n- Unknown component types (typos, missing registry entries)\n- Invalid or misspelled props (with \"did you mean?\" suggestions)\n- Prop type mismatches (e.g., number where string expected)\n- Missing required `meta` field on root TemplateSchema nodes\n- `$routes` outlet without a `routes` array on an ancestor\n- Orphan `$local` / `$setLocal` references without a `$localState` ancestor\n- DS layer consistency (mixing props from layers the component doesn't support)"; diff --git a/packages/schema-system/shared/src/contextTypes.ts b/packages/schema-system/shared/src/contextTypes.ts index 22996dec..683da88f 100644 --- a/packages/schema-system/shared/src/contextTypes.ts +++ b/packages/schema-system/shared/src/contextTypes.ts @@ -60,6 +60,12 @@ export interface StateMemberMeta { type: 'array' | 'object' | 'string' | 'boolean' | 'number'; /** Known properties on the value (for objects) or on array items (for arrays) */ properties?: string[]; + /** + * The model this member holds instances of, when it holds model instances. + * Preferred over spelling out `properties` by hand: the model's fields are generated + * from `@we/models`, so they stay complete as the model changes. Consumers union both. + */ + model?: string; } /** A store with its state properties and action methods */ diff --git a/packages/schema-system/shared/src/scope.test.ts b/packages/schema-system/shared/src/scope.test.ts index f89a40d5..58dee7d1 100644 --- a/packages/schema-system/shared/src/scope.test.ts +++ b/packages/schema-system/shared/src/scope.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ModelEntry, StoreEntry } from './contextTypes'; -import { findNodeChain, findScopeRef, getScopeAtNode, scopeRefToToken } from './scope'; +import { findNodeChain, findScopeRef, getScopeAtNode, inferRefKind, scopeRefToToken } from './scope'; import type { SchemaNode } from './types'; const storeEntries: StoreEntry[] = [ @@ -182,6 +182,74 @@ describe('getScopeAtNode', () => { }); }); +describe('store members that hold model instances', () => { + const entries: StoreEntry[] = [ + { name: 'spaceStore', state: { currentSpace: { type: 'object', model: 'Space' } }, actions: [] }, + ]; + + it('takes properties from the model registry rather than a hand-written list', () => { + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: entries, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + // `description` is a declared Space field; a hand-maintained list had omitted fields + // like this, so the picker could not offer them at all. + expect(paths).toContain('spaceStore.currentSpace.description'); + expect(paths).toContain('spaceStore.currentSpace.location'); + }); + + it('includes the base fields every model instance carries', () => { + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: entries, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + for (const base of ['id', 'author', 'createdAt', 'updatedAt']) { + expect(paths).toContain(`spaceStore.currentSpace.${base}`); + } + }); + + it('unions model fields with explicitly declared ones', () => { + const withComputed: StoreEntry[] = [ + { + name: 'spaceStore', + state: { currentSpace: { type: 'object', model: 'Space', properties: ['$memberCount'] } }, + actions: [], + }, + ]; + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: withComputed, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + expect(paths).toContain('spaceStore.currentSpace.name'); + expect(paths).toContain('spaceStore.currentSpace.$memberCount'); + }); + + it('falls back to declared properties when the model is unknown', () => { + const unknown: StoreEntry[] = [ + { name: 'spaceStore', state: { thing: { type: 'object', model: 'Nope', properties: ['a'] } }, actions: [] }, + ]; + const groups = getScopeAtNode({ id: 'n', type: 'Column' }, 'n', { storeEntries: unknown, models }); + const paths = groups.find((g) => g.label === 'spaceStore')?.refs.map((r) => r.path); + expect(paths).toContain('spaceStore.thing.a'); + }); +}); + +describe('inferRefKind', () => { + const tree: SchemaNode = { + id: 'root', + type: 'Column', + $localState: { draft: { type: 'string', initial: '' } }, + }; + const groups = getScopeAtNode(tree, 'root', { storeEntries }); + + it('resolves paths the listed scope does not contain', () => { + // The whole point: a store member whose metadata is incomplete is still reachable. + expect(inferRefKind('adamStore.somethingUndocumented.deep', groups)).toBe('store'); + expect(inferRefKind('draft.nested', groups)).toBe('local'); + expect(inferRefKind('$post.title', groups)).toBe('context'); + }); + + it('refuses paths whose first segment matches nothing known', () => { + expect(inferRefKind('mysteryStore.value', groups)).toBeNull(); + expect(inferRefKind('notAField', groups)).toBeNull(); + expect(inferRefKind(' ', groups)).toBeNull(); + }); +}); + describe('token conversion', () => { it('builds the right token per ref kind', () => { expect(scopeRefToToken({ kind: 'store', path: 'a.b' })).toEqual({ $store: 'a.b' }); diff --git a/packages/schema-system/shared/src/scope.ts b/packages/schema-system/shared/src/scope.ts index dac44aaa..b8adef7b 100644 --- a/packages/schema-system/shared/src/scope.ts +++ b/packages/schema-system/shared/src/scope.ts @@ -144,11 +144,29 @@ export function findNodeChain(root: SchemaNode, nodeId: string): SchemaNode[] | // ── Item field inference ──────────────────────────────────────────────────── +/** + * Fields every model instance carries from Ad4mModel, which aren't declared per-model. + * Templates use these constantly (`$post.author`, `$space.createdAt`), so leaving them + * out makes the picker look broken for the most common ownership/ordering conditions. + */ +const MODEL_BASE_FIELDS = ['id', 'author', 'createdAt', 'updatedAt']; + function modelProperties(models: ModelEntry[] | undefined, entity: string): string[] | undefined { const model = models?.find((m) => m.name === entity || m.className === entity); if (!model) return undefined; - // `id` is present on every model instance but isn't declared as a field. - return ['id', ...model.fields.map((f) => f.name), ...model.relations.map((r) => r.name)]; + return [...MODEL_BASE_FIELDS, ...model.fields.map((f) => f.name), ...model.relations.map((r) => r.name)]; +} + +/** + * The properties of a store state member: the model's fields when it holds model + * instances, unioned with any explicitly declared ones (a store may expose computed + * fields the model doesn't have). + */ +function memberProperties(meta: StateMemberMeta, models: ModelEntry[] | undefined): string[] | undefined { + const fromModel = meta.model ? modelProperties(models, meta.model) : undefined; + if (!fromModel) return meta.properties; + if (!meta.properties) return fromModel; + return [...new Set([...fromModel, ...meta.properties])]; } function storeMemberMeta( @@ -190,7 +208,7 @@ function inferItemProperties( } if (typeof token.$store === 'string') { const found = storeMemberMeta(options.storeEntries, token.$store); - return { properties: found?.meta.properties, hint: token.$store }; + return { properties: found && memberProperties(found.meta, options.models), hint: token.$store }; } if (typeof token.$local === 'string') { const ref = localRefs.get(token.$local); @@ -308,18 +326,19 @@ export function getScopeAtNode(root: SchemaNode, nodeId: string, options: ScopeO const refs: ScopeRef[] = []; for (const [member, meta] of Object.entries(store.state)) { const path = `${store.name}.${member}`; + const properties = memberProperties(meta, options.models); refs.push({ id: `store:${path}`, kind: 'store', path, label: member, valueType: meta.type, - properties: meta.properties, + properties, }); // Objects can be drilled into directly; an array's `properties` describe its // *items*, which are only reachable through $each — so they aren't listed here. - if (meta.type === 'object' && meta.properties) { - for (const prop of meta.properties) { + if (meta.type === 'object' && properties) { + for (const prop of properties) { refs.push({ id: `store:${path}.${prop}`, kind: 'store', @@ -353,6 +372,28 @@ export function scopeRefToToken(ref: Pick): unknown { } } +/** + * Work out what kind of reference a hand-typed path is, so a picker can offer paths that + * aren't in the listed scope — a store member whose metadata is incomplete, or a deeper + * nesting than the registry describes. + * + * Returns null when the path's first segment matches nothing known, rather than guessing: + * an unresolvable path would serialize to a token that silently reads `undefined`. + */ +export function inferRefKind(path: string, groups: ScopeGroup[]): ScopeRefKind | null { + const trimmed = path.trim(); + if (!trimmed) return null; + // Context refs and iteration variables are both `$`-prefixed strings at the token level. + if (trimmed.startsWith('$')) return 'context'; + + const [head] = trimmed.split('.'); + for (const group of groups) { + if (group.kind === 'store' && group.label === head) return 'store'; + if (group.kind === 'local' && group.refs.some((r) => r.path === head)) return 'local'; + } + return null; +} + /** Find the scope ref a token reads, or null if it isn't a plain reference. */ export function findScopeRef(groups: ScopeGroup[], token: unknown): ScopeRef | null { let kind: ScopeRefKind | null = null; From 536feb90de754e3c7f945b8feeb5bf17620d4e85 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:09:55 +0100 Subject: [PATCH 07/12] feat(editor): let the value picker accept a typed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing the store metadata removes today's dead end, but the registry describes stores by hand and so will never be exactly complete — and no list can cover arbitrary nesting. Without a way to say "the path I want isn't offered", any gap sends the author back to the JSON editor. The search box now doubles as a path entry: when the typed text isn't already in the list, the picker offers it as a reference. `inferRefKind` decides what kind it is from the first segment — a known store name, a $localState field, or a $-prefixed context ref — and returns null when the root matches nothing known, so a typo can't create a token that silently reads undefined. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/editor/ValueRefPicker.tsx | 33 ++++++++++++++++++- packages/schema-system/shared/src/index.ts | 2 +- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx index bc492738..9680a2dc 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx @@ -1,6 +1,7 @@ import { Column, Row } from '@we/components/solid'; import { tokenVar } from '@we/design-utils'; import type { ConditionOperand, FormStateToken, ScopeGroup, ScopeRef, ScopeValueType } from '@we/schema-shared'; +import { inferRefKind } from '@we/schema-shared'; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; /** @@ -124,6 +125,19 @@ export function ValueRefPicker(props: { .filter((group) => group.refs.length > 0); }); + /** + * The typed text as a reference, when it resolves to a known store/local/context root + * and isn't already offered in the list. Lets an author reach a property the registry + * doesn't describe without dropping to the JSON editor. + */ + const customPath = createMemo<{ kind: 'store' | 'local' | 'context'; path: string } | null>(() => { + const path = search().trim(); + if (!path) return null; + if (props.scope.some((g) => g.refs.some((r) => r.path === path))) return null; + const kind = inferRefKind(path, props.scope); + return kind && kind !== 'item' ? { kind, path } : null; + }); + const choose = (operand: ConditionOperand) => { props.onSelect(operand); setOpen(false); @@ -152,11 +166,28 @@ export function ValueRefPicker(props: { type="text" size="xs" autofocus - placeholder="Search values…" + placeholder="Search or type a path…" value={search()} on:input={(e: CustomEvent) => setSearch(e.detail)} /> + {/* The registry describes stores by hand and so is never quite complete. + Rather than dead-ending on a path it doesn't list, offer the typed one — + but only when its first segment resolves to a known store, local field or + context ref, so an unresolvable path can't be created by typo. */} + + {(custom) => ( + choose(custom())}> + + + + Use “{custom().path}” + + + + )} + + diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index e2d3ac49..94b28d17 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -89,7 +89,7 @@ export type { SectionEntry, StoredTemplate, FindNodeResult, PatchError } from '. export { createStoredTemplate, listSections, getSection, updateSection } from './sections'; export { getComponentMeta } from './componentMeta'; export type { ComponentMeta, PropMeta, PropLayer } from './componentMeta'; -export { findNodeChain, findScopeRef, getScopeAtNode, scopeRefToToken } from './scope'; +export { findNodeChain, findScopeRef, getScopeAtNode, inferRefKind, scopeRefToToken } from './scope'; export type { ScopeGroup, ScopeOptions, ScopeRef, ScopeRefKind, ScopeValueType } from './scope'; export { classifyContent, From 1e17db2b13e9b33bd160d9ae36ebea7b6ae97246 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:29:28 +0100 Subject: [PATCH 08/12] fix(schema-shared): re-read $if branches so edits render immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing a conditional's then/else — or swapping its condition — did not show up on the canvas. The change was in the schema, but the rendered output only caught up after the subtree remounted, which in practice meant leaving visual editing mode, changing route and coming back. resolveIfProp destructured condition/then/else from the token once, at resolve time, and closed over them. The memo below re-ran whenever the *data behind the condition* changed, so a store-driven conditional flipped branches correctly — but an edit to the branch values themselves was never read again. The visual editor renders from a Solid store and findMutations patches tokens in place, so no reference changes and nothing invalidates: exactly the case the eager destructure misses. (The renderer already had this lesson: its string-child branch calls resolveProp *inside* the reactive expression, with a comment saying why.) Reads the spec inside the memo instead, and inside the per-call closure on the $arg path so an edited branch takes effect on the next invocation rather than the next mount. Whether a condition uses $arg still decides the return shape once — that is a property of how the template was authored, not something an edit flips mid-session. Tests drive the resolver with a non-caching memo, so re-evaluation is observable without pulling Solid into this package: resolve, mutate the token, read again. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/propResolvers/conditional.test.ts | 104 ++++++++++++++++++ .../shared/src/propResolvers/conditional.ts | 34 ++++-- 2 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 packages/schema-system/shared/src/propResolvers/conditional.test.ts diff --git a/packages/schema-system/shared/src/propResolvers/conditional.test.ts b/packages/schema-system/shared/src/propResolvers/conditional.test.ts new file mode 100644 index 00000000..9da3851e --- /dev/null +++ b/packages/schema-system/shared/src/propResolvers/conditional.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProp } from './dispatcher'; +import { REACTIVE_ACCESSOR } from './reactive'; +import type { Memo } from './types'; + +/** + * A non-caching stand-in for the framework's memo: it hands back the thunk itself, so a + * test can invoke it repeatedly and observe what the resolver re-reads on each pass. + * Solid's createMemo would cache, hiding exactly the staleness these tests are about. + */ +const rerunMemo = ((fn: () => unknown) => fn) as unknown as Memo; + +function accessor(resolved: unknown): () => unknown { + expect(typeof resolved).toBe('function'); + expect(REACTIVE_ACCESSOR in (resolved as object)).toBe(true); + return resolved as () => unknown; +} + +describe('$if prop resolution — re-reads the schema', () => { + // The visual editor renders from a reactive store, so an edit to a $if mutates the + // token in place. Anything the resolver destructures once is invisible to that edit: + // the branch kept rendering its old value until the subtree remounted. + it('picks up an edited `then` branch', () => { + const token = { $if: { condition: true, then: 'Shared', else: 'Personal' } }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Shared'); + + token.$if.then = 'Public'; + expect(read()).toBe('Public'); + }); + + it('picks up an edited `else` branch', () => { + const token = { $if: { condition: false, then: 'Shared', else: 'Personal' } }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Personal'); + + token.$if.else = 'Private'; + expect(read()).toBe('Private'); + }); + + it('picks up an edited condition', () => { + const token: { $if: { condition: unknown; then: string; else: string } } = { + $if: { condition: false, then: 'Shared', else: 'Personal' }, + }; + const read = accessor(resolveProp(token, {}, {}, rerunMemo)); + expect(read()).toBe('Personal'); + + token.$if.condition = true; + expect(read()).toBe('Shared'); + }); + + it('picks up a condition swapped for a different operator shape', () => { + const stores = { spaceStore: { access: 'shared' } }; + const token: { $if: { condition: unknown; then: string; else: string } } = { + $if: { condition: { $eq: [{ $store: 'spaceStore.access' }, 'shared'] }, then: 'Shared', else: 'Personal' }, + }; + const read = accessor(resolveProp(token, stores, {}, rerunMemo)); + expect(read()).toBe('Shared'); + + token.$if.condition = { $eq: [{ $store: 'spaceStore.access' }, 'personal'] }; + expect(read()).toBe('Personal'); + }); + + it('still resolves store-backed conditions', () => { + const stores = { spaceStore: { access: 'personal' } }; + const token = { + $if: { condition: { $eq: [{ $store: 'spaceStore.access' }, 'shared'] }, then: 'Shared', else: 'Personal' }, + }; + expect(accessor(resolveProp(token, stores, {}, rerunMemo))()).toBe('Personal'); + }); + + it('resolves branches that are themselves tokens', () => { + const stores = { spaceStore: { name: 'Home' } }; + const token = { $if: { condition: true, then: { $store: 'spaceStore.name' }, else: 'none' } }; + expect(accessor(resolveProp(token, stores, {}, rerunMemo))()).toBe('Home'); + }); +}); + +describe('$if with $arg — evaluated per call', () => { + it('reads the branch at call time', () => { + const calls: string[] = []; + const stores = { s: { hit: (v: string) => calls.push(v) } }; + const token = { + $if: { + condition: { $eq: ['$arg.kind', 'a'] }, + then: { $action: 's.hit', args: ['then-branch'] }, + else: { $action: 's.hit', args: ['else-branch'] }, + }, + }; + const handler = resolveProp(token, stores, {}, rerunMemo) as (arg: unknown) => void; + + handler({ kind: 'a' }); + expect(calls).toEqual(['then-branch']); + + // Editing the branch must affect the next invocation, not just the next mount. + token.$if.then = { $action: 's.hit', args: ['edited'] }; + handler({ kind: 'a' }); + expect(calls).toEqual(['then-branch', 'edited']); + + handler({ kind: 'b' }); + expect(calls).toEqual(['then-branch', 'edited', 'else-branch']); + }); +}); diff --git a/packages/schema-system/shared/src/propResolvers/conditional.ts b/packages/schema-system/shared/src/propResolvers/conditional.ts index 8a9727fb..7cd7bcdc 100644 --- a/packages/schema-system/shared/src/propResolvers/conditional.ts +++ b/packages/schema-system/shared/src/propResolvers/conditional.ts @@ -37,16 +37,16 @@ export function resolveIfProp( memo: Memo, resolvePropFn: typeof resolveProp, ): unknown { - const { condition, then: thenValue, else: elseValue } = (value as { $if: IfProp }).$if; - - // Pre-wrap array branches into a single dispatch function so the memo always returns - // a function (or primitive/undefined), never a raw array. - const thenBranch = wrapArrayBranch(thenValue, stores, context, memo, resolvePropFn); - const elseBranch = wrapArrayBranch(elseValue, stores, context, memo, resolvePropFn); + // Read the spec lazily everywhere below. The schema is reactive in the visual editor, + // so a token edited in place must be picked up on the next evaluation — destructuring + // once here is what made edited branches invisible until the subtree remounted. + const spec = () => (value as { $if: IfProp }).$if; // Check if condition contains $arg tokens - if so, we need to return a function - // that evaluates the condition on each call with access to callback arguments - const conditionStr = JSON.stringify(condition) ?? ''; + // that evaluates the condition on each call with access to callback arguments. + // This shape decision is made once: whether a condition uses $arg is a property of how + // the template was authored, not something an edit is expected to flip mid-session. + const conditionStr = JSON.stringify(spec().condition) ?? ''; if (conditionStr.includes('$arg')) { // Return a function that evaluates the conditional when called return (...callArgs: unknown[]) => { @@ -78,12 +78,15 @@ export function resolveIfProp( }; // Resolve condition with $arg tokens replaced + const { condition, then: thenValue, else: elseValue } = spec(); const resolvedCondition = resolveWithArg(condition); const conditionResult = resolvePropFn(resolvedCondition, stores, context, memo); const conditionMet = isReactiveAccessor(conditionResult) ? conditionResult() : conditionResult; - // Resolve the appropriate branch (already array-wrapped if needed) - const branchResult = resolvePropFn(conditionMet ? thenBranch : elseBranch, stores, context, memo); + // Pre-wrap array branches into a single dispatch function so the result is always a + // function (or primitive/undefined), never a raw array. + const branch = wrapArrayBranch(conditionMet ? thenValue : elseValue, stores, context, memo, resolvePropFn); + const branchResult = resolvePropFn(branch, stores, context, memo); // If branch result is a function (e.g., an action or wrapped array), call it with the arguments if (typeof branchResult === 'function') { @@ -97,6 +100,17 @@ export function resolveIfProp( // Standard $if without $arg - wrap in memo to reactively re-evaluate when condition changes return markReactive( memo(() => { + // Re-read condition/then/else from `value` on every evaluation rather than closing + // over them. When the schema itself is reactive (the visual editor renders from a + // Solid store), destructuring once meant an edit to a branch was never picked up — + // the memo re-ran only when the *data* behind the condition changed, so an edited + // then/else only appeared after the subtree remounted. + const { condition, then: thenValue, else: elseValue } = spec(); + // Pre-wrap array branches into a single dispatch function so the memo always returns + // a function (or primitive/undefined), never a raw array. + const thenBranch = wrapArrayBranch(thenValue, stores, context, memo, resolvePropFn); + const elseBranch = wrapArrayBranch(elseValue, stores, context, memo, resolvePropFn); + const conditionResult = resolvePropFn(condition, stores, context, memo); // Unwrap reactive accessor if condition resolved to one (e.g. signal from $store) const conditionMet = isReactiveAccessor(conditionResult) ? conditionResult() : conditionResult; From 88afe0c768467f8a3a38f35c7a8f4a27d15faa28 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:29:28 +0100 Subject: [PATCH 09/12] fix(editor): make "pick a value from data" actually open the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button beside a literal input did nothing. It asked for reference mode by writing an empty reference, but an empty reference serializes to '' — which reads back as an empty *literal*. Callers that write through on every change (ValueEditor, used for the then/else branches) therefore bounced straight back to the literal input, and the control never appeared. Holds the intent as local UI state instead of deriving it from the operand. That also makes the switch non-destructive in both directions: nothing is written until a reference is actually chosen, so the node keeps its current value while the picker is open, and backing out via "use a fixed value" restores the literal that was already there rather than clearing it. The same button on the $count and validation-state rows had the same defect and is fixed by the same change. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/editor/ValueRefPicker.tsx | 49 +++++++++++++------ 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx index 9680a2dc..9396ef02 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx @@ -283,7 +283,27 @@ export function OperandInput(props: { allowCount?: boolean; placeholder?: string; }) { - const isLiteral = () => props.value?.kind === 'literal' || props.value?.kind === 'list'; + /** + * "Show me the picker" is UI state, not a property of the value. + * + * It can't be derived from the operand: an empty reference serializes to `''`, which + * reads back as an empty *literal*, so callers that write through on every change + * (ValueEditor does) would bounce straight back to the literal input — the switch + * button did nothing at all. Holding the intent locally also means the schema keeps its + * current value until a reference is actually chosen. + */ + const [refMode, setRefMode] = createSignal(false); + + const isLiteral = () => !refMode() && (props.value?.kind === 'literal' || props.value?.kind === 'list'); + + const pick = (operand: ConditionOperand) => { + setRefMode(false); + // Backing out of the picker via "use a fixed value" restores the literal that was + // already there rather than clearing it — switching modes and changing your mind + // shouldn't cost you the text you had typed. + if (operand.kind === 'literal' && operand.value === '' && props.value?.kind === 'literal') return; + props.onChange(operand); + }; const literalControl = () => { if (props.list) { @@ -352,13 +372,14 @@ export function OperandInput(props: { ); }; - /** Swap back to picking a reference, discarding the current composite/literal value. */ - const resetToRef = () => props.onChange({ kind: 'context', path: '' }); - - const resetButton = (title: string) => ( + /** + * Swap to the reference picker. Nothing is written yet — the current value stands until + * a reference is chosen, so cancelling out of the picker leaves the node as it was. + */ + const resetButton = (title: string, icon: string) => ( - - + setRefMode(true)} aria-label={title}> + ); @@ -377,7 +398,7 @@ export function OperandInput(props: { return ( @@ -415,7 +436,7 @@ export function OperandInput(props: { )} - {resetButton('Pick a different value')} + {resetButton('Pick a different value', 'arrow-counter-clockwise')} } > @@ -424,8 +445,8 @@ export function OperandInput(props: { fallback={ {literalControl()} - - - - - + {resetButton('Pick a value from data instead', 'database')} From 40a937b1b6b9db7226d56fc0b280c7173b8c5822 Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:58:16 +0100 Subject: [PATCH 10/12] fix(editor): keep a half-built $count out of the schema, and always offer both modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing "Count of a list…" wrote { $count: { items: '' } } straight to the schema. That token is not valid — an empty `items` reads back as a count over an empty literal — so the editor could not parse it back either, and dropped the field into the raw JSON view showing a fragment with no indication of what to type. The count row is now held locally until a list is actually picked, so the nested picker appears immediately and the schema keeps its previous value until the choice is complete. Replaces the single mode-switch button with a persistent pair: Aa goes straight to plain text entry, database opens the picker (whose footer still reaches list counts). There had been three different icons for what is really two actions, and reaching plain text from a count meant going through the picker first — two clicks for a switch that should be one. Both are always present with the active mode highlighted, so every mode is one click from either. That makes the picker's own "Use a fixed value…" entry redundant, so it and the allowLiteral prop are gone. A new allowText prop lets a caller drop the text button where a literal makes no sense. The render is an explicit Switch over a mode memo. Three nested Show/fallback pairs had made the reading order the inverse of the logical one, and this adds a fourth state. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/editor/ValueRefPicker.tsx | 265 +++++++++++------- 1 file changed, 161 insertions(+), 104 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx index 9396ef02..0f568cd1 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueRefPicker.tsx @@ -2,7 +2,7 @@ import { Column, Row } from '@we/components/solid'; import { tokenVar } from '@we/design-utils'; import type { ConditionOperand, FormStateToken, ScopeGroup, ScopeRef, ScopeValueType } from '@we/schema-shared'; import { inferRefKind } from '@we/schema-shared'; -import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch } from 'solid-js'; /** * Pickers for a single value in the logic editors. @@ -95,8 +95,6 @@ export function ValueRefPicker(props: { scope: ScopeGroup[]; value?: ConditionOperand; onSelect: (operand: ConditionOperand) => void; - /** Offer a "use a fixed value" entry that switches the operand to literal mode. */ - allowLiteral?: boolean; /** Offer a "count of a list" entry that wraps a reference in $count. */ allowCount?: boolean; placeholder?: string; @@ -236,24 +234,14 @@ export function ValueRefPicker(props: { - + - - choose({ kind: 'count', items: { kind: 'context', path: '' } })}> - - - Count of a list… - - - - - choose({ kind: 'literal', value: '' })}> - - - Use a fixed value… - - - + choose({ kind: 'count', items: { kind: 'context', path: '' } })}> + + + Count of a list… + + @@ -281,27 +269,50 @@ export function OperandInput(props: { list?: boolean; /** Offer wrapping a reference in `$count`. */ allowCount?: boolean; + /** + * Offer plain-text entry. False for the left side of a comparison, where a literal + * makes a constant condition — across the built-in templates the left operand is a + * reference 1823 times and a literal 10, while the right is a literal 965 times. + */ + allowText?: boolean; placeholder?: string; }) { /** - * "Show me the picker" is UI state, not a property of the value. + * Which editor to show is UI state, not something derivable from the value. * - * It can't be derived from the operand: an empty reference serializes to `''`, which - * reads back as an empty *literal*, so callers that write through on every change - * (ValueEditor does) would bounce straight back to the literal input — the switch - * button did nothing at all. Holding the intent locally also means the schema keeps its - * current value until a reference is actually chosen. + * Half-made choices have no valid serialized form: an empty reference writes `''`, + * which reads back as an empty *literal*, and `{ $count: { items: '' } }` reads back as + * a count over an empty literal. Callers that write through on every change (ValueEditor + * does) would bounce straight out of the mode that was just asked for — which is exactly + * how the switch buttons ended up doing nothing and how a new count landed in raw JSON. + * + * Holding the intent here means nothing is written until the choice is complete, so the + * node keeps its current value while a mode is being explored. */ const [refMode, setRefMode] = createSignal(false); + const [textMode, setTextMode] = createSignal(false); + const [pendingCount, setPendingCount] = createSignal | null>(null); - const isLiteral = () => !refMode() && (props.value?.kind === 'literal' || props.value?.kind === 'list'); + /** A reference is only usable once it actually points somewhere. */ + const isResolved = (operand: ConditionOperand | undefined): boolean => + !!operand && (operand.kind === 'store' || operand.kind === 'local' || operand.kind === 'context') + ? (operand as { path: string }).path.trim() !== '' + : false; - const pick = (operand: ConditionOperand) => { + const clearOverrides = () => { setRefMode(false); - // Backing out of the picker via "use a fixed value" restores the literal that was - // already there rather than clearing it — switching modes and changing your mind - // shouldn't cost you the text you had typed. - if (operand.kind === 'literal' && operand.value === '' && props.value?.kind === 'literal') return; + setTextMode(false); + setPendingCount(null); + }; + + const pick = (operand: ConditionOperand) => { + if (operand.kind === 'count' && !isResolved(operand.items)) { + setRefMode(false); + setTextMode(false); + setPendingCount(operand); + return; + } + clearOverrides(); props.onChange(operand); }; @@ -373,20 +384,55 @@ export function OperandInput(props: { }; /** - * Swap to the reference picker. Nothing is written yet — the current value stands until - * a reference is chosen, so cancelling out of the picker leaves the node as it was. + * The two ways to express a value, always both offered. Data opens the picker (whose + * footer also reaches list counts); Text goes straight to plain entry. Two orthogonal + * one-click affordances rather than routing text entry through the picker, so no mode + * is ever more than a click from either. + * + * Neither writes anything: the current value stands until something is picked or typed, + * so changing your mind costs nothing. */ - const resetButton = (title: string, icon: string) => ( - - setRefMode(true)} aria-label={title}> - - - + const modeButtons = () => ( + + + + { + setRefMode(false); + setPendingCount(null); + setTextMode(true); + }} + aria-label="Use a fixed value" + > + + + + + + { + setTextMode(false); + setPendingCount(null); + setRefMode(true); + }} + aria-label="Pick a value from data" + > + + + + ); // `$count` wraps another reference, so it renders as a labelled row containing a - // nested picker rather than as a leaf control. - const countValue = () => (props.value?.kind === 'count' ? props.value : null); + // nested picker rather than as a leaf control. A pending count (list not yet chosen) + // renders the same way, so the row appears the moment it is asked for. + const countValue = () => pendingCount() ?? (props.value?.kind === 'count' ? props.value : null); // Validation-state readers name a field from the surrounding $localState, which is // exactly the `local` group of the scope — so the field list comes for free. @@ -396,68 +442,79 @@ export function OperandInput(props: { return [{ label: 'the whole form', value: '$scope' }, ...fields.map((f) => ({ label: f, value: f }))]; }; + const mode = createMemo<'literal' | 'composite' | 'picker'>(() => { + if (textMode()) return 'literal'; + if (refMode()) return 'picker'; + if (countValue() || formStateValue()) return 'composite'; + if (props.value?.kind === 'literal' || props.value?.kind === 'list') return 'literal'; + return 'picker'; + }); + return ( - - - {(count) => ( - <> - - count of - - - props.onChange({ kind: 'count', items })} - placeholder="Select a list" - /> - - - )} - - - {(formState) => ( - <> - - {formState().token === 'error' ? 'error of' : formState().token === 'touched' ? 'edited' : 'valid'} - - - props.onChange({ kind: 'formState', token: formState().token, field: e.detail as string }) - } - /> - - )} - - {resetButton('Pick a different value', 'arrow-counter-clockwise')} - - } - > - - } - > - - {literalControl()} - {resetButton('Pick a value from data instead', 'database')} - - - + + + + {literalControl()} + + + + + {(count) => ( + <> + + count of + + + { + clearOverrides(); + props.onChange({ kind: 'count', items }); + }} + placeholder="Select a list" + /> + + + )} + + + {(formState) => ( + <> + + {formState().token === 'error' + ? 'error of' + : formState().token === 'touched' + ? 'edited' + : 'valid'} + + + props.onChange({ kind: 'formState', token: formState().token, field: e.detail as string }) + } + /> + + )} + + + + + + + + + + {modeButtons()} + ); } From 97023ddf14e1fa9dbf9457f49d016bdffcaddb1b Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:58:16 +0100 Subject: [PATCH 11/12] fix(editor): give the JSON view a way back to the pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw JSON editor was a one-way door. Once a value was an expression the builder can't represent — a $concat, or the malformed $count above — the only editor on offer was JSON, with no route back to a picked value or plain text. Adds the picker affordance beside the "custom expression" note: it swaps in the value control without writing anything, so the existing expression survives until a replacement is chosen. Restructures the branching as an explicit Switch over a mode memo, for the same reason as the sibling change: three levels of nested Show/fallback read in the inverse of their logical order. Co-Authored-By: Claude Opus 5 (1M context) --- .../solid/components/editor/ValueEditor.tsx | 200 ++++++++++-------- 1 file changed, 115 insertions(+), 85 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx index 9f8f6f25..f8d0cb99 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ValueEditor.tsx @@ -2,7 +2,7 @@ import { Column, Row } from '@we/components/solid'; import { tokenVar } from '@we/design-utils'; import type { ScopeGroup } from '@we/schema-shared'; import { parseValue, parseValueIf, serializeValue, serializeValueIf } from '@we/schema-shared'; -import { createMemo, Show } from 'solid-js'; +import { createMemo, createSignal, Match, Switch } from 'solid-js'; import { CodeViewer } from './CodeViewer'; import { ConditionEditor } from './ConditionEditor'; @@ -29,96 +29,126 @@ export function ValueEditor(props: { }) { const depth = () => props.depth ?? 0; - const valueIf = createMemo(() => (depth() < MAX_VALUE_IF_DEPTH ? parseValueIf(props.value) : null)); - const operand = createMemo(() => (valueIf() ? null : parseValue(props.value))); + /** + * Set when the author asks to replace a custom expression with a picked value. + * Without it the JSON editor is a one-way door: an expression the grammar can't + * represent has no path back to a picker. Nothing is written until something is + * picked, so the existing expression survives a change of mind. + */ + const [replacing, setReplacing] = createSignal(false); + + const emit = (value: unknown) => { + setReplacing(false); + props.onChange(value); + }; + + const valueIf = createMemo(() => (replacing() || depth() >= MAX_VALUE_IF_DEPTH ? null : parseValueIf(props.value))); + const operand = createMemo(() => (replacing() || valueIf() ? null : parseValue(props.value))); + const mode = createMemo<'conditional' | 'operand' | 'replacing' | 'custom'>(() => { + if (valueIf()) return 'conditional'; + if (operand()) return 'operand'; + return replacing() ? 'replacing' : 'custom'; + }); return ( - - - - - Custom expression — edit as JSON - - - + + emit(serializeValue(next))} + valueType="string" + allowCount + placeholder={props.placeholder} + /> + + + {/* Replacing a custom expression — nothing is written until something is picked */} + + emit(serializeValue(next))} + valueType="string" + allowCount + placeholder={props.placeholder ?? 'Pick a replacement value'} + /> + + + + + + + + + Custom expression — edit as JSON + + + + setReplacing(true)} + aria-label="Pick a value from data" > - props.onChange(JSON.parse(json))} - /> - - - } - > - {(value) => ( - + + + + + emit(JSON.parse(json))} /> + + + + + + {(branch) => ( + + props.onChange(serializeValue(next))} - valueType="string" - allowCount - placeholder={props.placeholder} + onChange={(condition) => props.onChange(serializeValueIf({ ...branch(), condition }))} /> - )} - - } - > - {(branch) => ( - - props.onChange(serializeValueIf({ ...branch(), condition }))} - /> - - - - Then show - - props.onChange(serializeValueIf({ ...branch(), then }))} - placeholder="Value when true" - /> - - - - Otherwise show - - - props.onChange( - serializeValueIf({ - condition: branch().condition, - then: branch().then, - // An empty branch means "render nothing" — drop the key rather than - // writing an empty string the renderer would print. - else: otherwise === '' ? undefined : otherwise, - }), - ) - } - placeholder="Value when false" - /> + + + + Then show + + props.onChange(serializeValueIf({ ...branch(), then }))} + placeholder="Value when true" + /> + + + + Otherwise show + + + props.onChange( + serializeValueIf({ + condition: branch().condition, + then: branch().then, + // An empty branch means "render nothing" — drop the key rather than + // writing an empty string the renderer would print. + else: otherwise === '' ? undefined : otherwise, + }), + ) + } + placeholder="Value when false" + /> + - - )} - + )} + + ); } From 2e38d5feebcbca991c2b8d479a65d9ffe6c4894c Mon Sep 17 00:00:00 2001 From: jhweir Date: Fri, 31 Jul 2026 15:58:16 +0100 Subject: [PATCH 12/12] fix(editor): drop text entry from a condition's left operand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed value belongs on the right of a comparison — it names what you are testing against, and is usually the only way to author the condition at all, since a string like 'shared' exists nowhere in data. On the left it makes the condition constant. The built-in templates bear that out: across 1782 parsed conditions the left operand is a reference 1823 times and a literal 10, while the right is a literal 965 times against 135 references. So the left operand no longer offers the text button. Existing literals there still render in a text input — the mode is derived from the value, not the button — they just aren't encouraged, and the picker is one click away. Co-Authored-By: Claude Opus 5 (1M context) --- .../frameworks/solid/components/editor/ConditionEditor.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx index 29d53998..cfe492c6 100644 --- a/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx +++ b/packages/app-framework/src/frameworks/solid/components/editor/ConditionEditor.tsx @@ -279,12 +279,16 @@ function ConditionRow(props: { + {/* No text entry on the left: this side names what is being tested, and a + literal here makes the condition constant. The right side is where a + fixed value belongs — that's what you're comparing against. */} props.onChange({ ...cmp(), left })} valueType={operandValueType(cmp().right, props.scope)} allowCount + allowText={false} placeholder="Select a value" />