Feature: Call Module - #99
Merged
Merged
Conversation
Binding live media to a <video> means assigning `srcObject`, which is a DOM property with no attribute form — lit-html cannot express it in a template, so it is assigned in `updated()` once the element exists. Put here rather than in a framework component on purpose. A primitive is where WE keeps imperative DOM work so every framework gets it once, and this is what lets a feature module show live video while shipping nothing but schema fragments. `playsinline` comes along because iOS Safari forces any playing video fullscreen without it, which would make a call tile unusable there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ModuleStoreDeps` held `signal` alone, which is enough for a module that only *holds* state — the notes panel's open flag. A module that **reconciles** against something the host owns needs more, and the call module needs all of it: `effect` to notice a peer joining a roster, `ephemeral` to signal, `presence` to know who is in the call, `dataset`/`datasetUri` to scope it. Every addition is a neutral port already declared in this package, never a host object. That is the line that stops the bag becoming a back door: a module receiving `EphemeralPort` works on any backend implementing one, whereas a module receiving `adamStore` would be an AD4M module wearing a neutral type. `datasetUri` is separate from `dataset` because `DatasetHandle` is deliberately opaque — a module cannot derive a global uri from it without peeking at backend internals, and it needs one whenever peers must compare an id. `presence` is narrowed to activities: saying "I am in this call" and reading the roster are legitimate; setting another agent's availability is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The third module, and the first to test that a module can reach the ephemeral port at all. Notes proved a module can own durable entities; the globe proved it can carry a heavyweight framework component. Neither sent a byte to a peer. Membership is presence's job, not the wire's. There is no join or leave message: the mesh reconciles against the presence roster, so a peer that appears gets a connection and one that disappears has its connection torn down. A message-based roster breaks on exactly the cases calls hit most — closed laptop, killed tab, network partition — each leaving a participant who never sent `leave` and a tile frozen forever. Presence expires on TTL, so a dead peer leaves on its own. The consequence: drop every message on this channel and the call fails to connect while the roster stays correct, which is the right way round. Both peers see each other join at slightly different moments and both fire `negotiationneeded`, so offers collide. Perfect negotiation resolves it, with the polite/impolite roles decided by comparing ids — deterministic, no round trip, the same lower-id-wins trick the tab coordinator uses. Screen share replaces the outbound video track rather than adding a second one, so the swap needs no renegotiation and cannot half-apply across peers. Receivers learn a screen is a screen from `MediaSettings` on the roster, which presence was already publishing — no extra protocol message, nothing to keep in sync. The cost is that camera and screen cannot both be sent; simultaneous needs a second transceiver and a way to label tracks, which is a protocol addition worth making against a real complaint. `coalesce: false` on the channel, emphatically: a dropped presence heartbeat costs nothing because the next carries the same state, but a dropped SDP offer is simply lost and that peer never connects. Ships as schema fragments only — no `frameworks`, no `backends`, no framework import — because `we-video` now takes a MediaStream. 28 tests: two agents negotiating in one process over `InMemoryBus`, including glare, roster teardown, traffic for another call in the same space, and offers from an agent the roster does not include. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Modules register in `PlatformProvider`, which sits above `StoreProvider` — the launcher template must be in the registry before the stores render, so registration cannot wait. But every port a module wants lives in a store that does not exist at that moment. Rather than reorder the tree, the deps handed to a module store are stable objects whose methods dereference at call time. A module holds `deps.presence` forever; what it points at is filled in when `PresenceStoreProvider` mounts. Every accessor answers safely before then — `peers()` is empty, `ephemeral()` is null — which is the same degrade-don't-throw contract the ports already require for a personal space with no neighbourhood. Activating modules after the stores mount was the alternative, and was rejected because it splits registration into two phases with different capabilities, and "which phase am I in" is the kind of implicit state the last round of seam bugs came from. `AdamStore` publishes the dataset, its global uri and the transport; `PresenceStore` publishes the roster and activity setters. Published rather than imported, so a module never reaches into a store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live debt from #98: every registered module's chrome appeared in every space, with no way for a community to turn one off. Gating is a schema condition wrapped around each module's slot node, not a filter over the registry. It needs no reactivity plumbing — `$if` already re-evaluates when the store changes, whereas `slotRegistry` is a plain Map that would have to become reactive — and it composes, so the module's own visibility rules still apply underneath and a module never learns it is being gated. An unset `Space.enabledModules` means "not decided", never "none". A space created before this field existed falls back to the registered set and keeps exactly the chrome it has today; treating empty as "none" would silently strip every existing space the moment this shipped. Toggling writes the resolved list rather than a diff, so the first toggle pins what was on by fallback. Known gap: a space created before this build has the old SHACL shape stored in its perspective, and shapes are only installed when a class is absent entirely (`hasSubjectClassLink`), so adding a property to an existing model does not re-register — there is no shape-migration path yet. The write is caught and logged; such a space keeps the fallback and loses nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bundled alongside globe and notes, and declared in `we-seed.json`. Takes nothing from the host — like notes, and more surprisingly, since live video looked certain to need a framework component until `we-video` grew a `stream` property. Tests cover the host side: that the module registers and docks, that it declares the capabilities a user should think twice about, that it constructs without any ports at all and degrades to a message rather than throwing, and that the per-space gate wraps module chrome while leaving core chrome alone. The notes test needed unwrapping for the gate — a consequence worth pinning, since every module's registered node is now one `$if` deeper than the module wrote it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bar rendered only when somebody was already in a call, so nobody could ever be the first — the same shape as the notes module shipping installed, registered and invisible one PR ago. The reasoning that produced it was even the same: starting a call felt like it belonged on a deliberate affordance rather than persistent furniture, which is true right up until no affordance exists. Gated on a new `canCall`, which is false in a personal space. There is no neighbourhood there and so nobody to call; offering the button and explaining the failure afterwards would be worse than not offering it, because the answer never changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran after the primitives CEM rebuild, which is what surfaces new props into the component registry docs. The earlier regeneration in this branch ran before it, so it only captured the store additions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate bugs, all visible on first use.
**The stage flickered.** `$each` renders through Solid's `<For>`, which is keyed
by reference, and `rebuildTiles()` minted fresh objects on every call — so every
heartbeat, connection-state change and mute toggle remounted every row, and a
remounted row means a new `<video>` that drops and re-attaches `srcObject`. Tiles
are now reused when nothing about a participant changed. The codebase hit this
same failure with `$query` results, where AD4M's prototype `id` getter defeated
`reconcile({ key: 'id' })`.
**The stage was white with a wide empty band.** `Grid`'s `minChildWidth` compiles
to `repeat(auto-fill, …)`, which keeps empty tracks, so one participant drew one
small tile and a lot of nothing; it is a wrapping `Row` of flex items now. And
`neutral-1000` is not "always dark" — dark themes set `multiplier: -1`, inverting
the neutral scale, so the darkest token in the light theme is the lightest in the
dark one. Surfaces have to use the same low-numbered tokens as the rest of the app.
**The call button rendered in the top-left corner.** `top`/`right`/`bottom`/`left`
are raw pass-through specs, not `SpaceValue`, so `bottom: '400'` emitted invalid
CSS and `position: fixed` fell back to the static position. All offsets are real
lengths now.
Behind the second point, the actual design fix: notes and calls had each invented
their own floating launcher in a different corner, and a third module would have
made three. A module now *declares* `launcher: { icon, label, action }` and the
host renders them into one rail on the right edge — a module knows what its
launcher means, only the host can stop launchers colliding. `availableWhen` keeps
the call tab out of personal spaces, where there is nobody to call and the answer
would never change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the stage **The mute flash.** The earlier fix kept tile objects stable only when *nothing* changed, which meant a mute still minted a new object — and `$each` renders through a reference-keyed `<For>`, so the row remounted and its `<video>` was rebuilt, dropping and re-attaching `srcObject`. Identity and volatile state are now separate. A tile carries `id`/`did`/`isSelf`/ `stream` and changes only when one of those does, both of which genuinely need a remount. Flags live in `tileStates` and a fragment reaches them with `$find` over a `$store`, which resolves inside the renderer's prop memo — so reading the signal registers a dependency and the badge updates while the row stays put. Context refs cannot do this: the dispatcher walks them with a plain `current?.[p]` and never invokes accessors, so a signal on the tile would resolve to the function itself. `$store` is the only reactive path into a row. **The 240px cap** was a hardcoded `height` on the tile. Tiles now stretch (`ay: 'stretch'` plus `alignSelf`), so they take the stage's full height. **Back to the top.** The stage was at the top originally only because `bottom: '900'` is not a CSS length and the offset was dropped — but that accident read better, so it is now deliberate, offset to clear the module rail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…them Corrects the previous commit, which moved the stage to the top but left the controls docked at the bottom — so the two halves of the call sat at opposite ends of the screen. Both are at the top now, controls above video. That is where the bar appeared originally, though only because `bottom: '400'` is not a CSS length: the offset was dropped and `position: fixed` fell back to the static position. The accident read better than the design, so it is now the design — a call you are in belongs where your eyes already are, rather than competing with whatever the space puts along its bottom edge. The two offsets are named constants because the stage's is derived from the bar's. They have to stay stacked, and a bare `128px` in one place and `72px` in another records no relationship at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72px was an unexamined guess at clearing the header. The bar floats over the content anyway, so it does not need the clearance. The stage offset is derived rather than guessed alongside it: the bar is 50px tall (a 32px sm button, py 200 top and bottom, plus 1px borders), so 10 + 50 + 12 puts the video 72px down. The arithmetic is in the comment because the two constants have to move together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
❌ Deploy Preview for coasys-we failed. Why did it fail? →
|
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.
PR: The Call Module — Proving the Ephemeral Port
Summary
The presence PR built an
EphemeralPortand used it for one thing: heartbeats. The feature-module PRbuilt a module contract and shipped two modules, neither of which sent a byte to another agent. This
PR joins them — a call module is distributable code reaching peer-to-peer transport, which is the last
unproven claim in both designs.
It also closes the live debt #98 left behind: a space can now decide which modules it runs.
Branch:
feat/call-module· 6 commits · 36 files · +3,159 / −31 · 810 testsDesign rationale for the layers underneath: presence-port.md,
feature-modules.md.
The result worth leading with
The call module ships as schema fragments. No
frameworks, nobackends, no framework import.This was not the expected outcome. Live video means assigning
srcObjectto a<video>element —imperative, and impossible to express as data — so a call looked certain to be the module that finally
needed a Tier-2 framework component.
It needed one layer down instead.
we-videogained astreamproperty, and a Lit primitive isprecisely where WE puts imperative DOM work so that every framework gets it once. The most demanding
module in the codebase is therefore data, and the fragments-first contract stops being an argument.
The check that made it viable: the Solid renderer assigns web-component props as DOM properties,
and its
deepUnwrapreturns any non-plain object untouched (proto !== Object.prototype). AMediaStreampasses through the whole prop pipeline intact.What's in it
@we/module-callprotocol.tsmesh.tsRTCPeerConnectionper peer; perfect negotiation; roster reconciliationmedia.tsstore.tsModuleStoreDepsaloneindex.tsEverywhere else
we-video—stream(+playsinline, which iOS Safari needs or it forces fullscreen).ModuleStoreDeps— greweffect,dataset,datasetUri,selfId,ephemeral,presence.moduleHostServices.ts— late binding, because modules register above the stores.Space.enabledModules— per-space enablement, with the gate applied inmoduleRegistry.Design decisions worth reviewing
Membership is presence's job; the wire only negotiates
There is no
joinand noleavemessage. The mesh reconciles against the presence roster: a peerthat appears gets a connection, one that disappears has its connection torn down.
This is the decision the rest of the module hangs off. A message-based roster breaks on exactly the
cases calls hit most — a closed laptop, a killed tab, a network partition — each leaving a participant
who never sent
leaveand a tile frozen forever. Presence already expires on TTL, and activities gowith it.
The consequence worth internalising: this channel is not authoritative about anything. Drop every
message on it and the call fails to connect while the roster stays correct. That is the right way
round, and it is why an unreliable AD4M broadcast degrades into "cannot connect" rather than "ghost
participants".
Perfect negotiation, with roles from id comparison
Both peers see each other join at slightly different moments, both add tracks, both fire
negotiationneeded— so offers collide. Each pair gets a polite and an impolite side by comparingids: deterministic, symmetric, no round trip. The same lower-id-wins trick that fixed the tab
coordinator's leader ties.
A test pins the failure that matters, which is not "does it connect" but the symmetric one: both peers
yielding, and neither ever connecting.
Screen share replaces the video track — and the roster explains it
replaceTrackdoes not renegotiate, so the swap is instant and cannot half-apply across peers.A receiver still has to know a screen is a screen, because a 16:9 desktop cropped into a square camera
tile is unreadable. That information was already travelling: presence publishes
MediaSettingson thecall activity, so
screenShareEnabledon the roster drivescontainvscover. No extra protocolmessage, nothing to keep in sync.
The cost, stated rather than hidden: camera and screen cannot both be sent. Simultaneous needs a
second transceiver and a way to label which track is which — a protocol addition, worth making
against a real complaint rather than speculatively.
coalesce: false, emphaticallyPresence sets
coalesce: truebecause a dropped heartbeat costs nothing — the next one carries thesame state. An SDP offer dropped because the previous send was slow is simply lost, and that peer never
connects. Same port, opposite setting, and the
ChannelOptionsdoc comment already said why.Addressed twice
publish(payload, { agentId })lets a backend with native unicast actually send to one peer. Therecipient is also in the payload, because
unicast: 'emulated'means fan out and filter on receipt —and a fanout-only transport would not filter at all, so a bystander would apply an offer meant for
someone else and negotiate a connection nobody asked for. A few bytes for correctness on every
transport tier.
planEphemeralrefusesunicast: 'none'outright. It deliberately does not requestconfidential: that would be the honest flag, since an SDP offer names your host candidates, butdemanding native unicast would refuse to run on AD4M. The trade is written down instead — on an
emulated transport everyone in the space sees the handshake, and the media is DTLS-encrypted
end-to-end regardless.
Late binding, not a reordered provider tree
Modules register in
PlatformProvider, aboveStoreProvider, because the launcher template must be inthe registry before the stores render. Every port a module wants lives in a store that does not exist
yet at that moment.
So the deps bag holds stable objects that dereference at call time. Activating modules after the
stores mount was the alternative, and splits registration into two phases with different capabilities —
"which phase am I in" being precisely the implicit state that produced #98's four seam bugs.
An unset
enabledModulesmeans "not decided", never "none"A space created before the field existed falls back to the registered set and keeps exactly the chrome
it has today. Treating empty as "none" would have silently stripped every existing space the moment
this shipped. Toggling writes the resolved list rather than a diff, so the first toggle also pins what
was on by fallback.
The gate is a schema condition wrapped around each slot node rather than a filter over the registry:
no reactivity plumbing needed (
$ifalready re-evaluates;slotRegistryis a plainMap), and itcomposes with the module's own visibility rules underneath.
Testing
Verified: 601 in
@we/schema-shared, 39 in@we/schema-solid, 142 in@we/app-framework, 28 in@we/module-call. All packages typecheck, lint clean, 13 schemas validate.The mesh tests run two agents negotiating in one process over
InMemoryBus— the ephemeral port'ssecond implementation, which exists so the port is not defined by one backend. It turns "does perfect
negotiation converge" from a two-laptop manual check into a unit test, and covers glare, roster
teardown, traffic for a different call in the same space, and an offer from an agent the roster does
not include.
One test found a real thing while being written: asserting core chrome was ungated by checking
node.type !== '$if'passed for the wrong reason — the sidebar's own node is already an$if. It nowasserts on the condition, across all three core slots.
Not yet verified live
No two-agent call has been made. The signalling logic is tested against a fake
RTCPeerConnection;what has not run is real SDP, real ICE, and AD4M's broadcast carrying it — which is the layer that was
timing out during the presence PR. Everything below is genuinely untested until two browsers connect:
sendBroadcastround-trips an SDP offer fast enough to negotiateSpace.enabledModuleswrite on a space created before this build, which is expected to fail andis caught and logged
Known limitations
to run, so it is a deployment decision rather than a module one — but it is the most likely reason a
real call fails.
bandwidth is the limit. An SFU is a server, and a local-first app that quietly requires one has
stopped being local-first.
so
Space.enabledModulesonly persists on spaces created by this build or later. This is now ageneral problem, not a call-module one: it will bite the next time any WE model gains a field.
What this unblocks
The ephemeral port has now carried two protocols with genuinely opposite requirements — presence
(last-write-wins, coalescing, fanout) and calls (handshake, no coalescing, unicast). That is the
strongest evidence so far that the seam is in the right place, and it is the thing
backend-ports-and-package-split.md assumes when its commits 5–6
extract the editor and AI as modules.
The remaining marketplace work is unchanged: install/consent, dynamic loading, a permission broker.
This module sharpens the case for the last one —
capabilities: ['microphone', 'camera', 'screen-share']is declared and unenforced, which is defensible for first-party bundled code andindefensible the moment a module arrives from a stranger.