Skip to content

Feature: Module system and space settings - #109

Merged
jhweir merged 21 commits into
devfrom
feat/module-system-and-space-settings
Aug 8, 2026
Merged

Feature: Module system and space settings#109
jhweir merged 21 commits into
devfrom
feat/module-system-and-space-settings

Conversation

@jhweir

@jhweir jhweir commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Module system, per-space settings, and the theme/loading correctness that fell out

Summary

This started as "the module toggles in shell settings don't work" and turned into settling the
module system's shape. The toggle was broken three ways, but the deeper problem was that the page
was editing a per-space field while sitting in global settings — the layering the seed had
described in a comment was never built, so the space half had been put where the agent half
belonged.

The branch builds that layering, gives per-space settings a home that does not depend on which
template a space happens to run, and then fixes the things that surfaced while testing it. A
recurring theme runs through most of the fixes: code that could not tell "I don't know yet" from
"the answer is no"
— a join gate flashing at someone already inside a space, a header asserting
"No description" about a space it had not seen, an empty dataset list read as "not joined". Each is
the same mistake in a different place, and each is fixed by giving the unknown a name rather than
inferring it from a falsy value.

Nothing here has been run against a live executor by me; every change is verified by typecheck,
tests, schema validation and a full build, and manually tested by James as it went.


Changes

The module system

packages/models/src/entities/AgentSettings.ts, SpacePreference.ts — the two personal layers
the seed described but never built. installedModules is what the agent wants available anywhere;
SpacePreference is what they decided about one space — muted modules, and template/theme
overrides. Both live in the root dataset, never in the space: writing "modules I have muted"
into a shared perspective would sync it to every other member, which is a privacy leak wearing a
setting's clothes.

packages/app-shell/src/shared/registries/moduleRegistry.tsmoduleSurface derives what a
module puts in front of the user from what it contributes, and that decides where it can be turned
off. A contribution is gated at the layer where it renders:

surface contributes renders agent space
chrome launcher, slots inside a space yes yes
app an embed in the shell yes no
capability components only wherever a template mounts it yes no

Chrome is the only surface a community decides about, because it is the only one appearing inside
their space. This is why toggling the globe module did nothing (it supplies CesiumGlobe to
whichever template asks) and why gating Flux per space would have been wrong — an app switcher is
shell-level, and its iframes deliberately outlive navigation, so a per-space gate would tear down a
live session as a side effect of walking into a space.

requiredBy + collectComponentTypes derive a template's module dependencies by walking the schema
for the components it mounts. Derived rather than read from meta.components, because no template
in the repo fills that field in and the default template plainly mounts CesiumGlobe. This is what
makes uninstalling a capability module refusable, and it names missingModules — a template
mounting a component nothing provides, which previously rendered nothing with no way to find out
why.

SpaceStoreactiveModules intersects the layers: registered ∩ installed ∩ enabled, less
personal mutes. Each layer falls back to the registered set when undecided, so an agent who never
opens the setting and a space that never decided both keep exactly what they had.

Per-space settings

packages/templates/shell/src/spaces/ — a settings page per space, reached from its card. Not a
"current space" page: navigateToSpace closes the shell overlay, so a page bound to where you are
standing could only ever configure that one place. Keying off the row you clicked decouples
configuring a space from being in it.

Every space-scoped write (updateSpaceMeta, updateSpaceImage, setSpaceDefaultTemplate/Theme,
setModuleEnabled) takes an optional target uuid. Two consequences: only the current space has a
live subscription, so each write also updates the cache the list reads; and switching the visible
template or theme is gated on the target actually being visible, so configuring another space does
not repaint the app.

canAdministerSpace is a predicate rather than an inline author/DID comparison, so creator-only can
grow into roles without every template changing. It is an affordance, not enforcement — a shared
space is a neighbourhood every member can write to.

Spaces & data collapses three sections into one list. Shared and personal differed by one field
on the same model, which is a badge; "All Perspectives" was a different axis entirely and moved to a
collapsed Advanced section that includes the system datasets. Deleting we-root stays possible —
resetting to a clean agent is worth being able to do while testing — but says what it costs.

Themes

ThemeStore — theme scope (does a space theme cover the whole window, or only its own content)
was already implemented but defaulted to global, reset every boot, and was only reachable from the
theme editor's toolbar. It is persisted to AgentSettings.themeScope, defaults to scoped, and is
surfaced in Settings → Appearance. Scoped is the default because the failure modes are not
symmetric: a low-contrast community theme taking over the whole window makes the settings page hard
to read, and that is the page you would go to to undo it.

The toolbar globe stays as a session preview that names the setting it is masking, so the revert
is expected rather than mysterious.

Both surfaces are derived now — documentTheme and activeTemplateTheme, one effect each —
replacing fourteen imperative applyThemeToDOM calls across six functions and three effects. That
is what fixed the flicker: the old scope transition wrote a signal (rendered on the next flush) and
documentElement (immediately), so for one frame the whole window wore the wrong theme.

Fixes that surfaced along the way

  • $event/$arg in $action args — only a bare reference survives to call time. Wrapped in
    any operator it is evaluated at render time against a context with no event, where the unresolved
    $-string is truthy, so the argument becomes a constant. This silently broke the module toggle,
    the MCP switch and every field of the AI model form. Fixed in action.ts, and the validator now
    rejects the nested form, because nothing errors at runtime and it had already bitten twice.
  • SDNA shape refresh — a property added to an existing model reached new spaces only; writes to
    it were dropped everywhere else. The executor already refreshes a SubjectClass registered with a
    shape; only WE's own guard stood in the way. refreshSpaceSdna diffs declared predicates against
    stored ones in one SPARQL query.
  • '' cannot be storedAd4mModel.innerUpdate skips any property set to an empty string, so
    "use the space's default" silently reverted. It is a named value now. This affects any field: a
    space description still cannot be cleared, which predates the branch and is left alone.
  • Schema validation — an import failure was not counted as an error, so the run printed the
    failure, skipped the file and exited 0. Two of the default template's largest schemas had never
    been validated. Coverage went from 5 schemas to 19.
  • $localState initials rejected $-prefixed context strings, so a form seeded from a $each
    item rendered the literal text $space.name in its input.
  • Scoped theme leaked colourcolor is set on html, body, #root and inherits as a resolved
    value, so re-declaring the token on the wrapper did nothing.
  • Space background — the scroll container had no background of its own, so anything the template
    did not cover fell through to the canvas, which the shell theme paints.
  • we-text now holds one line when empty and takes a loading prop that sizes its own
    placeholder. A hand-authored skeleton needs a height nobody can derive from the schema, and any
    measured value drifts when a theme changes fontScale.

Known follow-ups

Deliberately out of scope, in rough priority order:

  1. missingModules is computed but not surfaced. The install prompt James originally described
    — open a space using a module you do not have, get offered it — needs a UI. Everything it needs
    now exists.
  2. The uninstall guard only considers the template on screen. Uninstalling the globe from the
    landing page succeeds, and the globe route then renders nothing next time. Widening it to every
    installed template is small.
  3. Module-declared settingsModuleDefinition.settings?: { agent?, space? }, so a module
    carries its own controls instead of the shell hardcoding them. The call module wants both halves
    on day one.
  4. SpaceTemplatePreference migrates on read, so the legacy model stays until every space has
    been visited once. It can be deleted after a reasonable window.
  5. An empty string cannot be written to any @Property. Clearing a space description is a
    silent no-op today. Needs either a delete-the-link path in the ORM or a storable sentinel.
  6. no-use-before-define would have caught both TDZ crashes on this branch. Measured at 23 hits
    across 10 unrelated files, all false positives (deferred references in function declarations),
    so enabling it is its own cleanup.
  7. Per-space settings still live in the template's SettingsRoute as well. Both render the same
    store actions so they cannot drift, but a space on a template without that route relies on the
    new shell page.

Test plan

Verified:

  • pnpm build — full workspace, green
  • tsc --noEmit on models, schema-shared, primitives, widgets, app-shell, template-shell,
    template-default, editor, backend-ad4m
  • pnpm validate:schemas — 19 schemas, no issues
  • @we/schema-shared — 497 tests (7 added: $event/$arg arg nesting, fragment scope,
    $localState context refs)
  • @we/schema-solid — 48 tests (2 added: $localState seeded from a context ref, and a literal
    $ left alone)
  • Manually tested by James throughout: module toggles across two agents, per-space template and
    theme overrides, theme scoping, deep links and refresh, the sidebar +, share links

Not verified:

  • No change here has been run against a live executor by me. The SDNA refresh in particular
    only does real work on a pre-existing dataset.
  • AgentSettings gains three properties and SpacePreference is a new model. AgentSettings
    goes through installRoot rather than refreshSpace, so whether a pre-existing root dataset
    picks up added properties is unconfirmed — the first thing to check if a setting will not
    stick.
  • Theme editing in scoped mode. populateMissingOverrides relies on documentElement briefly
    carrying the theme being edited; if the light/dark buttons come up wrong, that is the probe.
  • The 1lh floor changes every we-text. If a layout relied on an empty one taking no
    space, that is where an unexpected gap would appear.
  • The right-hand CollapsibleSidebar variant and static (non-collapsible) groups — both share
    the header row that gained the group action, and neither is exercised by the spaces group.

Pre-existing, not from this branch

@we/schema-solid fails tsc --noEmit on two test files referencing BackendConfig.uuid. Present
on dev; the tests themselves pass, since vitest does not typecheck.

jhweir and others added 21 commits August 8, 2026 14:00
…operty

Three defects, one visible symptom: toggling a module in settings did nothing.

`$event.*` only resolved inside event handler *arrays* — those rebuild the
context with `event` at call time. A lone `{ $action, args }` resolves once at
render time, where `$event` matches no context key, and the dispatcher returns
an unresolved `$`-string verbatim. So the store was handed the literal string
`'$event.detail'`: truthy, silent, no error. `setModuleEnabled` took the `add`
branch on every click and a module could only ever be switched on. `$arg` and
`$event` were already synonyms in `extractFromPath`; now they are in `$action`
args too, which also fixes the MCP toggle and every field of the AI model form.

`setCurrentSpace` was handed back the object it had just mutated. Solid dedupes
on `===`, so nothing was notified and the module rail kept rendering the stale
set. Writes via `Space.update` now, and republishes a fresh reference.

`Space.enabledModules` could not persist in any space created before the field
existed: shapes are only written for a class absent entirely, so the stored
SHACL had no `we://enabled_modules` and the write was dropped. The executor
already refreshes a SubjectClass registered with a shape
(`remove_subject_class_shacl_links`) — only our own guard stood in the way.
`refreshSpaceSdna` diffs declared predicates against stored ones in a single
SPARQL query and re-registers what is out of date, so this fixes the class of
bug rather than the one field. A shape with no stored properties is treated as
fresh, not stale — on a freshly joined neighbourhood those triples can lag the
SubjectClass marker, and rewriting on that basis would churn every shape in the
space. Each shape refreshes at most once per process as a backstop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spaces & data carried three sections on two different axes. "Shared Spaces" and
"Personal Spaces" iterated the same model differing by one field, so the split
bought a second heading and a second "none yet" empty state for what is one
list; shared/personal is a badge now. "All Perspectives" was not the same axis
at all — raw datasets, ids, share URIs, schema cleanup and hard delete — and
moves to a collapsed Advanced section, which also stops hard delete sitting one
mis-click from a settings gear.

The one thing All Perspectives uniquely surfaced was a joined dataset that is
not a WE space, visible only as an id. Those are now rows in the spaces list in
their own state: a community synced in from another app is something you joined
and can act on, and opening it is what reaches the initialize gate. A space you
cannot see is a space you cannot leave.

Advanced deliberately includes the system datasets the spaces list drops.
Hiding them from the one surface meant to show everything held would reproduce
the problem it exists to solve. Deleting `we-root` stays possible — resetting to
a clean agent is worth being able to do while testing — but says what it costs,
which is not guessable from "Delete" on a dataset holding no space.

`canAdministerSpace` is a predicate rather than an inline author/DID comparison
in each template. Creator-only is today's answer, not the last one; templates
that ask by name keep working when it grows, templates comparing two DIDs would
all need editing. It decides whether to *offer* controls — a shared space is a
neighbourhood every member can write to, so it is not enforcement.

Also fixes the dataset shape in the AI context, which listed
uuid/sharedUrl/neighbourhood — none of which exist on `DatasetRef`. A schema
reading them got undefined while the validator confirmed the wrong name and
rejected the right one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three "orphan $local" errors reported against the shell's language settings
were about working code. `languagesLocalState` is declared by the `/languages`
route that composes the section, exactly as the comment beside it says —
`$localState` is scoped to the node declaring it, so a fragment legitimately
reads state its host page owns. Validated standalone, that always looks orphaned.

So the check now applies to templates, which `meta` marks as self-contained and
which therefore must declare everything they read, and not to bare fragments,
whose scope is not in view. Nothing is lost: the composed template is itself
validated, and the walk descends into the fragment there with the scope set.
The narrower "declares X but only Y is available" error still fires for
fragments — once a fragment declares its own state, a reference outside it is
wrong no matter what composes it.

The CLI also took only the *first* schema export per file, so which fragment got
checked was decided by declaration order. `RuntimeSettings.schema.ts` exports
seven sections and passed on the strength of the one at the top using no tokens;
now all seven are checked. Coverage across the shell templates goes from 11
schemas to 25.

Also adds `profileStore.clearProfileImage` to the AI context, which existed on
the store but not in its metadata — so the profile's remove-image button was
reported as calling an unknown method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every space-scoped write took the space from `datasetStore.currentDataset`, so
configuring a space meant being in it. That cannot work from the spaces list:
navigating to a space closes the settings overlay, so a page bound to wherever
you are standing could only configure that one place, and only while you stayed.

Each of `updateSpaceMeta`, `updateSpaceImage`, `setSpaceDefaultTemplate`,
`setSpaceDefaultTheme` and `setModuleEnabled` now takes an optional target uuid.
Omitting it keeps the previous meaning, so in-space callers are unchanged. Two
things follow from writing to a space that is not on screen: the live
subscription only refreshes the current one, so each write also updates the
cache the list reads; and switching the visible template or theme is only right
when the target *is* what is visible, so those apply behind an `isCurrent` check
— setting another space's default must not repaint the app.

`enabledModules` was a memo over the current space, which cannot answer for any
other. It is a plain function over the stored value now, and each spaces-list
row carries its own module settings — `$store` resolves a literal path, so a
page rendered for one row cannot ask for that row's modules by uuid, the same
constraint that made `launchModule` take an id.

The nav also matches on the first path segment rather than the whole path, so
`/spaces/<uuid>` keeps Spaces & data lit. Exact equality left nothing selected,
which reads as having navigated out of settings altogether.

Adds updateSpaceMeta, setSpaceDefaultTemplate and setSpaceDefaultTheme to the AI
context. All three were already called by the default template, but its schemas
fail to import in the validator (a .jpg import), so nothing reported them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm validate:schemas` covered only the app-shell test schemas, and the two
largest schemas in the default template could not be imported at all — a schema
is TypeScript, so validating it means resolving its imports, including
`import cover from '../assets/CTA/ForBuilders.jpg'`, which a bundler
understands and Node does not.

Worse, an import failure was not counted as an error. The run printed the
failure, excluded the file, reported "no issues found" and exited 0 — so an
unloadable schema looked exactly like a clean one. That is how `updateSpaceMeta`
sat in the default template calling a method the AI context had never heard of
without anything reporting it. It is a hard error now, and the run exits 1.

Asset imports resolve through a module hook to a stub returning the asset's own
URL — what a bundler's file loader yields, and a string is all a schema does
with it. Registered by path in a separate thread, so it is plain `.mjs` and is
copied into dist rather than bundled.

Coverage goes from 5 schemas to 19. Route and section files not named
`.schema.ts` come with them: the walk descends into whatever a validated
template imports, so the default template's routes are now checked through the
layouts that compose them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng you

The module toggle in global settings edited `Space.enabledModules` — a per-space
field, on a page of global settings, which is why toggling it there felt like it
belonged somewhere else. It did. The layering the seed already described is now
built:

  seed              what this deployment ships
  installedModules  what I want available, anywhere      (AgentSettings, mine)
  enabledModules    what this community runs             (Space, shared)
  mutedModules      what I want to see here              (mine, per space)

`activeModules` intersects them, and the chrome gate and launcher rail read that
rather than the community's decision alone: a space saying yes is necessary but
not sufficient. Each layer independently falls back to the registered set when
undecided, so an agent who never opens the setting and a space that never
decided both keep exactly what they had.

Both personal layers live in the root dataset. Writing "modules I have muted"
into a shared space would sync it to every other member — a privacy leak wearing
a setting's clothes. `SpaceModulePreference` is keyed by dataset id rather than
by `Space.url`, because a personal space has no url and this has to work for one.
It stores exclusions, not inclusions, so a module the community enables later
still appears: silence about a module means "no opinion", not "no".

Settings → Modules is now the agent layer and no longer needs a space to be
open. The per-space page carries both switches side by side, labelled "For me"
and "For everyone", and explains the states a switch cannot: a module the
community runs that you have not installed reads differently from one you
installed that the community has off, and the remedies are in different places.
Without that, the page would show an "on" switch beside a module that is not
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things that made the shell feel like it moved you somewhere:

Exploring the marketplace switched dataset. `joinSpace` focused what it joined,
so the one-time join button quietly moved you out of the space you were in while
the marketplace still looked like an overlay above it. Its routes name
`datasetStore.marketplaceDataset` explicitly and never needed it to be current,
so joining takes a `focus` flag and the gate passes false. Every shell overlay is
a layer over the space underneath — that property is what lets one host
space-scoped things at all.

There was no way into a space's settings from inside the space. `openShellView`
takes an optional path now, claimed by the overlay's router as it mounts, since
the navigate function does not exist until then. Taken rather than read, so a
later open with no path does not replay the last one.

The module rail carries that entry point, and now renders whenever you are in a
space rather than only when a module has a launcher. Disappearing when nothing
was enabled was defensible while the rail held only launchers; once it holds the
way into module settings, the empty state was the one state you could not
escape from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nce overrides

`resolveInitial` only resolved *object* tokens, so a `$localState` seeded with a
`$`-prefixed string — a context reference like `'$space.name'`, which every other
position in a schema accepts — was stored verbatim. The space settings form
showed the literal text `$space.name` in its name field and `$space.description`
in its description: no error, and the fields looked filled in. Strings resolve
now; one that matches no context key or store global still comes back unchanged,
so a literal merely starting with `$` is unaffected.

On top of that, a space's template and theme can be overridden per agent.
`Space` holds what the community set and every member sees; `SpacePreference`
holds what I decided about that community and nobody else sees — which modules I
want here, and which template and theme I want when I open it. It replaces the
module-only record added earlier, unreleased and so with nothing to migrate: one
record per space rather than one per concern, because these are read together and
the alternative was three round trips to answer "how do I want this space set up".

An override names *which* template rather than whether to use my own, so it
subsumes the older `SpaceTemplatePreference` binary and also answers for a space
that set no default at all. That record stays for now — it holds data already
written, and replacing it is separable.

`''` is a real picker option labelled "Use the space's default", not an empty
state. Someone who has overridden needs a way back, and the option list is built
in the store because a schema can $map an array into options but cannot prepend
one — without that entry, overriding would be one-way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…space overrides

Scoping already existed — `themeScope`, with a scoped mode that leaves your theme
on documentElement and renders the space's inside a wrapper whose CSS self-scopes
by attribute selector. Three things were wrong with it, none of them the
mechanism: it defaulted to global, it was an ephemeral signal that reset every
boot, and its only control was a globe icon on the theme editor's toolbar, where
no ordinary user would find it.

It is persisted to `AgentSettings.themeScope` now, defaulting to scoped, and
surfaced in Settings → Appearance. Scoped is the default because the failure
modes are not symmetric: a dark or low-contrast community theme taking over the
whole window makes the settings page hard to read, and that is the page you would
go to to undo it. The other way round, a space merely feels less immersive. So
immersion is the thing you opt into.

The toolbar globe stays, as a preview. Authoring a theme means flipping it to see
how it reads either way, and persisting that would silently rewrite a preference
the author notices days later on a settings page that had quietly changed. It
lasts the editing session, and its tooltip names the setting it is masking so the
revert is expected rather than mysterious. Effective scope is now derived —
`override ?? preference` — with one effect reconciling the DOM, so the toolbar,
the settings switch and the clearing of a preview all take the same path. That
derivation is also what made persistence possible: the previous two imperative
branches lived inside the toggle, so nothing would have replayed them at boot.

Per-space overrides gain a third answer. "Follow my default" is behaviourally
distinct from naming that default concretely — change the global default later
and the following spaces move with you while the pinned ones stay put — and it is
exactly what `SpaceTemplatePreference.preference = 'user'` always meant. With all
three expressible, that record is fully subsumed and is migrated across the first
time a space with one is opened, then deleted. Migrating on read rather than in a
boot sweep touches only spaces actually visited and cannot fail halfway through a
pass.

Both pickers also name what an option resolves to — "Use the space's default
(Flux Feed)" — using the defaults already carried on each spaces-list row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`createMemo` runs its body immediately, so a `const` it references must already
be initialized — `templateOverrideOptions` and `themeOverrideOptions` sat above
`spaceForUuid` and threw at provider construction, taking the whole app down.
TypeScript cannot see it: the reference is inside a closure, so it has no way to
know the closure runs eagerly.

Moved rather than made a function declaration, so the ordering stays the thing
that documents the dependency, with a note on the block saying why order matters
there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…be stored

Choosing it reverted on the next read. `Ad4mModel.innerUpdate` skips any property
whose new value is `''`, so writing one leaves the existing link in place — the
write reports success, the refetch returns the old id, and the picker snaps back
to whatever was last chosen. An empty string is simply not a storable value
through the ORM, which makes it the wrong way to say "no override".

So "follow the space" is a named value like "follow my default" already was, and
reads normalise anything falsy to it — a record written before these fields
existed has no value, and a picker bound to `''` would show blank with no way to
select back.

Worth knowing beyond this: the same rule means no `@Property` can be cleared to
an empty string through `save()`/`update()`. Clearing a space's description has
the same silent no-op, which predates this branch and is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A light space under a dark personal theme rendered light surfaces with white
text on anything that did not set its own colour.

The global stylesheet sets `color: var(--we-color-neutral-1000)` on
`html, body, #root`. A custom property is substituted where the declaration
lives, so that resolves against documentElement — the personal theme, in scoped
mode — and inherits down as a finished colour. The wrapper re-declares the token
for its subtree, which does nothing to a substitution that already happened
higher up, so the white kept coming.

`background-color` needs no equivalent: it does not inherit, which is why
surfaces looked right and only text leaked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fourteen places

The flicker was an ordering artefact. The scope transition wrote a signal — which
the scoped wrapper renders on Solid's next flush — and documentElement, which
lands immediately. So for one frame the whole window wore the personal theme
before the wrapper caught up, and the theme visibly went forward, back, and
forward again. It only showed when a paint fell between the two writes, which is
why it was intermittent.

Both surfaces are derived now:

  documentTheme        scoped ? personal : (editing ?? current)
  activeTemplateTheme  scoped ? (editing ?? current) : null

One effect applies each. They read the same signals, so they change in the same
flush and there is no intermediate state to paint. That takes documentElement
from fourteen imperative writers across six functions and three effects down to
four: the reconciling effect, the pre-reactive boot paint, and two deliberate
`getComputedStyle` probes in the editor that revert on the next flush anyway.

It also removes the class of bug where two writers disagreed — there is one
answer now, and it is computed rather than remembered. `spaceThemeData` and
`pendingSpaceThemeId` go with it: the first was tracking what `currentThemeId`
already knew, and the second existed because `replaceTheme` could be called
before a space's themes had loaded. `currentTheme` resolves against `allThemes()`,
so that now settles by recomputation when they arrive, and until then falls back
to the agent's own theme rather than a light default — a slow load reads as "not
themed yet" instead of flashing white.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrolling a template past the fold revealed the shell's background underneath it.

Three things combined. The scroll container had no background of its own, so
anything the template's content did not cover fell through to the canvas — which
`html, body, #root` paints from documentElement's tokens, i.e. the shell theme.
The space theme sat on a `display: contents` div inside it, which declares the
space's CSS vars but generates no box, so it could never paint. And the template
root was `height: 100%`, exactly viewport-tall, so a longer route overflowed the
box and everything below the fold was bare canvas.

The theme and the scroll container are now one element. A scrolling element's
background covers its whole scrollable overflow area rather than just the visible
box, and the vars are declared on that same element, so the background resolves
from the space's tokens and no template can leak the shell's however it handles
its own height. Overlays are siblings and correctly stay on the shell theme.

Template roots move from `height` to `minHeight` as well. That is no longer what
prevents the leak, but it is what a page background wants on its own terms: fill
the viewport when a route is short, grow when it is long. Added to the schema
rules, next to the existing instruction to set `bg` on a root — a rule that was
half the story, since a background that stops at the fold is not much better than
no background at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Joining by link was already built — `SpaceGate` has a join prompt for an unjoined
space and `joinSpace` takes a URL, a `neighbourhood://` URI or a bare id, and
there is an effect whose comment says it handles deep links and page refresh.
None of it was reachable, because `ShellStore` opened the landing-page overlay on
every boot regardless of the URL. The routing underneath worked and nobody could
tell. That is also what made shared links pointless: arriving by URL is the only
way anyone uses one.

The landing page now opens only when the path is `/`. Anything else is somewhere
that was asked for, so covering it is the wrong default.

With that reachable, the two ends of sharing are worth having. A space's settings
page shows its link and copies it — the web URL where there is an origin worth
putting in front of a path, the `neighbourhood://` URI otherwise, since a desktop
build has neither an origin nor an address bar. Both are accepted by `joinSpace`,
so whichever form arrives is the form that works. And Settings → Spaces gains a
field to paste one into, which is the only route in on desktop.

The `+` on the sidebar's spaces group is the other half: creating a space meant
going to Settings first, which is a long way round for the thing that group is a
list of. `SidebarGroup` gains an `action`, rendered as a sibling of the header
rather than inside it — the collapsible header is a button, and nesting one would
be invalid and would toggle the group on the way past. The modal moves to shell
chrome behind `shellStore.createSpaceOpen`, beside the remove-account modal that
is there for the same reason: two places open it, so it can belong to neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hovering the sidebar made everything jump up, a horizontal scrollbar appear, and
then both undo themselves. Two things, one of them latent long before the group
action arrived.

The header is a flex row now, and its button kept `width: 100%` — the full row
to itself, with the action button added beside it. A flex item defaults to
`min-width: auto` and the label is `white-space: nowrap`, so it could not shrink
to make room, and the row overflowed. Worst mid-expansion, when the container is
still narrow: hence a scrollbar that comes and goes. The header takes `flex: 1`
and `min-width: 0` instead, which is also correct when there is no action — one
flex child fills the row.

That only became visible because `__items` set `overflow-y: auto` alone. Per
spec, `visible` on the other axis then computes to `auto`, so the sidebar has
always been able to scroll sideways, and a horizontal scrollbar takes height from
the bottom and shifts the content up. Anything wider than the sidebar would have
done this. It is `overflow-x: hidden` now — labels already ellipsis and icons are
fixed width, so there was never anything to reach by scrolling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refreshing inside a space showed "Join this Space" for a frame before the page
appeared. The gate asked `currentDataset` — which is null for two different
reasons. One is that the agent has not joined. The other is that the dataset list
is still arriving, and then the switch to the matching dataset is itself async.
The gate could not tell those apart, so it answered the question before anyone
knew the answer.

`datasetStore.datasetsLoaded` records that the backend has answered, exactly as
`accountStore.accountsLoaded` already does for the boot screen — an empty list is
otherwise indistinguishable from an unfetched one. It is set in a `finally`: a
failed list is still an answer, and holding every gate in "resolving" for the
rest of the session would be the worse failure.

`spaceStore.routeSpaceUnjoined` is what a gate should read — the list has
arrived *and* nothing in it matches the route. Both halves matter: a matching
dataset that has not been switched to yet is not grounds for asking someone to
join either. It is false while the answer is unknown, so the gate renders nothing
rather than guessing, and a genuinely unjoined space still gets the prompt the
moment that is known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…get a toggle

"For me" never turned anything off. `{ $not: '$event.detail' }` in `$action` args
is an operator object, so it is evaluated once at render time — before any event
exists — where `'$event.detail'` is an unresolved `$`-string and therefore truthy.
The argument was a constant `false`, so every click un-muted. Only *bare*
`$event`/`$arg` strings survive that pass to be substituted at call time.

`setModuleVisible` replaces `setModuleMuted`, phrased the way a switch reports so
it takes the event value directly; storage stays a list of exclusions and the
inversion happens in the store, where it can be seen. This is the second time the
trap has bitten, so the validator now rejects an event reference nested inside an
operator within `$action` args — it can never resolve, and nothing errors at
runtime, so it is only findable by noticing a control that does not work.

The rest is the reason a globe toggle did nothing. Modules contribute different
kinds of thing, and only some of them are switchable:

  chrome       launcher or slots        notes, call    off hides the chrome
  app          an embedded application  flux           off withdraws the app
  capability   components only          globe          nothing to hide

`moduleSurface` derives that from the definition rather than asking a module to
declare it, so a new kind needs no change here or in the settings pages.
Capability modules leave the per-space list — a space does not run one, a
template uses one — and are listed globally without a switch. Uninstalling one
would take a component out from under whatever template uses it and the route
would just stop rendering; what would make it safe is templates declaring the
modules they need, which does not exist yet.

The per-space "for me" switch is also disabled while a module is not installed.
The page already explained that state and then left a live control next to the
explanation that could not act on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Toggling Flux changed nothing, and the reason turned out to be a gap in the
taxonomy rather than a missing filter. `apps` was `moduleRegistry.embeds()` read
once at construction and never filtered by anything, so the app sat in the
sidebar whatever the settings said.

The obvious fix — gate it on `activeModules` like other module chrome — is wrong.
An app switcher renders in the *shell*, beside Spaces rather than inside one, and
`PersistentAppFrames` deliberately keeps its iframes alive across navigation. A
per-space gate would make an entry come and go as the agent moved between spaces
that have nothing to do with it, and would tear down a live session as a side
effect of walking into a space.

So the rule is that a contribution is gated at the layer where it renders:

  chrome      launcher, slots    inside a space                  agent + space
  app         an embed           in the shell                    agent only
  capability  components only    wherever a template mounts it   agent only

Chrome is the only surface a community decides about, because it is the only one
that appears inside their space. The per-space list is chrome-only now, and the
app switcher filters on the installed set — which also means uninstalling is a
deliberate act rather than something that happens as you navigate.

Both other surfaces come out the same way: the template decides. A community that
wants no globe view or no Flux route uses a template without it, which is a
mechanism that already exists.

That is now safe to rely on, because a template's module dependencies are
knowable. `collectComponentTypes` walks a schema for the components it mounts and
`moduleRegistry.requiredBy` maps those to the modules that supply them — derived
rather than read from `meta.components`, which no template in the repo fills in
and which would be stale the moment one did. Uninstalling a module the visible
template mounts is refused and says which template needs it, and `missingModules`
names the state where a template mounts a component nothing provides, which
previously rendered nothing with no way to find out why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two collapses on two different async paths, which is why it moved twice.

`we-text` is `display: block`, and an empty block has no line box, so while the
space was still arriving the name occupied no height and then snapped to a full
heading line. `AvatarStack` is a flex container over its avatars, so with none it
likewise had no height — and members resolve separately from the space, later,
pushing the header down a second time.

Neither is fixed by pinning a height. The header was presenting "not loaded yet"
as fact: an empty name, no members, and — worse than a jump — the description
condition tests `description`, which is falsy while unloaded, so it asserted "No
description..." about a space it had not seen. That is the same mistake the join
gate was making, and the signal to avoid it already exists: `currentSpace` is
null until it is known.

So the name and description wait on the space and hold their place with a
skeleton, which reserves the same room and says why it is empty, and the absence
of a description is only claimed once there is a space to claim it about. The
members row takes a fixed floor, which is right rather than a workaround there:
it holds fixed-size avatars, so its height depends on neither the count nor any
font metric. 32px is `avatarSize.sm`, what it renders at once filled.

The images were never involved: `EditableImage` puts `height` through
`buildLayoutStyles`, and the cover and avatar are given fixed values, so both are
stable from first paint. The cover's `aspect` only sizes the crop modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reserving space for text that has not arrived needed a hand-authored skeleton
beside every async string, given a height nobody could derive. Both attempts at
that number were wrong in instructive ways. `em` resolved against the skeleton's
own inherited font-size rather than the heading variant's — which lives on the
`we-text` that is not being rendered — so it was measuring a different font
entirely. Measured pixels were right only for the current theme: `fontScale` is a
supported override, so a scaled theme brings the jump back for some agents and
not others.

The element that knows a line's height is `we-text`, so it does both jobs now.
An empty one holds `1lh`, which kills the whole class of shift for text that
simply arrives late, with no template change anywhere — inline text still
collapses, which is right for a run inside a sentence. And `loading` renders a
real `we-skeleton` at that same line height, so a deliberate placeholder is one
prop rather than three nodes and a magic number. `loadingWidth` is left to the
author, being the one thing the element genuinely cannot infer: how wide text it
has never seen would have been.

The space header collapses back to single nodes, and the same fix reaches
`AboutRoute` and `SpaceSidebar`, which had the identical gap. Both also asserted
"No description..." while unloaded — the condition tests `description`, falsy
whether the field is empty or the space simply has not arrived, so the container
is tested first now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit f7252f2
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7796a2c38e1a00084aaea0
😎 Deploy Preview https://deploy-preview-109--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 c60a035 into dev Aug 8, 2026
3 of 5 checks passed
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