Fix: UI sweep - #165
Merged
Merged
Conversation
Opening profile or settings drew the page at its content height and left the space template showing underneath it. Only in Firefox, which is why it survived the branch it came from. The shell overlay is the one host site where a surface *added* a box. The other three attach to an element that was already there and already sized — the template's surface is the element carrying THEME_SCOPE_ATTRIBUTE, a dock panel's is the panel, and the `$surface` schema node carries its own fill. This one went in between the overlay's scroll container and the view's root node at `height: auto`, and every shell view root sizes itself with `minHeight: '100%'`, so there was nothing definite left for that percentage to resolve against. Engine-dependent, and that is the whole reason it got through: Chrome resolves a percentage through an auto-height ancestor and looked entirely correct, Firefox follows CSS2.1 and treats it as indefinite. Measured in Chrome 150 and Firefox 152, against the real box chain rather than a simplified one — the simplified version passes in both. `height` and not `min-height`, which is the trap here: a `min-height: 100%` box is still auto-height, so the child's percentage is still indefinite and the page still collapses. It costs nothing on a long page, since overflow stays visible and a tall settings route still overflows this box and scrolls the overlay above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A space in a call wore a ring a clear step wider than the avatar inside it, with a band of row background showing between the two, so it read as a detached circle rather than as a ring around a face. `p: '100'` was there to keep the ring outside the avatar rather than over its edge — which the border box model already gives for nothing, since a border is painted outside the padding box and never over the content. So the padding bought no clearance that was not already there and spent 4px on a 32px avatar doing it: a 44px ring around a 32px face. Removing it also gives the rail back most of a jump it should never have had. The mark sets the row's height, so the ring grew a space's row by 12px against every other row in the rail the moment a call started, and shrank it again when the call ended. Now 4px, which is the border, and the border is the ring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tightening the ring moved the glyph inward, which is not obvious until you see why: an absolute offset resolves against the ring's padding box, so the badge's position was silently coupled to padding it had nothing to do with. Taking the padding out shrank that box by 4px on each axis and carried the badge along with it, from the avatar's rim to 62% of the way in — a phone sitting over the face rather than on it. `-4px` puts the disc's centre back on the circumference at 45°. That is where it already was before the ring changed, so this restores a position rather than choosing a new one; the difference is that the number now says what it is for instead of arriving as a side effect of a padding value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks nobody used
Three changes that turned out to be one. Asked where a badge on an avatar belongs, the answer was
"a fragment, and this is why the ring is a wrapper" — and the reason it was a wrapper was two
decorations on `we-avatar` that nothing has ever set.
**`we-avatar` loses `selected` and `online`.** Each was a fixed colour in a fixed position behind a
boolean, and neither had a single caller anywhere: not a template, not a schema, not a line of TSX.
Being unused was the smaller problem. `selected` wrote the same `--we-avatar-box-shadow` the `ring`
prop writes inline, so `AvatarStack` — the one component that does ring an avatar — silently
overrode it. `online` claimed the bottom-right corner, which is where a status badge goes, so the
rail's live-call mark was built as a box *around* the avatar specifically to avoid reading as
`online`. A decoration nobody used was shaping the design of the one people did.
**One tone vocabulary, in the token layer.** `AvatarTone` and `avatarToneRing` now live beside the
hues they name, and `AvatarStack`'s five hand-written box-shadows are gone. There were two spellings
of the same idea — scale positions in the stack, role names in the rail — so `success` meant two
different colours depending on which grammar you wrote it in. Scale positions won, on the stack's
own argument, which is preserved: a tone labels a person by a category the caller assigns, and a
theme pinning `dangerText` is deciding about error text, not about whoever is marked busy.
**`badgedAvatar`, in the schema kit.** A face, optionally ringed, optionally marked. The ring is now
the avatar's own `ring` prop, which the deletions above made available — so it takes no part in
layout (a call no longer changes the height of a rail row at all, rather than by 4px), and it
follows the avatar's border-radius, so a theme that squares avatars off gets a squared-off ring
instead of a circle cutting the corners.
Everything is derived from `size` as `calc()` over the size token, and that is the point of the
extraction rather than a detail of it: the geometry was welded to 32px. The `-4px` that puts the
disc on the rim of an `sm` avatar leaves it 1.5px short on `xs`, where an 18px disc is also three
quarters of the face — silently, with nothing to say so. Measured at every size in Chrome: the
disc's centre now lands on the circumference to within 0.1% of the radius from `xs` to `xl`.
`live` on a rail row consequently needs an avatar, which is what it always documented itself as
ringing.
The schema validator gains `calc()`/`min()`/`max()`/`clamp()`/`env()`/`var()` wherever a prop
accepts `{css-length}`. It rejected them, which is a gap rather than a rule — the runtime resolver
has always passed them through, and a primitive given a custom size writes the string straight onto
its own variable. A derived value was simply the first thing to write one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A panel that floats is a card over the app, and a card you cannot see past reads as a hole cut in the window rather than as something on top of it. At half opacity with the backdrop blurred, the space stays legible behind it and the panel is obviously in front of something. A displacing panel gets none of it, for the same reason it has no radius and no shadow: it has *taken* its room rather than borrowed it, meets the content edge to edge, and is not on top of anything there would be any point seeing through it to. Maximised needed excluding by hand, and that is the only part of this that is not one line. `resolveDock` reports a maximised panel as `floating: true` — correctly, since it is not displacing and every rule about taking room from the content wants the two together — so the frame's existing `floating` test would have made a full-window panel translucent over a full-window blur: expensive, and it puts the template *through* the panel you just asked to fill the screen. So the geometry now carries `maximised` alongside `floating`, and the glass reads both. `color-mix` toward transparent rather than an opacity on the box, since opacity would fade the panel's contents along with its background and take the text with it. The blur is gated on exactly the same condition as the transparency: over an opaque background it would buy a stacking context and a containing block for fixed descendants, and nothing anyone can see. The titlebar goes translucent too, so the card is one piece of glass rather than a solid bar stuck to a transparent body. It needs no blur of its own — it is inside the frame, which has already blurred everything behind the whole panel — and its 50% over the frame's 50% settles at about 75%, which is the right way round: the part you grab is more solid than the part you read. `dockRegistry` sits outside the schema validator's walk, so the conditions are pinned by a test instead. Both halves fail silently otherwise: a mistyped store path resolves to undefined, which is falsy, and every panel would simply stay opaque. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed: the frosted-glass mechanism already existed and this had reimplemented it. `surfaceOpacity` and `surfaceBlur` are theme overrides resolving to `--we-theme-surface-opacity` and `--we-theme-surface-blur`, and `Card` and every overlay primitive — modal, drawer, popover — already paint from one shared expression built on them. Writing `50%` and `blur(12px)` into the frame made the panel the one surface in the app that could not hear a theme: setting `surfaceOpacity` would have restyled every card and every modal and left the panels exactly where they were, with nothing to indicate why. The mix space was wrong too — `in oklch` against `in srgb` everywhere else. Now the same expression, character for character apart from the fallbacks. Those differ on purpose: a theme that says nothing leaves cards and modals opaque, because most surfaces sit *in* a page rather than over one and glass on all of them by default is a look nobody chose. A floating panel is the opposite case — it is chrome laid over the app, and seeing what it covers is the point of it floating rather than displacing — so it defaults to glass, and a theme overrules it either way. The test now pins the wiring rather than the appearance. Hardcoded numbers passed every assertion the first version made, which is the tell: what was worth protecting was never "is it translucent" but "does it read the theme". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
More of the space shows through. Still only the fallback — a theme setting `surfaceOpacity` decides this for panels as it already does for cards and modals. The titlebar's note went with it: it quoted a composite figure that was only true at 0.5. Its 0.3 over the frame's 0.3 now lands around half, which keeps the ordering the note is actually about — the bar you grab reads more solid than the body you read, at whatever the theme sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pencil beside a template's row was gated on that row being the one already on screen, so for every other template it was simply absent. The way to reach it was to pick the template — which closed the menu — open the menu again, and click the pencil that had now appeared. Two of those three steps existed only because of the gate. The row's own control acting on whatever happened to be selected is the exact thing `pickerRow`'s `actions` were introduced to stop: "the controls they replace acted on whatever was *currently* selected, so editing something you were not already using meant selecting it first and then finding the control again." The fork action beside it had been switching first all along; edit had not. `editorStore.isReadOnly` could not answer this. It describes whichever template is rendered, so every row got the same verdict — which is why the gate needed the identity test bolted on beside it, and why removing that alone would have offered editing on built-ins. So a switcher row now carries its own `editable`, derived exactly as `isReadOnly` is: through `isBuiltInTemplate`, not the bare id predicate the "Built-in" *group* is filtered by. The two differ on a built-in you have saved over — it has stored overrides, so it is editable while still belonging to that group. Both halves fail quietly and in opposite directions, so both are pinned by a test: gate on the wrong thing and the control vanishes, which reads as "not offered here"; drop the switch from the click and it appears to work, opening a session over whichever template was current while you edit something you did not click. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er exists Loose end from removing `selected` and `online` from `we-avatar`. The retro theme squared off the ring that `we-avatar[selected]` used to draw, and with the attribute gone the rule matches nothing. Caught by `themeSelectors`, which reads every theme's CSS and refuses a selector naming an element, part or attribute the primitives do not declare. Exactly the check that should have caught this, and it did — the reason it did not fail on the run before the commit is that it reads the primitives' build, and that build was still the old one until `pnpm build` refreshed it afterwards. Tests after a rebuild, not only after an edit. Nothing replaces the rule. `selected` had no callers to lose, and what stands in for it now is the `ring` prop — an inline box-shadow, which a theme stylesheet does not get to override anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening the default template in the visual editor, every section was inert: clicks passed through the whole thing and selected a shell container several levels up, and nothing said why. A view is a *different template* from the shell around it, and nothing downstream could tell. The editor resolves a click to the nearest `data-we-node-id` and looks it up in the template being edited; a view's nodes carry no such id, because `ensureNodeIds` runs on what becomes `currentTemplate` and a view is never that. Measured before writing anything: 2127 nodes across the six bundled views, none with an id. So `expandViewRoutes` marks where one template stops and the next begins, and the editor's hit-test reads both markers in one `closest` — the nearer wins, so a click inside a section can never again be answered by a shell node that merely happens to be the nearest thing carrying an id. The section outlines and names itself instead, in grey and dashed, because every other outline in there is a colour that means "this responds to you" and this one means the opposite. Deliberately not a selection and deliberately not an "Edit" button yet: a view cannot be edited from anywhere today, so a button would have nowhere to go. What this fixes is the editor lying about what it can edit. `PR_VIEW_EDITING.md` carries the rest. The marker goes on the view's own root where that root is a Solid component, and wraps it in a `display: contents` box where it is a custom element — the renderer delivers a web component's props as DOM *properties*, so an attribute selector would never match one, and the boundary would go missing on exactly the views nobody wrote, looking identical to the bug this removes. Every bundled view roots at a Column via `pageShell`, so today that branch is unreached; it exists because the failure it prevents is silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Lit primitives and the Solid layout components disagreed about which state wins when two are true at once. `ELEMENT_STATES` put hover before focus; this stylesheet put focus before hover. At equal specificity that is the opposite precedence — two lists written at different times, with the answer stated in neither. It matters because a state rule declares *every* property in the set and falls back to the base value for whatever it does not set. The winning state therefore discards the loser's values wholesale, including properties only the loser mentions. With hover last, the hover rule's `box-shadow` resolves to base and **takes the focus ring with it** for as long as the pointer rests on the focused element — which is the ordinary case of clicking into a text field, not a corner. So focus wins, and the Solid sheet moves to match the primitives. The old order was justified as reproducing the JS merge order `useStateProps` computed before the stylesheet existed; that is a description of what the code did rather than an argument for it, and what it did was drop the ring. The cost is real and is the lesser one: a focused element shows its resting fill rather than its hover fill while the pointer is over it, because focus stays quiet about what hover sets. Where that matters the call site restates it — which is the next commit. Two tests rather than one, because the generators live in packages that do not import each other and it is the *divergence* that is dangerous: a single component getting this wrong is visible, two generators disagreeing is not. Both were checked against a reversed order and fail on exactly the assertion that names the ring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ting inside it Four controls, three different answers to focus, and one of them left a grey line visible inside a blue one — two indicators where there is one thing to say. `we-input`, `we-textarea` and the `outline` button painted a 1px border and then a 2px ring outside it. `we-select`'s trigger — the control the input was given a border to match in the first place — recoloured its border to the accent and drew an inset 2px `accent-muted` halo, a third spelling that read as a glow *inside* the edge rather than as the edge changing. Beside each other in a row, they did not look like one family. They now share one treatment: the resting outline takes the ring's colour and the ring is a single pixel outside it, so the two mechanisms draw **one** 2px perimeter — the line that was already there, thickened and recoloured, which is what a control gaining focus actually does. The growth has to come from the ring rather than the border, and that is the part worth writing down: `border-width` lives inside the border box, so animating 1px → 2px shrinks the content box and nudges the text sideways mid-transition. A `box-shadow` is outside layout entirely, and interpolating one from `none` pads with a transparent zero-spread shadow — so the second pixel grows outward instead of appearing. Both halves are in `ANIMATABLE_STATE_PROPS` on one duration and easing, so they travel together without being asked to. Filled buttons keep the shared 2px ring: with no outline to recolour there is nothing to double up on, and both spellings land on 2px of accent. The `outline` variant restates the ring in its own `focusProps` because `mergeProps` is a shallow spread — a variant's state object replaces the base one, so omitting it would have traded the ring for a border rather than adding one to it. Two things go with it. `we-input` and `we-textarea` lose their `transition` prop: it was buying four times the default arrival and charging for it on the way out, since one prop feeds the state rules *and* the base rule, and departures are meant to snap. `we-select`'s transition moves onto its `:focus-within` rule for the same reason, and gains `box-shadow` so the ring stops popping in over an edge that is still moving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings in the contribution-surfaces docs sweep (#162) and the space settings panel (#164). One conflict, in `packages/ai-context/src/schemaContext.ts` — a *generated* file, and both sides had regenerated it. Resolved by regenerating from the merged fragments rather than reconciling the output by hand: `fragments/stores.ts` merged cleanly, carrying dev's new store surface and this branch's `switcherGroups.editable` doc, so one `generate-context` run produces the correct combined output for all seven derived files. Hand-merging generated text would have been guesswork that the next regeneration silently overwrote. Worth knowing where the two halves meet: #164's space settings panel goes through `dockFrame`, so it inherits this branch's glass treatment. It declares no `float`, so it opens *displacing* and is therefore opaque — glass only if somebody drags it off its edge. That is the rule working, not an exception to it. Verified on the merged tree: full build clean, 3172 tests pass, 31 schemas validate (up from 30 — `SpaceSettingsPanel` is new), and typecheck clean across primitives, app-shell, editor, schema-shared and schema-kit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Better, and less a taste call than it looks: the sidebar was the only place in the app that did otherwise. `CardsView/SpacesList`, `shell/spaces/SpacesList` and `SpaceHeader` all pass `image` + `initials` and have always shown letters. The rail passed `hash` as well — and `hash` outranked `initials` — so the same space with no picture showed generated art in the rail and its letters everywhere else, with nothing to say why. Worse, that hash was seeded with the space's **name**. Every other caller in the codebase seeds from a DID; this one changed a space's picture entirely when somebody renamed it. Identity art contradicting the identity. So `hash` becomes what it always meant — the stable thing a row *is* — and stops deciding what gets drawn. `we-avatar` now draws a picture, else letters, else a generated pattern, else a glyph, and the hash colours the letters. The identicon keeps the one case it exists for: an agent whose profile has not arrived has no initials, falls through, and two unresolved peers stay distinguishable instead of being two identical blank discs. The name arrives and the letters take over. The colour is the theme's own, not a palette bolted on: the `--we-color-*-100` / `-700` pair with the hue swapped, character for character — same lightness step, same saturation, same chroma taper. So it follows a theme's polarity and ramp exactly as `accent-muted` and `accent-text` do, and reusing that *pair* is what makes the contrast safe without measuring anything. Hues are quantised to twelve rather than hashed across the whole circle, and that was measured rather than assumed: twelve real uuids put their closest pair **three degrees** apart, which at avatar chroma is one colour — and two spaces looking *almost* alike reads as a rendering fault, where two looking frankly identical reads as a coincidence. Quantising trades uniqueness for separation, which is the right way round when the letters are the identifier and the colour is the second cue. `badgedAvatar` and `railItem` gain `hash` as its own option instead of deriving it from `name`, so the sidebar can seed from `$space.uuid` — a colour that survives a rename — and a future caller can still pass a DID and get the identicon behaviour people rely on. One visible change beyond spaces: `CardsView/UsersList` passes image + hash + initials, so a person whose name has loaded now shows coloured initials rather than an identicon there. That matches every other place a named person appears, and the unresolved case is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r it sits in Two letters looked squashed at `sm`, and the cause was that `[part='initials']` set no font-size at all — so the letters inherited one. In the sidebar the avatar sits inside a `we-button` at size `lg`, whose font-size is 20px, in a 32px disc. Measured at that size, against the disc's diameter: a single letter takes 46% and reads fine, a pair takes 81% and looks squashed, and the widest pair — `WM` — takes **119%**, wider than the circle containing it. So the report was the visible half of it: only two-letter spaces showed the problem, and nobody had cause to notice that the one-letter ones were oversized too, or that a space named something like "Wide Media" was spilling its letters outside the disc entirely. Now `calc(var(--we-avatar-size) * 0.4)`, which is how the icon branch immediately above has always worked — an avatar's contents should be a function of the avatar, not of the button it happens to be nested in. 0.4 is chosen against the worst case rather than the common one: the widest pair lands at 76% of the disc and a single letter at 30%. The icon uses 0.6 because a glyph is one mark and can afford to fill more than two letters can. `line-height: 1` alongside it, so the text box is the letters and nothing more and the disc's own centring has nothing to fight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One ratio gave "D" and "DT" the same cap height, so a single letter carried half the mass in the same circle and read as undersized — which is how it looked once the pairs stopped overflowing and there was anything to compare against. The correction is optical rather than geometric, so it is a second ratio rather than a bigger one: 0.5 for a lone letter against 0.4 for a pair, which lifts its cap height to 36% of the disc against the pair's 29%. Rendered against a rail of real space names, that is where the two stop disagreeing — 0.45 as a single ratio keeps the imbalance and squeezes the pairs, and 0.55 for the single overshoots into the letter dominating its own avatar. Expressed as a marker attribute and a CSS rule rather than an inline style, so the typography stays in the stylesheet where a theme can reach it, alongside the ratio it overrides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… one of them
Two questions were being answered by one flag. **Is this a dev build** is a fact about how the app
was compiled. **Should developer affordances be visible** is a question a developer is allowed to
have an opinion about — and the opinion they usually want is "not right now, I am looking at what a
user sees". Conflating them means the only way to check that is to build for production.
So `sessionStore.isDevelopment` keeps answering the first and only the first, and `devTools` answers
the second: the same value, minus a switch. `localStorage.setItem('we.devTools', 'off')` and a reload
makes a development build look shipped; removing the key brings the tools back. Nothing turns them on
in a production build — the build stays the ceiling, so this is not a way to reach users with
developer UI from a console.
Schema Tests is the first thing gated on it, behind `$if` rather than a hidden row: the rail is
chrome, and a hidden entry in it is still in the accessibility tree and still found by find-in-page —
a control somebody can reach by keyboard and cannot see.
The call module's fake peers honour the same switch, which is the point of putting it in
`@we/module-shared` rather than in the shell. A preview of production that still showed the `− N +`
controls would be a preview of something that does not exist, and a developer should not have to
remember which affordances opted in.
Read once at start-up on both sides, deliberately: the call module decides at *definition* time
whether its controls exist at all, so its half can never be reactive. A store value that updated
live while that one did not would be one switch with two answers.
The asymmetry is the thing worth testing, and it is: the switch can turn the tools off in a dev
build and cannot turn them on in a production one, whatever it is set to. Plus the case where
`localStorage` throws rather than merely being absent — a browser blocking site data — where the
honest fallback is whatever the build already said.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The switch existed but was a console incantation read once at start-up, so using it for its actual purpose — look at what a user sees, then come back — cost two reloads. And it did not cover everything: the fake-peer controls in a call, the schema-tests entry and RerenderLog each had their own gate. Settings -> Developer now holds it, and `sessionStore.devTools` is a signal, so throwing it takes effect on the press. The asymmetry is the design and is worth stating: the *page* gates on `isDevelopment`, the affordances gate on `devTools`. Gating the way to the switch on the switch makes turning it off a one-way door out of the app's own UI — there is a test for that, because it only shows up on the first real use. - devTools.ts gains `setDevToolsMuted`. Storing only the muted state keeps it to two values, so a later change to what unset means still reaches everyone who never expressed a preference. - The call module's fake peers split their two gates apart. `devPeersAvailable` is now the build flag alone — a production build still carries no node and no callable `addFakePeer`, which was the point of deciding it at module scope — while visibility is the `$if` and a per-solve re-read of the switch, so muting does not leave two synthetic participants on the stage. - The schema-tests double gate is closed: the shell view was registered unconditionally while its template entry was DEV-gated, so a production build had no way to name the harness and a complete way to open it. - RerenderLog stays build-gated deliberately, and now says why: it is a word in the vocabulary a template renders against, not chrome, and the registry is built once. Also corrects a comment that claimed the DEV branch tree-shakes the ~97KB of test schemas out of production. It does not — the module imports them at the top level, and the strings are in dist today. Exclusion needs an import() boundary; that is the next commit, and keeping it separate is deliberate since it touches the boot path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
~97KB of test schemas shipped to every user. The `import.meta.env.DEV` guards around the two registrations looked exactly like exclusion and were not: a branch decides whether a *value is used*, while a bundler answers to whether a *module is reachable*, and both call sites imported the harness at the top of the file. The strings were in dist behind a condition that could never be true there. The replacement is a property of the import graph rather than a flag. Exactly one module names the harness (`schemaTestsView.ts`), and the only reference to it is a dynamic `import()` in the new shell-view registry — so it becomes its own chunk, never fetched in production and fetched on the first press in development. The main bundle drops 2,374KB -> 2,329KB and "Schema Mutations" is gone from it. - shellViews.ts extracts the view registry out of TemplateLayout and splits it in two: eager entries for ordinary chrome, and lazy loaders for views whose *code* should not exist in a build that cannot open them. `resolveShellView` returns an accessor, so nothing about the eager path becomes asynchronous. - TemplateStore's schema-tests entry is removed rather than made lazy. It was unreachable: that list resolves a *template*, and the harness is only ever an overlay opened by `openShellView` — it was not in `templateManagementList` either, so it could not have been made anyone's default. Its only effect was the static import. - The harness is no longer re-exported from either schemas barrel. A barrel export is a static edge for everything that touches the barrel, which is how it got here in the first place. schemaTestsExcluded.test.ts pins the import-graph property, because this is a one-line regression that breaks nothing visible. Worth noting the test itself was vacuous twice before it worked: it exempted the whole harness directory, which exempted the barrel that had caused the bug, and then matched only `import` when the regression is spelt `export ... from`. Both now covered. Two smaller developer-only paths still ship and are deliberately left: RerenderLog (a handful of lines, statically imported by the registry) and the call module's fake-peer canvas, whose prebuilt dist emits `import.meta.env?.DEV` and so is not substituted by the app build. Neither is worth a boundary; both are named here so the claim being made is the accurate one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WE has absorbed most of what the one bundled app (Flux) was here for, and anything another AD4M app offers is now better expressed as a template against the same data. The entry point goes; the machinery stays. `appStore`, `PersistentAppFrames`, `resolveAppUrl` and the seed's `apps` block are untouched, so restoring the group is the only step needed to bring the feature back. Removing the group is the whole of the switch. `appStore.activateApp` had exactly one caller — this group — so there is now no route into an app at all: no stale control and no restored route to guard against. `activeAppId` stays null for the app's lifetime, which every reader of it already handles (the template stays visible, the editor and chrome rail stay enabled). Two things noted in the comment because neither is guessable from the seed: - Emptying the seed's `apps` array does NOT hide this section. `appsWithWe` prepends a `WE` sentinel, so a deployment with no apps configured still renders an "Apps" group containing a lone "WE" row. - It does do something this does not. `PersistentAppFrames` mounts an iframe per registered app eagerly, and a `display: none` ancestor does not stop an iframe fetching its `src` — so a configured-but-unreachable app is still fetched at every boot. Hiding the group hides the feature; clearing the seed is what stops that request. Left as a deployment decision rather than made here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…as no apps
Replaces the previous commit's approach, which deleted the group from the shell
template outright. That was the wrong layer: `templates/shell` is shared by every
deployment, so removing a capability there to express one deployment's
configuration would have taken embedded apps away from any seed that wants them.
The seed is the thing that describes a deployment; it should be the lever.
So the group is now conditional, and WE's own seed declares no apps.
The guard counts `appStore.apps` while the list iterates `appStore.appsWithWe`,
which reads like a slip and is not. `appsWithWe` prepends a `WE` sentinel whose row
means "get back out of an app" — it is never empty, so counting the list it
iterates would be a guard that is always true. That was in fact the behaviour: a
deployment configuring no apps still rendered an "Apps" heading over a lone "WE"
row that did nothing. `apps: []` now means what it looks like it means, for every
deployment rather than just this one.
Clearing the seed also stops the app being *fetched*. `PersistentAppFrames` mounts
an iframe per registered app eagerly, and a `display: none` ancestor does not stop
an iframe loading its `src` — so hiding the group alone would have left a remote
request at every boot for something nobody could reach. The chain is
seed.apps -> an embed module per app (initializeIntegrations) -> moduleRegistry
.embeds() -> appStore.apps(), and with the array empty the loop runs zero times:
no module, no group, no iframe. `appBridge`'s credentialed origins default to the
registered embeds, so no origin is credentialed either.
Nothing else is removed: `appStore`, `PersistentAppFrames`, `resolveAppUrl`,
`initializeIntegrations`' embed path and the seed schema all stand, and the seed
validator already treats an empty array as a supported mode ("native WE app
mode"). Restoring the feature is putting an entry back in the seed.
Also untracks the two tauri seed port maps. They are generated from we-seed.json
by `prebuild:steps`, and `apps/we-tauri/.gitignore` has always said "DO NOT COMMIT
- they are created from we-seed.json at build time" — they were tracked by
accident, which is why lint-staged refused to stage them. Left tracked they would
now differ from the index after every build, since they no longer carry Flux's
8080 entry.
The Flux entry, verbatim, so it need not be dug out of history:
{
"id": "flux",
"name": "Flux",
"icon": "camera",
"image": "https://app.fluxsocial.io/icon.png",
"description": "Social web3 toolkit for communities",
"capabilities": ["perspectives", "languages", "agents"],
"paths": {
"projectRoot": "../flux/app",
"dist": "../flux/app/dist",
"webUrl": "https://fluxsocial-dev.netlify.app",
"devServer": { "port": 3030, "host": "localhost" }
}
}
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it
`apps/we-tauri/.gitignore` says of `src-tauri/src/generated/`: "DO NOT COMMIT -
they are created from we-seed.json at build time". Four files in it were committed
anyway, and were quietly load-bearing: `lib.rs` declares `mod generated;`
unconditionally and calls `generated::setup_seed_servers`, while the generator's
native-mode path returned before writing either `mod.rs` or `seed_servers.rs`. So
in a deployment with no apps — which is now the default — those files were never
generated, and the Rust build only worked because copies were sitting in git.
Untracking them without fixing that would have broken the build outright.
- `writeGeneratedModule` is called on both paths, so `mod.rs` and `seed_servers.rs`
exist whatever the seed says. With no apps the emitted `setup_seed_servers` is a
no-op: no `serve_static_app` import and an underscored parameter, so it compiles
without warnings rather than complaining about what an empty deployment does not
use.
- The native path now calls `updateTauriConfig({})`. `tauri.conf.json` is
hand-authored except for `bundle.resources`, which the generator has always
owned — it was simply never cleared on this path. Left as it was it kept bundling
Flux after the seed stopped declaring it, so a release build would package the app
and start its server, and would fail outright on a machine without a Flux checkout
beside the repo. That was a live inconsistency introduced by emptying the seed.
- `generated/seed-bundle-resources.json` is no longer written, or tracked. It held
exactly what goes into `tauri.conf.json` and nothing ever read it — Tauri has no
way to reference an external file for `bundle.resources`, so the config is the
only consumer there can be. Two copies of one fact, the dead one committed.
- `pretauri:dev` / `pretauri:build` run the generator, so the tauri scripts no
longer depend on a prior `pnpm build` having produced `Cargo.toml`,
`tauri.conf.json` and the Rust module. That gap predates this change; untracking
is what would have made it bite.
`git ls-files --cached --ignored --exclude-standard` now reports nothing.
Verified: the no-apps `seed_servers.rs` compiles standalone under rustc with no
warnings; the apps path still emits byte-identical Rust and restores the Flux
resource when the seed declares it; deleting `src-tauri/src/generated/`,
`src/generated/` and `Cargo.toml` entirely and re-running the generator restores
all of them; and a full `pnpm build` now leaves the working tree clean instead of
dirtying the port maps every time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…odal Three complaints, one cause. `we-modal` set no width, and with `direction: column` + `ax: 'stretch'` its `[part='base']` shrink-wraps to its widest child's intrinsic width — so a modal's size was an emergent property of its longest line of text. A short confirmation came out too narrow to read; a wordy one came out too wide, from the same rule. Both were patched at call sites, differently each time: the widths in the tree were 320, 380, 400, 420, 500, 520, 560, 600, 640, 770, 850 and 900px, in five spellings (`px`, a layout token, `min(…, 92vw)`, `minWidth`, and nothing). That is not eleven needs; it is three needs and eight guesses. - **`we-modal` takes `size`** — `sm` (420) · `md` (640, the default) · `lg` (900) · `fullscreen`, mapping to the layout tokens that already existed for this and were already commented "narrow modals" / "standard modals". Each folds a gutter into its `max-width`, so a modal on a phone is no longer edge-to-edge with its corners under the bezel — which is what the two `min(…, 92vw)` call sites had noticed and fixed with a percentage, giving 30px of room on a phone and 100px on a desktop. Explicit `width`/`maxWidth` still win, so the escape hatch survives as an exception rather than as the only way to have a width. - **Default padding drops from `space-900` to `space-600`** (64px → 32px). 64px is a page section's padding; around a two-line confirmation it was most of the dialog, and is a large part of why modals read as small and empty. - **`confirmModal` covers the cases that forced four hand-written forks.** It took an `openLocal` string and a single action, which could express three of the codebase's seven confirmations. The wizard's "Discard this model?" and "Replace the fields below?" are gated on store flags, its "Remove this model?" on a string, and account removal on a pending record; two of them need a cancel button that does more than close. So the dialog a person is most likely to meet — the one guarding work they have half-finished — was the one with no shared design. `open` and `close` are now expressions, plus `cancel`, `tone`, `detail` and `children`. All four hand-written ones are gone. - **`formModal` is new**, for the ten modals that were a title, some fields, Cancel and Save. They agreed about nothing: four title spellings, three button rows, and two of the ten using the header/footer slots that keep the Save button on screen. It declares the draft on the modal, so closing resets it — which retires the by-hand `onSuccess` clears, and the `$resetLocal: '$scope'` the signal-type form had to sequence between its action and its close. - **Three dialogs were not modals at all**: account removal and both consent prompts were fixed Columns over a literal `rgba(0,0,0,0.5)` at z-index 9998 — outside the browser's top layer, ignoring the theme's `overlay` role, with no focus trap and no Escape. The consent prompt had no dismissal of any kind, so a keyboard user who could not reach its two buttons was stuck. `surface-audit` now reports no hand-rolled scrims. Also: the 11 block-input modals lose their `p="500" width="320px" r="300"` and `ax="center" minWidth="400px"` overrides (`ax: 'center'` is the shrink-wrap trap `modal.ts` documents), and their titles converge on `heading-md` from five spellings. `EditableImage` asked for a modal size from its crop aspect instead of pushing the sheet wider from the inside with a computed `minWidth` of up to 1100px, which had no answer for the viewport and simply overflowed. Verified: `pnpm -r test` (all green, 81 in the kit), `pnpm lint`, `pnpm build`, `validate` (31 schemas, no issues), `role-audit` (0), and `surface-audit` (no `rgba(0,0,0,0.5)` left). Regenerated the AI context, whose modal recipes now teach `size`, `confirmModal` and `formModal`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`we-modal` closes on a backdrop click and on Escape. The backdrop is every pixel that is not the sheet, Escape is next to the keys people reach for while writing, and neither is recoverable: a modal is `$if`-mounted, so closing unmounts the draft with it. Somebody three paragraphs into a post who brushed the trackpad had no way back. One modal in the app guarded against this — the model wizard, through `shapeStore.requestCloseWizard` — and it was the only one. - **`discardGuard`** returns the three pieces a guard needs, because a modal cannot be guarded from outside it: the flag has to be declared *on* the modal (so it dies with the draft), the confirmation has to be *inside* it (so it can read that flag), and `close` has to be *replaced* (so the backdrop asks). `close` becomes a `$if` over the dirty expression, which resolves at click time — pinned by three tests in `schema-solid`, since a `$if` action in a plain callback prop misses the handler- array path entirely and had no coverage. - **`formModal` takes `discardWhen`; `composerModal` guards by default.** Opt-in on the first because a one-field form is not worth the interruption, on by default on the second because a composer is where the most work is and the one place a template author cannot write the guard themselves. - **`BlockComposer` reports `onDirtyChange`.** Its content lives in Lexical, so no `$local` can see whether anything was typed, and without it the composer guard could only ask *every* time — including when somebody opened it and immediately changed their mind, which is the surest way to teach people to click through the dialog that matters. Baseline comparison rather than Lexical's dirty flags, which mark every node dirty on the `setEditorState` that loads a post to edit. - **Two stores learned to answer it.** `recordStore.recordDraftDirty`, because a record form's fields come from the model and a shape defined this morning has properties no schema was written against; `runtimeStore.aiFormDirty`, compared against a snapshot taken when the form opened, so looking at a model's settings and closing again asks nothing while a pasted API key is not thrown away. - **Guarded**: create-space, every composer (post, edit, reply, card, message, channel), new task, new event, edit call, new signal type, new relationship type, the record form and the AI model form. Left unguarded and said so: the two single-field "name this" modals. Two things fell out of the work: - **Escape reached every open overlay.** Each listens on `document`, so once modals actually stacked, Escape on a confirmation dismissed it *and* re-ran the close that had raised it. `OverlayElement` keeps a stack and only the topmost answers the keyboard — which was already latently wrong for the model wizard's own discard dialog. - **`confirmModal` spliced `onSuccess` onto whatever it was given.** Only `$action` has lifecycle hooks, so a `$setLocal` confirm silently never ran the close. Caught by the new kit test rather than by reading; the synchronous case now composes a handler array instead. `templates/kit/CONVENTIONS.md` and the AI context both carry the pattern, including the part that goes wrong: `dirty` must test only what the person typed. A field with a default and a picker is set from the first frame, so including it fires the guard on an untouched form — and a dialog people learn to click through is worse than no dialog, because it costs them the one time it was real. A form seeded from a record asks whether it *changed*, not whether it is filled in. Verified: `pnpm -r test` (86 in the kit, 108 in schema-solid, 629 in app-shell), `pnpm lint`, `pnpm build`, `validate` (31 schemas), `role-audit` (0), `surface-audit` (no scrims). Regenerated the AI context. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A transcriber emitted one run-together ~200-character "word" into a call card, and the card — and the route holding it — stretched off the screen. The fix is not on that card: we-text had no wrapping rule at all, so this was reachable from any bound string anywhere, and it was already being patched by hand at twelve sites through the raw `styles` escape hatch, in three spellings. `overflowWrap` is a new typography prop whose spec fallback is the default, so every typography component emits `overflow-wrap: var(--we-<name>-overflow-wrap, anywhere)` on [part='base'] — a node that says nothing gets a breakable line, a node that sets the prop overrides it. The fallback has to live in the spec table rather than in each primitive's stylesheet: the DS sheet is adopted last, so a var() with no fallback would resolve invalid-at-computed-value-time and clobber an earlier rule. `anywhere`, not `break-word`, and that is the whole reason this is a prop rather than something left to `styles`. The two break in the same places; only `anywhere` also reduces min-content width. A flex item and a `1fr` grid track are both sized by min-content — gridWrapper emits `repeat(N, 1fr)`, and `1fr`'s automatic minimum is `auto` — so under `break-word` the unbreakable string still propagates its width out to the route. It is the value that looks right and does not fix the bug; a test pins the choice. `word-break: break-all` is deliberately not offered: it breaks ordinary prose too, and on a bare identifier (what every site reaching for it held) it renders identically to `anywhere`. Scoped to the layout components too, via LAYOUT_DEFAULTS. `overflow-wrap` inherits, so Column/Row/Grid/Card also cover text they hold directly — a bare string child, a native <p> or <span> in a template, rendered block content. Without that, only text that happened to be wrapped in we-text would break, which reads as inconsistent styling rather than as a rule. markdown and html drop their hand-rolled `word-break: break-word` on :host. It was the same behaviour under a deprecated alias, and being on :host with no custom-property indirection it was unreachable from a schema. Twelve call sites de-patched. Four were dead CSS all along: three we-code nodes with block: true, where `:host([block]) [part='base']` sets `white-space: pre` and suppresses soft wrapping regardless of word-break, and one node that was already `truncate: true`. Removing them changes nothing, but leaving them would suggest the default does not reach there. whiteSpace joins overflowWrap in componentMeta — a pre-existing gap found on the way through. It has been a real DS prop for some time, in neither the validator's layer map nor the generated docs, so it was invisible to schema authors. One visible change beyond the bug: a we-button with a long unbreakable label now shrinks and breaks the label rather than pushing its row wider. That is the intended reading of the default and the one place it shows on content that was not already broken. Grid still emits `repeat(N, 1fr)` rather than `minmax(0, 1fr)`. Nothing here needs it now, but the `auto` minimum remains a general blowout vector for what is not text; on its own it would have turned this bug into a transcript spilling out of its card, so it stays a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"New post" could not be closed at all once anything had been typed. The backdrop, Escape and Cancel all set `confirmDiscardOpen`, and `composerModal` never mounted the confirmation that reads it — so the flag went true, no dialog appeared, and the modal had no remaining way out. `discardGuard` hands back three pieces that go in three different places (`close`, `localState`, `node`); the composer took two. Only the composer was affected, and it was affected every time: typing a post makes it dirty immediately, where the other guarded modals mount their `node` and only ask once there is something to lose. - `composerModal` mounts `guard.node`. - `kit.test.ts` asserts over *every* fixture that a fragment raising the discard flag also mounts something that reads it, and gains composerModal fixtures in both states — there were none at all, which is why nothing caught this. Verified the test fails with the fix reverted, naming the fragment. - `BlockComposer`'s dirty baseline no longer depends on mount ordering. A snapshot taken in `onMount` races Lexical's own initialisation — the empty paragraph is inserted in a batched `editor.update()` — so on the losing side a blank composer reported unsaved work the instant it opened, which is the guard firing when there is nothing to lose. Until content is *loaded* there is now no baseline at all and "unchanged" means "still an empty document", which no ordering can get wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…to be asked A call where one person pressed record produced a transcript of one person, which is not a smaller record of the meeting than the real one — it is a wrong one, and nothing about it says so to whoever reads it later. The prompt offering to join was reliably ignored, and it deserved to be: transcription is per microphone, so declining does not stop the call being recorded, it only removes your own words from a record being made anyway. So the default flips for that case, and only that case. Being *first* to transcribe a call is still a button press — a decision about the conversation, which belongs to a space's settings rather than to this module. Once somebody has made it, every other agent joins on their own. Leaving replaces dismissing, and is now per call rather than per peer: the old granularity was right for an offer, and would have let the next person to press record switch a departed agent back on. It sticks for the rest of the call and resets with the next one, because it is a decision about the conversation and not a standing preference. A persistent "never join me automatically" belongs to the agent settings layer, which does not exist yet. Three things auto-start has to earn: - It fails silently. A node with no speech model reverts to idle rather than opening every call with a warning about something nobody asked for. Pressing record still says `no-model`, which is the other half of the bargain. - It does not open the panel. `toggle` does, deliberately; recording that starts on its own has no request behind it and a panel every call is chrome. - It is legible. The record button is `danger` while listening rather than `secondary` — a state somebody chose can afford to be quiet, one that arrives on its own cannot, and it is also the way out. Coverage was already computed and documented as "for the panel to show", and was rendered nowhere. It now has a denominator and a readout beside the microphone meter: "3 of 5 transcribing", stated whether or not there is a gap, because a number that only appears when something is wrong is one nobody learns to read. It says so while the meeting is still happening and somebody can still act on it; the calls list already said it afterwards, when the only response left is to distrust what you are reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he bar says so plainly Three things from a real multi-party call. A claim outlives a pass now. The executor writes a claim once when it wins, never refreshes it, and reuses the same number as the stall clock after which a stood-down peer escalates to a claim of its own. On a local model a pass is minutes, so at sixty seconds every non-elected peer found the runner's claim expired, won its own, and ran the batch again - an LLM call per peer per batch, each ending in "Nothing to add" or, when sync was slower than the model, in duplicates. claimTtlMs is ten minutes, matching the activity TTL. The proper fix - a claim refreshed during the pass, a separate stall timer - is the executor's; see notes/ad4m/auto-processor-claim-followups.md. Two smaller things in the same area: batchReady no longer opens a row (it fires on every peer before the election and carries no agent, so it was read as this agent's own), and the relay broadcasts only its own passes, the rule resend already applied. And the bar itself: "writing what you found" rather than "what it found"; and the footnote about other people's prompts is gated on the space's setting rather than on a row lacking detail, which kept it on screen after somebody had turned sharing on. The host publishes the setting, the module contract gains detailShared(), and the note is one line at footnote size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… narrows Docking a panel wide enough took the hang-up button off the window. The bar was centred on the *content* — correctly, since window-centring had already walked it into the editor's controls once — but centring has no floor: a box centred on a space narrower than itself overhangs both sides equally, and the half that crosses the sidebar simply leaves. So the three floating pieces stop positioning themselves. Each now sits in a strip spanning the content's edges whose one child is laid out `safe center`: the same centring while it fits, and a clamp to the strip's edge when it does not. Which edge is the host's to say — `--we-chrome-give` names the side with the deeper dock, so a bar that cannot fit covers the panel that squeezed it rather than the sidebar or the window. The join prompt and the problem alert get all of it for free, having each guessed separately before. That keeps the controls reachable; it does not make them fit. The strip is a `$surface`, so the bar reads its own room and folds below it: the readout drops its sentence and keeps its faces, and screen share, video and spotlight move into one menu. Mute, camera and hang-up never fold, being the call itself, and neither do contributed controls — this module cannot fold chrome whose meaning it has no access to. Both halves of a fold come from one declaration now. The show/hide toggle was a hand-written copy of what `mediaToggle` builds, so the row and the menu could have come to disagree about what a control does; they are one `CallToggle` each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An AD4M agent is not created by WE. Somebody reaching WE Web through ad4m-connect brought an identity made in the ADAM Launcher, in Flux, or on a hosted node, and whatever named them there is the only name they have. WE read one of the three formats, mangled a second, and never asked anybody who had neither. Flux writes profile fields with expression.create(value, 'literal'), which the executor turns into a signed-expression envelope encoded as a literal:json: URL. parseLiteralTarget did String(decoded) on it, so every Flux-origin peer was called "[object Object]" throughout the app — bylines, call tiles, member lists. It unwraps the envelope now, accepts the pre-0.9 literal:// spelling that Literal.fromUrl refuses outright, and cannot return a non-string at all: it is the sole gate between a peer's published bytes and every name on screen. The launcher's format missed on both axes at once — its source is the agent's own DID and its predicates are has_firstname/has_lastname rather than Flux's has_given_name/has_family_name — so those profiles read as blank rather than partly parsed. getProfile gains a third fallback for it, after the two that exist. Whoever is left now gets asked. SessionStore sends an existing agent straight from login to ready, skipping the only screen that collects a name, so nobody in this position was ever asked for one. A dismissible prompt appears for an agent with no name anywhere, gated on profileStore.needsName — which stays false until the own-profile fetch has answered, since an empty profile and an unfetched one are otherwise indistinguishable and it would flash at every user on every launch. displayName falls back to "Anonymous" instead of ''. A blank label does not say "we do not know who this is" honestly, it says nothing at all. The two callers where a placeholder is worse than nothing — a transcript export, where three unnamed speakers would come out as three identical lines, and an activity row for a profile that is missing rather than unnamed — pass an explicit fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… it narrows Two symptoms with one cause. Nothing in the sticky nav's row said who gives up space, so a strip of `we-button`s — which set `white-space: nowrap`, making the strip's min-content width the sum of every view's label — refused to compress at all. Flexbox took the whole deficit out of the only sibling that could shrink, folding "1 online now" onto two lines, then overflowed the page's maxWidth; and since a template is mounted in an `overflow: auto` box, that read as the cover image sliding sideways rather than as a clipped nav. The views strip becomes the designated shrinker (`flex: '1 1 auto'`, `minWidth: '0'`, `overflowX: 'auto'`) and presence becomes an ornament that never absorbs somebody else's overflow. `minWidth` is the load-bearing half: without it `overflowX` has nothing to do, because the item is never asked to be narrower than its content. The same missing pair is fixed in the space-settings tab strip, which also expressed its overflow through the `styles` escape hatch. The mini-profile moves into flow. Absolutely positioned at `left: 16px` it was measured against the whole bar while the nav is measured against the centred column inside it, so below roughly 1600px it drew straight over the first view buttons; it now opens sideways on a `reveal` and takes its room from the strip. The bar's height stops being a guess. `NAV_BAR_HEIGHT` is set on the bar and subtracted by the route below it, replacing a literal `70px` against a bar that measured 73 and ignored a theme's control-height offset. It includes the scrollbar width, as the token rather than as 6px — retro sets it to 16 — because a scrollbar is drawn inside the box it belongs to. CardsView was carrying the same 70px, which is worse in a view: only a shell knows how tall its own chrome is, so it now fills the box it is given. Also documents what was missing when this was first written: overflowX, overflowY, scrollbarWidth, scrollbarGutter and flexShrink all work in a schema and none were in the DS-props reference, which listed `overflow` alone. flexShrink was likewise absent from componentMeta's editor layer index, so the inspector never offered it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The launcher was wired straight to `joinSpaceCall`, so one declaration produced three behaviours and two of them were wrong. In this space's call, `join` returns early on a matching id and the button silently absorbed the click. In an anchored call — or a call in a space you had navigated away from — the ids differ, so it hit `if (callId()) teardown()` and ended that call to start a new one, with no confirmation, from a button whose icon just says "call". Hiding it while active was the obvious fix and the wrong one. The rail is the only chrome that is always on screen and the surface people scan for "where am I"; being in a call is the most stateful thing the app does, and that was the one row of the rail that could never show it. So the button keeps its place and gains the one reading that holds in every state: go to the call. No call, start the space call. Call elsewhere, show the stage and navigate to it. Call here, toggle the stage — the only thing left to go to is the video. `join` is never reached while a call is running. `ModuleLauncher` gains `activeLabel` for the launcher whose two states are different acts, where one label is necessarily wrong half the time and the tooltip is an icon-only button's only name. `activeWhen`'s contract is widened with it: it does not have to mean "my panel is open". `joinSpaceCall` and `joinAnchoredCall` keep their replace-the-current-call semantics for the template controls that name their target explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…old meeting The other two ways into a call made the same assumption the module rail's launcher did — that there is no call running — and one of them is worse than anything the rail could do. A call card's Continue fires `joinSpaceCall` and `transcribe.resume`. The join is a no-op on the call you are in and a teardown of any other, which is the launcher's bug again. `resume` does not fail quietly at all: it re-points the live transcript at the record it was given and announces the claim, and peers adopt an announced record in preference to their own. So a stray click on last month's card moved everybody's live transcript into last month's meeting. The Cards header's Call button creates a `CollectionBlock` and then joins — deliberately, so a call is resumable before a word is said. Mid-call the create still fired and the join no-opped, leaving an orphaned empty card on the very list underneath it. Both now make the launcher's promise: while a call is running, go to the call. Branched inside the `onClick` array, which resolves lazily at call time, rather than as a `$if` in an action's args, which would freeze whichever state the list rendered in. Not disabled, deliberately. A disabled `we-button` sets the native attribute, and a disabled control does not reliably deliver hover to the tooltip that would explain it — so the explanation is the part that goes missing. A button that always works and says what it will do beats one that is inert and cannot say why. `transcribe` gains `liveCollectionId` so a card can tell the conversation happening now from one that finished last month — the space-wide call id is derived from the space, so it names the place calls happen rather than any one of them. The card that is the running call gets a Live badge; without it every card looks finished and the one whose button behaves differently is indistinguishable, which reads as the button behaving at random. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…all offers to Two bugs in yesterday's fix, both found by using it. `goToCall` toggled the stage, on the reasoning that a rail button is a tab whose second press closes what the first opened. That made a liar of every control calling it: the button says *go to the call*, and putting the video away is the opposite of going to it. Pressing it from a calls list hid the call the user was in. It shows now and never hides — "go to" is a direction, so it is idempotent the way navigation is, and pressing Home while on Home surprises nobody. Putting the video away is a real thing to want and has two controls of its own, the panel's close button and Video in the call bar, neither named after going somewhere. Every call card also offered "Go to the call" while a call was running, which is wrong for a reason only a list shows: the button sits beside one particular conversation, so "the call" reads as *this* card's call. A row of finished meetings each proposing to take you to a call that is none of them is a worse answer than no button. So a card offers the control in exactly the two states where it has an unambiguous subject — no call running, where it continues this one, and this card being the running call, where "go to the call" can only mean the one it is attached to. Otherwise absent. Absent rather than disabled: a disabled we-button sets the native attribute, and a disabled control does not reliably deliver hover to the tooltip that would explain it. There is nothing to explain anyway, since the call bar is on screen and the rail tab is lit. Note the gate is empty for a call nobody has transcribed yet, which is right — there is no record, so no card is that call and none of them should claim to be. The new gate test is structural rather than a substring of the whole tree. The first version asserted that liveCollectionId appeared somewhere before the tooltip and passed against the unfixed template, because the Live badge above already mentions it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s only filled thing `DropdownMenu` hardcoded its trigger, so a menu that belongs beside other controls could not look like one of them. The panel titlebar was the visible case: four `xs` ghost squares and then a filled pill, differing three ways from one cause. Not square, because `square` sets width and height from the component height and drops the size's horizontal padding — without it an icon-only trigger is glyph-plus-padding wide and shorter than it is broad. Inferred now from an icon with no label, since there is no caller who wants one glyph in a rounded rectangle. Filled, because the trigger set `bg="surface-active"` — the *pressed state* role, which `controlSurface` was added to stop things borrowing. It is `we-button`'s own `secondary` now, which is `controlSurface`, and identical at rest because that role was given the value everything borrowing it already had. Hovering to the accent, which nobody had noticed and no caller wanted: the hardcoded pair overrode `bg` and `color` on the default `primary` variant and not `hoverProps`/`activeProps`, so `accent-hover` survived the merge — beside four ghost squares going to `surface-hover`. So `triggerVariant`, and `triggerTitle` for the tooltip and the accessible name. The panel menu had no tooltip where its four neighbours each have one, and a `dots-three` chip says nothing about its subject. The `"Options"` fallback now applies only where there is no glyph either. Both of the move menus were rendering an arrows icon followed by the literal word; they name themselves with `triggerTitle` instead. The call bar's overflow menu was a hand-rolled `we-popover` and `we-menu`, with a comment saying why: the dropdown drew its own filled trigger and this had to sit in a row of ghost squares. It uses the component now, dropping the `we-menu-item` rows that copied the dropdown's `sm` metrics and check glyph by hand — three numbers and a colour kept in agreement with a component nothing linked them to. Its second reason was one conditional line: solo is offered only while something is focused. A `$if` in `items` resolves to `undefined` and reading `.type` off the hole threw. `renderEntry` guards it as a memo inside a `Show` rather than an early return, and leaves the hole in place rather than filtering it out — both because `Index` keys by position, so filtering shifts later entries into rows built for other items, and a snapshot taken at creation would render correctly once and never change again. The trigger's `slot` is passed explicitly on both paths. Solid assigns properties on custom elements, and `HTMLElement.slot` is a non-nullable DOMString, so an optional parameter left off writes the string "undefined" and the trigger renders into a slot nothing declares. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Snapping the call panel to any edge made it take the whole screen, with no practical way to resize it back — only that panel, and only while displacing. As a floating card it was honestly the size it looked. Three faults, each invisible on its own, and a stored `thickness` of 2378px carrying them. `fitPlacement` wrote a thickness nothing could honour. A spanning fit solves `span × ratio`, so wide content against a tall edge asks for far more than the screen has: the call stage on a 4K side edge wants 3761px of a 3760px region for a single 16:9 tile, and more for every arrangement above one. The number was written anyway and `resolveDock` clamped it at paint time, so the panel covered the region and the clamp hid why. It declines now, given a `maxThickness` it cannot meet. Clamping would be no better: it destroys the size the user chose and still leaves the band, since a panel at the full width of its edge is exactly as letterboxed as it was. The bound is `dockThickness` at `lg`, the largest size a dock is ever *asked* for, rather than the region — bounding at the region is barely a bound and still let one tile take 92% of a 4K screen. A panel wanting more room than the largest named dock is asking to be maximised, and there is a control for that. One `thickness` meant two things: a width on a side edge and a height on a top or bottom one. So a number solved for the left edge became a height the moment the panel was snapped to the bottom, which is why all four edges filled the screen once one of them had. `thicknessX` and `thicknessY` now, with no conversion between them, because there is none — how wide a panel wants to be says nothing about how tall. The bad value outlived both fixes. Placements are persisted, and a thickness is invisible while the panel floats — a float resolves from `w`/`h` and never reads one — so it sits in the browser waiting for the next dock. A legacy `thickness` is dropped on load rather than migrated onto an axis: the two things a migration would need to know are the two that made it wrong, since which axis it was solved for is not recorded and 2378px is well inside a 4K region yet absurd on any edge. Falling back to the card is the documented behaviour and the one people expect. A fourth fault, found while tracing this. A maximised panel reports `floating: true` — it has to, since that flag draws the radius, shadow and glass — so `grips` gave it all eight handles, and `resizeDock` read the same flag, took the floating arm, and wrote the box it measured (the whole window) over the card's `w`/`h`. The panel looked unchanged, because the maximised branch resolves ahead of the placement; the size it would restore to was gone, and for a panel whose dock thickness falls back to the card, so was the size it would dock at. A second route to the same full-screen dock. `grips` tests `maximised` alongside `floating` now, and `resizeDock` refuses outright — the geometry already said so by leaving `handleX` and `handleY` absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loading state was an xs spinner and a footnote on a pale pill, bottom-left, sharing a strip with the budget and expander warnings. Right for background work, wrong for the two cases it was actually being read in. A first load hid the centred empty state — it is gated on !loading — so the middle of the canvas was blank and the only word for it sat in a corner. A re-seed was worse: start() clears the store synchronously but only notifies at the end, and the renderer's node projection recomputes on graph notifications alone, so the previous graph stayed painted for the whole load with a footnote to say otherwise. EngineStatus gains reloading, and begin/endLoading take a scope. One boolean was covering two situations that want opposite treatment: an expansion lands beside a graph that stays usable, a reload means everything drawn is about to go. Both flags are derived from counters in one place, and start() holds its count across its whole body — seeds and auto-expansion are two loads, and releasing between them published a settled frame mid-start. loadSeeds and expandNode stay partial, so a refresh arriving from a subscription is background work and does not dim the graph under somebody reading it. The renderer folds the empty state and the loading state into one centred box. They answer the same question and had drifted into a gap where neither appeared; branching inside one box makes them exclusive by construction. The layer fades to 0.4 while a reload runs, so a centred spinner over a crisp board does not read as though that board is what is arriving. The corner chip survives for background work only, moved off scale positions onto surface-raised/border/text-muted so it is measured against what is behind it. The centred spinner waits 220ms before fading in, so a seed answered from cache never paints one. Under reduced motion the fade goes and the delay stays, that being the part doing the work. 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.
UI sweep: modal sizing and discard guards, avatar identity, focus rings, panel glass, agent names, and the dev/prod switch
Summary
A run of small UI fixes that accumulated while using the app, plus three pieces of structural work
that some of them turned up.
The fixes are mostly independent — a chrome overlay that stopped filling the window, a call ring
that did not hug its avatar, focus states that disagreed across the two design system families,
spaces showing hash identicons where initials would read better. Several were reproduced empirically
in headless Chrome and Firefox before being diagnosed, because more than one looked like a different
bug than it was.
Three turned structural, each because the fix had no single place to live.
Hiding the Schema Tests button in production needed a story about what "production" means,
and telling that story found a flag claiming to exclude ~97KB of test schemas from the
shipped bundle while not doing so — developer affordances now gate on one live switch with
a real control behind it, and the harness genuinely leaves the bundle, by a property of the
import graph rather than by a boolean. A modal that came out too narrow to read turned out
to have no width at all, so eleven call sites had each guessed one;
we-modalhas asizenow, and the confirm and form dialogs built on it have one shape each. And a backdrop click
that threw away a half-written post was unguarded everywhere but one place, so the guard is
a kit fragment rather than a pattern each modal re-derives.
Changes
Chrome overlay height
TemplateLayout.tsx— the shell overlay's surface wrapper getsheight: '100%'.Opening Profile or Settings left the space template showing through beneath them.
It is engine-specific, which the first diagnosis missed. Every shell view's root sizes itself with
minHeight: '100%', and this wrapper sits between the scroll container and that root — Chromeresolves a percentage through an auto-height ancestor, Firefox follows CSS 2.1 and treats it as
indefinite.
min-heighthere does not fix it, since the box stays auto-height and the child'spercentage stays indefinite. Measured in Chrome 150 and Firefox 152; only
heightworks.The call badge on a sidebar avatar
rail.ts,badgedAvatar.ts(new) — the ring had padding between it and the avatar,and then the badge sat too centrally. Rather than patch offsets in place, the badged
avatar became its own fragment with its geometry derived by
calcfrom the avatar size,so the badge lands on the circle's edge at any size instead of at one tuned size.
color.ts,avatar.ts— a sharedAvatarTonevocabulary, replacing two ad-hocmarks. Tones are scale positions rather than roles, preserving the rationale already
documented on
AvatarStack.This answers the question raised in review — badges are composed at the fragment layer,
not passed to
we-avataras a prop — so a second kind of badge is a second fragmentrather than a second prop on a primitive.
themes/retro/index.css— a dead rule squaring off the ringwe-avatar[selected]used todraw, left over from removing that attribute. Caught by
themeSelectors, which reads theprimitives' build, so it only failed after a rebuild rather than after the edit.
Panel glass
dockRegistry.ts,dockGeometry.ts— a floating panel is translucent and blurred; adisplacing or maximised one is opaque.
DockGeometrygainsmaximisedso the conditioncan tell the two apart.
Translucency goes through the existing
--we-theme-surface-opacity/--we-theme-surface-blurkeys via
color-mix(in srgb, …), so a theme can restyle it. Default settled at 0.3.Template picker
TemplateStore.tsx,DesignControls.schema.ts— editing a template from the pickertook three clicks for any row that was not already on screen: the edit action was gated on
the row being current, so the pencil was simply absent elsewhere.
TemplateSwitcherItemgains a per-row
editable, and the click switches first.Views in the visual editor
viewRoutes.ts,EditorOverlay.tsx— since the space-view-fragments work, a viewrendered as an unclickable black hole in the visual editor. Views are now marked with
boundary attributes and drawn as a labelled dashed region. Scoped deliberately to the legibility
fix — where an edited view should be saved is a real design question, noted in the follow-ups.
Focus states
input.ts,textarea.ts,button.ts,select.ts,dsInterop.ts— three separateproblems behind one odd-looking transition: a custom
transitionprop fighting the statemachinery, the
outlinebutton variant losing its ring becausemergePropsis a shallowspread (a variant's
focusPropsreplaces the base wholesale), and the two DS familiesordering hover and focus differently.
Rule order in
dsInteropis now[base, hover, focus, active, disabled]in both families,so "hovered and focused" has one answer. The ring reworked after review so the 1px border
takes the ring colour and expands into it, rather than leaving a grey line inside a blue
ring.
Space avatars
avatar.ts,Sidebar.schema.ts— spaces with no image showed hash identicons; theynow show initials on a colour seeded from the space's uuid, not its name, so the colour
survives a rename. Precedence is
image > initials > hash > icon.Two follow-up corrections: two-letter initials were squashed at
sm, inheriting a 20px font sizefrom the surrounding rail; a lone initial then looked too small at the same ratio, since equal cap
height gives one glyph half the mass of two. Single initials get their own larger ratio.
Developer affordances: one switch
devTools.ts,SessionStore.tsx,templateSurface.ts,Settings.schema.ts,Sidebar.schema.ts,devPeers.ts,call/index.ts,componentRegistry.tsx—Schema Tests is hidden in production, and so is everything else of its kind.
Three layers, named, because conflating them is what went wrong:
platform.isDevelopment— a fact about the build. Stays honest; never gated on.sessionStore.devTools— whether developer affordances should be visible. Now asignal rather than a value read once, with a Settings → Developer switch behind it.
The asymmetry is the design and is easy to get wrong: the Developer page gates on
isDevelopment, the affordances gate ondevTools. Gating the way to the switch on theswitch makes turning it off a one-way door out of the app's own UI. There is a test for it,
because it only shows up on the first genuine use.
The call module's fake peers keep their production-absence guarantee —
devPeersAvailableis thebuild flag alone, so a shipped app carries no node and no callable
addFakePeer— whilevisibility moved to an
$ifplus a per-solve re-read, so muting does not leave two syntheticparticipants on the stage. A double gate is also closed: the schema-tests shell view was
registered unconditionally while its template entry was DEV-gated, so production had no way to
name the harness and a complete way to open it.
Developer affordances: actually leaving the bundle
shellViews.ts(new),schemaTestsView.ts(new),TemplateLayout.tsx,TemplateStore.tsx, both schema barrels — the ~97KB harness now genuinely ships only indevelopment.
The
import.meta.env.DEVguards looked exactly like exclusion and were not. A branch decideswhether a value is used; a bundler answers to whether a module is reachable. Both call
sites imported the harness at the top of the file, so the schemas sat in
distbehind acondition that could never be true in the build carrying them — verified by grepping the
production bundle for "Schema Mutations".
The fix is a property of the import graph, not a flag: exactly one module names the harness,
and the only reference to it is a dynamic
import(). The view registry moved out ofTemplateLayoutintoshellViews.ts, which now has eager entries for ordinary chrome andlazy loaders for views whose code should not exist in a build that cannot open them.
TemplateStore's entry was deleted rather than made lazy — it was unreachable (that list resolvesa template, and the harness is only ever an overlay), so its only effect was the static import.
Main bundle: 2,374KB → 2,329KB, and "Schema Mutations" is gone from it.
The Apps section follows the seed, and this deployment has no apps
WE has absorbed most of what the one bundled app (Flux) was there for, and anything another AD4M
app offers is better expressed as a template against the same data. Embedded apps are being
deprecated by disuse — the capability stays, this deployment stops using it.
Sidebar.schema.ts— the Apps group is now conditional rather than deleted.Removing it from the template outright was the wrong layer:
templates/shellis shared by everydeployment, so expressing one deployment's configuration there would have taken embedded apps
away from any seed that wants them. The seed describes a deployment; it should be the lever.
The guard counts
appStore.appswhile the list iteratesappStore.appsWithWe. That reads likea slip and is not —
appsWithWeprepends aWEsentinel whose row means get back out of anapp, so it is never empty and counting it would be a guard that is always true. That was the
real behaviour: a deployment configuring no apps still rendered an "Apps" heading over a lone
"WE" row that did nothing.
apps: []now means what it looks like it means, for everyone.we-seed.json—appsis empty. This also stops the app being fetched:PersistentAppFramesmounts an iframe per registered app eagerly, and adisplay: noneancestor does not stop an iframe loading its
src, so hiding the group alone would have left aremote request at every boot for something nobody could reach. The chain is
seed.apps→ anembed module per app →
moduleRegistry.embeds()→appStore.apps(); with the array empty theloop runs zero times.
appBridge's credentialed origins default to the registered embeds, sono origin is credentialed either.
Nothing is removed:
appStore,PersistentAppFrames,resolveAppUrland the embed path ininitializeIntegrationsall stand, and the seed validator already treats an empty array as asupported mode ("native WE app mode"). Restoring the feature is putting an entry back in the
seed; the Flux block is reproduced verbatim in the commit message.
The tauri generated/ directory is actually generated
Emptying the seed exposed this rather than caused all of it. Six files under
apps/we-tauri/src-tauri/src/generated/were tracked despite that directory's own.gitignoresaying "DO NOT COMMIT — they are created from we-seed.json at build time" — and they were quietly
load-bearing, which is why nobody noticed.
generate-seed-config.cjs—lib.rsdeclaresmod generated;unconditionally and callsgenerated::setup_seed_servers, but the generator's native-mode path returned before writingmod.rsorseed_servers.rs. In a deployment with no apps — now the default — they were nevergenerated, and the build only worked because copies sat in git.
writeGeneratedModulenow runson both paths; with no apps it emits a no-op
setup_seed_serverswith no import and anunderscored parameter, so it compiles without warnings.
tauri.conf.json— the native path now callsupdateTauriConfig({}). The file ishand-authored except for
bundle.resources, which the generator has always owned but nevercleared on this path. This was a live inconsistency introduced by emptying the seed: it kept
bundling Flux, so a release build would package the app and start its server, and would fail
outright on a machine without a Flux checkout beside the repo.
seed-bundle-resources.jsondeleted, not just untracked. It held exactly what goes intotauri.conf.jsonand nothing ever read it — Tauri cannot reference an external file forbundle.resources, so the config is the only consumer there can be. Two copies of one fact,the dead one committed.
pretauri:dev/pretauri:buildhooks added, so the tauri scripts no longer depend on aprior
pnpm buildhaving producedCargo.toml,tauri.conf.jsonand the Rust module. That gappredates this branch; untracking is what would have made it bite.
git ls-files --cached --ignored --exclude-standardnow reports nothing.A modal's width was an emergent property of its longest line of text
Three complaints, one cause.
we-modalset no width, and withdirection: column+ax: 'stretch'its[part='base']shrink-wraps to its widest child's intrinsic width. A shortconfirmation came out too narrow to read; a wordy one came out too wide, from the same rule. Both
were patched at call sites, differently each time — the widths in the tree were 320, 380, 400, 420,
500, 520, 560, 600, 640, 770, 850 and 900px, in five spellings (
px, a layout token,min(…, 92vw),minWidth, and nothing). That is not eleven needs; it is three needs and eightguesses.
modal.ts,types.ts—we-modaltakes asize:sm(420) ·md(640, the default) ·lg(900) ·fullscreen, mapping to the layout tokens that already existed for this and werealready commented "narrow modals" / "standard modals". Each folds a gutter into its
max-width,so a modal on a phone is no longer edge-to-edge with its corners under the bezel — which is what
the two
min(…, 92vw)call sites had noticed and fixed with a percentage, giving 30px of room ona phone and 100px on a desktop. Explicit
width/maxWidthstill win, so the escape hatchsurvives as an exception rather than as the only way to have a width.
Default padding drops from
space-900tospace-600(64px → 32px). 64px is a page section'spadding; around a two-line confirmation it was most of the dialog, and is a large part of why
modals read as small and empty.
confirmModal.ts— covers the cases that had forced four hand-written forks. It took anopenLocalstring and a single action, which could express three of the codebase's sevenconfirmations: the wizard's "Discard this model?" and "Replace the fields below?" are gated on
store flags, its "Remove this model?" on a string, and account removal on a pending record, and
two of them need a cancel button that does more than close. So the dialog a person is most likely
to meet — the one guarding work they have half-finished — was the one with no shared design.
openandcloseare expressions now, pluscancel,tone,detailandchildren. All fourhand-written forks are gone.
formModal.ts(new) — for the ten modals that were a title, some fields, Cancel and Save, andagreed about nothing: four title spellings, three button rows, and two of the ten using the
header/footer slots that keep the Save button on screen. It declares the draft on the modal,
so closing resets it — which retires the by-hand
onSuccessclears, and the$resetLocal: '$scope'the signal-type form had to sequence between its action and its close.Three dialogs were not modals at all. Account removal and both consent prompts were fixed
Columns over a literalrgba(0,0,0,0.5)at z-index 9998 — outside the browser's top layer,ignoring the theme's
overlayrole, with no focus trap and no Escape. The consent prompt had nodismissal of any kind, so a keyboard user who could not reach its two buttons was stuck.
surface-auditnow reports no hand-rolled scrims.Also: the 11 block-input modals lose their
p="500" width="320px"andax="center" minWidth="400px"overrides (
ax: 'center'is the shrink-wrap trapmodal.tsdocuments), and their titles converge onheading-mdfrom five spellings.EditableImageasks for a modal size from its crop aspectinstead of pushing the sheet wider from the inside with a computed
minWidthof up to 1100px, whichhad no answer for the viewport and simply overflowed.
Ask before a stray click throws away what somebody typed
we-modalcloses on a backdrop click and on Escape. The backdrop is every pixel that is not thesheet, Escape is next to the keys people reach for while writing, and neither is recoverable: a
modal is
$if-mounted, so closing unmounts the draft with it. Somebody three paragraphs into a postwho brushed the trackpad had no way back. One modal in the app guarded against this — the model
wizard, through
shapeStore.requestCloseWizard— and it was the only one.discardGuard.ts(new) returns the three pieces a guard needs, because a modal cannot beguarded from outside it: the flag has to be declared on the modal (so it dies with the draft),
the confirmation has to be inside it (so it can read that flag), and
closehas to bereplaced (so the backdrop asks).
closebecomes a$ifover the dirty expression, whichresolves at click time — pinned by three tests in
schema-solid, since a$ifaction in a plaincallback prop misses the handler-array path entirely and had no coverage.
formModaltakesdiscardWhen;composerModalguards by default. Opt-in on the firstbecause a one-field form is not worth the interruption; on by default on the second because a
composer is where the most work is and the one place a template author cannot write the guard
themselves.
BlockComposer.tsxreportsonDirtyChange. Its content lives in Lexical, so no$localcansee whether anything was typed, and without it the composer guard could only ask every time —
including when somebody opened it and immediately changed their mind, which is the surest way to
teach people to click through the dialog that matters. Baseline comparison rather than Lexical's
own dirty flags, which mark every node dirty on the
setEditorStatethat loads a post to edit.Two stores learned to answer it —
recordStore.recordDraftDirty, because a record form'sfields come from the model and a shape defined this morning has properties no schema was written
against; and
runtimeStore.aiFormDirty, compared against a snapshot taken when the form opened,so looking at a model's settings and closing again asks nothing while a pasted API key is not
thrown away.
Guarded: create-space, every composer (post, edit, reply, card, message, channel), new task,
new event, edit call, new signal type, new relationship type, the record form and the AI model
form. Left unguarded and said so: the two single-field "name this" modals.
Two things fell out of the work:
document, so once modals actuallystacked, Escape on a confirmation dismissed it and re-ran the close that had raised it.
OverlayElementkeeps a stack and only the topmost answers the keyboard — already latently wrongfor the model wizard's own discard dialog.
confirmModalsplicedonSuccessonto whatever it was given. Only$actionhas lifecyclehooks, so a
$setLocalconfirm silently never ran the close. Caught by the new kit test ratherthan by reading; the synchronous case composes a handler array instead.
templates/kit/CONVENTIONS.mdand the AI context both carry the pattern, including the part thatgoes wrong:
dirtymust test only what the person typed. A field with a default and a picker is setfrom the first frame, so including it fires the guard on an untouched form — and a dialog people
learn to click through is worse than no dialog, because it costs them the one time it was real. A
form seeded from a record asks whether it changed, not whether it is filled in.
One follow-up bug, found and fixed on the branch. "New post" could not be closed at all once
anything had been typed: the backdrop, Escape and Cancel all set
confirmDiscardOpen, andcomposerModalnever mounted the confirmation that reads it —discardGuardhands back threepieces and the composer took two, so the flag went true, no dialog appeared, and the modal had no
remaining way out. It now mounts
guard.node, andkit.test.tsasserts over every fixture that afragment raising the discard flag also mounts something reading it.
BlockComposer's dirty baseline also no longer depends on mount ordering: a snapshot taken inonMountraces Lexical's own initialisation (the empty paragraph arrives in a batchededitor.update()), so on the losing side a blank composer reported unsaved work the instant itopened. There is now no baseline until content is loaded, and "unchanged" means "still an empty
document" — which no ordering can get wrong.
Text that cannot break no longer breaks the page
A transcriber emitted one run-together ~200-character "word" into a call card, and the card — and
the route holding it — stretched off the screen. The fix is not on that card:
we-texthad nowrapping rule at all, so this was reachable from any bound string anywhere, and it was already being
patched by hand at twelve sites through the raw
stylesescape hatch, in three differentspellings.
design-types,design-utils— a newoverflowWraptypography prop(
'normal' | 'break-word' | 'anywhere'), added totypographyKeys, toBASE_TYPOGRAPHY_SPECSwith
anywhereas the spec fallback, and tobuildLayoutStyles. The fallback is what makes ita default: every typography component now emits
overflow-wrap: var(--we-<name>-overflow-wrap, anywhere)on[part='base'], so a node that saysnothing gets a breakable line and a node that sets the prop overrides it. It has to live in the
spec rather than in each primitive's own stylesheet — the DS sheet is adopted last, so a
var()with no fallback would resolve invalid-at-computed-value-time and clobber an earlier rule.
anywhere, notbreak-word. This is the whole reason the prop exists rather than being leftto
styles. The two break in the same places; onlyanywherealso reduces min-content width. Aflex item and a
1frgrid track are both sized by min-content (gridWrapperemitsrepeat(N, 1fr), and1fr's automatic minimum isauto), so underbreak-wordthe unbreakablestring still propagates its width all the way out to the route.
break-wordis the value thatlooks right and does not fix the bug; there is a test pinning the choice so nobody simplifies it
back.
word-break: break-allis deliberately not offered — it breaks ordinary prose too, and ona bare identifier (which is what every site reaching for it held) it renders identically.
createLayoutComponent.tsx—LAYOUT_DEFAULTS, soColumn/Row/Grid/Cardget the samedefault beneath their own.
overflow-wrapinherits, so this also covers text those componentshold directly: a bare string child, a native
<p>or<span>in a template, rendered blockcontent. Without it only text that happened to be wrapped in
we-textwould break, which reads asinconsistent styling rather than as a rule.
componentMeta.ts—overflowWrapandwhiteSpaceadded to the enum map andDS_PROP_LAYER.whiteSpacewas a pre-existing omission: a real DS prop for some time, in neitherthe validator's layer map nor the generated docs, so invisible to schema authors.
markdown.ts,html.ts— their hand-rolledword-break: break-wordon:hostremoved. Itwas the same behaviour under a deprecated alias, and being on
:hostwith no custom-propertyindirection it was unreachable from a schema. The layer default covers them and inherits into the
rendered content.
Twelve call sites de-patched —
HostSettings,SpaceSettings,Settings,ConsentPrompt,RuntimeSettings(×3),LanguageSettings,BootScreen,NodeDetail,AiPanel. Four were deadCSS all along (three
we-codeblocks, wherewhite-space: presuppresses soft wrapping whateverword-breaksays, and onetruncatenode): removing them changes nothing, but leaving them wouldimply the default does not reach there.
Behaviour change worth knowing: a
we-buttonwith a long unbreakable label now shrinks and breaksthe label rather than pushing its row wider. That is the intended reading of the default, and it is
the one place in the sweep where the change is visible on content that was not already broken.
Joining somebody else's transcript no longer waits to be asked
A call where one person pressed record produced a transcript of one person, which is not a smaller
record of the meeting than the real one — it is a wrong one, and nothing about it says so to whoever
reads it later. The prompt offering to join was reliably ignored, and it deserved to be:
transcription is per microphone, so declining does not stop the call being recorded, it only removes
your own words from a record being made anyway.
So the default flips for that case, and only that case. Being first to transcribe a call is still
a button press — a decision about the conversation, which belongs to a space's settings rather than
to this module. Once somebody has made it, every other agent joins on their own.
transcribe/store.ts,index.ts— leaving replaces dismissing, and is per call ratherthan per peer: the old granularity was right for an offer, and would have let the next person to
press record switch a departed agent back on. It sticks for the rest of the call and resets with
the next one, because it is a decision about the conversation and not a standing preference. A
persistent "never join me automatically" belongs to the agent-settings layer, which does not exist
yet.
Three things auto-start has to earn:
call with a warning about something nobody asked for. Pressing record still says
no-model,which is the other half of the bargain.
toggledoes, deliberately; recording that starts on its ownhas no request behind it, and a panel every call is chrome.
dangerwhile listening rather thansecondary— astate somebody chose can afford to be quiet, one that arrives on its own cannot, and it is also
the way out.
CallControl.schema.ts,Panel.schema.ts,CardsView/CallsList.ts— coverage was alreadycomputed, and documented as "for the panel to show", and rendered nowhere. It now has a
denominator and a readout beside the microphone meter: "3 of 5 transcribing", stated whether or
not there is a gap, because a number that only appears when something is wrong is one nobody
learns to read. It says so while the meeting is still happening and somebody can still act on it;
the calls list already said it afterwards, when the only response left is to distrust what you are
reading.
The extraction bar, after a call with friends
Three things reported from testing auto-extraction on a real multi-party call. Two are UI; one is
a coordination bug whose proper fix is in the executor, mitigated here.
interpretationAdapter.ts—claimTtlMsgoes from 60s to 10 minutes. The executor writesa claim once when it wins, never refreshes it, and reuses the same number as the stall clock
after which a stood-down peer escalates straight to a claim of its own. On a local model a
pass is minutes, so at sixty seconds every non-elected peer found the runner's claim expired,
won its own, and re-ran the batch — an LLM call per peer per batch, each ending in a "Nothing
to add" row (the dedup found the runner's records) or, when sync was slower than the model, in
duplicates. That is what "an entry for every peer" was. Ten minutes matches the activity TTL
and is the bound on how long a dead runner holds a batch. The real fix — a claim that
refreshes during the pass, and a separate stall timer — is the executor's, and is written up in
notes/ad4m/auto-processor-claim-followups.md.interpretationAdapter.ts—batchReadyno longer opens a row. It fires on every peerbefore the election and carries no
agentDid, so it is dropped for an ordinary session and,for an admin one, arrives unattributed and was read as this agent's: "You are about to
extract" on four machines about to stand down, with nothing to close it.
claimedis where apass starts here.
interpretationRelay.ts—publishbroadcasts onlyminerows, the ruleresendalready applied. A host feeds it everything the backend reports; on a hosted executor that
includes another user's pass over the perspective-scoped stream, and the transport stamps
the sender as the runner. Test added.
InterpretationStore.tsx— "You are writing what you found" / "Anna is writing whatthey found". The pass's subject is the runner; "it" read as the machine's work.
interpretationActivity.ts—writingis documented as not reliably brief. It spans dedup,planning, per-instance writes, several batch commits and the provenance overlay, none of which
reports progress; a pass sat there for five minutes in testing, and where those minutes went is
not established from the code. Candidates and instrumentation are in the same notes file.
store.ts(transcribe),ExtractionStatus.schema.ts,module.ts,moduleHostServices.ts,InterpretationStore.tsx— the footnote about other people's prompts is gated on the spacesetting rather than on "a peer row with nothing to open". The old gate was true for a peer's
pass that had not reached the model, a skipped pass that never had an exchange, and a row
broadcast before the switch synced to its runner — so it kept explaining the setting after
somebody had turned it on. The host publishes
interpretationDetailShared; the module contractgains
detailShared();hasLockedPassbecomesdetailWithheld. The note itself is one lineat footnote size — two body-size sentences took more of the bar than the rows they explained.
Tests added for the four cases of the gate.
The call controls slid off the screen when the space narrowed
Docking a panel wide enough took the hang-up button off the window. The bar was centred on the
content — correctly, since window-centring had already walked it into the editor's controls once —
but centring has no floor: a box centred on a space narrower than itself overhangs both sides
equally, and the half that crosses the sidebar simply leaves.
call/index.ts,ShellStore.tsx— the three floating pieces stop positioning themselves.Each now sits in a strip spanning the content's edges whose one child is laid out
safe center:the same centring while it fits, and a clamp to the strip's edge when it does not. Which edge is
the host's to say —
--we-chrome-givenames the side with the deeper dock, so a bar that cannotfit covers the panel that squeezed it rather than the sidebar or the window. The join prompt and
the problem alert get all of it for free, having each guessed separately before.
That keeps the controls reachable; it does not make them fit. The strip is a
$surface, so thebar reads its own room and folds below it: the readout drops its sentence and keeps its faces, and
screen share, video and spotlight move into one menu. Mute, camera and hang-up never fold, being
the call itself, and neither do contributed controls — this module cannot fold chrome whose
meaning it has no access to.
Both halves of a fold come from one declaration now. The show/hide toggle was a hand-written copy
of what
mediaTogglebuilds, so the row and the menu could have come to disagree about what acontrol does; they are one
CallToggleeach.Agents WE did not create now have names
An AD4M agent is not created by WE. Somebody reaching WE Web through
ad4m-connectbrought anidentity made in the ADAM Launcher, in Flux, or on a hosted node, and whatever named them there is
the only name they have. WE read one of the three formats, mangled a second, and never asked anybody
who had neither.
agentHelpers.ts,profileTypes.ts— Flux writes profile fields withexpression.create(value, 'literal'), which the executor turns into a signed-expression envelopeencoded as a
literal:json:URL.parseLiteralTargetdidString(decoded)on it, so everyFlux-origin peer was called
[object Object]throughout the app — bylines, call tiles, memberlists. It unwraps the envelope now, accepts the pre-0.9
literal://spelling thatLiteral.fromUrlrefuses outright, and cannot return a non-string at all: it is the sole gatebetween a peer's published bytes and every name on screen.
The launcher's format missed on both axes at once — its source is the agent's own DID and its
predicates are
has_firstname/has_lastnamerather than Flux'shas_given_name/has_family_name— so those profiles read as blank rather than partly parsed.getProfilegains a third fallback for it, after the two that exist.ProfileStore.tsx,NamePrompt.schema.ts(new) — whoever is left now gets asked.SessionStoresends an existing agent straight from login to ready, skipping the only screen thatcollects a name, so nobody in this position was ever asked for one. A dismissible prompt appears
for an agent with no name anywhere, gated on
profileStore.needsName— which stays false untilthe own-profile fetch has answered, since an empty profile and an unfetched one are otherwise
indistinguishable and it would flash at every user on every launch.
displayNamefalls back to "Anonymous" instead of''. A blank label does not say "we do notknow who this is" honestly; it says nothing at all. The two callers where a placeholder is worse
than nothing — a transcript export, where three unnamed speakers would come out as three identical
lines, and an activity row for a profile that is missing rather than unnamed — pass an explicit
fallback.
The space header stopped fitting on the screen
Two symptoms with one cause, both in the default template's sticky nav: with enough views the
strip pushed off to the right and took the whole page's horizontal scroll with it, and the
presence beside it folded "1 online now" onto two lines well before that.
Nothing in that row said who gives up space.
we-buttonsetswhite-space: nowrap, so thestrip's min-content width is the sum of every view's label, and a flex item's
min-width: autoresolves to exactly that — the strip refused every request to compress. Flexbox therefore took
the entire deficit out of the only sibling that could shrink, which is a run of text, and then
overflowed the page's
maxWidth. Because a template is mounted in anoverflow: autobox, thatsurfaced as the cover image sliding sideways rather than as a clipped nav.
SpaceHeader.ts— the views strip becomes the designated shrinker:flex: '1 1 auto',minWidth: '0',overflowX: 'auto'.minWidthis the load-bearing half; without itoverflowXhas nothing to do, because the item is never asked to be narrower than its content.Presence gets
flex: '0 0 auto'so an ornament stops absorbing somebody else's overflow, andits label
whiteSpace: 'nowrap'.peopleRow.ts— the samewhiteSpace: 'nowrap'on the count, in the kit rather than at onecall site. Nine call sites had the latent version of this; "3 / Participants" is never what it
meant.
SpaceSettings.ts— its tab strip had the identical bug and the identical missingminWidth: '0', and expressed its overflow through thestylesescape hatch. NowoverflowX,which is a real DS prop.
The mini-profile moved into flow. Absolutely positioned at
left: 16pxit was measuredagainst the whole bar while the nav is measured against the centred
maxWidthcolumn inside it,so the two only missed each other above roughly 1600px of viewport — below that the avatar and
space name drew straight over the first view buttons. It now opens sideways on
$animatewithreveal axis: 'inline', taking its room from the one item that gives some up. Its avatar dropslg→md, matching the buttons beside it now that the bar is one control tall.The nav bar's height is a fact rather than a guess.
NAV_BAR_HEIGHTis exported, set on thebar, and subtracted by the route below it. It was a literal
70pxagainst a bar that measures 73 —button height, padding and border, none of which the consuming file can see — and blind to a
theme's
--we-theme-control-height-offset. There is no CSS way to say "the viewport minus thatsibling", so the bar declares the number and the subtraction reads the same expression.
It also includes
var(--we-scrollbar-width), because a scrollbar is drawn inside the box it belongsto. Hiding it instead (
scrollbarWidth: 'none') was wrong twice over: WE styles every scrollbarglobally from one token at 6px, so it fits — and hiding it left the overflow reachable only by a
horizontal gesture most mice cannot make. The reservation is unconditional, since nothing can ask
"am I overflowing right now?", and is the token rather than
6px:retrosets it to 16.CardsView/index.ts— was carrying the samecalc(100dvh - 70px), which is worse in aview: a view renders inside whatever shell a space is running, and only that shell knows how
tall its chrome is. Now
minHeight: '100%', which fills the box it was given and degrades to ano-op where a shell's outlet has no definite height.
Two gaps that explain why this was written wrong in the first place.
overflowX,overflowY,scrollbarWidth,scrollbarGutterandflexShrinkall work in a schema and none were documented —the DS-props fragment listed
overflowalone, which is very likely whySpaceSettingsreached forstyles. All five are in the Layout table now, with a passage on who-gives-up-space in aRow.flexShrinkwas also missing fromcomponentMeta.ts's editor layer index, so the inspector neveroffered a prop that has always worked.
The rail's call button, in the states nobody had pressed it in
Reported as "Start call doesn't disable or hide when I'm in a call". It is worse than a
button left enabled: the launcher was wired straight to
joinSpaceCall, so one declarationproduced three different behaviours and two of them were wrong.
joinreturns early on a matching id, so the button silentlyabsorbed the click. Dead controls in permanent chrome are the ones people press twice.
joinhitif (callId()) teardown()and ended that call to start a space-wide one. Noconfirmation, from a button whose icon just says "call".
after navigating away to look something up.
Hiding it when active was the obvious fix and is the wrong one. The rail is the only chrome
that is always on screen and the surface people scan for "where am I"; being in a call is the
most stateful thing the app does, and that was the one row of the rail that could never show
it. A control that vanishes also shifts the divider and everything below it.
So the button keeps its place and gains the one reading that holds in every state — go to
the call — which costs nothing when there is no call, because starting one is how you get to
it.
module-shared/src/module.ts—ModuleLaunchergainsactiveLabel. Most launchersneed nothing: "Notes" names a panel and a panel is called the same thing open or shut. It
exists for the launcher whose two states are different acts, where one label is necessarily
wrong half the time — and the half it is wrong in is the half where the tooltip, which is an
icon-only button's only name, describes something the button no longer does.
activeWhen'sdoc comment is widened at the same time: it does not have to mean "my panel is open".
module-call/src/store.ts— newgoToCall, which never callsjoinwhile a call isrunning. No call → start the space call. Call elsewhere → show the stage and navigate to it.
Call here → toggle the stage, since the only thing left to go to is the video.
elsewhereand
returnToCallare hoisted out of the returned object so the bar's way back and therail's launcher share one comparison rather than two that can disagree.
module-call/src/index.ts— the launcher declaresactiveWhen: 'active',activeLabel: 'Go to the call'andaction: 'goToCall'. The comment arguing against anactive state is replaced with the argument above rather than deleted, since it was reasoned
and the reasoning is what changed.
app-shell/…/SpaceStore.tsx—moduleLauncherscomputesactiveonce and picksactiveLabelfrom it. Three lines; the row shape the rail renders is unchanged, soChromeRail.schema.tsneeded no edit and the tooltip switches reactively.joinSpaceCallandjoinAnchoredCallkeep their replace-the-current-call semantics — switchingbetween calls is a real act, just not one a rail button should do by accident.
The other two ways into a call had the same assumption
Tracing the rail bug turned up two template controls making it as well, and one of them is worse
than anything the rail could do.
The Cards header's Call button creates a
CollectionBlockand then joins — deliberately, soa call is resumable before a word is said. Mid-call the create still fired and the join no-opped,
leaving an orphaned empty card on the very list underneath it.
A call card's Continue button fires
joinSpaceCallandtranscribe.resume.resumedoes notfail quietly: it re-points the live transcript at the record it was given and announces the
claim, and peers adopt an announced record in preference to their own. So one stray click on last
month's card moved everybody's live transcript into last month's meeting. That is shared-data
corruption that propagates by design, reached by a button whose icon says "call".
Both now make the rail's promise: while a call is running, go to the call — and the first
attempt at that promise was wrong twice over, caught in manual testing before either landed for
real.
goToCalltoggled the stage, so a button labelled go to the call put the call away; andevery card offered it, so a list of finished meetings each proposed taking you to a call that was
none of them. Going somewhere is idempotent now, the way navigation is, and a card offers the
control only where it has an unambiguous subject.
transcribe/store.ts— exposesliveCollectionId, the record the current call is writinginto. A card otherwise cannot tell the conversation happening now from one that finished last
month: the space-wide call id is derived from the space, so it names the place calls happen
rather than any one of them.
''rather thannullwhen there is none, because the only use is$eqagainst a record id and two falsy values would read as equal enough.call/store.ts—goToCallshows the stage rather than toggling it. "Go to" is adirection, so it is idempotent; putting the video away has two controls of its own (the panel's
close button, and Video in the call bar), neither named after going somewhere.
CardsView/CallsList.ts— Continue is offered in exactly two states: no call running, whereit continues this one, and this card being the running call, where "go to the call" can only
mean the one it is attached to. Otherwise absent. The live card gets a
Livebadge, comparedagainst
liveCollectionId— without it every card looks finished and the one whose buttonbehaves differently is indistinguishable, which reads as the button behaving at random.
CardsView/Header.ts— same branch, so the create cannot fire mid-call; the label becomes"Go to call".
Absent rather than disabled. A disabled control does not reliably deliver hover to the tooltip
that would explain it, so the explanation is the part that goes missing — an inert button that
cannot say why. There is nothing to explain on a card anyway: the call bar is on screen and the rail
tab is lit. Both branches live in the
onClickarray, which resolves lazily at call time(
dispatcher.ts:124); written as a$ifin an action's args it would freeze whichever state the list rendered in.The one control in the panel titlebar that was not a square
Four of a panel titlebar's five controls are
xsghost squares written out by hand. The fifth —the position menu — is a
DropdownMenu, and it drew a filled pill among them. Three differences,from one cause: the component hardcoded its own trigger and gave callers no way to say otherwise.
DropdownMenu.solid.tsx,DropdownMenu.types.ts— the trigger takes atriggerVariantanda
triggerTitle, and inferssquarefrom being icon-only.Not square because
squaresetswidth/heightfrom the component height and drops thesize's
px/py(button.ts:287);without it an icon-only trigger is glyph-plus-padding wide and shorter than it is broad. Inferred
rather than asked for — an icon with no label wants a square in every case there is.
Filled because the trigger set
bg="surface-active". That is the pressed-state role, andcontrolSurfacewas added precisely to stop things borrowing it (a secondary button, a slidertrack, a count chip); the trigger was one of the stragglers. It is
we-button's ownsecondarynow, which is
controlSurface, and identical at rest because that role was given the valueeverything borrowing it already had.
Hovering to the accent, which nobody had noticed and no caller wanted: the hardcoded pair
overrode
bgandcoloron the defaultprimaryvariant and nothoverProps/activeProps, soaccent-hoversurvived the merge. Beside four ghost squares going tosurface-hover.triggerTitleis the tooltip, and the accessible name for an icon-only trigger. The panel menuhad no tooltip at all where its four neighbours each have one, and a
dots-threechip saysnothing about its subject.
The
"Options"fallback was firing on callers that had asked for an icon. It applies onlywhere there is no glyph either now.
TasksView's andkanbanBoard's move menus were bothrendering an arrows icon followed by the literal word "Options"; they name themselves with
triggerTitleinstead ("Move this task" / "Move this card").dockRegistry.ts— the position menu asks fortriggerVariant: 'ghost'andtriggerTitle: 'Position'. The explicittriggerLabel: ''goes, since icon-only is inferred.The glyph stays
dots-three. It was briefly swapped fordots-nineon a report that it read asdisabled — at
xsthe titlebar draws icons in a 12px box, wheredots-threeis 3 px² of inkagainst 15 for the
xbeside it, each dot too small to occupy a whole pixel. Real arithmetic,wrong conclusion: it reproduced only on a low-DPI monitor and
dots-ninelooked worse inexchange, so the swap is reverted. A display artefact, not a defect to design around — though a
glyph that sparse at
xsis a genuine constraint on what can go in that box.index.ts(call),index.test.ts— the call bar's overflow menu was a hand-rolledwe-popover+we-menuwith a comment saying why: "that component draws its own filled triggerand this has to sit in a row of ghost squares as one of them". It is a
DropdownMenunow, andthe ~50 lines of
we-menu-itemthat copied the dropdown'ssmmetrics and its check glyph byhand — three numbers and a colour, kept in agreement with a component nothing linked them to —
are
itemSize: 'sm'and a five-field item object.The second reason it was hand-rolled was its one conditional line: solo is offered only while
something is focused, and a
$ifin theitemsprop resolves toundefined, so reading.typeoff the hole threw.
renderEntryguards it as a memo inside a<Show>rather than an earlyreturn null, and leaves the hole in place rather than filtering it out — both becauseIndexkeys by position, so filtering shifts every later entry into a row built for a different item and
a snapshot taken at creation would never update.
Solid assigns properties on custom elements, so the trigger's
slotis passed explicitly on bothpaths: an optional parameter left off writes the string
"undefined"intoHTMLElement.slotandthe trigger renders into a slot nothing declares. Caught by reading the compiled output.
A video panel that filled the screen the moment it docked
Snapping the call panel to a left, right, top or bottom edge made it take the whole screen, with no
practical way to resize it back. Only that panel, and only on displacing — as a floating card it was
honestly the size it looked.
The cause was a stored
thicknessof 2378px, confirmed from the reporter's ownlocalStorage. Threeseparate faults conspired, each invisible on its own.
fitPlacementwrote a thickness nothing could honour (dockGeometry.ts). A spanning fitsolves
span × ratio, so wide content against a tall edge asks for far more than the screen has:the call stage on a 4K side edge wants 3761px of a 3760px region for a single 16:9 tile, and
more for every arrangement above one. The number was written anyway and
resolveDockclamped it atpaint time, so the panel covered the region and the clamp hid why.
It declines now, given a
maxThicknessit cannot meet. Clamping would be no better — it destroysthe size the user chose and still leaves the band. The bound is
dockThicknessatlg, thelargest size a dock is ever asked for, rather than the region: bounding at the region is barely a
bound, and still let one tile take 92% of a 4K screen. A panel wanting more room than the largest
named dock is asking to be maximised, and there is a control for that.
One
thicknessmeant two things. It was a width on a side edge and a height on a top or bottomone, so a number solved for the left edge became a height the moment the panel was snapped to the
bottom — which is why all four edges filled the screen once one of them had. It is
thicknessXand
thicknessYnow. Nothing converts between them, because there is no conversion: how wide apanel wants to be says nothing about how tall.
The bad value outlived both fixes. Placements are persisted, and a thickness is invisible while
the panel floats — a float resolves from
w/hand never reads one — so it sits in the browserwaiting for the next dock. A legacy
thicknessis dropped on load rather than migrated onto anaxis: the two things a migration would need to know are the two that made it wrong, since which
axis it was solved for is not recorded and 2378px is well inside a 4K region yet absurd on any
edge. Falling back to the card is the documented behaviour and the one people expect.
A fourth fault, found while tracing this and fixed with it. A maximised panel reports
floating: true— it has to; that flag draws the radius, shadow and glass — sogripsgave it alleight handles, and
resizeDockread the same flag, took the floating arm, and wrote the box itmeasured (the whole window) over the card's
w/h. The panel looked unchanged, because themaximised branch resolves ahead of the placement; the size it would restore to was gone, and for a
panel whose dock thickness falls back to the card, so was the size it would dock at. A second route
to the same full-screen dock.
gripsnow testsmaximisedalongsidefloating, andresizeDockrefuses outright — the geometry already said so by leaving
handleX/handleYabsent.A graph that was loading said so in a footnote in the corner
The graph engine had a loading state and it was easy to miss: an
xsspinner and afootnote-sized "Loading…" on a pale pill, absolutely positioned bottom-left, sharing a strip
with the node-budget warning and any expander warnings. That is a reasonable home for
background work and the wrong one for the case it was actually being read in.
The empty state at the centre of the canvas is gated on
!loading, so a first load hidthe one thing occupying the middle of the screen and left the reader a blank canvas with a
footnote in a corner most eyes never visit. A re-seed — switching board or mode — was
worse:
start()clears the store synchronously but only notifies at the end, and therenderer's
nodes()memo recomputes ongraphnotifications only, so the previous graphstayed painted for the whole load. A footnote is not enough to stop a stale board being read
as a live one.
engine.ts—EngineStatusgainsreloading, andbeginLoading/endLoadingtake aLoadScope. One boolean was covering two situations that want opposite treatment: anexpansion lands beside a graph that stays on screen and stays usable, while a reload means
everything drawn is about to be thrown away. A renderer that cannot tell them apart has to
pick one and be wrong about the other.
Both flags are now derived from two counters in a single
syncLoading()rather than eachcall site deciding, and
start()holds itsreloadcount across its whole body ratherthan only the seed load. That last part is the subtle one: seeds and auto-expansion are two
loads, and releasing the count between them published a settled frame in the middle of a
start — which a renderer reads as "finished, and empty".
notify('graph')also moved justinside the
finally, so the nodes are on screen before the load reports itself done.loadSeedsandexpandNodestaypartial, which makes a refresh arriving from asubscription background work for free — the case where getting it wrong would dim the graph
under somebody who is reading it.
GraphView.solid.tsx— the empty state and the loading state are now one centred boxbranching on
loadingWholeGraph()(reloading, orloadingwith nothing drawn). Theyanswer the same question — why is there nothing here — and as two independent conditions
they had drifted into a gap where neither appeared. One box makes them exclusive by
construction rather than by two conditions agreeing.
The layer gets
--stalewhile a reload runs, fading what is drawn to 0.4. Without it acentred "Loading graph…" over a perfectly crisp board reads as though that board is what
is arriving. Opacity only, so nothing re-rasterises and the camera is untouched.
The corner chip survives for background work only, restyled from
neutral-100/neutral-600—scale positions, never measured against what is behind them — to
surface-raised,1px solid borderandtext-muted, with the empty state's colours moving totext-faint.GraphView.scss— the centred spinner is held back 220ms before a 160ms fade (both, so thefrom-state covers the delay): a seed answered from cache resolves inside a frame, and a graph that
flashes a spinner every time reads as slower than one that shows nothing. Under
prefers-reduced-motionthe fade collapses but the delay stays — the delay does the work.One regression worth recording, caught in review of the restyle. The chip was written
py="150"and there is no
150on the space scale.SpaceValueisSpaceToken | (string & {}), so anoff-scale token typechecks, emits
var(--we-space-150), resolves to nothing, and the declaration isdropped silently. Now
py="200".Known follow-ups
(fork into the space vs. an override record) are written up separately.
RerenderLogand the call module'sfake-peer canvas, whose prebuilt dist emits
import.meta.env?.DEVand so is not substituted bythe app build. Neither justifies a boundary; both are named in the commit so the claim made is
accurate.
process.env.NODE_ENVinprimitives/helpers.tsis left alone too — abuild-tool-neutral spelling for a package that must not depend on Vite.
is a Flux transition shim rather than an app platform. Worth knowing before deleting the plumbing:
templates are bounded by the component registry and modules are bundled rather than dynamically
loaded (to avoid a second reactive runtime), so an iframe is currently the only route for
third-party custom-code UI, and the
capabilities: [...]negotiation is the seed of a trust modelif that is ever wanted back. The seed schema also has no
enabled: false, so an app entry canonly be present or deleted — worth adding if deployments start toggling apps.
AgentSettings.installedModules) is still not built;adjacent to the settings work rather than part of it. It is also where a persistent "never
transcribe me automatically" would belong — leaving a transcript is per call for now, which is
right for a decision about a conversation and wrong as a standing preference.
a dead runner slow, and one number is still doing two jobs. The executor-side work — refresh the
claim during a pass, re-check the cursor before escalating, split the stall timer, tag
BatchReadywith the DID, bound the write phase — is innotes/ad4m/auto-processor-claim-followups.md.Gridstill emitsrepeat(N, 1fr)rather thanminmax(0, 1fr). Nothing in the reported bugneeds it now the text default is in place, but
1fr's automaticautominimum remains a generalblowout vector for anything that is not text. Left separate, because on its own it would have
turned the call-card bug into a transcript spilling out of the card, which looks worse.
views than fit" is an overflow menu, which is a component change: it has to measure what fits,
which no schema can express. Dropping the labels below
mdis the cheaper first move. Relatedly,scrollbar-widthis unset there so Chromium keeps WE's::-webkit-scrollbarstyling; Firefoxgets its own wider default, which is the app-wide situation already but the first place a height
depends on it.
we-buttondelivers hover to a wrappingwe-tooltipis browser-dependent and untested here,since the call controls avoid
disabledentirely.joinAnchoredCallunconditionally, so pressing it mid-call replaces the call you are in — at least the act it names,
and unlike the two fixed above it names a specific target. Worth deciding whether it should ask
first, or use
attachAnchor(which points the running call at a node without rejoining).Test plan
Condensed — the full per-stage log is in the commit messages.
Suites, at the tip of the branch, all green.
pnpm buildandpnpm testclean across themonorepo. Targeted:
app-shell651 (+6 todo, 49 files) ·schema-shared615 ·backend-ad4m238 ·graph-core180 ·backend-shared176 ·primitives133 ·schema-solid108 ·module-call108 ·design-utils95 ·template-kit92 ·module-transcribe80 ·graph-solid22.validate32schemas no issues;
role-audit0 findings;surface-audit461 sunken nodes, no new offenders andno hand-rolled scrims left; typecheck and eslint clean on every changed package.
New coverage: all three ways into a call, asserted on the composed view so a fragment fixed but
left unwired still fails · the three profile formats and the
needsNamegate · a$ifaction in aplain callback prop resolving at click time · every kit fixture that raises the discard flag ·
folded and unfolded call bars · transcribe auto-join, per-call leaving and the no-model fallback ·
four graph load-scope cases · three dock-geometry cases · the four
detailWithheldgate cases.Confirmed non-vacuous by reverting the fix and watching them fail: the two call-teardown cases ·
callEntryPoints(stashing the two template files fails 5 of 7) · the graph mid-start case (exactlyone non-loading status across a whole
start()) · the composerModal discard guard · the one-way-doordev gate · the import-graph exclusion, itself vacuous twice before it worked — it exempted the whole
harness directory, then matched only
importwhen the regression is speltexport … from.Checked against built output rather than source, since that is where three of these bugs lived:
the production bundle greps clean for "Schema Mutations" / "Schema Routing" (
RerenderLogandfake peerstill present, as declared above) · the generated stylesheet emits theoverflow-wrapfallback and a layout-only component emits none ·
dist/styles.csscarries the graph's 220ms delayand 0.4 stale opacity ·
dist/solid/index.jsconfirms Solid assignsslotas a property.Measured rather than assumed: the engine difference on overlay height, the avatar hue spread
across 12 uuids, the nav bar's height from its tokens, and the reported dock panel simulated through
the real
resolveDockon every edge — each written up in its section above. Tauri additionally:validate:seedreports "native WE app mode", the no-appsseed_servers.rscompiles standaloneunder
rustcwithout warnings, the apps path still emits byte-identical Rust, and deletinggenerated/andCargo.tomland re-running the generator restores every file.Manually tested in a real space: the call entry points — which found two bugs in the first
version of the card fix, both now pinned by tests that fail against the version that shipped them ·
the
Livebadge · continuing a finished call · the graph re-seed fade · the Developer switch bothdirections · Schema Tests on first press · panel glass · an overflowing view strip and the
mini-profile at laptop width · a maximised panel's grips · the discard guard on a composer,
including the half that matters (an untouched form closes without asking).
Still to test manually — needs hardware, another person, or a release build:
pnpm tauri:build). The generated Rust was checked standalone andthe config diff is a single field, but the full compile and bundle were not run here.
non-elected peers, no duplicates; a second agent auto-joining transcription and the "N of M
transcribing" readout; the call bar folding in a narrowed space with a deep dock.
rather than
[object Object]or a blank, and only a genuinely nameless agent asked.(Escape, focus trap, themed scrim); Escape with two stacked dismissing only the top.
view opened cold and a node expanded; focus rings across the four controls; the titlebar's
five controls resting/hovered/pressed;
retro's 16px scrollbar on an overflowing strip.