| 1 | # Meet slice 4: chat and screen share — Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **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. |
| 10 | |
| 11 | **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). |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - `CGO_ENABLED=0` throughout; `make ci` is the gate on every task; `make race` and `make browser` (sandbox OFF: UDP is blocked inside the Claude Code sandbox; the gate takes about seven minutes; `X-Meet-Test-Ice-TTL` values must be multiples of 6 s) on the tasks that name them. |
| 16 | - No schema change. Chat is not stored, not replayed, not ringed; gone with the tab (§6). |
| 17 | - `chat` text is at most 2048 bytes of UTF-8, refused above with 413 (§4.4); the server reads nothing inside `chat` beyond 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). |
| 18 | - 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). |
| 19 | - A 404 is never a 403; the `/signal` not-found body is unchanged. |
| 20 | - DOM safety: chat text is rendered with `textContent` only; no `innerHTML`, no linkification (§6); peer names come only from the envelope. |
| 21 | - The chat panel is one partial (`internal/calls/templates/partials/chat.html`) and one script (`internal/calls/static/chat.js`) with no dependency on `room.js` internals: `Chat.mount(root, {send})` returns `{append({from, text, at, self}), setEnabled(bool)}`; a second app can adopt both files unchanged. |
| 22 | - Screen share: one `sendonly` video transceiver per connection carrying the display track; stop calls `transceiver.stop()` and renegotiates so the receiver's track **ends**; the receiver removes the share tile on `ended` or `share {on:false}`, whichever first, and the camera tile continues (§5.4). One sharer at a time is not enforced. |
| 23 | - 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.md` under "## Slice 4". |
| 24 | - Screens work without JavaScript; the room page's scriptless paragraph is unchanged; the chat panel is inside `#room` (hidden without script). |
| 25 | - Commits: `git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit`, files by name, never `.claude/`, `node_modules` or `package-lock.json`; trailers `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2`. |
| 26 | |
| 27 | --- |
| 28 | |
| 29 | ## File structure |
| 30 | |
| 31 | - `internal/calls/hub/fanout.go` (+ `fanout_test.go`) — `Fanout`. |
| 32 | - `internal/calls/signal.go` — the `chat`/`share` arms and payload validation; `internal/callstest/signal_test.go` — the accepted-chat log extension; `internal/callstest/chat_test.go` — recipients over HTTP. |
| 33 | - `internal/calls/templates/partials/chat.html`, `internal/calls/static/chat.js`, `internal/calls/static/chat.css` — the panel (estilo's first chat). |
| 34 | - `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. |
| 35 | - `test/browser/call.mjs`, `internal/callstest/browser_test.go` — scenarios `chat`, `share`. |
| 36 | - `docs/superpowers/copy/2026-09-10-slice-2-strings.md`, `docs/superpowers/reviews/2026-09-10-slice-4-record.md`, spec §15.15. |
| 37 | |
| 38 | --- |
| 39 | |
| 40 | ### Task 1: `hub.Fanout`: an untargeted message from an admitted peer to every other admitted peer |
| 41 | |
| 42 | **Files:** |
| 43 | - Create: `internal/calls/hub/fanout.go`, `internal/calls/hub/fanout_test.go` |
| 44 | |
| 45 | **Interfaces:** |
| 46 | - Consumes: `memberStandingGone(ctx, sqid, session, peer, end bool) (bool, error)` (relay.go), `findLocked`, `roomLocked`/`h.rooms`, `broadcastLocked`, `audience`, `Message`, `ErrUnknownPeer`, `ErrNotAdmitted`, `ErrNotYours`, `ErrNotTargeted` (read `Authorize` in authorize.go for the exact shape of the standing step). |
| 47 | - Produces: |
| 48 | ```go |
| 49 | // Fanout delivers m to every admitted peer in the room except the sender. |
| 50 | // Sender: bound to this session, admitted; a member-kind participation |
| 51 | // re-derives standing first (§15.12) and is ended with lost{standing} |
| 52 | // when it is gone (ErrNotYours). m.Type must be chat or share |
| 53 | // (ErrNotFanout otherwise). The envelope is rebuilt: {Type, From, Name, |
| 54 | // Payload}; no To, Seq or Gen; nothing is ringed. |
| 55 | func (h *Hub) Fanout(ctx context.Context, sqid, session string, from PeerID, m Message) error |
| 56 | var ErrNotFanout = errors.New("hub: not a fan-out type") |
| 57 | ``` |
| 58 | |
| 59 | - [ ] **Step 1: Write the failing tests** |
| 60 | |
| 61 | ```go |
| 62 | package hub |
| 63 | |
| 64 | import ( |
| 65 | "context" |
| 66 | "encoding/json" |
| 67 | "errors" |
| 68 | "testing" |
| 69 | "time" |
| 70 | ) |
| 71 | |
| 72 | func TestFanoutReachesEveryOtherAdmittedPeerAndNobodyElse(t *testing.T) { |
| 73 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 74 | h := testHub(&now) |
| 75 | ctx := context.Background() |
| 76 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 77 | h.Join(ctx, "r1", "sa", a.Peer) |
| 78 | b, _ := h.Open("r1", "sb", 2, "Bob", KindMember) |
| 79 | h.Join(ctx, "r1", "sb", b.Peer) |
| 80 | g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 81 | h.Join(ctx, "r1", "sg", g.Peer) // waiting |
| 82 | drain(a.Sub) |
| 83 | drain(b.Sub) |
| 84 | drain(g.Sub) |
| 85 | |
| 86 | err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)}) |
| 87 | if err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | got := events(b.Sub) |
| 91 | 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 { |
| 92 | t.Fatalf("Bob got %+v", got) |
| 93 | } |
| 94 | if n := len(events(a.Sub)); n != 0 { |
| 95 | t.Fatalf("the sender must not receive its own chat, got %d", n) |
| 96 | } |
| 97 | if n := len(events(g.Sub)); n != 0 { |
| 98 | t.Fatalf("a waiting guest must receive nothing, got %d", n) |
| 99 | } |
| 100 | if n := len(peerOf(h, "r1", b.Peer).ring); n != 0 { |
| 101 | t.Fatalf("chat is never ringed, ring has %d", n) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestFanoutNeedsAnAdmittedSenderBoundToThisSession(t *testing.T) { |
| 106 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 107 | h := testHub(&now) |
| 108 | ctx := context.Background() |
| 109 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 110 | msg := Message{Type: TypeChat, Payload: json.RawMessage(`{"text":"hi"}`)} |
| 111 | if err := h.Fanout(ctx, "r1", "sa", a.Peer, msg); !errors.Is(err, ErrNotAdmitted) { |
| 112 | t.Fatalf("unjoined: %v", err) |
| 113 | } |
| 114 | h.Join(ctx, "r1", "sa", a.Peer) |
| 115 | if err := h.Fanout(ctx, "r1", "sb", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) { |
| 116 | t.Fatalf("another session: %v", err) |
| 117 | } |
| 118 | if err := h.Fanout(ctx, "r2", "sa", a.Peer, msg); !errors.Is(err, ErrUnknownPeer) { |
| 119 | t.Fatalf("another room: %v", err) |
| 120 | } |
| 121 | g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| 122 | h.Join(ctx, "r1", "sg", g.Peer) |
| 123 | if err := h.Fanout(ctx, "r1", "sg", g.Peer, msg); !errors.Is(err, ErrNotAdmitted) { |
| 124 | t.Fatalf("waiting guest: %v", err) |
| 125 | } |
| 126 | if err := h.Fanout(ctx, "r1", "sa", a.Peer, Message{Type: TypeOffer}); !errors.Is(err, ErrNotFanout) { |
| 127 | t.Fatalf("a targeted type is not a fan-out: %v", err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestFanoutDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) { |
| 132 | // Copy the fixture from authorize_test.go's standing test (the hub |
| 133 | // whose StandingFor consults a mutable map): revoke Ann's standing, |
| 134 | // Fanout → ErrNotYours, Ann's participation is gone, Bob's stream got |
| 135 | // `gone` naming Ann and no chat. Then an admitted guest past expiry |
| 136 | // (StandingFor !ok for the guest) fans out successfully (ruling 14). |
| 137 | } |
| 138 | ``` |
| 139 | |
| 140 | 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). |
| 141 | |
| 142 | - [ ] **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, `Fanout` undefined. |
| 143 | |
| 144 | - [ ] **Step 3: Implement** (`internal/calls/hub/fanout.go`) |
| 145 | |
| 146 | ```go |
| 147 | package hub |
| 148 | |
| 149 | import ( |
| 150 | "context" |
| 151 | "errors" |
| 152 | ) |
| 153 | |
| 154 | // ErrNotFanout is a Fanout of a type that is targeted or server-raised. |
| 155 | var ErrNotFanout = errors.New("hub: not a fan-out type") |
| 156 | |
| 157 | // Fanout is §4.3's chat and share: an admitted peer to every other |
| 158 | // admitted peer, the envelope rebuilt from the sender's participation |
| 159 | // so from and name can never be forged, nothing ringed or stored (§6). |
| 160 | // The sender's authority is §2.5's last row, exactly as Relay's: a |
| 161 | // member-kind participation re-derives standing first (§15.12) and one |
| 162 | // whose standing is gone is ended here with lost{standing}; a guest |
| 163 | // never consults standing (ruling 14). |
| 164 | func (h *Hub) Fanout(ctx context.Context, sqid, session string, from PeerID, m Message) error { |
| 165 | if m.Type != TypeChat && m.Type != TypeShare { |
| 166 | return ErrNotFanout |
| 167 | } |
| 168 | if gone, err := h.memberStandingGone(ctx, sqid, session, from, true); err != nil { |
| 169 | return err |
| 170 | } else if gone { |
| 171 | return ErrNotYours |
| 172 | } |
| 173 | h.mu.Lock() |
| 174 | defer h.mu.Unlock() |
| 175 | p, ok := h.findLocked(sqid, session, from) |
| 176 | if !ok { |
| 177 | return ErrUnknownPeer |
| 178 | } |
| 179 | if !p.Admitted { |
| 180 | return ErrNotAdmitted |
| 181 | } |
| 182 | r := h.rooms[sqid] |
| 183 | out := Message{Type: m.Type, From: p.Peer, Name: p.Name, Payload: m.Payload} |
| 184 | h.broadcastLocked(r, out, func(q *Participation) bool { return q.Admitted && q.Peer != p.Peer }) |
| 185 | return nil |
| 186 | } |
| 187 | ``` |
| 188 | |
| 189 | (Read `memberStandingGone`'s real return contract and `Relay`'s mapping of it before writing the two lines that use it; mirror `Authorize`.) |
| 190 | |
| 191 | - [ ] **Step 4: Run to see them pass; gate**: `make ci` and `CGO_ENABLED=1 go test -race ./internal/calls/hub/`. |
| 192 | |
| 193 | - [ ] **Step 5: Commit** |
| 194 | |
| 195 | ```bash |
| 196 | git add internal/calls/hub/fanout.go internal/calls/hub/fanout_test.go |
| 197 | git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: Fanout, an untargeted message to every other admitted peer |
| 198 | |
| 199 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 200 | Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| 201 | ``` |
| 202 | |
| 203 | --- |
| 204 | |
| 205 | ### Task 2: `/signal` dispatches `chat` and `share` |
| 206 | |
| 207 | **Files:** |
| 208 | - Modify: `internal/calls/signal.go` (the `case hub.TypeChat, hub.TypeShare` arm and a `validateFanout` helper), `internal/callstest/signal_test.go` (the captured-log test gains an accepted chat and a refused oversize chat), `internal/callstest/sse_test.go` only if a helper is missing |
| 209 | - Create: `internal/callstest/chat_test.go` |
| 210 | |
| 211 | **Interfaces:** |
| 212 | - 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...)`. |
| 213 | - 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: share` with `{type, from, name, payload}`. |
| 214 | |
| 215 | - [ ] **Step 1: Write the failing tests** (`internal/callstest/chat_test.go`) |
| 216 | |
| 217 | ```go |
| 218 | package callstest |
| 219 | |
| 220 | import ( |
| 221 | "net/http" |
| 222 | "strings" |
| 223 | "testing" |
| 224 | "time" |
| 225 | ) |
| 226 | |
| 227 | func TestChatReachesTheOtherAdmittedPeerWithTheSendersNameAndNotTheSender(t *testing.T) { |
| 228 | x, sqid, _ := memberWithMeeting(t) |
| 229 | a := x.browser(t) |
| 230 | a.signIn("paul@example.com") |
| 231 | sa := a.stream("/m/" + sqid + "/stream") |
| 232 | defer sa.close() |
| 233 | pa, _ := sa.hello() |
| 234 | a.signal(sqid, pa, map[string]any{"type": "join"}) |
| 235 | sa.nextOf("roster", 2*time.Second) |
| 236 | b := x.browser(t) |
| 237 | b.signIn("paul@rslw.com") |
| 238 | sb := b.stream("/m/" + sqid + "/stream") |
| 239 | defer sb.close() |
| 240 | pb, _ := sb.hello() |
| 241 | b.signal(sqid, pb, map[string]any{"type": "join"}) |
| 242 | sb.nextOf("roster", 2*time.Second) |
| 243 | sa.nextOf("peer", 2*time.Second) |
| 244 | |
| 245 | res, _ := a.signal(sqid, pa, map[string]any{"type": "chat", "payload": map[string]any{"text": "hello there"}}) |
| 246 | if res.StatusCode != http.StatusOK { |
| 247 | t.Fatalf("chat: %d", res.StatusCode) |
| 248 | } |
| 249 | ev, data, ok := sb.nextOf("chat", 2*time.Second) |
| 250 | if !ok || ev != "chat" { |
| 251 | t.Fatal("Bob did not receive the chat") |
| 252 | } |
| 253 | s := string(data) |
| 254 | 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"`) { |
| 255 | t.Fatalf("envelope %s", s) |
| 256 | } |
| 257 | if ev, _, ok := sa.next(300 * time.Millisecond); ok && ev == "chat" { |
| 258 | t.Fatal("the sender received its own chat") |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestChatNeverReachesAWaitingGuestAndAWaitingGuestCannotSend(t *testing.T) { |
| 263 | x, sqid, _ := memberWithMeeting(t) |
| 264 | a := x.browser(t) |
| 265 | a.signIn("paul@example.com") |
| 266 | sa := a.stream("/m/" + sqid + "/stream") |
| 267 | defer sa.close() |
| 268 | pa, _ := sa.hello() |
| 269 | a.signal(sqid, pa, map[string]any{"type": "join"}) |
| 270 | sa.nextOf("roster", 2*time.Second) |
| 271 | g, gpeer := guestJoinedAndWaiting(t, x, sqid) // its stream is g.s or returned; adapt to the helper's shape |
| 272 | sa.nextOf("knock", 2*time.Second) |
| 273 | |
| 274 | a.signal(sqid, pa, map[string]any{"type": "chat", "payload": map[string]any{"text": "secret"}}) |
| 275 | if ev, data, ok := g.stream.next(500 * time.Millisecond); ok && (ev == "chat" || strings.Contains(string(data), "secret")) { |
| 276 | t.Fatalf("a waiting guest received %s %s", ev, data) |
| 277 | } |
| 278 | res, body := g.signal(sqid, gpeer, map[string]any{"type": "chat", "payload": map[string]any{"text": "let me in"}}) |
| 279 | if res.StatusCode != http.StatusNotFound || !strings.Contains(string(body), `"error":"not-found"`) { |
| 280 | t.Fatalf("a waiting guest's chat must be the usual 404: %d %s", res.StatusCode, body) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | func TestChatLimitsAndShapes(t *testing.T) { |
| 285 | x, sqid, _ := memberWithMeeting(t) |
| 286 | a := x.browser(t) |
| 287 | a.signIn("paul@example.com") |
| 288 | sa := a.stream("/m/" + sqid + "/stream") |
| 289 | defer sa.close() |
| 290 | pa, _ := sa.hello() |
| 291 | a.signal(sqid, pa, map[string]any{"type": "join"}) |
| 292 | sa.nextOf("roster", 2*time.Second) |
| 293 | cases := []struct { |
| 294 | name string |
| 295 | body map[string]any |
| 296 | status int |
| 297 | }{ |
| 298 | {"2048 bytes ok", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2048)}}, 200}, |
| 299 | {"2049 bytes too large", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("a", 2049)}}, 413}, |
| 300 | {"multibyte counted in bytes", map[string]any{"type": "chat", "payload": map[string]any{"text": strings.Repeat("é", 1025)}}, 413}, |
| 301 | {"text not a string", map[string]any{"type": "chat", "payload": map[string]any{"text": 5}}, 400}, |
| 302 | {"extra key", map[string]any{"type": "chat", "payload": map[string]any{"text": "x", "html": "<b>"}}, 400}, |
| 303 | {"empty text", map[string]any{"type": "chat", "payload": map[string]any{"text": ""}}, 400}, |
| 304 | {"share on", map[string]any{"type": "share", "payload": map[string]any{"on": true}}, 200}, |
| 305 | {"share off", map[string]any{"type": "share", "payload": map[string]any{"on": false}}, 200}, |
| 306 | {"share not a bool", map[string]any{"type": "share", "payload": map[string]any{"on": "yes"}}, 400}, |
| 307 | {"chat with a target", map[string]any{"type": "chat", "to": pa, "payload": map[string]any{"text": "x"}}, 400}, |
| 308 | } |
| 309 | for _, c := range cases { |
| 310 | t.Run(c.name, func(t *testing.T) { |
| 311 | res, _ := a.signal(sqid, pa, c.body) |
| 312 | if res.StatusCode != c.status { |
| 313 | t.Fatalf("%s: %d, want %d", c.name, res.StatusCode, c.status) |
| 314 | } |
| 315 | }) |
| 316 | } |
| 317 | } |
| 318 | ``` |
| 319 | |
| 320 | 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. |
| 321 | |
| 322 | - [ ] **Step 2: Run to see them fail** (`go test ./internal/callstest/ -run 'Chat|Logs'`): the chat cases get 400 from the slice-4 stub. |
| 323 | |
| 324 | - [ ] **Step 3: Implement** in `signal.go`: replace the stub arm with |
| 325 | |
| 326 | ```go |
| 327 | case hub.TypeChat, hub.TypeShare: |
| 328 | if body.To != "" { |
| 329 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| 330 | return |
| 331 | } |
| 332 | if status, outcome := validateFanout(body.Type, body.Payload); status != 0 { |
| 333 | a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) |
| 334 | return |
| 335 | } |
| 336 | herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: body.Payload}) |
| 337 | ``` |
| 338 | |
| 339 | with |
| 340 | |
| 341 | ```go |
| 342 | // maxChatBytes is §4.4's 2 KB, counted in bytes of the text, not |
| 343 | // characters: the limit protects the other peers' streams, which carry |
| 344 | // bytes. |
| 345 | const maxChatBytes = 2048 |
| 346 | |
| 347 | // validateFanout checks the two untargeted payload shapes without |
| 348 | // keeping anything from them: chat is exactly {"text": string} with |
| 349 | // 1..2048 bytes; share is exactly {"on": bool}. Returns a status and |
| 350 | // an outcome word for the refusal log, or 0. |
| 351 | func validateFanout(typ string, raw json.RawMessage) (int, string) { |
| 352 | dec := json.NewDecoder(bytes.NewReader(raw)) |
| 353 | dec.DisallowUnknownFields() |
| 354 | switch typ { |
| 355 | case hub.TypeChat: |
| 356 | var p struct { |
| 357 | Text *string `json:"text"` |
| 358 | } |
| 359 | if err := dec.Decode(&p); err != nil || p.Text == nil || dec.More() { |
| 360 | return http.StatusBadRequest, "shape" |
| 361 | } |
| 362 | if len(*p.Text) == 0 { |
| 363 | return http.StatusBadRequest, "shape" |
| 364 | } |
| 365 | if len(*p.Text) > maxChatBytes { |
| 366 | return http.StatusRequestEntityTooLarge, "size" |
| 367 | } |
| 368 | case hub.TypeShare: |
| 369 | var p struct { |
| 370 | On *bool `json:"on"` |
| 371 | } |
| 372 | if err := dec.Decode(&p); err != nil || p.On == nil || dec.More() { |
| 373 | return http.StatusBadRequest, "shape" |
| 374 | } |
| 375 | } |
| 376 | return 0, "" |
| 377 | } |
| 378 | |
| 379 | func fanoutError(status int) string { |
| 380 | if status == http.StatusRequestEntityTooLarge { |
| 381 | return "too-large" |
| 382 | } |
| 383 | return "bad-request" |
| 384 | } |
| 385 | ``` |
| 386 | |
| 387 | Add `hub.ErrNotFanout` to the 400 arm of the error-mapping switch beside `ErrNotTargeted`. Keep `refuseSignal`'s log shape (room, peer, type, outcome only). |
| 388 | |
| 389 | - [ ] **Step 4: Gate**: `make ci`; `CGO_ENABLED=1 go test -race ./internal/...`. |
| 390 | |
| 391 | - [ ] **Step 5: Commit** `signal: chat and share fan out to every other admitted peer` with the trailers; files by name. |
| 392 | |
| 393 | --- |
| 394 | |
| 395 | ### Task 3: The chat panel: estilo's first chat, one partial and one script |
| 396 | |
| 397 | **Files:** |
| 398 | - Create: `internal/calls/templates/partials/chat.html`, `internal/calls/static/chat.js`, `internal/calls/static/chat.css` |
| 399 | - Modify: `internal/calls/templates/room.html` (mount the partial inside `#room`, after `#tiles`/`#controls`, before `#queue`; load `chat.css` and `chat.js` from the head block with `defer`), `internal/calls/app.go` or `render.go` only if partials need registering (check how the vendored `ui/*` partials are parsed and follow it), `internal/callstest/room_test.go` (the panel renders, hidden without script, with its `data-*`), `docs/superpowers/copy/2026-09-10-slice-2-strings.md` ("## Slice 4") |
| 400 | |
| 401 | **Interfaces (the primitive's contract, §6 and §10):** |
| 402 | - Partial `{{template "chat-panel" .}}` renders: |
| 403 | ```html |
| 404 | <section id="chat" class="chat" data-chat aria-label="Chat" hidden> |
| 405 | <h2 class="chat-title">Chat</h2> |
| 406 | <ol id="chat-log" class="chat-log" aria-live="polite" aria-relevant="additions"></ol> |
| 407 | <form id="chat-form" class="chat-form"> |
| 408 | <label for="chat-text" class="chat-label">Message</label> |
| 409 | <textarea id="chat-text" name="text" rows="2" maxlength="2048" rst-input required></textarea> |
| 410 | <button type="submit" id="chat-send" rst-btn>Send</button> |
| 411 | <p id="chat-status" class="chat-status" role="status"></p> |
| 412 | </form> |
| 413 | </section> |
| 414 | ``` |
| 415 | (`hidden` because the whole room is script-only; `room.js` un-hides it on admission.) |
| 416 | - `chat.js` defines `window.Chat = { mount(root, opts) }` with `opts.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() }`. `append` creates `<li class="chat-line"><span class="chat-name">name</span> <time datetime=…>HH:MM</time> <span class="chat-text">text</span></li>` with `textContent` only; 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 with `TextEncoder`). No `innerHTML`, no linkification, no dependency on `room.js`. |
| 417 | - `chat.css` uses the `--rst-*` tokens the theme defines (see `calls.css` for the ones in use); no colour alone for state. |
| 418 | |
| 419 | - [ ] **Step 1: Write the Go test** in `room_test.go`: the room page contains `id="chat"` with `hidden`, `id="chat-log"`, `id="chat-text"` with `maxlength="2048"`, and loads `/static/chat.js` from the head block with `defer` and `chat.css`; verify it fails before the partial exists. |
| 420 | - [ ] **Step 2: Write the partial, the script and the stylesheet to the contract**; `node --check internal/calls/static/chat.js`. |
| 421 | - [ ] **Step 3: A node test** `test/browser/chat.test.mjs` that lifts `append`'s line builder (or the byte counter) out of `chat.js` by name and proves: text is set with `textContent` (an angle bracket survives as text), the 500-line cap, the byte counter on multibyte input. Wire it into the Makefile's `browser` target beside `negotiation.test.mjs`. |
| 422 | - [ ] **Step 4: Catalogue the strings** ("Chat", "Message", "Send", "Could not send. Try again.", "Message is too long."). |
| 423 | - [ ] **Step 5: Gate** `make ci`; commit `chat: the panel partial and script, estilo's first chat` with the trailers. |
| 424 | |
| 425 | --- |
| 426 | |
| 427 | ### Task 4: `room.js`: chat wiring and screen share |
| 428 | |
| 429 | **Files:** |
| 430 | - 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` |
| 431 | |
| 432 | **Interfaces (the client contract):** |
| 433 | - **Broadcast lane.** `send(to, …)` is per-target with `seq`/`gen`; chat and share are untargeted. Add `broadcast(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 **no** `to`, `seq` or `gen` in the body; resolves true on 200. `stale`/`gen` logic does not apply to `'*'`. |
| 434 | - **Chat.** On admission (`state.admitted` becomes true), `Chat.mount(document.getElementById('chat'), {send: (text) => broadcast('chat', {text})})`, un-hide `#chat`, `setEnabled(true)`; on `handle('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})`. On `terminal`/`leave` → `setEnabled(false)`. A `chat` from a peer not in the roster is still rendered (the name is in the envelope; the hub guaranteed admission). |
| 435 | - **Screen share (§5.4).** `#share` toggles: on → `navigator.mediaDevices.getDisplayMedia({video: true, audio: false})`; store `share = {stream, track}`; for every live `pcs` entry `entry.shareTx = pc.addTransceiver(track, {direction: 'sendonly', streams: [stream]})` (the label is carried by the `share` message, not the stream); perfect negotiation carries the offer; `broadcast('share', {on: true})`; `aria-pressed` and the state span flip. The display track's own `ended` event (the browser's "stop sharing" bar) runs the same stop path. Off → for every entry `entry.shareTx.stop()` (fallback `sender.replaceTrack(null)` + `removeTrack` when `stop` is unsupported), renegotiation follows, `broadcast('share', {on: false})`, stop the display track. New connections created while sharing get the transceiver in `createPC`. |
| 436 | - **Receiving.** In the `track` handler, 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 with `ev.streams[0].id !== entry.stream.id`, or as a track whose transceiver `mid` is not the camera's — record `entry.cameraMid` from the first video transceiver) becomes `entry.share = {stream, track}` and a second tile keyed `peer + '#share'` labelled "<name>'s screen" (the `share {on:true}` message sets `entry.sharing = true` so the tile can be labelled even if the message precedes the track); on the track's `ended` **or** `share {on:false}`, whichever first, remove the share tile and clear `entry.share`; the camera tile is untouched. Frames on the share tile are counted like the camera's (`watchFrames`) under `frames.get(peer + '#share')`. |
| 437 | - `state()` gains `chat: {sent, received}`, `sharing: bool`, `shares: [peer ids with a share tile]`, and `frames` entries for share tiles. |
| 438 | - Strings: "Share screen", "On"/"Off" state, "<name>'s screen" caption, and a status sentence when `getDisplayMedia` is refused: "Screen sharing was not allowed." |
| 439 | |
| 440 | - [ ] **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, and `task-8-handoffs.md`.** |
| 441 | - [ ] **Step 2: Implement to the contract** in named functions with a header comment naming §5.4 and §6; `node --check`. |
| 442 | - [ ] **Step 3: Node tests** in `negotiation.test.mjs` (or a new `room-share.test.mjs`) for the pure parts liftable by name: the share/camera track classifier, the broadcast lane's body shape (no `to`/`seq`/`gen`), the share tile key. |
| 443 | - [ ] **Step 4: Catalogue the strings; gate** `make ci`, `node --test`, and `make browser` (sandbox off) staying 27/27. |
| 444 | - [ ] **Step 5: Commit** `room.js: chat over the broadcast lane; screen share as its own transceiver`. |
| 445 | |
| 446 | --- |
| 447 | |
| 448 | ### Task 5: The browser gate: `chat` and `share` |
| 449 | |
| 450 | **Files:** |
| 451 | - Modify: `test/browser/call.mjs`, `internal/callstest/browser_test.go` |
| 452 | |
| 453 | **Scenarios (§13):** |
| 454 | - `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-text` and presses Enter: B's `#chat-log` gains 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 `#chat` visible and `state().chat.received === 0` after 2 s; a route on `**/signal` sees exactly one chat POST with no `to`/`seq`/`gen`. Then admit the guest and send again: the guest now receives it. Assert no console line carries the text. |
| 455 | - `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 screen` or `--use-fake-device-for-media-stream` covers only cameras, so add `--auto-select-desktop-capture-source` and, if needed, `--auto-accept-this-tab-capture`); within 5 s the lower context's `state().shares` contains 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 `#share` again: within 5 s the share tile is gone, `state().shares` is empty, the received track ended (assert through a page-side listener recording `ended`), and the camera frames still rise. Also assert the sharer's `aria-pressed` flips and the state span reads Off. |
| 456 | - Keep the whole gate under ~8 minutes. |
| 457 | |
| 458 | - [ ] **Step 1: Write the scenarios** in the file's shape (per-scenario restart, `until`, `check`, `diagnose`, `state` flattening, the control channel for the guest link). |
| 459 | - [ ] **Step 2: Run `make browser`** with the sandbox off; both pass; record timings. |
| 460 | - [ ] **Step 3: Commit** `browser gate: chat to the admitted context only; the share round trip`. |
| 461 | |
| 462 | --- |
| 463 | |
| 464 | ### Task 6: Record and spec note |
| 465 | |
| 466 | **Files:** |
| 467 | - Modify: `docs/superpowers/specs/2026-09-07-meet-design.md` (§15.15: the chat and share wire — `{text}` ≤ 2048 bytes, 413 `too-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) |
| 468 | - Create: `docs/superpowers/reviews/2026-09-10-slice-4-record.md` in the slice 3 record's shape. |
| 469 | |
| 470 | - [ ] **Step 1: Write both; `make ci`; commit `docs: slice 4 record and spec note`.** |
| 471 | |
| 472 | --- |
| 473 | |
| 474 | ## Self-review |
| 475 | |
| 476 | **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). |
| 477 | |
| 478 | **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. |
| 479 | |
| 480 | **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. |
| 481 | |
| 482 | **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. |
| 483 | |