Skip to content

Fix: UI sweep - #165

Merged
jhweir merged 37 commits into
devfrom
fix/ui-sweep
Aug 27, 2026
Merged

Fix: UI sweep#165
jhweir merged 37 commits into
devfrom
fix/ui-sweep

Conversation

@jhweir

@jhweir jhweir commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

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-modal has a size
now, 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 gets height: '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 — Chrome
    resolves a percentage through an auto-height ancestor, Firefox follows CSS 2.1 and treats it as
    indefinite. min-height here does not fix it, since the box stays auto-height and the child's
    percentage stays indefinite. Measured in Chrome 150 and Firefox 152; only height works.

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 calc from 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 shared AvatarTone vocabulary, replacing two ad-hoc
    marks. 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-avatar as a prop — so a second kind of badge is a second fragment
    rather than a second prop on a primitive.

  • themes/retro/index.css — a dead rule squaring off the ring we-avatar[selected] used to
    draw, left over from removing that attribute. Caught by themeSelectors, which reads the
    primitives' 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; a
    displacing or maximised one is opaque. DockGeometry gains maximised so the condition
    can tell the two apart.

    Translucency goes through the existing --we-theme-surface-opacity / --we-theme-surface-blur
    keys 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 picker
    took 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. TemplateSwitcherItem
    gains 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 view
    rendered 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 separate
    problems behind one odd-looking transition: a custom transition prop fighting the state
    machinery, the outline button variant losing its ring because mergeProps is a shallow
    spread (a variant's focusProps replaces the base wholesale), and the two DS families
    ordering hover and focus differently.

    Rule order in dsInterop is 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; they
    now 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 size
    from 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:

    1. platform.isDevelopment — a fact about the build. Stays honest; never gated on.
    2. sessionStore.devTools — whether developer affordances should be visible. Now a
      signal rather than a value read once, with a Settings → Developer switch behind it.
    3. Bundle exclusion — a separate problem, addressed below.

    The asymmetry is the design and is easy to get wrong: the Developer 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 it,
    because it only shows up on the first genuine use.

    The call module's fake peers keep their production-absence guarantee — devPeersAvailable is the
    build flag alone, so a shipped app carries no node and no callable addFakePeer — while
    visibility moved to an $if plus a per-solve re-read, so muting does not leave two synthetic
    participants 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 in
    development.

    The import.meta.env.DEV guards looked exactly like exclusion and were not. A branch decides
    whether 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 dist behind a
    condition 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 of
    TemplateLayout into shellViews.ts, which now has eager entries for ordinary chrome and
    lazy 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 resolves
    a 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/shell is shared by every
    deployment, 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.apps while the list iterates appStore.appsWithWe. That reads like
    a slip and is not — appsWithWe prepends a WE sentinel whose row means get back out of an
    app
    , 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.jsonapps is empty. This 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 → moduleRegistry.embeds()appStore.apps(); with the array empty the
    loop runs zero times. appBridge's credentialed origins default to the registered embeds, so
    no origin is credentialed either.

    Nothing is removed: appStore, PersistentAppFrames, resolveAppUrl and the embed path in
    initializeIntegrations 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; 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 .gitignore
saying "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.cjslib.rs declares mod generated; unconditionally and calls
    generated::setup_seed_servers, but the generator's native-mode path returned before writing
    mod.rs or seed_servers.rs. In a deployment with no apps — now the default — they were never
    generated, and the build only worked because copies sat in git. writeGeneratedModule now runs
    on both paths; with no apps it emits a no-op setup_seed_servers with no import and an
    underscored parameter, so it compiles without warnings.

  • tauri.conf.json — the native path now calls updateTauriConfig({}). The file is
    hand-authored except for bundle.resources, which the generator has always owned but never
    cleared 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.json deleted, not just untracked. It held exactly what goes into
    tauri.conf.json and nothing ever read it — Tauri cannot 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 hooks added, 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 branch; untracking is what would have made it bite.

git ls-files --cached --ignored --exclude-standard now reports nothing.

A modal's width was an emergent property of its longest line of text

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. 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.

  • modal.ts, types.tswe-modal takes a 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.ts — covers the cases that had 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, 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.
    open and close are expressions now, plus cancel, tone, detail and children. All four
    hand-written forks are gone.

  • formModal.ts (new) — for the ten modals that were a title, some fields, Cancel and Save, and
    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" 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 asks 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.

Ask before a stray click throws away what somebody typed

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.ts (new) 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.tsx 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
    own dirty flags, which mark every node dirty on the setEditorState that loads a post to edit.

  • Two stores learned to answer itrecordStore.recordDraftDirty, because a record form's
    fields 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:

  • 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 — 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 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.

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, and
composerModal never mounted the confirmation that reads it — discardGuard hands back three
pieces 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, and kit.test.ts asserts over every fixture that a
fragment raising the discard flag also mounts something reading it.

BlockComposer's dirty baseline also no longer depends on mount ordering: a snapshot taken in
onMount races Lexical's own initialisation (the empty paragraph arrives in a batched
editor.update()), so on the losing side a blank composer reported unsaved work the instant it
opened. 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-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 different
spellings.

  • design-types, design-utils — a new overflowWrap typography prop
    ('normal' | 'break-word' | 'anywhere'), added to typographyKeys, to BASE_TYPOGRAPHY_SPECS
    with anywhere as the spec fallback, and to buildLayoutStyles. The fallback is what makes it
    a default: every typography component now emits
    overflow-wrap: var(--we-<name>-overflow-wrap, anywhere) on [part='base'], so a node that says
    nothing 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, not break-word. This is the whole reason the prop exists rather than being 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 all the way out to the route. break-word is the value that
    looks right and does not fix the bug; there is a test pinning the choice so nobody simplifies it
    back. word-break: break-all is deliberately not offered — it breaks ordinary prose too, and on
    a bare identifier (which is what every site reaching for it held) it renders identically.

  • createLayoutComponent.tsxLAYOUT_DEFAULTS, so Column/Row/Grid/Card get the same
    default beneath their own. overflow-wrap inherits, so this also covers text those components
    hold directly: a bare string child, a native <p> or <span> in a template, rendered block
    content. Without it only text that happened to be wrapped in we-text would break, which reads as
    inconsistent styling rather than as a rule.

  • componentMeta.tsoverflowWrap and whiteSpace added to the enum map and
    DS_PROP_LAYER. whiteSpace was a pre-existing omission: a real DS prop for some time, in neither
    the validator's layer map nor the generated docs, so invisible to schema authors.

  • markdown.ts, html.ts — their hand-rolled word-break: break-word on :host removed. It
    was the same behaviour under a deprecated alias, and being on :host with no custom-property
    indirection it was unreachable from a schema. The layer default covers them and inherits into the
    rendered content.

  • Twelve call sites de-patchedHostSettings, SpaceSettings, Settings, ConsentPrompt,
    RuntimeSettings (×3), LanguageSettings, BootScreen, NodeDetail, AiPanel. Four were dead
    CSS all along (three we-code blocks, where white-space: pre suppresses soft wrapping whatever
    word-break says, and one truncate node): removing them changes nothing, but leaving them would
    imply the default does not reach there.

Behaviour change worth knowing: 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 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 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:

    1. 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.
    2. 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.
    3. 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.
  • CallControl.schema.ts, Panel.schema.ts, CardsView/CallsList.ts — coverage was already
    computed, 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.tsclaimTtlMs goes from 60s to 10 minutes. 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 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.tsbatchReady no longer opens a row. It fires on every peer
    before 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. claimed is where a
    pass starts here.
  • interpretationRelay.tspublish broadcasts only mine rows, the rule resend
    already 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 what
    they found". The pass's subject is the runner; "it" read as the machine's work.
  • interpretationActivity.tswriting is 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 space
    setting 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 contract
    gains detailShared(); hasLockedPass becomes detailWithheld. The note itself is one line
    at 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-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.

Agents WE did not create now have names

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.

  • agentHelpers.ts, profileTypes.ts — 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.

  • ProfileStore.tsx, NamePrompt.schema.ts (new) — 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.

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-button sets white-space: nowrap, so the
strip's min-content width is the sum of every view's label, and a flex item's min-width: auto
resolves 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 an overflow: auto box, that
surfaced 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'. 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.
    Presence gets flex: '0 0 auto' so an ornament stops absorbing somebody else's overflow, and
    its label whiteSpace: 'nowrap'.
  • peopleRow.ts — the same whiteSpace: 'nowrap' on the count, in the kit rather than at one
    call 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 missing
    minWidth: '0', and expressed its overflow through the styles escape hatch. Now overflowX,
    which is a real DS prop.

The mini-profile moved into flow. Absolutely positioned at left: 16px it was measured
against the whole bar while the nav is measured against the centred maxWidth column 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 $animate with
reveal axis: 'inline', taking its room from the one item that gives some up. Its avatar drops
lgmd, 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_HEIGHT is exported, set on the
bar, and subtracted by the route below it. It was a literal 70px against 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 that
sibling", 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 belongs
to. Hiding it instead (scrollbarWidth: 'none') was wrong twice over: WE styles every scrollbar
globally 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: retro sets it to 16.

  • CardsView/index.ts — was carrying the same calc(100dvh - 70px), which is worse in a
    view: 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 a
    no-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, scrollbarGutter and flexShrink all work in a schema and none were documented —
the DS-props fragment listed overflow alone, which is very likely why SpaceSettings reached for
styles. All five are in the Layout table now, with a passage on who-gives-up-space in a Row.
flexShrink was also missing from componentMeta.ts's editor layer index, so the inspector never
offered 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 declaration
produced three different behaviours and two of them were wrong.

  • In this space's calljoin returns early on a matching id, so the button silently
    absorbed the click. Dead controls in permanent chrome are the ones people press twice.
  • In an anchored call (a call about a post) — the ids differ, so join hit
    if (callId()) teardown() and ended that call to start a space-wide one. No
    confirmation, from a button whose icon just says "call".
  • In a call in another space — the same teardown, reached by pressing the rail button
    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.tsModuleLauncher gains activeLabel. Most launchers
    need 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's
    doc comment is widened at the same time: it does not have to mean "my panel is open".
  • module-call/src/store.ts — new goToCall, which never calls join while a call is
    running. 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. elsewhere
    and returnToCall are hoisted out of the returned object so the bar's way back and the
    rail's launcher share one comparison rather than two that can disagree.
  • module-call/src/index.ts — the launcher declares activeWhen: 'active',
    activeLabel: 'Go to the call' and action: 'goToCall'. The comment arguing against an
    active state is replaced with the argument above rather than deleted, since it was reasoned
    and the reasoning is what changed.
  • app-shell/…/SpaceStore.tsxmoduleLaunchers computes active once and picks
    activeLabel from it. Three lines; the row shape the rail renders is unchanged, so
    ChromeRail.schema.ts needed no edit and the tooltip switches reactively.

joinSpaceCall and joinAnchoredCall keep their replace-the-current-call semantics — switching
between 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 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.

A call card's Continue button fires joinSpaceCall and transcribe.resume. resume does not
fail 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. goToCall toggled the stage, so a button labelled go to the call put the call away; and
every 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 — exposes liveCollectionId, the record the current call is writing
    into. 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 than null when there is none, because the only use is
    $eq against a record id and two falsy values would read as equal enough.
  • call/store.tsgoToCall shows the stage rather than toggling it. "Go to" is a
    direction, 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, 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. The live card gets a Live badge, compared
    against liveCollectionId — without it every card looks finished and the one whose button
    behaves 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 onClick array, which resolves lazily at call time
(dispatcher.ts:124); written as a
$if in 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 xs ghost 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 a triggerVariant and
    a triggerTitle, and infers square from being icon-only.

    Not square because square sets width/height from the component height and drops the
    size'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, and
    controlSurface was added precisely to stop things borrowing it (a secondary button, a slider
    track, a count chip); the trigger was one of the stragglers. 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.

    triggerTitle is the tooltip, and the accessible name for an icon-only trigger. The panel menu
    had no tooltip at all where its four neighbours each have one, and a dots-three chip says
    nothing about its subject.

  • The "Options" fallback was firing on callers that had asked for an icon. It applies only
    where there is no glyph either now. TasksView's and kanbanBoard's move menus were both
    rendering an arrows icon followed by the literal word "Options"; they name themselves with
    triggerTitle instead ("Move this task" / "Move this card").

  • dockRegistry.ts — the position menu asks for triggerVariant: 'ghost' and
    triggerTitle: 'Position'. The explicit triggerLabel: '' goes, since icon-only is inferred.

    The glyph stays dots-three. It was briefly swapped for dots-nine on a report that it read as
    disabled — at xs the titlebar draws icons in a 12px box, where dots-three is 3 px² of ink
    against 15 for the x beside it, each dot too small to occupy a whole pixel. Real arithmetic,
    wrong conclusion: it reproduced only on a low-DPI monitor and dots-nine looked worse in
    exchange, so the swap is reverted. A display artefact, not a defect to design around — though a
    glyph that sparse at xs is 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-rolled
    we-popover + we-menu with a comment saying why: "that component draws its own filled trigger
    and this has to sit in a row of ghost squares as one of them". It is a DropdownMenu now, and
    the ~50 lines of we-menu-item that copied the dropdown's sm metrics and its check glyph by
    hand — 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 $if in the items prop resolves to undefined, so reading .type
    off the hole threw. renderEntry guards it as a memo inside a <Show> rather than an early
    return null, and leaves the hole in place rather than filtering it out — both because Index
    keys 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 slot is passed explicitly on both
    paths: an optional parameter left off writes the string "undefined" into HTMLElement.slot and
    the 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 thickness of 2378px, confirmed from the reporter's own localStorage. Three
separate faults conspired, each invisible on its own.

  • fitPlacement wrote a thickness nothing could honour (dockGeometry.ts). 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. 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. It was 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. It is thicknessX
    and thicknessY now. Nothing converts between them, because there is no conversion: 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 and fixed with it. A maximised panel reports
floating: true — it has to; 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 now tests maximised alongside floating, and resizeDock
refuses outright — the geometry already said so by leaving handleX/handleY absent.

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 xs spinner and a
footnote-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 hid
the 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 the
renderer's nodes() memo recomputes on graph notifications only, so the previous graph
stayed painted for the whole load. A footnote is not enough to stop a stale board being read
as a live one.

  • engine.tsEngineStatus gains reloading, and beginLoading/endLoading take a
    LoadScope. One boolean was covering two situations that want opposite treatment: an
    expansion 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 each
    call site deciding, and start() holds its reload count across its whole body rather
    than 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 just
    inside the finally, so the nodes are on screen before the load reports itself done.

    loadSeeds and expandNode stay partial, which makes a refresh arriving from a
    subscription 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 box
    branching on loadingWholeGraph() (reloading, or loading with nothing drawn). They
    answer 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 --stale while a reload runs, fading what is drawn to 0.4. Without it a
    centred "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 border and text-muted, with the empty state's colours moving to text-faint.

  • GraphView.scss — the centred spinner is held back 220ms before a 160ms fade (both, so the
    from-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-motion the 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 150 on the space scale. SpaceValue is SpaceToken | (string & {}), so an
off-scale token typechecks, emits var(--we-space-150), resolves to nothing, and the declaration is
dropped silently. Now py="200".

Known follow-ups

  • Where an edited view is saved is unanswered — only the legibility fix is here. The options
    (fork into the space vs. an override record) are written up separately.
  • Two small developer-only paths still ship, deliberately: RerenderLog 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 justifies a boundary; both are named in the commit so the claim made is
    accurate. process.env.NODE_ENV in primitives/helpers.ts is left alone too — a
    build-tool-neutral spelling for a package that must not depend on Vite.
  • Whether to delete embedded apps entirely is deliberately left open. What is being deprecated
    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 model
    if that is ever wanted back. The seed schema also has no enabled: false, so an app entry can
    only be present or deleted — worth adding if deployments start toggling apps.
  • The agent-level module settings layer (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.
  • The claim TTL is a mitigation, not a fix. Ten minutes stops the re-runs but makes takeover of
    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
    BatchReady with the DID, bound the write phase — is in
    notes/ad4m/auto-processor-claim-followups.md.
  • Grid still emits repeat(N, 1fr) rather than minmax(0, 1fr). Nothing in the reported bug
    needs it now the text default is in place, but 1fr's automatic auto minimum remains a general
    blowout 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.
  • A horizontally scrolling strip is still a quiet affordance. The discoverable answer to "more
    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 md is the cheaper first move. Relatedly,
    scrollbar-width is unset there so Chromium keeps WE's ::-webkit-scrollbar styling; Firefox
    gets its own wider default, which is the app-wide situation already but the first place a height
    depends on it.
  • The disabled-button tooltip question is dodged rather than answered — whether a disabled
    we-button delivers hover to a wrapping we-tooltip is browser-dependent and untested here,
    since the call controls avoid disabled entirely.
  • The anchored-call button on a post is untouched. It still calls joinAnchoredCall
    unconditionally, 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 build and pnpm test clean across the
monorepo. Targeted: app-shell 651 (+6 todo, 49 files) · schema-shared 615 · backend-ad4m 238 ·
graph-core 180 · backend-shared 176 · primitives 133 · schema-solid 108 · module-call 108 ·
design-utils 95 · template-kit 92 · module-transcribe 80 · graph-solid 22. validate 32
schemas no issues; role-audit 0 findings; surface-audit 461 sunken nodes, no new offenders and
no 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 needsName gate · a $if action in a
plain 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 detailWithheld gate 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 (exactly
one non-loading status across a whole start()) · the composerModal discard guard · the one-way-door
dev gate · the import-graph exclusion, itself vacuous twice before it worked — it exempted the whole
harness directory, then matched only import when the regression is spelt export … 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" (RerenderLog and
fake peer still present, as declared above) · the generated stylesheet emits the overflow-wrap
fallback and a layout-only component emits none · dist/styles.css carries the graph's 220ms delay
and 0.4 stale opacity · dist/solid/index.js confirms Solid assigns slot as 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 resolveDock on every edge — each written up in its section above. Tauri additionally:
validate:seed reports "native WE app mode", the no-apps seed_servers.rs compiles standalone
under rustc without warnings, the apps path still emits byte-identical Rust, and deleting
generated/ and Cargo.toml and 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 Live badge · continuing a finished call · the graph re-seed fade · the Developer switch both
directions · 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:

  • A tauri release build (pnpm tauri:build). The generated Rust was checked standalone and
    the config diff is a single field, but the full compile and bundle were not run here.
  • A multi-party call on local models: one result row per batch, no "Nothing to add" from
    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.
  • The name prompt against identities made in the ADAM Launcher and in Flux — real names
    rather than [object Object] or a blank, and only a genuinely nameless agent asked.
  • Modals: sizes at phone width; the consent prompts and account removal as real modals
    (Escape, focus trap, themed scrim); Escape with two stacked dismissing only the top.
  • Remaining visual passes: the call panel docked to each edge on 4K and on a laptop; a graph
    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.

jhweir and others added 30 commits August 26, 2026 19:23
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>
jhweir and others added 7 commits August 27, 2026 13:22
… 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>
@netlify

netlify Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

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

QR Code

Use your smartphone camera to open QR code link.

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

@jhweir
jhweir merged commit b6fc1a2 into dev Aug 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant