Feature: Call transcription - #110
Merged
Merged
Conversation
…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>
…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>
✅ Deploy Preview for coasys-we ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
CollectionBlockwithkind: 'call'— one record per call, holding theutterances 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 streamto 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'smutedprop had never muted anything; presence dropped state changes and tookfive seconds to repair;
node.slothad never worked in the schema renderer; profile pictures werebeing 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 transcriptionmodel 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, pluscapabilities.tsso 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 pieceof genuinely imperative machinery is an
AudioWorkletdoing voice-activity detection on the audiothread, which lives in the store as plain TypeScript.
MediaStream, borrowed throughdeps.audioInput. That is what makesmuting the call stop the transcript: a muted track is disabled rather than removed, so the worklet
hears silence. A second
getUserMediawould have kept transcribing someone who believed they weremuted.
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
transcribepresence activity carriesrecordingand the collection claim — two facts with different lifetimes, since recording stopswhile the claim outlives it so a late joiner still adopts the record.
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.
ensureCollectionreturnswaiting/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.
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
participantsis 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.
never speaks still appears. Previously they showed up only if someone else happened to flush while
they were present.
never changes, so keying on it would let a rejoin append a second copy.
The call stage
float, fouredges,
full). The old four-state mode button encoded three questions at once, so it could not havea clear icon and reaching a given state took up to three clicks through states nobody wanted.
black rectangle over an empty-but-non-null
MediaStream.CallTileStateis split fromCallTileso volatile flags do not remount a row —<For>is keyed byreference, 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.
becoming the better part of a minute against a reconnecting remote executor.
Fixes found along the way
we-videonever muted. Themutedcontent attribute maps todefaultMutedand seeds the IDLproperty only at element creation; lit-html clones its template with bound attributes stripped, so
?mutedmoved a flag on an element already atmuted === false. The call's self tile played its ownmicrophone back through the speakers.
node.slotnever worked. Every node renders inside adisplay: contentswrapper, so the wrapperis 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-tooltippadded every trigger. An inline-block trigger stands on a text baseline with roomreserved for descenders — space belonging to a font, in a box that may hold no text.
imagewas reflected, so the whole payload was written into the DOM per avatar. Uploads are nowcapped at 512px too; they had no dimension ceiling at all.
styles, and one missingsuper.updatedturned the whole designsystem off for an element — now guarded by
check-super-calls.mjs.Around the edges
CollectionBlockgainstitleanddescription;deletePostbecomes the kind-agnosticdeleteCollection; a call can be named, described, deleted and continued from its card.templates that each concatenated first and last with no handle fallback.
AvatarStackdeduplicates and shows a+Nchip instead of silently dropping everyone pastmax.still succeeding.
we-seed.jsonenables thetranscribemodule.Known follow-ups
@HasManyhas nodistinct. The one-writer rule is a prose contract that nothing enforces; thenext 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.AvatarStackdedupe covers thedisplay;
$countover those older relations stays wrong. Backfilling means deleting another agent'sassertion, which is not obviously ours to do.
agree, and no election fixes that. The merge path was considered and deliberately not built.
slotstill does nothing on layer-4/5 components. It works onwe-*primitives and native tags;on
Column/Row/Cardit is received as a prop and dropped. Fixing it means forwardingslotinevery component in
@we/components.cost of being able to set one up ahead of time, but it does mean the calls list can show empty cards.
textContentignores a call's title, so search over the Cards route will not match it.and space avatars were capped here.
Test plan
Automated, run on the final tree:
pnpm -r --no-bail run test— 1292 passing, no failures, across 19 packages.pnpm --filter @we/schema-shared validate— 22 schemas, no issues.tsc --noEmiton every package touched: app-shell, backend-shared, backend-ad4m, models,primitives, components, module-call, module-transcribe, schema-solid, template-default,
block-shared.
pnpm buildon the packages whosedistthe app loads — several bugs in this branch werediagnosed only after discovering a stale build, so this is not a formality.
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:
Not verified:
about and unit-tested, not observed.
playsinlinematters most.