Feature: Module system and space settings - #109
Merged
Conversation
…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>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 layersthe seed described but never built.
installedModulesis what the agent wants available anywhere;SpacePreferenceis what they decided about one space — muted modules, and template/themeoverrides. 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.ts—moduleSurfacederives what amodule 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:
chromeappcapabilityChrome 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
CesiumGlobetowhichever 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+collectComponentTypesderive a template's module dependencies by walking the schemafor the components it mounts. Derived rather than read from
meta.components, because no templatein the repo fills that field in and the default template plainly mounts
CesiumGlobe. This is whatmakes uninstalling a capability module refusable, and it names
missingModules— a templatemounting a component nothing provides, which previously rendered nothing with no way to find out
why.
SpaceStore—activeModulesintersects the layers: registered ∩ installed ∩ enabled, lesspersonal 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:
navigateToSpacecloses the shell overlay, so a page bound to where you arestanding 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 alive 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.
canAdministerSpaceis a predicate rather than an inline author/DID comparison, so creator-only cangrow 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-rootstays 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 issurfaced 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 —
documentThemeandactiveTemplateTheme, one effect each —replacing fourteen imperative
applyThemeToDOMcalls across six functions and three effects. Thatis 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/$argin$actionargs — only a bare reference survives to call time. Wrapped inany 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 nowrejects the nested form, because nothing errors at runtime and it had already bitten twice.
it were dropped everywhere else. The executor already refreshes a SubjectClass registered with a
shape; only WE's own guard stood in the way.
refreshSpaceSdnadiffs declared predicates againststored ones in one SPARQL query.
''cannot be stored —Ad4mModel.innerUpdateskips 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.
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.
$localStateinitials rejected$-prefixed context strings, so a form seeded from a$eachitem rendered the literal text
$space.namein its input.coloris set onhtml, body, #rootand inherits as a resolvedvalue, so re-declaring the token on the wrapper did nothing.
did not cover fell through to the canvas, which the shell theme paints.
we-textnow holds one line when empty and takes aloadingprop that sizes its ownplaceholder. 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:
missingModulesis 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.
landing page succeeds, and the globe route then renders nothing next time. Widening it to every
installed template is small.
ModuleDefinition.settings?: { agent?, space? }, so a modulecarries its own controls instead of the shell hardcoding them. The call module wants both halves
on day one.
SpaceTemplatePreferencemigrates on read, so the legacy model stays until every space hasbeen visited once. It can be deleted after a reasonable window.
@Property. Clearing a space description is asilent no-op today. Needs either a delete-the-link path in the ORM or a storable sentinel.
no-use-before-definewould have caught both TDZ crashes on this branch. Measured at 23 hitsacross 10 unrelated files, all false positives (deferred references in
functiondeclarations),so enabling it is its own cleanup.
SettingsRouteas well. Both render the samestore 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, greentsc --noEmiton 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/$argarg nesting, fragment scope,$localStatecontext refs)@we/schema-solid— 48 tests (2 added:$localStateseeded from a context ref, and a literal$left alone)theme overrides, theme scoping, deep links and refresh, the sidebar
+, share linksNot verified:
only does real work on a pre-existing dataset.
AgentSettingsgains three properties andSpacePreferenceis a new model.AgentSettingsgoes through
installRootrather thanrefreshSpace, so whether a pre-existing root datasetpicks up added properties is unconfirmed — the first thing to check if a setting will not
stick.
populateMissingOverridesrelies on documentElement brieflycarrying the theme being edited; if the light/dark buttons come up wrong, that is the probe.
1lhfloor changes everywe-text. If a layout relied on an empty one taking nospace, that is where an unexpected gap would appear.
CollapsibleSidebarvariant and static (non-collapsible) groups — both sharethe header row that gained the group action, and neither is exercised by the spaces group.
Pre-existing, not from this branch
@we/schema-solidfailstsc --noEmiton two test files referencingBackendConfig.uuid. Presenton
dev; the tests themselves pass, since vitest does not typecheck.