oficina / meet Public
Meet slice 4: chat and screen share — Implementation Plan
> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: An admitted participant can send a short text message that every other admitted participant sees at once and nobody else ever sees, and can share a screen that arrives at every peer as a second labelled video tile which goes away the moment sharing stops.
Architecture: The hub gains one fan-out primitive (Fanout) for the two untargeted message types: sender must be an admitted participation bound to this session (the same authority as a relay message, §2.5 last row, §15.12), recipients are every other admitted peer, nothing is ringed or stored. /signal validates the two payload shapes (chat {text} ≤ 2 KB, share {on}) and dispatches. The chat panel is estilo's first chat: one self-contained template partial and one script file with a tiny mount API, adopted by room.html and driven by room.js through a broadcast lane in its existing outbound queue. Screen share is a getDisplayMedia track added as its own transceiver on every connection with a share message so peers label the tile; stopping stops the transceiver and renegotiates so the receiver's track ends. The gate proves chat reaches the admitted context and not a waiting one, and the share round trip with frames.
Tech Stack: Go 1.25 (rastrillo v0.26.0, chi), hand-written chat.js and room.js, Playwright 1.62.1 on Chromium, codex exec for the independent review.
Spec: docs/superpowers/specs/2026-09-07-meet-design.md revision 9: §4.3 (the chat and share rows and the recipient rule), §4.4 (2 KB, nothing of an accepted message in a log), §5.1 (renegotiation), §5.4 (screen share), §6 (chat), §10 (estilo: the panel is one partial and one script so a second app can adopt it), §12 item 4, §13 (the slice-4 gates: the captured-log test extended to accepted chat; no chat while waiting; the share round trip; chat arrives at the admitted context and not a waiting one).
Global Constraints
CGO_ENABLED=0throughout;make ciis the gate on every task;make raceandmake browser(sandbox OFF: UDP is blocked inside the Claude Code sandbox; the gate takes about seven minutes;X-Meet-Test-Ice-TTLvalues must be multiples of 6 s) on the tasks that name them.- No schema change. Chat is not stored, not replayed, not ringed; gone with the tab (§6).
chattext is at most 2048 bytes of UTF-8, refused above with 413 (§4.4); the server reads nothing insidechatbeyond the byte length and rebuilds the envelope with the sender's name; the text never reaches a log line, and neither does anything of an accepted message (§4.4).- Recipients: every other admitted peer in the room (the sender's own panel renders locally on send); a waiting guest receives nothing (§4.3, ruling 9).
- A 404 is never a 403; the
/signalnot-found body is unchanged. - DOM safety: chat text is rendered with
textContentonly; noinnerHTML, no linkification (§6); peer names come only from the envelope. - The chat panel is one partial (
internal/calls/templates/partials/chat.html) and one script (internal/calls/static/chat.js) with no dependency onroom.jsinternals:Chat.mount(root, {send})returns{append({from, text, at, self}), setEnabled(bool)}; a second app can adopt both files unchanged. - Screen share: one
sendonlyvideo transceiver per connection carrying the display track; stop callstransceiver.stop()and renegotiates so the receiver's track ends; the receiver removes the share tile onendedorshare {on:false}, whichever first, and the camera tile continues (§5.4). One sharer at a time is not enforced. - Copy review is waived this round per suite discussion 5; every new string is catalogued in
docs/superpowers/copy/2026-09-10-slice-2-strings.mdunder "## Slice 4". - Screens work without JavaScript; the room page's scriptless paragraph is unchanged; the chat panel is inside
#room(hidden without script). - Commits:
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit, files by name, never.claude/,node_modulesorpackage-lock.json; trailersCo-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2.
---
File structure
internal/calls/hub/fanout.go(+fanout_test.go) —Fanout.internal/calls/signal.go— thechat/sharearms and payload validation;internal/callstest/signal_test.go— the accepted-chat log extension;internal/callstest/chat_test.go— recipients over HTTP.internal/calls/templates/partials/chat.html,internal/calls/static/chat.js,internal/calls/static/chat.css— the panel (estilo's first chat).internal/calls/templates/room.html— mounts the partial;internal/calls/static/room.js— the broadcast lane, chat wiring, screen share;internal/calls/static/calls.css— share tile and the share button.test/browser/call.mjs,internal/callstest/browser_test.go— scenarioschat,share.docs/superpowers/copy/2026-09-10-slice-2-strings.md,docs/superpowers/reviews/2026-09-10-slice-4-record.md, spec §15.15.
---
Task 1: hub.Fanout: an untargeted message from an admitted peer to every other admitted peer
Files:
- Create:
internal/calls/hub/fanout.go,internal/calls/hub/fanout_test.go
Interfaces:
- Consumes:
memberStandingGone(ctx, sqid, session, peer, end bool) (bool, error)(relay.go),findLocked,roomLocked/h.rooms,broadcastLocked,audience,Message,ErrUnknownPeer,ErrNotAdmitted,ErrNotYours,ErrNotTargeted(readAuthorizein authorize.go for the exact shape of the standing step). - Produces: ``
go // Fanout delivers m to every admitted peer in the room except the sender. // Sender: bound to this session, admitted; a member-kind participation // re-derives standing first (§15.12) and is ended with lost{standing} // when it is gone (ErrNotYours). m.Type must be chat or share // (ErrNotFanout otherwise). The envelope is rebuilt: {Type, From, Name, // Payload}; no To, Seq or Gen; nothing is ringed. func (h *Hub) Fanout(ctx context.Context, sqid, session string, from PeerID, m Message) error var ErrNotFanout = errors.New("hub: not a fan-out type")``
- [ ] Step 1: Write the failing tests
package hub
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
)
func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) {
now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
h := testHub(&now)
ctx := context.Background()
a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
h.Join(ctx, "r1", "sa", a.Peer)
b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
h.Join(ctx, "r1", "sb", b.Peer)
g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
h.Join(ctx, "r1", "sg", g.Peer) // waiting
drain(a.Sub)
drain(b.Sub)
drain(g.Sub)
err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)})
if err != nil {
t.Fatal(err)
}
got := events(b.Sub)
if len(got) != 1 || got[0].Type != TypeChat || got[0].From != a.Peer || got[0].Name != "Ann" || string(got[0].Payload) != `{"text":"hi"}` || got[0].To != "" || got[0].Seq != 0 || got[0].Gen != 0 {
t.Fatalf("Bob got %+v", got)
}
if n := len(events(a.Sub)); n != 0 {
t.Fatalf("the sender must not receive its own chat, got %d", n)
}
if n := len(events(g.Sub)); n != 0 {
t.Fatalf("a waiting guest must receive nothing, got %d", n)
}
if n := len(peerOf(h, "r1", b.Peer).ring); n != 0 {
t.Fatalf("chat is never ringed, ring has %d", n)
}
}
func TestFanoutNeedsAnAdmittedSenderBoundToThisSession(t *testing.T) {
now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
h := testHub(&now)
ctx := context.Background()
a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}
if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); !errors.Is(err, ErrNotAdmitted) {
t.Fatalf("unjoined: %v", err)
}
h.Join(ctx, "r1", "sa", a.Peer)
if err := h.Fanout(ctx, "r1", "sb", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) {
t.Fatalf("another session: %v", err)
}
if err := h.Fanout(ctx, "r2", "sa", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) {
t.Fatalf("another room: %v", err)
}
g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
h.Join(ctx, "r1", "sg", g.Peer)
if err := h.Fanout(ctx, "r1", "sg", g.Peer, msg); !errors.Is(err, ErrNotAdmitted) {
t.Fatalf("waiting guest: %v", err)
}
if err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeOffer}); !errors.Is(err, ErrNotFanout) {
t.Fatalf("a targeted type is not a fan-out: %v", err)
}
}
func TestFanoutDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) {
// Copy the fixture from authorize_test.go's standing test (the hub
// whose StandingFor consults a mutable map): revoke Ann's standing,
// Fanout → ErrNotYours, Ann's participation is gone, Bob's stream got
// `gone` naming Ann and no chat. Then an admitted guest past expiry
// (StandingFor !ok for the guest) fans out successfully (ruling 14).
}Use the file's existing helpers (testHub, drain, events, and a peerOf lookup; if peerOf does not exist, read the participation through h.Find(sqid, session, peer) under the test's own lock discipline as other hub tests do).
- [ ] Step 2: Run to see them fail:
GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/hub/ -run Fanout— compile error,Fanoutundefined.
- [ ] Step 3: Implement (
internal/calls/hub/fanout.go)
package hub
import (
"context"
"errors"
)
// ErrNotFanout is a Fanout of a type that is targeted or server-raised.
var ErrNotFanout = errors.New("hub: not a fan-out type")
// Fanout is §4.3's chat and share: an admitted peer to every other
// admitted peer, the envelope rebuilt from the sender's participation
// so from and name can never be forged, nothing ringed or stored (§6).
// The sender's authority is §2.5's last row, exactly as Relay's: a
// member-kind participation re-derives standing first (§15.12) and one
// whose standing is gone is ended here with lost{standing}; a guest
// never consults standing (ruling 14).
func (h *Hub) Fanout(ctx context.Context, sqid, session string, from PeerID, m Message) error {
if m.Type != TypeChat && m.Type != TypeShare {
return ErrNotFanout
}
if gone, err := h.memberStandingGone(ctx, sqid, session, from, true); err != nil {
return err
} else if gone {
return ErrNotYours
}
h.mu.Lock()
defer h.mu.Unlock()
p, ok := h.findLocked(sqid, session, from)
if !ok {
return ErrUnknownPeer
}
if !p.Admitted {
return ErrNotAdmitted
}
r := h.rooms[sqid]
out := Message{Type: m.Type, From: p.Peer, Name: p.Name, Payload: m.Payload}
h.broadcastLocked(r, out, func(q *Participation) bool { return q.Admitted && q.Peer != p.Peer })
return nil
}(Read memberStandingGone's real return contract and Relay's mapping of it before writing the two lines that use it; mirror Authorize.)
- [ ] Step 4: Run to see them pass; gate:
make ciandCGO_ENABLED=1 go test -race ./internal/calls/hub/.
- [ ] Step 5: Commit
git add internal/calls/hub/fanout.go internal/calls/hub/fanout_test.go
git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: Fanout, an untargeted message to every other admitted peer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"---
Task 2: /signal dispatches chat and share
Files:
- Modify:
internal/calls/signal.go(thecase hub.TypeChat, hub.TypeSharearm and avalidateFanouthelper),internal/callstest/signal_test.go(the captured-log test gains an accepted chat and a refused oversize chat),internal/callstest/sse_test.goonly if a helper is missing - Create:
internal/callstest/chat_test.go
Interfaces:
- Consumes:
hub.Fanout,hub.ErrNotFanout, the existing error mapping switch,refuseSignal,client.signal(sqid, peer, body),sseReader.nextOf,guestJoinedAndWaiting(ice_test.go),newHarnessWithLogger(t, env, logger, members...). - Produces: the wire:
POST /m/{sqid}/signal {"peer", "type":"chat", "payload":{"text":"…"}}→ 200 on acceptance; 413{"error":"too-large"}when the text exceeds 2048 bytes; 400{"error":"bad-request"}for a payload that is not{text: string}(chat) or{on: bool}(share) or that has other keys; the usual 404s. Downstream event:event: chat/event: sharewith{type, from, name, payload}.
- [ ] Step 1: Write the failing tests (
internal/callstest/chat_test.go)
package callstest
import (
"net/http"
"strings"
"testing"
"time"
)
func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *testing.T) {
x, sqid, _ := memberWithMeeting(t)
a := x.browser(t)
a.signIn("paul@example.com")
sa := a.stream("/m/" + sqid + "/stream")
defer sa.close()
pa, _ := sa.hello()
a.signal(sqid, pa, map[string]any{"type": "join"})
sa.nextOf("roster", 2*time.Second)
b := x.browser(t)
b.signIn("paul@rslw.com")
sb := b.stream("/m/" + sqid + "/stream")
defer sb.close()
pb, _ := sb.hello()
b.signal(sqid, pb, map[string]any{"type": "join"})
sb.nextOf("roster", 2*time.Second)
sa.nextOf("peer", 2*time.Second)
res, _ := a.signal(sqid, pa, map[string]any{"type": "chat", "payload": map[string]any{"text": "hello there"}})
if res.StatusCode != http.StatusOK {
t.Fatalf("chat: %d", res.StatusCode)
}
ev, data, ok := sb.nextOf("chat", 2*time.Second)
if !ok || ev != "chat" {
t.Fatal("Bob did not receive the chat")
}
s := string(data)
if !strings.Contains(s, `"from":"`+pa+`"`) || !strings.Contains(s, `"name":"paul"`) || !strings.Contains(s, `"text":"hello there"`) || strings.Contains(s, `"seq"`) || strings.Contains(s, `"to"`) {
t.Fatalf("envelope %s", s)
}
if ev, _, ok := sa.next(300 * time.Millisecond); ok && ev == "chat" {
t.Fatal("the sender received its own chat")
}
}
func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) {
x, sqid, _ := memberWithMeeting(t)
a := x.browser(t)
a.signIn("paul@example.com")
sa := a.stream("/m/" + sqid + "/stream")
defer sa.close()
pa, _ := sa.hello()
a.signal(sqid, pa, map[string]any{"type": "join"})
sa.nextOf("roster", 2*time.Second)
g, gpeer := guestJoinedAndWaiting(t, x, sqid) // its stream is g.s or returned; adapt to the helper's shape
sa.nextOf("knock", 2*time.Second)
a.signal(sqid, pa, map[string]any{"type": "chat", "payload": map[string]any{"text": "secret"}})
if ev, data, ok := g.stream.next(500 * time.Millisecond); ok && (ev == "chat" || strings.Contains(string(data), "secret")) {
t.Fatalf("a waiting guest received %s %s", ev, data)
}
res, body := g.signal(sqid, gpeer, map[string]any{"type": "chat", "payload": map[string]any{"text": "let me in"}})
if res.StatusCode != http.StatusNotFound || !strings.Contains(string(body), `"error":"not-found"`) {
t.Fatalf("a waiting guest's chat must be the usual 404: %d %s", res.StatusCode, body)
}
}
func TestChatLimitsAndShapes(t *testing.T) {
x, sqid, _ := memberWithMeeting(t)
a := x.browser(t)
a.signIn("paul@example.com")
sa := a.stream("/m/" + sqid + "/stream")
defer sa.close()
pa, _ := sa.hello()
a.signal(sqid, pa, map[string]any{"type": "join"})
sa.nextOf("roster", 2*time.Second)
cases := []struct {
name string
body map[string]any
status int
}{
{"2048 bytes ok", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2048)}}, 200},
{"2049 bytes too large", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2049)}}, 413},
{"multibyte counted in bytes", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("é", 1025)}}, 413},
{"text not a string", map[string]any{"type": "chat", "payload": map[string]any{"text": 5}}, 400},
{"extra key", map[string]any{"type": "chat", "payload": map[string]any{"text": "x", "html": "<b>"}}, 400},
{"empty text", map[string]any{"type": "chat", "payload": map[string]any{"text": ""}}, 400},
{"share on", map[string]any{"type": "share", "payload": map[string]any{"on": true}}, 200},
{"share off", map[string]any{"type": "share", "payload": map[string]any{"on": false}}, 200},
{"share not a bool", map[string]any{"type": "share", "payload": map[string]any{"on": "yes"}}, 400},
{"chat with a target", map[string]any{"type": "chat", "to": pa, "payload": map[string]any{"text": "x"}}, 400},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, _ := a.signal(sqid, pa, c.body)
if res.StatusCode != c.status {
t.Fatalf("%s: %d, want %d", c.name, res.StatusCode, c.status)
}
})
}
}Extend TestSignallingLogsCarryNoContent (signal_test.go): after the existing rounds, send an accepted chat with a distinctive text ("xyzzy-plugh-chat") and an oversize chat; assert the buffer never contains the text, and that the oversize refusal line carries outcome=size (or the file's outcome vocabulary) and no text. Also assert the accepted chat appended nothing to the log.
- [ ] Step 2: Run to see them fail (
go test ./internal/callstest/ -run 'Chat|Logs'): the chat cases get 400 from the slice-4 stub.
- [ ] Step 3: Implement in
signal.go: replace the stub arm with
case hub.TypeChat, hub.TypeShare:
if body.To != "" {
a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"})
return
}
if status, outcome := validateFanout(body.Type, body.Payload); status != 0 {
a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)})
return
}
herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: body.Payload})with
// maxChatBytes is §4.4's 2 KB, counted in bytes of the text, not
// characters: the limit protects the other peers' streams, which carry
// bytes.
const maxChatBytes = 2048
// validateFanout checks the two untargeted payload shapes without
// keeping anything from them: chat is exactly {"text": string} with
// 1..2048 bytes; share is exactly {"on": bool}. Returns a status and
// an outcome word for the refusal log, or 0.
func validateFanout(typ string, raw json.RawMessage) (int, string) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
switch typ {
case hub.TypeChat:
var p struct {
Text *string `json:"text"`
}
if err := dec.Decode(&p); err != nil || p.Text == nil || dec.More() {
return http.StatusBadRequest, "shape"
}
if len(*p.Text) == 0 {
return http.StatusBadRequest, "shape"
}
if len(*p.Text) > maxChatBytes {
return http.StatusRequestEntityTooLarge, "size"
}
case hub.TypeShare:
var p struct {
On *bool `json:"on"`
}
if err := dec.Decode(&p); err != nil || p.On == nil || dec.More() {
return http.StatusBadRequest, "shape"
}
}
return 0, ""
}
func fanoutError(status int) string {
if status == http.StatusRequestEntityTooLarge {
return "too-large"
}
return "bad-request"
}Add hub.ErrNotFanout to the 400 arm of the error-mapping switch beside ErrNotTargeted. Keep refuseSignal's log shape (room, peer, type, outcome only).
- [ ] Step 4: Gate:
make ci;CGO_ENABLED=1 go test -race ./internal/....
- [ ] Step 5: Commit
signal: chat and share fan out to every other admitted peerwith the trailers; files by name.
---
Task 3: The chat panel: estilo's first chat, one partial and one script
Files:
- Create:
internal/calls/templates/partials/chat.html,internal/calls/static/chat.js,internal/calls/static/chat.css - Modify:
internal/calls/templates/room.html(mount the partial inside#room, after#tiles/#controls, before#queue; loadchat.cssandchat.jsfrom the head block withdefer),internal/calls/app.goorrender.goonly if partials need registering (check how the vendoredui/*partials are parsed and follow it),internal/callstest/room_test.go(the panel renders, hidden without script, with itsdata-*),docs/superpowers/copy/2026-09-10-slice-2-strings.md("## Slice 4")
Interfaces (the primitive's contract, §6 and §10):
- Partial
{{template "chat-panel" .}}renders: ``html <section id="chat" class="chat" data-chat aria-label="Chat" hidden> <h2 class="chat-title">Chat</h2> <ol id="chat-log" class="chat-log" aria-live="polite" aria-relevant="additions"></ol> <form id="chat-form" class="chat-form"> <label for="chat-text" class="chat-label">Message</label> <textarea id="chat-text" name="text" rows="2" maxlength="2048" rst-input required></textarea> <button type="submit" id="chat-send" rst-btn>Send</button> <p id="chat-status" class="chat-status" role="status"></p> </form> </section>`(hiddenbecause the whole room is script-only;room.js` un-hides it on admission.) chat.jsdefineswindow.Chat = { mount(root, opts) }withopts.send(text) → Promise<boolean>(resolves true when the message was accepted; the panel appends the local echo only then) and returns{ append({from, name, text, at, self}), setEnabled(enabled), clear() }.appendcreates<li class="chat-line"><span class="chat-name">name</span> <time datetime=…>HH:MM</time> <span class="chat-text">text</span></li>withtextContentonly; keeps the log scrolled to the newest line unless the reader has scrolled up; caps the log at 500 lines (drops the oldest). Enter sends, Shift+Enter is a newline. The status line says "Could not send. Try again." on a failed send and "Message is too long." when the textarea holds more than 2048 bytes (count withTextEncoder). NoinnerHTML, no linkification, no dependency onroom.js.chat.cssuses the--rst-*tokens the theme defines (seecalls.cssfor the ones in use); no colour alone for state.
- [ ] Step 1: Write the Go test in
room_test.go: the room page containsid="chat"withhidden,id="chat-log",id="chat-text"withmaxlength="2048", and loads/static/chat.jsfrom the head block withdeferandchat.css; verify it fails before the partial exists. - [ ] Step 2: Write the partial, the script and the stylesheet to the contract;
node --check internal/calls/static/chat.js. - [ ] Step 3: A node test
test/browser/chat.test.mjsthat liftsappend's line builder (or the byte counter) out ofchat.jsby name and proves: text is set withtextContent(an angle bracket survives as text), the 500-line cap, the byte counter on multibyte input. Wire it into the Makefile'sbrowsertarget besidenegotiation.test.mjs. - [ ] Step 4: Catalogue the strings ("Chat", "Message", "Send", "Could not send. Try again.", "Message is too long.").
- [ ] Step 5: Gate
make ci; commitchat: the panel partial and script, estilo's first chatwith the trailers.
---
Task 4: room.js: chat wiring and screen share
Files:
- Modify:
internal/calls/static/room.js,internal/calls/static/calls.css,internal/calls/templates/room.html(a Share button in#controls:<button type="button" id="share" rst-btn aria-pressed="false">Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button>),docs/superpowers/copy/2026-09-10-slice-2-strings.md
Interfaces (the client contract):
- Broadcast lane.
send(to, …)is per-target withseq/gen; chat and share are untargeted. Addbroadcast(type, payload) → Promise<boolean>: one queue keyed by the sentinel'*'with the same pump discipline (one POST in flight, the 30 s deadline, 429 with Retry-After, 401/403 → signed-out, 404 → the probe-first recovery, 400/413 never retried) but noto,seqorgenin the body; resolves true on 200.stale/genlogic does not apply to'*'. - Chat. On admission (
state.admittedbecomes true),Chat.mount(document.getElementById('chat'), {send: (text) => broadcast('chat', {text})}), un-hide#chat,setEnabled(true); onhandle('chat', msg)→chat.append({from: msg.from, name: msg.name, text: msg.payload.text, at: Date.now(), self: false}); on the local send's true →append({… self: true, name: selfName}). Onterminal/leave→setEnabled(false). Achatfrom a peer not in the roster is still rendered (the name is in the envelope; the hub guaranteed admission). - Screen share (§5.4).
#sharetoggles: on →navigator.mediaDevices.getDisplayMedia({video: true, audio: false}); storeshare = {stream, track}; for every livepcsentryentry.shareTx = pc.addTransceiver(track, {direction: 'sendonly', streams: [stream]})(the label is carried by thesharemessage, not the stream); perfect negotiation carries the offer;broadcast('share', {on: true});aria-pressedand the state span flip. The display track's ownendedevent (the browser's "stop sharing" bar) runs the same stop path. Off → for every entryentry.shareTx.stop()(fallbacksender.replaceTrack(null)+removeTrackwhenstopis unsupported), renegotiation follows,broadcast('share', {on: false}), stop the display track. New connections created while sharing get the transceiver increatePC. - Receiving. In the
trackhandler, a second video track from a peer that is not the camera (the camera is the first video track of the peer's first stream; the share arrives withev.streams[0].id !== entry.stream.id, or as a track whose transceivermidis not the camera's — recordentry.cameraMidfrom the first video transceiver) becomesentry.share = {stream, track}and a second tile keyedpeer + '#share'labelled "<name>'s screen" (theshare {on:true}message setsentry.sharing = trueso the tile can be labelled even if the message precedes the track); on the track'sendedorshare {on:false}, whichever first, remove the share tile and clearentry.share; the camera tile is untouched. Frames on the share tile are counted like the camera's (watchFrames) underframes.get(peer + '#share'). state()gainschat: {sent, received},sharing: bool,shares: [peer ids with a share tile], andframesentries for share tiles.- Strings: "Share screen", "On"/"Off" state, "<name>'s screen" caption, and a status sentence when
getDisplayMediais refused: "Screen sharing was not allowed."
- [ ] Step 1: Read §5.1, §5.4, §6, the current
room.js(send/pump/postSignal ~2003–2193, the track handler ~982,createPC,tile,renderTiles,state()),chat.js's API from Task 3, andtask-8-handoffs.md. - [ ] Step 2: Implement to the contract in named functions with a header comment naming §5.4 and §6;
node --check. - [ ] Step 3: Node tests in
negotiation.test.mjs(or a newroom-share.test.mjs) for the pure parts liftable by name: the share/camera track classifier, the broadcast lane's body shape (noto/seq/gen), the share tile key. - [ ] Step 4: Catalogue the strings; gate
make ci,node --test, andmake browser(sandbox off) staying 27/27. - [ ] Step 5: Commit
room.js: chat over the broadcast lane; screen share as its own transceiver.
---
Task 5: The browser gate: chat and share
Files:
- Modify:
test/browser/call.mjs,internal/callstest/browser_test.go
Scenarios (§13):
chat: two members connected; a third context signs in as a guest through the link and knocks (waiting). A types "hello from A" in#chat-textand presses Enter: B's#chat-loggains a line with A's name and the text within 2 s; A's own log shows the echo; the guest's page has no#chatvisible andstate().chat.received === 0after 2 s; a route on**/signalsees exactly one chat POST with noto/seq/gen. Then admit the guest and send again: the guest now receives it. Assert no console line carries the text.share: two members connected (frames both ways); the higher-id context clicks#share(the fake display source: launch Chromium with--auto-select-desktop-capture-source=Entire screenor--use-fake-device-for-media-streamcovers only cameras, so add--auto-select-desktop-capture-sourceand, if needed,--auto-accept-this-tab-capture); within 5 s the lower context'sstate().sharescontains the sharer's peer, a second tile labelled "<name>'s screen" exists,frames.get(peer + '#share') > 0, and the camera tile's frames keep rising. Then the sharer clicks#shareagain: within 5 s the share tile is gone,state().sharesis empty, the received track ended (assert through a page-side listener recordingended), and the camera frames still rise. Also assert the sharer'saria-pressedflips and the state span reads Off.- Keep the whole gate under ~8 minutes.
- [ ] Step 1: Write the scenarios in the file's shape (per-scenario restart,
until,check,diagnose,stateflattening, the control channel for the guest link). - [ ] Step 2: Run
make browserwith the sandbox off; both pass; record timings. - [ ] Step 3: Commit
browser gate: chat to the admitted context only; the share round trip.
---
Task 6: Record and spec note
Files:
- Modify:
docs/superpowers/specs/2026-09-07-meet-design.md(§15.15: the chat and share wire —{text}≤ 2048 bytes, 413too-large;{on}; fan-out excludes the sender; the downstream envelope{type, from, name, payload}; the panel's mount API as estilo's adoptable contract; the share transceiver and tile rules) - Create:
docs/superpowers/reviews/2026-09-10-slice-4-record.mdin the slice 3 record's shape.
- [ ] Step 1: Write both;
make ci; commitdocs: slice 4 record and spec note.
---
Self-review
Spec coverage (§12 item 4): chat.js (3), the chat and share messages with their recipient rules (1, 2), display-media tracks and renegotiation from either side (4). §13 slice-4 gates: the captured-log test extended to accepted chat (2); no chat while waiting (2, 5); the share round trip with the received track ending and the camera continuing (5); chat arrives at the admitted context and not at a waiting one (5). §10: the panel is one partial and one script (3).
Placeholders: Tasks 4 and 5 are contract-and-function-list tasks (Opus-tier), as slices 2 and 3 did; Task 1's third test names the fixture to copy.
Type consistency: hub.Fanout, hub.ErrNotFanout (1) used in 2; validateFanout (2); Chat.mount → {append, setEnabled, clear} (3) used in 4; broadcast('chat'|'share', payload) (4); state().chat/sharing/shares (4) read by 5.
Rulings for the controller: the fan-out excludes the sender (the panel echoes locally on acceptance); chat above 2048 bytes is 413 too-large, not 400; share carries only {on} (the tile label comes from the sender's name in the envelope); one Share button, no sharer limit.