Feature: Feature Modules - #98
Merged
Merged
Conversation
The rung above blocks: what a module contributes, and what a host must accept. Declared in the neutral package for the same reason dataSource.ts is — a module must be able to describe itself without importing a host, a framework, or a backend. **Framework code is optional, not assumed.** `components` is the only field that can hold framework-specific values. A module shipping schema fragments only imports nothing framework-shaped, because in a fragment `Column` is a registry key rather than an import — so the fragment renders on any framework whose renderer registers that key. That is not just tidy: an externally-loaded bundle carrying its own reactive runtime gets a *second* one, and reactivity silently stops crossing the boundary. Fragments-first is what makes dynamic loading tractable later. **Omitted `backends`/`frameworks` mean agnostic.** Making the portable case the default is what forces coupling to be opted into and declared. An entity-owning module must currently say `backends: ['ad4m']` — there is no manifest→SDNA compiler, so its models are decorated classes. That is the escape hatch working as intended: entity-owning modules stay unblocked while the coupling is visible at install. **Capabilities are declared, not enforced.** Nothing prevents a module calling getUserMedia without saying so. They exist to be *displayed* — the browser's model of showing the request and the origin, never a computed risk score, since a score derived from unenforced declarations manufactures false confidence. They are the hook enforcement attaches to if a permission broker is built. checkModuleCompatibility mirrors planQuery/planEphemeral: refuse loudly at registration rather than half-mounting something that cannot work, and report every problem at once so an install prompt can show them together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e registry
Generalises `shellRegistry` — three named keys typed `typeof shellRegistry` —
into an open `slotRegistry`, because a module must be able to *add* chrome
rather than override one of three. A call bar, a notifications tray, a
mini-player and an offline banner all want the same thing.
**Anchors order and group; they do not position.** This is what makes it a
faithful generalisation rather than a rewrite. WE's shell nodes position
themselves — bootScreen is a full-bleed $if, sidebar is a CollapsibleSidebar
carrying its own position prop — so wrapping each anchor in a container would
change how all three render. An anchor is semantic metadata that fixes order in
the flat output; the node still positions itself. `overlay` sorts first
specifically to reproduce today's [bootScreen, sidebar, templateEditor], and a
test asserts exactly that.
Ordering falls back to declared `order`, then to **id**. Entries come out of a
Map, so without the tiebreak equal-order chrome would rearrange depending on
which module happened to load first — the same reason presence sorts on
agentId.
The seed's white-label override moves from assigning `shellRegistry.bootScreen`
to `slotRegistry.replace('core:bootScreen', node)`. Deployment-level
configuration of app-level chrome: the layer whose scope matches, unlike a
per-space template, which has no channel into the shell and shouldn't.
`moduleRegistry` is the fourth registry beside appRegistry, modelRegistry,
templateRegistry and themeRegistry, and deliberately the same shape — modules
become the next thing following an existing pattern rather than a new concept.
Registering fans a definition out to the registries that already exist and
holds the module's store under `modules.<id>`. It refuses an incompatible
module loudly rather than half-mounting it, and applies nothing partially.
`modules: {}` is always in the stores bag even with no modules registered.
$store's single-segment path indexes the store object without a guard, so
`{ $store: 'modules.notes' }` would throw on a missing key rather than
returning undefined — and returning undefined is precisely what makes
`$if` on `modules.<id>` the supported way for a template to depend on an
optional module.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`packages/modules/globe/` — the sufficiency proof for the contribution points, and deliberately a conversion rather than a new feature. The globe already worked, so its behaviour is a fixed reference: if the module system can carry something built before the module system existed, the seams are real. Writing a new feature to test new seams means a failure can't be attributed to either. **Only the wiring moved.** CesiumGlobe stays in @we/widgets — it passes the props-only test, since layerFactoryRegistry is injected and its LayerStore is a private Map rather than a WE store. The layers stay in @we/cesium-layers. What moved is the layer set and the component wrapper, previously scattered through componentRegistry.tsx, which no longer imports @we/cesium-layers at all. **The module owns no store**, and that is the honest outcome rather than an omission: layer visibility is `$local` state in the route schema, so inventing a store would be new behaviour and would break the "identical afterwards" property the conversion exists to demonstrate. Worth having as the first example so nobody assumes stores are mandatory. createGlobeModule takes the widget as an argument rather than importing it, so the package never pulls Solid or @we/widgets into its own bundle — the host passes in the component it already has. That preserves the single-instance guarantee that starts to matter once modules load dynamically. It exercises defineModule, the registry, a **module-private sub-registry** (the layer factories — a shape the call module will never test), a framework component contributed for a genuinely imperative core, and compatibility declaration. It does not exercise shell slots (a route is not chrome; the host-slot migration covers those) or the ephemeral port. Rendering needs WebGL and a Cesium Ion token, so visual verification is manual. What is asserted is the part that actually moved: the layer set resolves identically from its new owner, every entry is a callable factory, and the module declares itself honestly — backend-agnostic because it owns no entities, solid-only because its imperative core really is a framework component. Adds packages/modules/* to the workspace globs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An unrecognised type threw, which took down the **whole render** rather than one node. So a template referencing a component from a feature module that isn't enabled produced a blank page — exactly the failure the module system's optional-dependency story depends on not happening, and the reason a template declaring `requiresModules` or `$if`-ing on `modules.<id>` could never have been a complete answer on its own. Now it fails the way the rest of the system does: loud, but scoped. A visible placeholder naming the component and the likely cause, plus a console error, while its siblings render normally. Dev-time loudness is not lost. `we-validate-schemas` already catches unknown types before runtime, which is the right place to catch a typo — 13 schemas validate on every run. What changes is only the runtime, where one bad node should cost that node and nothing more. Updates the test that pinned the old behaviour, and adds one to the portable slice proving siblings survive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Makes the globe an actually-registered module rather than a package the app happens to import. `we-seed.json` gains a `modules: ['globe']` key — "which modules to include" is already in the seed's stated purpose, so this is the deployment layer of the enablement story. The per-agent and per-space halves (AgentSettings.installedModules, Space.enabledModules) arrive with the marketplace, when modules become installable rather than bundled. Bundled modules are **factories, not definitions**, because a module may need something from the host to describe itself. The globe takes CesiumGlobe as an argument so its own package never imports Solid or @we/widgets — which keeps those single instances shared with the host, the property that starts to matter the moment modules load dynamically. That injection is also why `initializeIntegrations` now takes a deps argument rather than importing the component registry: it lives in framework-neutral `shared/`, and importing the Solid registry there would drag the whole component tree into it. `platform/context.tsx` hands the components over, where the framework is already known. Activation reports rather than throws, and distinguishes two faults that are easy to collapse: **missing** (the seed names a module this build lacks) from **refused** (present, but incompatible with this host). Collapsing them would send someone hunting for a packaging problem that isn't there. A silently missing module would otherwise surface much later as an unexplained missing component — which is precisely the confusion the renderer's new placeholder has to name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second module, chosen for exactly what the globe could not test: a module-declared model, its install path, and the predicate namespace. Fully solo-testable, because a personal perspective is local-only — no neighbourhood, no Holochain sync — so it exercises the install path without peer connectivity. **It is framework-agnostic despite shipping a whole panel.** Every piece of UI is a SchemaNode; nothing in the package imports Solid or @we/components, because in a fragment `Column` is a registry key rather than an import. That makes it Tier 1 of the convention, and it means this module has no framework import to duplicate — so it cannot introduce the second-runtime hazard when modules eventually load dynamically. **It ships almost no code.** Notes are a live `$query` in the fragment; creating and deleting go through `model.create`/`model.delete`, already in the stores bag. A module reaching for its own persistence layer would be duplicating the data port. The store holds only panel open/closed — and that *is* store state rather than `$localState`, because the panel is chrome and node-local state would reset it on every route change. Two contract additions the module forced, which is what a foil is for: - **`models`** — declarative, so the *host* owns the install mechanism. Module models now install through the same `ensureModelsRegistered` path as WE's own, which diffs against the perspective before writing. Idempotency in one place matters here specifically: `cleanupSpaceSdna` exists because shapes once got installed twice by different agents, and N modules each rolling their own install is that bug with more instances. - **`ModuleStoreDeps`** — reactivity is *injected*, not imported, the same port trick that keeps @we/schema-shared framework-neutral. Solid's createSignal already has the [read, write] shape, so the host lends it and the module stays framework-free. `backends: ['ad4m']` is declared, because owning entities means writing @Model-decorated classes until a manifest→SDNA compiler exists. The escape hatch working as designed: unblocked, with the coupling visible at install. NOTE_PREDICATES names the scheme rather than inlining it, because this is the first module to own entities and **whatever it picks becomes the convention**. `module://<moduleId>/<property>` — never `we://`, which belongs to WE's own models and would collide with TextBlock.text once both are in one perspective. Predicates are how existing data is found, so changing the scheme later orphans every note silently; a test pins the shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A module-owned entity needs **two** registrations, and missing either fails at a different moment: - SDNA install (`installSpaceSdna` → `ensureModelsRegistered`) puts the *shape* in the perspective. - `registerModel` puts the *class* where `model.create` / `model.delete` / `$query` can resolve it by name. Only the first was wired. So the notes panel rendered, the query returned nothing, and adding a note threw `Model "Note" not found in registry` — a failure well away from its cause, since everything visible looked correct. The gap was easy to miss because `SPACE_MODELS` registers itself in SpaceStore.tsx:75, so WE's own models were always present and the split never had to be noticed. The existing tests didn't catch it either: they asserted `moduleRegistry.models()` returned the model and that SDNA install included it, which are both true and neither of which is the thing that was broken. Adds `unregisterModel` for symmetry on module unregister. Note it detaches only the class — SDNA already installed in a perspective, and data written through it, remain. Removing those is uninstall semantics, which is a deliberate decision rather than a side effect of disabling a module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module shipped with only the expanded panel — gated on `modules.notes.open`, which starts false — plus a `toggleButton` fragment that no template placed. So it registered successfully, installed its SDNA, and was completely invisible with no way to open it. Chrome gated on state that nothing can change is not chrome. A module has to be reachable on its own, without depending on a template choosing to place its trigger: a template *may* place `toggleButton` wherever it likes, but that cannot be the only route in. The dock-right slot now renders a launcher tab when closed and the panel when open — the collapsed/expanded pair a docked panel actually is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e.method
`token.$action.split('.')` destructured exactly two segments, so
`modules.notes.toggle` resolved `stores.modules.notes` — an object rather than
a function. The handler was silently dropped: the button rendered, clicked, and
did nothing, with no error anywhere.
That is a direct consequence of choosing `modules.<id>.*` as the store
namespace without checking that $action supported the depth. `$store` has
always walked arbitrary depth via walkPath, so the two resolvers disagreed
about what a store path is — and only $store's version was ever exercised,
because every existing store sat at the top level.
Now the last segment is the method and everything before it is the path to its
owner. Accessors are deliberately *not* invoked while walking, unlike
`$store`'s walkPath: a store namespace is a plain object, and calling a signal
on the way to a method would be wrong.
Warnings now name the full path rather than just the first segment, which is
what made this hard to spot — "store modules not found" would have been a lie,
since `modules` was present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es to a space Two faults, one of them a design gap rather than a bug. **Module shapes never reached an existing space.** `switchPerspective` only installs SDNA into a perspective with *no* shapes at all — deliberately, so a foreign perspective (a Flux community with its own shapes) is never silently converted into a WE space. But that rule cannot apply to modules, because a module can be enabled **after** a space exists, which is the normal case the moment modules are installable rather than bundled. So every existing space failed any module query with "No SHACL shape stored for class 'Note'" from a perspective that otherwise looked perfectly healthy. `installModuleSdna` is therefore separate from `installSpaceSdna` and runs on every switch into a WE space. `ensureModelsRegistered` diffs before writing, so it is a read in the common case, and idempotency stays in one shared path — the reason `cleanupSpaceSdna` exists is shapes installed twice, and per-module install routines would be that bug with more instances. **The panel was app-level chrome over space-level data.** Notes are written into the current dataset, so offering the panel where there is no dataset is an invitation to lose what you typed. It now hides entirely outside a space. That is the crude version of a question the module system hasn't answered: *which* spaces should show it. `Space.enabledModules` is the real answer — a community turning a module on for its space — and it arrives with the marketplace alongside consent. Until then a module's chrome appears in every space, which is acceptable while modules are first-party and bundled, but it is a gap rather than a decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
❌ Deploy Preview for coasys-we failed. Why did it fail? →
|
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR: Feature Modules — The Missing Rung
Summary
WE's vision names feature modules as a distinct contribution type, a design guide specifies
packages/modules/<name>/in detail, and the marketplace plan lists four distributable types — noneof which is a feature module.
This PR builds the rung. A feature module is the thing above blocks: a bundle of stateful
capability that installs into a space and can be placed by a template — where templates and themes
are data, and elements/components/widgets are stateless presentation.
Two modules ship with it, chosen to test different halves of the contract, and both verified running
in the real app.
Branch:
feat/feature-modules· 10 commits · 39 files · +1,868 / −80 · 733 testsDesign rationale in full: feature-modules.md.
What's in it
@we/schema-shared— the contractModuleDefinition,SlotAnchor,SlotContribution,ModuleCapability,ModuleStoreDeps,defineModule,checkModuleCompatibility. Declared in the neutral package for the same reasondataSource.tsis: a module must describe itself without importing a host, a framework, or a backend.@we/app-framework— the host sideregistries/slotRegistry.tsshellRegistry; open collection of persistent chromeregistries/moduleRegistry.tsregistries/bundledModules.tstypes/seed.tsmodules: string[]The modules
@we/module-globe— the Cesium conversion. Only the wiring moved.@we/module-notes— a per-space scratchpad, the first module to own durable entities.Design decisions worth reviewing
Framework code is optional, and that is load-bearing
componentsis the only field that can hold framework-specific values, and it is optional. A moduleshipping schema fragments imports nothing framework-shaped, because in a fragment
Columnis aregistry key, not an import — so it renders on any framework whose renderer registers that key.
That is not tidiness. An externally-loaded bundle carrying its own reactive runtime gets a second
one, and reactivity silently stops crossing the boundary — no error, just components that never
update. Fragments-first is what makes dynamic loading tractable later, and notes proves it is
practical: an entire panel with zero framework imports.
Omitted means agnostic
backendsandframeworksomitted mean portable. Making the portable case the default is what forcescoupling to be opted into and declared rather than happening quietly. Notes declares
backends: ['ad4m']because owning entities means writing@Model-decorated classes until amanifest→SDNA compiler exists — the escape hatch working as designed: unblocked, with the coupling
visible at install.
Capabilities are declared, not enforced
Nothing prevents a module calling
getUserMediawithout saying so. They exist to be displayed —the browser's model of showing the request and the origin, never a computed risk score, since a score
derived from unenforced declarations manufactures false confidence. They are the hook enforcement
attaches to if a permission broker is ever built.
Anchors order and group; they do not position
The faithful-generalisation constraint. WE's shell nodes position themselves —
bootScreenis afull-bleed
$if,sidebarcarries its ownpositionprop — so wrapping each anchor in a containerwould have changed how all three render.
overlaysorts first specifically to reproduce the previous[bootScreen, sidebar, templateEditor]order, and a test pins it.Ordering falls back to declared
order, then id. Entries come from aMap, so without thetiebreak chrome would rearrange depending on which module loaded first.
Templates cannot configure shell chrome
Resolved to no, on scope rather than safety: shell chrome is app-lifetime and cross-space, a
template is per-space, so "this template hides the call bar" is incoherent the moment you navigate
into a space whose template disagrees while the call lives elsewhere.
TemplateProviderrenders theshell outside the keyed Router precisely so it survives template switches.
This deleted a whole design surface: no
$shelldirective, no per-contribution override levels, notemplate-facing slot API. Seed, agent preference, and the module's own state are the three
configuration paths.
The predicate namespace is a one-way door
module://<moduleId>/<property>, named inNOTE_PREDICATESrather than inlined, because this is thefirst module to own entities and whatever it picks becomes the convention. Never
we://, whichbelongs to WE's own models and would collide with
TextBlock.textonce both are in one perspective.Predicates are how existing data is found, so changing the scheme later orphans everything silently.
What the build changed from the plan
storeRegistrywas dropped as unnecessary.moduleStoresalready puts module state in the bagunder
modules.<id>.models(declarative, so the host owns theinstall mechanism and idempotency lives in one place) and
ModuleStoreDeps(reactivity injectedrather than imported, the same port trick that keeps
@we/schema-sharedframework-neutral).consumer. Everything is bundled and seed-declared, so there is no install path to gate.
Testing
Verified: 601 tests in
@we/schema-shared, 39 in@we/schema-solid, 132 in@we/app-framework.All packages typecheck, lint clean, 13 template schemas validate.
Verified live, four times: the globe after conversion, the globe after seed-driven activation, the
notes launcher, and finally notes persisting a note in a personal space — which exercises the whole
chain: seed activation → module registration → SDNA install → model-class registration → live
$query→model.create.Four bugs, all in seams, none caught by tests
Every one was found by running the app, and every one sat between two things each verified in
isolation:
Model "Note" not found in registry— SDNA install puts the shape in the perspective;registerModelputs the class wheremodel.createresolves it. Only the first was wired.panel was gated on state nothing could change.
$actiondestructured exactly two segments, somodules.notes.toggleresolved an object rather than a function. A consequence of choosingmodules.<id>.*without checking$actionsupported the depth;$storehad always walkedarbitrary depth, so the two resolvers disagreed and only one was ever exercised.
No SHACL shape stored for class 'Note'—switchPerspectiveinstalls SDNA only into aperspective with no shapes, so a foreign perspective is never silently converted. Modules cannot
follow that rule, because a module can be enabled after a space exists.
A green suite says nothing about a join. It is also a retrospective argument for the
convert-something-that-works strategy: the globe's fixed behaviour was the only available reference,
and every bug that appeared was in the module the globe did not resemble.
Known follow-ups
Space.enabledModules— live debt. A community cannot turn a module on or off, so chromeappears in every space. Untidy for notes, objectionable for the call module, where a community
that never asked for video would get a call bar. Close it alongside calls.
unregisterModeldetaches the class; SDNA and data remain. A deliberatedecision, not a side effect of disabling a module.
AgentSettings.installedModules+ consent prompt, dynamic loading,meta.requiresModules,a permission broker — all marketplace-era.
What this unblocks
The call module is now the smallest remaining piece:
channel('rtc')from the ephemeral port, adock-bottomslot, a store, and its stage view as the one Tier-2 framework component. Presencealready carries the roster as a
callactivity.backend-ports-and-package-split.md is downstream of this:
its commits 5–6 extract the editor and AI as modules following the
@we/module-globepattern. Thatwill stress the module system at a scale nothing here approached — the editor alone is 7,557 LOC
against globe's ~90.