diff --git a/packages/app-framework/package.json b/packages/app-framework/package.json index e07bd96d..0a90ac9f 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,9 @@ "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:*", + "@we/module-notes": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.2", 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/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/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/initializeIntegrations.ts b/packages/app-framework/src/shared/initializeIntegrations.ts index 0ff1bc7c..dded47fa 100644 --- a/packages/app-framework/src/shared/initializeIntegrations.ts +++ b/packages/app-framework/src/shared/initializeIntegrations.ts @@ -6,14 +6,32 @@ * 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'; import type { PlatformAdapter } from './platform/types'; import { appRegistry } from './registries/appRegistry'; -import { shellRegistry } from './registries/shellRegistry'; +import { activateSeedModules } from './registries/bundledModules'; +import { moduleRegistry } from './registries/moduleRegistry'; +import { slotRegistry } from './registries/slotRegistry'; + +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; + /** Reactivity lent to module stores, so a module needn't import a framework. */ + storeDeps?: ModuleStoreDeps; +} -export function initializeIntegrations(platformAdapter: PlatformAdapter): void { +export function initializeIntegrations(platformAdapter: PlatformAdapter, deps: IntegrationDeps = {}): void { try { const seed = weSeedFile as unknown as WeSeedFile; @@ -23,9 +41,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 @@ -42,7 +61,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 ?? {}, storeDeps: deps.storeDeps }, + { 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..abb54012 100644 --- a/packages/app-framework/src/shared/platform/context.tsx +++ b/packages/app-framework/src/shared/platform/context.tsx @@ -1,4 +1,5 @@ -import { createContext, ParentComponent, useContext } from 'solid-js'; +import { componentRegistry } from '@solid/registries/componentRegistry'; +import { createContext, createSignal, ParentComponent, useContext } from 'solid-js'; import { initializeIntegrations } from '../initializeIntegrations'; import { PlatformAdapter } from './types'; @@ -8,7 +9,14 @@ 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 }, + // 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 new file mode 100644 index 00000000..df7d5f81 --- /dev/null +++ b/packages/app-framework/src/shared/registries/bundledModules.ts @@ -0,0 +1,86 @@ +/** + * 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 { 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 + * 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 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 { + 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: ActivationDeps, + host: { backend: string; framework: string }, + registry: { + register: ( + definition: ModuleDefinition, + host: { backend: string; framework: string }, + storeDeps?: ModuleStoreDeps, + ) => { 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, deps.storeDeps); + 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/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 new file mode 100644 index 00000000..95806fa7 --- /dev/null +++ b/packages/app-framework/src/shared/registries/moduleRegistry.ts @@ -0,0 +1,158 @@ +/** + * 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, 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 { + 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 }, + storeDeps?: ModuleStoreDeps, + ): 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); + } + + // 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; + + // 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, + // 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}`); + for (const model of (entry.definition.models ?? []) as ModelClass[]) { + unregisterModel((model as unknown as { className: string }).className); + } + 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 ?? {})); + }, + + /** + * 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 = {}; + 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/src/shared/sdnaModels.ts b/packages/app-framework/src/shared/sdnaModels.ts index 05fdd4fb..e6244f84 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 @@ -156,6 +158,30 @@ export const SPACE_MODELS = [ */ export async function installSpaceSdna(p: PerspectiveProxy): Promise { 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)[]; + if (moduleModels.length) await ensureModelsRegistered(p, moduleModels); } /** 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/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/app-framework/tests/moduleRegistry.test.ts b/packages/app-framework/tests/moduleRegistry.test.ts new file mode 100644 index 00000000..a2240f29 --- /dev/null +++ b/packages/app-framework/tests/moduleRegistry.test.ts @@ -0,0 +1,179 @@ +/** + * 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' }; + +/** + * 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); + 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, + storeDeps, + ); + + 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, storeDeps); + 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({}); + }); +}); diff --git a/packages/app-framework/tests/notesModule.test.ts b/packages/app-framework/tests/notesModule.test.ts new file mode 100644 index 00000000..967c2417 --- /dev/null +++ b/packages/app-framework/tests/notesModule.test.ts @@ -0,0 +1,129 @@ +/** + * 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 { getModel } from '../src/shared/registries/modelRegistry'; +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('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. + 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('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. + 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/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/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..b4af2a9e --- /dev/null +++ b/packages/modules/notes/src/index.ts @@ -0,0 +1,210 @@ +/** + * 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 }; + +/** 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: { + // 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: { + 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/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(); + }); +}); diff --git a/packages/schema-system/shared/src/index.ts b/packages/schema-system/shared/src/index.ts index a01b2301..4a6fea3b 100644 --- a/packages/schema-system/shared/src/index.ts +++ b/packages/schema-system/shared/src/index.ts @@ -127,6 +127,15 @@ export type { RendererDataBindings, RendererStores, } from './dataSource'; +export { checkModuleCompatibility, defineModule } 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.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..39832e66 --- /dev/null +++ b/packages/schema-system/shared/src/module.ts @@ -0,0 +1,166 @@ +/** + * 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[]; + + /** + * 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?: (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. */ +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 }; +} 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('.')}"`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc38554a..1feb63a8 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,12 @@ importers: '@we/models': specifier: workspace:* version: link:../models + '@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 @@ -862,6 +865,46 @@ 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/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/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 diff --git a/we-seed.json b/we-seed.json index 426af281..62f487e7 100644 --- a/we-seed.json +++ b/we-seed.json @@ -5,25 +5,24 @@ "description": "Pure WE application built with design system and schema renderer", "author": "James Weir" }, - "features": { "useQueryIR": true }, - + "modules": [ + "globe", + "notes" + ], "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 +30,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",