Skip to content

Feature: Call transcription - #110

Merged
jhweir merged 56 commits into
devfrom
feat/call-transcription
Aug 10, 2026
Merged

Feature: Call transcription#110
jhweir merged 56 commits into
devfrom
feat/call-transcription

Conversation

@jhweir

@jhweir jhweir commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Call transcription

Summary

A call in WE could be joined and seen, but nothing came out of it. This branch adds a transcribe
module
that turns what is said in a call into durable text in the space, and takes the call module
from a floating video pill to a dockable stage that the rest of the workspace can live alongside.

The transcript lands as a CollectionBlock with kind: 'call' — one record per call, holding the
utterances as children and the roster as participants — rather than as loose blocks in the space.
That record is what makes a transcript a thing: it can be titled, described, browsed on the Cards
route, deleted, and picked back up after a call ends. Getting several agents to agree on one such
record, without a coordinator, is most of the interesting work here.

One architectural constraint shaped nearly every decision. The transcribe module never imports the
call module and vice versa — the call declares audioSource: 'localAudio', the host lends the stream
to whichever module asks, and cooperation happens through presence and slot anchors.

Along the way this fixed a lot of things it did not set out to fix. A call was audibly feeding back
because we-video's muted prop had never muted anything; presence dropped state changes and took
five seconds to repair; node.slot had never worked in the schema renderer; profile pictures were
being written into the DOM as multi-hundred-kilobyte attributes. Those are listed under Changes
rather than buried, because most were found by using the feature rather than by reading the code.


Changes

The transcription port

  • packages/backend-system/shared/src/transcription.ts — a port for using a transcription
    model rather than administering one: open a stream, feed it audio, get text back. Named in the
    contract so a distributable feature module can reach it without importing a host's adapter.
  • packages/backend-system/ad4m/src/transcriptionAdapter.ts — the AD4M implementation, plus
    capabilities.ts so a module can be told why it cannot run instead of half-mounting.

The transcribe module

  • packages/module-system/transcribe/ — fragments only: no framework, no backend. The one piece
    of genuinely imperative machinery is an AudioWorklet doing voice-activity detection on the audio
    thread, which lives in the store as plain TypeScript.
  • It listens to the call's own MediaStream, borrowed through deps.audioInput. That is what makes
    muting the call stop the transcript: a muted track is disabled rather than removed, so the worklet
    hears silence. A second getUserMedia would have kept transcribing someone who believed they were
    muted.
  • The VAD thresholds are Flux's effective ones, not the ones in its defaults file — Flux overwrites
    them at startup, so porting the file faithfully reproduced roughly double the real values and the
    symptom was having to speak up to be heard.

Agreeing on one record

  • Announce on the button press, not on the first flush. The transcribe presence activity carries
    recording and the collection claim — two facts with different lifetimes, since recording stops
    while the claim outlives it so a late joiner still adopts the record.
  • An election decides who creates it. Whoever is recording and sorts first creates; everyone else
    waits for the announcement. This works because intent is published early: by the time anybody
    speaks, every recorder knows who else is recording and they all sort the same list. The old race was
    not the heartbeat its comment claimed but the whole span before anyone had finished an utterance —
    which two agents starting together and then speaking together hit every time, producing two
    transcripts of one meeting.
  • Losing the election defers rather than drops. ensureCollection returns waiting / nowhere /
    ready, because "come back in a moment" and "there is nowhere for this to go" are nothing alike;
    collapsed into one null, the opening line of every call would be lost for everyone but one agent.
    The wait ends after 5s, since the elected agent may never speak.
  • Peers are offered the transcript, not started on it. Turning on someone's microphone and writing
    what it hears into a shared space is their decision. A named, dismissable prompt appears in the call
    bar. The cost is accepted: a call where nobody accepts is transcribed from one microphone.

The roster is a set, by construction

  • participants is a @HasMany — a bag of links. Nothing at the storage layer can refuse a duplicate,
    deliberately, since refusing means a read-modify-write that drops whoever loses the race. So it is a
    set only while there is one writer per member, and each agent now appends only itself.
  • Coverage survives and improves: appending is not tied to speaking, so an agent who never records and
    never speaks still appears. Previously they showed up only if someone else happened to flush while
    they were present.
  • The guard is keyed on the record, never on the call — the call id is derived from the space and
    never changes, so keying on it would let a rejoin append a second copy.

The call stage

  • Docked rather than floating, with the placement collapsed into a single radio choice (float, four
    edges, full). The old four-state mode button encoded three questions at once, so it could not have
    a clear icon and reaching a given state took up to three clicks through states nobody wanted.
  • Tiles show who they belong to, say "Connecting…" instead of showing nothing, and stopped rendering a
    black rectangle over an empty-but-non-null MediaStream.
  • CallTileState is split from CallTile so volatile flags do not remount a row — <For> is keyed by
    reference, and muting your microphone used to blank your own video.

Presence reliability

Five fixes, all of the same character: the roster is what every other feature reads, so a lost message
is a lie about who is there.

  • State changes were being dropped outright, and are now sent twice.
  • An agent entering a space announces instead of waiting to be noticed.
  • A beat sent while apparently alone solicits, so an outage repairs in one interval rather than
    becoming the better part of a minute against a reconnecting remote executor.
  • The publisher no longer rebuilds on every route change.

Fixes found along the way

  • we-video never muted. The muted content attribute maps to defaultMuted and seeds the IDL
    property only at element creation; lit-html clones its template with bound attributes stripped, so
    ?muted moved a flag on an element already at muted === false. The call's self tile played its own
    microphone back through the speakers.
  • node.slot never worked. Every node renders inside a display: contents wrapper, so the wrapper
    is the direct child a shadow host sees, and slot assignment considers direct children only. Content
    aimed at a named slot fell into the default one.
  • we-tooltip padded every trigger. An inline-block trigger stands on a text baseline with room
    reserved for descenders — space belonging to a font, in a box that may hold no text.
  • Avatars carried their picture as a DOM attribute. A WE profile picture is a base64 data URI, and
    image was reflected, so the whole payload was written into the DOM per avatar. Uploads are now
    capped at 512px too; they had no dimension ceiling at all.
  • Primitives silently ignored styles, and one missing super.updated turned the whole design
    system off for an element — now guarded by check-super-calls.mjs.
  • A theme wiped the shell's layout variables.

Around the edges

  • CollectionBlock gains title and description; deletePost becomes the kind-agnostic
    deleteCollection; a call can be named, described, deleted and continued from its card.
  • A display name is assembled once, where the profile cache is built, instead of by eight
    templates that each concatenated first and last with no handle fallback.
  • AvatarStack deduplicates and shows a +N chip instead of silently dropping everyone past max.
  • Join is resilient to the executor's flat 30s timeout, which a first join routinely exceeds while
    still succeeding.
  • Settings groups modules by surface and reads the actual grant instead of guessing.
  • we-seed.json enables the transcribe module.

Known follow-ups

  • @HasMany has no distinct. The one-writer rule is a prose contract that nothing enforces; the
    next module to append another agent's DID reintroduces duplicate rosters silently. Written up in
    docs/internal/plans/ad4m/hasmany-set-semantics.md, with the WE cleanup that follows once it lands.
  • Existing call records keep their duplicate participant links. The AvatarStack dedupe covers the
    display; $count over those older relations stays wrong. Backfilling means deleting another agent's
    assertion, which is not obviously ours to do.
  • A network partition can still produce two records. Two agents who cannot see each other cannot
    agree, and no election fixes that. The merge path was considered and deliberately not built.
  • slot still does nothing on layer-4/5 components. It works on we-* primitives and native tags;
    on Column/Row/Card it is received as a prop and dropped. Fixing it means forwarding slot in
    every component in @we/components.
  • A manually created call is an empty record until somebody speaks in it. That is the deliberate
    cost of being able to set one up ahead of time, but it does mean the calls list can show empty cards.
  • textContent ignores a call's title, so search over the Cards route will not match it.
  • The transcribe module does not seed a title — new calls start untitled.
  • Space avatars, and profile/space cover images, still upload without a dimension cap. Only profile
    and space avatars were capped here.

Test plan

Automated, run on the final tree:

  • pnpm -r --no-bail run test1292 passing, no failures, across 19 packages.
  • pnpm --filter @we/schema-shared validate — 22 schemas, no issues.
  • tsc --noEmit on every package touched: app-shell, backend-shared, backend-ad4m, models,
    primitives, components, module-call, module-transcribe, schema-solid, template-default,
    block-shared.
  • pnpm build on the packages whose dist the app loads — several bugs in this branch were
    diagnosed only after discovering a stale build, so this is not a formality.
  • New unit coverage for the parts that are silent when wrong: the election and its deadline, the
    deferred first utterance, resuming a call, and one-writer participation (33 tests in
    module-transcribe).

Manually verified in the running app, two agents against a shared space:

  • Two agents in a call; one starts transcribing, the other is prompted and joins.
  • Both speak — utterances land in one call record, attributed to the right speaker.
  • Starting a call no longer feeds back through the speakers.
  • A call record can be titled, described and deleted from its card; delete shows progress.
  • Continue rejoins a call and appends to the existing transcript.
  • The Call button on the calls tab creates a record and starts a call pinned to it.
  • Avatar stacks show one face per participant, with names on hover.
  • Joining a slow space reports progress rather than appearing to fail.

Not verified:

  • Anything beyond two agents. Election and roster behaviour at higher concurrency is reasoned
    about and unit-tested, not observed.
  • Behaviour under a real network partition.
  • iOS Safari, where playsinline matters most.

jhweir and others added 30 commits August 8, 2026 23:08
…istering one

`RuntimeAdminPort` already lists transcription models, adds them, downloads them
and picks a default — everything about managing one, and nothing that runs one.
So porting Flux's transcription meant a module reaching for `@coasys/ad4m`
directly and declaring `backends: ['ad4m']`, which is the coupling the module
contract exists to prevent.

`TranscriptionPort` is a session rather than a function. The obvious shape —
`transcribe(audio): Promise<string>` — is wrong for speech: a caller does not
have "the audio", it has a microphone producing samples indefinitely, and it
wants a sentence as soon as the sentence is finished rather than when the speaker
stops for the day. So: open a stream, feed it utterances, receive text.

Segmenting continuous audio into utterances stays with the caller. It needs an
`AudioWorklet` on the audio thread, which is a browser concern with no business
behind a backend port, and the caller is the only one that knows what it is
listening to.

The AD4M adapter is thin, because the executor already runs Whisper and segments
what it is fed. What it adds is the two guarantees the port makes and the client
does not: feeding a closed stream is harmless — audio arrives from a worklet on
its own schedule, so a buffer in flight when someone hangs up is ordinary rather
than exceptional — and opening an unusable model fails loudly, since a model
still downloading otherwise produces silence indistinguishable from nobody
speaking.

The in-memory backend omits the port, which is what optional is for.

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

Three additions to `ModuleStoreDeps`, all late-bound through the existing host
services registry.

`transcription` is the port. `createEntity` is the imperative twin of the
`model.create` a schema already has — a module that writes in response to a click
should keep using the schema action, which is why notes ships no CRUD wrapper,
but a transcript appears because somebody spoke and there is no event to hang a
schema action on.

`audioInput` is the interesting one. A transcriber needs the microphone the call
is already sending, not one of its own: a second `getUserMedia` would keep
listening straight through a mute, which is the kind of surprise that makes a
feature untrustworthy. But module stores have no channel to each other by design,
and opening one so a transcriber could reach into a call would be a worse answer
than routing through the host.

So a module *declares* `audioSource` — the key on its store that returns what it
is capturing — exactly as it declares a `launcher`. The call module knows it has a
microphone open; only the host knows who else might want to hear it. The host
resolves it on every read, because the producing store may not exist when a
consumer is constructed and the stream comes and goes with the call.

The call publishes the live stream rather than a copy. Muting disables the track
rather than removing it, so a listener receives silence and stops producing — and
"mute the call" means "stop transcribing" with no coordination between the two
and no way for them to disagree.

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

The port and the store deps landed in the two commits before this; here is the module
that uses them, plus the plumbing to register it.

The interesting part is what is absent: @we/module-transcribe does not import
@we/module-call and the call module does not import it. The call declares
`audioSource: 'localAudio'`, the host lends whatever it finds to every module store as
`deps.audioInput`, and the transcriber listens to that. Either can be uninstalled and the
other still works.

Routing the call's own MediaStream rather than a fresh getUserMedia is also what makes
muting stop the transcript: mute disables the track, the track goes silent, the VAD never
fires. A second capture would have kept transcribing someone who believed they were muted.

Voice-activity detection is Flux's, ported to TypeScript and shipped as a source string
loaded from a Blob URL. A worklet has no module resolution and is reached only by URL, so
the alternative was a file every host app serves from its public directory. It is exercised
against stubs in workletSource.test.ts — it runs in a scope where a thrown error looks
exactly like nobody speaking, so "silence emits nothing" is a test rather than a hope.

Transcripts are written as TextBlocks tagged 'transcript', flat, with no grouping. The tag
is what distinguishes them from the blocks that make up a post. Grouping is deliberately
deferred: the next step is an LLM pass building a knowledge map, which would rather
re-segment raw utterances than unpick a structure imposed here.

Also: the schema validator now walks packages/module-system, and knows `modules` as a
store namespace. Module fragments previously failed on their first token — every one of
them references `modules.<id>.*`, which is the only way a module can reach its own store —
so no module fragment could be validated at all. The panel is in a Panel.schema.ts for the
same reason; the other three modules still declare their fragments inline.
… left stale

Context regeneration for the new port and module, plus three test failures that were
sitting on dev since the module-system merge and would have been read as mine.

- callModule.test.ts asserted the per-space gate on `enabledModules`. It is `activeModules`
  now — what the space enabled is one of three layers, and a module the agent has not
  installed or has muted here must not render.
- moduleRegistry.test.ts predates `core:createSpace`, added when the sidebar's spaces group
  grew a plus button.
- executorFreeBoot.test.tsx stubs TemplateStore and ThemeStore by hand and mounts a partial
  provider stack. SpaceStore has since grown reads of `defaultTemplateId`/`defaultThemeId`
  and a dependency on AppStore, so every test in the file failed at provider construction.
  Stubs completed and AppStoreProvider added, matching the real nesting.

Also documents `modules.<id>.<key>` in the store-patterns fragment — it was reachable from
templates and validated by nothing.
…d never start

Three defects found reading the wiring end to end, before any manual testing. The first
was fatal.

**The call's audio was not observable.** `localAudio` read `callId()` and then reached
through a plain `controller` variable. But `join` sets `callId` first and only then builds
the controller and awaits getUserMedia — so a consumer was woken once, while there was
still nothing to hear, and never again. Nothing published a signal when the stream actually
arrived. The transcriber would have sat on `no-audio` for an entire call and its launcher
would never have appeared. It is a real signal now, written from `onStateChanged`, which
fires when devices are acquired. Later writes pass the same MediaStream object, so they
dedupe on `===` and muting does not churn the pipeline it is supposed to silence.

**The adapter guessed AD4M's model shape.** `downloaded`/`loaded` are on
`modelLoadingStatus`, not on `Model`, so `ready` was always true and a still-downloading
model looked usable. Also asks `getDefaultModel` instead of assuming the first entry.
Verified against @coasys/ad4m 0.13.0-test-9.

**Switching off during startup left an orphan.** Loading Whisper takes seconds, the panel
says "Starting…", and clicking again in that window is the obvious thing to do. The
half-built session used to finish and go on transcribing with nothing holding a reference
to it. `start` now builds into locals and publishes only when whole, checking a generation
counter after every await; `stop` bumps that counter first, so it cancels a start in flight
as well as tearing down a running one.
…alled it missing

Found while James tested against a remote host with a working transcription model.

The executor's `load_transcriber_model` finishes with
`publish_model_status(id, 100.0, "Loaded", downloaded: true, loaded: false)` — only the LLM
spawn path ever sets `loaded` true. The adapter read `loaded` first, so every working
transcription model came back `ready: false`. It reads `downloaded` now.

The worse half was one layer up: the module used `ready` as a *filter*, so "installed but
not reported ready" surfaced as "No transcription model is installed" — telling the user to
go and install the thing they had already installed. `no-model` now means the list is
empty, full stop. `ready` orders the choice and never excludes: the executor loads on
demand, and if a model genuinely cannot run, `open` fails and says why, which beats a
confident wrong diagnosis. Same mistake the adapter's own comment warns about — refusing to
work because a diagnostic said so — made one layer above where I wrote the warning.

Also: that panel offered an "Open AI settings" button on hosts with no AI settings. The
section is gated on `canManageAi`, which is false whenever `administersNode` is false —
true of every web session against a remote host. The button is now gated on the same flag,
with a line naming where models actually live instead.
…e you are on

Three connected fixes, all found by testing transcription against a remote host.

**Capabilities, not one boolean.** `administersNode` decided the whole settings page from
how the connection was obtained, which is coarser than the executor's own model. AD4M
grants a hosted guest `AI READ` and pointedly refuses `UPDATE`/`DELETE` — "admin operations
for managing AI models", in its own comment — so it had already drawn the line James asked
for. WE hid the section anyway, which is how a Whisper model the node was happily running
came to look like no model at all.

The token is a JWT carrying the capability list it was issued with; `capabilities.ts`
decodes it. Not the "probe each call and catch the error" the old comment warned against —
probing means calling to find out, this is reading the answer we were handed. Unverified
and advisory: it decides what to offer, the executor still decides what is allowed, so a
tampered decode changes which buttons appear and nothing else. An unreadable grant means
*unknown* and is treated as permitted, because a desktop host holds ALL_CAPABILITY behind
an empty token and reading that as "nothing" would empty the settings page on the machines
that own the node.

Capability is necessary, not sufficient. AD4M also grants a guest `LANGUAGE DELETE`, and
uninstalling a language plugin from a machine other people are using is a control that
should not exist rather than one that errors. So the two compose: read where the grant
allows, mutate the node only where the node is ours. `canConfigureAi` splits from
`canManageAi` and gates add/edit/remove/set-default.

**Authorized apps were never agent-scoped.** They sat in the guest surface on the reasoning
that `agent.getApps()` "answers for whoever is authenticated". It does not — `apps_map` is
one process-global map behind a single `apps_data.json`. On a multi-user node that list is
either empty or somebody else's, and it is always empty there anyway because a hosted
session is minted by `generate_user_jwt` and never recorded as an app. Moved to
node-scoped.

**A "Connected to" section.** `RemoteHost` and `UserInfo` were in the connector's hand and
discarded at the boundary, so an agent could be running on a metered node, paying per
operation, with nothing in WE mentioning that a node, an operator or a balance existed —
and would learn about credits by running out mid-call. Now carried through
`BackendInitResult` as neutral `BackendHostInfo`/`BackendAccountInfo` and shown on the
Connections page: who runs it, where, what it charges, what is left. The Connections nav
item gained `sessionStore.host` because the other two conditions are both false on web,
which would have hidden the one page that answers where your data lives.

`host.aiModels` is worth noting: it comes from the host directory, needs no capability, and
so answers "can this node transcribe?" even where the executor would refuse the model list.
…e modules it is about

Settings → Modules listed every module in one column, in registration order,
under a footnote that had to state three caveats at once — over rows where each
applied to only some of them. "A community still decides which of them it runs"
is true of chrome and of nothing else; "listed here without a switch" is true of
capability and of nothing else. Read against Flux, both were noise.

The three kinds were already distinguished: `moduleSurface` derives app / chrome
/ capability from what a module contributes, and `moduleInstallSettings` has
carried `surface` on every row all along. This just uses it. Because the surface
is derived rather than declared, a new module lands in the right group without
declaring one and without this file changing.

Each group states only what is true of it, and the groups are guarded on a count
so a seed shipping no embedded apps renders no empty heading.

Two things fixed on the way past:

- Flux was the one row with a name and nothing under it. The seed has always
  declared a description and the type has always had it — it simply was not
  forwarded to `defineModule`.

- The globe's "Used by templates", sitting in the switch's slot, read as a state
  rather than as the reason there is no control — and it was not even
  distinguishing, since a template can place the call or notes fragments too.
  The reason moves to the group blurb, where it is a reason, and the row gets a
  `we-tag` reading "Always on" — the vocabulary the templates and themes lists
  on this same page already use for a row that is not the user's to change.

The grouping supersedes the `$if` on `$mod.switchable`; the partition is
identical, since the store defines it as `surface !== 'capability'`. The field
stays on the store — it is documented API a user-authored template can read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r for call chrome

**The thresholds were wrong, and the way they were wrong is worth recording.**
`audio-processor.js` declares a DEFAULTS block and `TranscriberWidget.vue` overwrites it
over the port the moment it starts, so Flux never runs with the values it ships. Porting
the file faithfully reproduced roughly double the real thresholds — onset 0.08 against
0.04, held for 32ms rather than 16ms — which is why ordinary speech went unheard. The
worklet keeps the upstream defaults so it stays a faithful copy; the store sends the values
that actually run, as Flux does, and that is now the one place to tune from.

**A level meter, with the threshold drawn on it.** Flux runs a second AnalyserNode and a
rAF loop to draw its bar, measuring the same signal twice so the two can disagree. Ours
reports the RMS the VAD is already computing, throttled to ~64ms, so the bar and the
decision cannot diverge. The marker matters more than the bar: "is audio arriving" was
never really in doubt, "am I loud enough" is the question, and only the threshold answers
it. Had this existed, the bug above would have been visible in seconds.

**Module-declared slot anchors.** A module can now open an anchor others contribute to
(`anchors: ['call-controls']`) and mark where they land with `{ type: '$slot' }`. The call
module declares one; transcribe puts a record button in it. Neither imports or names the
other — uninstall either and the remaining one still works, which the alternative (the call
bar naming `modules.transcribe.*`) would not survive. Chosen over the cheap `$in` guard
because reactions and recording want the same bar, and building the extension point for one
consumer is how you get the wrong extension point.

Three things keep it from failing silently: contributions to non-core anchors are excluded
from the shell's top level, so a dangling one cannot render loose above the boot screen;
`danglingAnchors()` reports chrome aimed at an anchor nobody provides, checked after the
whole seed registers since order is a list not a dependency graph; and the validator rejects
a `$slot` without an anchor, which the host resolves away before any renderer sees it.

Resolution walks props and named slots, not just children — the per-space gate wraps every
contribution in an `$if` whose content is `props.then`, so a children-only walk never
reached the chrome it was meant to resolve.

**Record split from panel.** They were one flag, so the transcript vanished when you stopped
recording — exactly when you want to read it. The call bar records, the rail opens the
panel, and the panel carries its own record button so it stands alone if a template places
neither.
…e stream sizing the layout

The call stage was a fixed overlay of a fixed height that scrolled, and the camera
and a screen share laid out at different widths. Both had the same cause: nothing
ever gave the video a box, so the stream's own pixel dimensions sized the layout.

we-video gains a real `fit` (the call module had been passing one to an element
with no such property, so object-fit was never set), and a declared fit takes the
video out of flow inside a new [part="base"] wrapper. In flow, a percentage height
that fails to resolve falls back to the stream's intrinsic size — which is why a
720p camera and a 1080p capture differed, and why both grew past a stage whose
38vh turned out to be a floor rather than a ceiling. The rules are gated on each
fit value rather than on [fit], because the property reflects and defaults to '',
so presence would match every video in the codebase.

The stage itself is now a *dock*: a module says which edge, how big, and whether
to float, and the host owns the rest. This generalises what computeRightOffset
already did for the editor's rails — the content viewport is sized by four offsets
now, so a dock on any edge takes room rather than covering. It deletes the
module's `right: '72px'`, a hardcoded copy of the module rail's width that nothing
kept in step, and it is the deliberate version of the change slotRegistry's
docblock anticipated: an anchor that emits a container.

Which behaviour a piece of chrome wants turns out to be a property of the moment
rather than of the module, so the call contributes both. The bar overlays because
you glance at it; the stage insets because you watch it while reading the space.
One expand button walks hidden → strip → dock → max.

Tiles pack into a grid of 1fr tracks rather than a wrapping flex row, which cannot
overflow a definite box however many people join. Click to spotlight; a screen
share claims focus once and stops trying the moment you choose for yourself. The
focused tile spans via CSS in the same $each container and is never reparented —
Solid's <For> recreates across parents, so promoting it to a spotlight node would
drop srcObject on every click, the hazard the tile cache already exists for.

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

A blank panel is the same bug as the oversized one, seen from the other side. With
`fit` set the video is out of flow, so a wrapper that ends up zero pixels tall
shows nothing rather than showing something too big — and a wrapper sized
`height: 100%` is zero exactly when some ancestor's height turns out to be
indefinite, which is a browser judgement call at every stretched flex and grid item
in the chain.

So there is no chain now. The tile is already `position: relative`, and the video
host sets all four offsets: an absolutely positioned box takes its used size from
the containing block directly, with no percentage to resolve and nothing to fail.

The primitive gets the same treatment one level down. [part='base'] keeps the
design system's `height: 100%` in the ordinary case, but a fitted video overrides
it to `height: auto; align-self: stretch` — flex stretch takes whatever the host's
box is, definite or not, whereas a specified percentage that computes to auto is
still specified and so stops stretch from stepping in.

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

The real cause of the call video's sizing, and it was never in the layout.

`applyDSBehavior` writes every design-system custom property from the base class's
`updated`. `we-video` overrides that hook to attach `srcObject` — a DOM property
with no attribute form, so it has to be assigned imperatively — and never chained
the call. The consequence is total and completely silent for that one element:
props are accepted, properties are set, no var is ever written, and the `var()`
references in the generated stylesheet fall back to `auto`.

So `width`, `height` and `position` had never worked on `we-video`. What that
looked like was a video sized by its own stream: a 720p camera and a 1080p screen
capture at different widths, both taller than a panel with a definite height. Every
explanation of that — including two of mine — was about flex lines and percentage
resolution, because the props were right there in the schema and looked applied.
Giving the video a `fit` turned it from wrong-size into no-size, which is what
finally made it obvious.

`we-tooltip` and `we-popover` had the same omission, so their layout props were
dead too.

A build-time check now refuses to build a primitive whose `updated`,
`connectedCallback` or `disconnectedCallback` skips super. `firstUpdated` is
deliberately not checked: Lit's is empty and the DS does not override it, so
listing it would flag five correct primitives, and a check that cries wolf is one
people learn to run past.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects between "publish this now" and the other agent seeing it. Each is a
latency bug, which is why none of them ever looked like a bug: nothing fails,
nothing logs, and everything eventually arrives on the next 5s heartbeat.

The presence layer was already publishing immediately on setActivity and
clearActivity — the joins and leaves were never the problem. What happened to
those messages afterwards was.

**Coalescing dropped them.** The AD4M channel skips a publish while a previous one
is in flight, on the reasoning that last-write-wins traffic loses nothing because
the next message carries the same state. True of a heartbeat, false of the state
*change* the heartbeat has not started repeating yet. Joining a call, leaving one,
and answering a peer's `hello` all went down that channel. It now holds the latest
message and sends it when the in-flight one lands: still one send in flight and at
most one waiting, but nothing lost. A synchronous throw from `sendBroadcastU` could
also wedge the in-flight flag on forever, muting the channel for the session.

**The signal handler was registered fire-and-forget.** `addSignalHandler` returns a
promise because it subscribes on the executor. Publishing before it resolved raced
the presence handshake, and the same side always lost: a joiner broadcasts `hello`
the instant its scope exists, every peer answers within milliseconds, and answers
arriving before the subscription is live reach nobody. The joiner then sees an
empty space until each peer's next heartbeat. Publishes now await registration.

**Tab leadership was contended per origin and handed over on a 15s timeout.**
Leadership exists so N tabs showing one agent publish once; two tabs signed in as
*different* agents share none of that, so electing between them silenced one agent
entirely — whichever one you were not looking at, since leadership follows focus.
It is now scoped per DID. And a yielding leader stepped down silently, which is
indistinguishable from one that crashed, so the claimant waited out the full crash
timeout: fifteen seconds of nobody publishing every time focus moved, and fifteen
seconds before a freshly opened tab published anything at all. A yield now says so,
and "is anyone else here?" gets a round trip rather than a crash timeout.

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

The last of the slow paths, and the only one left that was slow every time: ten to
fifteen seconds after entering a space or refreshing before anyone else appeared,
while every event after that arrived at once.

Publishing is gated on tab leadership, and two things conspired at exactly the
moment a space is entered.

An **unfocused** tab still waited the full crash timeout to discover it was alone.
The previous fix shortened that for a focused tab only, on the reasoning that a
background tab should not displace a live leader — true, but it does need to know
whether one exists, and silence took fifteen seconds to say no. There is now a
`probe`: a claim's polite cousin that a leader answers without moving. Both
questions get a round trip; only the answer "nobody is here" was ever slow, and it
is the answer a lone tab always gets.

And the **handshake was published into a closed gate**. `start` sends `hello` so
peers answer at once, but if the gate opens afterwards that message is gone, with
nothing downstream able to tell — the send looks ordinary and the space looks
empty until each peer's next scheduled heartbeat. `PresenceSource.announce()`
re-runs the handshake, and the host calls it when it becomes the tab that
publishes. Registered before `start` so the immediate already-leader firing
no-ops and `start`'s own handshake is the one that goes out.

One ordering bug found by a test while writing this: the short fuse was armed
*after* asking, so a synchronously delivered answer set the patient timeout and the
arming then overwrote it — a tab told "somebody is here" took over anyway 300ms
later. Real channels deliver asynchronously, so only the in-process one caught it.

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

"Sometimes instant, sometimes ten seconds, sometimes one direction only" is the
signature of a lossy datagram, not of a logic bug — loss is independent per
direction, which is exactly why the two agents disagree about what happened.

`sendBroadcastU` acks the *send* and never the delivery, and two Holochain peers
still discovering each other exchange nothing at all. The join handshake was a
single fire-and-forget broadcast on top of that, so one lost packet cost a full
heartbeat interval of looking at an empty space and two lost packets cost two. It
now repeats — 1s after the first, 3s after that — and stops the instant any peer is
heard from, so a healthy space still sends exactly one and a session that sends all
three is one where nobody was listening anyway.

The rest is instrumentation, because the next round of this should not be guesswork
either. Every hop between "publish" and "the other agent sees it" is fire-and-forget,
so a message suppressed by our own leadership gate, held by coalescing, refused by
the executor, or dropped by the network all look identical from both ends: nothing
throws and nothing logs. `setTraceSink` in backend-shared is a no-op null check
until a host installs a sink; the app shell installs a console one when
`localStorage['we:trace']` is set, so it can be turned on in a build that is already
running and already misbehaving, on both machines, without a rebuild.

Traced: presence start/announce/send/recv/peers and handshake repeats; the adapter's
subscribe timing, send acks with elapsed ms, failures, queued publishes and every
receive-side drop with its reason; and the leadership gate, which is the one hop
that can swallow a message with nothing else to show for it. See
installConsoleTrace.ts for what each pattern means — in particular, `send:ok` on one
agent with no matching `recv` on the other is the transport, and is the case that
rules out everything above it.

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

Both agents run against the same remote executor, which changes what the failure
pattern means. Independent per-direction packet loss would rarely take both
directions out at once; a shared component — one host, one WAN link — does exactly
that, and "sometimes connection is lost both ways" is the tell.

The mechanism fits. `@coasys/ad4m`'s websocket client backs its reconnect off from
500ms to a thirty-second ceiling, and in that window sends reject with a 503 and no
signals arrive at all. Its callback registry survives the reconnect, so nothing
needs re-subscribing — but an agent comes back with a peer map that has been
decaying the whole time and no reason to say anything about it, then waits
passively for everyone else's next beat.

A heartbeat sent while this agent still knows no peers is now a `hello`, so it
solicits rather than just announces. Same message, same rate; the only difference
is that peers answer it. "No peers" is reached two ways — nobody is here, where
nothing is listening and the flag costs nothing, or the transport just dropped out,
where it is the whole repair.

That also makes the startup handshake's second retry redundant, so there is one
now: the retry covers the first second, the beat covers every fifth after it, and
three mechanisms are not needed for one gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Elapsed finds the gap inside one agent's log; it cannot line that gap up against
what the other agent was doing at the same moment. Both ends are needed for the one
finding that separates our bugs from the transport's — sent fine here, never
received there — so both columns are needed. UTC, so two machines align directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found in a trace of a slow space entry, sitting in plain sight: a `bye` between two
`presence start` lines one millisecond apart, and two `ephemeral subscribed` events
where there should be one.

`presence.start` was called with `myFocus()` and `availability()` read inside the
lifecycle effect, so the effect depended on the route. Every navigation therefore
stopped presence and started it again — broadcasting a `bye`, dropping the peer map,
re-registering the executor subscription and re-running the handshake. Entering a
space changes the route, so the churn landed exactly where it hurt most: peers were
told this agent had left, moments after it arrived. Both values already have their
own effects, which is the right shape — publish a change, do not rebuild the
publisher. They are untracked now.

The tab coordinator had a milder version of the same fault: keyed on
`session.me()`, it was rebuilt whenever that object was rewritten as profile fields
loaded, and the lifecycle effect depends on the coordinator. Keyed on the DID string
it no longer propagates.

Also warns when a broadcast takes more than five seconds to be accepted. The first
`sendBroadcastU` after joining a neighbourhood has been measured at eighteen
seconds, on both peers, unblocking within a few hundred milliseconds of each other —
two conductors finding one another, not a per-call cost. Nothing above this layer
can do anything about it, but everything above it looks broken while it happens, so
it should not take a trace to see.

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

The join bar handed `AvatarStack` raw presence records. Those carry an agent id and
nothing else — by design, since a roster that cached profiles would re-fetch every
peer's on every heartbeat — so every avatar fell through to the generic person
glyph. The call tiles had the same hole: `CallTile.name` and `CallTile.avatar`
existed, were never set by anything, and the fragment read them anyway.

The module could not fix this itself, and its own comment said so: presence gives
ids, a module cannot name a host store, and there was no identities port. There is
now. `ModuleStoreDeps.identities` lends the same directory the `$agent` block reads
— a reactive `get` and a `fetch` for what it has not cached — bound from
`ProfileStore` alongside the other late-bound host services.

The join is done at the point of display rather than folded onto the roster, and
that is the load-bearing part rather than a style preference. `$each` renders
through a reference-keyed `<For>`, so putting a profile on a tile object would
remount that participant's row the moment their picture arrived, and a remounted row
drops `srcObject`. Somebody's video would blink out exactly when their avatar
loaded. Faces are looked up by id, the same way volatile flags already are.

`hash` is always supplied rather than used as a fallback: it seeds a generated
avatar that is stable per agent, so an agent with no picture is still visually
distinct from every other agent with no picture instead of being the same grey
silhouette. The two dead fields on `CallTile` are gone, since an always-undefined
`avatar` on the one object that must not carry one is an invitation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A grid of faces answers "who is that" only while everyone's camera is on. Turn one
off and it is a generated avatar; share a screen and it is somebody's desktop, which
looks like everybody's desktop.

Each tile now carries a name in the corner, in the same absolutely positioned strip
as the mute and screen badges — one strip laid out left to right rather than two
corners that overlap once a tile gets small. Your own says "You" rather than your
name: shorter, and it is what you are actually scanning a grid for.

The small avatar beside it appears only while video is playing. With the camera off
the large avatar is already in the middle of the tile and a second copy of the same
face below it is noise; while video is playing it is the other way round, since a
shared desktop carries no clue whose it is. Both branches read one named condition,
because two places disagreeing about "is there video here" is invisible until it
shows a face twice or labels nothing at all.

A peer whose profile has not arrived gets no chip rather than an empty one. Faces
are looked up by id like the volatile flags, for the reason given there — a profile
lands after the tile exists, so folding it on would blank that person's video at the
moment their avatar loaded. Tested on the serialised fragment, which is the only
thing that would catch someone reasonably simplifying it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A peer still negotiating and a peer who has turned their camera off rendered
identically — a bare avatar — so the first seconds of a working call were
indistinguishable from a broken one, and the only thing on screen was silence.

`connecting` is derived from the absence of a stream rather than from the connection
state, and that is the load-bearing choice. `peerStates` holds nothing until the
first negotiation, which is exactly the window that showed nothing, so a badge keyed
on it could not cover the case being complained about. An absent stream cannot be
confused with a deliberate choice either: a peer who mutes their camera stays
connected and keeps delivering a stream with the video track disabled. Your own tile
uses the same flag for the seconds a permission prompt is up, since `join` announces
before it calls `getUserMedia`.

Failure is separated from waiting and does not animate like it — a spinner that
never stops is a worse lie than no spinner. A connected peer with their camera off
still gets nothing, because that state needs no explanation and labelling it would
put noise on every tile of every call.

The existing connection badge is now gated on there being video: with none, the
centre of the tile already says what is happening, and the badge repeating it two
centimetres below would be the same sentence twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not the upstream stall, though that is what made the window long enough to notice.

`mesh.ts` creates a peer's `MediaStream` when the peer appears in the roster and
adds tracks to it later, as `ontrack` fires. So `$tile.stream` is non-null and empty
for the whole of negotiation — and the tile asked "is there a stream?" to decide
between video and avatar. Every joining peer therefore got a `<video>` mounted over
nothing, which paints black, for as long as negotiation took. On a transport whose
first broadcast blocks for eighteen seconds, that is eighteen seconds of black.

It also defeated yesterday's fix: `connecting` was keyed on the same absent stream,
so the state that existed to explain the wait could never be true while the wait was
happening. Two attempts at this missed it for the same reason — every explanation
assumed the stream was missing, and it was present and empty.

`hasPicture` asks the question that was meant all along: is there a live video
track, and does the sender say it is on. Both halves are needed. The track alone
cannot tell a muted camera from a running one, because muting disables the track
rather than removing it; the roster flag alone cannot tell "on" from "on but not
arrived yet", which is the case here.

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

Neither the upstream stall nor a missing broadcast: mute publishes immediately and
always did. It is the same best-effort delivery as everything else, and what makes
it visible here is which message got dropped.

A lost heartbeat costs nothing — the next one carries the same state, five seconds
away at worst, and nothing was wrong in between. A lost *change* costs that whole
interval with every peer confidently displaying the opposite of what is true: a
muted microphone still showing as live. That reads as lag, which is why it looked
like latency rather than loss.

State changes now publish a second time after 700ms. One repeat, and at most one
pending — a second change replaces the first's, the same last-write-wins rule the
transport's coalescing already follows, so a run of quick toggles cannot queue a
repeat each. Applied to changes and nowhere near heartbeats, because the asymmetry
between them is the entire argument: mutes are rare and their loss is expensive,
heartbeats are frequent and their loss is free.

`activity:set` and `activity:clear` are traced, so the next question of this shape
can be answered from the log rather than by reading the publish path again.

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

Everything the last few days added to presence shared one unstated assumption: that
nothing can be known about whether a message left. `EphemeralChannel.publish`
returns void, so five separate schedules — heartbeat, handshake retry, soliciting
beat, state-change echo, announce — all fire on the chance that something went
wrong, none able to tell a stalled executor from a lossy network.

The information existed one layer down the whole time. The AD4M adapter awaits
`sendBroadcastU`, times it, and logs `send:ok` / `send:fail` before discarding it,
while the capability profile already declared `reliability: 'send-acked'` — the port
modelled the concept and the method did not expose it.

`onPublishResult` closes that. `publish` still returns nothing, because most callers
genuinely do not want to await a broadcast and a promise would make every one of
them choose between awaiting and an unhandled rejection; consumers that care
observe outcomes instead. Optional on the interface, because a transport that cannot
tell must not pretend — absent means "no idea", which is neither success nor failure.

Presence now retries when a send was *refused*, on a backoff capped at the heartbeat
interval. This is the one closed-loop repair among the five and worth distinguishing
from them: the others guard against loss the transport cannot see, so they can only
ever be timers; a refusal means nothing was sent and there is no chance about it.
A refused `hello` is retried as a `hello`, or the join is silently downgraded to an
announcement nobody answers. A superseded message is explicitly not a failure.

Two more things this makes right. The publish *reason* is now first-class rather
than inferred — three repair policies key off it, and a mute used to log as "beat",
which is the one line you would read to find out whether a mute had been sent. And
`setPinned` is finally called: it was designed for a tab holding a call, documented
as such, and never wired, so a call in an unfocused tab stopped being published the
moment you looked at another window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four complaints with one cause between three of them: a single button was encoding
whether the video showed, where it was, and how big — so it could not have a clear
icon, and any given state took up to three clicks through states nobody wanted.

Split into the two questions people actually ask. **Show the video** is a toggle
carrying the participant count, following the same active-variant convention as the
mute and camera buttons beside it. **Where does it go** is one menu, and floating
and full screen live in it as peers of the four edges — because that is what they
are: places to put the video, differing in how much room they take. The cycle is
gone, the three size buttons are gone, and the menu says "Position" rather than
"Options" — which came from `DropdownMenu`'s default, and could not be suppressed
even for an icon-only trigger. `??` instead of `||` fixes that for everyone.

The thin slice was not a preference. A grid cell is whatever shape the panel is, so
one participant in a right-hand dock got a 440×900 cell, and `cover` scaled a 16:9
face until it was 1600px wide and threw away 1160px of it. Tiles now always
`contain`, and paint no background while a picture is showing, so the letterboxing
is the panel rather than a grey box around the video.

Size is dragged now, and the host owns it — the same argument as placement: a module
cannot see the sidebar, the rail or the window, so it cannot say what a sensible
size is. Every docked panel gets dragging, including the ones that come later.

`we-resize-handle` reports a delta and nothing else. That is what lets it serve both
consumers: the editor's rails grow leftwards from a width that starts at zero when
closed, clamp at a minimum, and toggle the panel on a click, while a dock grows from
whichever edge it is on — a handle that owned the size could serve one of those. The
editor's rails now use it, which was worth doing beyond deduplication: the hand-
rolled version was mouse-only, so it did not work on a touchscreen at all, and its
rail was a plain div nobody could tab to. Pointer events and `role="separator"` fix
both for every consumer at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five things, three of them about two pieces of chrome wanting the same pixels.

The Position trigger stood taller than everything beside it because `DropdownMenu`
hardcoded its trigger to `we-button`'s `md` default with no way to say otherwise.
It takes a `size` now.

The participants button showed a person glyph, which says what the button is
*about* and nothing about what pressing it does — and the count beside it already
named the subject, so the icon was spending the only other slot repeating it.
Expanding and contracting arrows say the one thing left to say, and swap so it
always shows the move being offered.

Floating is the default placement now. Docking is a decision to give up room, and
the opening move is usually a glance at who is there; starting docked meant one
press of an unlabelled control reshaped the workspace.

The drag handle drew a permanent line, which reads as a scrollbar — that being the
only other thin vertical strip anyone puts beside a panel — and doubled up with the
panel's own border. Nothing at rest now, with the cursor as the affordance and the
line confirming it on hover. That is the primitive's default rather than a local
override, since a panel with a border is the normal case.

Two overlaps, and they are different problems. The call bar and a top-docked panel
both want the top centre, and only the module knows both exist — the host places
docks and knows nothing about a floating pill somebody renders through a slot — so
the bar swaps ends when the video docks up there. The editor's rails were a genuine
gap in the dock geometry: it cleared the sidebar and the module rail, which are
constants, and knew nothing about chrome that comes and goes, so a right-hand dock
opened directly on top of the controls being used to edit the thing it was covering.
Docks now measure against a region that excludes reserved edges, and the editor's
width is pushed in from the one place that knows about both.

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

The Position trigger was still 40px because `@we/components` had not been rebuilt —
`pnpm build:web` builds the app, not its workspace dependencies, so the `size` prop
added to `DropdownMenu` never reached the bundle. The code was right and the dist
was stale. Everything is rebuilt here.

The overlay problem is real and needed a shape change. A tile filled its grid cell,
and a cell is whatever proportions the panel happens to have — 440×900 for one
person in a side dock. With the picture letterboxed inside that, the name and the
mute badge were anchored to the bottom of a box the video occupied a band in the
middle of, so they sat in empty space well below what they described.

Two boxes now. The outer one is the cell and declares `container-type: size`; the
inner one is the picture, sized `min(100%, calc(100cqh * 16 / 9))` with a matching
`aspect-ratio` — the largest 16:9 that fits, which is the fit-a-ratio-box-in-a-
container calculation CSS cannot express for a non-replaced element without
something measurable to read. Overlays anchor to the picture and land on it.

That also makes `cover` safe again for cameras, which it was not against a cell of
arbitrary shape: against a box that already matches, it crops nothing from a 16:9
source and trims a 4:3 webcam the way every other call app does. A desktop still
gets `contain`, because cropping one is the difference between readable and not.

Where container queries are unavailable the width declaration is dropped and the box
fills its cell — which is exactly the behaviour being replaced, so the floor is
today's rendering rather than a broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things, and the third is a better answer than the one it replaces.

The drag line floated a few pixels inside the panel's own border, because the hit
area has to be wider than anything visible and the line was centred in it — so next
to a bordered panel it read as a second line rather than as that one. The handle
takes an `align` now, and the dock frame puts it flush at 3px, so dragging thickens
the border that is already there.

A side dock shared its height equally between rows, which is right when height is
the scarce dimension and wrong when it is not: a 440×900 panel gave one participant
a 900px row to be centred in, so a 247px picture floated in the middle with a third
of a screen of nothing above and below. Rows are sized from the column width there
and stacked at the top. The picture's own sizing has to follow, and cannot be shared
between the two regimes — `container-type: size` on a row whose height comes from
its content collapses it to nothing.

The panel is not shrunk to fit, though it could be. A side dock insets the content
for its full height whether or not it fills it, so the space below is unusable
either way — shrinking would trade a straight panel edge for a gap that reads as a
bug, and that space is where a participant list or a transcript goes.

For the third: docks used to open *beside* the module rail, which left them stranded
in the middle of the screen edge with the rail outside them and the editor's rails
on top — three things claiming one edge and only one able to have it. Yesterday's
fix taught the dock geometry to avoid the editor, which was the wrong direction. The
dock takes the edge now, and everything pinned to it moves: the rail, the editor's
rails and its floating toolbar all read `--we-dock-right` and slide inwards. That
deletes the reserved-edge mechanism rather than extending it, and the three
consumers live in three packages that share no imports — only a custom property on
the root, which is how `--we-sidebar-width` already works.

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

A side dock is the one arrangement that can genuinely overflow, and it was clipping
instead of scrolling. Everywhere else rows divide a definite height and cannot
exceed it — which is why the stage clips at all — but a side dock's rows come from
its *width*, so widening it makes every tile taller and enough participants or a
wide enough drag runs past the bottom of the screen. Clipping there hides people who
are in the call, so that regime scrolls and the others still do not.

The editor's toolbar lagged because it animates `right` over 300ms and its suspend
flag, `panelResizing`, only knows about the editor's own rails. During a dock drag
nothing told it to stop, so it trailed the panel edge it was supposed to sit beside.
`--we-chrome-transition` collapses to `0s` while a dock is dragged — the same
channel as the dock insets, because the toolbar is in another package with no path
to the shell store.

The overlap with the call bar was the bar's own fault: every other piece of floating
chrome moves when a dock takes an edge, and a bar pinned to the middle of the
*window* stayed put while the editor's controls slid inwards into it. It centres on
the content region now, which is where it always meant to be.

And a docked panel is flush. The 8px it sat off its edges is right for a floating
card, which needs air to read as being on top, and wrong for a panel that has taken
room *from* the app: the gap was a strip of background between the panel and the
content it displaced. The radius and shadow go with it — a radius leaves slivers of
background in the corners of a flush panel, and a shadow falls on content that is
beside it rather than beneath it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…king on top of each other

Both panels hand-rolled the same fixed overlay — `right: 48px`, a hardcoded copy of
the module rail's width — so they covered the space instead of making room in it,
opened on top of the editor's controls, and stayed put when a docked call panel took
the edge out from under them. All three are what the dock system was built to own,
and this is the moment I said to settle it: before the second dock panel ships.

Converting them exposed the gap that had been waiting. `contentInset` has always
summed panels sharing an edge, but `resolveDock` positioned each one independently,
so two right-hand docks resolved to the same box and sat on top of one another —
invisible while only the call module had one. Docks now resolve as a list, each
pushed past whatever is already holding its edge, in the registry's stable order.

`slot:dock-right` was the wrong capability to be declaring either. A slot draws over
what you were doing; a dock makes the rest of the app smaller, which is a stronger
thing to agree to and belongs in the list a user reads at install. No edge in the
name — which edge is the user's choice at runtime, so naming one would be a
declaration that goes stale the first time they move it.

Both panels gain drag-resizing, a narrow-window fallback and correct stacking
without asking for any of it, which is the argument for the host owning geometry
restated: the module says what it wants, and everything else follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhweir and others added 26 commits August 9, 2026 21:33
…elf inside a dock

Two bugs with one shape between them: something documented as working, quietly not.

`styles` is in `designSystemKeys`, honoured by every layout component, and described
in the prop tables as "inline CSS applied directly to the component's own element".
It was never declared on `DesignSystemProps` and never read by the primitives' style
pipeline — so a primitive accepted it, filtered it through, and dropped it. No error,
no warning. The visible consequence: the editor's rails pass
`--we-resize-handle-line: transparent` to suppress the handle's divider, and got a
divider drawn down the middle of a 32px icon rail instead.

Now declared and applied, written after the custom properties so it overrides a DS
prop setting the same thing — which is what "applied last" already promised.
Previously-written declarations are tracked and removed, so dropping a key from
`styles` actually drops it.

The editor hides itself by translating right by its own width, which used to put it
off the window edge. It is positioned at `right: var(--we-dock-right)` now, so its
own width lands it *inside* a docked panel rather than outside the window — and a
dock frame paints above it, so leaving the editor made its controls jump behind the
notes panel and stay there until something forced a relayout. The transform accounts
for the dock.

Not a bug: the theme panel opens closed, showing only its rail. That is existing
behaviour, and it read as "an empty panel appeared" because the rail had grown a
stray highlight line and sat flush against the notes panel's own resize handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applying a theme cleared the root element's inline style outright, to drop the previous theme's
token overrides. The root is shared: the shell publishes `--we-dock-*` and `--we-chrome-transition`
there, and the template provider `--we-sidebar-width`. So every theme application also deleted the
dock insets, and nothing recomputes an inset that has not changed — the values stayed gone.

That is the theme panel opening half underneath the notes panel, with empty space where it should
have been, and healing the moment the notes panel was dragged. It also fired on every token tweak
in the theme editor, since live editing re-applies.

The theme now tracks the properties it set and removes exactly those. A source-level test asserts
nobody clears the root wholesale again, since there is no runtime moment at which the damage shows:
the wipe and the re-set are the same tick, and only another package's value is missing.

Also suppresses the theme rail's resize line through the native `style` attribute rather than the
design system's `styles` prop — one element's custom property, not a design decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transcripts were written loose into the space as TextBlocks carrying
tag: 'transcript' — a field that holds the Lexical tag (ul, h1), was
never read back, and left every utterance in the Cards route's Text list
next to authored prose. Nothing said which call a line belonged to, who
was in it, or when it happened.

A call's record is now a CollectionBlock with kind: 'call', holding the
utterances as children and the roster as participants, attached to
whatever node the call was anchored to.

No Call entity. Everything that would have justified one turned out to
be a generic affordance: startedAt is createdAt, transcript is children,
and participants belongs on WeNode beside comments and signals. That is
also the better trade on WE's own terms — we://participants is generic
vocabulary like we://comment, where we://call as a bespoke entity would
have made calling a core concept. Calls become data instead.

Own the container, never the content. A note was a Note whose entire
content was a string, which is a TextBlock with fewer fields — so a note
written in the composer and one written in the panel were unrelated
records that could never meet. Notes are now core TextBlocks in a
kind: 'notes' collection. NOTE_MANIFEST stays registered and the panel
reads both shapes: dropping it would orphan existing notes rather than
delete them.

Core vocabulary (one-way doors on the predicates):
- CollectionBlock.kind — semantic, distinct from the Lexical `type`, and
  excluded from block serialization so it never reaches the editor.
  A scalar because it is the field you query by: `where: { kind: 'call' }`
  is a native eq that pushes down and composes with order/limit, where
  tag membership would need a traversal the query layer does not do.
- WeNode.participants — plain DIDs, add-only. Per-occurrence for free:
  a CollectionBlock is a WeNode, so two calls on one post are two
  collections with two rosters.
- WeNode.calls — untyped, mirroring comments rather than signals. The
  edge lives on the node because traversal is forward-only; typing it
  would mean importing CollectionBlock into the class it extends.

Module contract:
- createEntity takes options, forwarding { parent } to the Model.create
  that already supported it — so a transcript block is created inside its
  call rather than created loose and linked in a second step that a crash
  can interrupt.
- linkEntity adds one value to a to-many relation. Deliberately add-one:
  appending by writing the array back is a read-modify-write, and two
  agents doing it lose each other's entry.

Convergence uses transcribe's own presence activity, not a field on the
call's — so neither module references the other, and who is transcribing
against who is merely present comes out of it for free. First writer
creates and announces; the rest adopt; lowest id breaks a tie. The record
is created on the first utterance, so a call nobody speaks in leaves no
trace and no delete path is needed, and its lifetime is the call's rather
than the recording toggle's.

Also: manifestEntries in backend-shared, and the host merging WE's own
entities into what the ports resolve `scope` against. Only foreign
schemas were in that list, so a drill-down through core vocabulary could
never resolve — {anchor: 'CollectionBlock', via: 'children'} failed, and
every existing scope happened to be on a Flux entity so nothing caught
it. Fixed at the host because DataBindingDeps is declared in
backend-shared and the gap belonged to every backend equally.

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

Two separate marks that both inverted under the dark themes.

The avatar's disc is there for the identicon/initials/icon fallbacks, and
a picture covers it — so it was only ever visible as a rim at the image's
antialiased edge, near-black where neutral-100 inverts. It is dropped
when there is an image, matching on a non-empty `image` rather than the
attribute alone, since Lit reflects the empty default as an empty
attribute and that would take the disc from every fallback too.

AvatarStack painted a ring by default on the reasoning that overlapping
faces need separating. neutral-0 is only the surface colour on a surface
that happens to be neutral-0; anywhere else it is a visible band, and
under the dark themes it landed at 8% lightness. A ring reads as a
deliberate mark — selection, presence, a tone — so the stack no longer
makes one nobody asked for. Separation is the caller's call against the
surface they know they are on: pass `ring`, or set `overlap: 0`.
Two hand-maintained lists decided what a schema may name, and both had
drifted — silently, because the thing that would have caught the drift
was itself broken.

shellComponents was missing CesiumGlobe and TemplateCard, both genuinely
registered in componentRegistry, so every schema using them failed as an
unknown component. storeEntries was missing five members that had existed
for months — getSubgroupMessages, spaceDefaultThemeId, globalSpaceId,
installToSpace, operationLoading — so correct templates were reported as
wrong. Nobody noticed either, because the routes containing them were not
being validated at all.

A list you must remember to update is a list that will be wrong. Names are
mechanically derivable, so extractors/appShell.ts reads them: the store
interfaces for members, the registry object literal for components, with
shellComponents falling out as registered minus already-documented.

Source owns the names, the fragment owns the meaning. A description is
judgement, and StateMemberMeta's properties/model is what lets the
validator check one level into a $store path — neither is recoverable
from Accessor<Space | null>. So generation joins them and reports both
directions of drift: a description for a member that no longer exists
(loud, and now zero), and members with no description (a count, since
~109 are internal wiring and listing them would bury the signal).

That immediately surfaced entries describing members deleted in the
AdamStore dissolution — spaceStore.signalTypes and signalTypesBySlug,
editorStore.models/tasks/panelMode/operationLoading, and more. Removed.

The docs and the validator were also reading different lists: the Stores
prose was generated from the hand-authored entries while the validator
used the merged ones. Both come from the merged set now, and a store with
no description is listed with bare member names rather than skipped —
which is why presenceStore appears at all for the first time. Omitting it
left the reference asserting by silence that it does not exist.

Also rewrote the Signal types pattern in store-patterns and its twin in
dev-patterns. Both taught spaceStore.signalTypesBySlug, so an AI
following the reference would reproduce the bug that member's deletion
caused.

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

Three layers had to fail together for a deleted store member to survive a
week in a live template, and each was invisible because of the others.

walkNode returned immediately on a node with no `type`. A grouping route
— { path, children, routes } with nothing of its own to render — is
exactly that shape, and /space/:spaceId is one, so About, Globe, Cards,
Flux, Graph and Settings were never validated at all. Every space view in
the default template.

Operator nodes' props were never token-checked. Only $if's condition was,
which left the most data-dense prop in the language unread: $each's
`items` is where every $query lives. A generic pass now, so an operator
added later is covered by default rather than silently exempt.

$query checked only `entity`. Its where/include/order/scope hide their
tokens behind plain objects, which checkTokenValue does not recurse
through — so a $store in a where clause was never looked at. Same for
$filter/$find/$count internals, where `items` routinely holds a $store or
a whole $query and a typo produced an empty list and no complaint.

Entity names are checked only when the query names no `dataset`. Naming
one is the author saying the entity lives in a schema this validator has
no manifest for — Flux's Channel and Conversation, the query test page's
TestItem — all real entities that resolve fine at runtime. Checking them
anyway turned 54 working queries into errors the moment operator props
started being walked.

$queries now register their names in $local scope. They always shared one
namespace with $localState but only $localState was recorded, so every
read of a hoisted query was reported as undeclared once $count internals
started being walked — and a typo in a $queries key is catchable for the
first time. Their bodies are validated too, which they never were. A
node's own $localState is now in scope for its own props: declaring state
and consuming it on the same node is the ordinary shape, and checking
props against the parent scope reported all of them as undeclared.

Two bugs it found, fixed here:
- PostsList filtered its like-count projection on
  spaceStore.signalTypesBySlug, deleted with AdamStore in 044c88c with
  no replacement. It resolved to undefined, so every post's like count was
  wrong and sorting by likes with it. Now resolved by slug from the
  hoisted query the controls already render from, so the count and the
  buttons cannot disagree about which type `like` is.
- CreateSignalTypeModal passed `helpText` to we-form-field, which has no
  such prop — two help texts rendering nothing. It is `description`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muted` was bound as `?muted`, an attribute binding. On a media element the
`muted` content attribute maps to `defaultMuted` — it seeds the IDL `muted`
property when the element is created and never again — and lit-html clones its
template with bound attributes stripped, so the `<video>` was always created
without it. Every later toggle moved `defaultMuted` on an element already at
`muted === false`, which means the prop has never muted anything.

The call stage was asking for the right thing: the self tile carries
`isSelf: true` and passes it as `muted`, with a comment naming this exact
failure. It was dropped in the primitive, so starting a call played your own
microphone back through the speakers and the room fed back.

Bind the DOM property instead. The other booleans stay attributes — none of
them has the defaultMuted/muted split.

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

A WE profile picture is a base64 data URI, not a URL, and `we-avatar` declared
`image` with `reflect: true` — so Lit wrote the whole payload into the DOM as
an attribute on every avatar. Held twice per element, rewritten on each update,
and serialized into anything reading `outerHTML`. It was found by inspecting an
avatar in devtools, which stalled for about five seconds rendering the
attribute value; `we-image` already leaves `src` unreflected for this reason.

Drop the reflection and keep a `has-image` marker attribute instead, set in
`willUpdate` so the background disc is gone on the frame the picture first
paints. That marker replaces the `[image]:not([image=''])` selector, which only
existed to dodge Lit reflecting the empty default.

The payload was uncapped in the first place: `compressImageToFileData` scales
to a proportion of the original, which is no bound at all, so a phone photo
stayed a large fraction of itself — synced to every peer, cached and held in
memory to render a 32px circle. Cap profile and space avatars at 512px on the
longest edge, which covers the largest surface either renders at (the 120px
profile picture) at 4x DPR. The same trap `ACCOUNT_AVATAR_PX` already existed
for, one layer out.

Cover images are left uncapped deliberately — they render full-bleed, where an
avatar-sized ceiling would visibly soften them. Existing avatars keep their
size until they are replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The launcher buttons were `sm` in a rail that had nothing else in it to be
small against. Drop the size prop so they take the `md` default, pad the rail
by `200` rather than `100`, and separate the space-settings button from the
launchers with a little margin on the divider.

`MODULE_RAIL_WIDTH` moves from 48px to 56px to match: the rail's width is fixed,
so a 40px button inside 8px of padding a side no longer fits what was sized for
a 32px one. Recorded on the constant that it is derived from those two numbers
rather than picked, since nothing else enforces the relationship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two pieces of metadata every collection can have — a call, a notes
collection, a board — so they belong on the shared model rather than per kind.
Kind-specific state does not, and should not accumulate here as more scalars;
that is what a per-kind model or a JSON bag is for.

Scalars for the same reason `kind` is one: these are fields you query by.
`where: { title: { contains: … } }` pushes down and composes with `order` and
`limit`, and a list can sort by title. Held in `editorState` they would be
invisible to all of that — and a call has no `editorState` at all, since the
transcribe module creates it rather than the composer. `textContent` is not an
option either: it is derived from the children, so a title written there is
overwritten by the next reconcile.

No migration. An AD4M property is a link that exists only once written, so
collections predating this carry nothing and read as empty.

The Lexical fallback needs to know which node it is looking at, though:
`title` and `description` are real Lexical props on EventBlock, TaskBlock,
LinkBlock and four others, so excluding them globally in AD4M_ONLY_PROPS would
have quietly dropped a task's title from every round-trip. Scoped to root and
collection nodes instead — the same pair extractTextContent keys on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was only ever `deleteBlocks` on a root id — the recursive delete never knew
what kind of collection it was holding, and a post, a call record and a notes
collection are the same shape. Named for the post, a second surface wanting it
had to either call an action named for somebody else's noun or duplicate it.

Regenerated the ai-context outputs alongside the fragment, per the convention
on those files. The new CollectionBlock fields land in the same pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delete mirrors what a post card offers, gated on authorship the same way — a
weaker claim here, since a call is a shared event and the record belongs to it
as much as to the agent whose transcription created it, but a shared space is a
neighbourhood every member can write to, so this is an affordance rather than
enforcement either way.

Editing writes `title` and `description` straight onto the CollectionBlock with
`model.update`. No store action, because there is nothing for one to do once
the fields are plain scalars on the model. The header shows the title where
there is one and falls back to "Call" where there is not, which is the ordinary
case: the record is created by the first utterance, and nothing on that path
knows what the call was about.

The drafts are re-seeded on open rather than only at mount, or a modal that was
opened, edited and cancelled reopens holding the abandoned edit — with `from`
rather than `value`, since `value` sets a literal and would put the reference
string itself into the input. No validation: an empty title is meaningful, as
it returns the card to the plain "Call" it started as.

Both of PostsList's controls were dead, and that is what this depends on
fixing. `confirmDeleteOpen` and `editPostOpen` were never declared in any
`$localState` — `cardShell` declares `expanded` and `modalOpen` and nothing
else — so `$setLocal` warned to the console and no-opped: the buttons rendered,
took the click, and did nothing. The validator does not catch it, since it
checks that some `$localState` ancestor exists rather than that the field is in
one. `cardShell` now takes the extra state, because a card's controls live in
`header` and the card is the nearest node that can declare for them.

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

Two agents in a call each got their own transcript, and neither knew the other
had started. Both follow from the same thing: nothing was published until the
first flush.

Announce on the button press instead. The activity now carries `recording`
alongside the collection claim — two facts with different lifetimes, since
`recording` goes false on stop while the claim outlives it so a late joiner
still adopts the record rather than creating a second one.

That signal buys the prompt and fixes the race at once:

Peers are offered the transcript rather than started on it. Turning on someone
else's microphone and writing what it hears into a space other people read is
their decision, not a peer's — the module declares a `microphone` capability
for the same reason. So the pill in the call bar names who started, offers to
join, and can be dismissed per peer. The cost is real and accepted: a call
where nobody accepts is transcribed from one microphone.

Whoever is recording and sorts first creates the record; everyone else waits
for the announcement. This only works because intent is published at the press
— by then every recorder knows who else is recording, and they all sort the
same list. The old race was not the heartbeat its comment described but the
whole span before anyone had finished an utterance, which two agents starting
together and then speaking together hit every time.

Losing the election defers rather than drops. `ensureCollection` returns
waiting/nowhere/ready, because "come back in a moment" and "there is nowhere
for this to go" are nothing alike, and collapsing them into one null would have
lost the opening line of every call for everyone but one agent. The wait ends
after 5s: the elected agent only creates on their own first flush and may never
speak, and a duplicate is recoverable where a lost transcript is not.

A partition can still produce two records. Nothing here can prevent that.

Also moves `setRecent` after the write — a deferred utterance passes through
`flush` more than once and was listed once per attempt — and gives the test
harness an `audioInput`, without which the audio effect tore the session down
on every roster change and raced the buffer with a second flush.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a call walks the collection and removes every utterance under it, so a
long transcript takes a visible moment. The button absorbed the click and did
nothing until it finished, which reads as a failure and invites a second click
at a delete already running.

`deleting` is cleared in `onFinally` rather than `onError`: on the success path
the card unmounts with the record, so the only state worth restoring is the one
where it did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Joining is a long-running backend operation wearing a request/response call's
clothes: the executor fetches the neighbourhood expression and installs its
link language before the dataset exists at all. AD4M's client applies one flat
30s timeout to every call, so a first join routinely times out while still
running, and the 408 says nothing about whether it worked.

A failed call is therefore not a failed join, and the response to one is to
keep watching. `waitForJoinedDataset` polls the backend with backoff for five
minutes, asking it directly rather than watching this store's dataset list —
the list is fed by a change event, and the point is to not depend on any one
message arriving.

The work that makes a joined space usable — SDNA install, tracking, loading the
Space model — moves into `finishJoin`, because the dataset can arrive two ways
and both owe the app the same thing. Inline after the call, a timeout skipped
all of it: the space was joined and none of it had happened.

Joins are deduplicated by shared id while in flight. The backend deduplicates
by looking for a dataset it has already made, which is no help during the
window where a second click is most likely — exactly when the first is slow
enough to look stuck — and two joins racing that far apart fork the address
into two datasets.

`joinSpace` now rejects instead of swallowing, since every caller's
`onSuccess` navigates somewhere or clears an input and fired as though the join
had worked. `joiningSpace`, `joinSlow` and `joinError` give a UI something to
show meanwhile, keyed by id so a list spins only the row being joined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follows 84fc48a, which made a timed-out join recover but left the UI with no
way to describe any of it.

Only a call that gave up is now waited out. Recovering from every failure meant
a bad link spun for the whole five-minute window before saying anything, which
is worse than the timeout it replaced: `transportGaveUp` separates 408/503 and
a dropped socket — where the question is simply unanswered — from a backend
that looked and answered, and the second fails immediately.

`joinSpace` also asks the backend what it already has before asking it for
more. The caller checks this store's dataset list, which is a boot-time
snapshot plus whatever change events have landed since, and neither covers the
case that matters: a join this client abandoned, finished by the backend during
the reload. Joining again there is how one space becomes two.

The gate reads `joiningSpace`/`joinSlow`/`joinError` instead of its own
`$localState`, which could not be better than the store it was watching. The
button holds its loading state for as long as the store is working rather than
for as long as one network call lasts, a note after 8s explains that a first
join has to fetch and install the space, and a failure is stated in place —
a dead end deserves persistent text, not a toast that can be missed. The error
carries the space it was about, or it follows the user to the next unjoined
space they open and reports a problem that happened somewhere else.

Ids are normalised before any of that can compare them, since a share link and
the id inside it were two different spaces to both the dedupe and the
already-joined check. That also makes Settings' "Join with a link" field accept
the web URL its own comment always claimed it took.

Four tests drive the real stores against the in-memory backend: a join whose
call 408s after the backend has already done the work, a definitive error
failing fast rather than waiting out the window, two concurrent joins reaching
the backend once, and a web share link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There were already two answers to "what do we call this agent". The identities
port assembled "first last, else handle" for feature modules; eight templates
concatenated first and last inline with no fallback and no trim. The template
one was wrong for anybody who had published a handle and nothing else — they
rendered as a single space, nameless in a UI that knew what to call them.

`displayName` is now the one rule, next to the type it reads, and
`AgentProfileSummary` carries the result. Derived by the host rather than by a
backend adapter: how a name is made out of the parts a directory holds is a
display decision, and the adapter's job ends at reporting the parts.

Applied as a memo over the cache rather than at each write site. Half a dozen
paths put a profile in there — a fetch, an edit, an avatar upload, first-run
setup — and one of them would have forgotten; a name present on most rows is
worse than one absent from all of them.

One decoration point reaches everything, because the consumers were already
built from it: `spaceStore.members` maps over the cache, `presenceStore.peers`
maps over it, and `$identities` — backing both the `$agent` block and the module
identity port — is bound to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`node.slot` has never worked. Every node is rendered inside a
`display: contents` wrapper div, so the wrapper — not the component — is the
direct child a shadow host sees, and slot assignment considers direct children
only. `display: contents` does not change that: slotting is a DOM-tree
question, not a layout one.

So the attribute sat on the inner element where nothing could match it. A node
aimed at a named slot fell into the *default* slot instead: its content rendered
as permanently visible chrome beside the trigger, while the slot it named stayed
empty and showed its fallback. `we-tabs` hit this and worked around it from the
other side, finding its tabs with `querySelectorAll` at any depth.

Moving it to the wrapper makes named slots work for the first time, which also
makes the one existing use live: `TwitterTemplate` put `slot: 'tab'` on
`we-tab`, and `we-tabs` has only a default slot — those tabs would now be
assigned to a slot that does not exist and render nothing. They only ever worked
because the attribute was inert, so they are removed rather than pointed
somewhere. `we-tabs` finds its tabs by query regardless.

Its comment is corrected too: the workaround is still needed, but for a
different reason than it claimed — the assigned elements are the wrappers, never
the tabs.

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

An inline-block trigger is laid out in a line box, so it stands on the parent's
text baseline with room reserved beneath it for descenders — space belonging to
a font, in a box that may hold no text at all. Every tooltip was adding a few
phantom pixels below whatever it wrapped. It only became visible on the first
trigger whose height is its own rather than text-derived: a row of avatars sat
too high inside a host taller than its content, overflowing the trigger's box.

Flex boxes have no line boxes and no strut, so making both the host and the
trigger flex leaves the wrapper contributing no height of its own. The host
stays inline-level, so a tooltip around a word in a sentence still flows.

Also adds a `content` slot falling back to `title`. A tooltip is usually a
phrase and a string prop is the right shape for one, but some of what a tooltip
is for does not fit in a string — an avatar stack capped at five faces has to be
able to say who the other seven are. Reaching for `we-popover` instead would
mean re-implementing hover and focus timing that already works here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the stack misreported who was there.

It drew a repeat as a separate person. The lists it renders come from add-only
relations, where a repeat is normal and means nothing — a call's `participants`
is appended to with no coordination between agents, so a two-person call carried
each of them several times over and the row drew the same two faces repeatedly.
Deduped on `hash`, which callers seed with a DID precisely so a face is stable
per agent.

And `max` discarded everyone past it in silence, so a twelve-person call drew
five faces and read as a five-person call. A stack that cannot show everyone
should at least not misreport how many there are, so the remainder is now a
`+N` chip — sized off the avatar token so it stays a circle at every size.

The cap applies after the dedupe, so `max` counts people rather than links.

Note the dedupe is defence, not the fix: it stops the drawing being wrong while
leaving the stored relation duplicated, so `$count` over it stays wrong. See the
transcribe module for the write-side rule that makes the data right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`participants` is a `@HasMany` — a bag of links, not a set. Nothing at the
storage layer can refuse a link that is already there, deliberately: refusing
means reading the current set first, and a read-modify-write drops whoever loses
the race. So the relation is a set only while there is exactly one writer per
member, and the writer who can never be raced about an agent's presence is that
agent.

It used to append everyone it could see, from every agent recording. That is N
writes per person instead of one, repeated on every session that reset the
guard, growing without bound.

Coverage survives, and improves. The roster is about who was *present*, not who
contributed, so self-appending is deliberately not tied to speaking: an effect
adds this agent as soon as a record exists for its call, reading the id off the
presence claim. That covers someone who never turns transcription on and never
says a word — previously they appeared only if another agent happened to flush
while they were there, so a silent participant could be missed by timing alone.

The guard is keyed on the record rather than the call, and never cleared when a
call ends. The call id is derived from the space and never changes, and clearing
on leave would let a rejoin append a second copy of the same person.

`WeNode.participants` said "each agent appends itself" while the writer did
otherwise. That is now stated as a contract, since nothing enforces it and the
next writer has no other way to know. See
docs/internal/plans/ad4m/hasmany-set-semantics.md for making it structural.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things on the calls surface.

**A hover roster.** `peopleTooltip` wraps a group of faces and lists everyone in
it, one per line. It started as a prop on `AvatarStack` and the seam was in the
wrong place: what a reader hovers is the avatars *and the count beside them* —
"12 Members" is one statement — so owning it inside the stack meant hovering the
words produced nothing. Composing it here also keeps `AvatarStack`
presentational, which is what lets it stay in `@we/components` at all: a roster
needs names, that package cannot look up a profile, and the alternative was
every caller feeding names through the stack for it to hand back.

It lists everyone rather than the faces that fit, since the people the cap hid
are exactly the ones a reader cannot find out about any other way.

Where the boundary falls differs by what the group says. The member and presence
rows include their counts; the call card wraps only the faces, because the
utterance count beside them is about how much was said rather than who was
there, and the buttons further along should not summon a roster at all.

**Continue.** A transcript's record is reachable only while somebody who was in
the call still publishes a claim to it, so once everyone has left it can never
be added to. That is the right default — the next conversation in a space is a
different meeting — but it leaves no way back into one that ended because the
network dropped. Continue joins and pins this agent's transcript to that record,
and announces it, so one person pressing it pulls the whole call back onto the
old transcript. Offered to everyone, unlike edit and delete: continuing a call
is joining a conversation, not editing somebody's record of one.

**Start.** The calls tab swaps Post/Space for a Call button that creates the
record up front and joins pinned to it. It deliberately breaks the "no empty
records" rule — that rule is for calls nobody asked for, and the record has to
exist to be resumable.

The card's participant pictures now come from `profileStore.profiles` rather
than `spaceStore.members`, matching where their names come from: members is a
subset, and a call reaches anyone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not a data-model bug: two agents asserting the same triple is meaningful in an
agent-centric system, and collapsing it at the link layer would destroy
information. The gap is in the ORM's read surface — no `distinct`, and no access
to the authorship that makes duplicates meaningful — so a consumer gets
duplicates it did not ask for and no way to explain them.

Covers the proposal, why WE is not blocked, and what to clean up here once it
lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Says what the button does rather than restating the current setting alongside
the preview state, which the toggle beside it already shows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of the fourteen content types on the cards route explained an empty list
and the other nine rendered nothing at all, so switching type either told you
the space had no Flux channels or left a header over blank space that read as
still loading. The difference was not a decision — each placeholder had been
written by hand, and writing it fourteen times is what made it easy to skip.

`emptyState` is that placeholder as one helper, in the spirit of
`peopleTooltip`: the content type's own icon and a sentence naming what is
absent. `cardList` is the section around it — the query, hoisted to the list's
node via `$queries` so its count is readable from outside the `$each`, then the
grid or the placeholder. Hoisting also means one subscription answers both, so
the two can never disagree about how many rows there are.

Two things the sentence has to get right. A list that filters on `searchText`
says "No posts match your search" while a search is active, because an empty
result there is not a claim about what the space holds. And the spaces list
excludes the space you are in, so it says it lists no *other* spaces.

A query-backed list is empty on its first frame and fills a moment later, which
is the honest reading of that frame and the wrong thing to show — the
placeholder would blink on every switch. It mounts inside `$animate` with a
delayed fade, so it stays transparent for longer than a query takes and is only
ever seen when it is true. Lists whose emptiness is known synchronously — the
member roster, and the branch where Flux's SDNA is not installed at all — skip
the delay.

The Flux lists already covered "no such model here" and now cover "installed but
holding nothing" with the same sentence: the distinction is real but not the
reader's, who asked whether this space has Flux channels.

`blockSection` takes an options object, since it grew the icon and label the
placeholder needs and was already five positional arguments.

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

`title` and `description` were added to `CollectionBlock` without regenerating
the manifest compiled from it, so the two disagreed. Caught by
`coreManifest.test.ts`, which exists for exactly this and says what to run.

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

netlify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit a476a37
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6a7a2e14bd2dce0008c3a226
😎 Deploy Preview https://deploy-preview-110--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 0982ee7 into dev Aug 10, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant