Refactor: Package Decomposition - #100
Merged
Merged
Conversation
…ruction
`PlatformAdapter` answered two unrelated questions: *where am I running*
(web/electron/tauri, dev or not, how to resolve an embedded app's URL) and
*how do I reach the data layer*. Those vary independently — the same web host
reaches the executor differently from electron while resolving app URLs like
neither — so every host implemented one interface for two reasons.
The practical symptom: `shared/platform/types.ts` imported `@coasys/ad4m`
purely for a return type, so any host that wanted `isDesktop` also named the
data layer.
Split into `PlatformAdapter` (no client knowledge) and `BackendConnector`
(no platform knowledge), each supplied by the host at its entry point:
<PlatformProvider platform={webPlatform} backend={ad4mConnector}>
One provider rather than two nested ones — they are supplied together at
exactly one place, and nesting would add a level to every host for no gain.
What matters is that the contracts are separate: `usePlatform()` never
surfaces a way to reach the data layer, and `useBackend()` never surfaces
where the app is running.
Host adapter files renamed to match what they now export. No behaviour
change; `AdamStore` calls `backend.connect()` / `backend.connectionDetails()`
where it called `platform.buildAd4mClient()` / `platform.getConnectionDetails()`.
Verified: app-framework typechecks clean; all three hosts typecheck clean;
146 tests pass; eslint clean on the touched paths.
…ir own packages
`@we/schema-shared` had reached 9,000 LOC across five unrelated concerns —
schema semantics, the query layer, the ephemeral and presence ports, the
module contract, and the model manifest. All three feature modules
peer-depend on it in full: `@we/module-call` is 1,961 lines of WebRTC that
needs four exports and pulls the entire schema engine, indexer and validator
to get them. A third-party module author hits that first.
Split three ways, along the lines the imports already drew:
@we/backend-shared ports + query + manifest (~2,270 LOC)
▲
│ RendererStores — one type
@we/schema-shared semantics, indexer, resolvers, validation
▲
│ SchemaNode
@we/module-shared ModuleDefinition and friends (274 LOC)
`backend-shared` imports nothing from the schema side, which is worth
preserving: ports and query are the base layer, and a backend never needs to
know how a template renders.
`module-shared` is the package a module author installs — it re-exports the
module-facing slice of the backend contract so a module declares one
dependency rather than three. `schema-shared` re-exports `backend-shared`
(compatibility, plus `types.ts` genuinely names `RendererStores`) but not
`module-shared`, which would be circular.
Each `shared/` gains a README stating what belongs in it and what doesn't.
That is the load-bearing part: the split was needed because there was no rule,
so everything shared went to the one shared place.
Also moves packages/modules → packages/module-system so the new contract has a
home beside its implementations. Path-only; package names unchanged, so no
import churn.
Verified: three contract packages typecheck and build clean; app-framework
typechecks clean; 775 tests pass across backend-shared (114), module-shared (8),
schema-shared (479), app-framework (146), module-call (28); eslint and prettier
clean.
…e package Nine files knew what a `PerspectiveProxy` was, scattered through `app-framework/src/shared/` beside host concerns: the query adapter, the ephemeral port, agent helpers, SDNA install, foreign-shape synthesis, the model registry, the manifest converter. Gathered into `@we/backend-ad4m`, the AD4M surface is finally something you can read the shape of. One edge ran the wrong way and is now inverted. `installSpaceSdna` read the host's module registry to find module-owned models; a backend adapter reaching up into the shell would have been the single edge pointing against the dependency direction. It now takes them as an argument — the caller already holds the registry, so passing `moduleRegistry.models()` costs nothing. Tests move with their code: the adapter, ephemeral-port and manifest suites to `backend-ad4m/tests`, the query corpus to `backend-shared/tests`. `@coasys/ad4m` becomes a peer dependency of one package rather than an ambient fact — though app-framework still names it directly until the store split, so the dependency-direction lint rule lands with that commit rather than this one. Verified: backend-ad4m builds and typechecks clean; app-framework typechecks clean; 260 tests pass across backend-ad4m (30), backend-shared (121), app-framework (109); eslint and prettier clean.
… executor There were three near-copies of an in-memory `DataSource` — one in the portable-ui playground, one in `schema-solid`'s tests, one inline — and they had already drifted apart. The playground's routed queries through the shared QueryIR engine; the test copy reimplemented filtering, ordering and hydration by hand. Two implementations disagreeing about what the contract means is worse than none, because each looks authoritative from where it sits. The QueryIR version wins and becomes `@we/backend-inmemory`. The hand-rolled one is deleted. Two things this buys: - **A reference adapter.** A thin `QueryAdapter` over `compileQuery` → `executeQueryIR` with an honest capability profile, exercising the same renderer path the AD4M adapter does. A change that breaks the contract now breaks here first, loudly and in milliseconds. - **Stores testable without a running executor.** Anything that only needs `DataSource` can be tested against this instead of booting an executor and waiting on a perspective. Verified: builds clean; schema-solid 39 tests pass; playground 7 pass; eslint and prettier clean.
There were two registries doing convergent jobs. `appRegistry` held
`{id, name, icon, image, url, allow}` with its own seed section, its own
activation path and its own launcher wiring; `moduleRegistry` held modules
with capabilities, gating and refusal. An embedded app is a module whose
entire contribution is an iframe — four parallel mechanisms for something
that differs only in what it contributes.
`ModuleDefinition` gains `embed?: { url, allow, image }` and the registry
gains `embeds()`. `initializeIntegrations` builds a module definition per
seed app and registers it; `appRegistry` is deleted; `AppStore` reads
`moduleRegistry.embeds()`.
What folding it in buys, beyond one less registry:
- `backends: ['ad4m']` on an embedded app is now a declaration, not an
assumption. On a host that doesn't run it, registration is refused with a
reason — instead of an iframe that mounts and waits on a handshake nobody
will answer, which today expires after thirty seconds.
- `Space.enabledModules` gates embedded apps for free.
- One capability vocabulary. The merge surfaced that the two had been using
different ones — the seed said `perspectives`/`languages`/`agents`, modules
said `microphone`/`storage`. Unified via `seedCapabilityToModule`, with
unrecognised names passing through as `data:<name>` rather than being
dropped: silently discarding a declared capability would understate what
the user is agreeing to, which is the one failure this list must not have.
`PersistentAppFrames` still owns iframe mounting. Its positioning mirrors the
template viewport and the frames must survive template switches, so routing
them through generic slot chrome would lose both — an embedded app is a
module, but its iframe is not ordinary chrome.
The AD4M credential handshake stays in AdamStore for now; it is entangled with
that file's signals and moves with the store split rather than being touched
twice.
Verified: app-framework typechecks clean; 121 tests pass, including two new
ones covering embed registration and refusal; eslint and prettier clean.
…he conventions Three cleanups the decomposition surfaced, and the document that should have prevented two of them. **`@we/utils` folded into `@we/primitives`.** 74 lines — `formatCount`, `formatDate` — with exactly one consumer. It passed every rule the conventions doc stated while failing every reason to be a package. **Two dead playgrounds removed.** `react/demo` (116 LOC, untouched since 2025-12-30) and `react/ad4m-model-testing` (1,513 LOC, 2026-05-05); both predate the query IR and the module system. **`package-conventions.md` rewritten**, because it failed to prevent either hub this PR is dismantling, and one of its rules was actively wrong: - **A test for whether something deserves to be a package at all** — optionality, enforcement, or reuse; at least one must hold. It had rules for structuring a package and none for creating one, which is how `@we/utils` came to exist. - **Grouping directories** — `frameworks/` earns its keep when the variant names don't self-identify from the parent. `schema-system/solid` needs it; `backend-system/ad4m` does not, because the parent already says what it is. The doc previously prescribed flat siblings everywhere, which the code had quietly diverged from — and the code was right. - **Pattern A vs B on optionality, not substance.** Whether a consumer can *decline* part of it is the question; "is the shared layer substantial" was a proxy that occasionally misleads. - **Dependency direction**, including the `@coasys/*` rule with the module escape hatch it must not pretend away. - **Peer dependencies and injection** — load-bearing (it is what prevents the duplicate-reactive-runtime hazard) and previously written down nowhere. `@we/cesium-layers` deliberately stays at the top level. Moving it under the globe module was considered and reverted: its own `types.ts` documents it as the import a *third-party* layer author uses, so it is a public contract, not a private detail of one module. Verified: primitives typecheck clean; 271 tests pass across app-framework, schema-solid and backend-shared; eslint clean across the workspace.
`CLAUDE.md` is the file both a newcomer and an assistant read to navigate this repo, and it had drifted into being wrong in several load-bearing places: - `@we/models` listed as "Agnostic" — it is 24 AD4M-decorated classes - `@we/schema-shared` described as "schema semantics" long after it had accreted the query layer, the ports and the module contract - no row for `backend-*`, `module-*`, or any of the three shipped modules - "AD4M wiring" pointing at `app-framework/src/`, where it no longer lives The `architecture.ts` fragment now carries the package map with the new contract packages, the note that each host supplies a `PlatformAdapter` *and* a `BackendConnector`, and — the part worth having written down where it will be read — the dependency-direction rule, including which packages may import `@coasys/*` and the module escape hatch. Regenerated outputs: CLAUDE.md, copilot-instructions.md, we-schema.mdc, schemaContext.ts, contextData.ts, context.json.
12,800 lines of `app-framework/src/shared/schemas/` were the single largest block in the hub, and almost none of it is framework code — a template is a JSON node tree. Moved to `@we/template-shell` (sidebar, settings, profile, boot screen, marketplace, about, editor chrome, module rail) and `@we/template-default` (the default and twitter space templates). This is the schema system's own thesis applied to the build: if WE's chrome is data, it should version and ship as data. A deployment white-labels the boot screen by replacing a node rather than forking the shell, and that is now true at the package level too. Two things stayed behind deliberately: - **`SchemaTests`** — its store and mutation actions are real code driving models and Solid signals to exercise the renderer. A developer surface, not content. - **The `.glb` and logo assets** — used by shell components, not by templates. The CTA images moved with the about page that references them. `createSpaceModal` moved to `@we/template-shell` since both packages use it and the shell is the more foundational of the two. Verified: both content packages build with DTS; app-framework typechecks clean; 590 tests pass; the web app's production Vite build succeeds — which is the check that matters here, since it resolves the asset imports the DTS step does not.
The notes module shipped with `module://notes/text`, and its own doc comment names why that choice is a one-way door: predicates are how existing data is found, so changing the scheme later orphans every note silently — the links remain and simply stop matching. Changing it now costs a few days of local test data. In six months it costs everything written since. `we://module/<id>/<property>` instead of a second URI scheme: - **One root for the ecosystem.** Anything asking "is this WE data?" — tooling, a migration, an agent filtering a perspective — greps one prefix. - **`module/<id>` is a delegated subtree.** `we://<word>` is core vocabulary adjudicated by WE; `we://module/<id>/…` is adjudicated by module-id uniqueness, which the registry already enforces. The namespace shape documents who governs what, and needs no new scheme to extend. - **Ownership, not status.** These stay `we://module/notes/*` even if notes were later bundled by default. Predicates are identifiers, not documentation — and the asymmetry matters: promoting later is harmless, while something that shipped under a core name and then needed to become optional would have squatted the core namespace permanently. The rule the previous convention missed: **mint only in your subtree, but reuse the core vocabulary freely.** An entity that really has a name should use `we://name` — generic UI that displays names then works on it for free. That is shared vocabulary working as intended; only *minting* a new flat name is unadjudicated. Enforced rather than documented. `modulePredicateViolations` runs at registration and refuses a module that mints in another module's subtree or invents a scheme of its own, with the reason in `problems` — the same refuse-with-cause path an incompatible backend takes. `getModelPredicates` in the AD4M adapter supplies the input, since only the adapter that understands a model class can read predicates off it. Verified: 4 new unit tests on the rule, notes' 3 namespace tests updated, 122 app-framework tests pass, typecheck and lint clean.
`pnpm build` failed at `@we/app-framework` with "No loader is configured for .glb" — pointing at `WeCube`, a 3D component that has no business being in a bundle whose tsup entry is commented "Only build shared utilities (no JSX)". The cause was a layering violation, not a missing loader. `PlatformProvider` is a Solid provider — it calls `createContext`, `createSignal`, `createEffect` — but lived in `shared/platform/context.tsx` and imported the Solid component registry to pass `CesiumGlobe` into `initializeIntegrations`. So building `shared/index.ts` pulled in the entire Solid component tree behind it, and esbuild hit `WeCube`'s `.glb` import on the way through. Moved to `frameworks/solid/providers/PlatformProvider.tsx`, where every other provider already lives. The `PlatformAdapter` / `BackendConnector` *contracts* stay in `shared/` — they are framework-neutral and that is the whole point of them. Only the provider moved. No consumer changes: all three hosts already imported `PlatformProvider` from `@we/app-framework/solid`. Adding a `.glb` loader would have made the build pass while leaving the shared bundle carrying Solid, `three`, and the component tree. Verified: `pnpm build` completes across the workspace, including the web app's Vite production build (which emits `wecube-2.glb` correctly, so the 3D path is unaffected); 284 tests pass; eslint clean.
The about page rendered with all eight CTA images missing after the content extraction — silently, because nothing errored. Cause: `@we/template-shell` was pre-bundled. esbuild resolved its `.jpg` imports at *package* build time, emitted the images into `template-shell/dist/`, and froze plain relative strings like `"./ForBuilders-4RJHDICV.jpg"` into the JS. The app's bundler cannot rewrite a plain string, so those URLs shipped unchanged and 404'd against the app's own asset directory. Before the extraction this worked because the about page was reached through `@we/app-framework/solid`, which exports **source** — so the app's Vite saw the asset imports, emitted them, and rewrote the URLs. The extraction moved them behind a package boundary that had a build step, and quietly broke that. Both content packages now export `src/` with no build step, matching the existing `./solid` precedent. The general rule, now in `package-conventions.md` and both READMEs: > A package whose source imports assets must be consumed as source. Only the > bundler that emits the final output can resolve an asset URL. Verified: all eight images emit as hashed assets in the app build (`/assets/ForBuilders-Ds02EXd0.jpg` appears rewritten in the bundle, not as a bare relative string); `pnpm build` completes for all three hosts; 122 app-framework tests pass; eslint clean.
The editor was the one piece the decomposition could not move, because the dependency ran both ways: the shell imports the editor's components (`componentRegistry`, `TemplateLayout`), and the editor called `useAiStore()` / `useThemeStore()` / `useTemplateStore()` / `useAdamStore()` / `useSpaceStore()` back into the shell — 23 call sites across 9 files. A circular workspace dependency is worse than the large package it would have replaced, so nothing could be extracted until the cycle was cut. `@we/editor` now reaches its host entirely through `EditorHost`: template, theme, session, identity, and an optional image port. The shell provides it via `EditorHostAdapter`, which is the whole of the coupling, in one file, pointing one way. The port's member names mirror the stores' deliberately. Declaring the boundary and moving state across it are separate changes; doing both at once would produce a diff where neither half could be reviewed. What sits on the wrong side is marked `TODO(editor)` — the theme *editing session* belongs in the editor, and migrating it will change the adapter and nothing in the editor package. Two couplings had to be genuinely inverted rather than forwarded, because they would have made the editor backend-coupled: - **The background-image picker** called `ImageBlock.findAll` / `create` directly. Now an `images` port — "what images are here" and "store this file, give me a URL" are host concerns. A host without image storage omits the port and the picker degrades to its URL tab. - **`AgentProfileSummary`** came from `@we/backend-ad4m` for an author byline. Now a structural type. The AI panel is `@we/editor/ai`, a separate entry point rather than a separate package — a keyless deployment needs to not *ship* prompt code, which is an import-level property. `src/components/**` must not import `src/ai/**`, which keeps a later extraction a `git mv`. Verified: `@we/editor` typechecks with **zero** imports of `@coasys/*`, `@we/models`, `@we/backend-*`, or the shell — the invariant this commit exists to create; app-framework typechecks; `pnpm build` completes for all three hosts; 763 tests pass; eslint clean.
Visual-editor edits silently did nothing while AI-driven edits worked. When the editor was extracted it needed its own `deepClone` — the original lived in the shell's `@shared/utils`, which the editor can no longer import. The replacement preferred `structuredClone`. Every caller clones `templateStore.currentTemplate`, which is a Solid store — a `Proxy` — and `structuredClone` throws `DataCloneError` on a proxy. The edit handler aborted and the mutation never happened. The split symptom is what made it look like a port problem rather than a util problem: all five visual write sites go through the editor's clone, while `AiStore` still uses the shell's, so exactly one of the two paths broke. The original had already been through this — it carried a commented-out `structuredClone` line with no explanation, which is precisely the shape of knowledge that gets re-lost. Both copies now say why in prose, and cross- reference each other. Also worth stating: the round-trip is not merely "good enough" — it materialises the store's accessors into plain values, so callers get a detached snapshot they can mutate before handing it back through `updateTemplate`. A structural clone of a reactive proxy would not be detached in the same way. A template is JSON by definition, so nothing is lost. Verified: editor typechecks, web app builds, lint clean. Needs a visual-editor pass to confirm behaviour.
`mountTemplateEditor(element, { host })` — a mount function rather than a
component, deliberately. Solid renders into any DOM node, so a React, Vue or
Svelte application integrates by handing over an element: it never imports
Solid, never configures a JSX pragma, and never ends up with two reactive
runtimes in one bundle. Internally the surface stays Solid; externally it is a
function and a node. The same trick that makes the Lit primitives
framework-neutral at the boundary.
The claim that `@we/editor` reaches its application only through ports is now
tested rather than asserted. `portable-ui-slice` mounts the editor over the
same in-memory backend it already uses for the renderer, against
`standaloneEditorHost.ts` — a complete `EditorHost` built from plain signals
and one array. No WE shell, no stores, no perspective.
That file is also the honest answer to "what would adopting this cost?": it is
the whole integration for an application that already has templates.
Unimplemented ports throw or no-op loudly rather than pretending, because a
port that silently does nothing is worse than one that is obviously absent.
The image port is simply omitted, so the background picker degrades to its URL
tab — the designed behaviour for a host without image storage.
Verified the same way the renderer was: `pnpm why @coasys/ad4m` in that
package resolves to nothing, and the built bundle's only `@coasys` /
`PerspectiveProxy` occurrences are string literals inside generated component
metadata — type names in docs data, never an import.
Known limitation, documented in the mount function and the playground README:
the surface positions against the viewport rather than the element passed in,
inherited from having only ever run inside WE's shell. Usable for a
full-screen editing mode, not yet for editing inside a panel. Making it
container-relative is a contained change to two components and does not affect
this signature.
Verified: `pnpm build` completes across the workspace; playground 7 tests,
app-framework 122, schema-solid 39, backend-shared 121; eslint clean.
…indow The dock was `position: fixed` with `height: 100vh`, inherited from only ever running inside WE's shell — so an application mounting the editor into an element got chrome pinned to the window instead. That was the remaining obstacle to embedding it in a panel. `useEditorSurface` now supplies `positioning`, and the dock reads it. `mountTemplateEditor` defaults to `container`, which is what "mount the editor here" should mean, and sets `position: relative` on the given element when it is static — removing the most likely way to mis-integrate, which is chrome landing against the window because a `position` declaration was missed. Opt-in rather than inferred, and the default context is `viewport`. Always using `absolute` and expecting a positioned ancestor would silently drop the dock against the window in any host that has none — a layout that looks *nearly* right, which is worse than one that is obviously broken. It also means **WE's own path is untouched**: the shell mounts the dock through the component registry rather than `mountTemplateEditor`, so it gets the viewport default and the layout verified in review does not move. Not done, and now scoped precisely rather than hand-waved: the selection overlay's highlight and drag-ghost maths run in viewport coordinates via `getBoundingClientRect`. Correct over a full-window template, wrong inside a panel without offsetting by the container's rect. It stays off by default in `mountTemplateEditor`, and the README says why. Verified: `pnpm build` completes; app-framework typechecks with 0 errors; 289 tests across app-framework, schema-solid, playground and backend-shared; eslint clean.
… call
The adapter carried a `TODO(editor)` saying `editingTheme` and the
`updateEditing*` family were editor session state sitting on the host's side
of the port. Checked against how templates work, and it does not hold up.
The two are the same shape:
templateStore.currentTemplate host owns it · host renders it (TemplateLayout)
· editor mutates via updateTemplate
themeStore.editingTheme host owns it · host renders it (live preview)
· editor mutates via updateEditing*
Nobody proposes moving `currentTemplate` into the editor. The TODO pattern-
matched on the word "editing" and on the earlier `AiStore` three-way-cut
framing, which was a different problem.
Acting on it would have moved state away from the code that renders it and
required a `previewEditing` port to push it back — strictly worse than what
exists.
The rule underneath, which the codebase already followed and which is now
written down in the adapter and the editor README: **the host owns state the
host renders from; the editor mutates it through ports.** Every `ThemePort`
member passes that test, and it also explains why panel widths and edit modes
belong to the host — `computeRightOffset` reads them to size the shell's own
content viewport.
Recorded rather than quietly deleted, because "editing" in a name is not
evidence of where state belongs, and the mistake is an easy one to repeat.
…ewport-coupled Commit 15 listed the selection overlay as unfinished work: its maths "run in viewport coordinates via getBoundingClientRect" and would need offsetting by the container rect. That was wrong, and reading what the values are used for rather than counting the calls shows why. `getBoundingClientRect` appears twelve times. Eleven are inputs to `toRelative`, which subtracts the overlay's own rect — the viewport cancels out. The overlay root is `position: absolute; width: 100%; height: 100%`, so it fills whatever it is mounted in and its highlights land correctly there. The twelfth is a cursor-tracking drag ghost appended to `document.body` with `position: fixed`, which is *supposed* to be in viewport coordinates; making that container-relative would be the bug. So the overlay has always been embeddable. It is off by default in `mountTemplateEditor` because it draws *over* the template — an application mounting the editor beside its content does not want it — which is a composition choice, not a geometry limitation. The docs said the wrong one. Three tests now pin the property: a highlight lands identically under an overlay at the origin and one offset in a panel; the difference is invariant under scroll (which is why there is no scroll listener); and it does not depend on the overlay filling the window. The normalisation is duplicated in the test rather than imported, since the original closes over a component-scoped ref — noted in the file. Written because the signal is genuinely misleading: a dozen viewport-coordinate calls look like viewport coupling, and this is the second time that inference was drawn. A grep now lands on an answer instead.
…ates
Two names that had stopped describing their contents.
**`@we/app-framework` → `@we/app-shell`.** It is 15,352 LOC now, down 55% from
34,308, and what remains is stores, registries, the module host, seed handling
and shell chrome — WE's own application host. "Framework" oversold that, and
sitting beside `@we/editor`, `@we/backend-*` and `@we/module-*` it read like
the most important package rather than one app among the pieces.
Renamed in place, **not** split into `app-shell/{shared, frameworks/solid}` as
the plan drew it. That would have been Pattern A, and the conventions doc's own
rule is Pattern A only when a consumer can install one variant without the
others — nobody wants `shell-shared` without `shell-solid`. Pattern B, which is
what exists, is correct; the plan was wrong.
**`content/` → `templates/`.** "Content" collides with the product concept —
`Block` is documented as "a composable content unit" — so a directory named
`content/` holding templates competed with content meaning what users write.
The packages hold shell templates and space templates; `templates/` says so.
This also removes a planned `content/themes-default`, which would have been a
third meaning of "theme": `@we/themes` is the design system's CSS themes, and a
`Theme` model is user-authored data in a perspective. Those are different
things and should not have shared a directory.
Updated throughout: package manifests, workspace globs, tsconfig path aliases,
Vite aliases, the CI workflow, live docs, and the ai-context fragments
(regenerated). `docs/internal/old/` is left as written — it is an archive.
Verified: `pnpm build` completes across the workspace including all three
hosts; app-shell typechecks with 0 errors; 275 tests pass; eslint clean.
`templates/template-default/` stutters, and it breaks the convention every other family already follows: the directory drops the prefix because the parent supplies it, and the package name carries it because npm names have no parent. `@we/backend-ad4m` lives in `backend-system/ad4m/`, `@we/module-globe` in `module-system/globe/` — so `@we/template-default` lives in `templates/default/`. Pure `git mv`: workspace resolution is by package name, so no import changes anywhere. The lockfile's link paths regenerate on install. Verified: install clean, app-shell typechecks with 0 errors, web app builds, 122 tests pass.
`@we/cesium-layers` sat awkwardly at the top level, and the awkwardness had a cause: it was half of a system whose other half was squatting in the renderer. The layer contract — `CesiumLayer`, `LayerFactory`, `LayerContext`, 165 lines, 9 exports — lived inside `@we/widgets`, so a layer author had to peer-depend on the whole Solid-coupled widget package to obtain the interfaces. The layers' own doc comment apologised for this arrangement at length. Now a system in the conventions' sense, contract plus implementations: globe-system/ ├── protocol/ @we/globe-protocol the contract; peer-deps only `cesium` (types) └── layers/ @we/cesium-layers WE's first-party layers - A layer author depends on the protocol alone, or on `@we/cesium-layers` to also get the first-party layers. Neither names the renderer. - `CesiumGlobe` stays in `@we/widgets` and becomes just another implementer of the contract — its `../protocol` imports now point at the package. - `@we/module-globe` is untouched: it is a module, so it stays in module-system; the layers were never its private detail, it was merely their first consumer. - `@we/cesium-layers` keeps its name — the public import surface — while its `@we/widgets` peer-dependency is deleted, which was the point. Run against the package test: reuse (widgets, cesium-layers, module-globe all consume the contract), enforcement (a layer cannot reach renderer internals through a types-only package), optionality (the protocol installs without `@we/widgets`). All three hold, which is one more than the rule requires. Also: the conventions doc now states the two rules this session kept applying — directory names drop the kind prefix the parent supplies, and `templates/` is deliberately not a `-system` because it holds content, whose contract lives in schema-system. And removed the untracked build leftovers of the deleted `@we/utils` and react playgrounds, which `git rm` cannot see. Verified: `pnpm build` completes across the workspace; protocol, layers, widgets, module-globe and app-shell all build with DTS; 176 tests in the sweep; eslint clean.
Committed together because two share files (AdamStore is touched by both the re-export removal and the seed inversion); each is small and delineated here. **The backend-shared compatibility re-export is gone.** Seven files still imported backend symbols through `@we/schema-shared`; they now name `@we/backend-shared` directly and `@we/schema-solid` declares the dependency it was already using. A star re-export between contract packages is how a boundary stops meaning anything. The one genuine edge stays: `types.ts` imports `RendererStores`, because the renderer's surface names the bindings. **The runtime component metadata moves out of the doc generator.** `contextData` (2,219 generated lines, already typed by schema-shared's own `ContextData`) is now generated *into* `schema-system/shared/src/generated/`, beside the `getComponentMeta` that consumes it — the same arrangement as CLAUDE.md: the tool writes files other places own. `@we/editor` drops its `@we/ai-context` dependency entirely, which mattered most: an embeddable editor should not carry WE's documentation generator in its graph. `@we/ai-context` keeps `schemaContext` — prompt assembly is genuinely its runtime product; component metadata was not. **The deployment seed is supplied by the app.** Three shell files imported `we-seed.json` from the repo root, four to six directories outside their own package — the last dependency edge pointing the wrong way. A seed describes a deployment, and the apps are the deployments: they now import it and hand it to `PlatformProvider`, which forwards to `initializeIntegrations`; everything else reads a `seedRegistry` set exactly once. `queryIRFlag` applies its seed default on first read rather than at module load, because import order is not initialisation order. **2,437 lines of dead cube are deleted, 51 MB of model with them.** `ComplexWeCube` was registered nowhere and was the sole referent of `wecube-beveled.glb`; `wecube.glb` and `wecube-material.glb` had zero references. `WeCube` (571 lines) stays — it is live on the about page — and it stays in app-shell deliberately: it is WE-brand chrome for WE's own app, which is precisely what app-shell is for. Verified: `pnpm build` completes across the workspace; 756 tests pass across app-shell, schema-shared, schema-solid and editor; typecheck and eslint clean; the editor's dependency graph re-verified free of ai-context and @coasys.
…ncluded
`globe-system/` at the top level was answering the wrong question. The top
level is platform architecture — the machinery WE is *made of*: how templates
render, where data lives, how features install. The globe is a thing WE
*has*, and it sat in the architecture's row looking like a peer of
schema-system. Worse, the pattern would have scaled badly: graph, map,
calendar — ten features later the catalogue drowns the architecture.
The resolution was already in the repo's design: the module system *is* the
feature-packaging mechanism, so a feature lives under it — one package while
simple, a **family** when it grows its own extension point:
module-system/globe/
├── module/ @we/module-globe shell integration
├── protocol/ @we/globe-protocol the extension contract
├── layers/ @we/globe-layers first-party plugins (was @we/cesium-layers)
└── widget/ @we/globe-widget the renderer (was in design-system/5-widgets)
Three consequences that make this more than tidying:
- **The design system stops shipping a planetary renderer.** `cesium` leaves
`@we/widgets`' dependency graph entirely, along with `vite-plugin-cesium` —
which turned out to be referenced by nothing at all. 5-widgets is now what
its README implies: generic widgets. A feature's widget belongs to its
family; `GraphWidget` follows the day graph grows plugins.
- **The layers take their family name.** `@we/cesium-layers` →
`@we/globe-layers`, closing the one violation of the dir-plus-parent naming
rule while the rename is free — there are no external consumers yet, and
the window shuts when the marketplace opens. Cesium stays in the
description, where implementation detail belongs.
- **A third-party author now finds everything in one place.** Extending any
feature means looking in `module-system/<feature>/` — the module, the
contract, the reference implementations, and the renderer it plugs into.
The conventions doc states the rule (platform at top level, features in
module-system), the family pattern with graph as the worked future example,
the `protocol/`-as-contract amendment, and the two naming orderings that
deliberately coexist inside a family (`module-globe` kind-first with its
module siblings; `globe-*` domain-first with each other).
One mechanical find for the record: `git mv` carries a package's
`node_modules` symlinks and stale `dist` with it, and the broken links only
surface as baffling DTS errors ("cannot find type definition file for
'node'") two packages downstream. Purge both after moving a package.
Verified: all four family packages build with DTS; app-shell and the widget
typecheck clean; `pnpm build` completes across the workspace including all
three hosts; 164 tests in the sweep; eslint clean; ai-context regenerated.
…olved one Every generation run warned: Unresolved type "MediaStream" — add to typeExpansions in cem.ts. It arrived with `we-video`'s `stream` prop and the advice in the message points at the wrong table: `typeExpansions` is for names that expand into literal unions (design tokens), while a DOM global has no expansion — it belongs in `knownPrimitiveTypes` beside `HTMLElement` and `File`, which is where it now is. The generated outputs are byte-identical before and after: unresolved parts were already passed through unchanged, so the warning was pure noise. The warning text now names both tables and which kind of type each is for, so the next platform type that appears does not get misdirected the same way.
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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.
Package decomposition — contracts, modules, templates, and the editing surface
Summary
@we/app-frameworkhad become a 33k-line hub: fifteen workspace dependencies pointing in, threethin host apps pointing out, and every deployment taking the whole thing — the editor, the AI
assistant, the built-in templates, the platform glue and the data layer fused together.
@we/schema-sharedhad quietly become a second hub: 9,000 lines across five unrelated concernsthat every feature module peer-depended on in full (
@we/module-callneeded four exports andpulled the schema engine, indexer and validator to get them).
This PR splits both along the lines the imports already drew, states the conventions that failed to
prevent either hub, and enforces the boundaries rather than documenting them. The hub is now
@we/app-shellat 13.5k lines — WE's own app host, not the architecture — and the top level ofpackages/reads as the architecture: five*-systemdirectories each holding a contract plus itsimplementations,
templates/holding content, and single-package tools. Net diff is −1,956lines: the structure came out smaller than the hub it replaced.
Changes
Contracts (
backend-system/shared,module-system/shared)@we/backend-shared—DataSource+QueryAdapter, the query IR/validation/engine, theephemeral and presence ports, the model manifest. Imports nothing from the schema side: ports and
query are the base layer, and nothing here knows what a
SchemaNodeis.@we/module-shared— the feature-module contract, and the single package a module authorinstalls; it re-exports the module-facing slice of the backend contract so a module declares one
dependency, not three.
@we/schema-sharedkeeps schema semantics only. The temporary compatibility re-export of thebackend contract is already removed within this PR — consumers name the owning package.
shared/has a README stating what belongs here and what doesn't. That is theload-bearing part: both hubs formed because there was no rule, so everything shared went to the
one shared place.
Backends (
backend-system/ad4m,backend-system/inmemory)@we/backend-ad4mgathers the nine files that knew what aPerspectiveProxywas, previouslyscattered through the hub's
shared/.@coasys/ad4mbecomes a declared dependency of specificpackages rather than an ambient fact — importable by
backend-ad4m,models, and any moduledeclaring
backends: ['ad4m'], nothing else.@we/backend-inmemoryconsolidates three drifted copies of the in-memoryDataSource. It is thereference adapter for the contract, and it is how stores and the editor get tested without a
running executor.
installSpaceSdnatakes module-owned models asan argument instead of reading the host's registry.
Modules (
module-system/)appRegistryis deleted;ModuleDefinitiongainsembed.An embedded app now gets backend gating,
Space.enabledModules, and refusal-with-reason atregistration instead of a thirty-second timeout at runtime. The merge surfaced that apps and
modules used two different capability vocabularies; there is now one.
we://module/<id>/<prop>— one root for the ecosystem, with adelegated subtree whose adjudicator is module-id uniqueness. Mint only in your subtree; reuse the
core vocabulary (
we://name) freely. Enforced at registration bymodulePredicateViolations,not documented as a norm, because predicates are how existing data is found — a mistake here
silently orphans everything already written. The notes module migrates from the short-lived
module://scheme while only test data exists.module-system/globe/{module, protocol, layers, widget}.The layer contract (
@we/globe-protocol) moves out of@we/widgets; the first-party layersbecome
@we/globe-layers; theCesiumGloberenderer moves out of the design system. The statedrule: platform systems live at the top level, feature domains live in
module-system/— onepackage while simple, a family when the feature grows its own extension point.
GraphWidgetfollows the day graph grows plugins.
cesiumleaves the design system's dependency graph entirely, andvite-plugin-cesiumturned out to be referenced by nothing and is deleted.5-widgetsnow holdsgeneric widgets only.
Templates (
templates/)@we/template-shell(sidebar, settings, profile, bootscreen, marketplace, about, editor chrome, module rail) and
@we/template-default. Templates aredata; they now version and ship as data.
SchemaTestsstays behind precisely because its storeis real code — the boundary sorted data from code-pretending-to-be-data.
strings the app's bundler cannot rewrite, which shipped a silent all-images-404 on the about
page. Now a stated convention: a package whose source imports assets must be consumed as source.
Editing surface (
editor/)@we/editorextracts the visual overlay, design toolbar, panel dock and panels behind anEditorHostport (template · theme · session · identity · images). The extraction was blocked bya genuine cycle — the shell imports the editor's components while the editor called five shell
stores, 23 call sites — resolved by depending on a shape instead of an implementation.
EditorHostAdapterin app-shell is the whole coupling, in one file, pointing one way.pass-through: declaring the boundary and moving state across it stay separately reviewable. The
rule that fell out, now documented: the host owns state the host renders from; the editor
mutates it through ports (which is why
editingThemeand panel geometry stay host-side, sameas
currentTemplate).mountTemplateEditor(element, { host })mounts the surface into any DOM element, with the paneldock pinning to the container by default.
@we/editor/aiis a separate entry point rather than apackage — what a keyless deployment needs is to not ship prompt code, which entry points give;
src/components/**must not importsrc/ai/**.@we/backend-inmemory, and the editor's dependency graph is verified free of@coasys/*,@we/models,@we/backend-*and@we/ai-context.App shell (
app-shell/, renamed fromapp-framework)lines, down from 34.3k. "Framework" oversold it.
we-seed.jsonfromthe repo root, four to six directories outside their own package — the last dependency edge
pointing the wrong way. The apps (the deployments) now import it and hand it to
PlatformProvider; everything else reads aseedRegistryset exactly once.PlatformAdapter(where am I running) andBackendConnector(how do I reach the data layer)are separate contracts; each host supplies both at its entry point.
ComplexWeCube(1,866 lines, registered nowhere) and 51 MB of unreferenced.glbmodels.WeCube(571 lines, live on the about page) stays in app-shell deliberately — itis WE-brand chrome for WE's own app.
Conventions (
docs/architecture/package-conventions.md, rewritten)reuse; at least one must hold.
@we/utils(74 lines, one consumer) failed all three and isfolded into its consumer.
frameworks/earns itskeep when variant names don't self-identify — the doc previously prescribed the opposite of what
the code correctly did); Pattern A/B decided by optionality, not "substance"; dependency
direction including the
@coasys/*rule; peer-dependencies-and-injection; assets-consumed-as-source; directory names drop the kind prefix the parent supplies.
ai-context
contextData, 2.2k generated lines) is now generated intoschema-system/shared/src/generated/, beside thegetComponentMetathat consumes it — the toolwrites files other places own, same as CLAUDE.md.
@we/editordrops the dependency; promptassembly (
schemaContext) stays, being genuinely this package's runtime product.where-to-look sections reflect the new layout.
Known follow-ups
mechanical — dynamic-import
@we/editoron entering edit mode (./aiwas designed for this),lazy-load
WeCube, measure withrollup-plugin-visualizerfirst. Its own PR: every itemintroduces a loading state that needs eyes on it.
module-system/graphfamily when graph grows plugins;GraphWidgetmoves out of5-widgetsthen, per the stated rule.
module-shared(backends: ['ad4m']keeps entity-owning modules unblocked); if built, derivethe manifest from the decorated classes rather than inverting the source of truth.
docs/internal/old/still uses pre-rename paths — left as an archive, deliberately.Test plan
pnpm buildcompletes across the workspace, including all three host apps' production buildsbackend-ad4m 30, module-call 28, module-shared 12 incl. 4 new predicate-rule tests, editor 3
new geometry-contract tests, playground 7)
@we/editorimports no backend, noshell, no ai-context; the playground bundle's only
@coasys/PerspectiveProxyoccurrencesare string literals in generated component metadata
activation, template editing (visual edits, code panel, undo/redo), theme editing, panel
open/close/drag-resize, background-image picker, publish flows, AI chat
module launcher works — the one remaining runtime-risk surface
Three bugs were found by the manual rounds and fixed in-branch, all invisible to typecheck: a Solid
provider living in the shared bundle (surfaced as a
.glbloader error), pre-bundled templatesfreezing asset URLs (silent missing images), and
structuredClonethrowing on Solid store proxies(visual edits silently no-oping while AI edits worked).