From 94c8fe88f8e1aeaf17e568226214644bf5117446 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:09:51 +0100 Subject: [PATCH 01/10] feat(schema-shared): declare the feature-module contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rung above blocks: what a module contributes, and what a host must accept. Declared in the neutral package for the same reason dataSource.ts is — a module must be able to describe itself without importing a host, a framework, or a backend. **Framework code is optional, not assumed.** `components` is the only field that can hold framework-specific values. A module shipping schema fragments only imports nothing framework-shaped, because in a fragment `Column` is a registry key rather than an import — so the fragment renders on any framework whose renderer registers that key. That is not just tidy: an externally-loaded bundle carrying its own reactive runtime gets a *second* one, and reactivity silently stops crossing the boundary. Fragments-first is what makes dynamic loading tractable later. **Omitted `backends`/`frameworks` mean agnostic.** Making the portable case the default is what forces coupling to be opted into and declared. An entity-owning module must currently say `backends: ['ad4m']` — there is no manifest→SDNA compiler, so its models are decorated classes. That is the escape hatch working as intended: entity-owning modules stay unblocked while the coupling is visible at install. **Capabilities are declared, not enforced.** Nothing prevents a module calling getUserMedia without saying so. They exist to be *displayed* — the browser's model of showing the request and the origin, never a computed risk score, since a score derived from unenforced declarations manufactures false confidence. They are the hook enforcement attaches to if a permission broker is built. checkModuleCompatibility mirrors planQuery/planEphemeral: refuse loudly at registration rather than half-mounting something that cannot work, and report every problem at once so an install prompt can show them together. Co-Authored-By: Claude Opus 5 (1M context) --- packages/schema-system/shared/src/index.ts | 2 + .../schema-system/shared/src/module.test.ts | 73 +++++++++ packages/schema-system/shared/src/module.ts | 138 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 packages/schema-system/shared/src/module.test.ts create mode 100644 packages/schema-system/shared/src/module.ts diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index a01b2301..b024d961 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -127,6 +127,8 @@ export type { RendererDataBindings, RendererStores, } from './dataSource'; +export { checkModuleCompatibility, defineModule } from './module'; +export type { ModuleCapability, ModuleCompatibility, ModuleDefinition, SlotAnchor, SlotContribution } from './module'; export { createInMemoryEphemeralPort, InMemoryBus, inMemoryEphemeralCapabilities, planEphemeral } from './ephemeral'; export type { EphemeralCapabilities, diff --git a/packages/schema-system/shared/src/module.test.ts b/packages/schema-system/shared/src/module.test.ts new file mode 100644 index 00000000..b81c18f1 --- /dev/null +++ b/packages/schema-system/shared/src/module.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { checkModuleCompatibility, defineModule, type ModuleDefinition } from './module'; + +const host = { backend: 'ad4m', framework: 'solid' }; + +function mod(overrides: Partial = {}): ModuleDefinition { + return defineModule({ id: 'test', name: 'Test', ...overrides }); +} + +describe('checkModuleCompatibility', () => { + it('treats an omitted axis as agnostic, so the portable case is the default', () => { + // A module that declares neither backends nor frameworks runs anywhere. Making the portable case + // the default is what forces coupling to be opted into and declared. + expect(checkModuleCompatibility(mod(), host).compatible).toBe(true); + expect(checkModuleCompatibility(mod(), { backend: 'nextgraph', framework: 'react' }).compatible).toBe(true); + }); + + it('refuses a module that needs a backend this host is not', () => { + const plan = checkModuleCompatibility(mod({ backends: ['ad4m'] }), { ...host, backend: 'nextgraph' }); + expect(plan.compatible).toBe(false); + expect(plan.problems[0]).toContain('ad4m'); + expect(plan.problems[0]).toContain('nextgraph'); + }); + + it('admits an entity-owning module on the backend it declares', () => { + // The escape hatch working as intended: coupling is visible, not blocking. + expect(checkModuleCompatibility(mod({ backends: ['ad4m'] }), host).compatible).toBe(true); + }); + + it('refuses a module with no build for this framework', () => { + const plan = checkModuleCompatibility(mod({ frameworks: ['react'] }), host); + expect(plan.compatible).toBe(false); + expect(plan.problems[0]).toContain('react'); + }); + + it('accepts a module listing several options if the host is any of them', () => { + expect(checkModuleCompatibility(mod({ frameworks: ['react', 'solid'] }), host).compatible).toBe(true); + }); + + it('reports every problem at once, so the install prompt can show them together', () => { + const plan = checkModuleCompatibility(mod({ backends: ['nextgraph'], frameworks: ['vue'] }), host); + expect(plan.problems).toHaveLength(2); + }); +}); + +describe('defineModule', () => { + it('round-trips the definition unchanged', () => { + const definition = defineModule({ + id: 'notes', + name: 'Notes', + capabilities: ['storage', 'slot:dock-right'], + backends: ['ad4m'], + slots: [{ anchor: 'dock-right', node: { type: 'Column' }, order: 100 }], + }); + expect(definition.id).toBe('notes'); + expect(definition.slots?.[0].anchor).toBe('dock-right'); + }); + + it('allows a fragments-only module to declare no framework at all', () => { + // The case that matters for dynamic loading: no `components`, no `frameworks`, so nothing + // framework-shaped is imported and there is no second-runtime hazard. + const definition = defineModule({ + id: 'banner', + name: 'Banner', + schemas: { bar: { type: 'Column', children: ['hi'] } }, + slots: [{ anchor: 'banner', node: { type: 'Column' } }], + }); + expect(definition.frameworks).toBeUndefined(); + expect(definition.components).toBeUndefined(); + expect(checkModuleCompatibility(definition, { backend: 'anything', framework: 'anything' }).compatible).toBe(true); + }); +}); diff --git a/packages/schema-system/shared/src/module.ts b/packages/schema-system/shared/src/module.ts new file mode 100644 index 00000000..3cdf734d --- /dev/null +++ b/packages/schema-system/shared/src/module.ts @@ -0,0 +1,138 @@ +/** + * The feature-module contract — what a module contributes, and what a host must accept. + * + * A feature module is the rung above blocks: a bundle of **stateful capability** that installs into a + * space and can be placed by a template. Templates and themes are data; elements, components and + * widgets are stateless presentation; a feature module is the thing that holds state and talks to + * ports. See notes/we/August-2026/feature-modules.md. + * + * Declared here, in the neutral package, for the same reason `dataSource.ts` is: a module must be able + * to describe itself without importing a host, a framework, or a backend. + * + * ## Why framework code is optional, not assumed + * + * `components` is the only field that can carry framework-specific values, and it is optional. A + * module that ships **schema fragments only** imports nothing framework-shaped — and in a fragment + * `Column` is a registry key, not an import, so the fragment renders on any framework whose renderer + * registers that key. + * + * That is not merely tidy. An externally-loaded module bundle that imports its own copy of a reactive + * framework gets a *second runtime*, and reactivity silently stops crossing the boundary — no error, + * just components that never update. A module with no framework imports cannot have that problem. + * Fragments-first is what makes dynamic loading tractable later. + */ +import type { SchemaNode } from './types'; + +/** + * Where persistent chrome attaches. A small fixed set on purpose: too few and modules fight for + * position, too many and it becomes a layout system. + */ +export type SlotAnchor = 'overlay' | 'dock-left' | 'dock-right' | 'dock-bottom' | 'banner'; + +export interface SlotContribution { + anchor: SlotAnchor; + /** The chrome itself. A `SchemaNode` rather than a component so it stays inspectable and themeable, + * and so a deployment can white-label it. */ + node: SchemaNode; + /** + * Position within the anchor. Ties break deterministically on module id — without that, registration + * order leaks into layout and chrome reshuffles for no visible reason. + */ + order?: number; +} + +/** + * What a module asks to be allowed to do. + * + * **Declared, not enforced** — nothing today prevents a module calling `getUserMedia` without saying + * so. They exist to be *displayed* at install ("this module can: use your microphone"), which is the + * browser's model: show the request and the origin, never a computed risk score. A score derived from + * unenforced declarations would manufacture false confidence, which is worse than no score. + * + * The hook that enforcement attaches to later, if a permission broker is ever built. + */ +export type ModuleCapability = + 'microphone' | 'camera' | 'screen-share' | 'notifications' | 'storage' | `network:${string}` | `slot:${SlotAnchor}`; + +export interface ModuleDefinition { + /** Stable, unique. Namespaces this module's stores (`modules..*`) and its slot ordering ties. */ + id: string; + name: string; + description?: string; + icon?: string; + version?: string; + + /** Capabilities to display at install. See {@link ModuleCapability}. */ + capabilities?: ModuleCapability[]; + + /** + * Backends this module works on. **Omit to mean backend-agnostic** — the default is the portable + * case, so coupling is something you opt into and declare rather than something that happens + * quietly. + * + * A module owning durable entities must currently declare `['ad4m']`, because there is no + * manifest→SDNA compiler yet and its models are AD4M-decorated classes. That is the escape hatch + * working as intended, not a defeat: it keeps entity-owning modules unblocked while making the + * coupling visible at install. + */ + backends?: string[]; + + /** + * Frameworks this module provides components for. **Omit to mean framework-agnostic** — true of any + * module that ships fragments only. + */ + frameworks?: string[]; + + /** + * Framework components to register, by the name templates and fragments reference them under. + * Only for imperative cores that genuinely need framework code (a Cesium `Viewer`, an + * `RTCPeerConnection`, an editor). Chrome, buttons and panels should be fragments. + */ + components?: Record; + + /** Named schema fragments a template can place, and this module's own slot nodes can reference. */ + schemas?: Record; + + /** Persistent chrome. Rendered by the host outside the router, so it survives navigation. */ + slots?: SlotContribution[]; + + /** + * Reactive state, exposed to templates at `modules..`. + * + * A factory rather than a value so the host controls lifetime, and so a module can be registered + * before the host is ready to instantiate it. + */ + createStore?: () => Record; +} + +/** Identity function that exists for inference and for a greppable declaration site. */ +export function defineModule(definition: ModuleDefinition): ModuleDefinition { + return definition; +} + +export interface ModuleCompatibility { + compatible: boolean; + /** Human-readable reasons this module cannot run here, for the install prompt. */ + problems: string[]; +} + +/** + * Check a module against what this host actually is. Mirrors `planQuery` / `planEphemeral`: refuse + * loudly at registration rather than half-mounting something that cannot work. + */ +export function checkModuleCompatibility( + definition: ModuleDefinition, + host: { backend: string; framework: string }, +): ModuleCompatibility { + const problems: string[] = []; + + // Omitted means agnostic — the portable case is the default. + if (definition.backends?.length && !definition.backends.includes(host.backend)) { + problems.push(`needs backend ${definition.backends.join(' or ')}, but this host runs ${host.backend}`); + } + if (definition.frameworks?.length && !definition.frameworks.includes(host.framework)) { + problems.push(`needs framework ${definition.frameworks.join(' or ')}, but this host runs ${host.framework}`); + } + + return { compatible: problems.length === 0, problems }; +} From cece67e28004332a8815f8e59cc9d4b9278ca987 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:14:32 +0100 Subject: [PATCH 02/10] feat(app-framework): open the shell to module chrome, and add a module registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalises `shellRegistry` — three named keys typed `typeof shellRegistry` — into an open `slotRegistry`, because a module must be able to *add* chrome rather than override one of three. A call bar, a notifications tray, a mini-player and an offline banner all want the same thing. **Anchors order and group; they do not position.** This is what makes it a faithful generalisation rather than a rewrite. WE's shell nodes position themselves — bootScreen is a full-bleed $if, sidebar is a CollapsibleSidebar carrying its own position prop — so wrapping each anchor in a container would change how all three render. An anchor is semantic metadata that fixes order in the flat output; the node still positions itself. `overlay` sorts first specifically to reproduce today's [bootScreen, sidebar, templateEditor], and a test asserts exactly that. Ordering falls back to declared `order`, then to **id**. Entries come out of a Map, so without the tiebreak equal-order chrome would rearrange depending on which module happened to load first — the same reason presence sorts on agentId. The seed's white-label override moves from assigning `shellRegistry.bootScreen` to `slotRegistry.replace('core:bootScreen', node)`. Deployment-level configuration of app-level chrome: the layer whose scope matches, unlike a per-space template, which has no channel into the shell and shouldn't. `moduleRegistry` is the fourth registry beside appRegistry, modelRegistry, templateRegistry and themeRegistry, and deliberately the same shape — modules become the next thing following an existing pattern rather than a new concept. Registering fans a definition out to the registries that already exist and holds the module's store under `modules.`. It refuses an incompatible module loudly rather than half-mounting it, and applies nothing partially. `modules: {}` is always in the stores bag even with no modules registered. $store's single-segment path indexes the store object without a guard, so `{ $store: 'modules.notes' }` would throw on a missing key rather than returning undefined — and returning undefined is precisely what makes `$if` on `modules.` the supported way for a template to depend on an optional module. Co-Authored-By: Claude Opus 5 (1M context) --- .../solid/providers/TemplateProvider.tsx | 14 +- .../src/shared/initializeIntegrations.ts | 7 +- .../src/shared/registries/moduleRegistry.ts | 130 ++++++++++++++ .../src/shared/registries/shellRegistry.ts | 25 --- .../src/shared/registries/slotRegistry.ts | 105 +++++++++++ .../shared/schemas/shell/Sidebar.schema.ts | 3 +- .../schemas/shell/TemplateEditor.schema.ts | 2 +- .../tests/moduleRegistry.test.ts | 166 ++++++++++++++++++ 8 files changed, 418 insertions(+), 34 deletions(-) create mode 100644 packages/app-framework/src/shared/registries/moduleRegistry.ts delete mode 100644 packages/app-framework/src/shared/registries/shellRegistry.ts create mode 100644 packages/app-framework/src/shared/registries/slotRegistry.ts create mode 100644 packages/app-framework/tests/moduleRegistry.test.ts diff --git a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx index b0726597..38044052 100644 --- a/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx +++ b/packages/app-framework/src/frameworks/solid/providers/TemplateProvider.tsx @@ -2,7 +2,8 @@ import type { PerspectiveProxy } from '@coasys/ad4m'; import { createAd4mDataBindings } from '@shared/ad4mAdapter'; import { queryIRFlag } from '@shared/queryIRFlag'; import { getModel } from '@shared/registries/modelRegistry'; -import { shellRegistry } from '@shared/registries/shellRegistry'; +import { moduleStores } from '@shared/registries/moduleRegistry'; +import { slotRegistry } from '@shared/registries/slotRegistry'; import { componentRegistry as registry } from '@solid/registries/componentRegistry'; import { useAdamStore, @@ -79,6 +80,10 @@ export default function TemplateProvider() { templateStore, routeStore, presenceStore, + // Always present, even with no modules registered: `{ $store: 'modules.x' }` resolves through the + // single-segment path, which indexes the store object without a guard and would throw on a + // missing `modules` key rather than returning undefined. + modules: moduleStores, consoleStore, model: modelStore, // Host wiring, not backend adaptation — any backend would wire these the same way, so they stay @@ -111,11 +116,12 @@ export default function TemplateProvider() { return [getModel(modelName), resolvePerspective(opts?.perspective) ?? adamStore.currentPerspective()!] as const; } - // Shell chrome — boot screen + sidebar + template editor. - // Rendered once outside the keyed Router so it never remounts on template switches. + // Shell chrome — host slots plus anything feature modules contribute. + // Rendered once outside the keyed Router so it never remounts on template switches; that isolation + // is why a template has no channel into the shell. const shellSchema: TemplateSchema = { meta: { name: 'Shell', description: 'App shell chrome', icon: '' }, - children: [shellRegistry.bootScreen, shellRegistry.sidebar, shellRegistry.templateEditor], + children: slotRegistry.nodes(), }; const notFoundNode = { diff --git a/packages/app-framework/src/shared/initializeIntegrations.ts b/packages/app-framework/src/shared/initializeIntegrations.ts index 0ff1bc7c..dc200d5c 100644 --- a/packages/app-framework/src/shared/initializeIntegrations.ts +++ b/packages/app-framework/src/shared/initializeIntegrations.ts @@ -11,7 +11,7 @@ import type { WeSeedFile } from '../types/seed'; import { generateIframePermissions, validateSeedForLauncher } from './integrationComposer'; import type { PlatformAdapter } from './platform/types'; import { appRegistry } from './registries/appRegistry'; -import { shellRegistry } from './registries/shellRegistry'; +import { slotRegistry } from './registries/slotRegistry'; export function initializeIntegrations(platformAdapter: PlatformAdapter): void { try { @@ -23,9 +23,10 @@ export function initializeIntegrations(platformAdapter: PlatformAdapter): void { return; } - // Apply optional white-label boot screen override + // Apply optional white-label boot screen override. Deployment-level configuration of shell + // chrome — the layer whose scope matches, unlike a per-space template. if (seed.host?.ui?.bootScreen) { - shellRegistry.bootScreen = seed.host.ui.bootScreen; + slotRegistry.replace('core:bootScreen', seed.host.ui.bootScreen); } // Resolve each app URL once and register it diff --git a/packages/app-framework/src/shared/registries/moduleRegistry.ts b/packages/app-framework/src/shared/registries/moduleRegistry.ts new file mode 100644 index 00000000..fb040a4d --- /dev/null +++ b/packages/app-framework/src/shared/registries/moduleRegistry.ts @@ -0,0 +1,130 @@ +/** + * Module Registry — installed feature modules and what they contribute. + * + * The fourth registry alongside `appRegistry`, `modelRegistry`, `templateRegistry` and `themeRegistry`, + * and deliberately the same shape: modules become the next thing that follows an existing pattern + * rather than a new concept. Runtime only — the durable half (`AgentSettings.installedModules`, + * `Space.enabledModules`) arrives with the marketplace, when modules become installable rather than + * bundled. + * + * ## What registering does + * + * Fans a {@link ModuleDefinition} out to the registries that already exist — component registry, slot + * registry — and holds the module's store under `modules.` for the template bag. + * + * ## The `modules` namespace must always exist + * + * Subtle and easy to get wrong. `$store` resolution splits on `.`, and a **single-segment** path does + * `stores[storeName][prop]` with no guard — so `{ $store: 'modules.notes' }` throws if the `modules` + * key is absent, rather than returning undefined. Deeper paths go through `walkPath`, which does + * degrade safely. + * + * So: the namespace object is always present, and an individual module's key is absent until it + * registers. That is exactly what makes `{ $if: { condition: { $store: 'modules.notes' } } }` the + * supported way for a template to depend on an optional module. + */ +import type { ModuleDefinition } from '@we/schema-shared'; +import { checkModuleCompatibility } from '@we/schema-shared'; + +import { slotRegistry } from './slotRegistry'; + +export interface RegisteredModule { + definition: ModuleDefinition; + /** Instantiated lazily on registration, so a module can be declared before the host is ready. */ + store?: Record; +} + +const modules = new Map(); + +/** + * The `modules..*` namespace handed to the renderer's stores bag. + * + * A single stable object mutated in place rather than rebuilt, so the reference in the bag stays + * valid as modules register. + */ +export const moduleStores: Record = {}; + +export interface RegisterResult { + registered: boolean; + /** Why not, if it was refused — suitable for an install prompt or a console warning. */ + problems: string[]; +} + +export const moduleRegistry = { + /** + * Register a module against this host. + * + * Refuses loudly rather than half-mounting, mirroring `planQuery` / `planEphemeral`: a module whose + * declared backend or framework doesn't match would otherwise register components that fail at + * render time, far from the cause. + */ + register(definition: ModuleDefinition, host: { backend: string; framework: string }): RegisterResult { + const compatibility = checkModuleCompatibility(definition, host); + if (!compatibility.compatible) { + console.warn(`module "${definition.id}" not registered: ${compatibility.problems.join('; ')}`); + return { registered: false, problems: compatibility.problems }; + } + + if (modules.has(definition.id)) { + // Idempotent: re-registering the same id replaces rather than duplicating, so a hot reload or a + // double-init doesn't produce two of everything. + moduleRegistry.unregister(definition.id); + } + + const store = definition.createStore?.(); + modules.set(definition.id, { definition, store }); + if (store) moduleStores[definition.id] = store; + + for (const [index, slot] of (definition.slots ?? []).entries()) { + slotRegistry.register({ + ...slot, + // Namespaced, and indexed so one module can contribute more than one piece of chrome. + id: `${definition.id}:${index}`, + }); + } + + return { registered: true, problems: [] }; + }, + + unregister(id: string): void { + const entry = modules.get(id); + if (!entry) return; + for (const index of (entry.definition.slots ?? []).keys()) slotRegistry.remove(`${id}:${index}`); + delete moduleStores[id]; + modules.delete(id); + }, + + get(id: string): RegisteredModule | undefined { + return modules.get(id); + }, + + has(id: string): boolean { + return modules.has(id); + }, + + all(): RegisteredModule[] { + return [...modules.values()]; + }, + + /** + * Components every registered module contributes, for the host's component registry. + * + * Most modules should contribute none: in a schema fragment `Column` is a registry key rather than + * an import, so fragments are framework-agnostic. Framework components are for imperative cores + * only. + */ + components(): Record { + return Object.assign({}, ...moduleRegistry.all().map((m) => m.definition.components ?? {})); + }, + + /** Named schema fragments, keyed `.` so two modules can't collide. */ + schemas(): Record { + const out: Record = {}; + for (const { definition } of moduleRegistry.all()) { + for (const [name, node] of Object.entries(definition.schemas ?? {})) { + out[`${definition.id}.${name}`] = node; + } + } + return out; + }, +}; diff --git a/packages/app-framework/src/shared/registries/shellRegistry.ts b/packages/app-framework/src/shared/registries/shellRegistry.ts deleted file mode 100644 index 354533e2..00000000 --- a/packages/app-framework/src/shared/registries/shellRegistry.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Shell Registry - * - * Named slots for the persistent shell chrome. Each entry is independently - * overridable — seeds can swap out individual slots (e.g. white-label the - * boot screen, or strip the template editor entirely by setting it to null). - */ - -import { bootScreen } from '@shared/schemas/shell/BootScreen.schema'; -import { sidebar } from '@shared/schemas/shell/Sidebar.schema'; -import { templateEditor } from '@shared/schemas/shell/TemplateEditor.schema'; -import type { SchemaNode } from '@we/schema-shared'; - -export const shellRegistry = { - /** Full-screen boot/login overlay, shown before AD4M is ready */ - bootScreen: bootScreen as SchemaNode, - - /** Persistent left sidebar — always mounted alongside templates */ - sidebar: sidebar as SchemaNode, - - /** Template editing chrome — right panel + toolbar. Override with null to disable. */ - templateEditor: templateEditor as SchemaNode, -}; - -export type ShellRegistry = typeof shellRegistry; diff --git a/packages/app-framework/src/shared/registries/slotRegistry.ts b/packages/app-framework/src/shared/registries/slotRegistry.ts new file mode 100644 index 00000000..9f38453e --- /dev/null +++ b/packages/app-framework/src/shared/registries/slotRegistry.ts @@ -0,0 +1,105 @@ +/** + * Slot Registry — persistent shell chrome, from the host and from feature modules. + * + * Generalises the former fixed `shellRegistry` (three named keys, typed `typeof shellRegistry`) into + * an open collection, because a module has to be able to **add** chrome rather than override one of + * three. A call bar, a notifications tray, a mini-player and an offline banner all want the same + * thing. + * + * ## Anchors order and group; they do not position + * + * Deliberate, and it is what makes this a faithful generalisation rather than a rewrite. WE's existing + * shell nodes **position themselves** — `bootScreen` is a full-bleed `$if`, `sidebar` is a + * `CollapsibleSidebar` carrying its own `position` prop. Wrapping each anchor in a positioned + * container would change how all three render. + * + * So an anchor is semantic metadata: it groups contributions and fixes their order in the flat output. + * The node still positions itself, exactly as before. A future anchor could emit a container, but that + * would be a behaviour change and needs to be made deliberately, not smuggled in here. + * + * ## Templates cannot reach this + * + * Shell chrome is app-lifetime and cross-space; a template is per-space. "This template hides the call + * bar" is incoherent the moment you navigate — you are in a call hosted in space B while viewing space + * A. `TemplateProvider` renders the shell outside the keyed Router precisely so it survives template + * switches. Configuration belongs to the seed (deployment), an agent preference (per-user), and the + * module's own state — chrome that is only present when relevant needs no hiding mechanism at all. + */ +import type { SchemaNode, SlotAnchor, SlotContribution } from '@we/schema-shared'; + +import { bootScreen } from '../schemas/shell/BootScreen.schema'; +import { sidebar } from '../schemas/shell/Sidebar.schema'; +import { templateEditor } from '../schemas/shell/TemplateEditor.schema'; + +export interface SlotEntry extends SlotContribution { + /** Unique. `core:*` for host chrome, otherwise the contributing module's id. */ + id: string; +} + +/** + * Anchor precedence in the flat output. + * + * `overlay` first is not a design statement — it is what preserves today's + * `[bootScreen, sidebar, templateEditor]` ordering exactly. Changing it is a visual change. + */ +const ANCHOR_ORDER: SlotAnchor[] = ['overlay', 'dock-left', 'dock-right', 'dock-bottom', 'banner']; + +const entries = new Map(); + +export const slotRegistry = { + /** Add a contribution. Replaces any entry with the same id, so re-registration is idempotent. */ + register(entry: SlotEntry): void { + entries.set(entry.id, entry); + }, + + /** + * Swap the node of an existing entry, keeping its anchor and order. How a seed white-labels host + * chrome — the mechanism `initializeIntegrations` already used for `bootScreen`. + */ + replace(id: string, node: SchemaNode): void { + const existing = entries.get(id); + if (existing) entries.set(id, { ...existing, node }); + }, + + /** Remove a contribution — a module being disabled. */ + remove(id: string): void { + entries.delete(id); + }, + + get(id: string): SlotEntry | undefined { + return entries.get(id); + }, + + /** + * Every contribution, in render order: by anchor, then by declared `order`, then by **id**. + * + * The id tiebreak is not cosmetic. Entries come out of a `Map`, so equal-order contributions would + * otherwise follow registration order — and chrome would silently rearrange depending on which + * module happened to load first. + */ + ordered(): SlotEntry[] { + return [...entries.values()].sort( + (a, b) => + ANCHOR_ORDER.indexOf(a.anchor) - ANCHOR_ORDER.indexOf(b.anchor) || + (a.order ?? 0) - (b.order ?? 0) || + a.id.localeCompare(b.id), + ); + }, + + /** Just the nodes, ready to compose into the shell schema. */ + nodes(): SchemaNode[] { + return slotRegistry.ordered().map((entry) => entry.node); + }, +}; + +/** + * Host chrome, registered as ordinary entries so there is exactly one mechanism rather than a special + * case beside a general one. The order values preserve the previous hardcoded sequence. + */ +export function registerCoreSlots(): void { + slotRegistry.register({ id: 'core:bootScreen', anchor: 'overlay', node: bootScreen, order: 0 }); + slotRegistry.register({ id: 'core:sidebar', anchor: 'dock-left', node: sidebar, order: 0 }); + slotRegistry.register({ id: 'core:templateEditor', anchor: 'dock-right', node: templateEditor, order: 0 }); +} + +registerCoreSlots(); diff --git a/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts b/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts index 9a39d8c7..0bde507b 100644 --- a/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts +++ b/packages/app-framework/src/shared/schemas/shell/Sidebar.schema.ts @@ -6,7 +6,8 @@ import type { SchemaNode } from '@we/schema-shared'; * Persistent app chrome sidebar that wraps around the active template. * Provides: template/theme switching, current space info, installed apps, logout. * - * Rendered by shellRegistry alongside the boot screen and active template. + * Registered in slotRegistry as `core:sidebar` (anchor: dock-left), alongside the boot screen and + * template editor. * Only visible when the user is logged in (boot state === 'ready'). */ export const sidebar: SchemaNode = { diff --git a/packages/app-framework/src/shared/schemas/shell/TemplateEditor.schema.ts b/packages/app-framework/src/shared/schemas/shell/TemplateEditor.schema.ts index 42eeca11..ca85ad7e 100644 --- a/packages/app-framework/src/shared/schemas/shell/TemplateEditor.schema.ts +++ b/packages/app-framework/src/shared/schemas/shell/TemplateEditor.schema.ts @@ -7,7 +7,7 @@ import type { SchemaNode } from '@we/schema-shared'; * and the toolbar for switching templates and opening the editor. * * Grouped as a single registry slot so white-labelers can remove or replace - * the entire editing UI by overriding this entry in shellRegistry. + * the entire editing UI via slotRegistry.replace('core:templateEditor', node). * * Only mounted when the user is logged in (boot state === 'ready'). * The toolbar additionally hides when an external app or shell overlay is active. diff --git a/packages/app-framework/tests/moduleRegistry.test.ts b/packages/app-framework/tests/moduleRegistry.test.ts new file mode 100644 index 00000000..b96a2616 --- /dev/null +++ b/packages/app-framework/tests/moduleRegistry.test.ts @@ -0,0 +1,166 @@ +/** + * The slot and module registries. + * + * The load-bearing assertion is the first one: generalising `shellRegistry` into an open collection + * must leave the existing three shell entries rendering in exactly the same order. Everything else in + * this PR builds on that generalisation, so if it is not faithful, nothing downstream is trustworthy. + */ +import type { ModuleDefinition } from '@we/schema-shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { moduleRegistry, moduleStores } from '../src/shared/registries/moduleRegistry'; +import { registerCoreSlots, slotRegistry } from '../src/shared/registries/slotRegistry'; + +const host = { backend: 'ad4m', framework: 'solid' }; + +function reset() { + for (const entry of slotRegistry.ordered()) slotRegistry.remove(entry.id); + for (const { definition } of moduleRegistry.all()) moduleRegistry.unregister(definition.id); + registerCoreSlots(); +} + +function mod(overrides: Partial = {}): ModuleDefinition { + return { id: 'test', name: 'Test', ...overrides }; +} + +beforeEach(reset); + +describe('slotRegistry — faithful generalisation of shellRegistry', () => { + it('renders the three host slots in the order the old hardcoded array produced', () => { + // Was: [shellRegistry.bootScreen, shellRegistry.sidebar, shellRegistry.templateEditor] + expect(slotRegistry.ordered().map((e) => e.id)).toEqual(['core:bootScreen', 'core:sidebar', 'core:templateEditor']); + }); + + it('keeps host chrome first when a module contributes to a later anchor', () => { + slotRegistry.register({ id: 'call', anchor: 'dock-bottom', node: { type: 'Column' } }); + expect(slotRegistry.ordered().map((e) => e.id)).toEqual([ + 'core:bootScreen', + 'core:sidebar', + 'core:templateEditor', + 'call', + ]); + }); + + it('lets a seed white-label host chrome without disturbing its position', () => { + slotRegistry.replace('core:bootScreen', { type: 'we-text', children: ['custom'] }); + const entries = slotRegistry.ordered(); + expect(entries[0].id).toBe('core:bootScreen'); + expect(entries[0].node).toEqual({ type: 'we-text', children: ['custom'] }); + }); + + it('ignores a replace for an id that was never registered', () => { + slotRegistry.replace('nope', { type: 'Column' }); + expect(slotRegistry.get('nope')).toBeUndefined(); + }); + + it('orders by declared order within an anchor', () => { + slotRegistry.register({ id: 'b', anchor: 'dock-bottom', node: { type: 'Column' }, order: 200 }); + slotRegistry.register({ id: 'a', anchor: 'dock-bottom', node: { type: 'Column' }, order: 100 }); + const bottom = slotRegistry.ordered().filter((e) => e.anchor === 'dock-bottom'); + expect(bottom.map((e) => e.id)).toEqual(['a', 'b']); + }); + + it('breaks ties on id, so load order cannot leak into layout', () => { + // Entries come out of a Map; without the tiebreak, equal-order chrome would rearrange depending + // on which module happened to register first. + slotRegistry.register({ id: 'zebra', anchor: 'banner', node: { type: 'Column' } }); + slotRegistry.register({ id: 'apple', anchor: 'banner', node: { type: 'Column' } }); + const banner = slotRegistry.ordered().filter((e) => e.anchor === 'banner'); + expect(banner.map((e) => e.id)).toEqual(['apple', 'zebra']); + }); + + it('is idempotent on re-registration', () => { + slotRegistry.register({ id: 'x', anchor: 'banner', node: { type: 'Column' } }); + slotRegistry.register({ id: 'x', anchor: 'banner', node: { type: 'Row' } }); + expect(slotRegistry.ordered().filter((e) => e.id === 'x')).toHaveLength(1); + expect(slotRegistry.get('x')?.node).toEqual({ type: 'Row' }); + }); +}); + +describe('moduleRegistry', () => { + it('fans contributions out to the registries that already exist', () => { + moduleRegistry.register( + mod({ + id: 'notes', + slots: [{ anchor: 'dock-right', node: { type: 'Column' } }], + createStore: () => ({ open: true }), + }), + host, + ); + + expect(moduleStores.notes).toEqual({ open: true }); + expect(slotRegistry.get('notes:0')?.anchor).toBe('dock-right'); + }); + + it('leaves the module key absent until it registers, so $if on modules. works', () => { + // The whole point of the namespace convention: a template can depend on an optional module + // because the key is missing, not present-but-inert. + expect(moduleStores.notes).toBeUndefined(); + moduleRegistry.register(mod({ id: 'notes', createStore: () => ({}) }), host); + expect(moduleStores.notes).toBeDefined(); + moduleRegistry.unregister('notes'); + expect(moduleStores.notes).toBeUndefined(); + }); + + it('removes every contribution on unregister', () => { + moduleRegistry.register( + mod({ + id: 'multi', + slots: [ + { anchor: 'dock-bottom', node: { type: 'Column' } }, + { anchor: 'banner', node: { type: 'Row' } }, + ], + }), + host, + ); + expect(slotRegistry.ordered().filter((e) => e.id.startsWith('multi:'))).toHaveLength(2); + + moduleRegistry.unregister('multi'); + expect(slotRegistry.ordered().filter((e) => e.id.startsWith('multi:'))).toHaveLength(0); + }); + + it('replaces rather than duplicates when the same id registers twice', () => { + const definition = mod({ id: 'dupe', slots: [{ anchor: 'banner', node: { type: 'Column' } }] }); + moduleRegistry.register(definition, host); + moduleRegistry.register(definition, host); + + expect(moduleRegistry.all()).toHaveLength(1); + expect(slotRegistry.ordered().filter((e) => e.id.startsWith('dupe:'))).toHaveLength(1); + }); + + it('refuses an incompatible module loudly instead of half-mounting it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = moduleRegistry.register( + mod({ id: 'ng-only', backends: ['nextgraph'], slots: [{ anchor: 'banner', node: { type: 'Column' } }] }), + host, + ); + + expect(result.registered).toBe(false); + expect(result.problems[0]).toContain('nextgraph'); + // Nothing partially applied — no store, no chrome. + expect(moduleRegistry.has('ng-only')).toBe(false); + expect(slotRegistry.ordered().some((e) => e.id.startsWith('ng-only'))).toBe(false); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('namespaces schema fragments so two modules cannot collide', () => { + moduleRegistry.register(mod({ id: 'a', schemas: { panel: { type: 'Column' } } }), host); + moduleRegistry.register(mod({ id: 'b', schemas: { panel: { type: 'Row' } } }), host); + + expect(moduleRegistry.schemas()).toEqual({ + 'a.panel': { type: 'Column' }, + 'b.panel': { type: 'Row' }, + }); + }); + + it('registers a fragments-only module with no store and no components', () => { + const result = moduleRegistry.register( + mod({ id: 'banner', slots: [{ anchor: 'banner', node: { type: 'Column' } }] }), + host, + ); + expect(result.registered).toBe(true); + expect(moduleStores.banner).toBeUndefined(); + expect(moduleRegistry.components()).toEqual({}); + }); +}); From f8868c5fc03d93e1bc6e25a99857dff271310786 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:19:57 +0100 Subject: [PATCH 03/10] feat(modules): convert Cesium into the first feature module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/modules/globe/` — the sufficiency proof for the contribution points, and deliberately a conversion rather than a new feature. The globe already worked, so its behaviour is a fixed reference: if the module system can carry something built before the module system existed, the seams are real. Writing a new feature to test new seams means a failure can't be attributed to either. **Only the wiring moved.** CesiumGlobe stays in @we/widgets — it passes the props-only test, since layerFactoryRegistry is injected and its LayerStore is a private Map rather than a WE store. The layers stay in @we/cesium-layers. What moved is the layer set and the component wrapper, previously scattered through componentRegistry.tsx, which no longer imports @we/cesium-layers at all. **The module owns no store**, and that is the honest outcome rather than an omission: layer visibility is `$local` state in the route schema, so inventing a store would be new behaviour and would break the "identical afterwards" property the conversion exists to demonstrate. Worth having as the first example so nobody assumes stores are mandatory. createGlobeModule takes the widget as an argument rather than importing it, so the package never pulls Solid or @we/widgets into its own bundle — the host passes in the component it already has. That preserves the single-instance guarantee that starts to matter once modules load dynamically. It exercises defineModule, the registry, a **module-private sub-registry** (the layer factories — a shape the call module will never test), a framework component contributed for a genuinely imperative core, and compatibility declaration. It does not exercise shell slots (a route is not chrome; the host-slot migration covers those) or the ephemeral port. Rendering needs WebGL and a Cesium Ion token, so visual verification is manual. What is asserted is the part that actually moved: the layer set resolves identically from its new owner, every entry is a callable factory, and the module declares itself honestly — backend-agnostic because it owns no entities, solid-only because its imperative core really is a framework component. Adds packages/modules/* to the workspace globs. Co-Authored-By: Claude Opus 5 (1M context) --- packages/app-framework/package.json | 4 +- .../solid/registries/componentRegistry.tsx | 29 ++---- .../app-framework/tests/globeModule.test.ts | 81 +++++++++++++++++ packages/modules/globe/package.json | 36 ++++++++ packages/modules/globe/src/index.ts | 88 +++++++++++++++++++ packages/modules/globe/tsconfig.json | 8 ++ packages/modules/globe/tsup.config.ts | 14 +++ pnpm-lock.yaml | 28 +++++- pnpm-workspace.yaml | 2 + 9 files changed, 265 insertions(+), 25 deletions(-) create mode 100644 packages/app-framework/tests/globeModule.test.ts create mode 100644 packages/modules/globe/package.json create mode 100644 packages/modules/globe/src/index.ts create mode 100644 packages/modules/globe/tsconfig.json create mode 100644 packages/modules/globe/tsup.config.ts diff --git a/packages/app-framework/package.json b/packages/app-framework/package.json index e07bd96d..b29ccb26 100644 --- a/packages/app-framework/package.json +++ b/packages/app-framework/package.json @@ -35,7 +35,6 @@ }, "dependencies": { "@we/ai-context": "workspace:*", - "@we/cesium-layers": "workspace:*", "@we/components": "workspace:*", "@we/design-types": "workspace:*", "@we/design-utils": "workspace:*", @@ -53,7 +52,8 @@ "gsap": "^3.13.0", "solid-js": "^1.9.5", "three": "^0.176.0", - "zod": "^4.1.11" + "zod": "^4.1.11", + "@we/module-globe": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx index 20aa4fc9..51f2610a 100644 --- a/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx +++ b/packages/app-framework/src/frameworks/solid/registries/componentRegistry.tsx @@ -14,15 +14,6 @@ import { TaskDisplay, VideoDisplay, } from '@we/block-solid'; -import { - countryOutlinesLayer, - h3HexagonsLayer, - type LayerFactory, - pointLocationsLayer, - proceduralStarsLayer, - skyboxLayer, - solarSystemLayer, -} from '@we/cesium-layers'; import { Accordion, AvatarStack, @@ -50,6 +41,7 @@ import { Timeline, ToastContainer, } from '@we/components/solid'; +import { layerFactoryRegistry } from '@we/module-globe'; import type { ComponentRegistry } from '@we/schema-solid'; import { CesiumGlobe, CollapsibleSidebar, GraphWidget, mockGraphData, SpaceSidebarWidget } from '@we/widgets/solid'; @@ -59,17 +51,11 @@ import { DesignToolbar } from '../components/editor/DesignToolbar'; import { RightPanelContainer } from '../components/editor/RightPanelContainer'; import { TemplateCard } from '../components/marketplace/TemplateCard'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const layerFactoryRegistry: Record> = { - // Planet layers - pointLocationsLayer, - countryOutlinesLayer, - h3HexagonsLayer, - // Background layers - skyboxLayer, - proceduralStarsLayer, - solarSystemLayer, -}; +/** + * The globe's layer set moved to `@we/module-globe` — the module owns it now. Re-exported so existing + * importers keep working. + */ +export { layerFactoryRegistry }; export const componentRegistry: ComponentRegistry = { // @we/components @@ -102,6 +88,9 @@ export const componentRegistry: ComponentRegistry = { RightPanelContainer, SpaceSidebarWidget, CollapsibleSidebar, + // Contributed by @we/module-globe — registered here rather than injected by the module registry so + // the static registry stays the single source for what a template may name. When modules become + // installable this entry comes from moduleRegistry.components() instead. CesiumGlobe: (props) => , GraphWidget: (props) => , SignalControl, diff --git a/packages/app-framework/tests/globeModule.test.ts b/packages/app-framework/tests/globeModule.test.ts new file mode 100644 index 00000000..2954592a --- /dev/null +++ b/packages/app-framework/tests/globeModule.test.ts @@ -0,0 +1,81 @@ +/** + * The Cesium conversion, as a regression test rather than a demo. + * + * The globe was built before the module system existed, so its behaviour is a fixed reference: if the + * contribution points can carry it *unchanged*, they are sufficient. A throwaway "hello" module could + * only show that wiring exists. + * + * Rendering the globe needs WebGL and a Cesium Ion token, so visual verification is manual. What can + * be asserted here is the part that actually moved — that the layer set resolves identically from its + * new owner, and that the module declares itself honestly. + */ +import { createGlobeModule, layerFactoryRegistry } from '@we/module-globe'; +import { checkModuleCompatibility } from '@we/schema-shared'; +import { describe, expect, it } from 'vitest'; + +import { moduleRegistry } from '../src/shared/registries/moduleRegistry'; + +/** Exactly the set `componentRegistry.tsx` held before the conversion. */ +const EXPECTED_LAYERS = [ + 'pointLocationsLayer', + 'countryOutlinesLayer', + 'h3HexagonsLayer', + 'skyboxLayer', + 'proceduralStarsLayer', + 'solarSystemLayer', +]; + +describe('globe module — the layer set survived the move', () => { + it('ships every layer the app-framework registry used to hold', () => { + expect(Object.keys(layerFactoryRegistry).sort()).toEqual([...EXPECTED_LAYERS].sort()); + }); + + it('exposes each layer as a callable factory, not just a key', () => { + // The registry resolving by name is what CesiumGlobe depends on; a missing factory would only + // surface as a blank globe at runtime. + for (const name of EXPECTED_LAYERS) { + expect(typeof layerFactoryRegistry[name]).toBe('function'); + } + }); + + // The `componentRegistry` re-export is deliberately not asserted here: importing it pulls the whole + // Solid component tree into a node environment, and the re-export existing is a compile-time fact + // `tsc` already checks. A jsdom environment for one identity assertion isn't worth it. +}); + +describe('globe module — what it declares', () => { + const definition = createGlobeModule(() => null); + + it('is backend-agnostic, because it owns no entities', () => { + // `backends` omitted means portable. The globe has no durable data of its own, so it never meets + // the manifest→SDNA gap that forces `backends: ['ad4m']` on entity-owning modules. + expect(definition.backends).toBeUndefined(); + expect(checkModuleCompatibility(definition, { backend: 'nextgraph', framework: 'solid' }).compatible).toBe(true); + }); + + it('declares solid, because its imperative core genuinely is a framework component', () => { + expect(definition.frameworks).toEqual(['solid']); + const plan = checkModuleCompatibility(definition, { backend: 'ad4m', framework: 'react' }); + expect(plan.compatible).toBe(false); + expect(plan.problems[0]).toContain('react'); + }); + + it('contributes exactly one component — the imperative core, nothing else', () => { + // Tier 2 discipline: a Cesium Viewer must be framework code, but that is the *only* part which + // has to be. Chrome and panels would be fragments. + expect(Object.keys(definition.components ?? {})).toEqual(['CesiumGlobe']); + }); + + it('registers cleanly against a solid/ad4m host', () => { + const result = moduleRegistry.register(definition, { backend: 'ad4m', framework: 'solid' }); + expect(result.registered).toBe(true); + expect(moduleRegistry.components().CesiumGlobe).toBeDefined(); + moduleRegistry.unregister('globe'); + }); + + it('owns no store, which is a legitimate module shape', () => { + // Layer visibility is $local state in the route schema. Inventing a store would be new behaviour + // and would break the "identical afterwards" property this conversion exists to prove. + expect(definition.createStore).toBeUndefined(); + }); +}); diff --git a/packages/modules/globe/package.json b/packages/modules/globe/package.json new file mode 100644 index 00000000..8faa7a81 --- /dev/null +++ b/packages/modules/globe/package.json @@ -0,0 +1,36 @@ +{ + "name": "@we/module-globe", + "version": "0.1.0", + "description": "Globe feature module — the Cesium layer registry, route and schema fragments", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build:steps": "tsup", + "build": "we-build", + "dev": "tsup --watch" + }, + "dependencies": { + "@we/cesium-layers": "workspace:*" + }, + "peerDependencies": { + "@we/schema-shared": "workspace:*", + "@we/widgets": "workspace:*" + }, + "devDependencies": { + "@we/cli": "workspace:*", + "@we/schema-shared": "workspace:*", + "@we/widgets": "workspace:*", + "tsup": "^8.0.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/modules/globe/src/index.ts b/packages/modules/globe/src/index.ts new file mode 100644 index 00000000..d997bc8d --- /dev/null +++ b/packages/modules/globe/src/index.ts @@ -0,0 +1,88 @@ +/** + * The Globe feature module — the first module, and the proof that the contribution points are + * *sufficient*. + * + * Deliberately a **conversion, not a new feature**. The globe already worked, so its behaviour is a + * fixed reference: if the module system can carry something built before the module system existed, + * the seams are real. Writing a new feature to test new seams means a failure can't be attributed to + * either. + * + * ## What moved, and what did not + * + * Only the *wiring* moved. `CesiumGlobe` stays in `@we/widgets` — it passes the props-only test + * (`layerFactoryRegistry` is injected, and its `LayerStore` is a private `Map`, not a WE store), so it + * is correctly a design-system widget. The layers stay in `@we/cesium-layers`. What was scattered + * across `componentRegistry.tsx` — the layer set, the component wrapper — is now owned here. + * + * ## Why this module has no store + * + * Layer visibility is `$local` state inside the route schema (`enabled: { $local: 'showSkybox' }`). + * Inventing a store would be new behaviour and would break the "identical afterwards" property this + * conversion exists to demonstrate. A module with no store is a legitimate shape, and worth having as + * the first example so nobody assumes stores are mandatory. + * + * ## What it exercises, and what it deliberately doesn't + * + * Exercises: `defineModule`, the module registry, a **module-private sub-registry** (layer factories — + * something the call module will never test), a framework component contributed for an imperative + * core, and schema fragments. + * + * Doesn't: shell slots (the globe is a route, not chrome — the host-slot migration covers those), and + * the ephemeral port. + */ +import { + countryOutlinesLayer, + h3HexagonsLayer, + type LayerFactory, + pointLocationsLayer, + proceduralStarsLayer, + skyboxLayer, + solarSystemLayer, +} from '@we/cesium-layers'; +import { defineModule } from '@we/schema-shared'; + +/** + * The layer set this module ships — a **module-private registry**, nested inside the module system. + * + * Worth noting as a shape: a module may own a plugin system of its own without the host needing a + * generic "sub-registry" concept. Third-party layers do not need to live in this package either, since + * `CesiumGlobe` resolves layers through an injected `Record` — an external layer + * is just a package exporting a factory. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const layerFactoryRegistry: Record> = { + // Planet layers + pointLocationsLayer, + countryOutlinesLayer, + h3HexagonsLayer, + // Background layers + skyboxLayer, + proceduralStarsLayer, + solarSystemLayer, +}; + +/** + * Build the module definition. + * + * A factory taking the widget rather than importing it, so this package never pulls Solid or + * `@we/widgets` into its own bundle — the host passes in the component it already has. That keeps the + * single-instance guarantee that matters once modules load dynamically, and it is why `frameworks` + * below names solid: the *component* is Solid, even though this file is not. + */ +export function createGlobeModule(cesiumGlobeComponent: unknown) { + return defineModule({ + id: 'globe', + name: 'Globe', + description: '3D globe with a modular layer system — locations, country outlines, H3 hexagons.', + icon: 'globe-hemisphere-west', + + // The globe renders a WebGL canvas and can reach Cesium Ion for terrain/imagery assets. + capabilities: ['network:cesium-ion'], + + // Backend-agnostic: no owned entities, so no manifest→SDNA gap to fall into. `backends` omitted + // means portable, which is the default the contract makes you opt out of rather than into. + frameworks: ['solid'], + + components: { CesiumGlobe: cesiumGlobeComponent }, + }); +} diff --git a/packages/modules/globe/tsconfig.json b/packages/modules/globe/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/modules/globe/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/modules/globe/tsup.config.ts b/packages/modules/globe/tsup.config.ts new file mode 100644 index 00000000..4dad40ce --- /dev/null +++ b/packages/modules/globe/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'es2022', + splitting: false, + treeshake: true, + // Never bundled: cesium is huge, and @we/widgets must stay a single instance shared with the host. + external: ['cesium', '@we/widgets', '@we/schema-shared', 'solid-js'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc38554a..9297ea30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,9 +432,6 @@ importers: '@we/block-solid': specifier: workspace:* version: link:../block-system/frameworks/solid - '@we/cesium-layers': - specifier: workspace:* - version: link:../cesium-layers '@we/components': specifier: workspace:* version: link:../design-system/4-components @@ -447,6 +444,9 @@ importers: '@we/models': specifier: workspace:* version: link:../models + '@we/module-globe': + specifier: workspace:* + version: link:../modules/globe '@we/primitives': specifier: workspace:* version: link:../design-system/3-primitives @@ -862,6 +862,28 @@ importers: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) + packages/modules/globe: + dependencies: + '@we/cesium-layers': + specifier: workspace:* + version: link:../../cesium-layers + devDependencies: + '@we/cli': + specifier: workspace:* + version: link:../../cli + '@we/schema-shared': + specifier: workspace:* + version: link:../../schema-system/shared + '@we/widgets': + specifier: workspace:* + version: link:../../design-system/5-widgets + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages/schema-system/frameworks/solid: dependencies: '@we/design-types': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d3574bab..c42e59d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,8 @@ packages: - packages/schema-system/frameworks/* - packages/block-system/* - packages/block-system/frameworks/* + # Feature modules — the developer rung above blocks (see notes/we/August-2026/feature-modules.md) + - packages/modules/* onlyBuiltDependencies: - electron From f25056eae946b718d12cc05e6c744428cb663599 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:24:32 +0100 Subject: [PATCH 04/10] fix(schema-solid): a missing component costs that node, not the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unrecognised type threw, which took down the **whole render** rather than one node. So a template referencing a component from a feature module that isn't enabled produced a blank page — exactly the failure the module system's optional-dependency story depends on not happening, and the reason a template declaring `requiresModules` or `$if`-ing on `modules.` could never have been a complete answer on its own. Now it fails the way the rest of the system does: loud, but scoped. A visible placeholder naming the component and the likely cause, plus a console error, while its siblings render normally. Dev-time loudness is not lost. `we-validate-schemas` already catches unknown types before runtime, which is the right place to catch a typo — 13 schemas validate on every run. What changes is only the runtime, where one bad node should cost that node and nothing more. Updates the test that pinned the old behaviour, and adds one to the portable slice proving siblings survive. Co-Authored-By: Claude Opus 5 (1M context) --- .../frameworks/solid/src/SchemaRenderer.tsx | 26 +++++++++++++- .../solid/tests/SchemaRenderer.test.tsx | 8 +++-- .../solid/tests/portableSlice.test.tsx | 34 ++++++++++++++++++- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx index 120734c7..45410dba 100644 --- a/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx +++ b/packages/schema-system/frameworks/solid/src/SchemaRenderer.tsx @@ -849,7 +849,31 @@ export function RenderSchema({ node, stores, registry, context = {}, children }: const isWc = t.includes('-'); return registry[t] ?? (isHtml || isWc ? t : undefined); }); - if (!component()) throw new Error(`Schema node has unknown type "${node.type}".`); + // An unrecognised type used to throw, which took down **the whole render** rather than one node — + // so a template referencing a component from a module that isn't enabled produced a blank page. + // Fail the way the rest of the system does: loud, but scoped. + // + // Dev-time loudness is not lost: `we-validate-schemas` catches unknown types before runtime, which + // is the right place for a typo. What changes is only the runtime, where one bad node should cost + // that node and nothing more. + if (!component()) { + const message = `Unknown component "${node.type}"`; + console.error(`${message}. It may belong to a feature module that is not enabled, or the type may be misspelt.`); + return ( +
+ {message} +
+ ); + } // Prepare the slot elements in a reactive store const [slotElements, setSlotElements] = createStore>( diff --git a/packages/schema-system/frameworks/solid/tests/SchemaRenderer.test.tsx b/packages/schema-system/frameworks/solid/tests/SchemaRenderer.test.tsx index dcea8705..3c08b788 100644 --- a/packages/schema-system/frameworks/solid/tests/SchemaRenderer.test.tsx +++ b/packages/schema-system/frameworks/solid/tests/SchemaRenderer.test.tsx @@ -60,9 +60,13 @@ describe('SchemaRenderer', () => { }); // --- Unknown type throws --- - it('throws for unknown type', () => { + it('renders a placeholder for an unknown type instead of throwing', () => { + // Throwing took down the whole render, so one unresolvable node — a component from a feature + // module that isn't enabled — blanked the page. `we-validate-schemas` still catches typos before + // runtime, so nothing is lost at dev time. const node: SchemaNode = { type: 'UnknownComponent' }; - expect(() => renderSchema(node)).toThrow('Schema node has unknown type "UnknownComponent"'); + const { container } = renderSchema(node); + expect(container.querySelector('[data-we-missing-component="UnknownComponent"]')).toBeTruthy(); }); // --- $store prop resolution --- diff --git a/packages/schema-system/frameworks/solid/tests/portableSlice.test.tsx b/packages/schema-system/frameworks/solid/tests/portableSlice.test.tsx index 15c33af7..a4c03c84 100644 --- a/packages/schema-system/frameworks/solid/tests/portableSlice.test.tsx +++ b/packages/schema-system/frameworks/solid/tests/portableSlice.test.tsx @@ -10,7 +10,7 @@ */ import { render } from '@solidjs/testing-library'; import type { SchemaNode } from '@we/schema-shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { RenderSchema } from '../src/SchemaRenderer'; import type { ComponentRegistry } from '../src/types'; @@ -124,3 +124,35 @@ describe('portable-UI slice — renderer over an in-memory (non-AD4M) backend', expect(posts[0].textContent).toContain('Graph databases'); }); }); + +describe('unknown component types', () => { + it('renders a scoped placeholder rather than taking down the page', async () => { + // Previously this threw, so a template referencing a component from a module that is not enabled + // produced a blank page instead of one broken node. That is the failure the module system's + // optional-dependency story depends on not happening. + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const backend = seedBackend(); + + const schema: SchemaNode = { + type: 'Stack', + props: { testid: 'root' }, + children: [ + { type: 'Field', children: ['before'] }, + { type: 'CallStageView' }, // a module component that isn't registered + { type: 'Field', children: ['after'] }, + ], + }; + + const { container } = render(() => ); + await tick(); + + // The siblings still rendered — the page survived. + const root = container.querySelector('[data-testid="root"]')!; + expect(root.textContent).toContain('before'); + expect(root.textContent).toContain('after'); + // And the failure is visible rather than silent. + expect(container.querySelector('[data-we-missing-component="CallStageView"]')).toBeTruthy(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); + }); +}); From 8b13b702677fcff7e63649d751e8daa0621f6252 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:29:02 +0100 Subject: [PATCH 05/10] feat(app-framework): activate feature modules the seed declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the globe an actually-registered module rather than a package the app happens to import. `we-seed.json` gains a `modules: ['globe']` key — "which modules to include" is already in the seed's stated purpose, so this is the deployment layer of the enablement story. The per-agent and per-space halves (AgentSettings.installedModules, Space.enabledModules) arrive with the marketplace, when modules become installable rather than bundled. Bundled modules are **factories, not definitions**, because a module may need something from the host to describe itself. The globe takes CesiumGlobe as an argument so its own package never imports Solid or @we/widgets — which keeps those single instances shared with the host, the property that starts to matter the moment modules load dynamically. That injection is also why `initializeIntegrations` now takes a deps argument rather than importing the component registry: it lives in framework-neutral `shared/`, and importing the Solid registry there would drag the whole component tree into it. `platform/context.tsx` hands the components over, where the framework is already known. Activation reports rather than throws, and distinguishes two faults that are easy to collapse: **missing** (the seed names a module this build lacks) from **refused** (present, but incompatible with this host). Collapsing them would send someone hunting for a packaging problem that isn't there. A silently missing module would otherwise surface much later as an unexplained missing component — which is precisely the confusion the renderer's new placeholder has to name. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/shared/initializeIntegrations.ts | 30 +++++++- .../src/shared/platform/context.tsx | 5 +- .../src/shared/registries/bundledModules.ts | 76 +++++++++++++++++++ packages/app-framework/src/types/seed.ts | 13 ++++ .../tests/bundledModules.test.ts | 66 ++++++++++++++++ we-seed.json | 14 ++-- 6 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 packages/app-framework/src/shared/registries/bundledModules.ts create mode 100644 packages/app-framework/tests/bundledModules.test.ts diff --git a/packages/app-framework/src/shared/initializeIntegrations.ts b/packages/app-framework/src/shared/initializeIntegrations.ts index dc200d5c..54f24e94 100644 --- a/packages/app-framework/src/shared/initializeIntegrations.ts +++ b/packages/app-framework/src/shared/initializeIntegrations.ts @@ -11,9 +11,23 @@ import type { WeSeedFile } from '../types/seed'; import { generateIframePermissions, validateSeedForLauncher } from './integrationComposer'; import type { PlatformAdapter } from './platform/types'; import { appRegistry } from './registries/appRegistry'; +import { activateSeedModules } from './registries/bundledModules'; +import { moduleRegistry } from './registries/moduleRegistry'; import { slotRegistry } from './registries/slotRegistry'; -export function initializeIntegrations(platformAdapter: PlatformAdapter): void { +export interface IntegrationDeps { + /** + * Framework components a bundled module needs to describe itself, supplied by the caller. + * + * Injected rather than imported because this file is framework-neutral `shared/` code — importing + * the Solid component registry here would drag the whole component tree into it. It is also what + * keeps Solid and `@we/widgets` single instances shared with the host, which is the property that + * starts to matter once modules load dynamically. + */ + components?: Record; +} + +export function initializeIntegrations(platformAdapter: PlatformAdapter, deps: IntegrationDeps = {}): void { try { const seed = weSeedFile as unknown as WeSeedFile; @@ -43,7 +57,19 @@ export function initializeIntegrations(platformAdapter: PlatformAdapter): void { }); } - console.log(`✓ ${seed.project.name} initialized — ${seed.apps.length} embedded app(s)`); + // Activate the feature modules this deployment declares. Components are passed in rather than + // imported by each module, so Solid and @we/widgets stay single instances shared with the host. + const { activated } = activateSeedModules( + seed.modules, + { components: deps.components ?? {} }, + { backend: 'ad4m', framework: 'solid' }, + moduleRegistry, + ); + + console.log( + `✓ ${seed.project.name} initialized — ${seed.apps.length} embedded app(s)` + + (activated.length ? `, ${activated.length} module(s): ${activated.join(', ')}` : ''), + ); } catch (error) { console.error('❌ Failed to initialize integrations:', error); } diff --git a/packages/app-framework/src/shared/platform/context.tsx b/packages/app-framework/src/shared/platform/context.tsx index accd4322..4a429a32 100644 --- a/packages/app-framework/src/shared/platform/context.tsx +++ b/packages/app-framework/src/shared/platform/context.tsx @@ -1,3 +1,4 @@ +import { componentRegistry } from '@solid/registries/componentRegistry'; import { createContext, ParentComponent, useContext } from 'solid-js'; import { initializeIntegrations } from '../initializeIntegrations'; @@ -8,7 +9,9 @@ const PlatformContext = createContext(); export const PlatformProvider: ParentComponent<{ adapter: PlatformAdapter }> = (props) => { // Initialize integrations with platform adapter BEFORE rendering children // This must run synchronously so the launcher template is ready when TemplateStoreProvider reads the registry - initializeIntegrations(props.adapter); + // Components are handed over here, where the framework is known — `initializeIntegrations` itself + // stays framework-neutral. + initializeIntegrations(props.adapter, { components: { CesiumGlobe: componentRegistry.CesiumGlobe } }); return {props.children}; }; diff --git a/packages/app-framework/src/shared/registries/bundledModules.ts b/packages/app-framework/src/shared/registries/bundledModules.ts new file mode 100644 index 00000000..466ad8df --- /dev/null +++ b/packages/app-framework/src/shared/registries/bundledModules.ts @@ -0,0 +1,76 @@ +/** + * The feature modules compiled into this build, and the boot-time registration that activates the + * subset a deployment's seed asks for. + * + * Bundled rather than dynamically loaded, deliberately: the registry only needs static registration + * until the marketplace exists, and `import()` brings a whole separate problem (a dynamically-loaded + * bundle carrying its own reactive runtime gets a *second* one, and reactivity silently stops crossing + * the boundary). Solving that is worth doing against a real module, not speculatively. + * + * Adding a module here plus an id in `we-seed.json` is the whole install story for now. + */ +import { createGlobeModule } from '@we/module-globe'; +import type { ModuleDefinition } from '@we/schema-shared'; + +/** + * Factories rather than definitions, because a module may need something from the host to describe + * itself — the globe takes the `CesiumGlobe` component so its own package never imports Solid or + * `@we/widgets`, keeping those single instances shared with the host. + */ +export type BundledModuleFactory = (deps: BundledModuleDeps) => ModuleDefinition; + +export interface BundledModuleDeps { + /** Framework components the host already holds, passed in rather than imported by the module. */ + components: Record; +} + +export const bundledModules: Record = { + globe: ({ components }) => createGlobeModule(components.CesiumGlobe), +}; + +export interface ModuleActivation { + activated: string[]; + /** Ids the seed asked for that this build doesn't contain. */ + missing: string[]; + /** Ids that were refused as incompatible, with the reason. */ + refused: { id: string; problems: string[] }[]; +} + +/** + * Activate the modules a seed declares. + * + * Reports rather than throws. A deployment naming a module this build lacks is a configuration + * mistake, not a reason to fail boot — and a silently missing module surfaces much later as an + * unexplained missing component, which is precisely the confusion the renderer's placeholder now + * names. + */ +export function activateSeedModules( + ids: string[] | undefined, + deps: BundledModuleDeps, + host: { backend: string; framework: string }, + registry: { + register: ( + definition: ModuleDefinition, + host: { backend: string; framework: string }, + ) => { registered: boolean; problems: string[] }; + }, +): ModuleActivation { + const result: ModuleActivation = { activated: [], missing: [], refused: [] }; + + for (const id of ids ?? []) { + const factory = bundledModules[id]; + if (!factory) { + result.missing.push(id); + continue; + } + const outcome = registry.register(factory(deps), host); + if (outcome.registered) result.activated.push(id); + else result.refused.push({ id, problems: outcome.problems }); + } + + if (result.missing.length) { + console.warn(`seed declares modules not present in this build: ${result.missing.join(', ')}`); + } + + return result; +} diff --git a/packages/app-framework/src/types/seed.ts b/packages/app-framework/src/types/seed.ts index b44e90cb..47aecf08 100644 --- a/packages/app-framework/src/types/seed.ts +++ b/packages/app-framework/src/types/seed.ts @@ -35,6 +35,19 @@ export interface WeSeedFile { useQueryIR?: boolean; }; + /** + * Feature modules this deployment ships, by module id. + * + * A deployment declaring what it includes is what the seed is *for* — "which modules to include" is + * already in its stated purpose. Ids here are matched against the bundled module set at boot; an id + * with no bundled module is reported rather than ignored, since a silently missing module surfaces + * later as an unexplained missing component. + * + * Bundled only for now. When modules become installable, this stays the deployment-level list and + * `AgentSettings.installedModules` / `Space.enabledModules` carry the per-agent and per-space halves. + */ + modules?: string[]; + /** Host app customization (WE shell) — optional white-labeling */ host?: { /** Theme overrides for the host */ diff --git a/packages/app-framework/tests/bundledModules.test.ts b/packages/app-framework/tests/bundledModules.test.ts new file mode 100644 index 00000000..00e64b22 --- /dev/null +++ b/packages/app-framework/tests/bundledModules.test.ts @@ -0,0 +1,66 @@ +/** + * Seed-declared module activation. + * + * The seed's stated purpose already includes "which modules to include", so this is the deployment + * layer of the three-part enablement story — the other two (`AgentSettings.installedModules`, + * `Space.enabledModules`) arrive with the marketplace, when modules become installable rather than + * bundled. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { activateSeedModules, bundledModules } from '../src/shared/registries/bundledModules'; +import { moduleRegistry } from '../src/shared/registries/moduleRegistry'; + +const host = { backend: 'ad4m', framework: 'solid' }; +const deps = { components: { CesiumGlobe: () => null } }; + +beforeEach(() => { + for (const { definition } of moduleRegistry.all()) moduleRegistry.unregister(definition.id); +}); + +describe('activateSeedModules', () => { + it('activates a module the seed declares', () => { + const result = activateSeedModules(['globe'], deps, host, moduleRegistry); + expect(result.activated).toEqual(['globe']); + expect(moduleRegistry.has('globe')).toBe(true); + }); + + it('activates nothing when the seed declares nothing', () => { + expect(activateSeedModules(undefined, deps, host, moduleRegistry).activated).toEqual([]); + expect(moduleRegistry.all()).toHaveLength(0); + }); + + it('reports an unknown id rather than ignoring it', () => { + // A silently missing module surfaces much later as an unexplained missing component — which is + // exactly the confusion the renderer's placeholder now has to name. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = activateSeedModules(['globe', 'nonexistent'], deps, host, moduleRegistry); + + expect(result.activated).toEqual(['globe']); + expect(result.missing).toEqual(['nonexistent']); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('records a refusal separately from a missing id — they are different faults', () => { + // Missing means the build lacks the module; refused means it is present but cannot run here. + // Collapsing them would send someone hunting for a packaging problem that isn't there. + const refusing = { register: () => ({ registered: false, problems: ['needs backend nextgraph'] }) }; + const result = activateSeedModules(['globe'], deps, host, refusing); + + expect(result.activated).toEqual([]); + expect(result.missing).toEqual([]); + expect(result.refused).toEqual([{ id: 'globe', problems: ['needs backend nextgraph'] }]); + }); + + it('passes host components through, so a module never imports them itself', () => { + // The globe's definition is built from the CesiumGlobe the host already holds — which is what + // keeps Solid and @we/widgets single instances. + activateSeedModules(['globe'], deps, host, moduleRegistry); + expect(moduleRegistry.get('globe')?.definition.components?.CesiumGlobe).toBe(deps.components.CesiumGlobe); + }); + + it('exposes the globe as a bundled module', () => { + expect(Object.keys(bundledModules)).toContain('globe'); + }); +}); diff --git a/we-seed.json b/we-seed.json index 426af281..f83e8567 100644 --- a/we-seed.json +++ b/we-seed.json @@ -5,25 +5,23 @@ "description": "Pure WE application built with design system and schema renderer", "author": "James Weir" }, - "features": { "useQueryIR": true }, - + "modules": [ + "globe" + ], "ad4m": { "dataPath": "~/.we-native-app", "executorPath": "../ad4m/target/release/ad4m-executor", "repoPath": "../ad4m" }, - "globalSpaceUrl": "neighbourhood://QmzSYwdZnsgCrgG4bGzmLP21AMoQTbxfwfNQeGhCXvuKqV4zXCv", "marketplaceUrl": "neighbourhood://QmzSYwdjBtfHQgMBAFCi5nX1fxS3SQ7prh5CcysSrc2yngqL51H", - "electron": { "appDistPath": "dist", "basePort": 8080 }, - "apps": [ { "id": "flux", @@ -31,7 +29,11 @@ "icon": "camera", "image": "https://app.fluxsocial.io/icon.png", "description": "Social web3 toolkit for communities", - "capabilities": ["perspectives", "languages", "agents"], + "capabilities": [ + "perspectives", + "languages", + "agents" + ], "paths": { "projectRoot": "../flux/app", "dist": "../flux/app/dist", From 7462cb3195e5ec2c664cbd31e98fca95354f6cc3 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:37:57 +0100 Subject: [PATCH 06/10] =?UTF-8?q?feat(modules):=20notes=20=E2=80=94=20the?= =?UTF-8?q?=20first=20module=20to=20own=20durable=20entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second module, chosen for exactly what the globe could not test: a module-declared model, its install path, and the predicate namespace. Fully solo-testable, because a personal perspective is local-only — no neighbourhood, no Holochain sync — so it exercises the install path without peer connectivity. **It is framework-agnostic despite shipping a whole panel.** Every piece of UI is a SchemaNode; nothing in the package imports Solid or @we/components, because in a fragment `Column` is a registry key rather than an import. That makes it Tier 1 of the convention, and it means this module has no framework import to duplicate — so it cannot introduce the second-runtime hazard when modules eventually load dynamically. **It ships almost no code.** Notes are a live `$query` in the fragment; creating and deleting go through `model.create`/`model.delete`, already in the stores bag. A module reaching for its own persistence layer would be duplicating the data port. The store holds only panel open/closed — and that *is* store state rather than `$localState`, because the panel is chrome and node-local state would reset it on every route change. Two contract additions the module forced, which is what a foil is for: - **`models`** — declarative, so the *host* owns the install mechanism. Module models now install through the same `ensureModelsRegistered` path as WE's own, which diffs against the perspective before writing. Idempotency in one place matters here specifically: `cleanupSpaceSdna` exists because shapes once got installed twice by different agents, and N modules each rolling their own install is that bug with more instances. - **`ModuleStoreDeps`** — reactivity is *injected*, not imported, the same port trick that keeps @we/schema-shared framework-neutral. Solid's createSignal already has the [read, write] shape, so the host lends it and the module stays framework-free. `backends: ['ad4m']` is declared, because owning entities means writing @Model-decorated classes until a manifest→SDNA compiler exists. The escape hatch working as designed: unblocked, with the coupling visible at install. NOTE_PREDICATES names the scheme rather than inlining it, because this is the first module to own entities and **whatever it picks becomes the convention**. `module:///` — never `we://`, which belongs to WE's own models and would collide with TextBlock.text once both are in one perspective. Predicates are how existing data is found, so changing the scheme later orphans every note silently; a test pins the shape. Co-Authored-By: Claude Opus 5 (1M context) --- packages/app-framework/package.json | 3 +- .../src/shared/initializeIntegrations.ts | 6 +- .../src/shared/platform/context.tsx | 9 +- .../src/shared/registries/bundledModules.ts | 16 +- .../src/shared/registries/moduleRegistry.ts | 22 ++- .../app-framework/src/shared/sdnaModels.ts | 9 +- .../tests/moduleRegistry.test.ts | 15 +- .../app-framework/tests/notesModule.test.ts | 107 +++++++++++ packages/modules/notes/package.json | 36 ++++ packages/modules/notes/src/Note.ts | 43 +++++ packages/modules/notes/src/index.ts | 171 ++++++++++++++++++ packages/modules/notes/tsconfig.json | 12 ++ packages/modules/notes/tsup.config.ts | 14 ++ packages/schema-system/shared/src/index.ts | 9 +- packages/schema-system/shared/src/module.ts | 30 ++- pnpm-lock.yaml | 21 +++ we-seed.json | 3 +- 17 files changed, 511 insertions(+), 15 deletions(-) create mode 100644 packages/app-framework/tests/notesModule.test.ts create mode 100644 packages/modules/notes/package.json create mode 100644 packages/modules/notes/src/Note.ts create mode 100644 packages/modules/notes/src/index.ts create mode 100644 packages/modules/notes/tsconfig.json create mode 100644 packages/modules/notes/tsup.config.ts diff --git a/packages/app-framework/package.json b/packages/app-framework/package.json index b29ccb26..0a90ac9f 100644 --- a/packages/app-framework/package.json +++ b/packages/app-framework/package.json @@ -53,7 +53,8 @@ "solid-js": "^1.9.5", "three": "^0.176.0", "zod": "^4.1.11", - "@we/module-globe": "workspace:*" + "@we/module-globe": "workspace:*", + "@we/module-notes": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/packages/app-framework/src/shared/initializeIntegrations.ts b/packages/app-framework/src/shared/initializeIntegrations.ts index 54f24e94..dded47fa 100644 --- a/packages/app-framework/src/shared/initializeIntegrations.ts +++ b/packages/app-framework/src/shared/initializeIntegrations.ts @@ -6,6 +6,8 @@ * iframe mounting) is handled by AppStore and TemplateProvider at runtime. */ +import type { ModuleStoreDeps } from '@we/schema-shared'; + import weSeedFile from '../../../../we-seed.json'; import type { WeSeedFile } from '../types/seed'; import { generateIframePermissions, validateSeedForLauncher } from './integrationComposer'; @@ -25,6 +27,8 @@ export interface IntegrationDeps { * starts to matter once modules load dynamically. */ components?: Record; + /** Reactivity lent to module stores, so a module needn't import a framework. */ + storeDeps?: ModuleStoreDeps; } export function initializeIntegrations(platformAdapter: PlatformAdapter, deps: IntegrationDeps = {}): void { @@ -61,7 +65,7 @@ export function initializeIntegrations(platformAdapter: PlatformAdapter, deps: I // imported by each module, so Solid and @we/widgets stay single instances shared with the host. const { activated } = activateSeedModules( seed.modules, - { components: deps.components ?? {} }, + { components: deps.components ?? {}, storeDeps: deps.storeDeps }, { backend: 'ad4m', framework: 'solid' }, moduleRegistry, ); diff --git a/packages/app-framework/src/shared/platform/context.tsx b/packages/app-framework/src/shared/platform/context.tsx index 4a429a32..abb54012 100644 --- a/packages/app-framework/src/shared/platform/context.tsx +++ b/packages/app-framework/src/shared/platform/context.tsx @@ -1,5 +1,5 @@ import { componentRegistry } from '@solid/registries/componentRegistry'; -import { createContext, ParentComponent, useContext } from 'solid-js'; +import { createContext, createSignal, ParentComponent, useContext } from 'solid-js'; import { initializeIntegrations } from '../initializeIntegrations'; import { PlatformAdapter } from './types'; @@ -11,7 +11,12 @@ export const PlatformProvider: ParentComponent<{ adapter: PlatformAdapter }> = ( // This must run synchronously so the launcher template is ready when TemplateStoreProvider reads the registry // Components are handed over here, where the framework is known — `initializeIntegrations` itself // stays framework-neutral. - initializeIntegrations(props.adapter, { components: { CesiumGlobe: componentRegistry.CesiumGlobe } }); + initializeIntegrations(props.adapter, { + components: { CesiumGlobe: componentRegistry.CesiumGlobe }, + // Reactivity lent to module stores. Solid's createSignal already has the [read, write] shape the + // port asks for, so a module store gets reactivity without importing a framework. + storeDeps: { signal: (initial: T) => createSignal(initial) as [() => T, (next: T) => void] }, + }); return {props.children}; }; diff --git a/packages/app-framework/src/shared/registries/bundledModules.ts b/packages/app-framework/src/shared/registries/bundledModules.ts index 466ad8df..df7d5f81 100644 --- a/packages/app-framework/src/shared/registries/bundledModules.ts +++ b/packages/app-framework/src/shared/registries/bundledModules.ts @@ -10,7 +10,8 @@ * Adding a module here plus an id in `we-seed.json` is the whole install story for now. */ import { createGlobeModule } from '@we/module-globe'; -import type { ModuleDefinition } from '@we/schema-shared'; +import { notesModule } from '@we/module-notes'; +import type { ModuleDefinition, ModuleStoreDeps } from '@we/schema-shared'; /** * Factories rather than definitions, because a module may need something from the host to describe @@ -24,8 +25,16 @@ export interface BundledModuleDeps { components: Record; } +export interface ActivationDeps extends BundledModuleDeps { + /** Reactivity lent to module stores, so a module needn't import a framework. */ + storeDeps?: ModuleStoreDeps; +} + export const bundledModules: Record = { globe: ({ components }) => createGlobeModule(components.CesiumGlobe), + // Takes nothing from the host: every piece of its UI is a schema fragment, so it imports no + // framework at all. + notes: () => notesModule, }; export interface ModuleActivation { @@ -46,12 +55,13 @@ export interface ModuleActivation { */ export function activateSeedModules( ids: string[] | undefined, - deps: BundledModuleDeps, + deps: ActivationDeps, host: { backend: string; framework: string }, registry: { register: ( definition: ModuleDefinition, host: { backend: string; framework: string }, + storeDeps?: ModuleStoreDeps, ) => { registered: boolean; problems: string[] }; }, ): ModuleActivation { @@ -63,7 +73,7 @@ export function activateSeedModules( result.missing.push(id); continue; } - const outcome = registry.register(factory(deps), host); + const outcome = registry.register(factory(deps), host, deps.storeDeps); if (outcome.registered) result.activated.push(id); else result.refused.push({ id, problems: outcome.problems }); } diff --git a/packages/app-framework/src/shared/registries/moduleRegistry.ts b/packages/app-framework/src/shared/registries/moduleRegistry.ts index fb040a4d..576b7963 100644 --- a/packages/app-framework/src/shared/registries/moduleRegistry.ts +++ b/packages/app-framework/src/shared/registries/moduleRegistry.ts @@ -23,7 +23,7 @@ * registers. That is exactly what makes `{ $if: { condition: { $store: 'modules.notes' } } }` the * supported way for a template to depend on an optional module. */ -import type { ModuleDefinition } from '@we/schema-shared'; +import type { ModuleDefinition, ModuleStoreDeps } from '@we/schema-shared'; import { checkModuleCompatibility } from '@we/schema-shared'; import { slotRegistry } from './slotRegistry'; @@ -58,7 +58,11 @@ export const moduleRegistry = { * declared backend or framework doesn't match would otherwise register components that fail at * render time, far from the cause. */ - register(definition: ModuleDefinition, host: { backend: string; framework: string }): RegisterResult { + register( + definition: ModuleDefinition, + host: { backend: string; framework: string }, + storeDeps?: ModuleStoreDeps, + ): RegisterResult { const compatibility = checkModuleCompatibility(definition, host); if (!compatibility.compatible) { console.warn(`module "${definition.id}" not registered: ${compatibility.problems.join('; ')}`); @@ -71,7 +75,8 @@ export const moduleRegistry = { moduleRegistry.unregister(definition.id); } - const store = definition.createStore?.(); + // Reactivity is lent by the host, so a module store never imports a framework. + const store = storeDeps ? definition.createStore?.(storeDeps) : undefined; modules.set(definition.id, { definition, store }); if (store) moduleStores[definition.id] = store; @@ -117,6 +122,17 @@ export const moduleRegistry = { return Object.assign({}, ...moduleRegistry.all().map((m) => m.definition.components ?? {})); }, + /** + * Entity types every registered module owns, for the host to install into a dataset. + * + * Collected here rather than installed per-module so idempotency lives in **one** place — WE + * already carries `cleanupSpaceSdna` as remediation for shapes installed twice by different + * agents, and N modules each rolling their own install is that bug with more instances. + */ + models(): unknown[] { + return moduleRegistry.all().flatMap((m) => m.definition.models ?? []); + }, + /** Named schema fragments, keyed `.` so two modules can't collide. */ schemas(): Record { const out: Record = {}; diff --git a/packages/app-framework/src/shared/sdnaModels.ts b/packages/app-framework/src/shared/sdnaModels.ts index 05fdd4fb..d4acb295 100644 --- a/packages/app-framework/src/shared/sdnaModels.ts +++ b/packages/app-framework/src/shared/sdnaModels.ts @@ -27,6 +27,8 @@ import { WeNode, } from '@we/models'; +import { moduleRegistry } from './registries/moduleRegistry'; + /** * All SDNA models that belong to the we-root system perspective. * Centralised here so both AdamStore branches (create vs restore) always @@ -155,7 +157,12 @@ export const SPACE_MODELS = [ * are written. */ export async function installSpaceSdna(p: PerspectiveProxy): Promise { - await ensureModelsRegistered(p, SPACE_MODELS); + // Module-owned entities install by the same path as WE's own, so the diff-before-write check that + // `ensureModelsRegistered` performs applies to both. That single shared path is the whole point: + // `cleanupSpaceSdna` exists because shapes once got installed twice, and N modules each rolling + // their own install would be that bug with more instances. + const moduleModels = moduleRegistry.models() as (typeof Ad4mModel)[]; + await ensureModelsRegistered(p, [...SPACE_MODELS, ...moduleModels]); } /** diff --git a/packages/app-framework/tests/moduleRegistry.test.ts b/packages/app-framework/tests/moduleRegistry.test.ts index b96a2616..a2240f29 100644 --- a/packages/app-framework/tests/moduleRegistry.test.ts +++ b/packages/app-framework/tests/moduleRegistry.test.ts @@ -13,6 +13,18 @@ import { registerCoreSlots, slotRegistry } from '../src/shared/registries/slotRe const host = { backend: 'ad4m', framework: 'solid' }; +/** + * Stand-in reactivity. A module store is built from injected primitives rather than an imported + * framework, so a plain closure satisfies the port here — which is itself the point: nothing in a + * module store requires Solid to exist. + */ +const storeDeps = { + signal: (initial: T): [() => T, (next: T) => void] => { + let value = initial; + return [() => value, (next: T) => (value = next)]; + }, +}; + function reset() { for (const entry of slotRegistry.ordered()) slotRegistry.remove(entry.id); for (const { definition } of moduleRegistry.all()) moduleRegistry.unregister(definition.id); @@ -86,6 +98,7 @@ describe('moduleRegistry', () => { createStore: () => ({ open: true }), }), host, + storeDeps, ); expect(moduleStores.notes).toEqual({ open: true }); @@ -96,7 +109,7 @@ describe('moduleRegistry', () => { // The whole point of the namespace convention: a template can depend on an optional module // because the key is missing, not present-but-inert. expect(moduleStores.notes).toBeUndefined(); - moduleRegistry.register(mod({ id: 'notes', createStore: () => ({}) }), host); + moduleRegistry.register(mod({ id: 'notes', createStore: () => ({}) }), host, storeDeps); expect(moduleStores.notes).toBeDefined(); moduleRegistry.unregister('notes'); expect(moduleStores.notes).toBeUndefined(); diff --git a/packages/app-framework/tests/notesModule.test.ts b/packages/app-framework/tests/notesModule.test.ts new file mode 100644 index 00000000..ce87180d --- /dev/null +++ b/packages/app-framework/tests/notesModule.test.ts @@ -0,0 +1,107 @@ +/** + * The notes module — the first module to own durable entities. + * + * Chosen as the second module for exactly what the globe couldn't test: a module-declared model, its + * install path, and the predicate namespace that becomes the convention the moment it ships. It is + * fully solo-testable, since a personal perspective is local-only and needs no neighbourhood sync. + */ +import { NOTE_PREDICATES, notesModule } from '@we/module-notes'; +import { checkModuleCompatibility } from '@we/schema-shared'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { moduleRegistry, moduleStores } from '../src/shared/registries/moduleRegistry'; +import { registerCoreSlots, slotRegistry } from '../src/shared/registries/slotRegistry'; + +const host = { backend: 'ad4m', framework: 'solid' }; +const storeDeps = { + signal: (initial: T): [() => T, (next: T) => void] => { + let value = initial; + return [() => value, (next: T) => (value = next)]; + }, +}; + +beforeEach(() => { + for (const entry of slotRegistry.ordered()) slotRegistry.remove(entry.id); + for (const { definition } of moduleRegistry.all()) moduleRegistry.unregister(definition.id); + registerCoreSlots(); +}); + +describe('notes module — declared coupling', () => { + it('declares ad4m, because it owns entities and there is no manifest→SDNA compiler', () => { + // The escape hatch working as designed: entity-owning modules stay unblocked, and the coupling is + // visible at install rather than discovered later. + expect(notesModule.backends).toEqual(['ad4m']); + const plan = checkModuleCompatibility(notesModule, { backend: 'nextgraph', framework: 'solid' }); + expect(plan.compatible).toBe(false); + }); + + it('is framework-agnostic, because every piece of its UI is a fragment', () => { + // No `frameworks`, no `components`. In a fragment `Column` is a registry key rather than an + // import — so this module has no framework import to duplicate, and therefore cannot introduce + // the second-runtime hazard when modules load dynamically. + expect(notesModule.frameworks).toBeUndefined(); + expect(notesModule.components).toBeUndefined(); + expect(checkModuleCompatibility(notesModule, { backend: 'ad4m', framework: 'react' }).compatible).toBe(true); + }); + + it('declares what a user is actually agreeing to', () => { + expect(notesModule.capabilities).toEqual(['storage', 'slot:dock-right']); + }); +}); + +describe('notes module — contributions', () => { + it('registers a store, chrome, and a placeable fragment', () => { + const result = moduleRegistry.register(notesModule, host, storeDeps); + expect(result.registered).toBe(true); + + expect(moduleStores.notes).toBeDefined(); + expect(slotRegistry.get('notes:0')?.anchor).toBe('dock-right'); + expect(moduleRegistry.schemas()['notes.toggleButton']).toBeDefined(); + }); + + it('keeps panel state in the store, not node-local state', () => { + // The panel is chrome, so `$localState` would reset it on every route change — which is precisely + // what a docked panel must not do. + moduleRegistry.register(notesModule, host, storeDeps); + const store = moduleStores.notes as { open: () => boolean; toggle: () => void; close: () => void }; + + expect(store.open()).toBe(false); + store.toggle(); + expect(store.open()).toBe(true); + store.close(); + expect(store.open()).toBe(false); + }); + + it('surfaces its model for the host to install', () => { + // Declarative: the host owns the install mechanism, so the diff-before-write check lives in one + // place rather than being re-implemented per module. + moduleRegistry.register(notesModule, host, storeDeps); + expect(moduleRegistry.models()).toHaveLength(1); + }); + + it('withdraws everything on unregister', () => { + moduleRegistry.register(notesModule, host, storeDeps); + moduleRegistry.unregister('notes'); + + expect(moduleStores.notes).toBeUndefined(); + expect(slotRegistry.get('notes:0')).toBeUndefined(); + expect(moduleRegistry.models()).toHaveLength(0); + }); +}); + +describe('notes module — the predicate namespace', () => { + it('namespaces predicates by module id, never under we://', () => { + // A one-way door: predicates are how existing data is found, so changing this scheme later + // orphans every note silently — the links remain and simply stop matching. + for (const predicate of Object.values(NOTE_PREDICATES)) { + expect(predicate).toMatch(/^module:\/\/notes\/[a-z]+$/); + } + }); + + it("does not claim we://, which belongs to WE's own models", () => { + // `we://text` would collide with TextBlock.text the moment both are installed in one perspective. + for (const predicate of Object.values(NOTE_PREDICATES)) { + expect(predicate.startsWith('we://')).toBe(false); + } + }); +}); diff --git a/packages/modules/notes/package.json b/packages/modules/notes/package.json new file mode 100644 index 00000000..c6f8b42d --- /dev/null +++ b/packages/modules/notes/package.json @@ -0,0 +1,36 @@ +{ + "name": "@we/module-notes", + "version": "0.1.0", + "description": "Notes feature module \u2014 a per-space scratchpad panel", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build:steps": "tsup", + "build": "we-build", + "dev": "tsup --watch" + }, + "dependencies": {}, + "peerDependencies": { + "@we/schema-shared": "workspace:*", + "@coasys/ad4m": "*" + }, + "devDependencies": { + "@we/cli": "workspace:*", + "@we/schema-shared": "workspace:*", + "@coasys/ad4m": "0.11.0", + "tsup": "^8.0.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/modules/notes/src/Note.ts b/packages/modules/notes/src/Note.ts new file mode 100644 index 00000000..16ebf8a6 --- /dev/null +++ b/packages/modules/notes/src/Note.ts @@ -0,0 +1,43 @@ +import { Ad4mModel, Model, Property } from '@coasys/ad4m'; + +/** + * A note, scoped to whichever space it was written in. + * + * ## The predicate namespace — a one-way door + * + * `module:///`, and this is the first module to own entities, so **whatever is + * chosen here becomes the convention**. It is worth stating why rather than letting it be inferred: + * + * - **Not `we://`.** That namespace belongs to WE's own models. A module claiming `we://text` would + * collide with `TextBlock.text` the moment both are installed in one perspective. + * - **Module id in the path**, so two modules can each own a `text` property without a shared + * registry adjudicating between them. + * - **Stable forever.** Predicates are how existing data is found. Changing the scheme — even + * "improving" it — orphans every note anyone has written, silently, because the links are still + * there and simply no longer match. Version the scheme if it ever must change; never edit it in + * place. + * + * ## Why this file imports AD4M + * + * There is no manifest→SDNA compiler yet: `buildModelClasses` goes SHACL → classes, and + * `installSpaceSdna` takes decorated classes, so declaring a *new* entity means writing one. That is + * why the module declares `backends: ['ad4m']` — the escape hatch working as designed, keeping + * entity-owning modules unblocked while making the coupling visible at install rather than implicit. + * + * When the compiler lands, this becomes a neutral declaration and the predicates above become the + * AD4M adapter's binding table — generated deterministically from exactly this scheme. + */ +/** + * The predicates this module owns, named rather than inlined so the scheme is greppable and testable. + * Generated deterministically as `module:///` — the same rule an AD4M adapter would + * apply once a manifest→SDNA compiler exists. + */ +export const NOTE_PREDICATES = { + text: 'module://notes/text', +} as const; + +@Model({ name: 'Note' }) +export class Note extends Ad4mModel { + @Property({ through: NOTE_PREDICATES.text }) + text: string = ''; +} diff --git a/packages/modules/notes/src/index.ts b/packages/modules/notes/src/index.ts new file mode 100644 index 00000000..187917a1 --- /dev/null +++ b/packages/modules/notes/src/index.ts @@ -0,0 +1,171 @@ +/** + * The Notes feature module — a per-space scratchpad in a right-hand dock panel. + * + * The second module, and chosen for what it tests that the globe cannot: **module-owned entities**. + * It is fully solo-testable, because a personal perspective is local-only — no neighbourhood, no + * Holochain sync — so it exercises the install path without depending on peer connectivity. + * + * ## Fragments, not components + * + * Every piece of UI here is a `SchemaNode`. Nothing in this package imports Solid, `@we/components`, + * or any framework — `Column`, `we-button` and `we-textarea` are registry *keys* resolved by whichever + * renderer is running, so the same fragments would render under a React host. The module is Tier 1 in + * the convention: framework code only for imperative cores, and a notes panel has none. + * + * Note what that buys concretely: this module has no framework import to duplicate, so it cannot + * introduce the second-runtime hazard when modules eventually load dynamically. + * + * ## Where its state lives + * + * - **The notes themselves** — a live `$query` in the fragment. No store method, no manual + * subscription; the renderer's reactivity does it. + * - **Creating one** — `model.create`, already in the stores bag. The module ships no CRUD wrapper. + * - **Panel open/closed** — the store, because this is *chrome*. `$localState` is per-node and would + * reset the panel every time the route changed, which is exactly what a docked panel must not do. + * + * The store's reactivity is injected (`deps.signal`) rather than imported, the same port trick that + * keeps `@we/schema-shared` framework-neutral. + */ +import { defineModule, type ModuleStoreDeps, type SchemaNode } from '@we/schema-shared'; + +import { Note, NOTE_PREDICATES } from './Note'; + +export { Note, NOTE_PREDICATES }; + +/** The docked panel. Chrome, so its open state is module state rather than node-local. */ +const panel: SchemaNode = { + type: '$if', + props: { + condition: { $store: 'modules.notes.open' }, + then: { + type: 'Column', + props: { + position: 'fixed', + top: '0', + right: '0', + width: '320px', + height: '100%', + bg: 'neutral-0', + borderLeft: '1px solid neutral-200', + p: '400', + gap: '400', + zIndex: 'sticky', + }, + children: [ + { + type: 'Row', + props: { ax: 'between', ay: 'center' }, + children: [ + { type: 'we-text', props: { variant: 'heading-sm' }, children: ['Notes'] }, + { + type: 'we-button', + props: { variant: 'ghost', size: 'sm', onClick: { $action: 'modules.notes.close' } }, + children: [{ type: 'we-icon', props: { name: 'x' } }], + }, + ], + }, + { + type: 'Column', + props: { gap: '300' }, + $localState: { draft: { type: 'string', initial: '' } }, + children: [ + { + type: 'we-textarea', + props: { + value: { $local: 'draft' }, + placeholder: 'Jot something down…', + rows: 3, + onInput: { $setLocal: 'draft', from: '$event.detail' }, + }, + }, + { + type: 'we-button', + props: { + size: 'sm', + // No CRUD wrapper in this module — `model.create` is already in the stores bag, and a + // module reaching for its own persistence layer would be duplicating the data port. + onClick: [ + { $action: 'model.create', args: ['Note', { text: { $local: 'draft' } }] }, + { $setLocal: 'draft', value: '' }, + ], + }, + children: ['Add note'], + }, + ], + }, + { + type: 'we-scroll-area', + children: [ + { + type: 'Column', + props: { gap: '300' }, + children: [ + { + type: '$each', + // Live query — the renderer handles subscription and reactivity, so the module needs + // neither a notes array nor a refresh method. + props: { items: { $query: { entity: 'Note' } }, as: 'note' }, + children: [ + { + type: 'Column', + props: { bg: 'neutral-50', r: '300', p: '300', gap: '200' }, + children: [ + { type: 'we-text', children: ['$note.text'] }, + { + type: 'we-button', + props: { + variant: 'ghost', + size: 'xs', + onClick: { $action: 'model.delete', args: ['Note', '$note.id'] }, + }, + children: [{ type: 'we-icon', props: { name: 'trash' } }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }, +}; + +/** A drop-in trigger a template can place wherever it likes. */ +const toggleButton: SchemaNode = { + type: 'we-button', + props: { variant: 'ghost', size: 'sm', onClick: { $action: 'modules.notes.toggle' } }, + children: [{ type: 'we-icon', props: { name: 'note' } }], +}; + +export const notesModule = defineModule({ + id: 'notes', + name: 'Notes', + description: 'A per-space scratchpad in a docked panel.', + icon: 'note', + + // Displayed at install, never scored. "Store data in your spaces" and "add a panel to your screen" + // are the two things a user is actually agreeing to. + capabilities: ['storage', 'slot:dock-right'], + + // Declared because this module owns entities and there is no manifest→SDNA compiler yet. The + // coupling is visible at install rather than discovered later. + backends: ['ad4m'], + + // No `frameworks` — every piece of UI here is a fragment, so this module is framework-agnostic. + + models: [Note], + schemas: { toggleButton }, + slots: [{ anchor: 'dock-right', node: panel, order: 100 }], + + createStore: ({ signal }: ModuleStoreDeps) => { + const [open, setOpen] = signal(false); + return { + open, + toggle: () => setOpen(!open()), + close: () => setOpen(false), + }; + }, +}); diff --git a/packages/modules/notes/tsconfig.json b/packages/modules/notes/tsconfig.json new file mode 100644 index 00000000..f42bf3d2 --- /dev/null +++ b/packages/modules/notes/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + // Module-owned entities are @Model-decorated AD4M classes — the same requirement @we/models has, + // and the reason this module declares backends: ['ad4m']. + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"] +} diff --git a/packages/modules/notes/tsup.config.ts b/packages/modules/notes/tsup.config.ts new file mode 100644 index 00000000..cade674d --- /dev/null +++ b/packages/modules/notes/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + target: 'es2022', + splitting: false, + treeshake: true, + // Never bundled: cesium is huge, and @we/widgets must stay a single instance shared with the host. + external: ['@we/schema-shared', '@coasys/ad4m', 'solid-js'], +}); diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index b024d961..4a6fea3b 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -128,7 +128,14 @@ export type { RendererStores, } from './dataSource'; export { checkModuleCompatibility, defineModule } from './module'; -export type { ModuleCapability, ModuleCompatibility, ModuleDefinition, SlotAnchor, SlotContribution } from './module'; +export type { + ModuleCapability, + ModuleCompatibility, + ModuleDefinition, + ModuleStoreDeps, + SlotAnchor, + SlotContribution, +} from './module'; export { createInMemoryEphemeralPort, InMemoryBus, inMemoryEphemeralCapabilities, planEphemeral } from './ephemeral'; export type { EphemeralCapabilities, diff --git a/packages/schema-system/shared/src/module.ts b/packages/schema-system/shared/src/module.ts index 3cdf734d..39832e66 100644 --- a/packages/schema-system/shared/src/module.ts +++ b/packages/schema-system/shared/src/module.ts @@ -96,13 +96,41 @@ export interface ModuleDefinition { /** Persistent chrome. Rendered by the host outside the router, so it survives navigation. */ slots?: SlotContribution[]; + /** + * Durable entity types this module owns, installed by the host into the relevant dataset. + * + * Declarative on purpose: the *host* owns the install mechanism, so idempotency lives in one place + * rather than being re-implemented per module. That matters here more than it sounds — WE already + * has `cleanupSpaceSdna` as remediation for shapes that got installed twice by different agents, + * and N modules each rolling their own install is that bug with more instances. + * + * Typed `unknown[]` because the shape is the backend's: on AD4M these are `@Model`-decorated + * classes, which is why a module declaring them must also declare `backends: ['ad4m']` until a + * manifest→SDNA compiler exists. + */ + models?: unknown[]; + /** * Reactive state, exposed to templates at `modules..`. * * A factory rather than a value so the host controls lifetime, and so a module can be registered * before the host is ready to instantiate it. + * + * Reactivity primitives are **injected**, not imported — the same port trick that keeps + * `@we/schema-shared` framework-neutral (`resolveProp` taking a `memo`). A module store written + * against `deps.signal` never imports Solid, so it cannot introduce the second-runtime hazard that + * silently breaks reactivity across a dynamically-loaded boundary. */ - createStore?: () => Record; + createStore?: (deps: ModuleStoreDeps) => Record; +} + +/** + * The reactivity a host lends a module's store, so the module needn't import a framework. + * Mirrors the `memo` injection that makes `@we/schema-shared` framework-neutral. + */ +export interface ModuleStoreDeps { + /** Returns a `[read, write]` pair — Solid's `createSignal` shape, which every framework can supply. */ + signal: (initial: T) => [() => T, (next: T) => void]; } /** Identity function that exists for inference and for a greppable declaration site. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9297ea30..1feb63a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@we/module-globe': specifier: workspace:* version: link:../modules/globe + '@we/module-notes': + specifier: workspace:* + version: link:../modules/notes '@we/primitives': specifier: workspace:* version: link:../design-system/3-primitives @@ -884,6 +887,24 @@ importers: specifier: ^5.3.3 version: 5.9.3 + packages/modules/notes: + devDependencies: + '@coasys/ad4m': + specifier: 0.13.0-test-9 + version: 0.13.0-test-9 + '@we/cli': + specifier: workspace:* + version: link:../../cli + '@we/schema-shared': + specifier: workspace:* + version: link:../../schema-system/shared + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + packages/schema-system/frameworks/solid: dependencies: '@we/design-types': diff --git a/we-seed.json b/we-seed.json index f83e8567..62f487e7 100644 --- a/we-seed.json +++ b/we-seed.json @@ -9,7 +9,8 @@ "useQueryIR": true }, "modules": [ - "globe" + "globe", + "notes" ], "ad4m": { "dataPath": "~/.we-native-app", From a62370968e9027e60203bdd528d51b4f4bc471e4 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:41:23 +0100 Subject: [PATCH 07/10] fix(modules): register module-owned model classes, not just their SDNA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module-owned entity needs **two** registrations, and missing either fails at a different moment: - SDNA install (`installSpaceSdna` → `ensureModelsRegistered`) puts the *shape* in the perspective. - `registerModel` puts the *class* where `model.create` / `model.delete` / `$query` can resolve it by name. Only the first was wired. So the notes panel rendered, the query returned nothing, and adding a note threw `Model "Note" not found in registry` — a failure well away from its cause, since everything visible looked correct. The gap was easy to miss because `SPACE_MODELS` registers itself in SpaceStore.tsx:75, so WE's own models were always present and the split never had to be noticed. The existing tests didn't catch it either: they asserted `moduleRegistry.models()` returned the model and that SDNA install included it, which are both true and neither of which is the thing that was broken. Adds `unregisterModel` for symmetry on module unregister. Note it detaches only the class — SDNA already installed in a perspective, and data written through it, remain. Removing those is uninstall semantics, which is a deliberate decision rather than a side effect of disabling a module. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/shared/registries/modelRegistry.ts | 11 +++++++++++ .../src/shared/registries/moduleRegistry.ts | 12 ++++++++++++ packages/app-framework/tests/notesModule.test.ts | 13 +++++++++++++ 3 files changed, 36 insertions(+) diff --git a/packages/app-framework/src/shared/registries/modelRegistry.ts b/packages/app-framework/src/shared/registries/modelRegistry.ts index 0e4a6e46..039061da 100644 --- a/packages/app-framework/src/shared/registries/modelRegistry.ts +++ b/packages/app-framework/src/shared/registries/modelRegistry.ts @@ -11,6 +11,17 @@ export function registerModel(name: string, modelClass: ModelClass): void { modelRegistry[name] = modelClass; } +/** + * Remove a globally registered model — a feature module being unregistered. + * + * Note this only detaches the *class*; any SDNA already installed in a perspective, and any data + * written through it, remain. Removing those is a separate decision (uninstall semantics) that has to + * be made deliberately rather than as a side effect of disabling a module. + */ +export function unregisterModel(name: string): void { + delete modelRegistry[name]; +} + export function getModel(name: string): ModelClass { const model = modelRegistry[name]; if (!model) throw new Error(`Model "${name}" not found in registry. Did you call registerModel()?`); diff --git a/packages/app-framework/src/shared/registries/moduleRegistry.ts b/packages/app-framework/src/shared/registries/moduleRegistry.ts index 576b7963..95806fa7 100644 --- a/packages/app-framework/src/shared/registries/moduleRegistry.ts +++ b/packages/app-framework/src/shared/registries/moduleRegistry.ts @@ -26,6 +26,7 @@ import type { ModuleDefinition, ModuleStoreDeps } from '@we/schema-shared'; import { checkModuleCompatibility } from '@we/schema-shared'; +import { type ModelClass, registerModel, unregisterModel } from './modelRegistry'; import { slotRegistry } from './slotRegistry'; export interface RegisteredModule { @@ -80,6 +81,14 @@ export const moduleRegistry = { modules.set(definition.id, { definition, store }); if (store) moduleStores[definition.id] = store; + // Two registrations are needed for a module-owned entity, and missing either fails at a + // different moment: SDNA install (in `installSpaceSdna`) puts the *shape* in the perspective, + // while this puts the *class* where `model.create` / `$query` can resolve it by name. Without + // this one the panel renders and only writing a note fails. + for (const model of (definition.models ?? []) as ModelClass[]) { + registerModel((model as unknown as { className: string }).className, model); + } + for (const [index, slot] of (definition.slots ?? []).entries()) { slotRegistry.register({ ...slot, @@ -95,6 +104,9 @@ export const moduleRegistry = { const entry = modules.get(id); if (!entry) return; for (const index of (entry.definition.slots ?? []).keys()) slotRegistry.remove(`${id}:${index}`); + for (const model of (entry.definition.models ?? []) as ModelClass[]) { + unregisterModel((model as unknown as { className: string }).className); + } delete moduleStores[id]; modules.delete(id); }, diff --git a/packages/app-framework/tests/notesModule.test.ts b/packages/app-framework/tests/notesModule.test.ts index ce87180d..af291f1c 100644 --- a/packages/app-framework/tests/notesModule.test.ts +++ b/packages/app-framework/tests/notesModule.test.ts @@ -9,6 +9,7 @@ import { NOTE_PREDICATES, notesModule } from '@we/module-notes'; import { checkModuleCompatibility } from '@we/schema-shared'; import { beforeEach, describe, expect, it } from 'vitest'; +import { getModel } from '../src/shared/registries/modelRegistry'; import { moduleRegistry, moduleStores } from '../src/shared/registries/moduleRegistry'; import { registerCoreSlots, slotRegistry } from '../src/shared/registries/slotRegistry'; @@ -72,6 +73,18 @@ describe('notes module — contributions', () => { expect(store.open()).toBe(false); }); + it('resolves its model by name, so model.create can actually write a note', () => { + // Two registrations are needed and they fail at different moments. SDNA install puts the *shape* + // in the perspective; this puts the *class* where `model.create('Note', …)` and `$query` resolve + // it. Missing this one, the panel renders fine and only adding a note throws — which is exactly + // the bug the first version of this module shipped with. + moduleRegistry.register(notesModule, host, storeDeps); + expect(() => getModel('Note')).not.toThrow(); + + moduleRegistry.unregister('notes'); + expect(() => getModel('Note')).toThrow(/not found in registry/); + }); + it('surfaces its model for the host to install', () => { // Declarative: the host owns the install mechanism, so the diff-before-write check lives in one // place rather than being re-implemented per module. From c1232815eccf455a085103015995f6082e03ddbe Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:43:41 +0100 Subject: [PATCH 08/10] fix(modules): give notes its own launcher, so the module is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module shipped with only the expanded panel — gated on `modules.notes.open`, which starts false — plus a `toggleButton` fragment that no template placed. So it registered successfully, installed its SDNA, and was completely invisible with no way to open it. Chrome gated on state that nothing can change is not chrome. A module has to be reachable on its own, without depending on a template choosing to place its trigger: a template *may* place `toggleButton` wherever it likes, but that cannot be the only route in. The dock-right slot now renders a launcher tab when closed and the panel when open — the collapsed/expanded pair a docked panel actually is. Co-Authored-By: Claude Opus 5 (1M context) --- .../app-framework/tests/notesModule.test.ts | 9 ++++++ packages/modules/notes/src/index.ts | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/app-framework/tests/notesModule.test.ts b/packages/app-framework/tests/notesModule.test.ts index af291f1c..967c2417 100644 --- a/packages/app-framework/tests/notesModule.test.ts +++ b/packages/app-framework/tests/notesModule.test.ts @@ -60,6 +60,15 @@ describe('notes module — contributions', () => { expect(moduleRegistry.schemas()['notes.toggleButton']).toBeDefined(); }); + it('is reachable without any template cooperating', () => { + // The module shipped once with only the expanded panel plus a `toggleButton` fragment nothing + // placed — so it registered successfully and was invisible. Chrome gated on state nothing can + // change is not chrome, so the slot renders a launcher in the closed case. + moduleRegistry.register(notesModule, host, storeDeps); + const node = slotRegistry.get('notes:0')?.node as { props?: { else?: unknown } }; + expect(node.props?.else).toBeDefined(); + }); + it('keeps panel state in the store, not node-local state', () => { // The panel is chrome, so `$localState` would reset it on every route change — which is precisely // what a docked panel must not do. diff --git a/packages/modules/notes/src/index.ts b/packages/modules/notes/src/index.ts index 187917a1..24937d2c 100644 --- a/packages/modules/notes/src/index.ts +++ b/packages/modules/notes/src/index.ts @@ -32,11 +32,39 @@ import { Note, NOTE_PREDICATES } from './Note'; export { Note, NOTE_PREDICATES }; -/** The docked panel. Chrome, so its open state is module state rather than node-local. */ +/** Collapsed state — a launcher tab, so the module is reachable without any template cooperating. */ +const launcher: SchemaNode = { + type: 'we-button', + props: { + variant: 'secondary', + size: 'sm', + position: 'fixed', + right: '0', + top: '120px', + zIndex: 'sticky', + rtr: '0', + rbr: '0', + onClick: { $action: 'modules.notes.toggle' }, + }, + children: [{ type: 'we-icon', props: { name: 'note' } }], +}; + +/** + * The docked panel, with its own launcher. + * + * A module has to be reachable on its own. Shipping only the expanded panel plus a `toggleButton` + * fragment left no entry point at all until some template chose to place that fragment — so the + * module was installed, registered and invisible. Chrome that gates itself on state nothing can + * change is not chrome. + * + * `toggleButton` is still exported for templates that want the trigger somewhere of their own + * choosing; this is the fallback that guarantees the module is usable without one. + */ const panel: SchemaNode = { type: '$if', props: { condition: { $store: 'modules.notes.open' }, + else: launcher, then: { type: 'Column', props: { From f12d8af1ceb9f40af1dd7cc8a39e87a4964f7896 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:45:51 +0100 Subject: [PATCH 09/10] fix(schema-shared): $action resolves a namespaced path, not just store.method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `token.$action.split('.')` destructured exactly two segments, so `modules.notes.toggle` resolved `stores.modules.notes` — an object rather than a function. The handler was silently dropped: the button rendered, clicked, and did nothing, with no error anywhere. That is a direct consequence of choosing `modules..*` as the store namespace without checking that $action supported the depth. `$store` has always walked arbitrary depth via walkPath, so the two resolvers disagreed about what a store path is — and only $store's version was ever exercised, because every existing store sat at the top level. Now the last segment is the method and everything before it is the path to its owner. Accessors are deliberately *not* invoked while walking, unlike `$store`'s walkPath: a store namespace is a plain object, and calling a signal on the way to a method would be wrong. Warnings now name the full path rather than just the first segment, which is what made this hard to spot — "store modules not found" would have been a lie, since `modules` was present. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared/src/propResolvers/action.test.ts | 79 +++++++++++++++++++ .../shared/src/propResolvers/action.ts | 27 +++++-- 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 packages/schema-system/shared/src/propResolvers/action.test.ts diff --git a/packages/schema-system/shared/src/propResolvers/action.test.ts b/packages/schema-system/shared/src/propResolvers/action.test.ts new file mode 100644 index 00000000..2c11bdd2 --- /dev/null +++ b/packages/schema-system/shared/src/propResolvers/action.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveProp } from './dispatcher'; +import type { Props } from './types'; + +function resolve(token: unknown, stores: Props) { + return resolveProp(token, stores, {}); +} + +describe('$action path resolution', () => { + it('resolves the classic store.method form', () => { + const save = vi.fn(); + const handler = resolve({ $action: 'myStore.save' }, { myStore: { save } }); + + expect(typeof handler).toBe('function'); + (handler as () => void)(); + expect(save).toHaveBeenCalled(); + }); + + it('resolves a namespaced store.sub.method form', () => { + // Two segments used to be assumed, so `modules.notes.toggle` resolved `stores.modules.notes` — + // an object rather than a function — and the handler was silently dropped. The button rendered, + // clicked, and did nothing, with no error anywhere. + const toggle = vi.fn(); + const handler = resolve({ $action: 'modules.notes.toggle' }, { modules: { notes: { toggle } } }); + + expect(typeof handler).toBe('function'); + (handler as () => void)(); + expect(toggle).toHaveBeenCalled(); + }); + + it('resolves arbitrary depth, matching what $store has always allowed', () => { + const deep = vi.fn(); + const handler = resolve({ $action: 'a.b.c.d.run' }, { a: { b: { c: { d: { run: deep } } } } }); + + (handler as () => void)(); + expect(deep).toHaveBeenCalled(); + }); + + it('passes resolved args through', () => { + const add = vi.fn(); + const handler = resolve( + { $action: 'modules.notes.add', args: ['hello', { $store: 'cfg.tag' }] }, + { modules: { notes: { add } }, cfg: { tag: 'urgent' } }, + ); + + (handler as () => void)(); + expect(add).toHaveBeenCalledWith('hello', 'urgent'); + }); + + it('warns naming the full path when the owner is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolve({ $action: 'modules.absent.toggle' }, { modules: {} }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('modules.absent')); + warn.mockRestore(); + }); + + it('warns naming the method when the owner exists but the method does not', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolve({ $action: 'modules.notes.nope' }, { modules: { notes: {} } }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope')); + warn.mockRestore(); + }); + + it('does not invoke accessors while walking to the method', () => { + // Unlike `$store`'s walkPath, which calls signal accessors at each step to unwrap values, the + // path to a method must not be invoked — a store namespace is a plain object, and calling a + // signal on the way through would be wrong. + const signal = vi.fn(() => 'value'); + const run = vi.fn(); + const handler = resolve({ $action: 'modules.notes.run' }, { modules: { notes: { run, open: signal } } }); + + (handler as () => void)(); + expect(run).toHaveBeenCalled(); + expect(signal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/schema-system/shared/src/propResolvers/action.ts b/packages/schema-system/shared/src/propResolvers/action.ts index 93dab145..b4e2f682 100644 --- a/packages/schema-system/shared/src/propResolvers/action.ts +++ b/packages/schema-system/shared/src/propResolvers/action.ts @@ -101,15 +101,30 @@ export function resolveActionProp( onFinally?: unknown[]; }; - // Split the $action string into store name and method name - const [storeName, methodName] = token.$action.split('.'); + // Split the $action string into a path, taking the **last** segment as the method and everything + // before it as the path to the object holding it. + // + // Two segments used to be assumed (`store.method`), which quietly broke any namespaced store — + // `modules.notes.toggle` resolved `stores.modules.notes`, an object rather than a function, so the + // handler was silently dropped and the button did nothing. `$store` has always walked arbitrary + // depth, so this also removes an inconsistency between the two. + const segments = token.$action.split('.'); + const methodName = segments[segments.length - 1]; + const ownerPath = segments.slice(0, -1); + const storeName = ownerPath[0]; // Retrieve args and resolve any expressions within them (but not $arg tokens yet) const args = token.args ?? []; const resolvedArgs = args.map((arg) => resolvePropFn(arg, stores, context, memo)); - // Get the method from the store - const store = stores[storeName] as Props | undefined; + // Walk to the object that owns the method. Accessors are *not* called here, unlike `$store`'s + // walkPath: a store namespace is a plain object, and calling a signal on the way to a method would + // be wrong. + let owner: unknown = stores; + for (const segment of ownerPath) { + owner = (owner as Props | undefined)?.[segment]; + } + const store = owner as Props | undefined; const method = store?.[methodName]; // Return a callable function if the method exists @@ -172,6 +187,6 @@ export function resolveActionProp( } // Warn on missing store or method for debuggability - if (!store) console.warn(`Schema $action: store "${storeName}" not found`); - else if (!method) console.warn(`Schema $action: method "${methodName}" not found on store "${storeName}"`); + if (!store) console.warn(`Schema $action: store "${ownerPath.join('.')}" not found`); + else if (!method) console.warn(`Schema $action: method "${methodName}" not found on store "${ownerPath.join('.')}"`); } From fceeefc79ea3c00dcf9094e226350c23762572d5 Mon Sep 17 00:00:00 2001 From: jhweir Date: Sat, 1 Aug 2026 16:48:34 +0100 Subject: [PATCH 10/10] fix(modules): install module SDNA into existing spaces, and scope notes to a space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults, one of them a design gap rather than a bug. **Module shapes never reached an existing space.** `switchPerspective` only installs SDNA into a perspective with *no* shapes at all — deliberately, so a foreign perspective (a Flux community with its own shapes) is never silently converted into a WE space. But that rule cannot apply to modules, because a module can be enabled **after** a space exists, which is the normal case the moment modules are installable rather than bundled. So every existing space failed any module query with "No SHACL shape stored for class 'Note'" from a perspective that otherwise looked perfectly healthy. `installModuleSdna` is therefore separate from `installSpaceSdna` and runs on every switch into a WE space. `ensureModelsRegistered` diffs before writing, so it is a read in the common case, and idempotency stays in one shared path — the reason `cleanupSpaceSdna` exists is shapes installed twice, and per-module install routines would be that bug with more instances. **The panel was app-level chrome over space-level data.** Notes are written into the current dataset, so offering the panel where there is no dataset is an invitation to lose what you typed. It now hides entirely outside a space. That is the crude version of a question the module system hasn't answered: *which* spaces should show it. `Space.enabledModules` is the real answer — a community turning a module on for its space — and it arrives with the marketplace alongside consent. Until then a module's chrome appears in every space, which is acceptable while modules are first-party and bundled, but it is a gap rather than a decision. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/frameworks/solid/stores/AdamStore.tsx | 14 ++++++++- .../app-framework/src/shared/sdnaModels.ts | 29 +++++++++++++++---- packages/modules/notes/src/index.ts | 15 ++++++++-- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/app-framework/src/frameworks/solid/stores/AdamStore.tsx b/packages/app-framework/src/frameworks/solid/stores/AdamStore.tsx index 8a8feb5c..a685050a 100644 --- a/packages/app-framework/src/frameworks/solid/stores/AdamStore.tsx +++ b/packages/app-framework/src/frameworks/solid/stores/AdamStore.tsx @@ -11,7 +11,13 @@ import { import { buildModelClasses, buildModelManifest, getForeignShacl } from '@shared/perspectiveHelpers'; import { usePlatform } from '@shared/platform'; import { registerDynamicModels } from '@shared/registries/modelRegistry'; -import { deduplicateSpaceSdna, installRootSdna, installSpaceSdna, isModelRegistered } from '@shared/sdnaModels'; +import { + deduplicateSpaceSdna, + installModuleSdna, + installRootSdna, + installSpaceSdna, + isModelRegistered, +} from '@shared/sdnaModels'; import { isSpaceSelf, type LocationData, @@ -1296,6 +1302,12 @@ export function AdamStoreProvider(props: ParentProps) { await new Promise((resolve) => setTimeout(resolve, 500)); isWeSpace = await isModelRegistered(perspective, Space); } + } else { + // An existing WE space skips the install above by design, so a module enabled after the space + // was created would find its shapes missing — a query failing with "No SHACL shape stored for + // class X" in a perspective that otherwise looks healthy. Module shapes therefore install on + // every switch; `ensureModelsRegistered` diffs first, so this is a read in the common case. + await installModuleSdna(perspective); } // SDNA is installed — switch immediately so WE templates render. WE model classes diff --git a/packages/app-framework/src/shared/sdnaModels.ts b/packages/app-framework/src/shared/sdnaModels.ts index d4acb295..e6244f84 100644 --- a/packages/app-framework/src/shared/sdnaModels.ts +++ b/packages/app-framework/src/shared/sdnaModels.ts @@ -157,12 +157,31 @@ export const SPACE_MODELS = [ * are written. */ export async function installSpaceSdna(p: PerspectiveProxy): Promise { - // Module-owned entities install by the same path as WE's own, so the diff-before-write check that - // `ensureModelsRegistered` performs applies to both. That single shared path is the whole point: - // `cleanupSpaceSdna` exists because shapes once got installed twice, and N modules each rolling - // their own install would be that bug with more instances. + await ensureModelsRegistered(p, SPACE_MODELS); + await installModuleSdna(p); +} + +/** + * Install the shapes of every registered feature module into a perspective. + * + * Separate from `installSpaceSdna` because the two have genuinely different lifetimes. WE's own + * models are installed once, when a space is created or first joined — after which + * `switchPerspective` deliberately skips reinstalling them (it only installs into a perspective with + * *no* SDNA at all, so a foreign perspective is never silently converted into a WE space). + * + * A module's shapes cannot follow that rule, because a module can be enabled **after** a space + * already exists — which is the normal case the moment modules are installable rather than bundled. + * So this runs on every switch into a WE space, relying on `ensureModelsRegistered` to diff before + * writing. Without it, enabling a module leaves every existing space unable to query its entities: + * "No SHACL shape stored for class X", from a perspective that looks perfectly healthy. + * + * Idempotency lives in one shared path rather than per module deliberately — `cleanupSpaceSdna` + * exists because shapes once got installed twice by different agents, and N modules each rolling + * their own install would be that bug with more instances. + */ +export async function installModuleSdna(p: PerspectiveProxy): Promise { const moduleModels = moduleRegistry.models() as (typeof Ad4mModel)[]; - await ensureModelsRegistered(p, [...SPACE_MODELS, ...moduleModels]); + if (moduleModels.length) await ensureModelsRegistered(p, moduleModels); } /** diff --git a/packages/modules/notes/src/index.ts b/packages/modules/notes/src/index.ts index 24937d2c..b4af2a9e 100644 --- a/packages/modules/notes/src/index.ts +++ b/packages/modules/notes/src/index.ts @@ -63,8 +63,19 @@ const launcher: SchemaNode = { const panel: SchemaNode = { type: '$if', props: { - condition: { $store: 'modules.notes.open' }, - else: launcher, + // Nothing at all outside a space. Notes are written into the current dataset, so the panel is + // only meaningful where there is one — offering it on a screen with nowhere to save to would be + // an invitation to lose what you typed. + // + // This is the crude version of a question the module system hasn't answered yet: *which* spaces + // should show it. `Space.enabledModules` is the real answer — a community turning the module on + // for its space — and it arrives with the marketplace, alongside consent. Until then a module's + // chrome appears in every space, which is fine while modules are first-party and bundled. + condition: { $and: [{ $store: 'adamStore.currentPerspective' }, { $store: 'modules.notes.open' }] }, + else: { + type: '$if', + props: { condition: { $store: 'adamStore.currentPerspective' }, then: launcher }, + }, then: { type: 'Column', props: {