Skip to content

Refactor: Package Decomposition - #100

Merged
jhweir merged 23 commits into
devfrom
refactor/package-decomposition
Aug 2, 2026
Merged

Refactor: Package Decomposition#100
jhweir merged 23 commits into
devfrom
refactor/package-decomposition

Conversation

@jhweir

@jhweir jhweir commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Package decomposition — contracts, modules, templates, and the editing surface

Summary

@we/app-framework had become a 33k-line hub: fifteen workspace dependencies pointing in, three
thin 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-shared had quietly become a second hub: 9,000 lines across five unrelated concerns
that every feature module peer-depended on in full (@we/module-call needed four exports and
pulled 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-shell at 13.5k lines — WE's own app host, not the architecture — and the top level of
packages/ reads as the architecture: five *-system directories each holding a contract plus its
implementations, templates/ holding content, and single-package tools. Net diff is −1,956
lines
: the structure came out smaller than the hub it replaced.

Changes

Contracts (backend-system/shared, module-system/shared)

  • @we/backend-sharedDataSource + QueryAdapter, the query IR/validation/engine, the
    ephemeral 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 SchemaNode is.
  • @we/module-shared — the feature-module contract, and the single package a module author
    installs; it re-exports the module-facing slice of the backend contract so a module declares one
    dependency, not three.
  • @we/schema-shared keeps schema semantics only. The temporary compatibility re-export of the
    backend contract is already removed within this PR — consumers name the owning package.
  • Every shared/ has a README stating what belongs here and what doesn't. That is the
    load-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-ad4m gathers the nine files that knew what a PerspectiveProxy was, previously
    scattered through the hub's shared/. @coasys/ad4m becomes a declared dependency of specific
    packages rather than an ambient fact — importable by backend-ad4m, models, and any module
    declaring backends: ['ad4m'], nothing else.
  • @we/backend-inmemory consolidates three drifted copies of the in-memory DataSource. It is the
    reference adapter for the contract, and it is how stores and the editor get tested without a
    running executor.
  • One backwards edge inverted rather than tolerated: installSpaceSdna takes module-owned models as
    an argument instead of reading the host's registry.

Modules (module-system/)

  • Embedded apps register as modules. appRegistry is deleted; ModuleDefinition gains embed.
    An embedded app now gets backend gating, Space.enabledModules, and refusal-with-reason at
    registration instead of a thirty-second timeout at runtime. The merge surfaced that apps and
    modules used two different capability vocabularies; there is now one.
  • Module predicates mint under we://module/<id>/<prop> — one root for the ecosystem, with a
    delegated subtree whose adjudicator is module-id uniqueness. Mint only in your subtree; reuse the
    core vocabulary (we://name) freely. Enforced at registration by modulePredicateViolations,
    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.
  • The globe becomes a feature family: module-system/globe/{module, protocol, layers, widget}.
    The layer contract (@we/globe-protocol) moves out of @we/widgets; the first-party layers
    become @we/globe-layers; the CesiumGlobe renderer moves out of the design system. The stated
    rule: platform systems live at the top level, feature domains live in module-system/ — one
    package while simple, a family when the feature grows its own extension point. GraphWidget
    follows the day graph grows plugins.
  • Consequence: cesium leaves the design system's dependency graph entirely, and
    vite-plugin-cesium turned out to be referenced by nothing and is deleted. 5-widgets now holds
    generic widgets only.

Templates (templates/)

  • 12.8k lines of built-in templates move to @we/template-shell (sidebar, settings, profile, boot
    screen, marketplace, about, editor chrome, module rail) and @we/template-default. Templates are
    data; they now version and ship as data. SchemaTests stays behind precisely because its store
    is real code — the boundary sorted data from code-pretending-to-be-data.
  • Both packages are consumed as source with no build step — pre-bundling froze asset URLs into
    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/editor extracts the visual overlay, design toolbar, panel dock and panels behind an
    EditorHost port (template · theme · session · identity · images). The extraction was blocked by
    a 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.
    EditorHostAdapter in app-shell is the whole coupling, in one file, pointing one way.
  • The port's member names mirror the stores' deliberately, so the adapter is a structural
    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 editingTheme and panel geometry stay host-side, same
    as currentTemplate).
  • mountTemplateEditor(element, { host }) mounts the surface into any DOM element, with the panel
    dock pinning to the container by default. @we/editor/ai is a separate entry point rather than a
    package — what a keyless deployment needs is to not ship prompt code, which entry points give;
    src/components/** must not import src/ai/**.
  • The playground exercises the surface against a host built from plain signals over
    @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 from app-framework)

  • What remains is WE's own app host — stores, registries, module host, shell chrome — at 13.5k
    lines, down from 34.3k. "Framework" oversold it.
  • 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. The apps (the deployments) now import it and hand it to
    PlatformProvider; everything else reads a seedRegistry set exactly once.
  • PlatformAdapter (where am I running) and BackendConnector (how do I reach the data layer)
    are separate contracts; each host supplies both at its entry point.
  • Dead weight deleted: ComplexWeCube (1,866 lines, registered nowhere) and 51 MB of unreferenced
    .glb models. WeCube (571 lines, live on the about page) stays in app-shell deliberately — it
    is WE-brand chrome for WE's own app.

Conventions (docs/architecture/package-conventions.md, rewritten)

  • A test for whether something deserves to be a package at all — optionality, enforcement, or
    reuse; at least one must hold. @we/utils (74 lines, one consumer) failed all three and is
    folded into its consumer.
  • Platform vs feature placement; the family pattern; grouping directories (frameworks/ earns its
    keep 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

  • The runtime component metadata (contextData, 2.2k generated lines) is now generated into
    schema-system/shared/src/generated/, beside the getComponentMeta that consumes it — the tool
    writes files other places own, same as CLAUDE.md. @we/editor drops the dependency; prompt
    assembly (schemaContext) stays, being genuinely this package's runtime product.
  • CLAUDE.md and friends regenerated; the package map, dependency-direction rule and
    where-to-look sections reflect the new layout.

Known follow-ups

  • Bundle splitting. The web bundle is a single 7.7 MB chunk. The decomposition makes the fix
    mechanical — dynamic-import @we/editor on entering edit mode (./ai was designed for this),
    lazy-load WeCube, measure with rollup-plugin-visualizer first. Its own PR: every item
    introduces a loading state that needs eyes on it.
  • module-system/graph family when graph grows plugins; GraphWidget moves out of 5-widgets
    then, per the stated rule.
  • The manifest→SDNA compiler stays deferred, per the escape-hatch position documented in
    module-shared (backends: ['ad4m'] keeps entity-owning modules unblocked); if built, derive
    the 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 build completes across the workspace, including all three host apps' production builds
  • ~840 unit tests pass (schema-shared 479, backend-shared 121, app-shell 122, schema-solid 39,
    backend-ad4m 30, module-call 28, module-shared 12 incl. 4 new predicate-rule tests, editor 3
    new geometry-contract tests, playground 7)
  • Typecheck and eslint clean across the workspace
  • Boundary invariants verified by grep, not assumption: @we/editor imports no backend, no
    shell, no ai-context; the playground bundle's only @coasys/PerspectiveProxy occurrences
    are string literals in generated component metadata
  • About-page images verified emitting as hashed, URL-rewritten assets
  • Manual browser testing across three rounds: boot on all paths, embedded apps, seed module
    activation, template editing (visual edits, code panel, undo/redo), theme editing, panel
    open/close/drag-resize, background-image picker, publish flows, AI chat
  • Globe route after the final commit (renderer moved packages): globe renders, layers load,
    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 .glb loader error), pre-bundled templates
freezing asset URLs (silent missing images), and structuredClone throwing on Solid store proxies
(visual edits silently no-oping while AI edits worked).

jhweir added 23 commits August 1, 2026 22:29
…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.
@netlify

netlify Bot commented Aug 2, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit f8fcb0e
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a6f65a3939e8500080cb4fb
😎 Deploy Preview https://deploy-preview-100--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jhweir
jhweir merged commit db37527 into dev Aug 2, 2026
3 of 5 checks passed
@jhweir jhweir mentioned this pull request Aug 3, 2026
8 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant