diff --git a/.cursor/rules/we-schema.mdc b/.cursor/rules/we-schema.mdc index 46f4ae85d..9741fed7e 100644 --- a/.cursor/rules/we-schema.mdc +++ b/.cursor/rules/we-schema.mdc @@ -81,6 +81,7 @@ Glossary (these terms pervade stores, models, and `$query`/`perspective` in sche | `@we/widgets` | design-system/5-widgets | Generic widgets (graph, sidebar) — feature widgets live in their module family | Solid | | `@we/design-utils` | design-system/utils | Shared DS-props → style computation; token resolvers | Neutral core + `/solid` binding | | `@we/design-types` | design-system/types | Shared DS prop/type definitions | Agnostic | +| `@we/template-kit` | templates/kit | Reusable template fragments — authoring-time helpers that expand to plain nodes | Agnostic | | `@we/template-shell` · `@we/template-default` | templates/* | WE's shell surfaces and built-in space templates, as data | Agnostic | | `@we/editor` | packages/editor | Template/theme editing surface, embeddable via `EditorHost` | Solid (mount fn at the boundary) | | `@we/schema-shared` | schema-system/shared | Schema semantics: prop resolvers, validation, indexer, registry types, reactivity port | **Agnostic** | @@ -158,6 +159,8 @@ that declares `backends: ['ad4m']` — nothing else. See `docs/architecture/pack For deeper detail (data sync/persistence, block & editor internals, the local dev/test loop), see docs/architecture/codebase-map.md. +For how reusable template fragments work and where they are going, see +docs/architecture/template-fragments.md. --- @@ -860,19 +863,13 @@ when `relative` is enabled. Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined - VideoInput Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean -- Accordion - Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record - AudioVisualiser Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string - AvatarStack Props: avatars: AvatarInfo[], max?: number, size?: "xs" | "sm" | "md" | "lg" | "xl" | "xxs" | "xxl", overlap?: number, ring?: string, styles?: Record -- Breadcrumbs - Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record - Calendar Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record - Card (DesignSystemElement) -- CircleButton - Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record - CodeEditor Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record - CollapsedContent @@ -881,23 +878,17 @@ when `relative` is enabled. - Combobox (DesignSystemElement) Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes. - Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] + Props: styles?: Record, class?: string, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] - EditableImage (DesignSystemElement) - Props: src?: string, alt?: string, fit?: "fill" | "cover" | "contain" | "none" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number + Props: src?: string, alt?: string, fit?: "cover" | "contain" | "none" | "fill" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number - FlipCard Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string -- IconLabelButton - Props: icon: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox Props: srcs: string[], initialIndex: number, onClose: () => void -- List - Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record -- PostCard - Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record - RerenderLog Props: location: string - Row (DesignSystemElement) @@ -907,24 +898,12 @@ when `relative` is enabled. Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - SignalControl Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record -- Stepper - Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: "horizontal" | "vertical", styles?: Record -- Table - Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record -- Timeline - Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], styles?: Record - ToastContainer Props: position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center", styles?: Record @we/widgets: - CollapsibleSidebar 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) -- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering. -Displays typed nodes (user, space, post) and edges (follows, member-of, etc.) -with configurable styling, layout forces, and interaction handlers. - Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig -- SpaceSidebarWidget - Props: name: string, description?: string, class?: string, style?: Record - GraphView — A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and free-positioned boards, all from the same engine. @@ -1934,11 +1913,15 @@ Iterating over store data: "props": { "items": { "$store": "spaceStore.personalSpaces" }, "as": "space" }, "children": [ { - "type": "CircleButton", + "type": "we-button", "props": { - "label": "$space.name", + "variant": "ghost", "onClick": { "$action": "routeStore.navigate", "args": [{ "$concat": ["/space/", "$space.uuid"] }] } - } + }, + "children": [ + { "type": "we-avatar", "props": { "image": "$space.avatar", "initials": "$space.name", "size": "sm" } }, + { "type": "we-text", "children": ["$space.name"] } + ] } ] } @@ -2252,6 +2235,317 @@ Use preview: true when sketching a layout without real data. Remove it (and add --- +## Common Patterns (copy these shapes) + +These are the shapes WE's own templates use. Prefer them over inventing a new arrangement — they +carry decisions (loading behaviour, empty states, accessibility) that are easy to omit and hard to +notice missing. Copy the JSON and change the words; every one of them is ordinary nodes you can +then edit freely. + +### Empty state — what a list shows when it has nothing to show + +**A list must always have one.** An empty `$each` renders nothing at all, so a page with no content +looks identical to a page still loading, and the reader cannot tell which. + +```json +{ + "type": "$animate", + "props": { "enterTransition": { "type": "fade", "duration": 200, "delay": 400 } }, + "children": [ + { + "type": "Column", + "props": { "ax": "center", "ay": "center", "gap": "200", "p": "600", "width": "100%" }, + "children": [ + { "type": "we-icon", "props": { "name": "newspaper", "size": "lg", "color": "neutral-400" } }, + { + "type": "we-text", + "props": { "color": "neutral-400", "textAlign": "center" }, + "children": ["This space doesn't have any posts."] + } + ] + } + ] +} +``` + +The `$animate` wrapper is not decoration. A query-backed list is empty on its first frame and fills +a moment later, so without the delayed fade the placeholder blinks on every load and states +something false while it does. Drop the wrapper only when emptiness is known synchronously (a store +array, a missing model). + +**If the list filters on a search box**, say so instead of claiming the space is empty: + +```json +{ "$if": { "condition": { "$local": "searchText" }, + "then": "No posts match your search.", + "else": "This space doesn't have any posts." } } +``` + +### A list with its empty state — hoist the query so the count is readable + +```json +{ + "type": "Column", + "props": { "width": "100%" }, + "$queries": { "postRows": { "entity": "CollectionBlock", "where": { "type": "root" }, "limit": 20 } }, + "children": [ + { + "type": "$if", + "props": { + "condition": { "$count": { "items": { "$local": "postRows" } } }, + "then": { + "type": "Grid", + "props": { "columns": 1, "gap": "400", "width": "100%" }, + "children": [ + { + "type": "$each", + "props": { "items": { "$local": "postRows" }, "as": "post" }, + "children": [{ "type": "Card", "children": ["…"] }] + } + ] + }, + "else": { "…": "the empty state above" } + } + } + ] +} +``` + +Hoisting into `$queries` rather than leaving the query on the `$each` is what makes the count +readable from outside the loop, and it means one subscription answers both branches — so the +placeholder and the grid can never disagree about how many rows there are. + +### Gate / prompt page — an icon, what this is, and what to do about it + +```json +{ + "type": "Column", + "props": { "flex": "1", "height": "100%", "ax": "center", "ay": "center", "gap": "400", "p": "600" }, + "children": [ + { "type": "we-icon", "props": { "name": "lock", "size": "xl", "gradient": "primary" } }, + { "type": "we-text", "props": { "variant": "heading-md", "textAlign": "center" }, "children": ["Join this Space"] }, + { + "type": "we-text", + "props": { "variant": "body", "textAlign": "center", "maxWidth": "var(--we-layout-xs)" }, + "children": ["You haven't joined this space yet."] + }, + { "type": "we-button", "props": { "variant": "primary", "onClick": { "$action": "…" } }, "children": ["Join"] } + ] +} +``` + +Use `gradient` on the icon when there is something to do, and a flat `color` (`neutral-300`, +or `warning`) when there is not — the two read apart at a glance, and a dead end that looks like +an invitation is worse than one that looks like a dead end. + +### Confirm dialog + +```json +{ + "type": "$if", + "props": { + "condition": { "$local": "confirmDeleteOpen" }, + "then": { + "type": "we-modal", + "props": { "close": { "$setLocal": "confirmDeleteOpen", "value": false } }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["Delete post?"] }, + { "type": "we-text", "children": ["This cannot be undone."] }, + { + "type": "Row", + "props": { "ax": "end", "gap": "200" }, + "children": [ + { "type": "we-button", "props": { "variant": "ghost", "onClick": { "$setLocal": "confirmDeleteOpen", "value": false } }, "children": ["Cancel"] }, + { + "type": "we-button", + "props": { + "variant": "danger", + "onClick": { "$action": "spaceStore.deleteCollection", "args": ["$post.id"], + "onSuccess": [{ "$setLocal": "confirmDeleteOpen", "value": false }] } + }, + "children": ["Delete"] + } + ] + } + ] + } + } +} +``` + +The flag must be declared by an ancestor of **the button that opens it**, not merely of the modal. +Undeclared, `$setLocal` warns and no-ops: the button renders, takes the click, and does nothing. + +If the action is slow (a recursive delete walks its whole collection), add a `busy` boolean set +before it and cleared in `onFinally`, and bind the confirm button's `loading` and `disabled` to it. + +### Form field + +```json +{ + "type": "we-form-field", + "props": { "label": "Name", "error": { "$error": "name" } }, + "children": [ + { + "type": "we-input", + "props": { + "placeholder": "Space name…", + "value": { "$local": "name" }, + "onInput": { "$setLocal": "name", "from": "$event.detail" } + } + } + ] +} +``` + +`$error` is already empty until the field is touched, so it needs no `$if` around it. Which event +carries the value depends on the control: `we-input`/`we-textarea` emit `onInput` with +`$event.detail`, `we-select` emits `onChange` with `$event.detail`, and `Search` calls back +with the value itself as `$arg`. + +### Author byline + +```json +{ + "type": "$agent", + "props": { "did": "$post.author", "as": "author" }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "300" }, + "children": [ + { "type": "we-avatar", "props": { "size": "sm", "image": "$author.avatar", "hash": "$author.did" } }, + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["$author.name"] }, + { "type": "we-timestamp", "props": { "value": "$post.createdAt", "relative": true, "color": "neutral-500" } } + ] + } + ] +} +``` + +Always set `hash` as well as `image`, never as a fallback for it: `hash` seeds a generated avatar +that is stable per agent, so somebody whose profile has not arrived is still visually distinct from +everybody else whose profile has not arrived. A real picture wins where there is one. + +### A group of faces with a count + +```json +{ + "type": "Row", + "props": { "gap": "300", "ay": "center", "minHeight": "32px" }, + "children": [ + { + "type": "AvatarStack", + "props": { + "avatars": { "$map": { "items": { "$store": "spaceStore.members" }, + "select": { "image": "$item.avatar", "hash": "$item.did" } } }, + "max": 5, "size": "sm", "ring": "0 0 0 2px var(--we-ring-color)" + } + }, + { + "type": "Row", + "props": { "gap": "100", "ay": "center" }, + "children": [ + { "type": "we-number", "props": { "value": { "$count": { "items": { "$store": "spaceStore.members" } } }, "shorten": true } }, + { "type": "we-text", "children": [{ "$plural": { "count": { "$count": { "items": { "$store": "spaceStore.members" } } }, "one": "Member", "other": "Members" } }] } + ] + } + ] +} +``` + +**When the items are bare DIDs rather than profiles**, join each to its profile — and note the trap: +inside a `$map` `select`, a string is substituted only when it starts with `$item.`. A bare +`"$item"` is a **literal**, so every generated face comes out identical. Wrap it in a token object: + +```json +"select": { + "image": { "$find": { "items": { "$store": "profileStore.profiles" }, "where": { "did": "$item" }, "select": "avatar" } }, + "hash": { "$concat": ["$item"] } +} +``` + +`minHeight` on the row is worth keeping: `AvatarStack` has no height with no avatars, and people +resolve later than the record they belong to, so without a floor the row collapses and then pushes +everything below it down a second time. + +### Page shell — a route's outer box + +```json +{ + "type": "Column", + "props": { "width": "100%", "ax": "center" }, + "children": [ + { + "type": "Column", + "props": { "width": "100%", "maxWidth": "var(--we-layout-lg)", "gap": "500", "px": "400", "py": "500" }, + "children": ["…"] + } + ] +} +``` + +Two Columns, because centring and constraining are different jobs: the outer spans the viewport so +the route's background reaches the edges, the inner holds the measure. + +### Titled section on a card + +```json +{ + "type": "Card", + "props": { "bg": "neutral-100", "border": "1px solid neutral-200" }, + "children": [ + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { "type": "we-text", "props": { "variant": "heading-md" }, "children": ["About this space"] }, + { "type": "we-text", "children": ["Manage how this space appears to others."] } + ] + }, + "…" + ] +} +``` + +### Labelled attribute with an optional control + +```json +{ + "type": "Row", + "props": { "ay": "center", "ax": "between", "wrap": true }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "400", "py": "100" }, + "children": [ + { "type": "we-icon", "props": { "name": "globe", "color": "primary-600" } }, + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { + "type": "Row", + "props": { "gap": "300" }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "bold", "color": "neutral-700" }, "children": ["Discovery:"] }, + { "type": "we-text", "props": { "fontWeight": "bold" }, "children": ["Listed"] } + ] + }, + { "type": "we-text", "props": { "variant": "body" }, "children": ["Appears on the WE discovery globe"] } + ] + } + ] + }, + { "type": "we-switch", "props": { "checked": true, "onChange": { "$action": "…" } } } + ] +} +``` + +Drop the outer `Row` and the control for the read-only form. + +--- + ## Routing Structure Define nested routes using the "routes" array at the root node of the schema. @@ -2656,6 +2950,7 @@ Always read `CONVENTIONS.md` before creating or modifying files in that package. Key packages with conventions files: - `packages/models/CONVENTIONS.md` — model authoring: entities vs blocks, predicates, @Flag, WeNode, Model.create() pattern +- `packages/templates/kit/CONVENTIONS.md` — fragment authoring: what belongs in the kit, extraction threshold, options-object API, body style --- diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 46f4ae85d..9741fed7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -81,6 +81,7 @@ Glossary (these terms pervade stores, models, and `$query`/`perspective` in sche | `@we/widgets` | design-system/5-widgets | Generic widgets (graph, sidebar) — feature widgets live in their module family | Solid | | `@we/design-utils` | design-system/utils | Shared DS-props → style computation; token resolvers | Neutral core + `/solid` binding | | `@we/design-types` | design-system/types | Shared DS prop/type definitions | Agnostic | +| `@we/template-kit` | templates/kit | Reusable template fragments — authoring-time helpers that expand to plain nodes | Agnostic | | `@we/template-shell` · `@we/template-default` | templates/* | WE's shell surfaces and built-in space templates, as data | Agnostic | | `@we/editor` | packages/editor | Template/theme editing surface, embeddable via `EditorHost` | Solid (mount fn at the boundary) | | `@we/schema-shared` | schema-system/shared | Schema semantics: prop resolvers, validation, indexer, registry types, reactivity port | **Agnostic** | @@ -158,6 +159,8 @@ that declares `backends: ['ad4m']` — nothing else. See `docs/architecture/pack For deeper detail (data sync/persistence, block & editor internals, the local dev/test loop), see docs/architecture/codebase-map.md. +For how reusable template fragments work and where they are going, see +docs/architecture/template-fragments.md. --- @@ -860,19 +863,13 @@ when `relative` is enabled. Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined - VideoInput Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean -- Accordion - Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record - AudioVisualiser Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string - AvatarStack Props: avatars: AvatarInfo[], max?: number, size?: "xs" | "sm" | "md" | "lg" | "xl" | "xxs" | "xxl", overlap?: number, ring?: string, styles?: Record -- Breadcrumbs - Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record - Calendar Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record - Card (DesignSystemElement) -- CircleButton - Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record - CodeEditor Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record - CollapsedContent @@ -881,23 +878,17 @@ when `relative` is enabled. - Combobox (DesignSystemElement) Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes. - Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] + Props: styles?: Record, class?: string, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] - EditableImage (DesignSystemElement) - Props: src?: string, alt?: string, fit?: "fill" | "cover" | "contain" | "none" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number + Props: src?: string, alt?: string, fit?: "cover" | "contain" | "none" | "fill" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number - FlipCard Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string -- IconLabelButton - Props: icon: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox Props: srcs: string[], initialIndex: number, onClose: () => void -- List - Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record -- PostCard - Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record - RerenderLog Props: location: string - Row (DesignSystemElement) @@ -907,24 +898,12 @@ when `relative` is enabled. Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - SignalControl Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record -- Stepper - Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: "horizontal" | "vertical", styles?: Record -- Table - Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record -- Timeline - Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], styles?: Record - ToastContainer Props: position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center", styles?: Record @we/widgets: - CollapsibleSidebar 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) -- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering. -Displays typed nodes (user, space, post) and edges (follows, member-of, etc.) -with configurable styling, layout forces, and interaction handlers. - Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig -- SpaceSidebarWidget - Props: name: string, description?: string, class?: string, style?: Record - GraphView — A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and free-positioned boards, all from the same engine. @@ -1934,11 +1913,15 @@ Iterating over store data: "props": { "items": { "$store": "spaceStore.personalSpaces" }, "as": "space" }, "children": [ { - "type": "CircleButton", + "type": "we-button", "props": { - "label": "$space.name", + "variant": "ghost", "onClick": { "$action": "routeStore.navigate", "args": [{ "$concat": ["/space/", "$space.uuid"] }] } - } + }, + "children": [ + { "type": "we-avatar", "props": { "image": "$space.avatar", "initials": "$space.name", "size": "sm" } }, + { "type": "we-text", "children": ["$space.name"] } + ] } ] } @@ -2252,6 +2235,317 @@ Use preview: true when sketching a layout without real data. Remove it (and add --- +## Common Patterns (copy these shapes) + +These are the shapes WE's own templates use. Prefer them over inventing a new arrangement — they +carry decisions (loading behaviour, empty states, accessibility) that are easy to omit and hard to +notice missing. Copy the JSON and change the words; every one of them is ordinary nodes you can +then edit freely. + +### Empty state — what a list shows when it has nothing to show + +**A list must always have one.** An empty `$each` renders nothing at all, so a page with no content +looks identical to a page still loading, and the reader cannot tell which. + +```json +{ + "type": "$animate", + "props": { "enterTransition": { "type": "fade", "duration": 200, "delay": 400 } }, + "children": [ + { + "type": "Column", + "props": { "ax": "center", "ay": "center", "gap": "200", "p": "600", "width": "100%" }, + "children": [ + { "type": "we-icon", "props": { "name": "newspaper", "size": "lg", "color": "neutral-400" } }, + { + "type": "we-text", + "props": { "color": "neutral-400", "textAlign": "center" }, + "children": ["This space doesn't have any posts."] + } + ] + } + ] +} +``` + +The `$animate` wrapper is not decoration. A query-backed list is empty on its first frame and fills +a moment later, so without the delayed fade the placeholder blinks on every load and states +something false while it does. Drop the wrapper only when emptiness is known synchronously (a store +array, a missing model). + +**If the list filters on a search box**, say so instead of claiming the space is empty: + +```json +{ "$if": { "condition": { "$local": "searchText" }, + "then": "No posts match your search.", + "else": "This space doesn't have any posts." } } +``` + +### A list with its empty state — hoist the query so the count is readable + +```json +{ + "type": "Column", + "props": { "width": "100%" }, + "$queries": { "postRows": { "entity": "CollectionBlock", "where": { "type": "root" }, "limit": 20 } }, + "children": [ + { + "type": "$if", + "props": { + "condition": { "$count": { "items": { "$local": "postRows" } } }, + "then": { + "type": "Grid", + "props": { "columns": 1, "gap": "400", "width": "100%" }, + "children": [ + { + "type": "$each", + "props": { "items": { "$local": "postRows" }, "as": "post" }, + "children": [{ "type": "Card", "children": ["…"] }] + } + ] + }, + "else": { "…": "the empty state above" } + } + } + ] +} +``` + +Hoisting into `$queries` rather than leaving the query on the `$each` is what makes the count +readable from outside the loop, and it means one subscription answers both branches — so the +placeholder and the grid can never disagree about how many rows there are. + +### Gate / prompt page — an icon, what this is, and what to do about it + +```json +{ + "type": "Column", + "props": { "flex": "1", "height": "100%", "ax": "center", "ay": "center", "gap": "400", "p": "600" }, + "children": [ + { "type": "we-icon", "props": { "name": "lock", "size": "xl", "gradient": "primary" } }, + { "type": "we-text", "props": { "variant": "heading-md", "textAlign": "center" }, "children": ["Join this Space"] }, + { + "type": "we-text", + "props": { "variant": "body", "textAlign": "center", "maxWidth": "var(--we-layout-xs)" }, + "children": ["You haven't joined this space yet."] + }, + { "type": "we-button", "props": { "variant": "primary", "onClick": { "$action": "…" } }, "children": ["Join"] } + ] +} +``` + +Use `gradient` on the icon when there is something to do, and a flat `color` (`neutral-300`, +or `warning`) when there is not — the two read apart at a glance, and a dead end that looks like +an invitation is worse than one that looks like a dead end. + +### Confirm dialog + +```json +{ + "type": "$if", + "props": { + "condition": { "$local": "confirmDeleteOpen" }, + "then": { + "type": "we-modal", + "props": { "close": { "$setLocal": "confirmDeleteOpen", "value": false } }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["Delete post?"] }, + { "type": "we-text", "children": ["This cannot be undone."] }, + { + "type": "Row", + "props": { "ax": "end", "gap": "200" }, + "children": [ + { "type": "we-button", "props": { "variant": "ghost", "onClick": { "$setLocal": "confirmDeleteOpen", "value": false } }, "children": ["Cancel"] }, + { + "type": "we-button", + "props": { + "variant": "danger", + "onClick": { "$action": "spaceStore.deleteCollection", "args": ["$post.id"], + "onSuccess": [{ "$setLocal": "confirmDeleteOpen", "value": false }] } + }, + "children": ["Delete"] + } + ] + } + ] + } + } +} +``` + +The flag must be declared by an ancestor of **the button that opens it**, not merely of the modal. +Undeclared, `$setLocal` warns and no-ops: the button renders, takes the click, and does nothing. + +If the action is slow (a recursive delete walks its whole collection), add a `busy` boolean set +before it and cleared in `onFinally`, and bind the confirm button's `loading` and `disabled` to it. + +### Form field + +```json +{ + "type": "we-form-field", + "props": { "label": "Name", "error": { "$error": "name" } }, + "children": [ + { + "type": "we-input", + "props": { + "placeholder": "Space name…", + "value": { "$local": "name" }, + "onInput": { "$setLocal": "name", "from": "$event.detail" } + } + } + ] +} +``` + +`$error` is already empty until the field is touched, so it needs no `$if` around it. Which event +carries the value depends on the control: `we-input`/`we-textarea` emit `onInput` with +`$event.detail`, `we-select` emits `onChange` with `$event.detail`, and `Search` calls back +with the value itself as `$arg`. + +### Author byline + +```json +{ + "type": "$agent", + "props": { "did": "$post.author", "as": "author" }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "300" }, + "children": [ + { "type": "we-avatar", "props": { "size": "sm", "image": "$author.avatar", "hash": "$author.did" } }, + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["$author.name"] }, + { "type": "we-timestamp", "props": { "value": "$post.createdAt", "relative": true, "color": "neutral-500" } } + ] + } + ] +} +``` + +Always set `hash` as well as `image`, never as a fallback for it: `hash` seeds a generated avatar +that is stable per agent, so somebody whose profile has not arrived is still visually distinct from +everybody else whose profile has not arrived. A real picture wins where there is one. + +### A group of faces with a count + +```json +{ + "type": "Row", + "props": { "gap": "300", "ay": "center", "minHeight": "32px" }, + "children": [ + { + "type": "AvatarStack", + "props": { + "avatars": { "$map": { "items": { "$store": "spaceStore.members" }, + "select": { "image": "$item.avatar", "hash": "$item.did" } } }, + "max": 5, "size": "sm", "ring": "0 0 0 2px var(--we-ring-color)" + } + }, + { + "type": "Row", + "props": { "gap": "100", "ay": "center" }, + "children": [ + { "type": "we-number", "props": { "value": { "$count": { "items": { "$store": "spaceStore.members" } } }, "shorten": true } }, + { "type": "we-text", "children": [{ "$plural": { "count": { "$count": { "items": { "$store": "spaceStore.members" } } }, "one": "Member", "other": "Members" } }] } + ] + } + ] +} +``` + +**When the items are bare DIDs rather than profiles**, join each to its profile — and note the trap: +inside a `$map` `select`, a string is substituted only when it starts with `$item.`. A bare +`"$item"` is a **literal**, so every generated face comes out identical. Wrap it in a token object: + +```json +"select": { + "image": { "$find": { "items": { "$store": "profileStore.profiles" }, "where": { "did": "$item" }, "select": "avatar" } }, + "hash": { "$concat": ["$item"] } +} +``` + +`minHeight` on the row is worth keeping: `AvatarStack` has no height with no avatars, and people +resolve later than the record they belong to, so without a floor the row collapses and then pushes +everything below it down a second time. + +### Page shell — a route's outer box + +```json +{ + "type": "Column", + "props": { "width": "100%", "ax": "center" }, + "children": [ + { + "type": "Column", + "props": { "width": "100%", "maxWidth": "var(--we-layout-lg)", "gap": "500", "px": "400", "py": "500" }, + "children": ["…"] + } + ] +} +``` + +Two Columns, because centring and constraining are different jobs: the outer spans the viewport so +the route's background reaches the edges, the inner holds the measure. + +### Titled section on a card + +```json +{ + "type": "Card", + "props": { "bg": "neutral-100", "border": "1px solid neutral-200" }, + "children": [ + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { "type": "we-text", "props": { "variant": "heading-md" }, "children": ["About this space"] }, + { "type": "we-text", "children": ["Manage how this space appears to others."] } + ] + }, + "…" + ] +} +``` + +### Labelled attribute with an optional control + +```json +{ + "type": "Row", + "props": { "ay": "center", "ax": "between", "wrap": true }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "400", "py": "100" }, + "children": [ + { "type": "we-icon", "props": { "name": "globe", "color": "primary-600" } }, + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { + "type": "Row", + "props": { "gap": "300" }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "bold", "color": "neutral-700" }, "children": ["Discovery:"] }, + { "type": "we-text", "props": { "fontWeight": "bold" }, "children": ["Listed"] } + ] + }, + { "type": "we-text", "props": { "variant": "body" }, "children": ["Appears on the WE discovery globe"] } + ] + } + ] + }, + { "type": "we-switch", "props": { "checked": true, "onChange": { "$action": "…" } } } + ] +} +``` + +Drop the outer `Row` and the control for the read-only form. + +--- + ## Routing Structure Define nested routes using the "routes" array at the root node of the schema. @@ -2656,6 +2950,7 @@ Always read `CONVENTIONS.md` before creating or modifying files in that package. Key packages with conventions files: - `packages/models/CONVENTIONS.md` — model authoring: entities vs blocks, predicates, @Flag, WeNode, Model.create() pattern +- `packages/templates/kit/CONVENTIONS.md` — fragment authoring: what belongs in the kit, extraction threshold, options-object API, body style --- diff --git a/CLAUDE.md b/CLAUDE.md index 46f4ae85d..9741fed7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ Glossary (these terms pervade stores, models, and `$query`/`perspective` in sche | `@we/widgets` | design-system/5-widgets | Generic widgets (graph, sidebar) — feature widgets live in their module family | Solid | | `@we/design-utils` | design-system/utils | Shared DS-props → style computation; token resolvers | Neutral core + `/solid` binding | | `@we/design-types` | design-system/types | Shared DS prop/type definitions | Agnostic | +| `@we/template-kit` | templates/kit | Reusable template fragments — authoring-time helpers that expand to plain nodes | Agnostic | | `@we/template-shell` · `@we/template-default` | templates/* | WE's shell surfaces and built-in space templates, as data | Agnostic | | `@we/editor` | packages/editor | Template/theme editing surface, embeddable via `EditorHost` | Solid (mount fn at the boundary) | | `@we/schema-shared` | schema-system/shared | Schema semantics: prop resolvers, validation, indexer, registry types, reactivity port | **Agnostic** | @@ -158,6 +159,8 @@ that declares `backends: ['ad4m']` — nothing else. See `docs/architecture/pack For deeper detail (data sync/persistence, block & editor internals, the local dev/test loop), see docs/architecture/codebase-map.md. +For how reusable template fragments work and where they are going, see +docs/architecture/template-fragments.md. --- @@ -860,19 +863,13 @@ when `relative` is enabled. Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined - VideoInput Props: url: string | undefined, title: string | undefined, thumbnail: string | undefined, provider: string | undefined, width: number | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean -- Accordion - Props: children?: JSX.Element, renderContent?: ((item: AccordionItem, index: number) => JSX.Element), onChange?: ((openItems: string[]) => void), items?: AccordionItem[], multiple?: boolean, styles?: Record - AudioVisualiser Props: src: string | undefined, bars?: number, height?: number, color?: string, activeColor?: string - AvatarStack Props: avatars: AvatarInfo[], max?: number, size?: "xs" | "sm" | "md" | "lg" | "xl" | "xxs" | "xxl", overlap?: number, ring?: string, styles?: Record -- Breadcrumbs - Props: onNavigate?: ((item: BreadcrumbItem, index: number) => void), items?: BreadcrumbItem[], separator?: string, styles?: Record - Calendar Props: onSelect?: ((date: string) => void), value?: string, events?: CalendarEvent[], styles?: Record - Card (DesignSystemElement) -- CircleButton - Props: label: string, icon?: string, image?: string, onClick?: (() => void), class?: string, styles?: Record - CodeEditor Props: code: string, language?: CodeEditorLanguage, readOnly?: boolean, onChange?: ((code: string) => void), onSave?: ((code: string) => void), styles?: Record - CollapsedContent @@ -881,23 +878,17 @@ when `relative` is enabled. - Combobox (DesignSystemElement) Props: options: string[] | ComboboxOption[], value?: string, placeholder?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - DropdownMenu — Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes. - Props: class?: string, styles?: Record, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] + Props: styles?: Record, class?: string, placement?: Placement, triggerLabel?: string, triggerIcon?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", items: SolidDropdownMenuEntry[] - EditableImage (DesignSystemElement) - Props: src?: string, alt?: string, fit?: "fill" | "cover" | "contain" | "none" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number + Props: src?: string, alt?: string, fit?: "cover" | "contain" | "none" | "fill" | "scale-down", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number - FlipCard Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record - Grid (DesignSystemElement) Props: template?: string, columns?: number, minChildWidth?: string -- IconLabelButton - Props: icon: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, label: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, selected?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, iconWeight?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, onClick?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>, class?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor, styles?: import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined> - ImageCrop Props: src: string, fileName?: string, aspect?: number, maxSize?: number, outputType?: string, quality?: number, onReady?: ((ref: ImageCropRef) => void) - ImageLightbox Props: srcs: string[], initialIndex: number, onClose: () => void -- List - Props: children?: JSX.Element, renderItem?: ((item: ListItem, index: number) => JSX.Element), items?: ListItem[], ordered?: boolean, gap?: string, styles?: Record -- PostCard - Props: creator?: { name: string; avatar: string; }, title: string, text: string, class?: string, styles?: Record - RerenderLog Props: location: string - Row (DesignSystemElement) @@ -907,24 +898,12 @@ when `relative` is enabled. Props: options: SelectOption[], value?: string, placeholder?: string, searchable?: boolean, label?: string, size?: "xs" | "sm" | "md" | "lg" | "xl", onChange?: ((value: string) => void) - SignalControl Props: signalType: SignalTypeData, signals?: SignalData[], myDid?: string, onSignal?: ((value: number) => void), disabled?: boolean, preview?: boolean, class?: string, styles?: Record -- Stepper - Props: onStepClick?: ((index: number) => void), steps?: StepperStep[], activeStep?: number, orientation?: "horizontal" | "vertical", styles?: Record -- Table - Props: renderCell?: ((row: Record, column: TableColumn, index: number) => JSX.Element), columns: TableColumn[], rows: Record[], striped?: boolean, bordered?: boolean, styles?: Record -- Timeline - Props: children?: JSX.Element, renderItem?: ((item: TimelineItem, index: number) => JSX.Element), items?: TimelineItem[], styles?: Record - ToastContainer Props: position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center", styles?: Record @we/widgets: - CollapsibleSidebar 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) -- GraphWidget — 2D force-directed graph visualization using D3-force layout and Canvas rendering. -Displays typed nodes (user, space, post) and edges (follows, member-of, etc.) -with configurable styling, layout forces, and interaction handlers. - Props: data: GraphData, width?: string | number, height?: string | number, nodeStyle?: NodeStyleConfig, edgeStyle?: EdgeStyleConfig, layout?: LayoutConfig, interactions?: InteractionConfig -- SpaceSidebarWidget - Props: name: string, description?: string, class?: string, style?: Record - GraphView — A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and free-positioned boards, all from the same engine. @@ -1934,11 +1913,15 @@ Iterating over store data: "props": { "items": { "$store": "spaceStore.personalSpaces" }, "as": "space" }, "children": [ { - "type": "CircleButton", + "type": "we-button", "props": { - "label": "$space.name", + "variant": "ghost", "onClick": { "$action": "routeStore.navigate", "args": [{ "$concat": ["/space/", "$space.uuid"] }] } - } + }, + "children": [ + { "type": "we-avatar", "props": { "image": "$space.avatar", "initials": "$space.name", "size": "sm" } }, + { "type": "we-text", "children": ["$space.name"] } + ] } ] } @@ -2252,6 +2235,317 @@ Use preview: true when sketching a layout without real data. Remove it (and add --- +## Common Patterns (copy these shapes) + +These are the shapes WE's own templates use. Prefer them over inventing a new arrangement — they +carry decisions (loading behaviour, empty states, accessibility) that are easy to omit and hard to +notice missing. Copy the JSON and change the words; every one of them is ordinary nodes you can +then edit freely. + +### Empty state — what a list shows when it has nothing to show + +**A list must always have one.** An empty `$each` renders nothing at all, so a page with no content +looks identical to a page still loading, and the reader cannot tell which. + +```json +{ + "type": "$animate", + "props": { "enterTransition": { "type": "fade", "duration": 200, "delay": 400 } }, + "children": [ + { + "type": "Column", + "props": { "ax": "center", "ay": "center", "gap": "200", "p": "600", "width": "100%" }, + "children": [ + { "type": "we-icon", "props": { "name": "newspaper", "size": "lg", "color": "neutral-400" } }, + { + "type": "we-text", + "props": { "color": "neutral-400", "textAlign": "center" }, + "children": ["This space doesn't have any posts."] + } + ] + } + ] +} +``` + +The `$animate` wrapper is not decoration. A query-backed list is empty on its first frame and fills +a moment later, so without the delayed fade the placeholder blinks on every load and states +something false while it does. Drop the wrapper only when emptiness is known synchronously (a store +array, a missing model). + +**If the list filters on a search box**, say so instead of claiming the space is empty: + +```json +{ "$if": { "condition": { "$local": "searchText" }, + "then": "No posts match your search.", + "else": "This space doesn't have any posts." } } +``` + +### A list with its empty state — hoist the query so the count is readable + +```json +{ + "type": "Column", + "props": { "width": "100%" }, + "$queries": { "postRows": { "entity": "CollectionBlock", "where": { "type": "root" }, "limit": 20 } }, + "children": [ + { + "type": "$if", + "props": { + "condition": { "$count": { "items": { "$local": "postRows" } } }, + "then": { + "type": "Grid", + "props": { "columns": 1, "gap": "400", "width": "100%" }, + "children": [ + { + "type": "$each", + "props": { "items": { "$local": "postRows" }, "as": "post" }, + "children": [{ "type": "Card", "children": ["…"] }] + } + ] + }, + "else": { "…": "the empty state above" } + } + } + ] +} +``` + +Hoisting into `$queries` rather than leaving the query on the `$each` is what makes the count +readable from outside the loop, and it means one subscription answers both branches — so the +placeholder and the grid can never disagree about how many rows there are. + +### Gate / prompt page — an icon, what this is, and what to do about it + +```json +{ + "type": "Column", + "props": { "flex": "1", "height": "100%", "ax": "center", "ay": "center", "gap": "400", "p": "600" }, + "children": [ + { "type": "we-icon", "props": { "name": "lock", "size": "xl", "gradient": "primary" } }, + { "type": "we-text", "props": { "variant": "heading-md", "textAlign": "center" }, "children": ["Join this Space"] }, + { + "type": "we-text", + "props": { "variant": "body", "textAlign": "center", "maxWidth": "var(--we-layout-xs)" }, + "children": ["You haven't joined this space yet."] + }, + { "type": "we-button", "props": { "variant": "primary", "onClick": { "$action": "…" } }, "children": ["Join"] } + ] +} +``` + +Use `gradient` on the icon when there is something to do, and a flat `color` (`neutral-300`, +or `warning`) when there is not — the two read apart at a glance, and a dead end that looks like +an invitation is worse than one that looks like a dead end. + +### Confirm dialog + +```json +{ + "type": "$if", + "props": { + "condition": { "$local": "confirmDeleteOpen" }, + "then": { + "type": "we-modal", + "props": { "close": { "$setLocal": "confirmDeleteOpen", "value": false } }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["Delete post?"] }, + { "type": "we-text", "children": ["This cannot be undone."] }, + { + "type": "Row", + "props": { "ax": "end", "gap": "200" }, + "children": [ + { "type": "we-button", "props": { "variant": "ghost", "onClick": { "$setLocal": "confirmDeleteOpen", "value": false } }, "children": ["Cancel"] }, + { + "type": "we-button", + "props": { + "variant": "danger", + "onClick": { "$action": "spaceStore.deleteCollection", "args": ["$post.id"], + "onSuccess": [{ "$setLocal": "confirmDeleteOpen", "value": false }] } + }, + "children": ["Delete"] + } + ] + } + ] + } + } +} +``` + +The flag must be declared by an ancestor of **the button that opens it**, not merely of the modal. +Undeclared, `$setLocal` warns and no-ops: the button renders, takes the click, and does nothing. + +If the action is slow (a recursive delete walks its whole collection), add a `busy` boolean set +before it and cleared in `onFinally`, and bind the confirm button's `loading` and `disabled` to it. + +### Form field + +```json +{ + "type": "we-form-field", + "props": { "label": "Name", "error": { "$error": "name" } }, + "children": [ + { + "type": "we-input", + "props": { + "placeholder": "Space name…", + "value": { "$local": "name" }, + "onInput": { "$setLocal": "name", "from": "$event.detail" } + } + } + ] +} +``` + +`$error` is already empty until the field is touched, so it needs no `$if` around it. Which event +carries the value depends on the control: `we-input`/`we-textarea` emit `onInput` with +`$event.detail`, `we-select` emits `onChange` with `$event.detail`, and `Search` calls back +with the value itself as `$arg`. + +### Author byline + +```json +{ + "type": "$agent", + "props": { "did": "$post.author", "as": "author" }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "300" }, + "children": [ + { "type": "we-avatar", "props": { "size": "sm", "image": "$author.avatar", "hash": "$author.did" } }, + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["$author.name"] }, + { "type": "we-timestamp", "props": { "value": "$post.createdAt", "relative": true, "color": "neutral-500" } } + ] + } + ] +} +``` + +Always set `hash` as well as `image`, never as a fallback for it: `hash` seeds a generated avatar +that is stable per agent, so somebody whose profile has not arrived is still visually distinct from +everybody else whose profile has not arrived. A real picture wins where there is one. + +### A group of faces with a count + +```json +{ + "type": "Row", + "props": { "gap": "300", "ay": "center", "minHeight": "32px" }, + "children": [ + { + "type": "AvatarStack", + "props": { + "avatars": { "$map": { "items": { "$store": "spaceStore.members" }, + "select": { "image": "$item.avatar", "hash": "$item.did" } } }, + "max": 5, "size": "sm", "ring": "0 0 0 2px var(--we-ring-color)" + } + }, + { + "type": "Row", + "props": { "gap": "100", "ay": "center" }, + "children": [ + { "type": "we-number", "props": { "value": { "$count": { "items": { "$store": "spaceStore.members" } } }, "shorten": true } }, + { "type": "we-text", "children": [{ "$plural": { "count": { "$count": { "items": { "$store": "spaceStore.members" } } }, "one": "Member", "other": "Members" } }] } + ] + } + ] +} +``` + +**When the items are bare DIDs rather than profiles**, join each to its profile — and note the trap: +inside a `$map` `select`, a string is substituted only when it starts with `$item.`. A bare +`"$item"` is a **literal**, so every generated face comes out identical. Wrap it in a token object: + +```json +"select": { + "image": { "$find": { "items": { "$store": "profileStore.profiles" }, "where": { "did": "$item" }, "select": "avatar" } }, + "hash": { "$concat": ["$item"] } +} +``` + +`minHeight` on the row is worth keeping: `AvatarStack` has no height with no avatars, and people +resolve later than the record they belong to, so without a floor the row collapses and then pushes +everything below it down a second time. + +### Page shell — a route's outer box + +```json +{ + "type": "Column", + "props": { "width": "100%", "ax": "center" }, + "children": [ + { + "type": "Column", + "props": { "width": "100%", "maxWidth": "var(--we-layout-lg)", "gap": "500", "px": "400", "py": "500" }, + "children": ["…"] + } + ] +} +``` + +Two Columns, because centring and constraining are different jobs: the outer spans the viewport so +the route's background reaches the edges, the inner holds the measure. + +### Titled section on a card + +```json +{ + "type": "Card", + "props": { "bg": "neutral-100", "border": "1px solid neutral-200" }, + "children": [ + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { "type": "we-text", "props": { "variant": "heading-md" }, "children": ["About this space"] }, + { "type": "we-text", "children": ["Manage how this space appears to others."] } + ] + }, + "…" + ] +} +``` + +### Labelled attribute with an optional control + +```json +{ + "type": "Row", + "props": { "ay": "center", "ax": "between", "wrap": true }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "400", "py": "100" }, + "children": [ + { "type": "we-icon", "props": { "name": "globe", "color": "primary-600" } }, + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { + "type": "Row", + "props": { "gap": "300" }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "bold", "color": "neutral-700" }, "children": ["Discovery:"] }, + { "type": "we-text", "props": { "fontWeight": "bold" }, "children": ["Listed"] } + ] + }, + { "type": "we-text", "props": { "variant": "body" }, "children": ["Appears on the WE discovery globe"] } + ] + } + ] + }, + { "type": "we-switch", "props": { "checked": true, "onChange": { "$action": "…" } } } + ] +} +``` + +Drop the outer `Row` and the control for the read-only form. + +--- + ## Routing Structure Define nested routes using the "routes" array at the root node of the schema. @@ -2656,6 +2950,7 @@ Always read `CONVENTIONS.md` before creating or modifying files in that package. Key packages with conventions files: - `packages/models/CONVENTIONS.md` — model authoring: entities vs blocks, predicates, @Flag, WeNode, Model.create() pattern +- `packages/templates/kit/CONVENTIONS.md` — fragment authoring: what belongs in the kit, extraction threshold, options-object API, body style --- diff --git a/docs/architecture/template-fragments.md b/docs/architecture/template-fragments.md new file mode 100644 index 000000000..751212fe4 --- /dev/null +++ b/docs/architecture/template-fragments.md @@ -0,0 +1,290 @@ +# Template Fragments + +How reusable pieces of UI are shared between templates — today as authoring-time helpers, and where +that is going once fragments are things a community can extract, publish and install. + +Read this before adding to `@we/template-kit`, before proposing a new layer-4 component, and before +building anything that puts fragments in the marketplace. + +--- + +## The invariant + +**The runtime never knows fragments exist.** + +A fragment is an authoring-time construct. What ships, renders, syncs and gets published is plain +expanded JSON — indistinguishable from JSON written by hand. `@we/template-kit`'s exports are +functions returning `SchemaNode`s; they run when the template is built and leave nothing behind. + +Everything good about the design follows from that one rule: + +| | Because | +| ----------------------------------------------------- | ----------------------------------------------------------------- | +| No renderer change, ever | there is nothing to resolve at render time | +| A template outlives the kit | it carries its own nodes; the package is not a runtime dependency | +| The visual editor drills into everything | there is no opaque node to stop at | +| An AI reads and edits it | it sees ordinary nodes, not an indirection it has to understand | +| An installed fragment can't break your template later | you own your copy of the expansion | +| Portable to a future renderer | the artifact is the schema, not the toolchain | + +The cost is the one property expansion cannot give you: **editing a fragment does not update the +places it was already used.** For WE's own templates that cost is zero — they are built from the TS +helpers, so a fix reaches every core template on the next release. It is only real for templates a +user has saved. See _Where this is going_ for how that is repaid without giving up the invariant. + +--- + +## Fragment, component, or operator? + +The question that decides where a new piece of UI goes. It is not "is it repeated" — repetition +argues for extracting _something_, not for extracting it into code. + +> **Code owns only what data cannot express.** Everything above that line is arrangement, and +> arrangement stays data. + +Concretely, code means: behaviour and focus management, accessibility semantics, browser APIs, +measurement, performance-critical rendering. That is the whole list. + +| Wants to be | When | Examples | +| ------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------- | +| **A primitive** (`@we/primitives`) | focus traps, top layer, keyboard, ARIA | `we-modal`, `we-popover`, `we-input` | +| **A component** (`@we/components`) | measurement, layout maths, third-party libs | `AvatarStack`, `CollapsedContent`, `CodeEditor` | +| **A fragment** (`@we/template-kit`) | arrangement, even when repeated fourteen times | `gatePrompt`, `cardList`, `agentByline` | +| **An operator** (`@we/schema-shared`) | the repetition is _schema boilerplate_ **and** component-agnostic | `$in` rather than `$filter`+`$count`+`$gt` | + +**Why the line sits there and not somewhere more convenient.** A prop is a customisation somebody +predicted, implemented and shipped; a node tree is every customisation, including the ones nobody +thought of. Asked to make the icon on an empty state smaller, a fragment's answer is "edit that +node"; a component's answer is "wait for a release that adds `iconSize`", and the follow-up ("put the +icon on the right") has no answer at all. Trading that away to deduplicate would give up the property +the whole project exists for. + +So the pairs come apart in a way that looks odd until the rule is applied: `AvatarStack` is a +component and the count beside it is a fragment; `we-modal` is a primitive and the confirm dialog +inside it is a fragment. + +**What all-data costs, and what pays for it.** Consistency entropy is real — forty independently +editable prompts will drift into forty slightly different prompts. The answer is not opacity, it is +the layers that already exist: tokens and themes carry the global look, lints catch structural drift, +and provenance (below) tells a deliberate fork from an accidental one. And because most instances +will be untouched copies of the first expansion forever, **the seed matters more than a default +would**: a fragment ships as a starting distribution, not as something that can be fixed later for +everyone. + +### One vocabulary, two grammars + +Fragments serve schema-land; the editor and app-shell chrome are TSX. That is not a duplication +problem, because nothing is expressed twice: **components and primitives are the single-source +vocabulary, and above them sit two arrangement grammars** — fragments compose the vocabulary for +schemas, JSX composes the same vocabulary for code. Fragments never re-implement a component +(`sectionCard` uses `Card`; `peopleRow` uses `AvatarStack`), and TSX never re-implements a +primitive (the dev rule: DS components, not raw HTML). Tokens and themes carry visual identity +into both worlds from one definition. + +When one pattern is genuinely needed identically on both sides there are exactly two moves, both +with precedent — demote it to a component (`SignalControl`), or mount a schema island in TSX via +`RenderSchema`, which is how the entire shell already works. Never a second copy. + +And the boundary for which app surfaces are schemas at all: **surfaces a deployment should be able +to white-label or replace are schemas; tools are code.** The editor stays TSX — the tool must keep +working while the schema it is editing is broken, and rebuilding tool UI as schemas would generate +exactly the operator-language pressure named under the falsifiers. The full policy lives in +`packages/design-system/CONVENTIONS.md`. + +--- + +## When to extract + +- **Three real uses of the same shape.** Two is a coincidence. Three is a fragment. +- **Or: the divergence is a bug.** Fourteen card lists where five explained an empty result and nine + rendered blank; four participant stacks where one seeded avatars correctly and three did not. + Those are not style differences, and one call site can only be wrong once. +- **Not when serving the third call site would cost the fragment three options.** The presence row in + `SpaceHeader` and two compact bylines were left hand-written for exactly this reason. An + over-parameterised fragment is worse than the duplication it replaced, because it also hides it. + +--- + +## `@we/template-kit` + +``` +src/ + states/ emptyState · emptyNote · gatePrompt + layout/ pageShell · sectionCard · attributeRow · statChip + lists/ gridWrapper · cardShell · cardList + overlays/ confirmModal + we/ agentByline · peopleRow · peopleTooltip · adminSection · marketplaceList +``` + +**Two tiers.** Everything outside `we/` names no store and is portable to any deployment. `we/` reads +WE's own stores (`profileStore`, `runtimeStore`, `datasetStore`) or its schema machinery (`$agent`). + +That split is not decoration. **The kit's real dependency is the host's store surface, and +`package.json` cannot express it** — a fragment naming `spaceStore.members` resolves to nothing on a +deployment without that store, silently. Keeping the tiers apart is how a consumer can tell which +fragments will work for them, and it is the reason the store contract (below) matters. + +### The ambient-scope contract + +Fragments here are **not pure functions of their props**. They read up the tree and write into it: + +| Fragment | Requires in scope | Writes | +| ---------------------------- | -------------------------------------- | ------------------------------------------- | +| `cardShell`, `gridWrapper` | `$local: 'displayMode'` | — | +| `cardList` | — | `$local: 'Rows'` | +| `emptyState({ searchable })` | `$local: 'searchText'` | — | +| `marketplaceList` | — | `$local: 'search'`, `'sort'`, `'Items'` | +| `confirmModal` | the `openLocal` / `busyLocal` booleans | — | + +Reading up rather than taking a prop is deliberate — the display toggle belongs to the page, and +threading it through every list and card would add a prop to each whose only job is to be passed on. +The cost is that **a missing ambient value fails silently**: `$local` warns to the console and +resolves to `undefined`, so the UI renders confidently wrong. + +Inside one repo that is manageable, and the contracts are documented on each fragment. It is _not_ +manageable once fragments are installable by strangers — which is why declared requirements are a +prerequisite for the marketplace, not a nicety. See below. + +--- + +## Where this is going + +Nothing here is built. It is written down so the current shape stays compatible with it, and so that +deferring it is a decision rather than an oversight. + +### Three scopes for a definition + +| Scope | Definition lives | Purpose | +| ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- | +| **Kit** | shipped by WE, or installed from the marketplace | shared vocabulary, versioned | +| **Template-local** | in the template, in a root `fragments` map | _your_ card, edited once, updated across your template — no external dependency | +| **Module-provided** | exported by a feature module | the module knows what a good instance of its own thing looks like | +| **Detached** | nowhere; it is just nodes | one-off customisation | + +Template-local is what makes "edit my card once, update everywhere in my template" possible without +installing anything, and it is what the extract-a-subtree operation produces. + +Module-provided already exists in practice: `@we/module-graph` ships `fragments.ts` — "ready-made +graphs, as schema fragments… an LLM asked to _make me a knowledge map_ composes far better from one +working example than from a list of props." That was arrived at independently, in the same two API +shapes the kit uses (a static node, and a function taking parameters). Independent convergence is the +best evidence available that the shape is right. + +### An instance carries its provenance + +```jsonc +{ + "type": "Column", + "$fragment": { "id": "we/emptyState", "version": "1.2.0", + "props": { "icon": "newspaper", "label": "posts" } }, + "props": { … }, "children": [ … ] // the real, editable expansion +} +``` + +The tag is metadata the renderer ignores; the nodes are the truth. Two sources of truth exist +(definition + expansion) — the same cost generated code has, payable because expansion is +**deterministic**: re-expand from `props`, compare, and you know whether a human touched it. + +Provenance is **best-effort**. An AI rewriting a subtree will drop the tag; that must degrade to a +plain detached subtree and never to incorrect behaviour. + +### Four operations + +1. **Insert** — pick a fragment, fill params, expand into the tree. +2. **Push to definition** — promote an edit made in place, and re-expand every non-drifted instance. + Edit-then-promote rather than "go find the abstraction", because that is the affordance people + actually reach for. +3. **Detach** — drop the tag. Permanently local. +4. **Update** — a kit fragment moved version; re-expand, diff, apply or decline. + +### Drift semantics + +- **Param edits stay clean.** Changing a value the fragment declared as a parameter is normal use, + not customisation — the definition never owned it. Recorded on the tag; the instance keeps + receiving updates. +- **Structural edits drift.** Adding, moving, removing or rewrapping a node forks that instance. It + is excluded from propagation and marked, with three exits: detach, revert, or push. +- **v1 has no merging.** A drifted instance simply stays behind, visibly. +- **Later, optionally: props-granular.** If a fresh expansion and the instance have the _same tree + shape_, differing prop values can be treated as per-property overrides that survive updates + (override wins), while everything else keeps syncing. Requires no stored-format change — overrides + are derived by diffing, never written — so it is a behaviour change in the editor with zero + migration. +- **Structural merging is out of scope permanently.** Three-way tree merges over freely-restructured + nodes is the corner where silent corruption lives. Structure is yours; forking it is the point. + +Telling a param edit from a structural one requires a **source map from expansion** — which node and +prop each parameter landed on. The same map is what lets push-to-definition put the `$param` tokens +back before promoting, instead of baking one call site's label into every instance. That inverse step +is the fiddliest thing in the plan and worth prototyping on one fragment before committing to the +four-operation surface. + +### What it costs in code + +Small, and all optional-by-default: + +- `SchemaNode.$fragment?` — one field. Zod is `.strict()`, so it must be declared. `updateSchema` + already preserves unknown fields (it spreads `{...node}`). +- `TemplateSchema.fragments?` — a root map. Inert at runtime, but the validator, indexer, + `getScopeAtNode` and the ai-context generator all walk templates and must know to skip or use it. +- A `$param` token valid **only inside a definition body, only during expansion**. It never reaches + the renderer, which is what keeps the invariant intact. +- `requires: { local: [...], context: [...] }` on a definition, checked at insert against + `getScopeAtNode` — which already exists, for the visual editor's value pickers. This is where the + silent-`$local` failure class finally gets caught. +- Editor: the four operations and a drift indicator. + +Nothing in `@we/schema-solid`, nothing in the backend, nothing in the module contract. + +### Prerequisites before the marketplace accepts fragments + +1. **Declared scope requirements**, checked at insert. Without this, installing a fragment is a coin + flip that fails silently. +2. **An eject/detach operation** in the editor, so a tagged instance is never a dead end. +3. **Action disclosure at install.** Expansion makes installed fragments inert — they cannot act + until inserted — but insert is the moment `$action`s enter your template, and an installed + fragment runs with the template's full ambient authority. "It's data, so it's safe" is true of + presentation and false of actions. + +--- + +## Sequencing + +Done: + +1. The kit, and both of WE's own template packages built from it. +2. Loud failures — `$map` select lints, hoisted-query write checks, the previously unchecked + `$toggleLocal`/`$callLocal`, all with tests. +3. Form wiring — landed as the `field` _fragment_ rather than the operator this document first + assumed: which event carries a control's value is design-system knowledge, and an operator would + have to smuggle that table into the schema resolver. A worked example of the boundary. +4. Recipes in `@we/ai-context` — the kit's shapes in the in-app AI's prompt, with a hand-sync rule + in the kit's CONVENTIONS. +5. The design-system counterpart: arrangement-in-code components deleted, the + one-vocabulary-two-grammars policy in `design-system/CONVENTIONS.md`. + +Next: the component explorer and fragment palette +(`docs/internal/plans/prs/COMPONENT_EXPLORER_AND_FRAGMENT_PALETTE_PLAN.md`) — node insertion for +the editor and the AI through one checked pipeline, whose fragment manifests are the substrate the +provenance system lands on. + +The property worth protecting at every step: **the artifact is always plain JSON, so no step commits +you to the next one.** + +--- + +## Deferred obligations, and what triggers each + +Deliberately not built yet — deferring is the decision, and these are its tripwires. The failure +mode this table exists to prevent is calcifying by accident: the contract becoming whatever the +first hundred templates happened to depend on, versioned under pressure as archaeology. + +| Obligation | Trigger to build it | +| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `$fragment` provenance + editor machinery (insert/push/detach/update, drift) | A second serious template exists, or someone actually asks to extract-and-reuse across templates | +| Store-contract versioning (the template↔store surface as a declared, versioned API) | Store churn visibly slows, or the first _external_ template author appears. Until then the validator's store-path lints are the dev-mode form: they keep churn safe and become the contract's enforcement when it is written | +| Compatibility constitution becomes binding (registry append-only, shipped prop semantics frozen, deprecated components render forever) | The first persisted templates we are unwilling to break — marketplace beta at the latest. Pre-1.0, breaking freely is policy, which is why the dead-component deletion happened _now_ | +| Action capabilities + outbound-src filtering for untrusted templates | The marketplace accepts contributions from strangers. Disclosure at insert is necessary but is not containment | + +Each row is cheap to honour early and ruinous to retrofit late; the trigger is the latest safe +moment, not the recommended one. diff --git a/docs/internal/plans/prs/COMPONENT_EXPLORER_AND_FRAGMENT_PALETTE_PLAN.md b/docs/internal/plans/prs/COMPONENT_EXPLORER_AND_FRAGMENT_PALETTE_PLAN.md new file mode 100644 index 000000000..9cd71b5c2 --- /dev/null +++ b/docs/internal/plans/prs/COMPONENT_EXPLORER_AND_FRAGMENT_PALETTE_PLAN.md @@ -0,0 +1,88 @@ +# Component Explorer & Fragment Palette — Plan + +How authors add new nodes to a template from the visual editor: browsing the component vocabulary, +inserting kit fragments with parameters, and — later — the provenance machinery that lets a reused +fragment be adjusted globally. + +Grounding: `docs/architecture/template-fragments.md` (the expansion model this builds on), +`packages/design-system/CONVENTIONS.md` (one vocabulary, two grammars), +`packages/templates/kit/CONVENTIONS.md` (the fragments themselves). + +## Where this starts from + +- **Users cannot add nodes at all today.** The inspector edits existing nodes; there is no insert. + That gap is the real driver — fragments are the *good version* of solving it, not a separate + feature. +- The AI is already connected to the fragment system at the level the architecture intends: + the kit's shapes ship as JSON recipes in `schemaContext` (the in-app AI's system prompt), so an + AI-authored template produces the same trees the kit does. What the AI lacks is the same thing + the human lacks: an *insert operation* with checks. +- Everything needed to describe the vocabulary already exists in `context.json`: per-component + props, types, allowed values, `@ai` docs — the same data the validator and value pickers use. +- The kit is TypeScript in the app bundle. **The editor can simply import and call it.** Insert = + `emptyState({ icon, label })` → splice the returned nodes into the schema. No `$param` token, no + runtime fragment format, no new architecture for v1. + +## Design positions + +**One insert pipeline, two callers.** Insert-at-selection is a single editor operation — validate +target, check scope, splice nodes, select result — used by the palette UI *and* exposed to the +in-app AI as a tool. Both authors get the same checks; later, marketplace-fragment action +disclosure lives at this same choke point. Do not build a human path and an AI path. + +**Insert is where the ambient-scope contract finally gets enforced.** A fragment's manifest +declares `requires: { local: [...], context: [...] }`; the editor checks it against +`getScopeAtNode` (exists, powers the value pickers) at the drop target. Unmet requirements render +the fragment as *insertable-with-fix* — "declares `searchText` on the page for you" — or disabled +with the reason. This is the check `template-fragments.md` names as prerequisite #1 for +marketplace fragments; building it for the kit first means it exists before strangers need it. + +**Previews are live renders, not screenshots.** Fragments expand to plain nodes, and the policy +already allows schema islands in TSX via `RenderSchema` — so a preview is the fragment expanded +with sample props and rendered in the *current theme*, which screenshots can never be. Fragments +needing live data (`peopleRow`, `agentByline`) take the `SignalControl` precedent: sample data in +the manifest, rendered against a stub stores bag. Screenshot fallback only where a component +drags a heavy dependency (globe, graph). + +**A manifest per fragment, hand-written, in the kit.** Options interfaces are types and vanish at +runtime, so each fragment needs a small descriptor: display name, description, param form fields, +sample props for the preview, `requires`. Living beside the fragment keeps it honest (CONVENTIONS +gains: "a fragment ships with its manifest"), and it is the direct ancestor of the marketplace +fragment format — we learn the schema on ourselves first. + +## Phases + +**1. Component explorer + insert (no fragments yet).** +Panel listing primitives + registered components from `context.json`, text search over names and +`@ai` docs, click-to-insert at the current selection with sensible default props. Target validity +only (can this node hold children / this slot). This alone closes the "cannot add nodes" gap. + +**2. Fragment insert.** +Kit section in the explorer; param form from the manifest; live `RenderSchema` preview; scope +check with insert-with-fix. Editor imports `@we/template-kit` directly (lazy, like the other +editor chunks). + +**3. Palette + polish.** +Pinned favourites (per-agent, `AgentSettings`), recently used, drag-to-position as an alternative +to insert-at-selection, richer search (by prop, by store used). + +**4. Provenance and global adjustment.** +The `$fragment` tag, template-local definitions, push-to-definition, drift marking — designed in +full in `template-fragments.md`; not re-designed here. Gate: real demand for "edit my card +everywhere", per the trigger table. Phase 2's manifests and insert pipeline are deliberately the +substrate this lands on. + +**AI, alongside.** Recipes already cover authoring. When phase 2 lands, expose insert as a tool so +"add an empty state to this list" goes through the same pipeline — the AI stops re-typing +expansions and starts using the checked path. Marketplace fragments (data, `$param` expansion) +stay behind the triggers in the architecture doc. + +## Open questions + +- Param forms: hand-written per manifest (v1, fine at ~16 fragments) vs generated from a schema — + decide when the marketplace format forces the question anyway. +- Preview stores stub: how much of the stores bag must exist before `RenderSchema` is safe with + arbitrary fragments? (Worst case today: `$store` reads resolve to nothing — acceptable for + previews, but verify nothing throws.) +- Where the explorer lives: fourth right-panel alongside code/theme/visual, or a popover from an + insert affordance on the selected node. Lean popover-first — insertion is contextual. diff --git a/packages/ai-context/context.json b/packages/ai-context/context.json index f2a7a85d4..f1bc99c7f 100644 --- a/packages/ai-context/context.json +++ b/packages/ai-context/context.json @@ -2790,42 +2790,6 @@ ], "source": "components" }, - { - "name": "Accordion", - "props": [ - { - "name": "children", - "type": "JSX.Element", - "optional": true - }, - { - "name": "renderContent", - "type": "((item: AccordionItem, index: number) => JSX.Element)", - "optional": true - }, - { - "name": "onChange", - "type": "((openItems: string[]) => void)", - "optional": true - }, - { - "name": "items", - "type": "AccordionItem[]", - "optional": true - }, - { - "name": "multiple", - "type": "boolean", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, { "name": "AudioVisualiser", "props": [ @@ -2893,32 +2857,6 @@ ], "source": "components" }, - { - "name": "Breadcrumbs", - "props": [ - { - "name": "onNavigate", - "type": "((item: BreadcrumbItem, index: number) => void)", - "optional": true - }, - { - "name": "items", - "type": "BreadcrumbItem[]", - "optional": true - }, - { - "name": "separator", - "type": "string", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, { "name": "Calendar", "props": [ @@ -2951,42 +2889,6 @@ "props": [], "source": "components" }, - { - "name": "CircleButton", - "props": [ - { - "name": "label", - "type": "string", - "optional": false - }, - { - "name": "icon", - "type": "string", - "optional": true - }, - { - "name": "image", - "type": "string", - "optional": true - }, - { - "name": "onClick", - "type": "(() => void)", - "optional": true - }, - { - "name": "class", - "type": "string", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, { "name": "CodeEditor", "props": [ @@ -3117,13 +3019,13 @@ "description": "Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.", "props": [ { - "name": "class", - "type": "string", + "name": "styles", + "type": "Record", "optional": true }, { - "name": "styles", - "type": "Record", + "name": "class", + "type": "string", "optional": true }, { @@ -3170,7 +3072,7 @@ }, { "name": "fit", - "type": "\"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\"", + "type": "\"cover\" | \"contain\" | \"none\" | \"fill\" | \"scale-down\"", "optional": true }, { @@ -3294,47 +3196,6 @@ ], "source": "components" }, - { - "name": "IconLabelButton", - "props": [ - { - "name": "icon", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", - "optional": false - }, - { - "name": "label", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", - "optional": false - }, - { - "name": "selected", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", - "optional": true - }, - { - "name": "iconWeight", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", - "optional": true - }, - { - "name": "onClick", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor<(() => void) | undefined>", - "optional": true - }, - { - "name": "class", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor", - "optional": true - }, - { - "name": "styles", - "type": "import(\"/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid\").MaybeAccessor | undefined>", - "optional": true - } - ], - "source": "components" - }, { "name": "ImageCrop", "props": [ @@ -3397,73 +3258,6 @@ ], "source": "components" }, - { - "name": "List", - "props": [ - { - "name": "children", - "type": "JSX.Element", - "optional": true - }, - { - "name": "renderItem", - "type": "((item: ListItem, index: number) => JSX.Element)", - "optional": true - }, - { - "name": "items", - "type": "ListItem[]", - "optional": true - }, - { - "name": "ordered", - "type": "boolean", - "optional": true - }, - { - "name": "gap", - "type": "string", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, - { - "name": "PostCard", - "props": [ - { - "name": "creator", - "type": "{ name: string; avatar: string; }", - "optional": true - }, - { - "name": "title", - "type": "string", - "optional": false - }, - { - "name": "text", - "type": "string", - "optional": false - }, - { - "name": "class", - "type": "string", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, { "name": "RerenderLog", "props": [ @@ -3596,99 +3390,6 @@ ], "source": "components" }, - { - "name": "Stepper", - "props": [ - { - "name": "onStepClick", - "type": "((index: number) => void)", - "optional": true - }, - { - "name": "steps", - "type": "StepperStep[]", - "optional": true - }, - { - "name": "activeStep", - "type": "number", - "optional": true - }, - { - "name": "orientation", - "type": "\"horizontal\" | \"vertical\"", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, - { - "name": "Table", - "props": [ - { - "name": "renderCell", - "type": "((row: Record, column: TableColumn, index: number) => JSX.Element)", - "optional": true - }, - { - "name": "columns", - "type": "TableColumn[]", - "optional": false - }, - { - "name": "rows", - "type": "Record[]", - "optional": false - }, - { - "name": "striped", - "type": "boolean", - "optional": true - }, - { - "name": "bordered", - "type": "boolean", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, - { - "name": "Timeline", - "props": [ - { - "name": "children", - "type": "JSX.Element", - "optional": true - }, - { - "name": "renderItem", - "type": "((item: TimelineItem, index: number) => JSX.Element)", - "optional": true - }, - { - "name": "items", - "type": "TimelineItem[]", - "optional": true - }, - { - "name": "styles", - "type": "Record", - "optional": true - } - ], - "source": "components" - }, { "name": "ToastContainer", "props": [ @@ -3861,74 +3562,6 @@ ], "source": "widgets" }, - { - "name": "GraphWidget", - "description": "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.", - "props": [ - { - "name": "data", - "type": "GraphData", - "optional": false - }, - { - "name": "width", - "type": "string | number", - "optional": true - }, - { - "name": "height", - "type": "string | number", - "optional": true - }, - { - "name": "nodeStyle", - "type": "NodeStyleConfig", - "optional": true - }, - { - "name": "edgeStyle", - "type": "EdgeStyleConfig", - "optional": true - }, - { - "name": "layout", - "type": "LayoutConfig", - "optional": true - }, - { - "name": "interactions", - "type": "InteractionConfig", - "optional": true - } - ], - "source": "widgets" - }, - { - "name": "SpaceSidebarWidget", - "props": [ - { - "name": "name", - "type": "string", - "optional": false - }, - { - "name": "description", - "type": "string", - "optional": true - }, - { - "name": "class", - "type": "string", - "optional": true - }, - { - "name": "style", - "type": "Record", - "optional": true - } - ], - "source": "widgets" - }, { "name": "GraphView", "description": "A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and\nfree-positioned boards, all from the same engine.\n\nThe shape of a graph is set by four independent choices: where it starts (`seeds`), how much of it\nopens (`expansion`), how it is arranged (`layout`), and how it looks (`nodeStyle` / `edgeStyle`).\n\nCommon recipes:\n- **Knowledge map** — `seeds: { source: 'query', options: { entity: 'Belief' } }` with\n`expansion: { defaultDepth: 1 }` and `layout: { type: 'force' }`.\n- **Schema map** — `seeds: { source: 'schema' }`, which draws the dataset's own entity types and\nthe relations between them. Picks up model types added later with no template change.\n- **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content.\n- **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all.", diff --git a/packages/ai-context/src/assembler.ts b/packages/ai-context/src/assembler.ts index 98214f27a..51755938a 100644 --- a/packages/ai-context/src/assembler.ts +++ b/packages/ai-context/src/assembler.ts @@ -47,6 +47,11 @@ export function assembleReference(ctx: AssembledContext): string { // Store patterns sections.push(context.fragments.storePatterns.trim()); + // Ready-made shapes. After the stores and their patterns — a recipe names both, so it only reads + // once the vocabulary underneath it has been introduced — and before the rules, which are the + // short prohibitions a reader should meet last. + sections.push(context.fragments.patterns.trim()); + // Routing sections.push(context.fragments.routing.trim()); diff --git a/packages/ai-context/src/fragments/architecture.ts b/packages/ai-context/src/fragments/architecture.ts index 1ec233a9c..a74de4e34 100644 --- a/packages/ai-context/src/fragments/architecture.ts +++ b/packages/ai-context/src/fragments/architecture.ts @@ -85,6 +85,7 @@ Glossary (these terms pervade stores, models, and \`$query\`/\`perspective\` in | \`@we/widgets\` | design-system/5-widgets | Generic widgets (graph, sidebar) — feature widgets live in their module family | Solid | | \`@we/design-utils\` | design-system/utils | Shared DS-props → style computation; token resolvers | Neutral core + \`/solid\` binding | | \`@we/design-types\` | design-system/types | Shared DS prop/type definitions | Agnostic | +| \`@we/template-kit\` | templates/kit | Reusable template fragments — authoring-time helpers that expand to plain nodes | Agnostic | | \`@we/template-shell\` · \`@we/template-default\` | templates/* | WE's shell surfaces and built-in space templates, as data | Agnostic | | \`@we/editor\` | packages/editor | Template/theme editing surface, embeddable via \`EditorHost\` | Solid (mount fn at the boundary) | | \`@we/schema-shared\` | schema-system/shared | Schema semantics: prop resolvers, validation, indexer, registry types, reactivity port | **Agnostic** | @@ -162,4 +163,6 @@ that declares \`backends: ['ad4m']\` — nothing else. See \`docs/architecture/p For deeper detail (data sync/persistence, block & editor internals, the local dev/test loop), see docs/architecture/codebase-map.md. +For how reusable template fragments work and where they are going, see +docs/architecture/template-fragments.md. `; diff --git a/packages/ai-context/src/fragments/dev-patterns.ts b/packages/ai-context/src/fragments/dev-patterns.ts index 7f0a13a7e..a4468ff45 100644 --- a/packages/ai-context/src/fragments/dev-patterns.ts +++ b/packages/ai-context/src/fragments/dev-patterns.ts @@ -223,6 +223,7 @@ Always read \`CONVENTIONS.md\` before creating or modifying files in that packag Key packages with conventions files: - \`packages/models/CONVENTIONS.md\` — model authoring: entities vs blocks, predicates, @Flag, WeNode, Model.create() pattern +- \`packages/templates/kit/CONVENTIONS.md\` — fragment authoring: what belongs in the kit, extraction threshold, options-object API, body style --- diff --git a/packages/ai-context/src/fragments/patterns.ts b/packages/ai-context/src/fragments/patterns.ts new file mode 100644 index 000000000..b7610ffcf --- /dev/null +++ b/packages/ai-context/src/fragments/patterns.ts @@ -0,0 +1,321 @@ +/** + * Patterns fragment — the shapes WE's own templates are built from, as JSON to copy. + * + * These are the recipes behind `@we/template-kit`. The kit is TypeScript, so a template authored + * in the browser cannot import it; what it can do is produce the same JSON. That is the whole point + * of the kit expanding at authoring time — the output is the shared artifact, not the helper. + * + * Hand-maintained. When a kit fragment's expansion changes materially, change the recipe with it — + * they are two renderings of one decision, and a drifted recipe teaches the AI a shape the codebase + * has stopped using. + */ +export const patterns = ` +## Common Patterns (copy these shapes) + +These are the shapes WE's own templates use. Prefer them over inventing a new arrangement — they +carry decisions (loading behaviour, empty states, accessibility) that are easy to omit and hard to +notice missing. Copy the JSON and change the words; every one of them is ordinary nodes you can +then edit freely. + +### Empty state — what a list shows when it has nothing to show + +**A list must always have one.** An empty \`$each\` renders nothing at all, so a page with no content +looks identical to a page still loading, and the reader cannot tell which. + +\`\`\`json +{ + "type": "$animate", + "props": { "enterTransition": { "type": "fade", "duration": 200, "delay": 400 } }, + "children": [ + { + "type": "Column", + "props": { "ax": "center", "ay": "center", "gap": "200", "p": "600", "width": "100%" }, + "children": [ + { "type": "we-icon", "props": { "name": "newspaper", "size": "lg", "color": "neutral-400" } }, + { + "type": "we-text", + "props": { "color": "neutral-400", "textAlign": "center" }, + "children": ["This space doesn't have any posts."] + } + ] + } + ] +} +\`\`\` + +The \`$animate\` wrapper is not decoration. A query-backed list is empty on its first frame and fills +a moment later, so without the delayed fade the placeholder blinks on every load and states +something false while it does. Drop the wrapper only when emptiness is known synchronously (a store +array, a missing model). + +**If the list filters on a search box**, say so instead of claiming the space is empty: + +\`\`\`json +{ "$if": { "condition": { "$local": "searchText" }, + "then": "No posts match your search.", + "else": "This space doesn't have any posts." } } +\`\`\` + +### A list with its empty state — hoist the query so the count is readable + +\`\`\`json +{ + "type": "Column", + "props": { "width": "100%" }, + "$queries": { "postRows": { "entity": "CollectionBlock", "where": { "type": "root" }, "limit": 20 } }, + "children": [ + { + "type": "$if", + "props": { + "condition": { "$count": { "items": { "$local": "postRows" } } }, + "then": { + "type": "Grid", + "props": { "columns": 1, "gap": "400", "width": "100%" }, + "children": [ + { + "type": "$each", + "props": { "items": { "$local": "postRows" }, "as": "post" }, + "children": [{ "type": "Card", "children": ["…"] }] + } + ] + }, + "else": { "…": "the empty state above" } + } + } + ] +} +\`\`\` + +Hoisting into \`$queries\` rather than leaving the query on the \`$each\` is what makes the count +readable from outside the loop, and it means one subscription answers both branches — so the +placeholder and the grid can never disagree about how many rows there are. + +### Gate / prompt page — an icon, what this is, and what to do about it + +\`\`\`json +{ + "type": "Column", + "props": { "flex": "1", "height": "100%", "ax": "center", "ay": "center", "gap": "400", "p": "600" }, + "children": [ + { "type": "we-icon", "props": { "name": "lock", "size": "xl", "gradient": "primary" } }, + { "type": "we-text", "props": { "variant": "heading-md", "textAlign": "center" }, "children": ["Join this Space"] }, + { + "type": "we-text", + "props": { "variant": "body", "textAlign": "center", "maxWidth": "var(--we-layout-xs)" }, + "children": ["You haven't joined this space yet."] + }, + { "type": "we-button", "props": { "variant": "primary", "onClick": { "$action": "…" } }, "children": ["Join"] } + ] +} +\`\`\` + +Use \`gradient\` on the icon when there is something to do, and a flat \`color\` (\`neutral-300\`, +or \`warning\`) when there is not — the two read apart at a glance, and a dead end that looks like +an invitation is worse than one that looks like a dead end. + +### Confirm dialog + +\`\`\`json +{ + "type": "$if", + "props": { + "condition": { "$local": "confirmDeleteOpen" }, + "then": { + "type": "we-modal", + "props": { "close": { "$setLocal": "confirmDeleteOpen", "value": false } }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["Delete post?"] }, + { "type": "we-text", "children": ["This cannot be undone."] }, + { + "type": "Row", + "props": { "ax": "end", "gap": "200" }, + "children": [ + { "type": "we-button", "props": { "variant": "ghost", "onClick": { "$setLocal": "confirmDeleteOpen", "value": false } }, "children": ["Cancel"] }, + { + "type": "we-button", + "props": { + "variant": "danger", + "onClick": { "$action": "spaceStore.deleteCollection", "args": ["$post.id"], + "onSuccess": [{ "$setLocal": "confirmDeleteOpen", "value": false }] } + }, + "children": ["Delete"] + } + ] + } + ] + } + } +} +\`\`\` + +The flag must be declared by an ancestor of **the button that opens it**, not merely of the modal. +Undeclared, \`$setLocal\` warns and no-ops: the button renders, takes the click, and does nothing. + +If the action is slow (a recursive delete walks its whole collection), add a \`busy\` boolean set +before it and cleared in \`onFinally\`, and bind the confirm button's \`loading\` and \`disabled\` to it. + +### Form field + +\`\`\`json +{ + "type": "we-form-field", + "props": { "label": "Name", "error": { "$error": "name" } }, + "children": [ + { + "type": "we-input", + "props": { + "placeholder": "Space name…", + "value": { "$local": "name" }, + "onInput": { "$setLocal": "name", "from": "$event.detail" } + } + } + ] +} +\`\`\` + +\`$error\` is already empty until the field is touched, so it needs no \`$if\` around it. Which event +carries the value depends on the control: \`we-input\`/\`we-textarea\` emit \`onInput\` with +\`$event.detail\`, \`we-select\` emits \`onChange\` with \`$event.detail\`, and \`Search\` calls back +with the value itself as \`$arg\`. + +### Author byline + +\`\`\`json +{ + "type": "$agent", + "props": { "did": "$post.author", "as": "author" }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "300" }, + "children": [ + { "type": "we-avatar", "props": { "size": "sm", "image": "$author.avatar", "hash": "$author.did" } }, + { "type": "we-text", "props": { "fontWeight": "semibold" }, "children": ["$author.name"] }, + { "type": "we-timestamp", "props": { "value": "$post.createdAt", "relative": true, "color": "neutral-500" } } + ] + } + ] +} +\`\`\` + +Always set \`hash\` as well as \`image\`, never as a fallback for it: \`hash\` seeds a generated avatar +that is stable per agent, so somebody whose profile has not arrived is still visually distinct from +everybody else whose profile has not arrived. A real picture wins where there is one. + +### A group of faces with a count + +\`\`\`json +{ + "type": "Row", + "props": { "gap": "300", "ay": "center", "minHeight": "32px" }, + "children": [ + { + "type": "AvatarStack", + "props": { + "avatars": { "$map": { "items": { "$store": "spaceStore.members" }, + "select": { "image": "$item.avatar", "hash": "$item.did" } } }, + "max": 5, "size": "sm", "ring": "0 0 0 2px var(--we-ring-color)" + } + }, + { + "type": "Row", + "props": { "gap": "100", "ay": "center" }, + "children": [ + { "type": "we-number", "props": { "value": { "$count": { "items": { "$store": "spaceStore.members" } } }, "shorten": true } }, + { "type": "we-text", "children": [{ "$plural": { "count": { "$count": { "items": { "$store": "spaceStore.members" } } }, "one": "Member", "other": "Members" } }] } + ] + } + ] +} +\`\`\` + +**When the items are bare DIDs rather than profiles**, join each to its profile — and note the trap: +inside a \`$map\` \`select\`, a string is substituted only when it starts with \`$item.\`. A bare +\`"$item"\` is a **literal**, so every generated face comes out identical. Wrap it in a token object: + +\`\`\`json +"select": { + "image": { "$find": { "items": { "$store": "profileStore.profiles" }, "where": { "did": "$item" }, "select": "avatar" } }, + "hash": { "$concat": ["$item"] } +} +\`\`\` + +\`minHeight\` on the row is worth keeping: \`AvatarStack\` has no height with no avatars, and people +resolve later than the record they belong to, so without a floor the row collapses and then pushes +everything below it down a second time. + +### Page shell — a route's outer box + +\`\`\`json +{ + "type": "Column", + "props": { "width": "100%", "ax": "center" }, + "children": [ + { + "type": "Column", + "props": { "width": "100%", "maxWidth": "var(--we-layout-lg)", "gap": "500", "px": "400", "py": "500" }, + "children": ["…"] + } + ] +} +\`\`\` + +Two Columns, because centring and constraining are different jobs: the outer spans the viewport so +the route's background reaches the edges, the inner holds the measure. + +### Titled section on a card + +\`\`\`json +{ + "type": "Card", + "props": { "bg": "neutral-100", "border": "1px solid neutral-200" }, + "children": [ + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { "type": "we-text", "props": { "variant": "heading-md" }, "children": ["About this space"] }, + { "type": "we-text", "children": ["Manage how this space appears to others."] } + ] + }, + "…" + ] +} +\`\`\` + +### Labelled attribute with an optional control + +\`\`\`json +{ + "type": "Row", + "props": { "ay": "center", "ax": "between", "wrap": true }, + "children": [ + { + "type": "Row", + "props": { "ay": "center", "gap": "400", "py": "100" }, + "children": [ + { "type": "we-icon", "props": { "name": "globe", "color": "primary-600" } }, + { + "type": "Column", + "props": { "gap": "100" }, + "children": [ + { + "type": "Row", + "props": { "gap": "300" }, + "children": [ + { "type": "we-text", "props": { "fontWeight": "bold", "color": "neutral-700" }, "children": ["Discovery:"] }, + { "type": "we-text", "props": { "fontWeight": "bold" }, "children": ["Listed"] } + ] + }, + { "type": "we-text", "props": { "variant": "body" }, "children": ["Appears on the WE discovery globe"] } + ] + } + ] + }, + { "type": "we-switch", "props": { "checked": true, "onChange": { "$action": "…" } } } + ] +} +\`\`\` + +Drop the outer \`Row\` and the control for the read-only form. +`; diff --git a/packages/ai-context/src/fragments/store-patterns.ts b/packages/ai-context/src/fragments/store-patterns.ts index 8f5719489..02133432f 100644 --- a/packages/ai-context/src/fragments/store-patterns.ts +++ b/packages/ai-context/src/fragments/store-patterns.ts @@ -27,11 +27,15 @@ Iterating over store data: "props": { "items": { "$store": "spaceStore.personalSpaces" }, "as": "space" }, "children": [ { - "type": "CircleButton", + "type": "we-button", "props": { - "label": "$space.name", + "variant": "ghost", "onClick": { "$action": "routeStore.navigate", "args": [{ "$concat": ["/space/", "$space.uuid"] }] } - } + }, + "children": [ + { "type": "we-avatar", "props": { "image": "$space.avatar", "initials": "$space.name", "size": "sm" } }, + { "type": "we-text", "children": ["$space.name"] } + ] } ] } diff --git a/packages/ai-context/src/generate.ts b/packages/ai-context/src/generate.ts index 22f70f37a..5a9ca0147 100644 --- a/packages/ai-context/src/generate.ts +++ b/packages/ai-context/src/generate.ts @@ -30,6 +30,7 @@ import { extractComponentProps } from './extractors/typescript.js'; import { architecture } from './fragments/architecture.js'; import { designSystemProps } from './fragments/design-system-props.js'; import { devPatterns } from './fragments/dev-patterns.js'; +import { patterns } from './fragments/patterns.js'; import { routing } from './fragments/routing.js'; import { rules } from './fragments/rules.js'; import { schemaOperators } from './fragments/schema-operators.js'; @@ -231,6 +232,7 @@ async function main() { // the reference lists exactly the members the validator accepts. stores: generateStoresText(contextData.storeEntries ?? []), storePatterns, + patterns, rules, }, }; diff --git a/packages/ai-context/src/schemaContext.ts b/packages/ai-context/src/schemaContext.ts index bb9469fcf..ec46320c3 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\": \"spaceStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }] }\nExample — navigate to newly created item:\n{ \"$action\": \"spaceStore.createSpace\", \"args\": [...], \"onSuccess\": [{ \"$setLocal\": \"modalOpen\", \"value\": false }, { \"$action\": \"routeStore.navigate\", \"args\": [{ \"$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\": \"datasetStore.rootDataset\" }] }\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\": \"datasetStore.rootDataset\" }] }\n\nmodel.delete — deletes a model instance:\n{ \"$action\": \"model.delete\", \"args\": [\"ModelName\", \"$item.id\"] }\n\nUse perspective: 'datasetStore.rootDataset' 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\": \"datasetStore.systemDatasetUuids\" }] }\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 backend-store paths inside $query and conditions:\n- $currentDataset — the currently active dataset (an AD4M perspective, in the AD4M backend). Use as a dataset value.\n A host store's dataset accessor (e.g. `dataset: 'datasetStore.marketplaceDataset'`) works as a dataset value too.\n When passing a dataset to a *component prop* rather than a query, append `.handle` — component props take the\n backend's own dataset handle: { \"perspective\": { \"$store\": \"datasetStore.currentDataset.handle\" } }.\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 (in onBlur; opt-in, see below).\n{ \"$touch\": \"$all\" } — marks all fields in scope as touched (use before submit guard).\n{ \"$resetLocal\": \"$scope\" } — resets all fields to initial values and clears touched state.\n\nHandler arrays (compose multiple actions on one event):\n{ \"onClick\": [{ \"$touch\": \"$all\" }, { \"$if\": { \"condition\": { \"$formValid\": \"$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 — validate on submit:\n{\n \"$localState\": {\n \"name\": { \"type\": \"string\", \"initial\": \"\", \"validate\": [{ \"rule\": \"required\" }] },\n \"submitting\": { \"type\": \"boolean\", \"initial\": false }\n },\n \"children\": [\n {\n \"type\": \"we-form-field\",\n \"props\": { \"label\": \"Name\", \"error\": { \"$error\": \"name\" } },\n \"children\": [{\n \"type\": \"we-input\",\n \"props\": {\n \"value\": { \"$local\": \"name\" },\n \"onInput\": { \"$setLocal\": \"name\", \"from\": \"$event.detail\" }\n }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"loading\": { \"$local\": \"submitting\" },\n \"disabled\": { \"$local\": \"submitting\" },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"store.save\", \"args\": [{ \"$local\": \"name\" }], \"onSuccess\": [{ \"$setLocal\": \"submitDone\", \"value\": true }] } } }\n ]\n },\n \"children\": [\"Submit\"]\n }\n ]\n}\n\nThe submit button is disabled only while the request is in flight — NOT on { \"$not\": { \"$formValid\": \"$scope\" } }.\nThose two are mutually exclusive. A button disabled while the form is invalid can never be clicked in the one\nstate where { \"$touch\": \"$all\" } would reveal something, so the guard chain becomes dead code and blur is left\nas the user's only feedback path. Choose one shape:\n - Validate on submit (above). The button is always clickable and the errors appear on the click that was\n refused, which is where the user asked the question.\n - Hard gate: \"disabled\": { \"$not\": { \"$formValid\": \"$scope\" } }, and then drop { \"$touch\": \"$all\" } as dead\n and wire \"onBlur\": { \"$touch\": \"fieldName\" } per field — otherwise no error is ever reachable.\n\n\"onBlur\": { \"$touch\": \"fieldName\" } is an opt-in, not boilerplate. It earns its place on long multi-field forms\nwhere a field is worth judging the moment it is left — a \"match\" rule on a confirm-password field, say. On a\nshort form it fires an error at someone who merely clicked through a field they had not filled in yet.\n\nNo validation, just a precondition (sign-in, search, any single-field submit):\nWhen nothing about the value is locally judgeable — a password is only wrong once the backend says so — skip the\nvalidation machinery and gate on the value itself:\n{\n \"$localState\": { \"password\": { \"type\": \"string\", \"initial\": \"\" } },\n ...\n \"disabled\": { \"$not\": { \"$local\": \"password\" } }\n}\nA \"required\" rule here would exist only to drive \"disabled\", and its message is then one stray { \"$touch\": … }\naway from telling the user \"Password is required\" about a field they simply have not typed into yet.\n\n## Block-level Dynamic Structures\n\nBlock-level structures use \"type\" starting with \"$\" for dynamic rendering of schema nodes.\n\nEach loop:\n{ \"type\": \"$each\", \"props\": { \"items\": { \"$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\nModule slot outlet:\n{ \"type\": \"$slot\", \"props\": { \"anchor\": \"call-controls\" } }\nRenders whatever other feature modules have contributed to that anchor, in order. Only meaningful\ninside a module's own chrome: the module declares the anchor name in its `anchors` list and marks\nwhere contributions land with this. Resolves to nothing when no module has contributed — no empty\ncontainer, no gap. Templates have no use for it; chrome is the host's and the modules', not a\ntemplate's.\n\n---\n\n## Component Registry\n\nMost @we/primitives also accept Design System Props (see next section for details and exceptions).\n\n@we/primitives:\n- we-alert (DesignSystemElement)\n Props: variant: 'neutral' | 'primary' | 'success' | 'warning' | 'danger' = 'primary', 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' | 'bare' = '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', revealable: boolean = false, size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'\n- we-link (DesignSystemElement)\n Props: href: string = '', target: string = '', rel: string = '', download: string = '', disabled: boolean = false\n- we-location-picker (DesignSystemElement)\n Props: latitude?: number | undefined, longitude?: number | undefined, placeholder: string = 'Set location…', disabled: boolean = false, reverseGeocode: boolean = true\n- we-markdown (DesignSystemElement)\n Props: content: string = '', markdownGap: string = ''\n- we-menu (DesignSystemElement) — Vertical list container for menu items inside a popover.\nNot a standalone selector — wrap in we-popover for dropdown behavior.\n- we-menu-group (LayoutElement)\n Props: collapsible: boolean = false, open: boolean = false, title: string = ''\n- we-menu-item (DesignSystemElement) — Single actionable item inside a we-menu.\nSupports selected, active, and danger states.\n Props: selected: boolean = false, active: boolean = false, variant: 'default' | 'danger' = 'default', label: unknown, value: unknown\n- we-modal (OverlayElement)\n Props: 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-resize-handle (LayoutElement) — A drag target that reports how far it has moved, and nothing else.\n\n## Why it reports a delta rather than owning a size\n\nThe obvious design is a handle that resizes its neighbour. It is the wrong one, because \"what does\nthis drag mean\" is never the handle's business: the editor's panel rails grow *leftwards* from a\nwidth that starts at zero when the panel is closed, clamp at a minimum, and close the panel again\nbelow a threshold — while a docked call panel grows from whichever edge it is attached to. A\nhandle that owned the size could serve one of those and not the other.\n\nSo it emits `resizestart`, `resize` and `resizeend`, each carrying `delta`: pixels moved along its\naxis **since the drag began**, signed in screen direction (right and down positive). The consumer\ncaptures its own starting size and applies whatever sign and limits it has. Delta-from-start\nrather than incremental, because every consumer would otherwise have to accumulate, and one of\nthem would get it wrong after a dropped event.\n\n## Why a primitive rather than a hook\n\nThere were two implementations of this before it existed and they diverged in ways nobody chose:\nthe editor's is mouse-only, so it does not work on a touchscreen at all, and its rail is a plain\ndiv — not focusable, so there is no way to resize a panel from the keyboard. Pointer events and a\n`separator` role fix both once, for every consumer, in the layer where imperative DOM work belongs.\n Props: orientation: 'vertical' | 'horizontal' = 'vertical', align: 'start' | 'center' | 'end' = 'center', step: number = 16, dragging: boolean = false\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 `reorder` CustomEvent on drop with the new ordered array\nof IDs — the event name is unprefixed, like every other primitive's\n(`change`, `select`, `toggle`). In Solid, listen with `on:reorder`; a\nlistener for `we-reorder` never fires and the drop silently does nothing.\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 = '', loading: boolean = false, loadingWidth: string = '100%'\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', fit: '' | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' = '', autoplay: boolean = false, loop: boolean = false, muted: boolean = false, playsinline: boolean = false, stream?: MediaStream | null | undefined\n\n@we/components:\n- AudioDisplay\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | undefined, duration: number | undefined, albumArt: string | undefined\n- AudioInput\n Props: title: string | undefined, artist: string | undefined, audioUrl: string | FileData | undefined, duration: number | undefined, albumArt: string | undefined, onChange: (property: string, value: unknown) => void, isSelected: () => boolean\n- BlockComposer (DesignSystemElement)\n Props: editorState?: 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, size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\", items: SolidDropdownMenuEntry[]\n- EditableImage (DesignSystemElement)\n Props: src?: string, alt?: string, fit?: \"fill\" | \"cover\" | \"contain\" | \"none\" | \"scale-down\", placeholderIcon?: string, onImageChange?: ((file: File) => void), onImageRemove?: (() => void), uploadLabel?: string, editLabel?: string, class?: string, aspect?: number, maxSize?: number\n- FlipCard\n Props: front?: JSX.Element, back?: JSX.Element, width?: string, height?: string, flipOnHover?: boolean, flipDuration?: string, wobbleOnHover?: boolean, wobbleDegree?: number, class?: string, styles?: Record\n- Grid (DesignSystemElement)\n Props: template?: string, columns?: number, minChildWidth?: string\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- 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- GraphView — A general-purpose graph view: knowledge maps, schema maps, hierarchies, cluster maps and\nfree-positioned boards, all from the same engine.\n\nThe shape of a graph is set by four independent choices: where it starts (`seeds`), how much of it\nopens (`expansion`), how it is arranged (`layout`), and how it looks (`nodeStyle` / `edgeStyle`).\n\nCommon recipes:\n- **Knowledge map** — `seeds: { source: 'query', options: { entity: 'Belief' } }` with\n`expansion: { defaultDepth: 1 }` and `layout: { type: 'force' }`.\n- **Schema map** — `seeds: { source: 'schema' }`, which draws the dataset's own entity types and\nthe relations between them. Picks up model types added later with no template change.\n- **Hierarchy** — `layout: { type: 'tree' }` with a `collection` expansion for nested content.\n- **Static diagram** — `seeds: { literal: true, nodes: [...], edges: [...] }` and no expansion at all.\n Props: seeds?: SeedSpec | SeedSpec[], expansion?: ExpansionSpec, layout?: LayoutSpec, nodeStyle?: NodeStyleRules, edgeStyle?: EdgeStyleRules, behaviours?: BehaviourSpec[], width?: string, height?: string, bg?: string, showStatus?: boolean, showControls?: boolean, onNodeClick?: ((node: GraphNode) => void), onNodeDoubleClick?: ((node: GraphNode) => void), onEdgeClick?: ((edge: GraphEdge) => void), onSelectionChange?: ((ids: string[]) => void), onNodeDragEnd?: ((payload: { id: string; x: number; y: number; }) => void), host?: GraphHostBindings\n\n---\n\n## Component Plugin Registries\n\nSome components resolve named plugins from their props. These are the names each accepts —\na name not listed here does not exist, and the component will warn rather than render.\n\n### GraphView\n\nNames resolvable inside GraphView props: seed sources (seeds.source), expanders (expansion.expanders), layouts (layout.type) and behaviours (behaviours[]).\n\n**seed**\n\n- `query` — Loads instances of one entity type as nodes; can draw named relations immediately.\n - entity: string — Entity type to load (required).\n - where: object — Filter, same operators as $query.\n - order: object — e.g. { createdAt: \"desc\" }.\n - limit: number — Defaults to 100.\n - relations: string[] — Relations to hydrate and draw as edges up front.\n - Example: `{ \"source\": \"query\", \"options\": { \"entity\": \"Post\", \"limit\": 50, \"relations\": [\"author\"] } }`\n- `schema` — Maps the dataset's own entity types and the relations between them — one node per type. Picks up model types installed after the template was written, so it suits spaces whose vocabulary is open-ended.\n - entities: string[] — Restrict to these types; omit for all of them.\n - Example: `{ \"source\": \"schema\" }`\n- `dataset` — Seeds a single node for the current space — the starting point for exploring outward.\n - label: string\n - Example: `{ \"source\": \"dataset\", \"options\": { \"label\": \"This space\" } }`\n\n**expander**\n\n- `entity` — Follows an entity's typed relations, forwards and backwards, from the dataset's schema. The default for knowledge maps.\n - relations: string[] — Only follow these.\n - exclude: string[] — Never follow these.\n - Example: `\"expansion\": { \"expanders\": [\"entity\"], \"direction\": \"both\", \"defaultDepth\": 1 }`\n- `collection` — Opens a container into its children through an untyped to-many relation — the drill-down the schema cannot describe. Recurses naturally into nested collections.\n - parents: string[] — Container types. Defaults to CollectionBlock.\n - via: string — Relation holding the children. Defaults to \"children\".\n - children: string[] — Child entity types to look for.\n - Example: `\"expansion\": { \"expanders\": [\"collection\"], \"defaultDepth\": 2, \"direction\": \"out\" }`\n- `schema` — Opens an entity-type node from the schema seed into instances of that type — the step from \"what kinds of thing are here\" to \"here they are\". Paired with the schema seed it makes one map out of two.\n - limit: number — Instances loaded per type. Default 25.\n - Example: `\"seeds\": { \"source\": \"schema\" }, \"expansion\": { \"expanders\": [\"schema\", \"entity\"] }`\n- `property` — Opens an instance out into its own scalar fields, and optionally into shared value nodes so instances converge on common values. The resolution level below an entity.\n - properties: string[] — Only show these fields.\n - valueNodes: boolean — Promote values to shared nodes. Defaults to true.\n - Example: `\"expansion\": { \"expanders\": [\"property\"] }`\n\n**layout**\n\n- `force` — Force-directed, with warm start so newly expanded nodes settle around what is already placed rather than restarting the whole map. The default.\n - distance: number — Preferred edge length. Default 90.\n - charge: number — Repulsion; more negative spreads further. Default -220.\n - collide: number — Minimum spacing. Default 28.\n - Example: `{ \"type\": \"force\", \"options\": { \"distance\": 140 } }`\n- `tree` — Layered hierarchy from the graph roots. The right choice for containment and org charts.\n - direction: \"down\" | \"right\"\n - levelGap: number\n - siblingGap: number\n - Example: `{ \"type\": \"tree\", \"options\": { \"direction\": \"right\", \"levelGap\": 200 } }`\n- `radial` — Concentric rings by hop distance from the roots — reads as distance from a centre.\n - ringGap: number\n - Example: `{ \"type\": \"radial\" }`\n- `grid` — Uniform grid, optionally ordered by a node data field. Honest default when edges say little.\n - columns: number\n - sortBy: string — Node data field to order by.\n - Example: `{ \"type\": \"grid\", \"options\": { \"columns\": 6, \"sortBy\": \"name\" } }`\n- `manual` — Positions come from the nodes themselves — a board, where position is the data being edited rather than something derived. Pair with drag-node and persist via onNodeDragEnd.\n - xField: string — Node data field holding x. Default \"x\".\n - yField: string — Node data field holding y. Default \"y\".\n - Example: `{ \"type\": \"manual\" }`\n\n**behaviour**\n\n- `pan-zoom` — Drag the background to pan, wheel to zoom about the pointer. List it last — it is the fallback.\n - Example: `\"behaviours\": [\"pan-zoom\", \"select\", \"expand-on-double-click\"]`\n- `select` — Click to select, shift-click to extend, background to clear. Emits onNodeClick.\n- `drag-node` — Drag a node to move it. Releases on drop by default so the layout stays in charge; pass { pin: true } on a board.\n - pin: boolean — Leave the node pinned where it was dropped.\n - Example: `{ \"type\": \"drag-node\", \"options\": { \"pin\": true } }`\n- `expand-on-double-click` — Double-click a node to expand it. The usual gesture on a map you also want to select on.\n - direction: \"in\" | \"out\" | \"both\"\n- `expand-on-click` — Single click expands — for maps meant purely for exploring, where selection is not needed.\n - direction: \"in\" | \"out\" | \"both\"\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 | SpaceValue | Top offset — space token or CSS length |\n| right | SpaceValue | Right offset — space token or CSS length |\n| bottom | SpaceValue | Bottom offset — space token or CSS length |\n| left | SpaceValue | Left offset — space token or CSS length |\n| zIndex | number | Stack order |\n| display | \"flex\" \\| \"block\" \\| \"inline\" \\| \"inline-block\" \\| \"grid\" \\| \"inline-flex\" | Display mode |\n| flex | string | Flex shorthand (e.g. \"1\", \"0 0 auto\", \"none\") — controls grow/shrink/basis |\n| 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 — a URL, or a CSS gradient (linear-, radial- or conic-gradient, including several comma-separated for a mesh). Sets background-image, defaults background-size to cover, background-position to center, background-repeat to no-repeat. Composes with bg, which paints beneath it |\n| bgFit | \"cover\" \\| \"contain\" | Background image sizing (default: \"cover\") — only meaningful with bgImage |\n| bgPosition | string | Background image position (default: \"center\", e.g. \"top\", \"50% 20%\") — only meaningful with bgImage |\n| bgImageOpacity | number | Fades bgImage only (0–1), independent of the element's own content/opacity — only meaningful with bgImage |\n| bgImageTint | ColorValue | Color bgImage fades toward as bgImageOpacity decreases (default: the element's own `bg`, or neutral-0) — only meaningful with bgImageOpacity |\n| color | ColorValue | Text/foreground color (token) |\n| opacity | number | Opacity (0–1) |\n| border | string | Border shorthand (e.g. \"1px solid neutral-200\" — color tokens are resolved) |\n| borderColor | ColorValue | Border color (token, e.g. \"neutral-200\", \"primary-500\") |\n| borderTop | string | Top border shorthand (color tokens resolved) |\n| borderRight | string | Right border shorthand (color tokens resolved) |\n| borderBottom | string | Bottom border shorthand (color tokens resolved) |\n| borderLeft | string | Left border shorthand (color tokens resolved) |\n| borderWidth | string | Border width (raw CSS, e.g. \"1px\", \"2px 0\") |\n| shadow | \"sm\" \\| \"md\" \\| \"lg\" \\| \"xl\" | Shadow token |\n| cursor | \"pointer\" \\| \"default\" \\| \"text\" \\| \"not-allowed\" | Cursor style |\n| pointerEvents | \"none\" \\| \"auto\" | Pointer events |\n| transform | string | CSS transform |\n| transition | string | CSS transition |\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 keyboard focus (:focus-visible) — deliberately not applied on mouse click. `we-button` and `we-input` already carry a default focus ring; only set this to override it |\n| disabledProps | Partial\\ | Styles when disabled |\n\n### Additional\n\n| Prop | Type | Description |\n|------|------|-------------|\n| styles | Record\\ | Inline CSS applied directly to the component's own element (raw CSS values allowed). For Column, Row, Grid — use this when you need CSS the DS props don't cover. Applied last, so it genuinely overrides a DS prop setting the same property. **Do not confuse with node-level styles** (see Schema Structure) which applies to a wrapper div, not the component. |\n| onClick | ActionToken | Event handler (see dynamic logic) |\n\n---\n\n## Design Tokens\n\nUse design tokens for spacing, color, radius, etc. Do not use raw CSS values unless using the styles prop.\n\nanimation.transition: '0', '100', '200', '300', '400', '500'\n\navatarSize: 'xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'\n\nborder.color: 'base', 'strong'\n\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 - datasetOrder: string [we://dataset_order]\n - globalSpaceJoined: boolean = false [we://global_space_joined]\n - globalSpaceUrl: string [we://global_space_url]\n - useSpaceTemplate: boolean = true [we://use_space_template]\n - themeScope: string [we://theme_scope]\n - installedModules: string [we://installed_modules]\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 - kind: string [we://kind]\n - title: string [we://title]\n - description: string [we://description]\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 - discovery: string = 'hidden' [we://discovery]\n - avatar: string [we://image]\n - coverImage: string [we://thumbnail]\n - defaultTemplateId: string [we://default_template_id]\n - defaultThemeId: string [we://default_theme_id]\n - enabledModules: string [we://enabled_modules]\n Relations:\n - location: HasOne [we://location]\n\nSpacePreference extends WeNode:\n Fields:\n - spaceUuid: string [we://space_uuid]\n - mutedModules: string [we://muted_modules]\n - templateId: string [we://template_id]\n - themeId: string [we://theme_id]\n\nSpaceTemplatePreference extends WeNode:\n Fields:\n - spaceUrl: string [we://space_url]\n - preference: string [we://preference]\n\nTagBlock extends WeNode:\n Fields:\n - name: string (required) [we://name]\n - color: string [we://color]\n - version: number [we://version]\n\nTaskBlock extends WeNode:\n Fields:\n - title: string (required) [we://title]\n - description: string [we://description]\n - status: string = todo [we://status]\n - priority: string = medium [we://priority]\n - dueDate: string [we://due_date]\n - assignee: string [we://assignee]\n - version: number [we://version]\n\nTemplate extends WeNode:\n Fields:\n - name: string [we://name]\n - description: string [we://description]\n - icon: string [we://icon]\n - origin: string [we://origin]\n - version: number = 1 [we://version]\n - slug: string [we://slug]\n - schema: string = null [we://template_schema]\n - themeId: string [we://theme_id]\n 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 - participants: HasMany [we://participants]\n - calls: HasMany [we://call]\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\nAccountStore:\n- State:\n - canManageAccounts: boolean — the host can manage local accounts (false on web). Gate every account control on this\n - accounts: Account[] — local accounts (id, name, avatar, active, hasAgent, sharedWithLauncher). id is the data directory; hasAgent is false for one scaffolded but never set up\n - activeAccount: Account | undefined — the account this app instance is running against. Correct at first paint: the list is seeded from a synchronous cache\n - hasOtherAccounts: boolean — true when there is somewhere else to switch to\n - accountsLoaded: boolean — the host has answered. Without it an empty list reads as a first run and flashes a welcome at a returning user\n - isFirstRun: boolean — nothing has ever been set up on this machine: the host has answered and no account holds an identity yet\n - busy: boolean — a mutation is in flight; a successful one ends in a relaunch\n - switchingTo: Account | null — the account being switched to, from the click until the process goes away\n - creating: boolean — true from the moment a create is requested until the process goes away\n - error: string — the last account error, for display\n - pendingRemoval: Account | null — the account a removal was requested for, awaiting confirmation\n- Actions:\n - refresh(): re-reads the account list from the host\n - createAccount(): creates an account under a provisional name and switches into it — the setup screen names it. Does not return on success\n - syncDisplay({ name?, avatar? }): mirrors the profile onto the running account, so the locked sign-in screen has a name and picture. Never throws\n - switchAccount(id: string): switches to another account. Does not return on success\n - removeAccount(id: string): deletes an account and its data. Refuses the active one\n - requestRemoval(id: string): opens the removal confirmation for that account\n - cancelRemoval(): closes the removal confirmation without deleting\n - confirmRemoval(): deletes the account awaiting confirmation\n - clearError(): clears the error slot\n\nAppStore:\n- State:\n - apps: RegisteredApp[] — list of registered external apps (id, name, image)\n - appsWithWe: 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 - provideInstalledModules(): unknown\n\nDatasetStore:\n- State:\n - datasets: array of dataset handles (all joined datasets; AD4M perspectives in this backend)\n - orderedDatasets: datasets sorted by user-defined sidebar order, system datasets excluded\n - currentDataset: dataset handle | null (the dataset currently being viewed)\n - currentDatasetUri: unknown\n - currentDatasetCid: string | undefined — the neighbourhood CID of the current dataset (prefix stripped)\n - currentDatasetModels: ModelManifestEntry[] (non-WE SHACL models from the current dataset; injected as externalModels into AI messages)\n - isWeSpace: boolean — true once the current dataset is confirmed to have WE's Space SDNA installed (false for a joined-but-foreign dataset, e.g. one synced in from Flux)\n - joinedSpaceCids: string[] — CIDs of every joined shared dataset\n - datasetsLoaded: boolean — the backend has answered with the dataset list. An empty list is otherwise indistinguishable from \"not fetched yet\", so anything asking \"have I joined this?\" reads the boot frame as \"no\". The same reason accountStore.accountsLoaded exists\n - systemDatasetUuids: string[] — uuids of the we-root/we-test system datasets\n - rootDataset: dataset handle | null — the agent's personal root dataset (we-root models live here)\n - testDataset: unknown\n - globalDataset: dataset handle | null — the seed-configured global discovery space, once joined\n - marketplaceDataset: dataset handle | null — the seed-configured marketplace, once joined\n - agentSettings: unknown\n - globalSpaceConfigured: boolean — the seed declares a global space\n - globalSpaceId: string | null — the dataset id of the seed-configured global discovery space, or null when it is not configured or not joined. Compare a route segment against it to tell \"the user is in the global space\" from \"the user is in a space of their own\"\n - marketplaceConfigured: boolean — the seed declares a marketplace\n - marketplaceId: unknown\n - marketplaceJoined: boolean — the marketplace dataset is joined locally\n - getDatasetOrder: unknown\n- Actions:\n - switchDataset(uuid: string): switches to a dataset by UUID, registers its SHACL models as dynamic model classes, and populates currentDatasetModels\n - reorderDatasets(newOrder: string[]): reorders the sidebar items by UUID array\n - removeDataset(): unknown\n - updateAgentSettings(updates: Partial): merges and persists root-dataset agent settings\n - clearCurrentDataset(): unknown\n - cleanupSpaceSdna(uuid?: string): one-time remediation for a space that accumulated duplicate SDNA installs — removes the redundant duplicate link copies. Defaults to the current dataset. Returns a display-ready summary string naming how many links were removed and the DIDs that authored them (your own DID annotated with \"(you)\"), or an empty string if nothing needed cleaning up\n - trackDataset(): unknown\n - onDatasetRemoved(): unknown\n - initSystemDatasets(): unknown\n - loadDatasets(): unknown\n - subscribeToChanges(): unknown\n\nEditorStore:\n- State:\n - messages: unknown\n - isOpen: 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 - contentMode: unknown\n - schemaJson: 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 - isEditingTemplate: unknown\n - editAction: unknown\n - codePanelOpen: unknown\n - themePanelOpen: unknown\n - visualPanelOpen: unknown\n - isEditingTheme: unknown\n - aiPanelWidth: unknown\n - codePanelWidth: unknown\n - themePanelWidth: unknown\n - visualPanelWidth: unknown\n- Actions:\n - newChat(): unknown\n - switchSession(): unknown\n - deleteSession(): unknown\n - setContentMode(): unknown\n - onSchemaEdit(): unknown\n - undo(): undoes the last schema edit\n - redo(): redoes the last undone schema edit\n - pushSnapshot(): unknown\n - startFork(): unknown\n - startFresh(): unknown\n - confirmPicker(): unknown\n - cancelPicker(): unknown\n - enterTemplateEditing(): unknown\n - exitTemplateEditing(): unknown\n - toggle(): toggles the AI chat panel open/closed\n - open(): unknown\n - close(): unknown\n - toggleCodePanel(): unknown\n - openCodePanel(): unknown\n - closeCodePanel(): unknown\n - toggleThemePanel(): unknown\n - openThemePanel(): unknown\n - closeThemePanel(): unknown\n - toggleVisualPanel(): unknown\n - enterThemeEditing(): unknown\n - exitThemeEditing(): unknown\n - toggleThemeEditing(): unknown\n - setAiPanelWidth(): unknown\n - setCodePanelWidth(): unknown\n - setThemePanelWidth(): unknown\n - setVisualPanelWidth(): unknown\n - sendMessage(): unknown\n - clearHistory(): unknown\n - setApiKey(): unknown\n\nPresenceStore:\n- State:\n - peers: unknown\n - online: unknown\n - onlineHere: unknown\n - calls: unknown\n - available: unknown\n - focusDepth: unknown\n- Actions:\n - setFocusDepth(): unknown\n - setAvailability(): unknown\n - setActivity(): unknown\n - clearActivity(): unknown\n\nProfileStore:\n- State:\n - profiles: AgentProfileSummary[] — cache of all fetched profiles (did, firstName, lastName, handle, bio, avatar, coverImage, location)\n - ownProfile: AgentProfileSummary | undefined — reactive accessor for the current user's own profile (derived from the cache)\n - pendingAvatar: unknown\n- Actions:\n - setPendingAvatar(file: File): holds a picture chosen before an agent exists; uploaded by completeAccountSetup\n - completeAccountSetup(name: string, password: string): the whole of first-run setup — creates the agent, then publishes the name and picture, then lets the app appear\n - fetchProfile(did: string): fetches and caches an agent's profile from their public dataset\n - updateOwnProfile(fields: { firstName?, lastName?, handle?, bio? }): updates own profile text fields and publishes to the public dataset\n - updateProfileImage(field: \"avatar\" | \"coverImage\", imageFile: File): uploads the image and publishes its expression URL to the public dataset\n - clearProfileImage(field: \"avatar\" | \"coverImage\"): removes that image from the published profile\n - updateOwnLocation(update: { latitude?, longitude?, city?, country?, countryCode? }): merges the location update into the cache and publishes to the public dataset\n\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 - setNavigateFunction(): unknown\n - setCurrentPath(): unknown\n - navigate(to: string, options?): navigates to a route\n\nRuntimeStore:\n- State:\n - canAdminister: boolean — this backend exposes runtime administration at all\n - canManageTrust: boolean — gate the trusted-agents section on this\n - canManageNetwork: boolean — gate the peer-network section on this\n - canManageApps: boolean — gate the authorized-apps section on this\n - canManageLanguages: boolean — gate the languages section on this\n - canManageAi: boolean — gate the AI section on this\n - canConfigureAi: boolean — the models can be changed, not just listed. False for a guest on somebody else's node, where AD4M grants AI READ but refuses UPDATE/DELETE. Gate add/edit/remove/set-default controls on this and the section itself on canManageAi\n - canConfigureExecutor: boolean — this host starts the backend, so how it starts it can be changed. False on web\n - aiModels: AiModelView[] — installed models, each carrying its display strings (kindLabel, sourceLabel, detail, statusText, ready) alongside id/name/kind/source/isDefault. Empty until loadAiModels() runs\n - aiTasks: AiTask[] — named prompts apps registered against a model (id, name, modelId, systemPrompt)\n - aiForm: AiModelForm | null — the model form while it is open, null when closed. One flat field per input; read with runtimeStore.aiForm.\n - aiPresetOptions: { label, value }[] — model names the backend can fetch itself, for the open form kind\n - aiFormComplete: boolean — the open form has every field its chosen source needs\n - languages: InstalledLanguage[] — language plugins installed in this backend (address, name, system). Empty until loadLanguages() runs\n - trustedAgents: string[] — trusted peer ids. Empty until loadTrustedAgents() runs\n - authorizedApps: AuthorizedApp[] — external apps holding credentials (id, name, description, url, iconUrl, capabilities, revoked). Empty until loadAuthorizedApps() runs\n - networkMetrics: string — backend diagnostic blob, displayed verbatim. Empty until requested\n - peerInfos: string[] — this node peer-discovery records, for out-of-band exchange\n - loading: boolean — true while any runtime call is in flight\n - error: string — the last runtime error, for display\n - canBackUp: boolean — a database export/import can be offered: the backend writes the file and the host can name one. False on web\n - logLevels: { crate, level }[] — per-crate log levels the user has set, sorted. Empty means the backend own defaults are in use\n - backupStatus: string — what the last export or import did, for display. Empty until one runs\n - mcpEnabled: boolean — whether the backend serves MCP on its next start\n - mcpPort: number — the port MCP is served on\n - executorRestartPending: boolean — settings were changed that the running backend has not picked up\n - pendingConsent: ConsentRequest | null — a request awaiting the user's decision (kind: 'capability' | 'trust', title, message, app, peerId)\n - consentSecret: string — a code an approval returned, to be relayed to the asking app\n- Actions:\n - loadAiModels(): fetches the installed AI models and their load status\n - loadAiTasks(): fetches the prompts apps registered against a model\n - newAiModel(): opens the model form empty, for a new model\n - editAiModel(id: string): opens the model form on an existing model\n - setAiFormField(field: string, value: string | boolean): sets one field of the open model form. Takes the field name so one action serves every input\n - closeAiForm(): closes the model form, discarding it\n - saveAiModel(): saves the open form — adds or updates depending on whether it has an id\n - removeAiModel(id: string): deletes a model\n - setDefaultAiModel(id: string): makes this the model apps get when they ask for its kind\n - removeAiTask(id: string): deletes a registered prompt\n - loadLanguages(): fetches the installed languages\n - installLanguage(address: string): installs a language by content address, then reloads the list\n - removeLanguage(address: string): removes an installed language. Refuses the backend own system languages\n - loadTrustedAgents(): fetches the trusted-agent list\n - trustAgent(id: string): trusts a peer, then reloads the list\n - untrustAgent(id: string): untrusts a peer, then reloads the list\n - loadAuthorizedApps(): fetches apps holding credentials against this agent\n - revokeApp(id: string): invalidates an app's tokens, keeping the grant listed\n - removeApp(id: string): forgets the grant entirely\n - loadNetworkMetrics(): fetches the diagnostic blob\n - restartNetwork(): restarts the peer-networking layer\n - loadPeerInfos(): fetches this node peer-discovery records\n - addPeerInfos(text: string): adds pasted peer records (JSON array or one per line)\n - setMcpEnabled(enabled: boolean): turns MCP on or off for the backend next start\n - setLogLevel(crate: string, level: string): sets one crate log level — adds it when not already set, so there is no separate add. Levels: error, warn, info, debug, trace\n - removeLogLevel(crate: string): drops an override, returning that crate to the backend default\n - exportDatabase(): asks for a file, then has the backend write everything to it\n - importDatabase(): asks for a file, then has the backend read it back in\n - setMcpPort(port: number): sets the MCP port. The host refuses one outside 1024-65535\n - restartExecutor(): starts the backend over so written settings take effect. Does not return\n - approveConsent(): grants the pending request\n - denyConsent(): declines the pending request\n - dismissConsentSecret(): clears the confirmation code display\n\nSessionStore:\n- State:\n - bootState: string — 'initialising' | 'login' | 'createAgent' | 'finishing' | 'ready' | 'error'\n - bootError: string — why the boot failed, when bootState is 'error'. Empty otherwise\n - passwordError: boolean — true after a failed unlock attempt\n - loginLoading: boolean\n - createAgentError: string — the backend message from a failed agent creation, or empty\n - createAgentLoading: boolean\n - client: the backend client handle | undefined\n - agentSession: unknown\n - lifecycle: unknown\n - backendPorts: unknown\n - me: Agent | undefined — the authenticated identity; prefer the $me token in schemas\n - port: unknown\n - token: unknown\n - serverUrl: unknown\n - host: BackendHostInfo | undefined — the node this session runs against when it is somebody's hosting rather than this machine (id, name, description, imageUrl, location, url, computeSpecs, aiModels, rates). Undefined on desktop and on a local executor, so its presence is also the answer to \"am I a guest here?\" — gate any \"connected to\" UI on it. `aiModels` comes from the host directory and needs no capability, so it answers \"can this node transcribe?\" even where the executor refuses to list its models\n - hostAccount: BackendAccountInfo | undefined — this agent's account with that node (email, remainingCredits, walletAddress, freeAccess). Check freeAccess before showing a balance: on a free node the credit figure means nothing and \"0\" reads as an account that has run dry\n - isDevelopment: unknown\n - ephemeralPort: unknown\n- Actions:\n - login(password: string): unlocks the agent and loads user data\n - createAgent(password: string): creates the agent, loads user data, and lands on the 'finishing' boot state (not 'ready')\n - clearPasswordError(): clears the failed-unlock flag. Chain it after the password field's $setLocal — the verdict was on the submitted password, so editing that password retracts it and a stale \"Incorrect password\" should not sit over the correction\n - finishSetup(): leaves 'finishing' for the running app — sets bootState to 'ready'\n - logout(): locks the agent and returns to the login screen\n - retryBoot(): starts the whole boot again from the failure screen, by reloading. A failed boot can have got anywhere before it threw, so retrying in place would race the remains of the first attempt\n - refreshMe(): unknown\n - markReady(): unknown\n - onSessionUnlocked(): unknown\n\nShellStore:\n- State:\n - activeShellView: string | null — id of the currently open shell overlay ('profile' | 'settings' | 'schema-tests' | 'landing-page'), or null\n - takePendingPath: unknown\n - createSpaceOpen: unknown\n - dockGeometry: unknown\n - contentInset: unknown\n - dockResizing: unknown\n- Actions:\n - openShellView(id: string, path?: string): opens a shell overlay by id, optionally at a route inside it — the overlay keeps its own memory router, so this never touches the browser URL\n - closeShellView(): closes the currently open shell overlay\n - setCreateSpaceOpen(open: boolean): opens or closes the create-space modal. Shell state rather than a page’s $localState because more than one place opens it — the settings page and the sidebar’s spaces group — and a page-scoped flag could only be set from inside that page\n - scrollToId(id: string): smooth-scrolls the element with that DOM id into view\n - beginDockResize(): unknown\n - resizeDock(): unknown\n - endDockResize(): unknown\n\nSpaceStore:\n- State:\n - memberDids: string[] — DIDs of all members in the current space (includes own DID)\n - members: AgentProfileSummary[] — cached profiles for all memberDids\n - spaceDefaultTemplateId: string — the current space's default template ID (empty string when no space is active)\n - spaceDefaultThemeId: string — the current space's default theme ID (empty string when no space is active). The counterpart to spaceDefaultTemplateId; compare against it to mark which theme a space is currently on\n - currentSpace: Space | null — the current space model (all Space fields: uuid, url, name, description, access, discovery, avatar, coverImage, defaultTemplateId, defaultThemeId, location, plus id/author/createdAt)\n - mySpaces: array of Space objects — every space the agent holds, across all joined datasets\n - personalSpaces: array of Space objects (local/personal spaces; all Space fields)\n - sharedSpaces: array of Space objects (shared/neighbourhood spaces; all Space fields)\n - spaceList: { uuid, name, description, avatar, kind: 'shared' | 'personal' | 'foreign', isWeSpace, canAdminister }[] — one row per joined dataset the agent can act on, ordered like the sidebar and excluding the system datasets. Includes datasets that are not WE spaces (kind 'foreign', isWeSpace false), which are waiting to be initialized. `uuid` is the dataset id, so it keys navigation and settings whether or not a Space record exists\n - routeSpaceUnjoined: boolean — the current route points at a space this agent has not joined, as a settled fact. What a join gate should read: `currentDataset` being null is also true for the first frames of a refresh, so gating on that flashes a join prompt at someone already inside. False while the answer is still unknown\n - creatingSpace: boolean (true while a new space is being created)\n - joiningSpace: string — the shared id of the space a join is running for, '' when none is. The id rather than a flag so a list can spin only the row being joined; a gate compares it against its own route segment. Stays set for the whole join, which outlives the network call that starts it\n - joinSlow: boolean — that join has been going long enough to be worth mentioning. Joining a shared space has to fetch and install it before it exists anywhere, so a first join routinely takes a minute; pair with joiningSpace to say so instead of spinning in silence\n - joinError: { spaceId, message } | null — the last join failure, ready to display. Carries the space so a gate can tell whether the failure is its own: compare joinError.spaceId against the route segment, or a bare message follows the user to the next unjoined space they open\n - orderedSidebarItems: array of sidebar items in user-defined order (uuid, name, avatar, spaceId) — personal + shared spaces merged\n - foreignSpacePrefill: { name, description, avatar } | null — detected from a foreign app's own model (e.g. Flux's Community) for prefilling the \"Initialize as WE space\" gate; null once the perspective is a WE space or no recognized foreign model is found\n - enabledModules: string[] — ids of the feature modules THIS SPACE has turned on: the community’s decision, shared with every member. An unset value means \"not decided\", not \"none\": it falls back to every registered module, so spaces predating the setting keep the chrome they had\n - templateOverrideOptions: { label, value }[] — options for the per-space template override picker: \"Use the space’s default\" (space-default), \"Use my default\" (agent-default), then every template. Each of the first two names what it resolves to. Pre-built because a schema can $map a store array into options but cannot prepend one, and without those entries overriding would be one-way\n - themeOverrideOptions: { label, value }[] — the same, for themes\n - installedModules: string[] — ids of the feature modules THIS AGENT wants available anywhere. Personal, held in the root dataset; unset means \"not decided\" and falls back to every registered module\n - requiredModules: string[] — module ids the template on screen mounts components from, derived by walking the schema rather than read from meta.components (which no template fills in). What makes uninstalling a capability module refusable\n - missingModules: string[] — of those, the ones this agent has not installed. Non-empty means the template is mounting a component nothing provides, so part of the page silently renders nothing. Empty in the ordinary case\n - activeModules: string[] — what actually renders here for this agent: registered ∩ installed ∩ enabled, less the modules muted in this space. Module chrome and the launcher rail gate on this; enabledModules alone is not sufficient\n - moduleInstallSettings: { id, name, description, icon, installed, surface, switchable }[] — every registered module and whether this agent wants it anywhere. The global Settings → Modules list, and the only place an 'app' or 'capability' module is decided about: a contribution is gated at the layer where it renders, and only 'chrome' renders inside a space. `surface` is derived from what the module contributes. Its per-space counterpart is `modules` on each spaceList row, which carries enabled/installed/visible/active together and lists chrome modules only\n - moduleLaunchers: { id, icon, label, active }[] — launchers for the modules enabled here and available in this space; what the host module rail renders. Pair with { $action: \"spaceStore.launchModule\", args: [\"$mod.id\"] }\n- Actions:\n - createSpace(name, description, access: 'personal' | 'shared', discovery: 'hidden' | 'listed', avatarFile?, coverImageFile?, location?): creates a new space with full setup\n - joinSpace(id: string, focus = true): joins a shared space by share link, neighbourhood URL or CID, or focuses it if already joined. Pass focus: false to join without navigating there — for a caller that needs the dataset present rather than open, which is how the marketplace reads its own dataset without moving you out of the space you are in. Rejects when the join could not be completed, so onSuccess means what it says; watch joiningSpace/joinSlow/joinError for what to show while it runs. A join whose network call times out keeps going: the backend usually finishes anyway, and this waits for that before believing the failure\n - initializeAsWeSpace(name: string, description: string, avatarValue?: File | string | null): installs WE's Space SDNA into the current, already-joined, foreign-native dataset (e.g. one synced in from Flux) and creates a Space entity in place — access is always 'shared' since the dataset is already a published neighbourhood\n - removeSpace(uuid: string): removes a space — clears its global-discovery listing (when authored by this agent) and removes the backing dataset\n - createPost(editorState: unknown): creates a new post\n - updatePost(postId: string, editorState: unknown): reconciles an edited post against its existing blocks — updates/reuses blocks whose id survived the edit, creates new ones, deletes ones no longer present\n - deleteCollection(collectionId: string): permanently deletes a CollectionBlock and everything inside it, recursively. Kind-agnostic — a post, a call record and a notes collection are the same shape, so this is the one delete for all of them\n - updateSpaceImage(field: \"avatar\" | \"coverImage\", imageFile: File, spaceUuid?): uploads and sets the space avatar or cover image\n - updateSpaceMeta(updates: { name?, description?, discovery?, location? }, spaceUuid?): updates the space everyone sees. Omit spaceUuid to target the space on screen; pass one to configure a space from the spaces list without navigating to it\n - setSpaceDefaultTemplate(templateId: string, spaceUuid?): sets the template members see when they enter that space. Only repaints the app when the target is the space currently on screen\n - setSpaceDefaultTheme(themeId: string, spaceUuid?): sets the theme members see when they enter that space\n - setModuleEnabled(moduleId: string, enabled: boolean, spaceUuid?): turns a feature module on or off for a space; writes the resolved list, so the first toggle also pins whatever was on by fallback. Omit spaceUuid for the space on screen\n - setModuleInstalled(moduleId: string, installed: boolean): turns a module on or off for this agent in every space. Personal — writes AgentSettings.installedModules in the root dataset, so no other member sees it\n - setModuleVisible(moduleId: string, visible: boolean, spaceUuid?): shows or hides a module for this agent in one space, without changing what the community runs. Private: written to the root dataset, never to the space. Phrased positively so a switch can pass `$event.detail` bare — wrapping it in an operator such as `$not` would evaluate at render time and send a constant\n - setSpaceTemplateOverride(templateId: string, spaceUuid?): sets the template THIS AGENT sees in one space, overriding the community's default. Three values: 'space-default' follows the space, 'agent-default' follows your own global default (tracking later changes to it), or a concrete template id pins that one. Private, and applied immediately when that space is the one on screen. Note the sentinels are named values, not '' — the ORM skips empty strings on update, so '' cannot clear a property\n - setSpaceThemeOverride(themeId: string, spaceUuid?): sets the theme THIS AGENT sees in one space. Same three values as setSpaceTemplateOverride. Private\n - launchModule(moduleId: string): invokes that module's declared launcher action. Takes an id rather than a path because $action resolves a literal string, so a rail iterating over modules cannot build modules.. itself\n - createSignalType(config: Partial): creates a new signal type in the community; slug auto-derived from name if blank\n - 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 - canAdministerSpace(uuid: string): whether this agent may change what every member of that space sees — true for a personal space, and for a shared one they authored. A UI affordance for deciding whether to offer the controls, NOT enforcement: a shared space is a neighbourhood every member can write to. Ask by name rather than comparing author to $me.did, so the answer can grow (multiple admins, roles) without every template changing\n - copyShareLink(uuid: string): copies that space's share link to the clipboard, with a toast either way. No-op for a personal space, which has no global id and so no shareable link — read `spaceList[].shareLink` to decide whether to offer the control at all\n - getSubgroupMessages(subgroupId: string): messages belonging to one of Flux's conversation subgroups, fetched on demand. A dialect query against a foreign schema rather than a WE model, so it goes through the backend's interop surface instead of $query — which is why it is a store method and not a relation you can drill into\n - removeSpaceFromGlobal(): unknown\n - updateSpaceInCache(): unknown\n - loadSpaces(): unknown\n - test(): unknown\n\nTemplateStore:\n- State:\n - personalTemplates: array of TemplateSchema objects — core templates plus user's installed custom templates (excludes space templates)\n - spaceTemplates: array of TemplateSchema objects — templates loaded from the current space perspective\n - builtInTemplates: array of TemplateSchema objects — built-in system templates (always available)\n - myTemplates: array of TemplateSchema objects — user's installed custom templates only (excludes built-in and space templates)\n - allTemplates: array of TemplateSchema objects — union of built-in + personal + space templates\n - templateManagementList: TemplateManagementItem[] — flat list of all templates with management metadata (id, name, icon, description, isBuiltIn, isInstalled, isDefault)\n - switcherGroups: TemplateSwitcherGroup[] — pre-grouped flat items for the template switcher UI; each group has { label: string, items: { id, name, icon }[] }. Groups: \"Space templates\", \"My templates\", \"Built-in\". Use $filter where: { name: { contains: ... } } for search since items have a flat name field.\n - currentTemplate: TemplateSchema (the active template)\n - loading: unknown\n - defaultTemplateId: unknown\n - operationLoading: unknown\n- Actions:\n - provideSpaceLookup(): unknown\n - updateTemplate(newTemplate: TemplateSchema): updates the current template\n - replaceTemplate(): unknown\n - switchTemplate(newTemplateId: string): switches to another template\n - removeTemplate(): removes the current template\n - deleteTemplate(): unknown\n - installTemplate(): unknown\n - uninstallTemplate(): unknown\n - installFromMarketplace(): unknown\n - installToSpace(marketplaceTemplateId: string): copies a marketplace template into the current space, so every member of that community gets it — as opposed to installing it for yourself. Pair with templateStore.operationLoading to show progress on the row being installed\n - toggleInstalled(): unknown\n - setDefaultTemplate(): unknown\n - saveTemplate(name: string): saves the current template\n - saveTemplateAs(): unknown\n - publishToSpace(): unknown\n - deleteMarketplaceTemplate(): unknown\n - publishToMarketplace(): unknown\n - persistCurrentTemplate(): unknown\n - preloadSpaceTemplates(): unknown\n - loadSpaceTemplates(): unknown\n - refreshSpaceTemplates(): unknown\n - clearSpaceTemplates(): unknown\n - isBuiltInTemplate(): unknown\n - isInstalled(): unknown\n - getTemplateModel(): unknown\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 - editingTheme: unknown\n - operationLoading: string | null — the id of the theme operation currently in flight, namespaced by kind (e.g. 'marketplace-install:'), or null when idle. A key rather than a boolean so one row's spinner does not appear on every row — compare it against the row you are rendering\n - themeScope: unknown\n - themeScopePreference: unknown\n - themeScopeGlobal: unknown\n - themeScopePreviewing: unknown\n - activeTemplateTheme: unknown\n - saveEditingTheme: unknown\n- Actions:\n - registerHistoryCallbacks(): unknown\n - applySnapshot(): unknown\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 - previewThemeScope(scope: 'global' | 'scoped' | null): previews a scope for the current theme-editing session without writing the preference; null drops the preview. Cleared when editing ends\n - setThemeScopeGlobal(global: boolean): persists whether a space's theme covers the whole window (true) or only the space's own content (false, the default). Takes a boolean because a switch emits one and a schema cannot map it to a string — `$if` in an action's args resolves at render time, before the event exists\n - replaceTheme(): unknown\n - restorePersonalTheme(): unknown\n - clearSpaceTheme(): unknown\n - startEditing(): unknown\n - changeBasePreset(): unknown\n - updateEditingOverrides(): unknown\n - updateEditingCss(): unknown\n - updateEditingMeta(): unknown\n - cancelEditing(): unknown\n - createAndStartEditing(): unknown\n - saveEditingThemeAs(): unknown\n - deleteTheme(themeId: string): permanently deletes a custom theme\n - installFromMarketplace(marketplaceThemeId: string): installs a marketplace theme into installedThemes\n - uninstallTheme(themeId: string): removes an installed theme (deletes the model)\n - deleteMarketplaceTheme(): unknown\n - publishToMarketplace(): unknown\n - publishToSpace(): unknown\n - loadInstalledThemes(): unknown\n\nModel:\n- State:\n- Actions:\n - create(): unknown\n - update(): unknown\n - delete(): unknown\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\nFeature-module stores:\n{ \"$store\": \"modules..\" } and { \"$action\": \"modules..\" }\nEach installed feature module publishes its store under its own id — modules.call.tiles,\nmodules.notes.open, modules.transcribe.pending. Which ids exist depends on the deployment's seed,\nso these are not listed in the Stores section below and are never checked against a known-member\nlist. A reference to a module that is not installed simply resolves to nothing.\n\nIterating over store data:\n{\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$store\": \"spaceStore.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 }\n }]\n },\n {\n \"type\": \"we-button\",\n \"props\": {\n \"text\": \"Submit\",\n \"loading\": { \"$local\": \"loading\" },\n \"disabled\": { \"$local\": \"loading\" },\n \"onClick\": [\n { \"$touch\": \"$all\" },\n { \"$if\": { \"condition\": { \"$formValid\": \"$scope\" }, \"then\": { \"$action\": \"myStore.submit\", \"args\": [{ \"$local\": \"name\" }] } } }\n ]\n }\n }\n ]\n}\nThe button is disabled only while the submit is in flight. Disabling it on { \"$not\": { \"$formValid\": \"$scope\" } }\ninstead contradicts the { \"$touch\": \"$all\" } beneath it — the button is unclickable in exactly the state that\nguard exists to report. See the \"Typical form pattern\" section for the full rationale and the two valid shapes.\n\nRepeating lists with $each:\nALWAYS use $each for lists of similar items — never duplicate the same node structure.\nWrite the template once; $each renders it for each item.\n\nUse literal arrays for fixed/sample data:\n{\n \"type\": \"$each\",\n \"props\": {\n \"items\": [\n { \"title\": \"First Post\", \"text\": \"Hello world.\", \"author\": \"Alice\" },\n { \"title\": \"Second Post\", \"text\": \"Another update.\", \"author\": \"Bob\" }\n ],\n \"as\": \"post\"\n },\n \"children\": [\n {\n \"type\": \"Column\",\n \"props\": { \"bg\": \"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.\nResolve them by slug from a hoisted $queries subscription on the node.\n\nThere is no store accessor for this. spaceStore.signalTypesBySlug existed once and was removed;\nschemas still referencing it filtered on undefined — a like count that silently counted the wrong\nthing. Query the SignalType entity instead, and look the slug up with $find.\n\nALWAYS ask the user: \"What slug should I use? (e.g. 'like', 'upvote', 'star')\"\nThen use that slug in the pattern below.\n\nPattern — live wired SignalControl (one hoisted query, reused by the projection and the control):\n{\n \"$queries\": { \"signalTypes\": { \"entity\": \"SignalType\", \"subscribe\": true } },\n \"type\": \"Column\",\n \"children\": [\n {\n \"type\": \"$each\",\n \"props\": {\n \"items\": {\n \"$query\": {\n \"entity\": \"MyBlock\",\n \"include\": {\n \"signals\": true,\n \"$totalLikeCount\": {\n \"from\": \"signals\",\n \"where\": {\n \"signalTypeId\": { \"$find\": { \"items\": { \"$local\": \"signalTypes\" }, \"where\": { \"slug\": \"like\" }, \"select\": \"id\" } }\n },\n \"count\": true\n }\n }\n }\n },\n \"as\": \"item\"\n },\n \"children\": [\n {\n \"type\": \"$if\",\n \"props\": {\n \"condition\": { \"$count\": { \"items\": { \"$local\": \"signalTypes\" } } },\n \"then\": {\n \"type\": \"$each\",\n \"props\": { \"items\": { \"$local\": \"signalTypes\" }, \"as\": \"sig\" },\n \"children\": [\n {\n \"type\": \"SignalControl\",\n \"props\": {\n \"signalType\": \"$sig\",\n \"signals\": { \"$filter\": { \"items\": \"$item.signals\", \"where\": { \"signalTypeId\": \"$sig.id\" } } },\n \"myDid\": \"$me.did\",\n \"onSignal\": { \"$action\": \"spaceStore.upsertSignal\", \"args\": [\"$item.id\", \"$sig.id\", \"$arg\"] }\n }\n }\n ]\n }\n }\n }\n ]\n }\n ]\n}\n\nNotes:\n- $queries and $localState share one $local namespace, so { \"$local\": \"signalTypes\" } reads the\n subscription from any descendant — the projection above and the controls below stay in agreement\n about which type a slug means.\n- The $count guard renders nothing until the community has created a signal type.\n- Iterating signalTypes renders every type the community defined; use $find with a slug only where\n one specific type is meant (e.g. a like count).\n- Replace \"like\" with the user's slug.\n- $query include adds $totalLikeCount as a computed property on each item.\n- signalType prop accepts the full SignalType object (provides icon, mode, range to the UI component).\n\nPreview / mockup mode (static, no store wiring):\n{\n \"type\": \"SignalControl\",\n \"props\": {\n \"preview\": true,\n \"signalType\": { \"icon\": \"❤️\", \"mode\": \"toggle\", \"rangeMin\": 0, \"rangeMax\": 1 }\n }\n}\nUse preview: true when sketching a layout without real data. Remove it (and add the full wiring above) when going live.\n\n---\n\n## 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 keyboard-focus state. Supported on @we/primitives (we-text, we-button, etc.) and layout components (Column, Row). focusProps fires on `:focus-visible` (keyboard), not on mouse click. Do not add a focus ring by hand — `we-button` and `we-input` already have one, themeable via the `ringColor` theme key.\n- Use 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- To make a block of content clickable **without any button appearance**, use `we-button` with `variant: 'bare'` — never a `Column`/`Row` with an `onClick`. `bare` is the appearance-free variant: no background, no hover, no padding, no radius, inherited colour — but still a real ` -
-
- {props.renderContent ? props.renderContent(item, i()) : item.content} -
-
- - ); - }} - - - ); -} diff --git a/packages/design-system/4-components/src/components/data/Accordion/Accordion.types.ts b/packages/design-system/4-components/src/components/data/Accordion/Accordion.types.ts deleted file mode 100644 index b17d1a14c..000000000 --- a/packages/design-system/4-components/src/components/data/Accordion/Accordion.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface AccordionItem { - id?: string; - title: string; - content?: string; - disabled?: boolean; -} - -export interface AccordionProps { - items?: AccordionItem[]; - multiple?: boolean; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/components/data/List/List.scss b/packages/design-system/4-components/src/components/data/List/List.scss deleted file mode 100644 index bab63a1ee..000000000 --- a/packages/design-system/4-components/src/components/data/List/List.scss +++ /dev/null @@ -1,24 +0,0 @@ -.we-list { - display: flex; - flex-direction: column; - margin: 0; - padding: 0; - list-style: none; - - &--ordered { - padding-left: 1.5em; - list-style: decimal; - } -} - -.we-list-item { - display: flex; - align-items: center; - gap: var(--we-space-200); - padding: var(--we-space-200) 0; - - &__content { - display: flex; - flex-direction: column; - } -} diff --git a/packages/design-system/4-components/src/components/data/List/List.solid.tsx b/packages/design-system/4-components/src/components/data/List/List.solid.tsx deleted file mode 100644 index 0f8b09670..000000000 --- a/packages/design-system/4-components/src/components/data/List/List.solid.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { For, type JSX } from 'solid-js'; - -export type * from './List.types'; -import type { ListItem, ListProps } from './List.types'; - -interface SolidListProps extends ListProps { - children?: JSX.Element; - renderItem?: (item: ListItem, index: number) => JSX.Element; -} - -function DefaultListItem(props: { item: ListItem }) { - return ( -
- {props.item.icon && } -
- {props.item.label} - {props.item.description && ( - - {props.item.description} - - )} -
-
- ); -} - -export function List(props: SolidListProps) { - const gap = () => props.gap || 'var(--we-space-100)'; - const className = () => `we-list${props.ordered ? ' we-list--ordered' : ''}`; - - // Dynamic-only styles: gap (prop-driven) + consumer overrides - const dynamicStyles = () => ({ gap: gap(), ...props.styles }); - - // If children are provided, render them directly - if (props.children) { - const Tag = props.ordered ? 'ol' : 'ul'; - return ( - - {props.children} - - ); - } - - // Render from items array - const Tag = props.ordered ? 'ol' : 'ul'; - return ( - - - {(item, i) =>
  • {props.renderItem ? props.renderItem(item, i()) : }
  • } -
    -
    - ); -} diff --git a/packages/design-system/4-components/src/components/data/List/List.types.ts b/packages/design-system/4-components/src/components/data/List/List.types.ts deleted file mode 100644 index 6cedb7cbf..000000000 --- a/packages/design-system/4-components/src/components/data/List/List.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ListItem { - id?: string; - label: string; - description?: string; - icon?: string; -} - -export interface ListProps { - items?: ListItem[]; - ordered?: boolean; - gap?: string; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/components/data/Table/Table.scss b/packages/design-system/4-components/src/components/data/Table/Table.scss deleted file mode 100644 index 614164f4c..000000000 --- a/packages/design-system/4-components/src/components/data/Table/Table.scss +++ /dev/null @@ -1,26 +0,0 @@ -.we-table { - width: 100%; - border-collapse: collapse; - font-family: var(--we-font-family); - font-size: var(--we-font-size-400); - - th { - text-align: left; - padding: var(--we-space-200) var(--we-space-300); - border-bottom: 2px solid var(--we-color-neutral-200); - font-weight: var(--we-font-weight-semibold); - color: var(--we-color-neutral-700); - } - - td { - padding: var(--we-space-200) var(--we-space-300); - } - - &--bordered td { - border-bottom: 1px solid var(--we-color-neutral-200); - } - - &--striped tbody tr:nth-child(even) { - background-color: var(--we-color-neutral-50); - } -} diff --git a/packages/design-system/4-components/src/components/data/Table/Table.solid.tsx b/packages/design-system/4-components/src/components/data/Table/Table.solid.tsx deleted file mode 100644 index 9c90264ef..000000000 --- a/packages/design-system/4-components/src/components/data/Table/Table.solid.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { For, type JSX } from 'solid-js'; - -export type * from './Table.types'; -import type { TableColumn, TableProps } from './Table.types'; - -interface SolidTableProps extends TableProps { - renderCell?: (row: Record, column: TableColumn, index: number) => JSX.Element; -} - -export function Table(props: SolidTableProps) { - const className = () => { - let cls = 'we-table'; - if (props.striped) cls += ' we-table--striped'; - if (props.bordered) cls += ' we-table--bordered'; - return cls; - }; - - return ( - - - - - {(col) => } - - - - - - {(row, rowIndex) => ( - - - {(col) => ( - - )} - - - )} - - -
    {col.header}
    {props.renderCell ? props.renderCell(row, col, rowIndex()) : (row[col.key] as string)}
    - ); -} diff --git a/packages/design-system/4-components/src/components/data/Table/Table.types.ts b/packages/design-system/4-components/src/components/data/Table/Table.types.ts deleted file mode 100644 index 21191fba8..000000000 --- a/packages/design-system/4-components/src/components/data/Table/Table.types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface TableColumn { - key: string; - header: string; - width?: string; -} - -export interface TableProps { - columns: TableColumn[]; - rows: Record[]; - striped?: boolean; - bordered?: boolean; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/components/data/Timeline/Timeline.scss b/packages/design-system/4-components/src/components/data/Timeline/Timeline.scss deleted file mode 100644 index 0ff0180fc..000000000 --- a/packages/design-system/4-components/src/components/data/Timeline/Timeline.scss +++ /dev/null @@ -1,74 +0,0 @@ -.we-timeline { - display: flex; - flex-direction: column; - position: relative; - - &__item { - display: flex; - gap: var(--we-space-300); - position: relative; - padding-bottom: var(--we-space-500); - - &:last-child { - padding-bottom: 0; - } - } - - &__indicator { - display: flex; - flex-direction: column; - align-items: center; - flex-shrink: 0; - } - - &__dot { - width: 12px; - height: 12px; - border-radius: var(--we-radius-full); - background: var(--we-color-primary-500); - border: 2px solid var(--we-color-neutral-0); - box-shadow: 0 0 0 2px var(--we-color-primary-200); - z-index: 1; - flex-shrink: 0; - - &--icon { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: var(--we-color-primary-100); - color: var(--we-color-primary-600); - } - } - - &__line { - width: 2px; - flex: 1; - background: var(--we-color-neutral-200); - margin-top: var(--we-space-100); - } - - &__content { - display: flex; - flex-direction: column; - gap: var(--we-space-50); - padding-top: 0; - } - - &__label { - font-weight: 500; - color: var(--we-color-neutral-800); - font-size: 0.875rem; - } - - &__description { - color: var(--we-color-neutral-600); - font-size: 0.8125rem; - } - - &__timestamp { - color: var(--we-color-neutral-400); - font-size: 0.75rem; - } -} diff --git a/packages/design-system/4-components/src/components/data/Timeline/Timeline.solid.tsx b/packages/design-system/4-components/src/components/data/Timeline/Timeline.solid.tsx deleted file mode 100644 index 4e8c1c607..000000000 --- a/packages/design-system/4-components/src/components/data/Timeline/Timeline.solid.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { For, Show } from 'solid-js'; - -export type * from './Timeline.types'; -import type { JSX } from 'solid-js'; - -import type { TimelineItem, TimelineProps } from './Timeline.types'; - -interface SolidTimelineProps extends TimelineProps { - children?: JSX.Element; - renderItem?: (item: TimelineItem, index: number) => JSX.Element; -} - -export function Timeline(props: SolidTimelineProps) { - const items = () => props.items || []; - - return ( -
    - - {(item, i) => ( -
    -
    -
    - {item.icon && } -
    - {i() < items().length - 1 &&
    } -
    -
    - {props.renderItem ? ( - props.renderItem(item, i()) - ) : ( - <> - {item.label} - - {item.description} - - - {item.timestamp} - - - )} -
    -
    - )} - -
    - ); -} diff --git a/packages/design-system/4-components/src/components/data/Timeline/Timeline.types.ts b/packages/design-system/4-components/src/components/data/Timeline/Timeline.types.ts deleted file mode 100644 index f7ee43c10..000000000 --- a/packages/design-system/4-components/src/components/data/Timeline/Timeline.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface TimelineItem { - label: string; - description?: string; - icon?: string; - timestamp?: string; -} - -export interface TimelineProps { - items?: TimelineItem[]; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.scss b/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.scss deleted file mode 100644 index 92d4bb7af..000000000 --- a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.scss +++ /dev/null @@ -1,35 +0,0 @@ -.we-breadcrumbs { - display: flex; - align-items: center; - gap: var(--we-space-200); - list-style: none; - margin: 0; - padding: 0; - font-size: 0.875rem; - - &__item { - display: flex; - align-items: center; - gap: var(--we-space-100); - } - - &__link { - color: var(--we-color-neutral-500); - text-decoration: none; - transition: color 0.15s ease; - - &:hover { - color: var(--we-color-primary-600); - } - } - - &__current { - color: var(--we-color-neutral-800); - font-weight: 500; - } - - &__separator { - color: var(--we-color-neutral-400); - user-select: none; - } -} diff --git a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.solid.tsx b/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.solid.tsx deleted file mode 100644 index 780b646c0..000000000 --- a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.solid.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { For } from 'solid-js'; - -export type * from './Breadcrumbs.types'; -import type { BreadcrumbItem, BreadcrumbsProps } from './Breadcrumbs.types'; - -interface SolidBreadcrumbsProps extends BreadcrumbsProps { - onNavigate?: (item: BreadcrumbItem, index: number) => void; -} - -export function Breadcrumbs(props: SolidBreadcrumbsProps) { - const items = () => props.items || []; - const separator = () => props.separator || '/'; - - return ( - - ); -} diff --git a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts b/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts deleted file mode 100644 index 6b600bdd4..000000000 --- a/packages/design-system/4-components/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface BreadcrumbItem { - label: string; - href?: string; - icon?: string; -} - -export interface BreadcrumbsProps { - items?: BreadcrumbItem[]; - separator?: string; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.scss b/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.scss deleted file mode 100644 index 495206a11..000000000 --- a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.scss +++ /dev/null @@ -1,89 +0,0 @@ -.we-stepper { - display: flex; - align-items: flex-start; - - &--vertical { - flex-direction: column; - } - - &__step { - display: flex; - align-items: center; - gap: var(--we-space-200); - flex: 1; - - .we-stepper--vertical & { - flex-direction: row; - flex: initial; - } - } - - &__indicator { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--we-radius-full); - font-size: 0.875rem; - font-weight: 600; - flex-shrink: 0; - transition: - background 0.2s ease, - color 0.2s ease; - - &--completed { - background: var(--we-color-primary-500); - color: white; - } - - &--active { - background: var(--we-color-primary-100); - color: var(--we-color-primary-700); - border: 2px solid var(--we-color-primary-500); - } - - &--pending { - background: var(--we-color-neutral-100); - color: var(--we-color-neutral-500); - } - } - - &__info { - display: flex; - flex-direction: column; - } - - &__label { - font-size: 0.875rem; - font-weight: 500; - color: var(--we-color-neutral-800); - } - - &__description { - font-size: 0.75rem; - color: var(--we-color-neutral-500); - } - - &__connector { - flex: 1; - height: 2px; - margin: 0 var(--we-space-200); - align-self: center; - - .we-stepper--vertical & { - width: 2px; - height: 24px; - margin: var(--we-space-100) 0 var(--we-space-100) 15px; - flex: initial; - } - - &--completed { - background: var(--we-color-primary-500); - } - - &--pending { - background: var(--we-color-neutral-200); - } - } -} diff --git a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.solid.tsx b/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.solid.tsx deleted file mode 100644 index f509a2abe..000000000 --- a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.solid.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { For, Show } from 'solid-js'; - -export type * from './Stepper.types'; -import type { StepperProps } from './Stepper.types'; - -interface SolidStepperProps extends StepperProps { - onStepClick?: (index: number) => void; -} - -export function Stepper(props: SolidStepperProps) { - const steps = () => props.steps || []; - const activeStep = () => props.activeStep ?? 0; - const orientation = () => props.orientation || 'horizontal'; - - const indicatorClass = (i: number) => { - if (i < activeStep()) return 'we-stepper__indicator--completed'; - if (i === activeStep()) return 'we-stepper__indicator--active'; - return 'we-stepper__indicator--pending'; - }; - - return ( -
    - - {(step, i) => ( - <> -
    -
    props.onStepClick?.(i())}> - {i() < activeStep() ? ( - - ) : step.icon ? ( - - ) : ( - i() + 1 - )} -
    -
    - {step.label} - - {step.description} - -
    -
    - {i() < steps().length - 1 && ( -
    - )} - - )} - -
    - ); -} diff --git a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.types.ts b/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.types.ts deleted file mode 100644 index dc544d686..000000000 --- a/packages/design-system/4-components/src/components/navigation/Stepper/Stepper.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface StepperStep { - label: string; - description?: string; - icon?: string; -} - -export interface StepperProps { - steps?: StepperStep[]; - activeStep?: number; - orientation?: 'horizontal' | 'vertical'; - styles?: Record; -} diff --git a/packages/design-system/4-components/src/frameworks/solid/index.ts b/packages/design-system/4-components/src/frameworks/solid/index.ts index ed6572a43..768589953 100644 --- a/packages/design-system/4-components/src/frameworks/solid/index.ts +++ b/packages/design-system/4-components/src/frameworks/solid/index.ts @@ -19,20 +19,12 @@ export { ImageLightbox, type ImageLightboxProps } from '../../components/media/I // Cards export { FlipCard, type FlipCardProps } from '../../components/cards/FlipCard/FlipCard.solid'; -export { PostCard, type PostCardProps } from '../../components/cards/PostCard/PostCard.solid'; // Inputs export { Search, type SearchProps } from '../../components/inputs/Search/Search.solid'; export { Select, type SelectOption, type SelectProps } from '../../components/inputs/Select/Select.solid'; export { Combobox, type ComboboxOption, type ComboboxProps } from '../../components/inputs/Combobox/Combobox.solid'; -// Buttons -export { CircleButton, type CircleButtonProps } from '../../components/buttons/CircleButton/CircleButton.solid'; -export { - IconLabelButton, - type IconLabelButtonProps, -} from '../../components/buttons/IconLabelButton/IconLabelButton.solid'; - // Menus export { DropdownMenu, @@ -50,11 +42,7 @@ export { type CodeEditorLanguage, type CodeEditorProps, } from '../../components/data/CodeEditor/CodeEditor.solid'; -export { Accordion, type AccordionItem, type AccordionProps } from '../../components/data/Accordion/Accordion.solid'; export { Calendar, type CalendarEvent, type CalendarProps } from '../../components/data/Calendar/Calendar.solid'; -export { List, type ListItem, type ListProps } from '../../components/data/List/List.solid'; -export { Table, type TableColumn, type TableProps } from '../../components/data/Table/Table.solid'; -export { Timeline, type TimelineItem, type TimelineProps } from '../../components/data/Timeline/Timeline.solid'; // Feedback export { @@ -65,14 +53,6 @@ export { type ToastVariant, } from '../../components/feedback/Toast/Toast.solid'; -// Navigation -export { - Breadcrumbs, - type BreadcrumbItem, - type BreadcrumbsProps, -} from '../../components/navigation/Breadcrumbs/Breadcrumbs.solid'; -export { Stepper, type StepperStep, type StepperProps } from '../../components/navigation/Stepper/Stepper.solid'; - // Signals export { SignalControl, diff --git a/packages/design-system/4-components/src/styles/index.scss b/packages/design-system/4-components/src/styles/index.scss index 77fc28206..0e53c94e3 100644 --- a/packages/design-system/4-components/src/styles/index.scss +++ b/packages/design-system/4-components/src/styles/index.scss @@ -1,14 +1,7 @@ @use '../components/signals/SignalControl/SignalControl.scss'; @use '../components/cards/FlipCard/FlipCard.scss'; -@use '../components/cards/PostCard/PostCard.scss'; @use '../components/data/CodeEditor/CodeEditor.scss'; -@use '../components/data/Accordion/Accordion.scss'; @use '../components/data/Calendar/Calendar.scss'; -@use '../components/data/List/List.scss'; -@use '../components/data/Table/Table.scss'; -@use '../components/data/Timeline/Timeline.scss'; @use '../components/media/AudioVisualiser/AudioVisualiser.scss'; @use '../components/media/EditableImage/EditableImage.scss'; @use '../components/feedback/Toast/Toast.scss'; -@use '../components/navigation/Breadcrumbs/Breadcrumbs.scss'; -@use '../components/navigation/Stepper/Stepper.scss'; diff --git a/packages/design-system/5-widgets/src/frameworks/solid/index.ts b/packages/design-system/5-widgets/src/frameworks/solid/index.ts index b7a46d1f4..b95685d2a 100644 --- a/packages/design-system/5-widgets/src/frameworks/solid/index.ts +++ b/packages/design-system/5-widgets/src/frameworks/solid/index.ts @@ -1,28 +1,7 @@ // Sidebars -export { - SpaceSidebarWidget, - type SpaceSidebarWidgetProps, -} from '../../widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.solid'; export { CollapsibleSidebar, CollapsibleSidebarContext, type CollapsibleSidebarProps, type CollapsibleSidebarItem, } from '../../widgets/sidebars/CollapsibleSidebar'; - -// Cesium - -// Graph -export { GraphWidget, type GraphWidgetProps } from '../../widgets/graph/GraphWidget/GraphWidget.solid'; -export { mockGraphData } from '../../widgets/graph/GraphWidget/mockData'; -export type { - GraphData, - GraphNode, - GraphEdge, - NodeType, - EdgeType, - NodeStyleConfig, - EdgeStyleConfig, - LayoutConfig, - InteractionConfig, -} from '../../widgets/graph/GraphWidget/GraphWidget.types'; diff --git a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.solid.tsx b/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.solid.tsx deleted file mode 100644 index ebb70cfbd..000000000 --- a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.solid.tsx +++ /dev/null @@ -1,408 +0,0 @@ -/** - * Graph Widget - * - * A 2D force-directed graph visualization using D3-force for layout and Canvas for rendering. - * Displays nodes (users, spaces, posts) and edges (relationships) with interactive features. - */ - -import * as d3 from 'd3-force'; -import { createEffect, createSignal, onCleanup, onMount } from 'solid-js'; - -export type * from './GraphWidget.types'; -import type { GraphEdge, GraphNode, GraphWidgetProps } from './GraphWidget.types'; - -// Default colors for node types -const DEFAULT_NODE_COLORS = { - user: '#3b82f6', // blue - space: '#10b981', // green - post: '#f59e0b', // amber -}; - -// Default edge colors -const DEFAULT_EDGE_COLORS = { - follows: '#64748b', - 'member-of': '#10b981', - 'posted-in': '#8b5cf6', - 'commented-on': '#f59e0b', -}; - -export function GraphWidget(props: GraphWidgetProps) { - let canvasRef: HTMLCanvasElement | undefined; - let containerRef: HTMLDivElement | undefined; - let animationFrameId: number | undefined; - - const [hoveredNode, setHoveredNode] = createSignal(null); - const [transform, setTransform] = createSignal({ x: 0, y: 0, k: 1 }); - const [dimensions, setDimensions] = createSignal({ width: 800, height: 600 }); - - // D3 simulation - let simulation: d3.Simulation | undefined; - - // Image cache for avatars - const imageCache = new Map(); - - // Preload avatar images - const preloadImages = () => { - props.data.nodes.forEach((node) => { - if (node.avatar && !imageCache.has(node.avatar)) { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = () => { - imageCache.set(node.avatar!, img); - render(); // Re-render when image loads - }; - img.onerror = () => { - console.warn(`Failed to load avatar image: ${node.avatar}`); - }; - img.src = node.avatar; - } - }); - }; - - // Default styling functions - const getNodeSize = (node: GraphNode) => { - if (props.nodeStyle?.size) return props.nodeStyle.size(node); - return node.size || (node.type === 'user' ? 12 : node.type === 'space' ? 14 : 8); - }; - - const getNodeColor = (node: GraphNode) => { - if (props.nodeStyle?.color) return props.nodeStyle.color(node); - return node.color || DEFAULT_NODE_COLORS[node.type] || '#666'; - }; - - const getNodeLabel = (node: GraphNode) => { - if (props.nodeStyle?.label) return props.nodeStyle.label(node); - return node.label; - }; - - const getLabelColor = (node: GraphNode) => { - const labelColor = props.nodeStyle?.labelColor; - if (!labelColor) return '#333'; - return typeof labelColor === 'function' ? labelColor(node) : labelColor; - }; - - const getLabelBackgroundColor = (node: GraphNode) => { - const labelBgColor = props.nodeStyle?.labelBackgroundColor; - if (!labelBgColor) return 'rgba(255, 255, 255, 0.9)'; - return typeof labelBgColor === 'function' ? labelBgColor(node) : labelBgColor; - }; - - const getLabelFontSize = () => { - return props.nodeStyle?.labelFontSize || 12; - }; - - const getEdgeWidth = (edge: GraphEdge) => { - if (props.edgeStyle?.width) return props.edgeStyle.width(edge); - return edge.width || 1.5; - }; - - const getEdgeColor = (edge: GraphEdge) => { - if (props.edgeStyle?.color) return props.edgeStyle.color(edge); - return edge.color || DEFAULT_EDGE_COLORS[edge.type] || '#999'; - }; - - // Transform screen coordinates to graph coordinates - const screenToGraph = (screenX: number, screenY: number) => { - const t = transform(); - return { - x: (screenX - t.x) / t.k, - y: (screenY - t.y) / t.k, - }; - }; - - // Find node at position - const findNodeAtPosition = (x: number, y: number): GraphNode | null => { - const graphPos = screenToGraph(x, y); - for (const node of props.data.nodes) { - if (node.x === undefined || node.y === undefined) continue; - const size = getNodeSize(node); - const dx = node.x - graphPos.x; - const dy = node.y - graphPos.y; - if (dx * dx + dy * dy <= size * size) { - return node; - } - } - return null; - }; - - // Render function - const render = () => { - if (!canvasRef) return; - - const ctx = canvasRef.getContext('2d'); - if (!ctx) return; - - const { width, height } = dimensions(); - const t = transform(); - const hovered = hoveredNode(); - - // Clear canvas - ctx.clearRect(0, 0, width, height); - - // Apply transform - ctx.save(); - ctx.translate(t.x, t.y); - ctx.scale(t.k, t.k); - - // Draw edges - props.data.edges.forEach((edge) => { - const source = typeof edge.source === 'string' ? props.data.nodes.find((n) => n.id === edge.source) : edge.source; - const target = typeof edge.target === 'string' ? props.data.nodes.find((n) => n.id === edge.target) : edge.target; - - if (!source || !target || source.x === undefined || target.x === undefined) return; - - ctx.beginPath(); - ctx.moveTo(source.x, source.y!); - ctx.lineTo(target.x, target.y!); - ctx.strokeStyle = getEdgeColor(edge as GraphEdge); - ctx.lineWidth = getEdgeWidth(edge as GraphEdge) / t.k; - ctx.globalAlpha = 0.6; - ctx.stroke(); - ctx.globalAlpha = 1; - }); - - // Draw nodes - props.data.nodes.forEach((node) => { - if (node.x === undefined || node.y === undefined) return; - - const size = getNodeSize(node); - const color = getNodeColor(node); - const isHovered = hovered?.id === node.id; - - // Check if node has avatar and image is loaded - const avatarImage = node.avatar ? imageCache.get(node.avatar) : null; - - if (avatarImage) { - // Draw avatar image with circular clip - ctx.save(); - ctx.beginPath(); - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); - ctx.closePath(); - ctx.clip(); - - // Draw image centered in circle - ctx.drawImage(avatarImage, node.x - size, node.y - size, size * 2, size * 2); - - ctx.restore(); - - // Border for avatar - ctx.beginPath(); - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); - ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; - ctx.lineWidth = 1 / t.k; - ctx.stroke(); - } else { - // Fallback to colored circle if no avatar - ctx.beginPath(); - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); - ctx.fillStyle = color; - ctx.fill(); - - // Border - ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; - ctx.lineWidth = 1 / t.k; - ctx.stroke(); - } - - // Hover highlight - if (isHovered) { - ctx.beginPath(); - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 3 / t.k; - ctx.stroke(); - } - }); - - // Draw labels - if (props.nodeStyle?.showLabel !== false) { - const fontSize = getLabelFontSize(); - ctx.font = `${fontSize / t.k}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - - props.data.nodes.forEach((node) => { - if (node.x === undefined || node.y === undefined) return; - - const label = getNodeLabel(node); - const size = getNodeSize(node); - const labelColor = getLabelColor(node); - const labelBgColor = getLabelBackgroundColor(node); - - // Label background - const metrics = ctx.measureText(label); - const padding = 4 / t.k; - const labelY = node.y + size + 4 / t.k; - - ctx.fillStyle = labelBgColor; - ctx.fillRect( - node.x - metrics.width / 2 - padding, - labelY - padding, - metrics.width + padding * 2, - fontSize / t.k + padding * 2, - ); - - // Label text - ctx.fillStyle = labelColor; - ctx.fillText(label, node.x, labelY); - }); - } - - ctx.restore(); - }; - - // Handle mouse move - const handleMouseMove = (e: MouseEvent) => { - if (!containerRef) return; - - const rect = containerRef.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const node = findNodeAtPosition(x, y); - setHoveredNode(node); - - if (node) { - containerRef.style.cursor = 'pointer'; - } else { - containerRef.style.cursor = 'default'; - } - - props.interactions?.onNodeHover?.(node); - }; - - // Handle click - const handleClick = (e: MouseEvent) => { - if (!containerRef) return; - - const rect = containerRef.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const node = findNodeAtPosition(x, y); - if (node) { - props.interactions?.onNodeClick?.(node); - } - }; - - // Handle wheel for zoom - const handleWheel = (e: WheelEvent) => { - if (props.interactions?.enableZoom === false) return; - - e.preventDefault(); - const t = transform(); - const delta = -e.deltaY * 0.001; - const newK = Math.max(0.1, Math.min(5, t.k * (1 + delta))); - - // Zoom towards mouse position - if (containerRef) { - const rect = containerRef.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const newX = x - ((x - t.x) / t.k) * newK; - const newY = y - ((y - t.y) / t.k) * newK; - - setTransform({ x: newX, y: newY, k: newK }); - } - }; - - // Update canvas size - const updateSize = () => { - if (!containerRef || !canvasRef) return; - - const rect = containerRef.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - canvasRef.width = rect.width * dpr; - canvasRef.height = rect.height * dpr; - canvasRef.style.width = `${rect.width}px`; - canvasRef.style.height = `${rect.height}px`; - - const ctx = canvasRef.getContext('2d'); - if (ctx) { - ctx.scale(dpr, dpr); - } - - setDimensions({ width: rect.width, height: rect.height }); - }; - - onMount(() => { - if (!canvasRef || !containerRef) return; - - // Initialize canvas size - updateSize(); - window.addEventListener('resize', updateSize); - - // Initialize transform to center - const { width, height } = dimensions(); - setTransform({ x: width / 2, y: height / 2, k: 1 }); - - // Setup D3 force simulation - const layoutConfig = props.layout || {}; - - simulation = d3 - .forceSimulation(props.data.nodes) - .force( - 'link', - d3 - .forceLink(props.data.edges) - .id((d) => d.id) - .distance(layoutConfig.distance || 100), - ) - .force('charge', d3.forceManyBody().strength(layoutConfig.chargeStrength || -300)) - .force('center', d3.forceCenter(0, 0).strength(layoutConfig.centerForce || 0.1)) - .force( - 'collision', - d3.forceCollide().radius((d) => getNodeSize(d as GraphNode) + 5), - ) - .alpha(1) - .alphaDecay(0.02) - .on('tick', render); - - // Preload avatar images - preloadImages(); - - // Start render loop - const animate = () => { - render(); - animationFrameId = requestAnimationFrame(animate); - }; - animate(); - }); - - onCleanup(() => { - if (simulation) { - simulation.stop(); - } - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - window.removeEventListener('resize', updateSize); - }); - - // Re-render when data or style changes - createEffect(() => { - void props.data; - void props.nodeStyle; - void props.edgeStyle; - render(); - }); - - return ( -
    - -
    - ); -} diff --git a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.types.ts b/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.types.ts deleted file mode 100644 index 6f62140b6..000000000 --- a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/GraphWidget.types.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Graph Widget Types - */ - -export type NodeType = 'user' | 'space' | 'post'; -export type EdgeType = 'follows' | 'member-of' | 'posted-in' | 'commented-on'; - -export interface GraphNode { - id: string; - type: NodeType; - label: string; - color?: string; - size?: number; - avatar?: string; - // D3 force simulation adds these - x?: number; - y?: number; - vx?: number; - vy?: number; - fx?: number | null; - fy?: number | null; -} - -export interface GraphEdge { - id: string; - source: string | GraphNode; - target: string | GraphNode; - type: EdgeType; - label?: string; - color?: string; - width?: number; -} - -export interface GraphData { - nodes: GraphNode[]; - edges: GraphEdge[]; -} - -export interface NodeStyleConfig { - size?: (node: GraphNode) => number; - color?: (node: GraphNode) => string; - label?: (node: GraphNode) => string; - showLabel?: boolean; - labelColor?: string | ((node: GraphNode) => string); - labelBackgroundColor?: string | ((node: GraphNode) => string); - labelFontSize?: number; -} - -export interface EdgeStyleConfig { - width?: (edge: GraphEdge) => number; - color?: (edge: GraphEdge) => string; - showLabel?: boolean; -} - -export interface LayoutConfig { - type?: 'force' | 'static'; - strength?: number; - distance?: number; - centerForce?: number; - chargeStrength?: number; -} - -export interface InteractionConfig { - onNodeClick?: (node: GraphNode) => void; - onNodeHover?: (node: GraphNode | null) => void; - onEdgeClick?: (edge: GraphEdge) => void; - enableZoom?: boolean; - enablePan?: boolean; -} - -/** - * @ai 2D force-directed graph visualization using D3-force layout and Canvas rendering. - * Displays typed nodes (user, space, post) and edges (follows, member-of, etc.) - * with configurable styling, layout forces, and interaction handlers. - */ -export interface GraphWidgetProps { - /** Graph data with nodes and edges */ - data: GraphData; - /** Width of the canvas */ - width?: string | number; - /** Height of the canvas */ - height?: string | number; - /** Node styling configuration */ - nodeStyle?: NodeStyleConfig; - /** Edge styling configuration */ - edgeStyle?: EdgeStyleConfig; - /** Layout configuration */ - layout?: LayoutConfig; - /** Interaction handlers */ - interactions?: InteractionConfig; -} diff --git a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/mockData.ts b/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/mockData.ts deleted file mode 100644 index 70a4bcec6..000000000 --- a/packages/design-system/5-widgets/src/widgets/graph/GraphWidget/mockData.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Mock graph data for testing - */ - -import type { GraphData } from './GraphWidget.types'; - -export const mockGraphData: GraphData = { - nodes: [ - // Users - { - id: 'user-1', - type: 'user', - label: 'Sarah Chen', - avatar: 'https://i.pravatar.cc/150?img=5', - }, - { - id: 'user-2', - type: 'user', - label: 'Marcus Rodriguez', - avatar: 'https://i.pravatar.cc/150?img=12', - }, - { - id: 'user-3', - type: 'user', - label: 'Elena Popov', - avatar: 'https://i.pravatar.cc/150?img=9', - }, - { - id: 'user-4', - type: 'user', - label: 'James Wilson', - avatar: 'https://i.pravatar.cc/150?img=3', - }, - { - id: 'user-5', - type: 'user', - label: 'Priya Sharma', - avatar: 'https://i.pravatar.cc/150?img=16', - }, - { - id: 'user-6', - type: 'user', - label: 'Alex Kim', - avatar: 'https://i.pravatar.cc/150?img=7', - }, - { - id: 'user-7', - type: 'user', - label: 'Isabella Martinez', - avatar: 'https://i.pravatar.cc/150?img=10', - }, - { - id: 'user-8', - type: 'user', - label: 'David Zhang', - avatar: 'https://i.pravatar.cc/150?img=15', - }, - { - id: 'user-9', - type: 'user', - label: 'Olivia Brown', - avatar: 'https://i.pravatar.cc/150?img=20', - }, - { - id: 'user-10', - type: 'user', - label: 'Noah Anderson', - avatar: 'https://i.pravatar.cc/150?img=8', - }, - - // Spaces - { - id: 'space-1', - type: 'space', - label: 'Design Team', - }, - { - id: 'space-2', - type: 'space', - label: 'Dev Team', - }, - { - id: 'space-3', - type: 'space', - label: 'Product Strategy', - }, - { - id: 'space-4', - type: 'space', - label: 'Marketing', - }, - { - id: 'space-5', - type: 'space', - label: 'Research Lab', - }, - { - id: 'space-6', - type: 'space', - label: 'Community', - }, - - // Posts - { - id: 'post-1', - type: 'post', - label: 'Design Systems Guide', - }, - { - id: 'post-2', - type: 'post', - label: 'Web3 UX Patterns', - }, - { - id: 'post-3', - type: 'post', - label: 'Animation Tips', - }, - { - id: 'post-4', - type: 'post', - label: 'TypeScript Features', - }, - { - id: 'post-5', - type: 'post', - label: 'Accessibility Matters', - }, - { - id: 'post-6', - type: 'post', - label: 'React Patterns', - }, - { - id: 'post-7', - type: 'post', - label: 'Performance Tips', - }, - { - id: 'post-8', - type: 'post', - label: 'CSS Grid Mastery', - }, - { - id: 'post-9', - type: 'post', - label: 'API Design Best Practices', - }, - { - id: 'post-10', - type: 'post', - label: 'GraphQL vs REST', - }, - ], - edges: [ - // User follows - { id: 'e1', source: 'user-1', target: 'user-2', type: 'follows' }, - { id: 'e2', source: 'user-1', target: 'user-3', type: 'follows' }, - { id: 'e3', source: 'user-2', target: 'user-4', type: 'follows' }, - { id: 'e4', source: 'user-3', target: 'user-5', type: 'follows' }, - { id: 'e5', source: 'user-4', target: 'user-1', type: 'follows' }, - { id: 'e6', source: 'user-5', target: 'user-2', type: 'follows' }, - { id: 'e25', source: 'user-6', target: 'user-1', type: 'follows' }, - { id: 'e26', source: 'user-6', target: 'user-7', type: 'follows' }, - { id: 'e27', source: 'user-7', target: 'user-8', type: 'follows' }, - { id: 'e28', source: 'user-8', target: 'user-9', type: 'follows' }, - { id: 'e29', source: 'user-9', target: 'user-10', type: 'follows' }, - { id: 'e30', source: 'user-10', target: 'user-6', type: 'follows' }, - { id: 'e31', source: 'user-7', target: 'user-3', type: 'follows' }, - { id: 'e32', source: 'user-8', target: 'user-4', type: 'follows' }, - - // Space memberships - { id: 'e7', source: 'user-1', target: 'space-1', type: 'member-of' }, - { id: 'e8', source: 'user-2', target: 'space-1', type: 'member-of' }, - { id: 'e9', source: 'user-2', target: 'space-2', type: 'member-of' }, - { id: 'e10', source: 'user-3', target: 'space-2', type: 'member-of' }, - { id: 'e11', source: 'user-4', target: 'space-2', type: 'member-of' }, - { id: 'e12', source: 'user-4', target: 'space-3', type: 'member-of' }, - { id: 'e13', source: 'user-5', target: 'space-3', type: 'member-of' }, - { id: 'e33', source: 'user-6', target: 'space-1', type: 'member-of' }, - { id: 'e34', source: 'user-6', target: 'space-4', type: 'member-of' }, - { id: 'e35', source: 'user-7', target: 'space-4', type: 'member-of' }, - { id: 'e36', source: 'user-7', target: 'space-5', type: 'member-of' }, - { id: 'e37', source: 'user-8', target: 'space-2', type: 'member-of' }, - { id: 'e38', source: 'user-8', target: 'space-5', type: 'member-of' }, - { id: 'e39', source: 'user-9', target: 'space-6', type: 'member-of' }, - { id: 'e40', source: 'user-10', target: 'space-6', type: 'member-of' }, - { id: 'e41', source: 'user-10', target: 'space-3', type: 'member-of' }, - - // Posts in spaces - { id: 'e14', source: 'post-1', target: 'space-1', type: 'posted-in' }, - { id: 'e15', source: 'post-2', target: 'space-3', type: 'posted-in' }, - { id: 'e16', source: 'post-3', target: 'space-1', type: 'posted-in' }, - { id: 'e17', source: 'post-4', target: 'space-2', type: 'posted-in' }, - { id: 'e18', source: 'post-5', target: 'space-3', type: 'posted-in' }, - { id: 'e42', source: 'post-6', target: 'space-2', type: 'posted-in' }, - { id: 'e43', source: 'post-7', target: 'space-2', type: 'posted-in' }, - { id: 'e44', source: 'post-8', target: 'space-1', type: 'posted-in' }, - { id: 'e45', source: 'post-9', target: 'space-5', type: 'posted-in' }, - { id: 'e46', source: 'post-10', target: 'space-5', type: 'posted-in' }, - - // User comments on posts - { id: 'e19', source: 'user-1', target: 'post-1', type: 'commented-on' }, - { id: 'e20', source: 'user-2', target: 'post-1', type: 'commented-on' }, - { id: 'e21', source: 'user-3', target: 'post-3', type: 'commented-on' }, - { id: 'e22', source: 'user-4', target: 'post-4', type: 'commented-on' }, - { id: 'e23', source: 'user-5', target: 'post-2', type: 'commented-on' }, - { id: 'e24', source: 'user-1', target: 'post-5', type: 'commented-on' }, - { id: 'e47', source: 'user-6', target: 'post-1', type: 'commented-on' }, - { id: 'e48', source: 'user-6', target: 'post-8', type: 'commented-on' }, - { id: 'e49', source: 'user-7', target: 'post-6', type: 'commented-on' }, - { id: 'e50', source: 'user-8', target: 'post-4', type: 'commented-on' }, - { id: 'e51', source: 'user-8', target: 'post-7', type: 'commented-on' }, - { id: 'e52', source: 'user-9', target: 'post-9', type: 'commented-on' }, - { id: 'e53', source: 'user-10', target: 'post-10', type: 'commented-on' }, - { id: 'e54', source: 'user-10', target: 'post-2', type: 'commented-on' }, - ], -}; diff --git a/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.solid.tsx b/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.solid.tsx deleted file mode 100644 index 8117b2813..000000000 --- a/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.solid.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Column, Row } from '@we/components/solid'; - -export type * from './SpaceSidebarWidget.types'; -import type { SpaceSidebarWidgetProps } from './SpaceSidebarWidget.types'; - -export function SpaceSidebarWidget(props: SpaceSidebarWidgetProps) { - return ( - -
    - - - {/* */} - - {props.name} - - - - {props.description && {props.description}} - - - ); -} diff --git a/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.types.ts b/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.types.ts deleted file mode 100644 index d19e4044b..000000000 --- a/packages/design-system/5-widgets/src/widgets/sidebars/SpaceSidebarWidget/SpaceSidebarWidget.types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface SpaceSidebarWidgetProps { - name: string; - description?: string; - // image?: string; - class?: string; - style?: Record; -} diff --git a/packages/design-system/CONVENTIONS.md b/packages/design-system/CONVENTIONS.md index 0002b025f..1ce7687bb 100644 --- a/packages/design-system/CONVENTIONS.md +++ b/packages/design-system/CONVENTIONS.md @@ -4,13 +4,62 @@ Rules and patterns for building UI components (`4-components/`) and widgets (`5- ## Package Roles -| Package | Scope | Examples | -| --------------------------------- | -------------------------------------------------------- | -------------------------------------------- | -| `4-components` (`@we/components`) | Single-purpose Solid components composed from primitives | CircleButton, Column, Row, DropdownMenu | -| `5-widgets` (`@we/widgets`) | Composite blocks combining multiple components | CollapsibleSidebar, CesiumGlobe, GraphWidget | +| Package | Scope | Examples | +| --------------------------------- | -------------------------------------------------------- | -------------------------------------- | +| `4-components` (`@we/components`) | Single-purpose Solid components composed from primitives | AvatarStack, Column, Row, DropdownMenu | +| `5-widgets` (`@we/widgets`) | Composite blocks combining multiple components | CollapsibleSidebar, CesiumGlobe | Components are leaf-level building blocks. Widgets encapsulate higher-level features. Composition above widgets is handled by the schema system. +## Does this deserve to be code at all? + +> **Code owns only what data cannot express**: behaviour and focus management, accessibility +> semantics, browser APIs, measurement, performance-critical rendering. That is the whole list. +> Everything above it is arrangement, and arrangement belongs in the schema layer — a template +> fragment (`@we/template-kit`) or plain nodes — where an author can edit it. + +A component that is only a fixed arrangement of primitives is the anti-pattern this rule exists +for: it freezes layout decisions behind props nobody can extend from a template, and because +templates cannot use it and code does not need it, it dies unused. Nine of them were deleted in +one sweep (`PostCard`, `CircleButton`, `IconLabelButton`, `List`, `Table`, `Timeline`, `Accordion`, +`Breadcrumbs`, `Stepper`) — every one pure arrangement, every one with zero consumers, one +carrying an invalid design token nobody had noticed because nothing rendered it. Function-valued +props (`renderItem`, `renderCell`) are the tell: a schema cannot express a render function, so a +component built around one is unreachable from the layer composition is supposed to happen in. + +The test before adding a component: **name the thing it does that a `Column` full of primitives +cannot.** `AvatarStack` has overlap maths; `CollapsedContent` measures; `DropdownMenu` owns focus. +If the answer is "it groups things nicely", it is a fragment. + +## One vocabulary, two grammars + +Components serve two consumers, and the same rules keep them from diverging: + +- **Schemas** reach components through the registry (`componentRegistry.tsx`). A registry entry is + vocabulary the validator accepts and the AI is taught — never register a component templates + cannot meaningfully drive. +- **TSX code** (the editor, app-shell chrome) imports components directly, and composes them with + JSX the way schemas compose them with fragments. + +Neither side may re-implement what the other layer owns: fragments arrange components, never +rebuild them; TSX composes primitives and components, never raw HTML with inline styles. That is +what keeps visual identity single-sourced while the two arrangement grammars stay independent. + +When one _pattern_ is genuinely needed identically on both sides, there are exactly two moves — +never a second copy: + +1. **Demote it to a component** — when it carries behaviour anyway, or pixel-identity matters more + than template editability (`SignalControl`). +2. **Mount a schema island in TSX** via `RenderSchema` — when the shared thing is data-driven and + themeable. The shell does this wholesale: Settings, BootScreen and the sidebar are schemas + rendered inside the Solid app. + +Which app surfaces get built as schemas at all follows one rule: **surfaces a deployment should be +able to white-label or replace are schemas; tools are code.** The settings pages are schemas; the +editor's inspector is not, and should not be — the tool must keep working while the schema it is +editing is broken, and rebuilding tool UI as schemas would pressure the operator language toward +general-purpose growth the schema system deliberately refuses. + ## Directory Structure Every component/widget follows the same layout: @@ -39,17 +88,17 @@ Each component has a `*.types.ts` file defining its prop interface. These are ** ```ts // ✅ Good — framework-agnostic -export interface PostCardProps { - title: string; - creator?: { name: string; avatar: string }; - style?: Record; +export interface FlipCardProps { + width?: string; + flipOnHover?: boolean; + styles?: Record; } // ❌ Bad — Solid-specific import { JSX } from 'solid-js'; -export interface PostCardProps { - title: string; - style?: JSX.CSSProperties; +export interface FlipCardProps { + width?: string; + styles?: JSX.CSSProperties; } ``` diff --git a/packages/module-system/graph/src/fragments.ts b/packages/module-system/graph/src/fragments.ts index ddf96f62b..13657bfad 100644 --- a/packages/module-system/graph/src/fragments.ts +++ b/packages/module-system/graph/src/fragments.ts @@ -9,6 +9,11 @@ * * They are also deliberately small. A fragment that configured everything would be a thing to modify * rather than a thing to read. + * + * These are *module-provided* fragments — the scope `docs/architecture/template-fragments.md` names + * alongside the kit's own — and they follow `packages/templates/kit/CONVENTIONS.md`: a single + * options object where anything varies, bodies that read as the tree they emit, and a doc comment + * saying why each exists. */ import type { SchemaNode } from '@we/schema-shared'; @@ -41,15 +46,20 @@ export const schemaMap: SchemaNode = { * judgement call: zero shows disconnected dots and tells you nothing about structure, two opens far * enough that the first paint is already a hairball on any real dataset. */ -export const knowledgeMap = (entity: string): SchemaNode => ({ +export interface KnowledgeMapOptions { + /** The entity type the map is seeded from and highlights. */ + entity: string; +} + +export const knowledgeMap = (opts: KnowledgeMapOptions): SchemaNode => ({ type: 'GraphView', props: { - seeds: { source: 'query', options: { entity, limit: 60 } }, + seeds: { source: 'query', options: { entity: opts.entity, limit: 60 } }, expansion: { defaultDepth: 1, direction: 'both', limit: 25, maxNodes: 600 }, layout: { type: 'force' }, nodeStyle: [ { style: { size: 14, color: 'neutral-400' } }, - { when: { type: entity }, style: { size: 20, color: 'primary-500' } }, + { when: { type: opts.entity }, style: { size: 20, color: 'primary-500' } }, { when: { unresolved: true }, style: { color: 'neutral-200' } }, ], edgeStyle: [{ style: { curve: 'bezier', arrow: 'target' } }], diff --git a/packages/module-system/graph/src/index.ts b/packages/module-system/graph/src/index.ts index 79c0918ed..de630d4b5 100644 --- a/packages/module-system/graph/src/index.ts +++ b/packages/module-system/graph/src/index.ts @@ -54,14 +54,16 @@ export function createGraphModule(graphViewComponent: unknown) { /** * Placeable graphs. Named rather than parameterised where a fragment is genuinely fixed, and a - * function where the only thing that varies is the entity — a `knowledgeMap('Belief')` reads - * better in a template than a fragment plus an override. + * function where the only thing that varies is the entity — `knowledgeMap({ entity: 'Belief' })` + * reads better in a template than a fragment plus an override. Options object rather than a + * positional argument, per the kit's fragment conventions: uniform call sites, and room to grow + * depth or layout options without a migration. */ schemas: { schemaMap, contentTree, staticDiagram, - knowledgeMapPosts: knowledgeMap('Post'), + knowledgeMapPosts: knowledgeMap({ entity: 'Post' }), }, }); } diff --git a/packages/schema-system/shared/src/generated/contextData.ts b/packages/schema-system/shared/src/generated/contextData.ts index 12d18a38d..f75625953 100644 --- a/packages/schema-system/shared/src/generated/contextData.ts +++ b/packages/schema-system/shared/src/generated/contextData.ts @@ -1024,18 +1024,6 @@ export const contextData: ContextData = { ], source: 'components', }, - { - name: 'Accordion', - props: [ - { name: 'children', type: 'JSX.Element', optional: true }, - { name: 'renderContent', type: '((item: AccordionItem, index: number) => JSX.Element)', optional: true }, - { name: 'onChange', type: '((openItems: string[]) => void)', optional: true }, - { name: 'items', type: 'AccordionItem[]', optional: true }, - { name: 'multiple', type: 'boolean', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, { name: 'AudioVisualiser', props: [ @@ -1059,16 +1047,6 @@ export const contextData: ContextData = { ], source: 'components', }, - { - name: 'Breadcrumbs', - props: [ - { name: 'onNavigate', type: '((item: BreadcrumbItem, index: number) => void)', optional: true }, - { name: 'items', type: 'BreadcrumbItem[]', optional: true }, - { name: 'separator', type: 'string', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, { name: 'Calendar', props: [ @@ -1080,18 +1058,6 @@ export const contextData: ContextData = { source: 'components', }, { name: 'Card', superclass: 'DesignSystemElement', props: [], source: 'components' }, - { - name: 'CircleButton', - props: [ - { name: 'label', type: 'string', optional: false }, - { name: 'icon', type: 'string', optional: true }, - { name: 'image', type: 'string', optional: true }, - { name: 'onClick', type: '(() => void)', optional: true }, - { name: 'class', type: 'string', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, { name: 'CodeEditor', props: [ @@ -1137,8 +1103,8 @@ export const contextData: ContextData = { description: 'Flexible dropdown menu for actions, toggles, and grouped items. Use for context menus, settings panels, layer controls, and command palettes.', props: [ - { name: 'class', type: 'string', optional: true }, { name: 'styles', type: 'Record', optional: true }, + { name: 'class', type: 'string', optional: true }, { name: 'placement', type: 'Placement', optional: true }, { name: 'triggerLabel', type: 'string', optional: true }, { name: 'triggerIcon', type: 'string', optional: true }, @@ -1153,7 +1119,7 @@ export const contextData: ContextData = { props: [ { name: 'src', type: 'string', optional: true }, { name: 'alt', type: 'string', optional: true }, - { name: 'fit', type: '"fill" | "cover" | "contain" | "none" | "scale-down"', optional: true }, + { name: 'fit', type: '"cover" | "contain" | "none" | "fill" | "scale-down"', optional: true }, { name: 'placeholderIcon', type: 'string', optional: true }, { name: 'onImageChange', type: '((file: File) => void)', optional: true }, { name: 'onImageRemove', type: '(() => void)', optional: true }, @@ -1191,47 +1157,6 @@ export const contextData: ContextData = { ], source: 'components', }, - { - name: 'IconLabelButton', - props: [ - { - name: 'icon', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', - optional: false, - }, - { - name: 'label', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', - optional: false, - }, - { - name: 'selected', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', - optional: true, - }, - { - name: 'iconWeight', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', - optional: true, - }, - { - name: 'onClick', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor<(() => void) | undefined>', - optional: true, - }, - { - name: 'class', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor', - optional: true, - }, - { - name: 'styles', - type: 'import("/home/james/Desktop/Coding/we/packages/design-system/utils/dist/solid").MaybeAccessor | undefined>', - optional: true, - }, - ], - source: 'components', - }, { name: 'ImageCrop', props: [ @@ -1254,29 +1179,6 @@ export const contextData: ContextData = { ], source: 'components', }, - { - name: 'List', - props: [ - { name: 'children', type: 'JSX.Element', optional: true }, - { name: 'renderItem', type: '((item: ListItem, index: number) => JSX.Element)', optional: true }, - { name: 'items', type: 'ListItem[]', optional: true }, - { name: 'ordered', type: 'boolean', optional: true }, - { name: 'gap', type: 'string', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, - { - name: 'PostCard', - props: [ - { name: 'creator', type: '{ name: string; avatar: string; }', optional: true }, - { name: 'title', type: 'string', optional: false }, - { name: 'text', type: 'string', optional: false }, - { name: 'class', type: 'string', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, { name: 'RerenderLog', props: [{ name: 'location', type: 'string', optional: false }], source: 'components' }, { name: 'Row', superclass: 'DesignSystemElement', props: [], source: 'components' }, { @@ -1318,43 +1220,6 @@ export const contextData: ContextData = { ], source: 'components', }, - { - name: 'Stepper', - props: [ - { name: 'onStepClick', type: '((index: number) => void)', optional: true }, - { name: 'steps', type: 'StepperStep[]', optional: true }, - { name: 'activeStep', type: 'number', optional: true }, - { name: 'orientation', type: '"horizontal" | "vertical"', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, - { - name: 'Table', - props: [ - { - name: 'renderCell', - type: '((row: Record, column: TableColumn, index: number) => JSX.Element)', - optional: true, - }, - { name: 'columns', type: 'TableColumn[]', optional: false }, - { name: 'rows', type: 'Record[]', optional: false }, - { name: 'striped', type: 'boolean', optional: true }, - { name: 'bordered', type: 'boolean', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, - { - name: 'Timeline', - props: [ - { name: 'children', type: 'JSX.Element', optional: true }, - { name: 'renderItem', type: '((item: TimelineItem, index: number) => JSX.Element)', optional: true }, - { name: 'items', type: 'TimelineItem[]', optional: true }, - { name: 'styles', type: 'Record', optional: true }, - ], - source: 'components', - }, { name: 'ToastContainer', props: [ @@ -1403,31 +1268,6 @@ export const contextData: ContextData = { ], source: 'widgets', }, - { - name: 'GraphWidget', - description: - '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.', - props: [ - { name: 'data', type: 'GraphData', optional: false }, - { name: 'width', type: 'string | number', optional: true }, - { name: 'height', type: 'string | number', optional: true }, - { name: 'nodeStyle', type: 'NodeStyleConfig', optional: true }, - { name: 'edgeStyle', type: 'EdgeStyleConfig', optional: true }, - { name: 'layout', type: 'LayoutConfig', optional: true }, - { name: 'interactions', type: 'InteractionConfig', optional: true }, - ], - source: 'widgets', - }, - { - name: 'SpaceSidebarWidget', - props: [ - { name: 'name', type: 'string', optional: false }, - { name: 'description', type: 'string', optional: true }, - { name: 'class', type: 'string', optional: true }, - { name: 'style', type: 'Record', optional: true }, - ], - source: 'widgets', - }, { name: 'GraphView', description: diff --git a/packages/schema-system/shared/src/semanticValidation.test.ts b/packages/schema-system/shared/src/semanticValidation.test.ts new file mode 100644 index 000000000..7ed3e840e --- /dev/null +++ b/packages/schema-system/shared/src/semanticValidation.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { buildValidationContext, validateSemantic } from './semanticValidation'; +import type { SchemaNode } from './types'; + +// The same generated context the CLI reads, loaded the same way — this package deliberately does +// not depend on `@we/ai-context` (it is a build tool, and the dependency would point the wrong way). +const contextData = JSON.parse(readFileSync(resolve(import.meta.dirname, '../../../ai-context/context.json'), 'utf-8')); +const context = buildValidationContext(contextData); + +/** Validate a fragment (no `meta`), which is how a section of a template is judged. */ +function check(node: SchemaNode) { + return validateSemantic(node, context); +} + +function messages(node: SchemaNode, severity: 'error' | 'warning') { + return check(node) + .errors.filter((e) => e.severity === severity) + .map((e) => e.message); +} + +/** + * These cover the failure class this validator exists for: schemas that render, accept input, and + * quietly do the wrong thing. Every case below shipped in WE's own templates at some point. + */ +describe('$map select references', () => { + it('rejects a bare $item, which resolves to a literal', () => { + const node: SchemaNode = { + type: 'AvatarStack', + props: { + avatars: { $map: { items: { $store: 'spaceStore.members' }, select: { hash: '$item' } } }, + }, + }; + const errs = messages(node, 'error'); + expect(errs).toHaveLength(1); + expect(errs[0]).toContain('resolved as a literal'); + expect(errs[0]).toContain('$concat'); + }); + + it('accepts a dotted $item path, and a token object', () => { + const node: SchemaNode = { + type: 'AvatarStack', + props: { + avatars: { + $map: { + items: { $store: 'spaceStore.members' }, + select: { image: '$item.avatar', hash: { $concat: ['$item'] } }, + }, + }, + }, + }; + expect(messages(node, 'error')).toEqual([]); + }); + + it('warns about any other $-prefixed string in a select', () => { + const node: SchemaNode = { + type: 'AvatarStack', + props: { + avatars: { $map: { items: { $store: 'spaceStore.members' }, select: { hash: '$person.did' } } }, + }, + }; + expect(messages(node, 'warning').join()).toContain('passed through as a literal'); + }); + + it('checks tokens inside the source expression', () => { + const node: SchemaNode = { + type: 'AvatarStack', + props: { + avatars: { $map: { items: { $store: 'spaceStore.definitelyNotAMember' }, select: { hash: '$item.did' } } }, + }, + }; + // The source used to be read from a key the grammar does not have (`source`), so nothing in it + // was ever checked. + expect(messages(node, 'warning').join()).toContain('definitelyNotAMember'); + }); +}); + +describe('writes to hoisted query results', () => { + const listNode = (write: Record): SchemaNode => ({ + type: 'Column', + $queries: { signalTypes: { entity: 'SignalType', subscribe: true } }, + children: [{ type: 'we-button', props: { onClick: write }, children: ['Go'] }], + }); + + it('rejects $setLocal against a $queries name', () => { + const errs = messages(listNode({ $setLocal: 'signalTypes', value: [] }), 'error'); + expect(errs.join()).toContain('read-only'); + }); + + it('rejects $toggleLocal against a $queries name', () => { + const errs = messages(listNode({ $toggleLocal: 'signalTypes' }), 'error'); + expect(errs.join()).toContain('read-only'); + }); + + it('allows reads of the same name', () => { + const node: SchemaNode = { + type: 'Column', + $queries: { signalTypes: { entity: 'SignalType', subscribe: true } }, + children: [{ type: 'we-text', children: [{ $count: { items: { $local: 'signalTypes' } } }] }], + }; + expect(messages(node, 'error')).toEqual([]); + }); + + it('allows a write once $localState shadows the query name', () => { + const node: SchemaNode = { + type: 'Column', + $queries: { items: { entity: 'SignalType', subscribe: true } }, + children: [ + { + type: 'Column', + $localState: { items: { type: 'object', initial: null } }, + children: [{ type: 'we-button', props: { onClick: { $setLocal: 'items', value: null } }, children: ['x'] }], + }, + ], + }; + expect(messages(node, 'error')).toEqual([]); + }); +}); + +describe('undeclared local writes', () => { + it('rejects $toggleLocal against a field nothing declares', () => { + const node: SchemaNode = { + type: 'Column', + $localState: { open: { type: 'boolean', initial: false } }, + children: [{ type: 'we-button', props: { onClick: { $toggleLocal: 'opne' } }, children: ['x'] }], + }; + expect(messages(node, 'error').join()).toContain('opne'); + }); + + it('rejects $callLocal against a field nothing declares', () => { + const node: SchemaNode = { + type: 'Column', + $localState: { onConfirm: { type: 'function', initial: null } }, + children: [{ type: 'we-button', props: { onClick: { $callLocal: 'onConfrim' } }, children: ['x'] }], + }; + expect(messages(node, 'error').join()).toContain('onConfrim'); + }); +}); diff --git a/packages/schema-system/shared/src/semanticValidation.ts b/packages/schema-system/shared/src/semanticValidation.ts index 9801ecf37..39070592d 100644 --- a/packages/schema-system/shared/src/semanticValidation.ts +++ b/packages/schema-system/shared/src/semanticValidation.ts @@ -338,6 +338,14 @@ export function buildValidationContext(data: ContextData): ValidationContext { interface WalkState { localScope: Set | null; // null = no $localState in scope + /** + * Of those, the names contributed by `$queries` — which are read-only. + * + * Kept apart from `localScope` because a *read* does not care which declared it (that is the + * point of one namespace), while a *write* very much does: `$setLocal` on a hoisted query warns + * to the console and no-ops, so the control renders, takes the click and does nothing. + */ + queryScope: Set; hasRoutesAncestor: boolean; /** True only for the root template node and for route entry nodes — the positions the router * actually reads routes arrays from. Child nodes that are not route entries must never own @@ -580,8 +588,13 @@ function updateLocalScope(n: Record, state: WalkState): WalkSta const newFields = new Set(state.localScope ?? []); if (hasState) for (const key of Object.keys(localState)) newFields.add(key); - if (hasQueries) for (const key of Object.keys(queries)) newFields.add(key); - return { ...state, localScope: newFields }; + const newQueries = new Set(state.queryScope); + if (hasQueries) for (const key of Object.keys(queries)) newQueries.add(key); + // A `$localState` field on the same node shadows the hoisted query of that name, so the write + // check below must not treat it as read-only any more. + if (hasState) for (const key of Object.keys(localState)) newQueries.delete(key); + for (const key of newQueries) newFields.add(key); + return { ...state, localScope: newFields, queryScope: newQueries }; } function checkProps( @@ -728,6 +741,21 @@ function checkTokenValue( // $setLocal token if ('$setLocal' in obj && typeof obj.$setLocal === 'string') { checkLocalRef(obj.$setLocal, `${path}.$setLocal`, 'setLocal', state, errors); + checkLocalWrite(obj.$setLocal, `${path}.$setLocal`, 'setLocal', state, errors); + } + + /* + $toggleLocal and $callLocal were never checked at all, so a typo in either produced exactly the + failure this validator exists to catch: the button renders, takes the click, warns to a console + nobody has open, and does nothing. + */ + if ('$toggleLocal' in obj && typeof obj.$toggleLocal === 'string') { + checkLocalRef(obj.$toggleLocal, `${path}.$toggleLocal`, 'toggleLocal', state, errors); + checkLocalWrite(obj.$toggleLocal, `${path}.$toggleLocal`, 'toggleLocal', state, errors); + } + + if ('$callLocal' in obj && typeof obj.$callLocal === 'string') { + checkLocalRef(obj.$callLocal, `${path}.$callLocal`, 'callLocal', state, errors); } // $error token @@ -759,7 +787,10 @@ function checkTokenValue( // $resetLocal token — skip $scope if ('$resetLocal' in obj && typeof obj.$resetLocal === 'string') { - // $resetLocal: "$scope" is always valid — skip + if (obj.$resetLocal !== '$scope') { + checkLocalRef(obj.$resetLocal, `${path}.$resetLocal`, 'resetLocal', state, errors); + checkLocalWrite(obj.$resetLocal, `${path}.$resetLocal`, 'resetLocal', state, errors); + } } // Recurse into nested token objects ($if, $concat, $map, $eq, $ne, etc.) @@ -778,9 +809,12 @@ function checkTokenValue( if ('$map' in obj && typeof obj.$map === 'object' && obj.$map !== null) { const mapObj = obj.$map as Record; - checkTokenValue(mapObj.source, `${path}.$map.source`, ctx, state, errors); + // `items`, not `source` — this read the wrong key since it was written, so a bad `$store` + // inside a `$map`'s source was never reported. + checkTokenValue(mapObj.items, `${path}.$map.items`, ctx, state, errors); if (mapObj.select && typeof mapObj.select === 'object') { for (const [k, v] of Object.entries(mapObj.select as Record)) { + checkMapSelectValue(v, `${path}.$map.select.${k}`, errors); checkTokenValue(v, `${path}.$map.select.${k}`, ctx, state, errors); } } @@ -1095,6 +1129,68 @@ function checkModelRef(name: string, path: string, ctx: ValidationContext, error } } +/** + * A `$map` `select` value that looks like a reference but is resolved as a literal. + * + * `resolveSelectValue` substitutes a string only when it starts with `'$item.'`. Everything else is + * passed through untouched, so a bare `'$item'` becomes the five characters `$item` — identical for + * every row. Four participant stacks seeded their avatars that way and rendered the same generated + * face for everybody, which reads as a styling quirk rather than as a bug, and survived four + * separate reviews. The fix is a token object (`{ $concat: ['$item'] }`), which is always resolved. + * + * `'$item'` is an error because it is never intentional. Other `$`-strings are a warning: a literal + * beginning with `$` is legal, just very rarely what somebody meant to write. + */ +function checkMapSelectValue(value: unknown, path: string, errors: ValidationError[]): void { + if (typeof value !== 'string' || !value.startsWith('$')) return; + if (value.startsWith('$item.')) return; + + if (value === '$item') { + errors.push({ + path, + message: + `"$item" in a $map select is resolved as a literal, not as the current item — only ` + + `"$item." is substituted. Every row will get the same value. ` + + `Use { "$concat": ["$item"] } instead.`, + severity: 'error', + }); + return; + } + + errors.push({ + path, + message: + `"${value}" in a $map select is passed through as a literal string. Only "$item." is ` + + `substituted; wrap a context reference in a token object to have it resolved.`, + severity: 'warning', + }); +} + +/** + * A write to a name a `$queries` entry owns. + * + * `$queries` and `$localState` share one `$local` namespace so a reader need not care which + * declared a name — but query results are read-only. `$setLocal` against one warns and no-ops, so + * the control renders, accepts the click, and does nothing at all. + */ +function checkLocalWrite( + fieldName: string, + path: string, + tokenType: string, + state: WalkState, + errors: ValidationError[], +): void { + const rootField = fieldName.split('.')[0]; + if (!state.queryScope.has(rootField)) return; + errors.push({ + path, + message: + `$${tokenType} writes to "${rootField}", which is declared by $queries and is read-only. ` + + `The write will warn and no-op at runtime. Declare it in $localState instead, or write to a different field.`, + severity: 'error', + }); +} + function checkLocalRef( fieldName: string, path: string, @@ -1334,6 +1430,7 @@ export function validateSemantic(schema: unknown, context: ValidationContext): V // it reads. Anything else is a fragment; see `WalkState.isFragment`. const state: WalkState = { localScope: null, + queryScope: new Set(), hasRoutesAncestor: false, isRouteEligible: true, isFragment: !meta, diff --git a/packages/templates/default/package.json b/packages/templates/default/package.json index 4dad209eb..5bd20fbb3 100644 --- a/packages/templates/default/package.json +++ b/packages/templates/default/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@we/schema-shared": "workspace:*", + "@we/template-kit": "workspace:*", "@we/template-shell": "workspace:*" }, "devDependencies": { diff --git a/packages/templates/default/src/DefaultTemplate/HeaderLayout/SpaceHeader.ts b/packages/templates/default/src/DefaultTemplate/HeaderLayout/SpaceHeader.ts index 39ed8228d..9f7acfe4a 100644 --- a/packages/templates/default/src/DefaultTemplate/HeaderLayout/SpaceHeader.ts +++ b/packages/templates/default/src/DefaultTemplate/HeaderLayout/SpaceHeader.ts @@ -1,6 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { peopleTooltip } from '../PeopleTooltip.ts'; +import { peopleRow, peopleTooltip } from '@we/template-kit'; export const spaceHeader: SchemaNode = { type: 'Column', @@ -99,67 +98,13 @@ export const spaceHeader: SchemaNode = { // The faces and the count are one thing to a reader — "12 Members" — so the // roster hangs off both rather than off the avatars alone. Hovering the words // and getting nothing was the tell that the tooltip belonged out here. - peopleTooltip({ + peopleRow({ items: { $store: 'spaceStore.members' }, - image: '$person.avatar', - hash: '$person.did', - name: '$person.name', - children: [ - { - type: 'Row', - // `AvatarStack` is a flex container over its avatars, so with none it has - // no children and no height. Members resolve on their own path, later than - // the space itself, so this collapsed and then pushed the header down a - // second time. A fixed floor is right here rather than a workaround: the - // row holds fixed-size avatars, so its height depends on neither the count - // nor on any font metric. - props: { gap: '300', ay: 'center', mt: '200', minHeight: '32px' }, - children: [ - { - type: 'AvatarStack', - props: { - avatars: { - $map: { - items: { $store: 'spaceStore.members' }, - select: { - image: '$item.avatar', - hash: '$item.did', - }, - }, - }, - max: 5, - size: 'sm', - ring: '0 0 0 2px var(--we-ring-color)', - }, - }, - { - type: 'Row', - props: { gap: '100', ay: 'center' }, - children: [ - { - type: 'we-number', - props: { - value: { $count: { items: { $store: 'spaceStore.members' } } }, - shorten: true, - }, - }, - { - type: 'we-text', - children: [ - { - $plural: { - count: { $count: { items: { $store: 'spaceStore.members' } } }, - one: 'Member', - other: 'Members', - }, - }, - ], - }, - ], - }, - ], - }, - ], + noun: 'Member', + // Members resolve on their own path, later than the space itself, so without + // a floor this row collapsed and then pushed the header down a second time. + minHeight: '32px', + rowProps: { mt: '200' }, }), ], }, @@ -283,6 +228,12 @@ export const spaceNavBar: SchemaNode = { condition: { $gt: [{ $count: { items: { $store: 'presenceStore.online' } } }, 0] }, // The count, the label and the faces are one statement, so the roster covers all // three — "3 online now" is as much the hover target as the avatars are. + // + // Written out rather than built with `peopleRow`: this one puts the count first, + // carries a liveness `tone` per avatar and has no ring, so routing it through the + // fragment would mean three options serving one call site. Two instances of a + // shape is a coincidence; the members row above and the participant rows on cards + // are the three that made a fragment worth having. then: peopleTooltip({ items: { $store: 'presenceStore.online' }, image: '$person.avatar', diff --git a/packages/templates/default/src/DefaultTemplate/InitializeSpaceGate.ts b/packages/templates/default/src/DefaultTemplate/InitializeSpaceGate.ts index 07daca4db..4ce958df8 100644 --- a/packages/templates/default/src/DefaultTemplate/InitializeSpaceGate.ts +++ b/packages/templates/default/src/DefaultTemplate/InitializeSpaceGate.ts @@ -1,12 +1,16 @@ import type { SchemaNode } from '@we/schema-shared'; +import { field, gatePrompt } from '@we/template-kit'; // Shown when a joined perspective has some other app's SDNA installed (e.g. a Flux // Community) but not WE's Space model — offers to add WE's space features in place, // prefilled from that foreign app's own data where recognized (spaceStore.foreignSpacePrefill). -export const initializeSpaceGate: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600', overflow: 'auto' }, - $localState: { +export const initializeSpaceGate: SchemaNode = gatePrompt({ + icon: 'rocket', + title: 'Set Up This Space in WE', + body: "This space was created in another app. Add WE's space features — templates, themes, signals — to enable it here.", + // The form makes this the one prompt tall enough to overflow on a short window. + scroll: true, + localState: { name: { type: 'string', initial: { $store: 'spaceStore.foreignSpacePrefill.name' }, @@ -17,15 +21,6 @@ export const initializeSpaceGate: SchemaNode = { submitting: { type: 'boolean', initial: false }, }, children: [ - { type: 'we-icon', props: { name: 'rocket', size: 'xl' } }, - { type: 'we-text', props: { variant: 'heading-md', textAlign: 'center' }, children: ['Set Up This Space in WE'] }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: 'var(--we-layout-xs)' }, - children: [ - "This space was created in another app. Add WE's space features — templates, themes, signals — to enable it here.", - ], - }, { type: 'Column', props: { width: '100%', maxWidth: '400px', gap: '300' }, @@ -53,37 +48,8 @@ export const initializeSpaceGate: SchemaNode = { }, ], }, - { - type: 'we-form-field', - props: { label: 'Name', error: { $if: { condition: { $error: 'name' }, then: { $error: 'name' } } } }, - children: [ - { - type: 'we-input', - props: { - bg: 'neutral-25', - placeholder: 'Space name...', - value: { $local: 'name' }, - onInput: { $setLocal: 'name', from: '$event.detail' }, - onBlur: { $touch: 'name' }, - }, - }, - ], - }, - { - type: 'we-form-field', - props: { label: 'Description' }, - children: [ - { - type: 'we-input', - props: { - bg: 'neutral-25', - placeholder: 'Description (optional)', - value: { $local: 'description' }, - onInput: { $setLocal: 'description', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'name', label: 'Name', placeholder: 'Space name...', validated: true, touchOnBlur: true }), + field({ name: 'description', label: 'Description', placeholder: 'Description (optional)' }), { type: 'we-button', props: { @@ -111,4 +77,4 @@ export const initializeSpaceGate: SchemaNode = { ], }, ], -}; +}); diff --git a/packages/templates/default/src/DefaultTemplate/SpaceGate.ts b/packages/templates/default/src/DefaultTemplate/SpaceGate.ts index 8239e014d..75c591fc8 100644 --- a/packages/templates/default/src/DefaultTemplate/SpaceGate.ts +++ b/packages/templates/default/src/DefaultTemplate/SpaceGate.ts @@ -1,4 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; +import { gatePrompt } from '@we/template-kit'; // The join prompt body differs depending on whether this is the WE global discovery space // or just a regular shared space the user hasn't joined yet. @@ -59,63 +60,32 @@ function joinControls(label: string): SchemaNode[] { ]; } -const globalSpaceJoinPrompt: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'globe-hemisphere-west', size: 'xl', gradient: 'primary' } }, - { - type: 'we-text', - props: { variant: 'heading-md', textAlign: 'center' }, - children: ['Join the global discovery space'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: 'var(--we-layout-xs)' }, - children: [ - "This is the WE network's global discovery space — find communities and people from across the network. Join to explore spaces on the globe and connect with others.", - ], - }, - ...joinControls('Join'), - ], -}; +const globalSpaceJoinPrompt: SchemaNode = gatePrompt({ + icon: 'globe-hemisphere-west', + iconGradient: 'primary', + title: 'Join the global discovery space', + body: "This is the WE network's global discovery space — find communities and people from across the network. Join to explore spaces on the globe and connect with others.", + children: joinControls('Join'), +}); -const regularSpaceJoinPrompt: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'lock', size: 'xl', gradient: 'primary' } }, - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Join this Space'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: '400px' }, - children: ["You haven't joined this space yet. Click below to connect and start collaborating."], - }, - ...joinControls('Join Space'), - ], -}; +const regularSpaceJoinPrompt: SchemaNode = gatePrompt({ + icon: 'lock', + iconGradient: 'primary', + title: 'Join this Space', + body: "You haven't joined this space yet. Click below to connect and start collaborating.", + children: joinControls('Join Space'), +}); -const notConfiguredPrompt: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'warning', size: 'xl' } }, - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Global space not configured'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: '400px' }, - children: ['No global space URL has been set in we-seed.json. Add a globalSpaceUrl to enable joining.'], - }, - ], -}; +/* + A dead end rather than an invitation, and it says so before it is read: a flat warning icon + instead of the gradient the join prompts carry. Nothing the reader does here changes it — the + seed file is not theirs to edit from inside the app. +*/ +const notConfiguredPrompt: SchemaNode = gatePrompt({ + icon: 'warning', + title: 'Global space not configured', + body: 'No global space URL has been set in we-seed.json. Add a globalSpaceUrl to enable joining.', +}); // Shown when the user has not yet joined the space. // Renders a join prompt, or a config warning if no global space URL is set. diff --git a/packages/templates/default/src/DefaultTemplate/routes/AboutRoute/index.ts b/packages/templates/default/src/DefaultTemplate/routes/AboutRoute/index.ts index 7939a31e7..298f359de 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/AboutRoute/index.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/AboutRoute/index.ts @@ -1,286 +1,150 @@ import type { RouteSchema } from '@we/schema-shared'; +import { attributeRow, pageShell, sectionCard } from '@we/template-kit'; export const aboutRoute: RouteSchema = { path: '/about', - type: 'Column', - props: { width: '100%', ax: 'center' }, - children: [ - { - type: 'Column', - props: { width: '100%', maxWidth: 'var(--we-layout-lg)', gap: '500', px: '400', pt: '500' }, - children: [ - // ─── Name & Description ─────────────────────────────────────────── - { - type: 'Card', - props: { bg: 'neutral-100', border: '1px solid neutral-200' }, - children: [ - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['About this space'], - }, - - // Name field - { - type: 'Column', - props: { gap: '100' }, - children: [ - { type: 'we-text', props: { color: 'neutral-700' }, children: ['Name'] }, - { - type: 'we-text', - props: { - variant: 'heading-md', - color: 'neutral-1000', - loading: { $not: { $store: 'spaceStore.currentSpace' } }, - loadingWidth: '220px', - }, - children: [{ $store: 'spaceStore.currentSpace.name' }], + ...pageShell({ + children: [ + sectionCard({ + title: 'About this space', + children: [ + // Name field + { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'we-text', props: { color: 'neutral-700' }, children: ['Name'] }, + { + type: 'we-text', + props: { + variant: 'heading-md', + color: 'neutral-1000', + loading: { $not: { $store: 'spaceStore.currentSpace' } }, + loadingWidth: '220px', }, - ], - }, + children: [{ $store: 'spaceStore.currentSpace.name' }], + }, + ], + }, - // Description field - { - type: 'Column', - props: { gap: '100' }, - children: [ - { type: 'we-text', props: { color: 'neutral-700' }, children: ['Description'] }, - // Waits on the space rather than reading through it: the inner condition tests - // `description`, which is falsy while unloaded, so on its own it claimed "No - // description..." about a space it had not seen yet. - { - type: '$if', - props: { - condition: { $store: 'spaceStore.currentSpace' }, - then: { - type: '$if', - props: { - condition: { $store: 'spaceStore.currentSpace.description' }, - then: { type: 'we-text', children: [{ $store: 'spaceStore.currentSpace.description' }] }, - else: { - type: 'we-text', - props: { italic: true }, - children: ['No description...'], - }, + // Description field + { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'we-text', props: { color: 'neutral-700' }, children: ['Description'] }, + // Waits on the space rather than reading through it: the inner condition tests + // `description`, which is falsy while unloaded, so on its own it claimed "No + // description..." about a space it had not seen yet. + { + type: '$if', + props: { + condition: { $store: 'spaceStore.currentSpace' }, + then: { + type: '$if', + props: { + condition: { $store: 'spaceStore.currentSpace.description' }, + then: { type: 'we-text', children: [{ $store: 'spaceStore.currentSpace.description' }] }, + else: { + type: 'we-text', + props: { italic: true }, + children: ['No description...'], }, }, - else: { type: 'we-text', props: { loading: true, loadingWidth: '320px' } }, }, + else: { type: 'we-text', props: { loading: true, loadingWidth: '320px' } }, }, - ], - }, + }, + ], + }, - // Access - { - type: 'Row', - props: { ay: 'center', gap: '400', py: '100' }, - children: [ - { type: 'we-icon', props: { name: 'lock-simple', color: 'primary-600' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Access:'], - }, - { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: [ - { - $if: { - condition: { $store: 'spaceStore.currentSpace.url' }, - then: 'Shared', - else: 'Personal', - }, - }, - ], - }, - ], - }, - { - type: 'we-text', - props: { variant: 'body' }, - children: [ - { - $if: { - condition: { $store: 'spaceStore.currentSpace.url' }, - then: 'Joinable by anyone with the link', - else: 'Only visible to you', - }, - }, - ], - }, - ], - }, - ], + attributeRow({ + icon: 'lock-simple', + label: 'Access', + value: { $if: { condition: { $store: 'spaceStore.currentSpace.url' }, then: 'Shared', else: 'Personal' } }, + description: { + $if: { + condition: { $store: 'spaceStore.currentSpace.url' }, + then: 'Joinable by anyone with the link', + else: 'Only visible to you', + }, }, + }), - // Discovery - { - type: 'Row', - props: { ay: 'center', gap: '400', py: '100' }, - children: [ - { type: 'we-icon', props: { name: 'globe', color: 'primary-600' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Discovery:'], - }, - { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: [ - { - $if: { - condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - then: 'Listed', - else: 'Hidden', - }, - }, - ], - }, - ], - }, - { - type: 'we-text', - props: { variant: 'body' }, - children: [ - { - $if: { - condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - then: 'Appears on the WE discovery globe', - else: 'Not shown in global discovery', - }, - }, - ], - }, - ], - }, - ], + attributeRow({ + icon: 'globe', + label: 'Discovery', + value: { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + then: 'Listed', + else: 'Hidden', + }, + }, + description: { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + then: 'Appears on the WE discovery globe', + else: 'Not shown in global discovery', + }, }, + }), - // Location (if set) - { - type: '$if', - props: { - condition: { $store: 'spaceStore.currentSpace.location' }, - then: { - type: 'Row', - props: { ay: 'center', gap: '400', py: '100' }, - children: [ - { type: 'we-icon', props: { name: 'map-pin', color: 'primary-600' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Location:'], - }, - { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: [ - { - $concat: [ - { $store: 'spaceStore.currentSpace.location.city' }, - ', ', - { $store: 'spaceStore.currentSpace.location.country' }, - ], - }, - ], - }, - ], - }, - ], - }, + // Only when set: an absent location is not a fact about the space worth stating. + { + type: '$if', + props: { + condition: { $store: 'spaceStore.currentSpace.location' }, + then: attributeRow({ + icon: 'map-pin', + label: 'Location', + value: { + $concat: [ + { $store: 'spaceStore.currentSpace.location.city' }, + ', ', + { $store: 'spaceStore.currentSpace.location.country' }, ], }, - }, + }), }, + }, - // History - { + attributeRow({ + icon: 'clock', + label: 'Created', + value: { + type: 'we-timestamp', + props: { value: { $store: 'spaceStore.currentSpace.createdAt' }, relative: true, fontWeight: 'bold' }, + }, + description: { type: 'Row', - props: { ay: 'center', gap: '400', py: '100' }, + props: { gap: '200', ay: 'center' }, children: [ - { type: 'we-icon', props: { name: 'clock', color: 'primary-600' } }, + { type: 'we-text', props: { variant: 'body' }, children: ['By'] }, { - type: 'Column', - props: { gap: '100' }, + type: '$agent', + props: { did: { $store: 'spaceStore.currentSpace.author' }, as: 'agent' }, children: [ { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Created:'], - }, - { - type: 'we-timestamp', - props: { - value: { $store: 'spaceStore.currentSpace.createdAt' }, - relative: true, - fontWeight: 'bold', - }, - }, - ], + type: 'we-avatar', + props: { + image: '$agent.avatar', + hash: { $store: 'spaceStore.currentSpace.author' }, + size: 'xs', + }, }, { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { type: 'we-text', props: { variant: 'body' }, children: ['By'] }, - { - type: '$agent', - props: { did: { $store: 'spaceStore.currentSpace.author' }, as: 'agent' }, - children: [ - { - type: 'we-avatar', - props: { - image: '$agent.avatar', - hash: { $store: 'spaceStore.currentSpace.author' }, - size: 'xs', - }, - }, - { - type: 'we-text', - props: { variant: 'body', truncate: true, maxWidth: '160px' }, - children: ['$agent.name'], - }, - ], - }, - ], + type: 'we-text', + props: { variant: 'body', truncate: true, maxWidth: '160px' }, + children: ['$agent.name'], }, ], }, ], }, - ], - }, - ], - }, - ], + }), + ], + }), + ], + }), }; diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/BlocksList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/BlocksList.ts index cd1850cf3..69e34c705 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/BlocksList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/BlocksList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState } from '@we/template-kit'; interface BlockSectionOptions { /** The `contentType` value this section is selected by, from the header's type picker. */ diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CallsList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CallsList.ts index 679f05148..c67b569b4 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CallsList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CallsList.ts @@ -1,8 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { peopleTooltip } from '../../PeopleTooltip.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { agentByline, cardList, cardShell, confirmModal, emptyState, field, peopleRow } from '@we/template-kit'; /** * Recorded calls in this space. @@ -100,66 +97,7 @@ export const callsList: SchemaNode = { // Just the faces here, not the utterance count beside them: that number is // about how much was said, not about who was there, so it is not part of the // same statement the way a member count is. - peopleTooltip({ - items: '$call.participants', - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$person' }, - select: 'avatar', - }, - }, - hash: { $concat: ['$person'] }, - name: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$person' }, - select: 'name', - }, - }, - children: [ - { - type: 'AvatarStack', - props: { - /* - `participants` is a list of DIDs, so each one is joined to the - profile that carries a picture. - - The lookup is inside the `select` rather than a filter over the - cache, because the ordering has to follow the *call's* roster — and - because `$filter` has no set-membership operator to express - "profiles whose did is in this list" with. - - `hash` is set unconditionally, never as a fallback for a missing - `image`: it seeds a generated avatar that is stable per agent, so - somebody whose profile has not arrived is still visually distinct - from everybody else whose profile has not arrived. A real picture - wins where there is one. - */ - avatars: { - $map: { - items: '$call.participants', - select: { - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$item' }, - select: 'avatar', - }, - }, - // Wrapped rather than written as a bare '$item': a plain string - // in a `select` is treated as a literal, and only a token object - // is resolved against the item context. - hash: { $concat: ['$item'] }, - }, - }, - }, - max: 5, - size: 'sm', - }, - }, - ], - }), + peopleRow({ items: '$call.participants', dids: true, as: 'participant' }), { type: 'we-text', props: { fontSize: '200', color: 'neutral-700' }, @@ -302,35 +240,13 @@ export const callsList: SchemaNode = { props: { close: { $setLocal: 'editOpen', value: false } }, children: [ { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['Edit call'] }, - { - type: 'we-form-field', - props: { label: 'Title' }, - children: [ - { - type: 'we-input', - props: { - value: { $local: 'titleDraft' }, - placeholder: 'What was this call about?', - onInput: { $setLocal: 'titleDraft', from: '$event.detail' }, - }, - }, - ], - }, - { - type: 'we-form-field', - props: { label: 'Description' }, - children: [ - { - type: 'we-textarea', - props: { - value: { $local: 'descriptionDraft' }, - rows: 3, - placeholder: 'Anything worth remembering about it', - onInput: { $setLocal: 'descriptionDraft', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'titleDraft', label: 'Title', placeholder: 'What was this call about?' }), + field({ + name: 'descriptionDraft', + label: 'Description', + control: 'textarea', + placeholder: 'Anything worth remembering about it', + }), { type: 'Row', props: { ax: 'end', gap: '200' }, @@ -364,74 +280,20 @@ export const callsList: SchemaNode = { }, }, }, - { - type: '$if', - props: { - condition: { $local: 'confirmDeleteOpen' }, - then: { - type: 'we-modal', - props: { close: { $setLocal: 'confirmDeleteOpen', value: false } }, - children: [ - { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['Delete call?'] }, - { - type: 'we-text', - children: [ - 'This will permanently delete the recording and every utterance in it. This cannot be undone.', - ], - }, - { - type: 'Row', - props: { ax: 'end', gap: '200' }, - children: [ - { - type: 'we-button', - props: { - variant: 'ghost', - onClick: { $setLocal: 'confirmDeleteOpen', value: false }, - }, - children: ['Cancel'], - }, - { - type: 'we-button', - props: { - variant: 'danger', - /* - The delete is not instant — it walks the collection and - removes every utterance under it, so a long transcript - takes a visible moment. Without a spinner the button - absorbs the click and nothing happens, which reads as a - failure and invites a second click at a delete that is - already running. - - `deleting` is cleared in `onFinally` rather than - `onError`: on the success path the card unmounts with the - record, so the only state worth restoring is the one where - it did not, and a failure that left the button spinning - forever would be the worse end of that trade. - */ - loading: { $local: 'deleting' }, - disabled: { $local: 'deleting' }, - // The generic collection delete: a call record is a - // CollectionBlock like a post, and the recursive delete does - // not care which kind it is holding. - onClick: [ - { $setLocal: 'deleting', value: true }, - { - $action: 'spaceStore.deleteCollection', - args: ['$call.id'], - onSuccess: [{ $setLocal: 'confirmDeleteOpen', value: false }], - onFinally: [{ $setLocal: 'deleting', value: false }], - }, - ], - }, - children: ['Delete'], - }, - ], - }, - ], - }, - }, - }, + confirmModal({ + openLocal: 'confirmDeleteOpen', + title: 'Delete call?', + body: 'This will permanently delete the recording and every utterance in it. This cannot be undone.', + confirmLabel: 'Delete', + // The delete walks the collection and removes every utterance under it, so a + // long transcript takes a visible moment. Without the spinner the button + // absorbs the click and appears to have failed, inviting a second click at a + // delete already running. + busyLocal: 'deleting', + // A call record is a CollectionBlock like a post, and the recursive delete + // does not care which kind it is holding. + confirm: { $action: 'spaceStore.deleteCollection', args: ['$call.id'] }, + }), ], }, ], @@ -481,50 +343,16 @@ export const callsList: SchemaNode = { same idiom `PostsList` uses for a post's author — and it reaches anyone, not only the current space's members. */ - { - type: '$agent', - props: { did: '$utterance.author', as: 'speaker' }, - children: [ - { - type: 'Row', - props: { gap: '300', ay: 'start' }, - children: [ - { - type: 'we-avatar', - props: { size: 'sm', image: '$speaker.avatar', hash: '$speaker.did' }, - }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '200' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'semibold', color: 'neutral-600' }, - children: ['$speaker.name'], - }, - { - // When each utterance was written — which is when it was - // *said*, since a block is flushed as the speaker finishes. - type: 'we-timestamp', - props: { value: '$utterance.createdAt', relative: true, color: 'neutral-500' }, - }, - ], - }, - { - type: 'we-text', - props: { color: 'neutral-900' }, - children: ['$utterance.text'], - }, - ], - }, - ], - }, - ], - }, + agentByline({ + did: '$utterance.author', + as: 'speaker', + stacked: true, + nameColor: 'neutral-600', + // When each utterance was written — which is when it was *said*, since a + // block is flushed as the speaker finishes. + timestamp: '$utterance.createdAt', + children: [{ type: 'we-text', props: { color: 'neutral-900' }, children: ['$utterance.text'] }], + }), ], }, ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxChannelsList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxChannelsList.ts index ff2d8e2f6..7b95b7f48 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxChannelsList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxChannelsList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState, statChip } from '@we/template-kit'; // Flux's Channel model only exists in perspectives where Flux SDNA is installed (e.g. a // Flux community synced into WE). Guard on presence in currentPerspectiveModels so a plain @@ -80,24 +78,8 @@ export const fluxChannelsList: SchemaNode = { type: 'Row', props: { gap: '500', ay: 'center', wrap: true }, children: [ - { - type: 'Row', - props: { gap: '100', ay: 'center', flex: 'none' }, - children: [ - { type: 'we-icon', props: { name: 'chat-dots', size: 'sm', color: 'neutral-600' } }, - { type: 'we-number', props: { value: '$channel.$conversationCount' } }, - { type: 'we-text', props: { color: 'neutral-600' }, children: ['Conversations'] }, - ], - }, - { - type: 'Row', - props: { gap: '100', ay: 'center', flex: 'none' }, - children: [ - { type: 'we-icon', props: { name: 'envelope-simple', size: 'sm', color: 'neutral-600' } }, - { type: 'we-number', props: { value: '$channel.$messageCount' } }, - { type: 'we-text', props: { color: 'neutral-600' }, children: ['Messages'] }, - ], - }, + statChip({ icon: 'chat-dots', count: '$channel.$conversationCount', label: 'Conversations' }), + statChip({ icon: 'envelope-simple', count: '$channel.$messageCount', label: 'Messages' }), ], }, ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationSubgroupsList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationSubgroupsList.ts index 0ee2db0b1..bf107f36b 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationSubgroupsList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationSubgroupsList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState, peopleRow } from '@we/template-kit'; const hasConversationSubgroupModel = { $find: { items: { $store: 'datasetStore.currentDatasetModels' }, where: { name: 'ConversationSubgroup' } }, @@ -57,57 +55,7 @@ export const fluxConversationSubgroupsList: SchemaNode = { then: { type: 'we-text', props: { color: 'neutral-600' }, children: ['$subgroup.summary'] }, }, }, - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { - type: 'AvatarStack', - props: { - avatars: { - $map: { - items: '$subgroup.participants', - select: { - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$item' }, - select: 'avatar', - }, - }, - hash: '$item', - }, - }, - }, - max: 5, - size: 'sm', - ring: '0 0 0 2px var(--we-ring-color)', - }, - }, - { - type: 'Row', - props: { gap: '100' }, - children: [ - { - type: 'we-number', - props: { value: { $count: { items: '$subgroup.participants' } }, shorten: true }, - }, - { - type: 'we-text', - children: [ - { - $plural: { - count: { $count: { items: '$subgroup.participants' } }, - one: 'Participant', - other: 'Participants', - }, - }, - ], - }, - ], - }, - ], - }, + peopleRow({ items: '$subgroup.participants', dids: true, noun: 'Participant' }), ], }), ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsList.ts index dbc2c8b8f..d09dfbfb7 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState, peopleRow } from '@we/template-kit'; const hasConversationModel = { $find: { items: { $store: 'datasetStore.currentDatasetModels' }, where: { name: 'Conversation' } }, @@ -61,57 +59,7 @@ export const fluxConversationsList: SchemaNode = { then: { type: 'we-text', props: { color: 'neutral-600' }, children: ['$conversation.summary'] }, }, }, - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { - type: 'AvatarStack', - props: { - avatars: { - $map: { - items: '$conversation.participants', - select: { - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$item' }, - select: 'avatar', - }, - }, - hash: '$item', - }, - }, - }, - max: 5, - size: 'sm', - ring: '0 0 0 2px var(--we-ring-color)', - }, - }, - { - type: 'Row', - props: { gap: '100' }, - children: [ - { - type: 'we-number', - props: { value: { $count: { items: '$conversation.participants' } }, shorten: true }, - }, - { - type: 'we-text', - children: [ - { - $plural: { - count: { $count: { items: '$conversation.participants' } }, - one: 'Participant', - other: 'Participants', - }, - }, - ], - }, - ], - }, - ], - }, + peopleRow({ items: '$conversation.participants', dids: true, noun: 'Participant' }), ], }), ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsNestedList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsNestedList.ts index 6f9e3362d..9a2266cdc 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsNestedList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxConversationsNestedList.ts @@ -1,7 +1,5 @@ import type { LocalStateField, SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { agentByline, cardList, cardShell, emptyState, peopleRow } from '@we/template-kit'; const hasConversationModel = { $find: { items: { $store: 'datasetStore.currentDatasetModels' }, where: { name: 'Conversation' } }, @@ -106,28 +104,7 @@ const subgroupMessagesList: SchemaNode = { type: 'Column', props: { gap: '200', p: '400', bg: 'neutral-50', r: '300', border: '1px solid neutral-200' }, children: [ - { - type: '$agent', - props: { did: '$msg.author', as: 'author' }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { type: 'we-avatar', props: { size: 'sm', image: '$author.avatar', hash: '$author.did' } }, - { - type: 'we-text', - props: { fontWeight: 'semibold' }, - children: ['$author.name'], - }, - { - type: 'we-timestamp', - props: { value: '$msg.timestamp', relative: true, color: 'neutral-500' }, - }, - ], - }, - ], - }, + agentByline({ did: '$msg.author', timestamp: '$msg.timestamp' }), { type: 'we-html', props: { color: 'neutral-700', content: '$msg.body' } }, ], }, @@ -165,57 +142,7 @@ const subgroupCard: SchemaNode = withLocalState( then: { type: 'we-text', props: { color: 'neutral-600' }, children: ['$subgroup.summary'] }, }, }, - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { - type: 'AvatarStack', - props: { - avatars: { - $map: { - items: '$subgroup.participants', - select: { - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$item' }, - select: 'avatar', - }, - }, - hash: '$item', - }, - }, - }, - max: 5, - size: 'sm', - ring: '0 0 0 2px var(--we-ring-color)', - }, - }, - { - type: 'Row', - props: { gap: '100' }, - children: [ - { - type: 'we-number', - props: { value: { $count: { items: '$subgroup.participants' } }, shorten: true }, - }, - { - type: 'we-text', - children: [ - { - $plural: { - count: { $count: { items: '$subgroup.participants' } }, - one: 'Participant', - other: 'Participants', - }, - }, - ], - }, - ], - }, - ], - }, + peopleRow({ items: '$subgroup.participants', dids: true, noun: 'Participant' }), subgroupMessagesToggle, subgroupMessagesList, ], @@ -330,57 +257,7 @@ export const fluxConversationsNestedList: SchemaNode = { }, }, }, - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { - type: 'AvatarStack', - props: { - avatars: { - $map: { - items: '$conversation.participants', - select: { - image: { - $find: { - items: { $store: 'profileStore.profiles' }, - where: { did: '$item' }, - select: 'avatar', - }, - }, - hash: '$item', - }, - }, - }, - max: 5, - size: 'sm', - ring: '0 0 0 2px var(--we-ring-color)', - }, - }, - { - type: 'Row', - props: { gap: '100' }, - children: [ - { - type: 'we-number', - props: { value: { $count: { items: '$conversation.participants' } }, shorten: true }, - }, - { - type: 'we-text', - children: [ - { - $plural: { - count: { $count: { items: '$conversation.participants' } }, - one: 'Participant', - other: 'Participants', - }, - }, - ], - }, - ], - }, - ], - }, + peopleRow({ items: '$conversation.participants', dids: true, noun: 'Participant' }), conversationSubgroupsToggle, conversationSubgroupsList, ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxMessagesList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxMessagesList.ts index 574e6218d..4a1b921d7 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxMessagesList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/FluxMessagesList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { agentByline, cardList, cardShell, emptyState } from '@we/template-kit'; const hasMessageModel = { $find: { items: { $store: 'datasetStore.currentDatasetModels' }, where: { name: 'Message' } }, @@ -34,33 +32,7 @@ export const fluxMessagesList: SchemaNode = { empty: noRows, children: [ cardShell({ - header: [ - { - type: '$agent', - props: { did: '$message.author', as: 'author' }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { - type: 'we-avatar', - props: { size: 'sm', image: '$author.avatar', hash: '$author.did' }, - }, - { - type: 'we-text', - props: { fontWeight: 'semibold' }, - children: ['$author.name'], - }, - { - type: 'we-timestamp', - props: { value: '$message.createdAt', relative: true, color: 'neutral-500' }, - }, - ], - }, - ], - }, - ], + header: [agentByline({ did: '$message.author', timestamp: '$message.createdAt' })], body: [{ type: 'we-html', props: { content: '$message.body' } }], }), ], diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/PostsList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/PostsList.ts index deae16429..97a7101f9 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/PostsList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/PostsList.ts @@ -1,7 +1,6 @@ import type { SchemaNode } from '@we/schema-shared'; +import { agentByline, cardList, cardShell, confirmModal, emptyState } from '@we/template-kit'; -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; import { postComposerModal } from './PostComposerModal.ts'; export const postsList: SchemaNode = { @@ -75,35 +74,7 @@ export const postsList: SchemaNode = { editPostOpen: { type: 'boolean', initial: false }, }, children: [ - { - type: '$agent', - props: { did: '$post.author', as: 'author' }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { - type: 'we-avatar', - props: { - size: 'sm', - image: '$author.avatar', - hash: '$author.did', - }, - }, - { - type: 'we-text', - props: { fontWeight: 'semibold' }, - children: ['$author.name'], - }, - { - type: 'we-timestamp', - props: { value: '$post.createdAt', relative: true, color: 'neutral-500' }, - }, - ], - }, - ], - }, + agentByline({ did: '$post.author', timestamp: '$post.createdAt' }), { type: '$if', props: { @@ -145,51 +116,13 @@ export const postsList: SchemaNode = { }, children: [{ type: 'we-icon', props: { name: 'trash' } }], }, - { - type: '$if', - props: { - condition: { $local: 'confirmDeleteOpen' }, - then: { - type: 'we-modal', - props: { close: { $setLocal: 'confirmDeleteOpen', value: false } }, - children: [ - { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['Delete post?'] }, - { - type: 'we-text', - children: [ - 'This will permanently delete the post and everything inside it. This cannot be undone.', - ], - }, - { - type: 'Row', - props: { ax: 'end', gap: '200' }, - children: [ - { - type: 'we-button', - props: { - variant: 'ghost', - onClick: { $setLocal: 'confirmDeleteOpen', value: false }, - }, - children: ['Cancel'], - }, - { - type: 'we-button', - props: { - variant: 'danger', - onClick: { - $action: 'spaceStore.deleteCollection', - args: ['$post.id'], - onSuccess: [{ $setLocal: 'confirmDeleteOpen', value: false }], - }, - }, - children: ['Delete'], - }, - ], - }, - ], - }, - }, - }, + confirmModal({ + openLocal: 'confirmDeleteOpen', + title: 'Delete post?', + body: 'This will permanently delete the post and everything inside it. This cannot be undone.', + confirmLabel: 'Delete', + confirm: { $action: 'spaceStore.deleteCollection', args: ['$post.id'] }, + }), ], }, }, diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/SpacesList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/SpacesList.ts index 3447e9b5a..0bb6d4686 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/SpacesList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/SpacesList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, confirmModal, emptyState, statChip } from '@we/template-kit'; export const spacesList: SchemaNode = cardList({ query: { @@ -81,55 +79,13 @@ export const spacesList: SchemaNode = cardList({ }, children: [{ type: 'we-icon', props: { name: 'trash' } }], }, - { - type: '$if', - props: { - condition: { $local: 'confirmDeleteOpen' }, - then: { - type: 'we-modal', - props: { close: { $setLocal: 'confirmDeleteOpen', value: false } }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'semibold' }, - children: ['Remove from discovery?'], - }, - { - type: 'we-text', - children: [ - 'This will remove this space from the global discovery listing. The space and all its content will remain intact.', - ], - }, - { - type: 'Row', - props: { ax: 'end', gap: '200' }, - children: [ - { - type: 'we-button', - props: { - variant: 'ghost', - onClick: { $setLocal: 'confirmDeleteOpen', value: false }, - }, - children: ['Cancel'], - }, - { - type: 'we-button', - props: { - variant: 'danger', - onClick: { - $action: 'model.delete', - args: ['Space', '$space.id'], - onSuccess: [{ $setLocal: 'confirmDeleteOpen', value: false }], - }, - }, - children: ['Remove'], - }, - ], - }, - ], - }, - }, - }, + confirmModal({ + openLocal: 'confirmDeleteOpen', + title: 'Remove from discovery?', + body: 'This will remove this space from the global discovery listing. The space and all its content will remain intact.', + confirmLabel: 'Remove', + confirm: { $action: 'model.delete', args: ['Space', '$space.id'] }, + }), ], }, }, @@ -149,55 +105,25 @@ export const spacesList: SchemaNode = cardList({ type: 'Row', props: { gap: '500', ay: 'center', wrap: true }, children: [ - { - type: 'Row', - props: { gap: '100', ay: 'center', flex: 'none' }, - children: [ - { type: 'we-icon', props: { name: 'lock-simple', size: 'sm', color: 'neutral-600' } }, - { type: 'we-text', props: { color: 'neutral-600' }, children: ['Access:'] }, - { - type: 'we-text', - props: { color: 'neutral-800' }, - children: [{ $if: { condition: '$space.url', then: 'Shared', else: 'Personal' } }], - }, - ], - }, - { - type: 'Row', - props: { gap: '100', ay: 'center', flex: 'none' }, - children: [ - { type: 'we-icon', props: { name: 'globe', size: 'sm', color: 'neutral-600' } }, - { type: 'we-text', props: { color: 'neutral-600' }, children: ['Discovery:'] }, - { - type: 'we-text', - props: { color: 'neutral-800' }, - children: [ - { $if: { condition: { $eq: ['$space.discovery', 'listed'] }, then: 'Listed', else: 'Hidden' } }, - ], - }, - ], - }, + statChip({ + icon: 'lock-simple', + label: 'Access', + value: { $if: { condition: '$space.url', then: 'Shared', else: 'Personal' } }, + }), + statChip({ + icon: 'globe', + label: 'Discovery', + value: { $if: { condition: { $eq: ['$space.discovery', 'listed'] }, then: 'Listed', else: 'Hidden' } }, + }), { type: '$if', props: { condition: '$space.location', - then: { - type: 'Row', - props: { gap: '100', ay: 'center', flex: 'none' }, - children: [ - { type: 'we-icon', props: { name: 'map-pin', size: 'sm', color: 'neutral-600' } }, - { - type: 'we-text', - props: { color: 'neutral-600' }, - children: ['Location:'], - }, - { - type: 'we-text', - props: { color: 'neutral-800' }, - children: [{ $concat: ['$space.location.city', ', ', '$space.location.country'] }], - }, - ], - }, + then: statChip({ + icon: 'map-pin', + label: 'Location', + value: { $concat: ['$space.location.city', ', ', '$space.location.country'] }, + }), }, }, { diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/TemplatesList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/TemplatesList.ts index 46b91d07b..e09f73a3f 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/TemplatesList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/TemplatesList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState } from '@we/template-kit'; export const templatesList: SchemaNode = cardList({ query: { diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/ThemesList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/ThemesList.ts index 09f8d0086..8b006dc89 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/ThemesList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/ThemesList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState } from '@we/template-kit'; export const themesList: SchemaNode = cardList({ query: { diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/UsersList.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/UsersList.ts index be5d71522..de7552c9a 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/UsersList.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/UsersList.ts @@ -1,7 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyState } from '../../EmptyState.ts'; -import { cardList, cardShell } from './CardShell.ts'; +import { cardList, cardShell, emptyState } from '@we/template-kit'; export const usersList: SchemaNode = cardList({ // The roster is already in the store, so this list is filtered in place rather than queried — diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/index.ts b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/index.ts index 023901be8..597ee572c 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/index.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/index.ts @@ -1,4 +1,5 @@ import type { RouteSchema } from '@we/schema-shared'; +import { pageShell } from '@we/template-kit'; import { createSpaceModal } from '@we/template-shell'; import { blocksList } from './BlocksList.ts'; @@ -34,8 +35,6 @@ const NON_BLOCK_CONTENT_TYPES = [ export const cardsRoute: RouteSchema = { path: '/cards', - type: 'Column', - props: { width: '100%', ax: 'center' }, $localState: { createPostOpen: { type: 'boolean', initial: false }, createSpaceModalOpen: { type: 'boolean', initial: false }, @@ -45,63 +44,57 @@ export const cardsRoute: RouteSchema = { displayMode: { type: 'string', initial: 'expanded' }, searchText: { type: 'string', initial: '' }, }, - children: [ - { - type: 'Column', - props: { - gap: '400', - px: '600', - py: '400', - width: '100%', - maxWidth: 'var(--we-layout-lg)', - minHeight: 'calc(100vh - 70px)', - }, - children: [ - cardsHeader, + ...pageShell({ + gap: '400', + px: '600', + py: '400', + // Holds the grid open on a space with little content — the viewport, less the nav bar. + minHeight: 'calc(100vh - 70px)', + children: [ + cardsHeader, - { type: '$if', props: { condition: { $local: 'createPostOpen' }, then: createPostModal } }, - { type: '$if', props: { condition: { $local: 'createSpaceModalOpen' }, then: createSpaceModal } }, + { type: '$if', props: { condition: { $local: 'createPostOpen' }, then: createPostModal } }, + { type: '$if', props: { condition: { $local: 'createSpaceModalOpen' }, then: createSpaceModal } }, - { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'posts'] }, then: postsList } }, - callsList, - { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'users'] }, then: usersList } }, - { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'spaces'] }, then: spacesList } }, - { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'templates'] }, then: templatesList } }, - { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'themes'] }, then: themesList } }, - { - type: '$if', - props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-channels'] }, then: fluxChannelsList }, - }, - { - type: '$if', - props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-conversations'] }, then: fluxConversationsList }, - }, - { - type: '$if', - props: { - condition: { $eq: [{ $local: 'contentType' }, 'flux-conversations-nested'] }, - then: fluxConversationsNestedList, - }, - }, - { - type: '$if', - props: { - condition: { $eq: [{ $local: 'contentType' }, 'flux-conversation-subgroups'] }, - then: fluxConversationSubgroupsList, - }, + { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'posts'] }, then: postsList } }, + callsList, + { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'users'] }, then: usersList } }, + { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'spaces'] }, then: spacesList } }, + { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'templates'] }, then: templatesList } }, + { type: '$if', props: { condition: { $eq: [{ $local: 'contentType' }, 'themes'] }, then: themesList } }, + { + type: '$if', + props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-channels'] }, then: fluxChannelsList }, + }, + { + type: '$if', + props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-conversations'] }, then: fluxConversationsList }, + }, + { + type: '$if', + props: { + condition: { $eq: [{ $local: 'contentType' }, 'flux-conversations-nested'] }, + then: fluxConversationsNestedList, }, - { - type: '$if', - props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-messages'] }, then: fluxMessagesList }, + }, + { + type: '$if', + props: { + condition: { $eq: [{ $local: 'contentType' }, 'flux-conversation-subgroups'] }, + then: fluxConversationSubgroupsList, }, - { - type: '$if', - props: { - condition: { $not: { $in: [{ $local: 'contentType' }, NON_BLOCK_CONTENT_TYPES] } }, - then: blocksList, - }, + }, + { + type: '$if', + props: { condition: { $eq: [{ $local: 'contentType' }, 'flux-messages'] }, then: fluxMessagesList }, + }, + { + type: '$if', + props: { + condition: { $not: { $in: [{ $local: 'contentType' }, NON_BLOCK_CONTENT_TYPES] } }, + then: blocksList, }, - ], - }, - ], + }, + ], + }), }; diff --git a/packages/templates/default/src/DefaultTemplate/routes/HomeRoute/index.ts b/packages/templates/default/src/DefaultTemplate/routes/HomeRoute/index.ts index cc95314d7..f9fb87c03 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/HomeRoute/index.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/HomeRoute/index.ts @@ -7,6 +7,7 @@ */ import type { RouteSchema } from '@we/schema-shared'; +import { gatePrompt } from '@we/template-kit'; export const homeRoute: RouteSchema = { path: '/', @@ -79,26 +80,29 @@ export const homeRoute: RouteSchema = { }, ], }, - // Empty state — shown inline when there are no spaces yet + // Empty state — shown inline when there are no spaces yet. + // + // `$count` rather than reading `.length` off the store path: the latter worked, but it was + // the only list in the template asking that way, and it silently returns nothing on a store + // that hands back anything other than a plain array. { type: '$if', props: { - condition: { $not: { $store: 'spaceStore.orderedSidebarItems.length' } }, + condition: { $not: { $count: { items: { $store: 'spaceStore.orderedSidebarItems' } } } }, then: { type: 'Card', props: { ax: 'center', bg: 'neutral-0', width: '100%' }, children: [ - { type: 'we-icon', props: { name: 'plus-circle', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { variant: 'subheading', textAlign: 'center' }, - children: ['No spaces yet'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center' }, - children: ['Create or join a space to get started.'], - }, + // Inside a card that has its own flow, so it does not claim the height a page-level + // gate does. + gatePrompt({ + icon: 'plus-circle', + iconColor: 'neutral-300', + title: 'No spaces yet', + body: 'Create or join a space to get started.', + fill: false, + gap: '300', + }), ], }, }, diff --git a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/CreateSignalTypeModal.ts b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/CreateSignalTypeModal.ts index 1cf9db656..6aed8d1f1 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/CreateSignalTypeModal.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/CreateSignalTypeModal.ts @@ -1,3 +1,5 @@ +import { field } from '@we/template-kit'; + export const createSignalTypeModal = { type: 'we-modal', props: { close: { $setLocal: 'createSignalTypeOpen', value: false }, maxWidth: '500px', width: '100%' }, @@ -22,52 +24,18 @@ export const createSignalTypeModal = { }, // Name - { - type: 'we-form-field', - props: { label: 'Name' }, - children: [ - { - type: 'we-input', - props: { - placeholder: 'e.g. Like', - value: { $local: 'name' }, - onInput: { $setLocal: 'name', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'name', label: 'Name', placeholder: 'e.g. Like' }), // Slug - { - type: 'we-form-field', - props: { label: 'Slug', description: 'Auto-generated from name. Used in schemas to reference this signal type.' }, - children: [ - { - type: 'we-input', - props: { - placeholder: 'e.g. like', - value: { $local: 'slug' }, - onInput: { $setLocal: 'slug', from: '$event.detail' }, - }, - }, - ], - }, + field({ + name: 'slug', + label: 'Slug', + description: 'Auto-generated from name. Used in schemas to reference this signal type.', + placeholder: 'e.g. like', + }), // Description - { - type: 'we-form-field', - props: { label: 'Description' }, - children: [ - { - type: 'we-textarea', - props: { - placeholder: 'Description', - value: { $local: 'description' }, - onInput: { $setLocal: 'description', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'description', label: 'Description', control: 'textarea', placeholder: 'Description' }), // Mode & icon selectors { @@ -75,25 +43,7 @@ export const createSignalTypeModal = { props: { gap: '400', ax: 'center', wrap: true }, children: [ // Mode selector - { - type: 'we-form-field', - props: { label: 'Mode' }, - children: [ - { - type: 'we-select', - props: { - value: { $local: 'mode' }, - onChange: { $setLocal: 'mode', from: '$event.target.value' }, - options: [ - { label: 'Toggle', value: 'toggle' }, - { label: 'Vote', value: 'vote' }, - { label: 'Rating', value: 'rating' }, - { label: 'Slider', value: 'slider' }, - ], - }, - }, - ], - }, + field({ name: 'mode', label: 'Mode', control: 'select' }), // Primary icon { diff --git a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/MarketplaceBrowser.ts b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/MarketplaceBrowser.ts index cff728c18..26edbf1fd 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/MarketplaceBrowser.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/MarketplaceBrowser.ts @@ -1,87 +1,26 @@ import type { SchemaNode } from '@we/schema-shared'; +import { marketplaceList } from '@we/template-kit'; -export const marketplaceBrowser: SchemaNode = { - type: 'Column', - props: { gap: '300' }, - $localState: { - search: { type: 'string', initial: '' }, - }, - $queries: { - marketplaceTemplates: { - entity: 'Template', - dataset: 'datasetStore.marketplaceDataset', - subscribe: true, - }, - }, - children: [ - { - type: 'Search', - props: { - placeholder: 'Search marketplace…', - value: { $local: 'search' }, - onSearch: { $setLocal: 'search', from: '$arg' }, - width: '100%', - }, - }, - { - type: '$if', - props: { - condition: { $count: { items: { $local: 'marketplaceTemplates' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { - items: { - $filter: { - items: { $local: 'marketplaceTemplates' }, - where: { name: { contains: { $local: 'search' } } }, - }, - }, - as: 'marketplaceTemplate', - }, - children: [ - { - type: 'TemplateCard', - props: { - template: '$marketplaceTemplate', - mode: 'compact', - installLabel: 'Install to Space', - installed: { - $count: { - items: { - $filter: { - items: { $store: 'templateStore.spaceTemplates' }, - where: { id: '$marketplaceTemplate.slug' }, - }, - }, - }, - }, - onInstall: { - $action: 'templateStore.installToSpace', - args: ['$marketplaceTemplate.id'], - }, - }, - }, - ], - }, - ], - }, - else: { - type: 'Column', - props: { ay: 'center', ax: 'center', p: '500', gap: '200' }, - children: [ - { type: 'we-icon', props: { name: 'layout', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['No templates available in the marketplace yet.'], - }, - ], +/** The compact form, for the panel inside space settings — installs to the space, not to you. */ +export const marketplaceBrowser: SchemaNode = marketplaceList({ + entity: 'Template', + as: 'marketplaceTemplate', + label: 'templates', + emptyIcon: 'layout', + layout: 'list', + card: { + mode: 'compact', + installLabel: 'Install to Space', + installed: { + $count: { + items: { + $filter: { + items: { $store: 'templateStore.spaceTemplates' }, + where: { id: '$marketplaceTemplate.slug' }, + }, }, }, }, - ], -}; + onInstall: { $action: 'templateStore.installToSpace', args: ['$marketplaceTemplate.id'] }, + }, +}); diff --git a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/ThemeMarketplaceBrowser.ts b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/ThemeMarketplaceBrowser.ts index b7b069bfe..0e629cead 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/ThemeMarketplaceBrowser.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/ThemeMarketplaceBrowser.ts @@ -1,86 +1,25 @@ import type { SchemaNode } from '@we/schema-shared'; +import { marketplaceList } from '@we/template-kit'; -export const themeMarketplaceBrowser: SchemaNode = { - type: 'Column', - props: { gap: '300' }, - $localState: { - search: { type: 'string', initial: '' }, - }, - $queries: { - marketplaceThemes: { - entity: 'Theme', - dataset: 'datasetStore.marketplaceDataset', - subscribe: true, - }, - }, - children: [ - { - type: 'Search', - props: { - placeholder: 'Search themes…', - value: { $local: 'search' }, - onSearch: { $setLocal: 'search', from: '$arg' }, - width: '100%', +/** The compact form, for the panel inside space settings. */ +export const themeMarketplaceBrowser: SchemaNode = marketplaceList({ + entity: 'Theme', + as: 'marketplaceTheme', + label: 'themes', + emptyIcon: 'paint-bucket', + layout: 'list', + card: { + mode: 'compact', + installed: { + $find: { + items: { $store: 'themeStore.installedThemes' }, + where: { name: '$marketplaceTheme.name' }, + select: 'version', }, }, - { - type: '$if', - props: { - condition: { $count: { items: { $local: 'marketplaceThemes' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { - items: { - $filter: { - items: { $local: 'marketplaceThemes' }, - where: { name: { contains: { $local: 'search' } } }, - }, - }, - as: 'marketplaceTheme', - }, - children: [ - { - type: 'TemplateCard', - props: { - template: '$marketplaceTheme', - mode: 'compact', - installed: { - $find: { - items: { $store: 'themeStore.installedThemes' }, - where: { name: '$marketplaceTheme.name' }, - select: 'version', - }, - }, - onInstall: { $action: 'themeStore.installFromMarketplace', args: ['$marketplaceTheme.id'] }, - isLoading: { - $eq: [ - { $store: 'themeStore.operationLoading' }, - { $concat: ['marketplace-install:', '$marketplaceTheme.id'] }, - ], - }, - }, - }, - ], - }, - ], - }, - else: { - type: 'Column', - props: { ay: 'center', ax: 'center', p: '500', gap: '200' }, - children: [ - { type: 'we-icon', props: { name: 'paint-bucket', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['No themes available in the marketplace yet.'], - }, - ], - }, - }, + onInstall: { $action: 'themeStore.installFromMarketplace', args: ['$marketplaceTheme.id'] }, + isLoading: { + $eq: [{ $store: 'themeStore.operationLoading' }, { $concat: ['marketplace-install:', '$marketplaceTheme.id'] }], }, - ], -}; + }, +}); diff --git a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/index.ts b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/index.ts index 72adb823e..a8ec073d1 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/index.ts +++ b/packages/templates/default/src/DefaultTemplate/routes/SettingsRoute/index.ts @@ -1,104 +1,84 @@ -import type { RouteSchema, SchemaNode } from '@we/schema-shared'; +import type { RouteSchema, SchemaNode, SchemaProp } from '@we/schema-shared'; +import { attributeRow, pageShell, sectionCard } from '@we/template-kit'; import { createSignalTypeModal } from './CreateSignalTypeModal.ts'; import { marketplaceBrowser } from './MarketplaceBrowser.ts'; import { signalTypeCard } from './SignalTypeCard.ts'; import { themeMarketplaceBrowser } from './ThemeMarketplaceBrowser.ts'; -const templateRow: SchemaNode = { - type: 'Row', - props: { - ay: 'center', - ax: 'between', - p: '300', - r: '300', - bg: { - $if: { - condition: { $eq: ['$template.id', { $store: 'spaceStore.spaceDefaultTemplateId' }] }, - then: 'neutral-200', - else: 'neutral-50', - }, - }, - }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { type: 'we-icon', props: { name: '$template.meta.icon' } }, - { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['$template.meta.name'] }, - ], - }, - { - type: '$if', - props: { - condition: { $eq: ['$template.id', { $store: 'spaceStore.spaceDefaultTemplateId' }] }, - then: { - type: 'we-badge', - props: { variant: 'primary', size: 'sm' }, - children: ['Default'], - }, - else: { - type: 'we-button', - props: { - variant: 'secondary', - size: 'sm', - onClick: { $action: 'spaceStore.setSpaceDefaultTemplate', args: ['$template.id'] }, - }, - children: ['Set as default'], - }, - }, - }, - ], -}; +/** + * A row in one of the "what do members see by default" pickers. + * + * Templates and themes had a row each, identical down to the padding, and they had already begun to + * drift — one compared `$template.id` against the space default while the marketplace list compared + * `$template.slug`. One generator, two calls, one comparison. + * + * The current default states itself with a badge rather than a disabled button: there is nothing to + * press on the row you are already using, and a greyed-out "Set as default" invites the press anyway. + */ +const defaultPickerRow = (opts: { + as: string; + icon: SchemaProp; + name: string; + currentDefault: string; + setDefault: string; +}): SchemaNode => { + const isDefault = { $eq: [`$${opts.as}.id`, { $store: opts.currentDefault }] }; -const themeRow: SchemaNode = { - type: 'Row', - props: { - ay: 'center', - ax: 'between', - p: '300', - r: '300', - bg: { - $if: { - condition: { $eq: ['$theme.id', { $store: 'spaceStore.spaceDefaultThemeId' }] }, - then: 'neutral-200', - else: 'neutral-50', - }, - }, - }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { type: 'we-icon', props: { name: '$theme.icon' } }, - { type: 'we-text', props: { fontWeight: 'semibold' }, children: ['$theme.name'] }, - ], + return { + type: 'Row', + props: { + ay: 'center', + ax: 'between', + p: '300', + r: '300', + bg: { $if: { condition: isDefault, then: 'neutral-200', else: 'neutral-50' } }, }, - { - type: '$if', - props: { - condition: { $eq: ['$theme.id', { $store: 'spaceStore.spaceDefaultThemeId' }] }, - then: { - type: 'we-badge', - props: { variant: 'primary', size: 'sm' }, - children: ['Default'], - }, - else: { - type: 'we-button', - props: { - variant: 'secondary', - size: 'sm', - onClick: { $action: 'spaceStore.setSpaceDefaultTheme', args: ['$theme.id'] }, + children: [ + { + type: 'Row', + props: { ay: 'center', gap: '300' }, + children: [ + { type: 'we-icon', props: { name: opts.icon } }, + { type: 'we-text', props: { fontWeight: 'semibold' }, children: [opts.name] }, + ], + }, + { + type: '$if', + props: { + condition: isDefault, + then: { type: 'we-badge', props: { variant: 'primary', size: 'sm' }, children: ['Default'] }, + else: { + type: 'we-button', + props: { + variant: 'secondary', + size: 'sm', + onClick: { $action: opts.setDefault, args: [`$${opts.as}.id`] }, + }, + children: ['Set as default'], }, - children: ['Set as default'], }, }, - }, - ], + ], + }; }; +const templateRow: SchemaNode = defaultPickerRow({ + as: 'template', + icon: '$template.meta.icon', + name: '$template.meta.name', + currentDefault: 'spaceStore.spaceDefaultTemplateId', + setDefault: 'spaceStore.setSpaceDefaultTemplate', +}); + +const themeRow: SchemaNode = defaultPickerRow({ + as: 'theme', + icon: '$theme.icon', + name: '$theme.name', + currentDefault: 'spaceStore.spaceDefaultThemeId', + setDefault: 'spaceStore.setSpaceDefaultTheme', +}); + const saveLocationOnBlur = [ { $if: { @@ -133,8 +113,6 @@ const saveOnBlur = [ export const settingsRoute: RouteSchema = { path: '/settings', - type: 'Column', - props: { width: '100%', ax: 'center' }, $localState: { showMarketplace: { type: 'boolean', initial: false }, showThemeMarketplace: { type: 'boolean', initial: false }, @@ -147,655 +125,484 @@ export const settingsRoute: RouteSchema = { location: { type: 'object', initial: { $store: 'spaceStore.currentSpace.location' } }, locationDirty: { type: 'boolean', initial: false }, }, - children: [ - { - type: 'Column', - props: { width: '100%', maxWidth: 'var(--we-layout-lg)', gap: '500', px: '400', py: '500' }, - children: [ - // About this space - { - type: 'Card', - props: { bg: 'neutral-100', border: '1px solid neutral-200' }, - children: [ - // Header row — title + saving spinner - { - type: 'Row', - props: { ax: 'between', ay: 'center' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['About this space'], + ...pageShell({ + children: [ + sectionCard({ + title: 'About this space', + description: 'Manage how this space appears to others.', + // Everything on this card saves on blur, so the only signal that a change was taken is + // this spinner. It sits by the title rather than by the field, because a blur has usually + // moved the cursor somewhere else by the time the write lands. + aside: { + type: '$if', + props: { condition: { $local: 'saving' }, then: { type: 'we-spinner', props: { size: 'sm' } } }, + }, + children: [ + // Name + { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'we-text', props: { color: 'neutral-700' }, children: ['Name'] }, + { + type: 'we-form-field', + children: [ + { + type: 'we-input', + props: { + value: { $local: 'editName' }, + disabled: { $local: 'saving' }, + fontSize: '500', + fontWeight: 'semibold', + onInput: [ + { $setLocal: 'editName', from: '$event.detail' }, + { $setLocal: 'isDirty', value: true }, + ], + onBlur: saveOnBlur, }, - { - type: 'we-text', - children: ['Manage how this space appears to others.'], + }, + ], + }, + ], + }, + + // Description + { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'we-text', props: { color: 'neutral-700' }, children: ['Description'] }, + { + type: 'we-form-field', + children: [ + { + type: 'we-textarea', + props: { + value: { $local: 'editDescription' }, + disabled: { $local: 'saving' }, + onInput: [ + { $setLocal: 'editDescription', from: '$event.detail' }, + { $setLocal: 'isDirty', value: true }, + ], + onBlur: saveOnBlur, }, - ], - }, - { - type: '$if', - props: { - condition: { $local: 'saving' }, - then: { type: 'we-spinner', props: { size: 'sm' } }, }, - }, - ], - }, + ], + }, + ], + }, - // Name - { - type: 'Column', - props: { gap: '100' }, - children: [ - { type: 'we-text', props: { color: 'neutral-700' }, children: ['Name'] }, - { - type: 'we-form-field', - children: [ + attributeRow({ + icon: 'globe', + label: 'Discovery', + value: { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + then: 'Listed', + else: 'Hidden', + }, + }, + description: { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + then: 'Appears on the WE discovery globe', + else: 'Not shown in global discovery', + }, + }, + control: { + type: 'we-switch', + props: { + py: '400', + checked: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + labelOn: 'Listed', + labelOff: 'Hidden', + disabled: { $local: 'saving' }, + onChange: { + $action: 'spaceStore.updateSpaceMeta', + args: [ { - type: 'we-input', - props: { - value: { $local: 'editName' }, - disabled: { $local: 'saving' }, - fontSize: '500', - fontWeight: 'semibold', - onInput: [ - { $setLocal: 'editName', from: '$event.detail' }, - { $setLocal: 'isDirty', value: true }, - ], - onBlur: saveOnBlur, + discovery: { + $if: { + condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, + then: 'hidden', + else: 'listed', + }, }, }, ], }, - ], + }, }, + }), - // Description - { - type: 'Column', - props: { gap: '100' }, - children: [ - { type: 'we-text', props: { color: 'neutral-700' }, children: ['Description'] }, - { - type: 'we-form-field', - children: [ - { - type: 'we-textarea', - props: { - value: { $local: 'editDescription' }, - disabled: { $local: 'saving' }, - onInput: [ - { $setLocal: 'editDescription', from: '$event.detail' }, - { $setLocal: 'isDirty', value: true }, - ], - onBlur: saveOnBlur, - }, - }, + attributeRow({ + icon: 'map-pin', + label: 'Location', + value: { + $if: { + condition: { $store: 'spaceStore.currentSpace.location' }, + then: { + $concat: [ + { $store: 'spaceStore.currentSpace.location.city' }, + ', ', + { $store: 'spaceStore.currentSpace.location.country' }, ], }, - ], + else: 'Not set', + }, }, - - // Discovery - { + control: { type: 'Row', - props: { ay: 'center', ax: 'between', wrap: true }, + props: { ay: 'center', gap: '300' }, children: [ { - type: 'Row', - props: { ay: 'center', gap: '400' }, - children: [ - { type: 'we-icon', props: { name: 'globe', color: 'primary-600' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Discovery:'], - }, - { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: [ - { - $if: { - condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - then: 'Listed', - else: 'Hidden', - }, - }, - ], - }, - ], - }, - { - type: 'we-text', - props: { variant: 'body' }, - children: [ - { - $if: { - condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - then: 'Appears on the WE discovery globe', - else: 'Not shown in global discovery', - }, - }, - ], - }, - ], - }, - ], + type: 'we-button', + props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'editLocation' } }, + children: [{ $if: { condition: { $local: 'editLocation' }, then: 'Hide', else: 'Edit' } }], }, { - type: 'we-switch', + type: 'we-button', props: { - py: '400', - checked: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - labelOn: 'Listed', - labelOff: 'Hidden', - disabled: { $local: 'saving' }, - onChange: { - $action: 'spaceStore.updateSpaceMeta', - args: [ - { - discovery: { - $if: { - condition: { $eq: [{ $store: 'spaceStore.currentSpace.discovery' }, 'listed'] }, - then: 'hidden', - else: 'listed', - }, - }, - }, - ], - }, + size: 'sm', + variant: 'danger', + onClick: [ + { $setLocal: 'location', value: null }, + { $action: 'spaceStore.updateSpaceMeta', args: [{ location: null }] }, + ], }, + children: [ + { type: 'we-icon', props: { name: 'trash' } }, + { type: 'we-text', children: ['Remove'] }, + ], }, ], }, + }), - // Location - { - type: 'Row', - props: { ay: 'center', ax: 'between', wrap: true }, - children: [ - { - type: 'Row', - props: { ay: 'center', gap: '400', py: '100' }, - children: [ - { type: 'we-icon', props: { name: 'map-pin', color: 'primary-600' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-text', - props: { fontWeight: 'bold', color: 'neutral-700' }, - children: ['Location:'], - }, + { + type: '$if', + props: { + condition: { $local: 'editLocation' }, + then: { + type: 'Column', + props: { gap: '300' }, + children: [ + { + type: 'we-form-field', + props: { label: 'Location' }, + children: [ + { + type: 'we-location-picker', + props: { + latitude: { $local: 'location.latitude' }, + longitude: { $local: 'location.longitude' }, + onChange: [ + { $setLocal: 'location', from: '$event.detail' }, { - type: '$if', - props: { - condition: { $store: 'spaceStore.currentSpace.location' }, - then: { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: [ - { - $concat: [ - { $store: 'spaceStore.currentSpace.location.city' }, - ', ', - { $store: 'spaceStore.currentSpace.location.country' }, - ], - }, - ], - }, - else: { - type: 'we-text', - props: { fontWeight: 'bold' }, - children: ['Not set'], - }, - }, + $action: 'spaceStore.updateSpaceMeta', + args: [{ location: { $local: 'location' } }], }, ], }, - ], - }, - ], - }, - { - type: 'Row', - props: { ay: 'center', gap: '300' }, - children: [ - { - type: 'we-button', - props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'editLocation' } }, - children: [ - { - type: '$if', - props: { - condition: { $local: 'editLocation' }, - then: { type: 'we-text', children: ['Hide'] }, - else: { type: 'we-text', children: ['Edit'] }, - }, - }, - ], - }, - { - type: 'we-button', - props: { - size: 'sm', - variant: 'danger', - onClick: [ - { $setLocal: 'location', value: null }, - { $action: 'spaceStore.updateSpaceMeta', args: [{ location: null }] }, - ], }, - children: [ - { type: 'we-icon', props: { name: 'trash' } }, - { type: 'we-text', children: ['Remove'] }, - ], - }, - ], - }, - ], - }, - { - type: '$if', - props: { - condition: { $local: 'editLocation' }, - then: { - type: 'Column', - props: { gap: '300' }, - children: [ - { - type: 'we-form-field', - props: { label: 'Location' }, - children: [ - { - type: 'we-location-picker', - props: { - latitude: { $local: 'location.latitude' }, - longitude: { $local: 'location.longitude' }, - onChange: [ - { $setLocal: 'location', from: '$event.detail' }, + ], + }, + { + type: '$if', + props: { + condition: { $local: 'location' }, + then: { + type: 'Column', + props: { gap: '300' }, + children: [ + { + type: 'Row', + props: { gap: '300' }, + children: [ + { + type: 'we-form-field', + props: { label: 'City', flex: '1' }, + children: [ + { + type: 'we-input', + props: { + value: { $local: 'location.city' }, + placeholder: 'City…', + onInput: [ + { $setLocal: 'location', merge: { city: '$event.detail' } }, + { $setLocal: 'locationDirty', value: true }, + ], + onBlur: saveLocationOnBlur, + }, + }, + ], + }, { - $action: 'spaceStore.updateSpaceMeta', - args: [{ location: { $local: 'location' } }], + type: 'we-form-field', + props: { label: 'Country', flex: '1' }, + children: [ + { + type: 'we-input', + props: { + value: { $local: 'location.country' }, + placeholder: 'Country…', + onInput: [ + { $setLocal: 'location', merge: { country: '$event.detail' } }, + { $setLocal: 'locationDirty', value: true }, + ], + onBlur: saveLocationOnBlur, + }, + }, + ], }, ], }, - }, - ], - }, - { - type: '$if', - props: { - condition: { $local: 'location' }, - then: { - type: 'Column', - props: { gap: '300' }, - children: [ - { - type: 'Row', - props: { gap: '300' }, - children: [ - { - type: 'we-form-field', - props: { label: 'City', flex: '1' }, - children: [ - { - type: 'we-input', - props: { - value: { $local: 'location.city' }, - placeholder: 'City…', - onInput: [ - { $setLocal: 'location', merge: { city: '$event.detail' } }, - { $setLocal: 'locationDirty', value: true }, - ], - onBlur: saveLocationOnBlur, - }, - }, - ], - }, - { - type: 'we-form-field', - props: { label: 'Country', flex: '1' }, - children: [ - { - type: 'we-input', - props: { - value: { $local: 'location.country' }, - placeholder: 'Country…', - onInput: [ - { $setLocal: 'location', merge: { country: '$event.detail' } }, - { $setLocal: 'locationDirty', value: true }, - ], - onBlur: saveLocationOnBlur, - }, - }, - ], - }, - ], - }, - ], - }, + ], }, }, - ], - }, + }, + ], }, }, - ], - }, - - // Default Template - { - type: 'Card', - props: { bg: 'neutral-100', border: '1px solid neutral-200' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Default Template'], - }, - { - type: 'we-text', - children: ['Choose the template members see when they enter this space.'], - }, - ], - }, + }, + ], + }), - // Core templates - { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-text', - props: { variant: 'body', fontWeight: 'semibold', textTransform: 'uppercase' }, - children: ['Built-in Templates'], - }, - { - type: '$each', - props: { items: { $store: 'templateStore.builtInTemplates' }, as: 'template' }, - children: [templateRow], - }, - ], - }, + // Default Template + sectionCard({ + title: 'Default Template', + description: 'Choose the template members see when they enter this space.', + children: [ + // Core templates + { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: 'we-text', + props: { variant: 'body', fontWeight: 'semibold', textTransform: 'uppercase' }, + children: ['Built-in Templates'], + }, + { + type: '$each', + props: { items: { $store: 'templateStore.builtInTemplates' }, as: 'template' }, + children: [templateRow], + }, + ], + }, - // Space templates (only shown when present) - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'templateStore.spaceTemplates' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-text', - props: { - variant: 'body', - fontWeight: 'semibold', - textTransform: 'uppercase', - }, - children: ['Space Templates'], - }, - { - type: '$each', - props: { items: { $store: 'templateStore.spaceTemplates' }, as: 'template' }, - children: [templateRow], + // Space templates (only shown when present) + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'templateStore.spaceTemplates' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: 'we-text', + props: { + variant: 'body', + fontWeight: 'semibold', + textTransform: 'uppercase', }, - ], - }, + children: ['Space Templates'], + }, + { + type: '$each', + props: { items: { $store: 'templateStore.spaceTemplates' }, as: 'template' }, + children: [templateRow], + }, + ], }, }, + }, - // Browse Marketplace - { - type: 'Row', - props: { ax: 'between', ay: 'center' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { - variant: 'body', - fontWeight: 'semibold', - textTransform: 'uppercase', - }, - children: ['Browse Marketplace'], + // Browse Marketplace + { + type: 'Row', + props: { ax: 'between', ay: 'center' }, + children: [ + { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'we-text', + props: { + variant: 'body', + fontWeight: 'semibold', + textTransform: 'uppercase', }, - { - type: 'we-text', - props: { variant: 'body' }, - children: ['Install templates from the marketplace into this space.'], - }, - ], - }, - { - type: 'we-button', - props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'showMarketplace' } }, - children: [ - { - type: '$if', - props: { - condition: { $local: 'showMarketplace' }, - then: { type: 'we-text', children: ['Hide'] }, - else: { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { type: 'we-icon', props: { name: 'magnifying-glass' } }, - { type: 'we-text', children: ['Browse'] }, - ], - }, + children: ['Browse Marketplace'], + }, + { + type: 'we-text', + props: { variant: 'body' }, + children: ['Install templates from the marketplace into this space.'], + }, + ], + }, + { + type: 'we-button', + props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'showMarketplace' } }, + children: [ + { + type: '$if', + props: { + condition: { $local: 'showMarketplace' }, + then: { type: 'we-text', children: ['Hide'] }, + else: { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'magnifying-glass' } }, + { type: 'we-text', children: ['Browse'] }, + ], }, }, - ], - }, - ], - }, - { - type: '$if', - props: { condition: { $local: 'showMarketplace' }, then: marketplaceBrowser }, - }, - ], - }, - - // Default Theme - { - type: 'Card', - props: { bg: 'neutral-100', border: '1px solid neutral-200' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Default Theme'], - }, - { - type: 'we-text', - children: ['Choose the theme members see when they enter this space.'], - }, - ], - }, + }, + ], + }, + ], + }, + { + type: '$if', + props: { condition: { $local: 'showMarketplace' }, then: marketplaceBrowser }, + }, + ], + }), - // Built-in themes - { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-text', - props: { variant: 'body', fontWeight: 'semibold', textTransform: 'uppercase' }, - children: ['Built-in Themes'], - }, - { - type: '$each', - props: { items: { $store: 'themeStore.builtInThemes' }, as: 'theme' }, - children: [themeRow], - }, - ], - }, + // Default Theme + sectionCard({ + title: 'Default Theme', + description: 'Choose the theme members see when they enter this space.', + children: [ + // Built-in themes + { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: 'we-text', + props: { variant: 'body', fontWeight: 'semibold', textTransform: 'uppercase' }, + children: ['Built-in Themes'], + }, + { + type: '$each', + props: { items: { $store: 'themeStore.builtInThemes' }, as: 'theme' }, + children: [themeRow], + }, + ], + }, - // Space themes (only shown when present) - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'themeStore.spaceThemes' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-text', - props: { - variant: 'body', - fontWeight: 'semibold', - textTransform: 'uppercase', - }, - children: ['Space Themes'], - }, - { - type: '$each', - props: { items: { $store: 'themeStore.spaceThemes' }, as: 'theme' }, - children: [themeRow], + // Space themes (only shown when present) + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'themeStore.spaceThemes' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: 'we-text', + props: { + variant: 'body', + fontWeight: 'semibold', + textTransform: 'uppercase', }, - ], - }, + children: ['Space Themes'], + }, + { + type: '$each', + props: { items: { $store: 'themeStore.spaceThemes' }, as: 'theme' }, + children: [themeRow], + }, + ], }, }, + }, - // Browse Marketplace - { - type: 'Row', - props: { ax: 'between', ay: 'center' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { - variant: 'body', - fontWeight: 'semibold', - textTransform: 'uppercase', - }, - children: ['Browse Marketplace'], - }, - { - type: 'we-text', - props: { variant: 'body' }, - children: ['Install themes from the marketplace into this space.'], + // Browse Marketplace + { + type: 'Row', + props: { ax: 'between', ay: 'center' }, + children: [ + { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'we-text', + props: { + variant: 'body', + fontWeight: 'semibold', + textTransform: 'uppercase', }, - ], - }, - { - type: 'we-button', - props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'showThemeMarketplace' } }, - children: [ - { - type: '$if', - props: { - condition: { $local: 'showThemeMarketplace' }, - then: { type: 'we-text', children: ['Hide'] }, - else: { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { type: 'we-icon', props: { name: 'magnifying-glass' } }, - { type: 'we-text', children: ['Browse'] }, - ], - }, + children: ['Browse Marketplace'], + }, + { + type: 'we-text', + props: { variant: 'body' }, + children: ['Install themes from the marketplace into this space.'], + }, + ], + }, + { + type: 'we-button', + props: { variant: 'secondary', size: 'sm', onClick: { $toggleLocal: 'showThemeMarketplace' } }, + children: [ + { + type: '$if', + props: { + condition: { $local: 'showThemeMarketplace' }, + then: { type: 'we-text', children: ['Hide'] }, + else: { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'we-icon', props: { name: 'magnifying-glass' } }, + { type: 'we-text', children: ['Browse'] }, + ], }, }, - ], - }, - ], - }, - { - type: '$if', - props: { condition: { $local: 'showThemeMarketplace' }, then: themeMarketplaceBrowser }, - }, - ], - }, + }, + ], + }, + ], + }, + { + type: '$if', + props: { condition: { $local: 'showThemeMarketplace' }, then: themeMarketplaceBrowser }, + }, + ], + }), - // Signal Types - { - type: 'Card', - props: { bg: 'neutral-100', border: '1px solid neutral-200' }, + sectionCard({ + title: 'Signal Types', + description: 'Create and manage custom signal types to categorize and enrich your signals.', + aside: { + type: 'we-button', + props: { variant: 'secondary', size: 'sm', onClick: { $setLocal: 'createSignalTypeOpen', value: true } }, children: [ - { - type: 'Row', - props: { ax: 'between', ay: 'center' }, - children: [ - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Signal Types'], - }, - { - type: 'we-text', - children: ['Create and manage custom signal types to categorize and enrich your signals.'], - }, - ], - }, - { - type: 'we-button', - props: { - variant: 'secondary', - size: 'sm', - onClick: { $setLocal: 'createSignalTypeOpen', value: true }, - }, - children: [ - { type: 'we-icon', props: { name: 'plus' } }, - { type: 'we-text', children: ['Add Signal Type'] }, - ], - }, - ], - }, - { - type: '$each', - props: { items: { $query: { entity: 'SignalType', subscribe: true } }, as: 'signalType' }, - children: [signalTypeCard], - }, - { - type: '$if', - props: { condition: { $local: 'createSignalTypeOpen' }, then: createSignalTypeModal }, - }, + { type: 'we-icon', props: { name: 'plus' } }, + { type: 'we-text', children: ['Add Signal Type'] }, ], }, - ], - }, - ], + children: [ + { + type: '$each', + props: { items: { $query: { entity: 'SignalType', subscribe: true } }, as: 'signalType' }, + children: [signalTypeCard], + }, + { + type: '$if', + props: { condition: { $local: 'createSignalTypeOpen' }, then: createSignalTypeModal }, + }, + ], + }), + ], + }), }; diff --git a/packages/templates/kit/CONVENTIONS.md b/packages/templates/kit/CONVENTIONS.md new file mode 100644 index 000000000..089128389 --- /dev/null +++ b/packages/templates/kit/CONVENTIONS.md @@ -0,0 +1,94 @@ +# @we/template-kit — Fragment Authoring Conventions + +Rules for adding to or changing this package. The package's purpose and the fragment architecture +live in [README.md](./README.md) and `docs/architecture/template-fragments.md`; this file is the +working ruleset. + +These rules also govern **module-provided fragments** — a feature module shipping ready-made +schemas beside its components (`@we/module-graph`'s `fragments.ts` is the first). Same options-object +API, same body style, same doc-comment duty; only the address differs. + +## Should this be a fragment at all? + +> **Code owns only what data cannot express.** Behaviour and focus management, accessibility +> semantics, browser APIs, measurement, performance-critical rendering — that is the whole list. +> Everything above it is arrangement, and arrangement stays data. + +| It wants to be | When | Example | +| --------------------------------- | --------------------------------------------- | ------------------------ | +| A primitive (`@we/primitives`) | focus trap, top layer, keyboard, ARIA | `we-modal` | +| A component (`@we/components`) | measurement, layout maths, third-party libs | `AvatarStack` | +| **A fragment (here)** | arrangement, however often repeated | `gatePrompt`, `cardList` | +| An operator (`@we/schema-shared`) | schema boilerplate that is component-agnostic | `$in` | + +The pairs come apart deliberately: `AvatarStack` is a component (overlap maths) and the count beside +it is a fragment; `we-modal` is a primitive and the confirm dialog inside it is a fragment. When the +repetition is a `value`/`onInput` wiring pattern, check whether it is component-agnostic before +reaching for an operator — `field` stayed a fragment because _which event carries the value_ is +design-system knowledge, and an operator would smuggle that table into the schema resolver. + +## When to extract + +- **Three real uses of the same shape.** Two is a coincidence. +- **Or the divergence is a bug** — nine lists missing the empty state five others had; four avatar + stacks seeded with a literal where one had it right. +- **Never speculatively.** A fragment merges in the same commit as the call sites it replaces. An + export with zero callers is marketplace-vocabulary noise and untested API. +- **Not when the third call site costs three options.** An over-parameterised fragment is worse + than the duplication, because it also hides it. Leave the odd one hand-written and say why at the + call site. + +## API shape + +- **Always a single options object**, never positional arguments — every fragment reads the same + way at a call site, and options can be added without a migration. +- Types say what a value is for: + - `string` — plain text the fragment may interpolate (labels it adds a colon to, nouns it + pluralises). Never accept `Content` where the body does `` `${value}` `` — a token object would + render `[object Object]`. + - `Content` (from `types.ts`) — anything legal in a `children` position. + - `SchemaProp` — an expression: a context ref, a token, a literal. + - `LocalStateField` — pass-through `$localState` declarations. Import it; don't hand-roll the + shape. +- An option that only reproduces a difference between two existing call sites is drift being + promoted to API. Decide which call site is right instead. + +## Body style — the fragment reads as the tree it emits + +A fragment's body has one special property: it can _look like_ the JSON it produces, which makes it +self-documenting and keeps it visually parallel to its recipe in `@we/ai-context`. Protect that. + +- **Name a node only when the output references it more than once** — usually because an option + rearranges structure and the arrangements share pieces (`agentByline`'s `avatar` in both + layouts; `confirmModal`'s `close` in three positions). Everything else is written inline, in + place, however deep. +- **Bulk and comments are never a reason to extract.** A single-use subtree stays inline; the + comment stays with it. +- **Derived scalars are fine** (`` const key = `${opts.as}Rows` ``), and so is a derived _value_ with + branching where inlining would nest ternaries inside the tree. +- **The other direction cuts too:** two near-identical branch literals differing in the middle are + how the marketplace browsers drifted apart. If avoiding that needs many named parts, it is + usually two fragments. +- Spreads by intent, not habit: + - value has a default, key always present → `px: opts.px ?? '400'` + - the key itself is optional → `...(opts.minHeight !== undefined && { minHeight: opts.minHeight })` + +## Ambient scope + +Fragments may read `$local` from ancestors and write results into `$local` — that is what makes +`cardShell` usable without threading `displayMode` through every layer. The costs are borne by +documentation until insert-time checking exists: + +- **Every read up the tree and every write into it is declared in the fragment's doc comment**, the + way `lists/cards.ts` opens with its `displayMode` / `Rows` contract. +- **Only `we/` may name a store.** The tier split is the package's real dependency declaration — + see README. + +## Documentation + +- Every fragment carries a doc comment saying **why it exists** — the bug or drift it ended — not + just what it renders. The kit is also the record of what the shapes learned the hard way (the + bare-`$item` hash, the collapsing `AvatarStack` row). +- **When an expansion changes materially, update its recipe** in + `@we/ai-context/src/fragments/patterns.ts`. The recipe and the fragment are two renderings of one + decision; a drifted recipe teaches the AI a shape the codebase stopped using. diff --git a/packages/templates/kit/README.md b/packages/templates/kit/README.md new file mode 100644 index 000000000..c2448f661 --- /dev/null +++ b/packages/templates/kit/README.md @@ -0,0 +1,51 @@ +# @we/template-kit + +The shapes WE's templates are built from, as data. + +Every export is a function returning `SchemaNode`s. It runs when a template is built and leaves +nothing behind: what ships is plain JSON, indistinguishable from JSON written by hand. That is the +package's one architectural rule — **the runtime never knows fragments exist** — and everything +worth having follows from it: no renderer change, templates that outlive the kit, a visual editor +that can drill into every node, an AI that reads ordinary trees, and installed patterns that can +never break a template after the fact because the template owns its copy of the expansion. + +```ts +import { emptyState, cardList } from '@we/template-kit'; + +const postsList = cardList({ + query: { entity: 'CollectionBlock', where: { type: 'root' }, limit: 20 }, + as: 'post', + empty: emptyState({ icon: 'newspaper', label: 'posts', searchable: true }), + children: [/* the card, once, as nodes */], +}); +``` + +## Why fragments exist + +WE's thesis is that an interface is data a community owns, not code an app ships. That leaves a gap +between the two contribution rungs: components are code (high barrier, opaque to the editor), and +templates are whole artifacts (shareable, but not composable). Fragments are the middle rung — a +named, parameterised _shape_ that expands into editable nodes. Today they serve WE's own templates; +the same shapes ship as JSON recipes in the generated AI reference, so an author in the browser +produces the same trees this package does. Where this goes next — extraction in the editor, +provenance tags, marketplace sharing — is designed in +[docs/architecture/template-fragments.md](../../../docs/architecture/template-fragments.md). + +## Two tiers + +| Tier | Directories | May reference | +| --------- | ------------------------------------------------- | ------------------------------------------------------------------------- | +| Portable | `states/` `layout/` `lists/` `overlays/` `input/` | components, primitives, `$local` contracts it documents | +| WE-domain | `we/` | WE's stores (`profileStore`, `datasetStore`, `runtimeStore`) and `$agent` | + +The split is the package's honest dependency declaration. A fragment naming `spaceStore.members` +resolves to nothing on a deployment without that store — silently — and `package.json` cannot say +so. Nothing outside `we/` may name a store. + +## What belongs here + +Code owns only what data cannot express: behaviour, focus, accessibility semantics, browser APIs, +measurement. Everything above that line is arrangement, and arrangement belongs here — because a +prop is a customisation somebody predicted, while a node tree is every customisation, including the +ones nobody thought of. The full decision rule, and the rules for writing a fragment, are in +[CONVENTIONS.md](./CONVENTIONS.md). diff --git a/packages/templates/kit/package.json b/packages/templates/kit/package.json new file mode 100644 index 000000000..638b08a53 --- /dev/null +++ b/packages/templates/kit/package.json @@ -0,0 +1,23 @@ +{ + "name": "@we/template-kit", + "version": "0.1.0", + "description": "Reusable template fragments \u2014 the shapes WE's own templates are built from, as data", + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "dependencies": { + "@we/schema-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.10.0", + "vitest": "^4.0.15" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + } +} diff --git a/packages/templates/kit/src/index.ts b/packages/templates/kit/src/index.ts new file mode 100644 index 000000000..e4c0274d6 --- /dev/null +++ b/packages/templates/kit/src/index.ts @@ -0,0 +1,72 @@ +/** + * The shapes WE's templates are built from, as data. + * + * Every export here is a function returning `SchemaNode`s — it runs at authoring time and leaves + * nothing behind. What ships is the JSON it produced, indistinguishable from JSON written by hand, + * which is what keeps a template inspectable in the visual editor, editable by an AI, and free of + * any dependency on this package at runtime. + * + * ## Two tiers + * + * - **Layout, states, lists, overlays** name no store. They are portable to any deployment. + * - **`we/`** reads WE's own stores (`profileStore`, `runtimeStore`) or its schema machinery + * (`$agent`). They are portable only where those exist. + * + * The split is not decoration: the store surface is this kit's real dependency, and it is not + * expressed in `package.json`. Keeping the tiers apart is how a consumer can tell which fragments + * will work for them. + * + * ## What belongs here, and what belongs in `@we/components` + * + * Code should own only what data *cannot express*: behaviour, focus management, accessibility + * semantics, browser APIs, measurement. Everything above that line is arrangement, and arrangement + * stays here — because a prop is a customisation somebody predicted, while a node tree is every + * customisation, including the ones nobody thought of. + * + * So `AvatarStack` is a component (overlap maths) and the count beside it is a fragment; + * `we-modal` is a primitive (focus trap, top layer) and the confirm dialog inside it is a fragment. + * + * The package README states this rule in full; CONVENTIONS.md carries the authoring rules + * (extraction threshold, options-object API, the const rule); and + * `docs/architecture/template-fragments.md` is where all of it is going. + */ + +// States — what a surface shows when it has nothing to show. +export { emptyNote, emptyState } from './states/emptyState.ts'; +export type { EmptyStateOptions } from './states/emptyState.ts'; +export { gatePrompt } from './states/gatePrompt.ts'; +export type { GatePromptOptions } from './states/gatePrompt.ts'; + +// Lists — a grid of cards, and the card in it. +export { cardList, cardShell } from './lists/cards.ts'; +export type { CardListOptions, CardShellOptions } from './lists/cards.ts'; + +// Layout — the boxes a page is made of. +export { attributeRow } from './layout/attributeRow.ts'; +export type { AttributeRowOptions } from './layout/attributeRow.ts'; +export { pageShell } from './layout/pageShell.ts'; +export type { PageShellOptions } from './layout/pageShell.ts'; +export { sectionCard } from './layout/sectionCard.ts'; +export type { SectionCardOptions } from './layout/sectionCard.ts'; +export { statChip } from './layout/statChip.ts'; +export type { StatChipOptions } from './layout/statChip.ts'; + +// Input. +export { field } from './input/field.ts'; +export type { FieldOptions } from './input/field.ts'; + +// Overlays. +export { confirmModal } from './overlays/confirmModal.ts'; +export type { ConfirmModalOptions } from './overlays/confirmModal.ts'; + +// WE-domain — these name WE's stores or its agent machinery. +export { adminSection } from './we/adminSection.ts'; +export type { AdminSectionOptions } from './we/adminSection.ts'; +export { agentByline } from './we/agentByline.ts'; +export type { AgentBylineOptions } from './we/agentByline.ts'; +export { marketplaceList } from './we/marketplaceList.ts'; +export type { MarketplaceListOptions } from './we/marketplaceList.ts'; +export { peopleRow } from './we/peopleRow.ts'; +export type { PeopleRowOptions } from './we/peopleRow.ts'; +export { peopleTooltip } from './we/peopleTooltip.ts'; +export type { PeopleTooltipOptions } from './we/peopleTooltip.ts'; diff --git a/packages/templates/kit/src/input/field.ts b/packages/templates/kit/src/input/field.ts new file mode 100644 index 000000000..b178857b2 --- /dev/null +++ b/packages/templates/kit/src/input/field.ts @@ -0,0 +1,95 @@ +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; + +/** The controls a field can hold, and how each reports a new value. */ +const CONTROLS = { + input: { tag: 'we-input', event: 'onInput', from: '$event.detail' }, + textarea: { tag: 'we-textarea', event: 'onInput', from: '$event.detail' }, + select: { tag: 'we-select', event: 'onChange', from: '$event.detail' }, +} as const; + +export interface FieldOptions { + /** The `$localState` field this reads and writes. An ancestor must declare it. */ + name: string; + label?: string; + /** The line under the label, for a field whose name is not self-explanatory. */ + description?: string; + control?: keyof typeof CONTROLS; + placeholder?: string; + /** Extra props for the control itself — `options` for a select, `rows` for a textarea, `type`. */ + props?: Record; + /** + * Surface this field's validation error. + * + * `$error` is already empty until the field is touched, so this needs no condition around it — + * several call sites wrapped it in a `$if` testing the same token it was about to render. + */ + validated?: boolean; + /** + * Mark the field touched when it is left. + * + * An opt-in, not boilerplate. It earns its place on a long form where a field is worth judging the + * moment it is left — a `match` rule on a confirm-password, say. On a short form it fires an error + * at somebody who merely clicked through a field they had not filled in yet. + */ + touchOnBlur?: boolean; + disabled?: SchemaProp; + /** Actions to run after the value is stored — a dirty flag, usually. */ + also?: SchemaProp[]; + /** What to do when the field is left — an autosave, usually. */ + onBlur?: SchemaProp; +} + +/** + * A labelled control bound to one field of local state. + * + * Forty-two of these across the two template packages, each spelling out the same triple: read + * `$local`, write `$setLocal` on the control's own event, and pass the field's error to the wrapper. + * + * ## Why this is a fragment and not a `$field` operator + * + * A schema-level shorthand was the obvious move — the boilerplate is `value`/`onInput`/`$setLocal`, + * which is schema machinery rather than a UI shape. It is the wrong move, because the mapping from + * a field to *how a control reports a change* is per-component knowledge: `we-input` emits + * `onInput` with `$event.detail`, `we-select` emits `onChange`, `Search` calls back with the value + * itself as `$arg`. An operator would have to either hold a table of component conventions — putting + * design-system knowledge inside the schema resolver, which is exactly what the layering forbids — + * or be told the event and the path at each call site, at which point it saves nothing over writing + * the two props. + * + * A fragment can hold that table, because it lives in the layer that already knows the components. + */ +export function field(opts: FieldOptions): SchemaNode { + const control = CONTROLS[opts.control ?? 'input']; + + const setValue = { $setLocal: opts.name, from: control.from }; + + return { + type: 'we-form-field', + props: { + ...(opts.label && { label: opts.label }), + ...(opts.description && { description: opts.description }), + ...(opts.validated && { error: { $error: opts.name } }), + }, + children: [ + { + type: control.tag, + props: { + ...(opts.placeholder && { placeholder: opts.placeholder }), + value: { $local: opts.name }, + ...(opts.disabled !== undefined && { disabled: opts.disabled }), + [control.event]: opts.also?.length ? [setValue, ...opts.also] : setValue, + ...(opts.touchOnBlur || opts.onBlur + ? { + onBlur: opts.touchOnBlur + ? opts.onBlur + ? [{ $touch: opts.name }, opts.onBlur] + : { $touch: opts.name } + : opts.onBlur, + } + : {}), + ...opts.props, + }, + }, + ], + }; +} diff --git a/packages/templates/kit/src/kit.test.ts b/packages/templates/kit/src/kit.test.ts new file mode 100644 index 000000000..124f67d4e --- /dev/null +++ b/packages/templates/kit/src/kit.test.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { buildValidationContext, type SchemaNode, validateSemantic } from '@we/schema-shared'; +import { describe, expect, it } from 'vitest'; + +import { + adminSection, + agentByline, + attributeRow, + cardList, + cardShell, + confirmModal, + emptyNote, + emptyState, + field, + gatePrompt, + marketplaceList, + pageShell, + peopleRow, + peopleTooltip, + sectionCard, + statChip, +} from './index.ts'; + +/** + * The kit's contract is its *output*: every fragment expands to plain nodes a template could have + * carried by hand. So the tests assert about expansions, not implementations — a fragment is free + * to restructure internally as long as what it emits stays valid, tier-honest, and keeps the few + * behaviours call sites depend on. + */ + +// The same generated context the validator CLI reads. This package deliberately does not depend on +// `@we/ai-context` (a build tool — the dependency would point the wrong way). +const contextData = JSON.parse(readFileSync(resolve(import.meta.dirname, '../../../ai-context/context.json'), 'utf-8')); +const context = buildValidationContext(contextData); + +/** Representative expansions. Fragments with structural branches contribute one entry per branch. */ +const portable: Record = { + emptyState: emptyState({ icon: 'newspaper', label: 'posts', searchable: true }), + 'emptyState (no delay)': emptyState({ icon: 'user', label: 'members', delay: 0 }), + emptyNote: emptyNote('No spaces yet'), + gatePrompt: gatePrompt({ icon: 'lock', iconGradient: 'primary', title: 'Join', body: 'Body.' }), + 'gatePrompt (form)': gatePrompt({ + icon: 'rocket', + title: 'Set up', + scroll: true, + localState: { name: { type: 'string', initial: '' } }, + children: [field({ name: 'name', label: 'Name' })], + }), + pageShell: pageShell({ children: [{ type: 'we-text', children: ['x'] }], minHeight: '100vh' }), + sectionCard: sectionCard({ title: 'About', description: 'D.', children: [] }), + 'sectionCard (aside)': sectionCard({ title: 'Signals', aside: { type: 'we-spinner' }, children: [] }), + attributeRow: attributeRow({ icon: 'globe', label: 'Discovery', value: 'Listed' }), + 'attributeRow (control)': attributeRow({ + icon: 'map-pin', + label: 'Location', + value: 'Berlin', + control: { type: 'we-switch' }, + }), + statChip: statChip({ icon: 'chat-dots', count: '$channel.$count', label: 'Conversations' }), + 'statChip (value)': statChip({ icon: 'lock-simple', label: 'Access', value: 'Shared' }), + cardShell: cardShell({ header: [{ type: 'we-text', children: ['h'] }], body: [] }), + 'cardList (query)': cardList({ + query: { entity: 'SignalType', subscribe: true }, + as: 'sig', + empty: emptyNote('none'), + children: [{ type: 'we-text', children: ['$sig.name'] }], + }), + 'cardList (items)': cardList({ + items: { $local: 'rows' }, + as: 'row', + empty: emptyNote('none'), + children: [{ type: 'we-text', children: ['$row.name'] }], + }), + confirmModal: confirmModal({ + openLocal: 'confirmOpen', + title: 'Delete?', + body: 'Gone forever.', + confirmLabel: 'Delete', + confirm: { $action: 'spaceStore.deleteCollection', args: ['$post.id'] }, + busyLocal: 'deleting', + }), + field: field({ name: 'name', label: 'Name', validated: true, touchOnBlur: true }), + 'field (select)': field({ name: 'mode', control: 'select', props: { options: [] } }), + 'field (textarea)': field({ name: 'bio', control: 'textarea' }), +}; + +const weDomain: Record = { + agentByline: agentByline({ did: '$post.author', timestamp: '$post.createdAt' }), + 'agentByline (stacked)': agentByline({ did: '$u.author', as: 'speaker', stacked: true }), + peopleRow: peopleRow({ items: { $store: 'spaceStore.members' }, noun: 'Member' }), + 'peopleRow (dids)': peopleRow({ items: '$call.participants', dids: true }), + peopleTooltip: peopleTooltip({ + items: { $store: 'spaceStore.members' }, + image: '$person.avatar', + hash: '$person.did', + name: '$person.name', + children: [{ type: 'we-text', children: ['7'] }], + }), + adminSection: adminSection({ title: 'Models', icon: 'sparkle', refresh: 'runtimeStore.loadAiModels', children: [] }), + marketplaceList: marketplaceList({ + entity: 'Template', + as: 'template', + label: 'templates', + emptyIcon: 'layout', + sortable: true, + card: { mode: 'marketplace' }, + }), + 'marketplaceList (list)': marketplaceList({ + entity: 'Theme', + as: 'theme', + label: 'themes', + emptyIcon: 'paint-bucket', + layout: 'list', + card: { mode: 'compact' }, + }), +}; + +/** Depth-first over nodes, props and operator tokens alike. */ +function walk(value: unknown, visit: (node: Record) => void): void { + if (Array.isArray(value)) return value.forEach((v) => walk(v, visit)); + if (value === null || typeof value !== 'object') return; + visit(value as Record); + Object.values(value).forEach((v) => walk(v, visit)); +} + +/** + * The ambient scope `lists/cards.ts` documents as its contract: `displayMode` belongs to the page. + * Declaring their own `$localState` is what switches the validator's scope checking on for these + * two fragments, so validating them bare would flag the very reads the contract permits — this + * shim is that contract made explicit, the same declaration the palette's insert-with-fix will + * one day add for real. + */ +const withAmbientScope = (node: SchemaNode): SchemaNode => ({ + type: 'Column', + $localState: { displayMode: { type: 'string', initial: 'expanded' } }, + children: [node], +}); + +describe('every expansion is a valid schema fragment', () => { + const needsAmbient = new Set(['cardShell', 'cardList (query)']); + for (const [name, node] of Object.entries({ ...portable, ...weDomain })) { + it(name, () => { + const result = validateSemantic(needsAmbient.has(name) ? withAmbientScope(node) : node, context); + expect(result.errors.filter((e) => e.severity === 'error')).toEqual([]); + }); + } +}); + +describe('the portable tier names no store and no agent machinery', () => { + // The tier split is the kit's real dependency declaration (see README) and nothing in + // package.json can enforce it. This walk can: the inputs above are store-free except where a + // *caller* supplies an action, so any `$store` or `$agent` found here was introduced by the + // fragment itself. `$action` is exempt for exactly that reason — confirmModal passes its + // caller's action through, which is the caller's dependency, not the kit's. + for (const [name, node] of Object.entries(portable)) { + it(name, () => { + const offences: string[] = []; + walk(node, (n) => { + if ('$store' in n) offences.push(`$store: ${String(n.$store)}`); + if (n.type === '$agent') offences.push('$agent node'); + }); + // confirmModal's `confirm` is caller input — filter the one store path this test passed in. + expect(offences.filter((o) => !o.includes('spaceStore.deleteCollection'))).toEqual([]); + }); + } +}); + +describe('contracts call sites depend on', () => { + it('peopleRow in dids mode seeds avatar hashes with a token, never the literal $item', () => { + // THE bug this branch was born from: a bare '$item' in a $map select is a literal, so every + // generated face came out identical. The fragment must emit a token object. + let select: Record | undefined; + walk(weDomain['peopleRow (dids)'], (n) => { + if ('$map' in n) select = (n.$map as { select: Record }).select; + }); + expect(select).toBeDefined(); + expect(select!.hash).not.toBe('$item'); + expect(select!.hash).toEqual({ $concat: ['$item'] }); + }); + + it('field wires the event each control actually emits', () => { + const eventOf = (node: SchemaNode, tag: string) => { + let props: Record = {}; + walk(node, (n) => { + if (n.type === tag) props = n.props as Record; + }); + return props; + }; + expect(eventOf(portable.field, 'we-input')).toHaveProperty('onInput'); + expect(eventOf(portable['field (select)'], 'we-select')).toHaveProperty('onChange'); + expect(eventOf(portable['field (textarea)'], 'we-textarea')).toHaveProperty('onInput'); + }); + + it('confirmModal clears its flag from every exit: close, cancel, and success', () => { + let closes = 0; + walk(portable.confirmModal, (n) => { + if (n.$setLocal === 'confirmOpen' && n.value === false) closes += 1; + }); + expect(closes).toBeGreaterThanOrEqual(3); + }); + + it('cardList in query mode hoists under Rows, and both branches read the same items', () => { + const node = portable['cardList (query)']; + expect(node.$queries).toHaveProperty('sigRows'); + const readers: unknown[] = []; + walk(node, (n) => { + if ('$count' in n) readers.push((n.$count as { items: unknown }).items); + if (n.type === '$each') readers.push((n.props as { items: unknown }).items); + }); + expect(readers).toEqual([{ $local: 'sigRows' }, { $local: 'sigRows' }]); + }); + + it('agentByline uses one interpolation for the profile in both arrangements', () => { + for (const node of [weDomain.agentByline, weDomain['agentByline (stacked)']]) { + const as = (node.props as { as: string }).as; + let avatarProps: Record = {}; + walk(node, (n) => { + if (n.type === 'we-avatar') avatarProps = n.props as Record; + }); + expect(avatarProps.image).toBe(`$${as}.avatar`); + expect(avatarProps.hash).toBe(`$${as}.did`); + } + }); + + it('emptyState mounts bare when delay is 0, wrapped in $animate otherwise', () => { + expect(portable['emptyState (no delay)'].type).toBe('Column'); + expect(portable.emptyState.type).toBe('$animate'); + }); +}); diff --git a/packages/templates/kit/src/layout/attributeRow.ts b/packages/templates/kit/src/layout/attributeRow.ts new file mode 100644 index 000000000..676261e6c --- /dev/null +++ b/packages/templates/kit/src/layout/attributeRow.ts @@ -0,0 +1,59 @@ +import type { SchemaNode } from '@we/schema-shared'; + +import type { Content } from '../types.ts'; + +export interface AttributeRowOptions { + icon: string; + iconColor?: string; + /** The attribute's name. Rendered with its colon, so pass it without one. */ + label: string; + /** The current value, as a string or an expression. */ + value: Content; + /** A line under the pair explaining what the value means. */ + description?: Content; + /** A switch, a button pair, an editor — placed at the far right, which turns the row into a setting. */ + control?: SchemaNode; +} + +/** + * One property of a space, stated: an icon, `Label: Value`, and a line saying what that implies. + * + * The same row appears read-only on the About page and with a switch on Settings, which is why the + * control is a slot rather than a second fragment — the two had drifted into describing the same + * property differently ("Listed" on one page, "Appears on the WE discovery globe" on the other), + * which is the kind of divergence nobody chooses and everybody ships. + */ +export function attributeRow(opts: AttributeRowOptions): SchemaNode { + const stack: SchemaNode = { + type: 'Row', + props: { ay: 'center', gap: '400', py: '100' }, + children: [ + { type: 'we-icon', props: { name: opts.icon, color: opts.iconColor ?? 'primary-600' } }, + { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'Row', + props: { gap: '300' }, + children: [ + { type: 'we-text', props: { fontWeight: 'bold', color: 'neutral-700' }, children: [`${opts.label}:`] }, + { type: 'we-text', props: { fontWeight: 'bold' }, children: [opts.value] }, + ], + }, + ...(opts.description !== undefined + ? [{ type: 'we-text', props: { variant: 'body' }, children: [opts.description] } as SchemaNode] + : []), + ], + }, + ], + }; + + if (!opts.control) return stack; + + return { + type: 'Row', + props: { ay: 'center', ax: 'between', wrap: true }, + children: [stack, opts.control], + }; +} diff --git a/packages/templates/kit/src/layout/pageShell.ts b/packages/templates/kit/src/layout/pageShell.ts new file mode 100644 index 000000000..391df5225 --- /dev/null +++ b/packages/templates/kit/src/layout/pageShell.ts @@ -0,0 +1,46 @@ +import type { SchemaNode } from '@we/schema-shared'; + +export interface PageShellOptions { + children: SchemaNode[]; + /** Space between the page's sections. */ + gap?: string; + /** Horizontal padding inside the measure. */ + px?: string; + /** Vertical padding inside the measure. */ + py?: string; + /** The measure itself. Defaults to the `lg` layout token, which is what every route uses. */ + maxWidth?: string; + /** Keep a short page at least a screen tall — the cards route uses this to hold its grid open. */ + minHeight?: string; +} + +/** + * A route's outer box: full width, contents centred, held to a readable measure. + * + * Every route in the default template opens with the same two nested Columns — one to centre, one + * to constrain — and they had drifted into four different paddings for no reason anyone chose. + * + * Two Columns rather than one, because centring and constraining are different jobs: the outer one + * spans the viewport so a route's background reaches the edges, and the inner one holds the measure. + * A single node cannot do both. + */ +export function pageShell(opts: PageShellOptions): SchemaNode { + return { + type: 'Column', + props: { width: '100%', ax: 'center' }, + children: [ + { + type: 'Column', + props: { + width: '100%', + maxWidth: opts.maxWidth ?? 'var(--we-layout-lg)', + gap: opts.gap ?? '500', + px: opts.px ?? '400', + py: opts.py ?? '500', + ...(opts.minHeight !== undefined && { minHeight: opts.minHeight }), + }, + children: opts.children, + }, + ], + }; +} diff --git a/packages/templates/kit/src/layout/sectionCard.ts b/packages/templates/kit/src/layout/sectionCard.ts new file mode 100644 index 000000000..6bb72583d --- /dev/null +++ b/packages/templates/kit/src/layout/sectionCard.ts @@ -0,0 +1,37 @@ +import type { SchemaNode } from '@we/schema-shared'; + +export interface SectionCardOptions { + title: string; + /** The line under the title saying what the section is for. */ + description?: string; + /** Shown at the right of the title row — a spinner, a count, an action. */ + aside?: SchemaNode; + children: SchemaNode[]; +} + +/** + * A titled block of settings or facts, on a card. + * + * The unit both the About and Settings routes are built from, six times over between them, with the + * title/description pair hand-written each time. The `aside` slot exists because one of those six + * needed a saving spinner beside the title and had to restructure the header to get one. + */ +export function sectionCard(opts: SectionCardOptions): SchemaNode { + const heading: SchemaNode = { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'we-text', props: { variant: 'heading-md' }, children: [opts.title] }, + ...(opts.description ? [{ type: 'we-text', children: [opts.description] } as SchemaNode] : []), + ], + }; + + return { + type: 'Card', + props: { bg: 'neutral-100', border: '1px solid neutral-200' }, + children: [ + opts.aside ? { type: 'Row', props: { ax: 'between', ay: 'center' }, children: [heading, opts.aside] } : heading, + ...opts.children, + ], + }; +} diff --git a/packages/templates/kit/src/layout/statChip.ts b/packages/templates/kit/src/layout/statChip.ts new file mode 100644 index 000000000..4d40048ac --- /dev/null +++ b/packages/templates/kit/src/layout/statChip.ts @@ -0,0 +1,50 @@ +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; + +import type { Content } from '../types.ts'; + +export interface StatChipOptions { + icon: string; + /** A number to show, abbreviated — for "12 Conversations". Mutually exclusive with `value`. */ + count?: SchemaProp; + /** A value to show after the label — for "Access: Shared". Mutually exclusive with `count`. */ + value?: Content; + /** + * The noun after a count, or the name before a value. + * + * A plain string, not `Content`: the value form interpolates it (`\`${label}:\``), and a token + * object through that template literal would render `[object Object]:`. + */ + label: string; + iconColor?: string; +} + +/** + * A small icon-led fact, sized to sit in a wrapping row of them at the foot of a card. + * + * Two shapes, because cards state two kinds of fact and they read the same way: a count with its + * noun ("12 Conversations") and a property with its value ("Access: Shared"). Passing `count` gets + * the first — through `we-number`, so large counts abbreviate — and `value` gets the second. + * + * `flex: 'none'` is the point of the fragment as much as the layout is: without it these shrink + * unevenly when the row wraps, and a count ends up on a different line from its noun. + */ +export function statChip(opts: StatChipOptions): SchemaNode { + const isCount = opts.count !== undefined; + + return { + type: 'Row', + props: { gap: '100', ay: 'center', flex: 'none' }, + children: [ + { type: 'we-icon', props: { name: opts.icon, size: 'sm', color: opts.iconColor ?? 'neutral-600' } }, + ...(isCount + ? [ + { type: 'we-number', props: { value: opts.count, shorten: true } } as SchemaNode, + { type: 'we-text', props: { color: 'neutral-600' }, children: [opts.label] } as SchemaNode, + ] + : [ + { type: 'we-text', props: { color: 'neutral-600' }, children: [`${opts.label}:`] } as SchemaNode, + { type: 'we-text', props: { color: 'neutral-800' }, children: [opts.value] } as SchemaNode, + ]), + ], + }; +} diff --git a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CardShell.ts b/packages/templates/kit/src/lists/cards.ts similarity index 82% rename from packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CardShell.ts rename to packages/templates/kit/src/lists/cards.ts index 429b70c80..7f7c1fdc8 100644 --- a/packages/templates/default/src/DefaultTemplate/routes/CardsRoute/CardShell.ts +++ b/packages/templates/kit/src/lists/cards.ts @@ -1,4 +1,20 @@ -import type { QueryStateField, SchemaNode, SchemaProp } from '@we/schema-shared'; +/** + * A list of cards, and the card in it. + * + * ## What these require from the surrounding template + * + * These are the kit's most scope-dependent fragments, so the contract is written down rather than + * discovered: `cardShell` and `gridWrapper` read **`$local: 'displayMode'`** (`'compact' | 'expanded' + * | 'grid'`) from an ancestor, and `cardList` *writes* its query results into `$local` under + * `Rows`. A template using them must declare `displayMode` somewhere above, and must not use + * that derived name for anything else. + * + * Reading up the tree rather than taking a prop is deliberate: the display toggle belongs to the + * page, and threading it through every list and every card would put a prop on each of them whose + * only job is to be passed on. The cost is this paragraph, and a check at insert time when these + * become insertable. + */ +import type { LocalStateField, QueryStateField, SchemaNode, SchemaProp } from '@we/schema-shared'; export interface CardShellOptions { /** Nodes always visible regardless of display mode (compact header row) */ @@ -21,7 +37,7 @@ export interface CardShellOptions { * console and no-ops: the button renders, is clickable, and does nothing. Both of PostsList's * controls were in exactly that state. */ - localState?: Record; + localState?: Record; } const defaultMaxHeight = { @@ -119,8 +135,12 @@ export function cardShell(opts: CardShellOptions): SchemaNode { }; } -/** Grid wrapper that switches between 1-column and 3-column based on displayMode */ -export const gridWrapper = (children: SchemaNode[]): SchemaNode => ({ +/** + * Grid wrapper switching 1 ↔ 3 columns on displayMode. Internal: after the cardList conversion no + * template calls it directly, and an export nobody calls is vocabulary noise once fragments are + * things the marketplace lists. + */ +const gridWrapper = (children: SchemaNode[]): SchemaNode => ({ type: 'Grid', props: { gap: '400', diff --git a/packages/templates/kit/src/overlays/confirmModal.ts b/packages/templates/kit/src/overlays/confirmModal.ts new file mode 100644 index 000000000..db717d895 --- /dev/null +++ b/packages/templates/kit/src/overlays/confirmModal.ts @@ -0,0 +1,91 @@ +import type { SchemaNode } from '@we/schema-shared'; + +import type { Content } from '../types.ts'; + +export interface ConfirmModalOptions { + /** + * The boolean `$local` that opens it. Must be declared by an ancestor — on a card, that means + * `cardShell`'s `localState`, since the button that sets it lives in the card's header. + */ + openLocal: string; + title: string; + /** What confirming will do, and whether it can be undone. */ + body: Content; + confirmLabel: string; + /** The action to run. Closing the modal on success is added for you. */ + confirm: Record; + confirmVariant?: string; + cancelLabel?: string; + /** + * A boolean `$local` to spin the confirm button while the action runs, set before it and cleared + * in `onFinally`. + * + * Worth declaring whenever the action is not instant — a recursive delete walks its whole + * collection, and without a spinner the button absorbs the click and appears to have failed, + * which invites a second click at a delete already running. Cleared in `onFinally` rather than + * `onError` because on the success path the node usually unmounts with the record; a failure that + * left the button spinning forever is the worse end of that trade. + */ + busyLocal?: string; +} + +/** + * "Are you sure?", with the gate that opens it. + * + * Returns the `$if` as well as the modal, so a caller writes one node rather than remembering that + * a modal must be conditionally mounted — three of these existed and all three were the same twelve + * lines around a different sentence. + * + * The cancel button and the close handler both clear `openLocal`, and so does the action's + * `onSuccess`: the modal is never left open over a completed action, and never closed over a + * running one. + */ +export function confirmModal(opts: ConfirmModalOptions): SchemaNode { + const close = { $setLocal: opts.openLocal, value: false }; + const confirmAction = { ...opts.confirm, onSuccess: [close, ...((opts.confirm.onSuccess as unknown[]) ?? [])] }; + + const onClick = opts.busyLocal + ? [ + { $setLocal: opts.busyLocal, value: true }, + { ...confirmAction, onFinally: [{ $setLocal: opts.busyLocal, value: false }] }, + ] + : confirmAction; + + return { + type: '$if', + props: { + condition: { $local: opts.openLocal }, + then: { + type: 'we-modal', + props: { close }, + children: [ + { type: 'we-text', props: { fontWeight: 'semibold' }, children: [opts.title] }, + { type: 'we-text', children: [opts.body] }, + { + type: 'Row', + props: { ax: 'end', gap: '200' }, + children: [ + { + type: 'we-button', + props: { variant: 'ghost', onClick: close }, + children: [opts.cancelLabel ?? 'Cancel'], + }, + { + type: 'we-button', + props: { + variant: opts.confirmVariant ?? 'danger', + ...(opts.busyLocal && { + loading: { $local: opts.busyLocal }, + disabled: { $local: opts.busyLocal }, + }), + onClick, + }, + children: [opts.confirmLabel], + }, + ], + }, + ], + }, + }, + }; +} diff --git a/packages/templates/default/src/DefaultTemplate/EmptyState.ts b/packages/templates/kit/src/states/emptyState.ts similarity index 67% rename from packages/templates/default/src/DefaultTemplate/EmptyState.ts rename to packages/templates/kit/src/states/emptyState.ts index cceb76464..87aadfed5 100644 --- a/packages/templates/default/src/DefaultTemplate/EmptyState.ts +++ b/packages/templates/kit/src/states/emptyState.ts @@ -1,5 +1,7 @@ import type { SchemaNode } from '@we/schema-shared'; +import type { Content } from '../types.ts'; + export interface EmptyStateOptions { /** The content type's own icon — the same name the type picker uses for it. */ icon: string; @@ -15,7 +17,7 @@ export interface EmptyStateOptions { */ searchable?: boolean; /** Replaces the sentence entirely, for a list the `label` phrasing does not fit. */ - message?: unknown; + message?: Content; /** * How long the placeholder stays invisible before fading in, in ms. * @@ -32,20 +34,25 @@ export interface EmptyStateOptions { * What a list shows when it has nothing to show: the content type's icon, and a sentence naming * what is absent. * + * ## Why this is a fragment and not a component + * + * Nothing here needs code — it is a centred Column, an icon and a line of text. Kept as data, an + * author who wants a smaller icon edits one node; as a component they would need an `iconSize` prop + * to have been predicted, implemented and released first. See CONVENTIONS.md for the full rule. + * * ## Why a helper rather than a node per list * - * Five of the card lists had one of these and the other nine had nothing, so switching content type - * either explained the emptiness or left the page blank depending on which type you picked. The - * difference was not a decision — it was that each one had been written by hand, and writing it - * fourteen times is what made it easy to skip. One helper is also the only way the icon, the muted - * colour and the wording stay in agreement as lists are added. + * Five of the cards route's fourteen lists had one of these and the other nine had nothing, so + * switching content type either explained the emptiness or left the page blank depending on which + * type you picked. The difference was not a decision — it was that each one had been written by + * hand, and writing it fourteen times is what made it easy to skip. * * Sized and centred rather than a bare line of text, because it stands in for a grid of cards: a * left-aligned sentence under a header reads as a caption for content that is about to appear. */ export function emptyState(opts: EmptyStateOptions): SchemaNode { const nothingHere = `This space doesn't have any ${opts.label}.`; - const message = + const message: Content = opts.message ?? (opts.searchable ? { @@ -75,3 +82,18 @@ export function emptyState(opts: EmptyStateOptions): SchemaNode { children: [placeholder], }; } + +/** + * The one-line version, for a list inside a section that already has a heading. + * + * Where `emptyState` stands in for a whole page of content and is sized accordingly, this stands in + * for a few rows under a title that has already said what they would have been — so it says only + * that there are none, quietly. + */ +export function emptyNote(text: string): SchemaNode { + return { + type: 'we-text', + props: { variant: 'footnote', color: 'neutral-500', italic: true }, + children: [text], + }; +} diff --git a/packages/templates/kit/src/states/gatePrompt.ts b/packages/templates/kit/src/states/gatePrompt.ts new file mode 100644 index 000000000..a35fe04a7 --- /dev/null +++ b/packages/templates/kit/src/states/gatePrompt.ts @@ -0,0 +1,89 @@ +import type { LocalStateField, SchemaNode } from '@we/schema-shared'; + +import type { Content } from '../types.ts'; + +export interface GatePromptOptions { + icon: string; + /** + * The icon's treatment, and the prompt's whole tone in one prop. + * + * A gradient reads as an invitation — join this, explore that. A flat neutral or warning colour + * reads as a dead end: nothing you do here will change it. Pick by whether the prompt has an + * action under it, and the two will stay legible apart at a glance. + */ + iconGradient?: string; + iconColor?: string; + title: string; + /** The paragraph under the title. Omit for a prompt whose title says everything. */ + body?: Content; + /** How wide that paragraph may get before wrapping. */ + bodyWidth?: string; + /** Anything below the text: a button, a status line, a whole form. */ + children?: SchemaNode[]; + /** + * Fill the height available and centre inside it — right for a gate standing in for a page, + * wrong for one inside a panel that has its own flow. + */ + fill?: boolean; + /** Let a tall prompt scroll rather than clip. Set it when `children` is a form. */ + scroll?: boolean; + gap?: string; + /** State the prompt's own controls need — a `joining` flag, a form's fields. */ + localState?: Record; +} + +/** + * The page that stands in for a page: an icon, a line saying where you are, a sentence saying why + * there is nothing here, and — when there is something to do about it — the thing to do. + * + * There were eight of these across the two template packages, and two of them (`notConfigured` in + * `SpaceGate` and in `MarketplaceGate`) were byte-identical in different packages. That is the + * signature of a shape being copied rather than chosen, and it is the whole argument for this kit: + * nobody decided the marketplace's "coming soon" should have a different gap and a different title + * size from every other prompt, it just drifted. + * + * Everything it renders stays plain nodes, so a template that wants this shape with a smaller icon + * or the button above the text edits the expansion. Nothing here is behaviour, so nothing here + * needs to be code. + */ +export function gatePrompt(opts: GatePromptOptions): SchemaNode { + return { + ...(opts.localState && { $localState: opts.localState }), + type: 'Column', + props: { + flex: '1', + ...(opts.fill !== false && { height: '100%' }), + ax: 'center', + ay: 'center', + gap: opts.gap ?? '400', + p: '600', + ...(opts.scroll && { overflow: 'auto' }), + }, + children: [ + { + type: 'we-icon', + props: { + name: opts.icon, + size: 'xl', + ...(opts.iconGradient && { gradient: opts.iconGradient }), + ...(opts.iconColor && { color: opts.iconColor }), + }, + }, + { type: 'we-text', props: { variant: 'heading-md', textAlign: 'center' }, children: [opts.title] }, + ...(opts.body !== undefined + ? [ + { + type: 'we-text', + props: { + variant: 'body', + textAlign: 'center', + maxWidth: opts.bodyWidth ?? 'var(--we-layout-xs)', + }, + children: [opts.body], + } as SchemaNode, + ] + : []), + ...(opts.children ?? []), + ], + }; +} diff --git a/packages/templates/kit/src/types.ts b/packages/templates/kit/src/types.ts new file mode 100644 index 000000000..a06dd4c08 --- /dev/null +++ b/packages/templates/kit/src/types.ts @@ -0,0 +1,11 @@ +import type { SchemaNode } from '@we/schema-shared'; + +/** + * Anything that can sit in a node's `children`: a literal string, another node, or an operator + * token that resolves to text (`$store`, `$concat`, `$if`, `$plural`, …). + * + * Named here because most of this kit's options are "a label, or an expression producing one", and + * `unknown` would push the cast onto every fragment while `string` would refuse the expressions + * that make the fragments worth having. + */ +export type Content = NonNullable[number]; diff --git a/packages/templates/shell/src/settingsSection.ts b/packages/templates/kit/src/we/adminSection.ts similarity index 52% rename from packages/templates/shell/src/settingsSection.ts rename to packages/templates/kit/src/we/adminSection.ts index 197f08162..7cba376e2 100644 --- a/packages/templates/shell/src/settingsSection.ts +++ b/packages/templates/kit/src/we/adminSection.ts @@ -1,13 +1,25 @@ import type { SchemaNode } from '@we/schema-shared'; /** - * A labelled block with a heading and a refresh control. + * A labelled block of backend administration, with a heading and a refresh control. * - * The shape every backend-administration section shares. They all render a list the backend owns - * rather than one WE writes, so a manual refresh is not a convenience here — nothing in the app - * changes these, and there is no subscription to tell us when something else did. + * The shape every runtime-settings section shares. They all render a list the *backend* owns rather + * than one WE writes, so a manual refresh is not a convenience here — nothing in the app changes + * these, and there is no subscription to tell us when something else did. + * + * WE-domain rather than layout: the spinner reads `runtimeStore.loading`, so a deployment without + * that store gets a control that never resolves. */ -export function section(title: string, icon: string, refresh: string, children: SchemaNode[]): SchemaNode { +export interface AdminSectionOptions { + title: string; + icon: string; + /** The store action a manual refresh calls — `'runtimeStore.loadTrustedAgents'`. */ + refresh: string; + children: SchemaNode[]; +} + +export function adminSection(opts: AdminSectionOptions): SchemaNode { + const { title, icon, refresh, children } = opts; return { type: 'Column', props: { gap: '300' }, @@ -40,12 +52,3 @@ export function section(title: string, icon: string, refresh: string, children: ], }; } - -/** The line every one of these sections shows in place of a list it has nothing for. */ -export function emptyNote(text: string): SchemaNode { - return { - type: 'we-text', - props: { variant: 'footnote', color: 'neutral-500', italic: true }, - children: [text], - }; -} diff --git a/packages/templates/kit/src/we/agentByline.ts b/packages/templates/kit/src/we/agentByline.ts new file mode 100644 index 000000000..b754e5a3e --- /dev/null +++ b/packages/templates/kit/src/we/agentByline.ts @@ -0,0 +1,80 @@ +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; + +export interface AgentBylineOptions { + /** The DID to look up — usually a record's `author` field. */ + did: SchemaProp; + /** + * Context key the resolved profile is bound to. Must not collide with an outer `$each` or a + * surrounding `$agent`, which is why it is worth naming per call site rather than defaulting + * everywhere to `author`. + */ + as?: string; + avatarSize?: string; + /** When this was written. Shown relative, because that is what a reader wants from a byline. */ + timestamp?: SchemaProp; + /** Stack the name above the timestamp rather than running them along one line. */ + stacked?: boolean; + nameColor?: string; + /** Anything after the name — a badge, a menu, a role. */ + children?: SchemaNode[]; +} + +/** + * Who wrote this, with their picture: the line at the top of a post, a message, an utterance. + * + * `$agent` is the reason this has to be a fragment rather than a component — resolving a DID to a + * profile is schema machinery, and a registered component cannot do it. Everything the fragment + * draws is otherwise ordinary nodes, so a template wanting the avatar bigger or the timestamp first + * edits the expansion. + * + * The picture is addressed by `hash` as well as `image` so an agent with no avatar still gets a + * stable identicon rather than an empty circle — the same face every time, which is most of what a + * byline is for. + * + * `avatar`, `name` and `time` are named because both arrangements share them — the `as` + * interpolation should exist exactly once. See CONVENTIONS.md. + */ +export function agentByline(opts: AgentBylineOptions): SchemaNode { + const as = opts.as ?? 'author'; + const avatar: SchemaNode = { + type: 'we-avatar', + props: { size: opts.avatarSize ?? 'sm', image: `$${as}.avatar`, hash: `$${as}.did` }, + }; + const name: SchemaNode = { + type: 'we-text', + props: { fontWeight: 'semibold', ...(opts.nameColor && { color: opts.nameColor }) }, + children: [`$${as}.name`], + }; + const time: SchemaNode[] = + opts.timestamp !== undefined + ? [{ type: 'we-timestamp', props: { value: opts.timestamp, relative: true, color: 'neutral-500' } }] + : []; + + return { + type: '$agent', + props: { did: opts.did, as }, + children: [ + opts.stacked + ? { + type: 'Row', + props: { gap: '300', ay: 'start' }, + children: [ + avatar, + { + type: 'Column', + props: { gap: '100' }, + children: [ + { type: 'Row', props: { ay: 'center', gap: '200' }, children: [name, ...time] }, + ...(opts.children ?? []), + ], + }, + ], + } + : { + type: 'Row', + props: { ay: 'center', gap: '300' }, + children: [avatar, name, ...time, ...(opts.children ?? [])], + }, + ], + }; +} diff --git a/packages/templates/kit/src/we/marketplaceList.ts b/packages/templates/kit/src/we/marketplaceList.ts new file mode 100644 index 000000000..0c7bb75bd --- /dev/null +++ b/packages/templates/kit/src/we/marketplaceList.ts @@ -0,0 +1,130 @@ +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; + +import { gatePrompt } from '../states/gatePrompt.ts'; + +export interface MarketplaceListOptions { + /** What to list out of the marketplace dataset — `Template` or `Theme`. */ + entity: string; + /** Context key for one row. Also names the hoisted results (`Items`). */ + as: string; + /** Plural noun, for the search placeholder and the empty state. */ + label: string; + /** The empty state's icon — the type's own. */ + emptyIcon: string; + /** A second line under the empty state's title. The compact form omits it. */ + emptyBody?: string; + /** Props for each row's `TemplateCard` beyond `template`, which is supplied. */ + card: Record; + /** + * Three across, for the marketplace's own pages, or a single column for a panel inside settings. + * The difference is the surface, not the content — the same rows either way. + */ + layout?: 'grid' | 'list'; + /** Offer a newest/oldest control, and drive the query's order from it. */ + sortable?: boolean; + /** Relations to hydrate — screenshots, on the pages that show them. */ + include?: Record; +} + +/** + * Browse what the marketplace holds, with a search box and a card per item. + * + * There were four of these and they were two pairs of near-identical files: templates and themes + * on the marketplace's own pages, and the same two again as compact panels inside space settings. + * Copies rather than choices, and they had already drifted — the themes page wires up install, + * delete and a per-row spinner, and the templates page beside it wires up none of the three. + * + * The four axes that genuinely differ are options here; everything else is the same list. Reading + * `datasetStore.marketplaceDataset` puts this in the WE tier: a deployment without a marketplace + * gets a query against nothing. + */ +export function marketplaceList(opts: MarketplaceListOptions): SchemaNode { + const key = `${opts.as}Items`; + const grid = opts.layout !== 'list'; + + const rows: SchemaNode = { + type: '$each', + props: { + items: { + $filter: { items: { $local: key }, where: { name: { contains: { $local: 'search' } } } }, + }, + as: opts.as, + }, + children: [{ type: 'TemplateCard', props: { template: `$${opts.as}`, ...opts.card } }], + }; + + return { + type: 'Column', + props: grid ? { flex: '1', p: '500', gap: '400', ax: 'center', minHeight: '100%' } : { gap: '300' }, + $localState: { + search: { type: 'string', initial: '' }, + ...(opts.sortable && { sort: { type: 'string', initial: 'desc' } }), + }, + $queries: { + [key]: { + entity: opts.entity, + dataset: 'datasetStore.marketplaceDataset', + subscribe: true, + ...(opts.sortable && { order: { createdAt: { $local: 'sort' } } }), + ...(opts.include && { include: opts.include }), + }, + }, + children: [ + { + type: 'Row', + props: grid ? { gap: '300', ay: 'center', maxWidth: '1200px', width: '100%' } : { width: '100%' }, + children: [ + { + type: 'Search', + props: { + ...(grid && { bg: 'neutral-0', border: '1px solid neutral-300', maxWidth: '300px' }), + placeholder: `Search ${opts.label}…`, + value: { $local: 'search' }, + // `$arg` rather than `$event.detail`: `Search` is a layer-4 component and calls its + // handler with the value itself, not with a DOM event. + onSearch: { $setLocal: 'search', from: '$arg' }, + width: '100%', + }, + }, + ...(opts.sortable + ? [ + { + type: 'we-select', + props: { + value: { $local: 'sort' }, + options: [ + { value: 'desc', label: 'Newest first' }, + { value: 'asc', label: 'Oldest first' }, + ], + onChange: { $setLocal: 'sort', from: '$event.detail' }, + }, + } as SchemaNode, + ] + : []), + ], + }, + { + type: '$if', + props: { + condition: { $count: { items: { $local: key } } }, + then: grid + ? { + type: 'Column', + props: { flex: '1', gap: '0', maxWidth: '1200px', width: '100%' }, + children: [{ type: 'Grid', props: { columns: 3, gap: '400', width: '100%' }, children: [rows] }], + } + : { type: 'Column', props: { gap: '200' }, children: [rows] }, + else: gatePrompt({ + icon: opts.emptyIcon, + iconColor: 'neutral-300', + title: `No ${opts.label} available yet`, + body: opts.emptyBody, + fill: false, + gap: '300', + bodyWidth: '360px', + }), + }, + }, + ], + }; +} diff --git a/packages/templates/kit/src/we/peopleRow.ts b/packages/templates/kit/src/we/peopleRow.ts new file mode 100644 index 000000000..1dd6c9fcd --- /dev/null +++ b/packages/templates/kit/src/we/peopleRow.ts @@ -0,0 +1,126 @@ +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; + +import { peopleTooltip } from './peopleTooltip.ts'; + +export interface PeopleRowOptions { + /** The people. Profile objects by default; bare DIDs when `dids` is set. */ + items: SchemaProp; + /** + * The items are DIDs rather than profiles, so pictures and names are joined from + * `profileStore.profiles`. + * + * The join happens per row rather than as a filter over the cache, because the order has to + * follow *this* list — and because `$filter` has no set-membership operator to express "profiles + * whose did is in this list" with. + */ + dids?: boolean; + /** Singular noun for the count beside the faces. Omit for faces alone. */ + noun?: string; + /** The plural, when it is not `${noun}s`. */ + nounPlural?: string; + max?: number; + size?: string; + /** Context key for one person inside the roster tooltip. */ + as?: string; + /** + * A height floor for the row. + * + * `AvatarStack` is a flex container over its avatars, so with none it has no children and no + * height. People resolve on their own path, later than the record they belong to, so a row + * without this collapses and then pushes everything below it down a second time. A fixed floor + * is right rather than a workaround: the row holds fixed-size avatars, so its height depends on + * neither the count nor any font metric. + */ + minHeight?: string; + /** Extra props on the outer Row — margins, mostly. */ + rowProps?: Record; +} + +/** + * A group of faces and how many there are, with the full roster on hover. + * + * The count is inside the hover target, not beside it: "7 Members" and the faces are one statement, + * and a reader who hovers the words expects the same answer as one who hovers the pictures. That + * was the bug that moved the tooltip out of `AvatarStack` in the first place — see + * [peopleTooltip](./peopleTooltip.ts). + * + * `AvatarStack` stays a component because it does real work (overlap maths, ring, sizing); what + * this adds around it — the join, the count, the noun — is arrangement, and stays data. + */ +export function peopleRow(opts: PeopleRowOptions): SchemaNode { + const as = opts.as ?? 'person'; + /** + * Join a DID to one field of its cached profile. Takes the context ref because the same join runs + * in two scopes: the stack's `$map` addresses a person as `$item`, the roster rows as `$`. + */ + const lookup = (ref: string, field: string) => ({ + $find: { items: { $store: 'profileStore.profiles' }, where: { did: ref }, select: field }, + }); + const count = { $count: { items: opts.items } }; + + return peopleTooltip({ + items: opts.items, + as, + image: opts.dids ? lookup(`$${as}`, 'avatar') : `$${as}.avatar`, + hash: opts.dids ? { $concat: [`$${as}`] } : `$${as}.did`, + name: opts.dids ? lookup(`$${as}`, 'name') : `$${as}.name`, + children: [ + { + type: 'Row', + props: { + gap: '300', + ay: 'center', + ...(opts.minHeight && { minHeight: opts.minHeight }), + ...opts.rowProps, + }, + children: [ + { + type: 'AvatarStack', + props: { + avatars: { + $map: { + items: opts.items, + select: opts.dids + ? { + image: lookup('$item', 'avatar'), + /* + Wrapped rather than written as a bare `'$item'`. `$map`'s `select` + resolves a string only when it starts with `'$item.'`; a bare one is a + literal, so the hash would be the five characters `$item` for everybody + and every generated avatar in the row would come out identical. + + Set unconditionally, never as a fallback for a missing `image`: it seeds + an avatar that is stable per agent, so somebody whose profile has not + arrived is still visually distinct from everybody else whose profile has + not arrived. A real picture wins where there is one. + */ + hash: { $concat: ['$item'] }, + } + : { image: '$item.avatar', hash: '$item.did' }, + }, + }, + max: opts.max ?? 5, + size: opts.size ?? 'sm', + ring: '0 0 0 2px var(--we-ring-color)', + }, + }, + ...(opts.noun + ? [ + { + type: 'Row', + props: { gap: '100', ay: 'center' }, + children: [ + { type: 'we-number', props: { value: count, shorten: true } }, + { + type: 'we-text', + children: [{ $plural: { count, one: opts.noun, other: opts.nounPlural ?? `${opts.noun}s` } }], + }, + ], + } as SchemaNode, + ] + : []), + ], + }, + ], + }); +} diff --git a/packages/templates/default/src/DefaultTemplate/PeopleTooltip.ts b/packages/templates/kit/src/we/peopleTooltip.ts similarity index 96% rename from packages/templates/default/src/DefaultTemplate/PeopleTooltip.ts rename to packages/templates/kit/src/we/peopleTooltip.ts index cbc8ad8e2..389d29537 100644 --- a/packages/templates/default/src/DefaultTemplate/PeopleTooltip.ts +++ b/packages/templates/kit/src/we/peopleTooltip.ts @@ -1,18 +1,18 @@ -import type { SchemaNode } from '@we/schema-shared'; +import type { SchemaNode, SchemaProp } from '@we/schema-shared'; export interface PeopleTooltipOptions { /** What to wrap — the avatars *and* whatever count or label sits beside them. */ children: SchemaNode[]; /** The people to list. Any array expression: a store accessor, or a field on the current item. */ - items: unknown; + items: SchemaProp; /** Context key for each person inside the list. Must not collide with an outer `$each`. */ as?: string; /** Expression for a row's picture, in terms of `as`. */ - image: unknown; + image: SchemaProp; /** Expression for a row's identicon seed — a DID, so a face is stable per agent. */ - hash: unknown; + hash: SchemaProp; /** Expression for a row's name. A row that resolves to nothing shows a face and no label. */ - name: unknown; + name: SchemaProp; placement?: 'top' | 'bottom' | 'left' | 'right'; } diff --git a/packages/templates/kit/tsconfig.json b/packages/templates/kit/tsconfig.json new file mode 100644 index 000000000..8face7aa8 --- /dev/null +++ b/packages/templates/kit/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/templates/shell/package.json b/packages/templates/shell/package.json index 3b3c62630..ffa764fd0 100644 --- a/packages/templates/shell/package.json +++ b/packages/templates/shell/package.json @@ -10,7 +10,8 @@ } }, "dependencies": { - "@we/schema-shared": "workspace:*" + "@we/schema-shared": "workspace:*", + "@we/template-kit": "workspace:*" }, "devDependencies": { "@types/node": "^24.10.0" diff --git a/packages/templates/shell/src/AiSettings.schema.ts b/packages/templates/shell/src/AiSettings.schema.ts index ce2e325ad..09089d25b 100644 --- a/packages/templates/shell/src/AiSettings.schema.ts +++ b/packages/templates/shell/src/AiSettings.schema.ts @@ -1,6 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyNote, section } from './settingsSection.ts'; +import { adminSection, emptyNote } from '@we/template-kit'; /** * AI — the models the backend runs or calls, and the prompts apps have registered against them. @@ -364,68 +363,78 @@ export const aiSection: SchemaNode = { type: 'Column', props: { gap: '600' }, children: [ - section('Models', 'sparkle', 'runtimeStore.loadAiModels', [ - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'runtimeStore.aiModels' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.aiModels' }, as: 'model' }, - children: [modelCard], - }, - ], + adminSection({ + title: 'Models', + icon: 'sparkle', + refresh: 'runtimeStore.loadAiModels', + children: [ + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'runtimeStore.aiModels' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.aiModels' }, as: 'model' }, + children: [modelCard], + }, + ], + }, + else: emptyNote('No models are configured. Apps asking for one will have nothing to use.'), }, - else: emptyNote('No models are configured. Apps asking for one will have nothing to use.'), }, - }, - { - type: '$if', - props: { - condition: { $store: 'runtimeStore.canConfigureAi' }, - then: { - type: 'Row', - children: [ - { - type: 'we-button', - props: { - text: 'Add a model', - size: 'sm', - variant: 'secondary', - onClick: { $action: 'runtimeStore.newAiModel' }, + { + type: '$if', + props: { + condition: { $store: 'runtimeStore.canConfigureAi' }, + then: { + type: 'Row', + children: [ + { + type: 'we-button', + props: { + text: 'Add a model', + size: 'sm', + variant: 'secondary', + onClick: { $action: 'runtimeStore.newAiModel' }, + }, + children: [{ type: 'we-icon', props: { name: 'plus' } }], }, - children: [{ type: 'we-icon', props: { name: 'plus' } }], - }, - ], + ], + }, }, }, - }, - ]), + ], + }), - section('Tasks', 'list-checks', 'runtimeStore.loadAiTasks', [ - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'runtimeStore.aiTasks' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.aiTasks' }, as: 'task' }, - children: [taskCard], - }, - ], + adminSection({ + title: 'Tasks', + icon: 'list-checks', + refresh: 'runtimeStore.loadAiTasks', + children: [ + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'runtimeStore.aiTasks' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.aiTasks' }, as: 'task' }, + children: [taskCard], + }, + ], + }, + else: emptyNote('No app has registered a prompt yet.'), }, - else: emptyNote('No app has registered a prompt yet.'), }, - }, - ]), + ], + }), { type: '$if', props: { condition: { $store: 'runtimeStore.aiForm' }, then: modelForm } }, ], diff --git a/packages/templates/shell/src/BootScreen.schema.ts b/packages/templates/shell/src/BootScreen.schema.ts index b44c15d88..5e75c5d36 100644 --- a/packages/templates/shell/src/BootScreen.schema.ts +++ b/packages/templates/shell/src/BootScreen.schema.ts @@ -1,4 +1,5 @@ import type { OperatorToken, SchemaNode, SchemaProp } from '@we/schema-shared'; +import { field } from '@we/template-kit'; /** * The boot screen: the four states a session can be in before the app is usable. @@ -438,69 +439,7 @@ const unlockForm: SchemaNode = { }, }, }, - { - type: 'we-form-field', - props: { - error: { - $if: { - condition: { $store: 'sessionStore.passwordError' }, - then: 'Incorrect password', - else: '', - }, - }, - }, - children: [ - // Field and submit on one row — the shape an OS sign-in uses when there is exactly one - // thing to type and one thing to do with it. The setup screen keeps a full-width button - // instead, because its submit applies to three fields rather than the one beside it. - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { - type: 'we-input', - props: { - width: '220px', - type: 'password', - // The reveal toggle is the input's own, not a button assembled beside it. - revealable: true, - placeholder: 'Password...', - value: { $local: 'password' }, - // Editing the password retracts the verdict on it. "Incorrect password" is about - // the string that was submitted, so it has nothing to say about the one being - // typed to replace it — left up, it reads as a running judgement of the new one. - onInput: [ - { $setLocal: 'password', from: '$event.detail' }, - { $action: 'sessionStore.clearPasswordError' }, - ], - // Enter carries the same precondition as the button, or an empty field would - // reach the executor, fail to unlock, and come back as "Incorrect password" — - // the wrong diagnosis for a password that was never typed. - onKeyDown: { - $if: { - condition: { $and: [{ $eq: ['$arg.detail.key', 'Enter'] }, { $local: 'password' }] }, - then: { $action: 'sessionStore.login', args: [{ $local: 'password' }] }, - }, - }, - }, - }, - { - type: 'we-button', - props: { - variant: 'primary', - // Gated on the value, not on a validation rule. There is nothing to submit until - // something is typed, which is a precondition rather than a judgement — and the - // OS sign-in screens this follows all hold the button until there is. - disabled: { $not: { $local: 'password' } }, - loading: { $store: 'sessionStore.loginLoading' }, - onClick: { $action: 'sessionStore.login', args: [{ $local: 'password' }] }, - }, - children: ['Login'], - }, - ], - }, - ], - }, + field({ name: 'password', placeholder: 'Password...' }), ], }; @@ -798,67 +737,15 @@ export const bootScreen: SchemaNode = { }, // The profile's name, not a separate local label. One DID, one // identity, one thing to type. - { - type: 'we-form-field', - props: { label: 'Name', error: { $error: 'name' } }, - children: [ - { - type: 'we-input', - props: { - placeholder: 'Name...', - value: { $local: 'name' }, - onInput: { $setLocal: 'name', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'name', label: 'Name', placeholder: 'Name...', validated: true }), // Password + confirm - { - type: 'we-form-field', - props: { label: 'Password', error: { $error: 'password' } }, - children: [ - { - type: 'we-input', - props: { - width: '100%', - type: 'password', - revealable: true, - placeholder: 'Password...', - value: { $local: 'password' }, - onInput: { $setLocal: 'password', from: '$event.detail' }, - }, - }, - ], - }, - { - type: 'we-form-field', - props: { - label: 'Confirm password', - error: { - $if: { - condition: { $error: 'confirm' }, - then: { $error: 'confirm' }, - else: { $store: 'sessionStore.createAgentError' }, - }, - }, - }, - children: [ - { - type: 'we-input', - props: { - width: '100%', - type: 'password', - // Each field's toggle is its own — the primitive keeps the - // reveal state per instance, so revealing one does not - // reveal the other. - revealable: true, - placeholder: 'Confirm password...', - value: { $local: 'confirm' }, - onInput: { $setLocal: 'confirm', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'password', label: 'Password', placeholder: 'Password...', validated: true }), + field({ + name: 'confirm', + label: 'Confirm password', + placeholder: 'Confirm password...', + validated: true, + }), ], }, { diff --git a/packages/templates/shell/src/CreateSpaceModal.ts b/packages/templates/shell/src/CreateSpaceModal.ts index 0d8f4ea08..81e8e9920 100644 --- a/packages/templates/shell/src/CreateSpaceModal.ts +++ b/packages/templates/shell/src/CreateSpaceModal.ts @@ -1,4 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; +import { field } from '@we/template-kit'; export const createSpaceModal = { type: 'we-modal', @@ -67,37 +68,10 @@ export const createSpaceModal = { }, // Name - { - type: 'we-form-field', - props: { label: 'Name', error: { $if: { condition: { $error: 'name' }, then: { $error: 'name' } } } }, - children: [ - { - type: 'we-input', - props: { - placeholder: 'Space name...', - value: { $local: 'name' }, - onInput: { $setLocal: 'name', from: '$event.detail' }, - onBlur: { $touch: 'name' }, - }, - }, - ], - }, + field({ name: 'name', label: 'Name', placeholder: 'Space name...', validated: true, touchOnBlur: true }), // Description - { - type: 'we-form-field', - props: { label: 'Description' }, - children: [ - { - type: 'we-input', - props: { - placeholder: 'Description (optional)', - value: { $local: 'description' }, - onInput: { $setLocal: 'description', from: '$event.detail' }, - }, - }, - ], - }, + field({ name: 'description', label: 'Description', placeholder: 'Description (optional)' }), // Location picker { diff --git a/packages/templates/shell/src/LanguageSettings.schema.ts b/packages/templates/shell/src/LanguageSettings.schema.ts index 8b10f08a6..37e6accfa 100644 --- a/packages/templates/shell/src/LanguageSettings.schema.ts +++ b/packages/templates/shell/src/LanguageSettings.schema.ts @@ -1,6 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyNote, section } from './settingsSection.ts'; +import { adminSection, emptyNote } from '@we/template-kit'; /** * Languages — the plugins the backend uses to store and retrieve expressions. @@ -68,59 +67,64 @@ export const languagesSection: SchemaNode = { type: '$if', props: { condition: { $store: 'runtimeStore.canManageLanguages' }, - then: section('Languages', 'code', 'runtimeStore.loadLanguages', [ - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'runtimeStore.languages' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.languages' }, as: 'language' }, - children: [languageRow], - }, - ], + then: adminSection({ + title: 'Languages', + icon: 'code', + refresh: 'runtimeStore.loadLanguages', + children: [ + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'runtimeStore.languages' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.languages' }, as: 'language' }, + children: [languageRow], + }, + ], + }, + else: emptyNote('No languages are installed.'), }, - else: emptyNote('No languages are installed.'), }, - }, - { - type: 'Row', - props: { gap: '200' }, - children: [ - { - type: 'we-input', - props: { - flex: '1', - size: 'sm', - placeholder: 'Language address, e.g. QmUTkvPcyaUGntqfzi3iR1xomADm5yYC2j8hcPdhMHpTem', - value: { $local: 'newLanguageAddress' }, - onInput: { $setLocal: 'newLanguageAddress', from: '$event.detail' }, + { + type: 'Row', + props: { gap: '200' }, + children: [ + { + type: 'we-input', + props: { + flex: '1', + size: 'sm', + placeholder: 'Language address, e.g. QmUTkvPcyaUGntqfzi3iR1xomADm5yYC2j8hcPdhMHpTem', + value: { $local: 'newLanguageAddress' }, + onInput: { $setLocal: 'newLanguageAddress', from: '$event.detail' }, + }, }, - }, - { - type: 'we-button', - props: { - text: 'Install', - size: 'sm', - variant: 'secondary', - // The backend fetches the bundle over the network, so this is the one runtime action - // that can take long enough to need a spinner rather than just finishing. - loading: { $store: 'runtimeStore.loading' }, - disabled: { $not: { $local: 'newLanguageAddress' } }, - onClick: { - $action: 'runtimeStore.installLanguage', - args: [{ $local: 'newLanguageAddress' }], - onSuccess: [{ $setLocal: 'newLanguageAddress', value: '' }], + { + type: 'we-button', + props: { + text: 'Install', + size: 'sm', + variant: 'secondary', + // The backend fetches the bundle over the network, so this is the one runtime action + // that can take long enough to need a spinner rather than just finishing. + loading: { $store: 'runtimeStore.loading' }, + disabled: { $not: { $local: 'newLanguageAddress' } }, + onClick: { + $action: 'runtimeStore.installLanguage', + args: [{ $local: 'newLanguageAddress' }], + onSuccess: [{ $setLocal: 'newLanguageAddress', value: '' }], + }, }, }, - }, - ], - }, - ]), + ], + }, + ], + }), }, }; diff --git a/packages/templates/shell/src/RuntimeSettings.schema.ts b/packages/templates/shell/src/RuntimeSettings.schema.ts index 96a6061eb..9d747a600 100644 --- a/packages/templates/shell/src/RuntimeSettings.schema.ts +++ b/packages/templates/shell/src/RuntimeSettings.schema.ts @@ -1,6 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; - -import { emptyNote, section } from './settingsSection.ts'; +import { adminSection, emptyNote } from '@we/template-kit'; /** The five the backend's logger accepts; anything else it silently drops. */ const LOG_LEVEL_OPTIONS = [ @@ -51,107 +50,112 @@ export const connectedApps: SchemaNode = { type: '$if', props: { condition: { $store: 'runtimeStore.canManageApps' }, - then: section('Connected apps', 'squares-four', 'runtimeStore.loadAuthorizedApps', [ - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'runtimeStore.authorizedApps' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.authorizedApps' }, as: 'app' }, - children: [ - { - type: 'Card', - props: { bg: 'neutral-100' }, - children: [ - { - type: 'Row', - props: { gap: '300', ay: 'center', ax: 'between' }, - children: [ - { - type: 'Row', - props: { gap: '300', ay: 'center' }, - children: [ - { type: 'we-avatar', props: { image: '$app.iconUrl', size: 'sm' } }, - { - type: 'Column', - props: { gap: '100' }, - children: [ - { - type: 'Row', - props: { gap: '200', ay: 'center' }, - children: [ - { type: 'we-text', props: { variant: 'label' }, children: ['$app.name'] }, - { - type: '$if', - props: { - condition: '$app.revoked', - then: { - type: 'we-badge', - props: { variant: 'neutral', size: 'xs' }, - children: ['Revoked'], + then: adminSection({ + title: 'Connected apps', + icon: 'squares-four', + refresh: 'runtimeStore.loadAuthorizedApps', + children: [ + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'runtimeStore.authorizedApps' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.authorizedApps' }, as: 'app' }, + children: [ + { + type: 'Card', + props: { bg: 'neutral-100' }, + children: [ + { + type: 'Row', + props: { gap: '300', ay: 'center', ax: 'between' }, + children: [ + { + type: 'Row', + props: { gap: '300', ay: 'center' }, + children: [ + { type: 'we-avatar', props: { image: '$app.iconUrl', size: 'sm' } }, + { + type: 'Column', + props: { gap: '100' }, + children: [ + { + type: 'Row', + props: { gap: '200', ay: 'center' }, + children: [ + { type: 'we-text', props: { variant: 'label' }, children: ['$app.name'] }, + { + type: '$if', + props: { + condition: '$app.revoked', + then: { + type: 'we-badge', + props: { variant: 'neutral', size: 'xs' }, + children: ['Revoked'], + }, }, }, + ], + }, + { + type: 'we-text', + props: { variant: 'footnote', color: 'neutral-500' }, + children: ['$app.url'], + }, + ], + }, + ], + }, + { + type: 'Row', + props: { gap: '200' }, + children: [ + // Revoke stays available while unrevoked; remove always is, + // so a stale entry can be cleared without revoking first. + { + type: '$if', + props: { + condition: { $not: '$app.revoked' }, + then: { + type: 'we-button', + props: { + text: 'Revoke', + variant: 'ghost', + size: 'sm', + onClick: { $action: 'runtimeStore.revokeApp', args: ['$app.id'] }, }, - ], - }, - { - type: 'we-text', - props: { variant: 'footnote', color: 'neutral-500' }, - children: ['$app.url'], - }, - ], - }, - ], - }, - { - type: 'Row', - props: { gap: '200' }, - children: [ - // Revoke stays available while unrevoked; remove always is, - // so a stale entry can be cleared without revoking first. - { - type: '$if', - props: { - condition: { $not: '$app.revoked' }, - then: { - type: 'we-button', - props: { - text: 'Revoke', - variant: 'ghost', - size: 'sm', - onClick: { $action: 'runtimeStore.revokeApp', args: ['$app.id'] }, }, }, }, - }, - { - type: 'we-button', - props: { - variant: 'ghost', - size: 'sm', - onClick: { $action: 'runtimeStore.removeApp', args: ['$app.id'] }, + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + onClick: { $action: 'runtimeStore.removeApp', args: ['$app.id'] }, + }, + children: [{ type: 'we-icon', props: { name: 'trash' } }], }, - children: [{ type: 'we-icon', props: { name: 'trash' } }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + else: emptyNote('No apps have been granted access to your agent.'), }, - else: emptyNote('No apps have been granted access to your agent.'), }, - }, - ]), + ], + }), }, }; @@ -160,85 +164,90 @@ export const trustedAgents: SchemaNode = { type: '$if', props: { condition: { $store: 'runtimeStore.canManageTrust' }, - then: section('Trusted agents', 'shield-check', 'runtimeStore.loadTrustedAgents', [ - { - type: '$if', - props: { - condition: { $count: { items: { $store: 'runtimeStore.trustedAgents' } } }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.trustedAgents' }, as: 'did' }, - children: [ - { - type: 'Row', - props: { - gap: '300', - ay: 'center', - ax: 'between', - bg: 'neutral-100', - r: '300', - px: '300', - py: '200', - }, - children: [ - { - type: 'we-text', - props: { variant: 'footnote', styles: { 'word-break': 'break-all' } }, - children: ['$did'], + then: adminSection({ + title: 'Trusted agents', + icon: 'shield-check', + refresh: 'runtimeStore.loadTrustedAgents', + children: [ + { + type: '$if', + props: { + condition: { $count: { items: { $store: 'runtimeStore.trustedAgents' } } }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.trustedAgents' }, as: 'did' }, + children: [ + { + type: 'Row', + props: { + gap: '300', + ay: 'center', + ax: 'between', + bg: 'neutral-100', + r: '300', + px: '300', + py: '200', }, - { - type: 'we-button', - props: { - variant: 'ghost', - size: 'sm', - onClick: { $action: 'runtimeStore.untrustAgent', args: ['$did'] }, + children: [ + { + type: 'we-text', + props: { variant: 'footnote', styles: { 'word-break': 'break-all' } }, + children: ['$did'], }, - children: [{ type: 'we-icon', props: { name: 'x' } }], - }, - ], - }, - ], - }, - ], + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'sm', + onClick: { $action: 'runtimeStore.untrustAgent', args: ['$did'] }, + }, + children: [{ type: 'we-icon', props: { name: 'x' } }], + }, + ], + }, + ], + }, + ], + }, + else: emptyNote('No agents are explicitly trusted yet.'), }, - else: emptyNote('No agents are explicitly trusted yet.'), }, - }, - { - type: 'Row', - props: { gap: '200' }, - children: [ - { - type: 'we-input', - props: { - flex: '1', - size: 'sm', - placeholder: 'did:key:...', - value: { $local: 'newTrustedAgent' }, - onInput: { $setLocal: 'newTrustedAgent', from: '$event.detail' }, + { + type: 'Row', + props: { gap: '200' }, + children: [ + { + type: 'we-input', + props: { + flex: '1', + size: 'sm', + placeholder: 'did:key:...', + value: { $local: 'newTrustedAgent' }, + onInput: { $setLocal: 'newTrustedAgent', from: '$event.detail' }, + }, }, - }, - { - type: 'we-button', - props: { - text: 'Trust', - size: 'sm', - variant: 'secondary', - disabled: { $not: { $local: 'newTrustedAgent' } }, - onClick: { - $action: 'runtimeStore.trustAgent', - args: [{ $local: 'newTrustedAgent' }], - onSuccess: [{ $setLocal: 'newTrustedAgent', value: '' }], + { + type: 'we-button', + props: { + text: 'Trust', + size: 'sm', + variant: 'secondary', + disabled: { $not: { $local: 'newTrustedAgent' } }, + onClick: { + $action: 'runtimeStore.trustAgent', + args: [{ $local: 'newTrustedAgent' }], + onSuccess: [{ $setLocal: 'newTrustedAgent', value: '' }], + }, }, }, - }, - ], - }, - ]), + ], + }, + ], + }), }, }; @@ -550,120 +559,125 @@ export const peerNetwork: SchemaNode = { type: '$if', props: { condition: { $store: 'runtimeStore.canManageNetwork' }, - then: section('Peer network', 'globe', 'runtimeStore.loadNetworkMetrics', [ - { - type: 'Row', - props: { gap: '200', wrap: true }, - children: [ - { - type: 'we-button', - props: { - text: 'Restart networking', - size: 'sm', - variant: 'secondary', - loading: { $store: 'runtimeStore.loading' }, - onClick: { $action: 'runtimeStore.restartNetwork' }, - }, - }, - { - type: 'we-button', - props: { - text: 'Exchange peer info', - size: 'sm', - variant: 'ghost', - onClick: [{ $toggleLocal: 'showPeerExchange' }, { $action: 'runtimeStore.loadPeerInfos' }], + then: adminSection({ + title: 'Peer network', + icon: 'globe', + refresh: 'runtimeStore.loadNetworkMetrics', + children: [ + { + type: 'Row', + props: { gap: '200', wrap: true }, + children: [ + { + type: 'we-button', + props: { + text: 'Restart networking', + size: 'sm', + variant: 'secondary', + loading: { $store: 'runtimeStore.loading' }, + onClick: { $action: 'runtimeStore.restartNetwork' }, + }, }, - }, - ], - }, - // Diagnostics are opt-in: the blob is long, unformatted, and meaningless unless - // something is already wrong. - { - type: '$if', - props: { - condition: { $store: 'runtimeStore.networkMetrics' }, - then: { - type: 'we-scroll-area', - props: { maxHeight: '200px' }, - children: [ - { - type: 'we-code', - props: { block: true }, - children: [{ $store: 'runtimeStore.networkMetrics' }], + { + type: 'we-button', + props: { + text: 'Exchange peer info', + size: 'sm', + variant: 'ghost', + onClick: [{ $toggleLocal: 'showPeerExchange' }, { $action: 'runtimeStore.loadPeerInfos' }], }, - ], + }, + ], + }, + // Diagnostics are opt-in: the blob is long, unformatted, and meaningless unless + // something is already wrong. + { + type: '$if', + props: { + condition: { $store: 'runtimeStore.networkMetrics' }, + then: { + type: 'we-scroll-area', + props: { maxHeight: '200px' }, + children: [ + { + type: 'we-code', + props: { block: true }, + children: [{ $store: 'runtimeStore.networkMetrics' }], + }, + ], + }, }, }, - }, - // Manual peer exchange — the escape hatch for when discovery cannot find anyone. - { - type: '$if', - props: { - condition: { $local: 'showPeerExchange' }, - then: { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: 'we-text', - props: { variant: 'footnote', color: 'neutral-500' }, - children: [ - 'Share these records with a peer who cannot find you, and paste theirs below. Only needed when automatic discovery fails.', - ], - }, - // One block per record rather than the whole array as children: the array - // would stringify, and each record is separately copyable this way. - { - type: 'we-scroll-area', - props: { maxHeight: '120px' }, - children: [ - { - type: 'Column', - props: { gap: '200' }, - children: [ - { - type: '$each', - props: { items: { $store: 'runtimeStore.peerInfos' }, as: 'info' }, - children: [ - { - type: 'we-code', - props: { block: true, styles: { 'word-break': 'break-all' } }, - children: ['$info'], - }, - ], - }, - ], + // Manual peer exchange — the escape hatch for when discovery cannot find anyone. + { + type: '$if', + props: { + condition: { $local: 'showPeerExchange' }, + then: { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: 'we-text', + props: { variant: 'footnote', color: 'neutral-500' }, + children: [ + 'Share these records with a peer who cannot find you, and paste theirs below. Only needed when automatic discovery fails.', + ], + }, + // One block per record rather than the whole array as children: the array + // would stringify, and each record is separately copyable this way. + { + type: 'we-scroll-area', + props: { maxHeight: '120px' }, + children: [ + { + type: 'Column', + props: { gap: '200' }, + children: [ + { + type: '$each', + props: { items: { $store: 'runtimeStore.peerInfos' }, as: 'info' }, + children: [ + { + type: 'we-code', + props: { block: true, styles: { 'word-break': 'break-all' } }, + children: ['$info'], + }, + ], + }, + ], + }, + ], + }, + { + type: 'we-textarea', + props: { + rows: 3, + placeholder: "Paste a peer's info here...", + value: { $local: 'peerInfoText' }, + onInput: { $setLocal: 'peerInfoText', from: '$event.detail' }, }, - ], - }, - { - type: 'we-textarea', - props: { - rows: 3, - placeholder: "Paste a peer's info here...", - value: { $local: 'peerInfoText' }, - onInput: { $setLocal: 'peerInfoText', from: '$event.detail' }, }, - }, - { - type: 'we-button', - props: { - text: 'Add peer info', - size: 'sm', - variant: 'secondary', - disabled: { $not: { $local: 'peerInfoText' } }, - onClick: { - $action: 'runtimeStore.addPeerInfos', - args: [{ $local: 'peerInfoText' }], - onSuccess: [{ $setLocal: 'peerInfoText', value: '' }], + { + type: 'we-button', + props: { + text: 'Add peer info', + size: 'sm', + variant: 'secondary', + disabled: { $not: { $local: 'peerInfoText' } }, + onClick: { + $action: 'runtimeStore.addPeerInfos', + args: [{ $local: 'peerInfoText' }], + onSuccess: [{ $setLocal: 'peerInfoText', value: '' }], + }, }, }, - }, - ], + ], + }, }, }, - }, - ]), + ], + }), }, }; diff --git a/packages/templates/shell/src/marketplace/MarketplaceGate.ts b/packages/templates/shell/src/marketplace/MarketplaceGate.ts index 0a4595426..1e6c01944 100644 --- a/packages/templates/shell/src/marketplace/MarketplaceGate.ts +++ b/packages/templates/shell/src/marketplace/MarketplaceGate.ts @@ -1,38 +1,24 @@ import type { SchemaNode } from '@we/schema-shared'; +import { gatePrompt } from '@we/template-kit'; -const notConfiguredPrompt: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'warning', size: 'xl' } }, - { - type: 'we-text', - props: { variant: 'heading-md' }, - children: ['Marketplace not configured'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: '400px' }, - children: ['No marketplace URL has been set in we-seed.json.'], - }, - ], -}; +/* + A dead end rather than an invitation, and it says so before it is read: a flat warning icon + instead of the gradient the join prompt carries. Nothing the reader does here changes it — the + seed file is not theirs to edit from inside the app. +*/ +const notConfiguredPrompt: SchemaNode = gatePrompt({ + icon: 'warning', + title: 'Marketplace not configured', + body: 'No marketplace URL has been set in we-seed.json.', +}); -const joinPrompt: SchemaNode = { - type: 'Column', - props: { flex: '1', height: '100%', ax: 'center', ay: 'center', gap: '400', p: '600' }, +const joinPrompt: SchemaNode = gatePrompt({ + icon: 'storefront', + iconGradient: 'primary', + title: 'Module Marketplace', + body: 'Browse and install templates, themes, blocks, and components shared by the WE community.', + bodyWidth: '420px', children: [ - { type: 'we-icon', props: { name: 'storefront', size: 'xl', gradient: 'primary' } }, - { - type: 'we-text', - props: { variant: 'heading-md', textAlign: 'center' }, - children: ['Module Marketplace'], - }, - { - type: 'we-text', - props: { variant: 'body', textAlign: 'center', maxWidth: '420px' }, - children: ['Browse and install templates, themes, blocks, and components shared by the WE community.'], - }, { type: 'we-button', $localState: { joining: { type: 'boolean', initial: false } }, @@ -56,7 +42,7 @@ const joinPrompt: SchemaNode = { children: ['Explore Marketplace'], }, ], -}; +}); export const marketplaceGate: SchemaNode = { type: '$if', diff --git a/packages/templates/shell/src/marketplace/routes/BlocksRoute.ts b/packages/templates/shell/src/marketplace/routes/BlocksRoute.ts index 49201ca48..aa70c1923 100644 --- a/packages/templates/shell/src/marketplace/routes/BlocksRoute.ts +++ b/packages/templates/shell/src/marketplace/routes/BlocksRoute.ts @@ -1,19 +1,13 @@ import type { SchemaNode } from '@we/schema-shared'; +import { gatePrompt } from '@we/template-kit'; -export const blocksRoute: SchemaNode = { - type: 'Column', - props: { flex: '1', ax: 'center', ay: 'center', gap: '300', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'cube', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['Blocks coming soon'], - }, - { - type: 'we-text', - props: { variant: 'label', textAlign: 'center', maxWidth: '360px' }, - children: ['Community block publishing will be available in a future update.'], - }, - ], -}; +// `fill: false` — this sits inside the marketplace's own tab flow, which already has a height. +export const blocksRoute: SchemaNode = gatePrompt({ + icon: 'cube', + iconColor: 'neutral-300', + title: 'Blocks coming soon', + body: 'Community block publishing will be available in a future update.', + fill: false, + gap: '300', + bodyWidth: '360px', +}); diff --git a/packages/templates/shell/src/marketplace/routes/ComponentsRoute.ts b/packages/templates/shell/src/marketplace/routes/ComponentsRoute.ts index c33e024a9..d4a8c8d3b 100644 --- a/packages/templates/shell/src/marketplace/routes/ComponentsRoute.ts +++ b/packages/templates/shell/src/marketplace/routes/ComponentsRoute.ts @@ -1,19 +1,13 @@ import type { SchemaNode } from '@we/schema-shared'; +import { gatePrompt } from '@we/template-kit'; -export const componentsRoute: SchemaNode = { - type: 'Column', - props: { flex: '1', ax: 'center', ay: 'center', gap: '300', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'puzzle-piece', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['Components coming soon'], - }, - { - type: 'we-text', - props: { variant: 'label', textAlign: 'center', maxWidth: '360px' }, - children: ['Community component publishing will be available in a future update.'], - }, - ], -}; +// `fill: false` — this sits inside the marketplace's own tab flow, which already has a height. +export const componentsRoute: SchemaNode = gatePrompt({ + icon: 'puzzle-piece', + iconColor: 'neutral-300', + title: 'Components coming soon', + body: 'Community component publishing will be available in a future update.', + fill: false, + gap: '300', + bodyWidth: '360px', +}); diff --git a/packages/templates/shell/src/marketplace/routes/TemplatesRoute.ts b/packages/templates/shell/src/marketplace/routes/TemplatesRoute.ts index bdc5d1429..31e48971c 100644 --- a/packages/templates/shell/src/marketplace/routes/TemplatesRoute.ts +++ b/packages/templates/shell/src/marketplace/routes/TemplatesRoute.ts @@ -1,118 +1,29 @@ import type { SchemaNode } from '@we/schema-shared'; +import { marketplaceList } from '@we/template-kit'; -const emptyState: SchemaNode = { - type: 'Column', - props: { flex: '1', ax: 'center', ay: 'center', gap: '300', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'layout', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['No templates available yet'], - }, - { - type: 'we-text', - props: { variant: 'label', textAlign: 'center' }, - children: ['Be the first to publish a template to the marketplace.'], +export const templatesRoute: SchemaNode = marketplaceList({ + entity: 'Template', + as: 'template', + label: 'templates', + emptyIcon: 'layout', + emptyBody: 'Be the first to publish a template to the marketplace.', + sortable: true, + include: { screenshots: true }, + card: { + mode: 'marketplace', + // Matched on slug *and* author: a marketplace is a shared space, so two agents can publish + // templates under the same slug and only one of them is the one you installed. + installed: { + $find: { + items: { $store: 'templateStore.myTemplates' }, + where: { id: '$template.slug', author: '$template.author' }, + select: 'templateVersion', + }, }, - ], -}; - -export const templatesRoute: SchemaNode = { - type: 'Column', - props: { flex: '1', p: '500', gap: '400', ax: 'center', minHeight: '100%' }, - $localState: { - search: { type: 'string', initial: '' }, - sort: { type: 'string', initial: 'desc' }, - }, - $queries: { - templates: { - entity: 'Template', - dataset: 'datasetStore.marketplaceDataset', - order: { createdAt: { $local: 'sort' } }, - include: { screenshots: true }, - subscribe: true, + onInstall: { $action: 'templateStore.installFromMarketplace', args: ['$template.id'] }, + onDelete: { $action: 'templateStore.deleteMarketplaceTemplate', args: ['$template.id'] }, + isLoading: { + $eq: [{ $store: 'templateStore.operationLoading' }, { $concat: ['marketplace-install:', '$template.id'] }], }, }, - children: [ - // Filters - { - type: 'Row', - props: { gap: '300', ay: 'center', maxWidth: '1200px', width: '100%' }, - children: [ - { - type: 'Search', - props: { - bg: 'neutral-0', - border: '1px solid neutral-300', - placeholder: 'Search templates…', - value: { $local: 'search' }, - onSearch: { $setLocal: 'search', from: '$arg' }, - width: '100%', - maxWidth: '300px', - }, - }, - { - type: 'we-select', - props: { - value: { $local: 'sort' }, - options: [ - { value: 'desc', label: 'Newest first' }, - { value: 'asc', label: 'Oldest first' }, - ], - onChange: { $setLocal: 'sort', from: '$event.detail' }, - }, - }, - ], - }, - - // Grid or empty state - { - type: '$if', - props: { - condition: { $count: { items: { $local: 'templates' } } }, - then: { - type: 'Column', - props: { flex: '1', gap: '0', maxWidth: '1200px', width: '100%' }, - children: [ - { - type: 'Grid', - props: { columns: 3, gap: '400', width: '100%' }, - children: [ - { - type: '$each', - props: { - items: { - $filter: { - items: { $local: 'templates' }, - where: { name: { contains: { $local: 'search' } } }, - }, - }, - as: 'template', - }, - children: [ - { - type: 'TemplateCard', - props: { - template: '$template', - mode: 'marketplace', - installed: { - $find: { - items: { $store: 'templateStore.myTemplates' }, - where: { id: '$template.slug', author: '$template.author' }, - select: 'templateVersion', - }, - }, - }, - }, - ], - }, - ], - }, - ], - }, - else: emptyState, - }, - }, - ], -}; +}); diff --git a/packages/templates/shell/src/marketplace/routes/ThemesRoute.ts b/packages/templates/shell/src/marketplace/routes/ThemesRoute.ts index 819b8f8b7..24ac679bd 100644 --- a/packages/templates/shell/src/marketplace/routes/ThemesRoute.ts +++ b/packages/templates/shell/src/marketplace/routes/ThemesRoute.ts @@ -1,123 +1,24 @@ import type { SchemaNode } from '@we/schema-shared'; +import { marketplaceList } from '@we/template-kit'; -const emptyState: SchemaNode = { - type: 'Column', - props: { flex: '1', ax: 'center', ay: 'center', gap: '300', p: '600' }, - children: [ - { type: 'we-icon', props: { name: 'paint-bucket', size: 'xl', color: 'neutral-300' } }, - { - type: 'we-text', - props: { textAlign: 'center' }, - children: ['No themes available yet'], - }, - { - type: 'we-text', - props: { variant: 'label', textAlign: 'center' }, - children: ['Be the first to publish a theme to the marketplace.'], - }, - ], -}; - -const themeCard: SchemaNode = { - type: 'TemplateCard', - props: { - template: '$theme', +export const themesRoute: SchemaNode = marketplaceList({ + entity: 'Theme', + as: 'theme', + label: 'themes', + emptyIcon: 'paint-bucket', + emptyBody: 'Be the first to publish a theme to the marketplace.', + sortable: true, + include: { screenshots: true }, + card: { mode: 'marketplace', installed: { - $find: { - items: { $store: 'themeStore.installedThemes' }, - where: { name: '$theme.name' }, - select: 'version', - }, + $find: { items: { $store: 'themeStore.installedThemes' }, where: { name: '$theme.name' }, select: 'version' }, }, onInstall: { $action: 'themeStore.installFromMarketplace', args: ['$theme.id'] }, onDelete: { $action: 'themeStore.deleteMarketplaceTheme', args: ['$theme.id'] }, + // Namespaced, so one row's spinner does not appear on every row. isLoading: { $eq: [{ $store: 'themeStore.operationLoading' }, { $concat: ['marketplace-install:', '$theme.id'] }], }, }, -}; - -export const themesRoute: SchemaNode = { - type: 'Column', - props: { flex: '1', p: '500', gap: '400', ax: 'center', minHeight: '100%' }, - $localState: { - search: { type: 'string', initial: '' }, - sort: { type: 'string', initial: 'desc' }, - }, - $queries: { - themes: { - entity: 'Theme', - dataset: 'datasetStore.marketplaceDataset', - order: { createdAt: { $local: 'sort' } }, - include: { screenshots: true }, - subscribe: true, - }, - }, - children: [ - // Filters - { - type: 'Row', - props: { gap: '300', ay: 'center', maxWidth: '1200px', width: '100%' }, - children: [ - { - type: 'Search', - props: { - bg: 'neutral-0', - border: '1px solid neutral-300', - placeholder: 'Search themes…', - value: { $local: 'search' }, - onSearch: { $setLocal: 'search', from: '$arg' }, - width: '100%', - maxWidth: '300px', - }, - }, - { - type: 'we-select', - props: { - value: { $local: 'sort' }, - options: [ - { value: 'desc', label: 'Newest first' }, - { value: 'asc', label: 'Oldest first' }, - ], - onChange: { $setLocal: 'sort', from: '$event.detail' }, - }, - }, - ], - }, - - // Grid or empty state - { - type: '$if', - props: { - condition: { $count: { items: { $local: 'themes' } } }, - then: { - type: 'Column', - props: { flex: '1', gap: '0', maxWidth: '1200px', width: '100%' }, - children: [ - { - type: 'Grid', - props: { columns: 3, gap: '400', width: '100%' }, - children: [ - { - type: '$each', - props: { - items: { - $filter: { - items: { $local: 'themes' }, - where: { name: { contains: { $local: 'search' } } }, - }, - }, - as: 'theme', - }, - children: [themeCard], - }, - ], - }, - ], - }, - else: emptyState, - }, - }, - ], -}; +}); diff --git a/packages/templates/shell/src/spaces/SpacesList.ts b/packages/templates/shell/src/spaces/SpacesList.ts index 99469c2d3..441baa78d 100644 --- a/packages/templates/shell/src/spaces/SpacesList.ts +++ b/packages/templates/shell/src/spaces/SpacesList.ts @@ -1,4 +1,5 @@ import type { SchemaNode } from '@we/schema-shared'; +import { emptyNote } from '@we/template-kit'; /** * One row per joined dataset, space or not — see `spaceStore.spaceList`. @@ -138,11 +139,7 @@ export const spacesListSection: SchemaNode = { }, ], }, - else: { - type: 'we-text', - props: { variant: 'footnote', color: 'neutral-400', italic: true }, - children: ['No spaces yet'], - }, + else: emptyNote('No spaces yet'), }, }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91b1f6502..87872cb62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1343,6 +1343,9 @@ importers: '@we/schema-shared': specifier: workspace:* version: link:../../schema-system/shared + '@we/template-kit': + specifier: workspace:* + version: link:../kit '@we/template-shell': specifier: workspace:* version: link:../shell @@ -1351,11 +1354,27 @@ importers: specifier: ^24.10.0 version: 24.13.2 + packages/templates/kit: + dependencies: + '@we/schema-shared': + specifier: workspace:* + version: link:../../schema-system/shared + devDependencies: + '@types/node': + specifier: ^24.10.0 + version: 24.13.2 + vitest: + specifier: ^4.0.15 + version: 4.1.10(@types/node@24.13.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.6(@types/node@24.13.2)(sass@1.101.0)(tsx@4.23.0)(yaml@2.9.0)) + packages/templates/shell: dependencies: '@we/schema-shared': specifier: workspace:* version: link:../../schema-system/shared + '@we/template-kit': + specifier: workspace:* + version: link:../kit devDependencies: '@types/node': specifier: ^24.10.0