oficina / meet Public

Merge branch 'main' into feat-web-native-consistency

The branch was cut against 9225ec2 and main has since gained mid-call
device switching, which lands in the same place: both rewrite the
room's controls block. Resolved by keeping the branch's structure —
the call workspace, the stage, the Focus/Grid segmented control, the
chat sidebar toggle, icons on every control — and putting the Devices
button and its panel inside it.

The panel takes a row of its own under the controls that opened it
(`grid-column: 1 / -1`, and row 3 once the sidebar exists). The
alternative was the sidebar column, where it would argue with chat
over 20rem for three selects and a sentence.

Two small things the resolution needed. The Devices button wanted an
icon like every other control in the row, so icons.go gains Lucide's
sliders-horizontal. And the branch's `.meet-pickers > div` makes every
picker a ~12rem flex item, which is right for the three selects and
wrong for the studio switch — its sentence of explanation sets in a
ribbon at that width — so `.meet-pickers > .meet-raw-row` takes its
line back, by specificity rather than by order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 772c1e06581abbf979b453b1dfcc5ab51e72e1b7 parent 527e7e4parent b6e8f29
20 files changed, +2196 −57
  • AGENTS.md +24 −5
  • Makefile +2 −0
  • PRODUCT.md +269 −0
  • README.md +175 −0
  • docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md +157 −0
  • docs/superpowers/reviews/2026-09-14-slice-6-record.md +209 −0
  • docs/superpowers/specs/2026-09-07-meet-design.md +128 −40
  • internal/meet/icons/icons.go +6 −5
  • internal/meet/static/devices.js +90 −2
  • internal/meet/static/meet.css +43 −0
  • internal/meet/static/room.js +577 −3
  • internal/meet/templates/join.html +8 −0
  • internal/meet/templates/room.html +27 −0
  • internal/meettest/browser_test.go +1 −0
  • internal/meettest/holding_test.go +40 −0
  • internal/meettest/room_test.go +47 −0
  • test/browser/audio.test.mjs +130 −0
  • test/browser/call.mjs +107 −0
  • test/browser/devices.test.mjs +116 −0
  • test/browser/room-lane.test.mjs +40 −2

CI failed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 3bc2096..a163561 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,11 +50,13 @@ mechanically.
a rename is indistinguishable from a drop plus an add.
- Money is integer cents. A float never touches a value a person will
be held to.
-- Screens work with JavaScript disabled, with one exception: browser
+- Screens work with JavaScript disabled, with two exceptions: browser
media participation (the holding screen's camera preview and join
- button, and the call itself in the room) needs script; both pages
- render their title, standing, floor notice and navigation without it.
- Destructive actions get their own confirm-page URL.
+ button, and the call itself in the room), and, once a call exists,
+ the room's chat panel and its admission queue's Admit controls; both
+ pages still render their title, standing, floor notice and
+ navigation without script. Destructive actions get their own
+ confirm-page URL.
- The gate is `make ci` (vet + gofmt + tests) — the same definition
CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
stack is cgo-free by design.
@@ -81,9 +83,26 @@ the golden deployment target, not a requirement: `./app -addr :8080`
serves anywhere. `GET /healthz` and `GET /api/version` are answered
by the framework, outside app middleware.
+## Deploying
+
+`make release VERSION=<sha>` builds and stamps the artifact; `carlos
+deploy --app meet --account oficina --channel edge --version <sha>
+--target linux-arm64 releases/meet-linux-arm64` ships and promotes it,
+waiting until `/api/version` serves it — but only once `carlos apps
+build-probe --app meet --account oficina /api/version` has been run
+for the app (set on the flagship app since 2026-09-09); a new app or
+account needs that first, or the wait watches `X-Carlos-Version`
+instead, which can report the version adopted before the process has
+actually changed. A migration applied under an earlier filename fails
+at boot; otherwise migrations never re-run. Every deploy, and every
+relay config change, restarts the instance and drops live calls — no
+drain exists. The wait can time out before
+hand-over — check `/api/version` before retrying. See the README's
+Deploying section for the canary recipe.
+
## UX conventions (seeded from profile: considered)
-Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the one exception named above: browser media participation, on the holding screen and in the room.
+Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the exceptions named above: browser media participation, on the holding screen and in the room, and, once a call exists, the room's chat panel and admission controls.
These are rastrillo's own conventions, and the profile is named for what it does rather than after anyone else's work. For wider reading on interface quality, impeccable.style, the WAI-ARIA Authoring Practices and Inclusive Components are all worth your time — none of them is endorsed by, or authoritative for, this framework.
diff --git a/Makefile b/Makefile
index 70bb573..d542b79 100644
--- a/Makefile
+++ b/Makefile
@@ -149,6 +149,8 @@ browser:
node --test test/browser/room-share.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
+ node --test test/browser/audio.test.mjs
+ node --test test/browser/devices.test.mjs
npm install --no-save playwright@1.62.1
npx playwright install chromium
go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 0000000..871ef8e
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,269 @@
+# Product
+
+<!-- impeccable:product-schema 1 -->
+
+> **How this file was written.** Written unattended, on the same
+> instruction that produced `oficina/docs`' `PRODUCT.md`: every fact
+> below comes from repository evidence, not an interview —
+> `docs/superpowers/specs/2026-09-07-meet-design.md` (revision 9, plus
+> the 2026-09-10 and 2026-09-12 amendments), `AGENTS.md`, the slice
+> records under `docs/superpowers/reviews/`, `native/README.md`, and the
+> shipped code in `internal/meet/` on branch `slice/6-deploy`, main
+> `9225ec2`. Facts are cited to a spec section or a file. A reading
+> rather than a citation is marked **[inferred — confirm]** and should
+> be corrected, not trusted. A bare "ruling N" is the Meet spec's §1; a
+> doctrine ruling is always written "suite ruling N".
+
+## Platform
+
+Web, plus native Mac and iPhone/iPad clients over the same server
+contracts (spec §8.4's 2026-09-14 amendment, `native/README.md`).
+
+## Users
+
+**Primary: a member of one team**, making and joining that team's video
+calls. The instance boundary is the team — one instance per team
+(preamble; suite ruling 1, 5, §0.2), the "lone shape" until a suite home
+exists (§7.1). **Secondary: an outside guest**, admitted with a real
+identity, never as an anonymous URL holder: every participant signs in,
+and a meeting link gives an outsider a **guest grant**, never a way in
+without a name (ruling 1; suite ruling 15); a guest waits until an
+admitted member lets them in (ruling 9, §3.3).
+
+Access is a magic link gated by `MEET_ALLOWED_ADDRESSES`
+(`internal/meet/config.go`), with optional Home sign-in when an operator
+sets all four `MEET_HOME_*` settings (§7.1.1) — Home only *links* an
+existing member after independent local sign-in; it never creates a
+person or grants membership. Unlike Docs' §8.1, the spec states no
+passkey ruling of its own. **[inferred — confirm]** the team is assumed
+small enough that an address allowlist is appropriate; the spec states
+no target size.
+
+## Product Purpose
+
+Meet is **video calling for one Oficina team**: a member makes a
+meeting, sends one link, and the people who follow it set up camera and
+microphone, wait to be let in if not on the team, and talk (preamble).
+"The instance carries the pages, the sign-in, the signalling and the
+in-call chat, and never a frame of media" (§0.1) — falsifiable: publish
+the SQLite file, its WAL, and every log line, and the worst anyone
+learns is the shape of the team's meetings and who minted what, never a
+title, chat text, or signalling payload, enforced by a WAL-grep test and
+a captured-logger test, not merely asserted (§0.1).
+
+## Positioning
+
+1. **The claim is made per process, not once** (§0.1): Meet's own
+ process never touches a media packet; the platform TURN relay
+ forwards encrypted DTLS-SRTP it cannot read; a future platform SFU
+ decrypts to forward and gets its own spec revision when it ships
+ (§5.7).
+2. **No relay of its own, by ruling** (suite ruling 6, §5.2): present
+ `CARLOS_RELAY` mints ICE credentials per call; absent means host
+ candidates only, stated before the join button (§5.5).
+3. **Two authorities, not one, for a room.** *Standing* (§2.3, computed
+ per request, never stored) decides who may **enter**; a
+ *participation* (§2.4, hub state) decides who may **continue** — an
+ admitted guest keeps talking past its grant's expiry while a member
+ who loses standing is dropped at once (ruling 14).
+
+## Operating Context
+
+- **One Go binary per instance**, `CGO_ENABLED=0`, deployed on CARLOS as
+ a **unit route** (§7.3). Native Apple bindings under `native/` are the
+ one place cgo is used, isolated in `native/go.mod` (`AGENTS.md`).
+- **Routes today** (`internal/meet/app.go`): `GET /`, `POST /meetings`;
+ `GET /j/{token}` (outside the session group); the holding screen and
+ room; signalling (`stream`, `signal`, `ice`); `POST /m/{sqid}/share`
+ (2026-09-12); five destructive confirmation pairs (remove, takeover,
+ replace invitation, end meeting, revoke agent); Settings and agent
+ tokens; sign-in including optional Home routes; native handoff
+ endpoints; a bearer-only `/api/v1/*` group (`api.go`) outside CSRF and
+ the session group. `GET /healthz`/`GET /api/version` answer from
+ rastrillo itself, outside app middleware.
+- **Scriptless, with browser media participation as the exception, and
+ two script-only parts inside a live room.** Reaching a camera and
+ another browser needs script; so, once a call exists, do the chat
+ panel (`partials/chat.html` renders it `hidden` until script un-hides
+ it) and the admission queue's Admit controls (`room.js`). Every other
+ screen — the list, creating a meeting, a link shown once, every
+ confirm page, sign-in, `/welcome`, the agent token page, the Share
+ page and the Home account-link screens (§7.1.1), and the
+ server-rendered parts of the holding screen and room — works with
+ scripts off (§9; `AGENTS.md`'s UX line carries the media exception;
+ full list below).
+- **Nothing stored beyond a meeting's bones.** Rooms, peers, admission,
+ chat and presence live in an in-process hub and die with the process
+ (§2.2); a restart drops every live call (§4.5). Stored: `persons`,
+ `meetings` (sealed title, `internal/meet/seal/`), `meeting_links`
+ (hash-indexed plus a sealed copy for sharing since 2026-09-12),
+ `pending_joins` (a keyed address fingerprint, never plaintext),
+ `guest_grants`, `agents` (a member's minted principal; the token
+ itself is never stored, only its SHA-256) (models.go; §2.1).
+
+## Capabilities and Constraints
+
+**Shipped:** meeting creation and listing; a link shown once, replaced
+or shared via `POST /m/{sqid}/share`; sign-in with the pending-join
+exception admitting an outsider only while holding a live link (ruling
+12, §3.2); optional Home account-linking (§7.1.1); the holding screen
+and a P2P mesh room capped at four with perfect negotiation, resume, and
+a replay ring (§4); relay-minted ICE credentials renewed before deadline
+(§5.2, §5.3); ephemeral chat and screen share (§6, §5.4); a bearer-token
+drive surface for agents re-checking operator membership on every
+request (§8, §15.17); native Mac and iPhone/iPad apps sharing the Go
+protocol core (`native/README.md`).
+
+**Deliberately not built yet** (§11, as amended): see "What is
+deliberately not here" below for the full, cited list.
+
+**Binding technical constraints:**
+
+- **Media never touches Meet's own process** (§0.1); no handler accepts
+ a media packet. **`scope.Owned`, no bound request body**: every
+ user-owned query goes through it, reads and writes alike; a row that
+ isn't yours 404s, never 403s (`AGENTS.md`).
+- **Standing is computed, never cached** — every request, every
+ 15-second hub tick (§2.3, §2.4). A shipped migration is never edited.
+ The agent API's rate limit keys on the resolved agent, not the client
+ address, deliberately (§15.17; see below).
+- **`CGO_ENABLED=0 make ci` is the server's cgo-free gate.** Two
+ things run under `CGO_ENABLED=1` and never ship: `make race`, for
+ any change to `internal/meet/hub` or the stream, and `make
+ native-test`'s own `meetcore` race sub-step. The native Mac and
+ iPhone/iPad apps are the one place real cgo does ship — `MeetBridge`
+ is a binary target `MeetKit` links (`native/apple/Package.swift`,
+ `native/scripts/build-mac.sh`) — while every server release stays
+ `CGO_ENABLED=0` (`AGENTS.md`). **`make browser`** needs UDP, which
+ the Claude Code bash sandbox blocks (`AGENTS.md`).
+
+## Brand Commitments
+
+- **Name:** Meet, `amadan.net/oficina/meet`, first served at
+ `meet.oficina.works` (preamble; ruling 2 chose `oficina/meet` over
+ `oficina/conferencia`). Built beside Docs, Calendar, Slides and the
+ suite doctrine, consuming a decision made there rather than restating
+ it (preamble, §0.2).
+- **The theme is Estilo's**, adopted at whatever Estilo holds when each
+ slice lands. As recorded on 2026-09-14 (`git ls-remote --tags`
+ against `oficina/estilo`, empty at that check), no tag exists;
+ consumed at a pseudo-version, already adopted by the Home integration
+ on main. **[inferred — confirm]** the exact commit at any later date.
+- **The chat panel is Meet's contribution to Estilo** — "one
+ self-contained template partial and one script file so a second app
+ can adopt it," the suite's first chat, built to be taken (§6).
+- **Voice: [inferred — confirm].** Copy review was waived for this
+ documentation round (steering on `oficina/suite` discussion 5), so
+ shipped strings were not checked against a review record.
+
+## Evidence on Hand
+
+- **Spec:** revision 9 plus the 2026-09-10 and 2026-09-12 amendments,
+ 2100+ lines, six rounds of Codex review (§14). **Plans:**
+ `docs/superpowers/plans/`, one programme (slices 2–6) plus a plan per
+ slice. **Slice records:** `docs/superpowers/reviews/`, one per slice
+ 2–5 (room, relay, chat/screen-share, drive surface), each an index
+ into the spec's own §15 rulings for that slice.
+- **Native:** `native/README.md`, `native/DESIGN.md`,
+ `native/VERIFICATION.md` — build, gates, stated acceptance limits (no
+ real-device pass claimed for camera permissions, audio-device
+ switching, sleep/wake, or Home passkeys).
+- **What does not exist and must not be invented:** customers,
+ testimonials, benchmarks, pricing, usage numbers, launch date, press,
+ or a production promotion from this slice — edge
+ (`meet.oficina.works`) is promoted by hand by Paul, separately from
+ documentation (slice 6 plan, "What slice 6 was"). The acknowledgements
+ page and the Estilo theme pin are the other two slice-6 items with a
+ disposition rather than a delivery; spec §15.18 is the record for all
+ three.
+
+## Product Principles
+
+1. **State the degradation rather than let it be discovered.** The
+ no-relay floor says calls usually connect only on the same network
+ (§5.5); §0.3 states what the privacy claim excludes (signalling in
+ flight, a replay ring of up to 32 messages for 30 seconds, a link as
+ a bearer); the mail-budget residue is written down, not claimed away
+ (§3.2).
+2. **A verification that cannot distinguish "no" from "I can't see it"
+ is not a verification.** The §0.1 claim is enforced by grepping raw
+ SQLite bytes and the WAL and capturing the logger during a real call;
+ `make browser` asserts a received track by `ontrack` and frame count,
+ never a label (§13).
+3. **Two authorities are named because one would be wrong in some
+ direction** (§2.3–§2.4, ruling 14): a single authority either drops
+ an admitted guest at the grant's two-hour mark or lets an expired
+ grant re-enter.
+4. **A contract is written for the next client, not the current one.**
+ Signalling, `/ice` and the meeting API are contracts a native client
+ could speak (ruling 15, §8.4); the native apps are that ruling paying
+ off.
+5. **Actors are `person:<id>` or `agent:<id>`, never fabricated**
+ (§7.2, doctrine ruling 9).
+
+## Accessibility & Inclusion
+
+`AGENTS.md`'s vendored UI conventions apply: labelled fields with one
+error envelope, destructive actions on their own confirm-page URL and
+never a modal (`ui/confirm-form` — §3.1, §3.5, §4.2), state shown with a
+text label and not colour alone, and empty states that say what the
+screen is for.
+
+**The scriptless floor is the accessibility constraint this app adds
+beyond that shared list.** Reaching a camera and another browser needs
+script, and so, once a call exists, do the room's chat panel and its
+admission-queue Admit controls; nothing else does. With scripts off
+the holding screen's join button becomes one paragraph saying a call
+needs JavaScript and everything else on the page works without it
+(§3.3); the room does the same (§3.4).
+
+No target locale count or WCAG conformance level is stated in the Meet
+spec or `AGENTS.md`. **[inferred — confirm]**: Docs states "WCAG 2.2 AA,
+no exemptions" for itself; nothing here says the same target binds Meet,
+and this file does not assume it does.
+
+## What is deliberately not here
+
+- **Scriptless media, chat and admission** — the exceptions to the
+ scriptless floor: reaching a camera and another browser needs script,
+ and so, once a call exists, do the room's chat panel and its
+ admission-queue Admit controls; every other screen, including the
+ server-rendered parts of the holding screen and the room, works with
+ scripts off (spec §9; `AGENTS.md`'s UX-conventions line carries the
+ media exception in the same words).
+- **No STUN or TURN server of Meet's own.** ICE servers come from the
+ platform relay, present or not at all — never a server Meet names
+ itself (suite ruling 6, §5.2, §5.5).
+- **No SFU, and the room is capped at four admitted peers** — not a
+ claimed product limit, the mesh's own (ruling 5), lifted only when a
+ platform SFU that decrypts to forward ships, in its own spec revision,
+ not a quiet cap change (§5.7, §0.3, §11 "rooms above four").
+- **No client-address limiter on the drive surface.** The agent API's
+ per-agent rate limit (one request a second sustained, burst 60) keys
+ on the resolved agent, not the client address: behind the platform's
+ relay socket every request can share one address, and an
+ address-keyed limiter would let one flood take out every real agent
+ at once (§15.17).
+- **No passkeys ruling of its own.** Unlike Docs §8.1, the spec states
+ no instance-local-passkey decision. **[inferred — confirm]**.
+- **Chat is never stored.** A `chat` message fans out to admitted peers
+ and renders client-side; not replayed to a peer who joins or resumes
+ later, gone with the tab (ruling 8, §6).
+- **No recurring meetings, Calendar integration, recording, attendance
+ records, reactions, dial-in, captions, multi-host signalling,
+ push-to-talk, or virtual backgrounds, and no way to deny a knock** — a
+ guest who isn't admitted simply waits or leaves (§11).
+- **No native client sign-in method of its own** — a native client
+ authenticates through the same Home/local sign-in the browser uses,
+ handed off through a five-minute, one-use authorization; no separate
+ bearer or token scheme for native apps (§8.4's 2026-09-14 amendment,
+ `native/README.md`).
+ **No attendance claim, ever**: §0.3 says Meet does not record who was
+ in a call or for how long; an earlier revision implied one and the
+ spec dropped the implication rather than building the model.
+
+---
+
+*See also:* the design spec, `README.md`, `AGENTS.md`, and the slice
+records under `docs/superpowers/reviews/`.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..53753fb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,175 @@
+# Meet
+
+Meet is video calling for one team. A member makes a meeting, now or
+for later, and sends one link; anyone who follows it sets up their
+camera and microphone on a holding screen, waits to be let in if they
+are not on the team, and talks in a room of up to four, with chat
+beside the call and a screen to share. An agent can also create,
+list, relink and end meetings over an API. Every screen works with
+JavaScript disabled except browser media participation — the camera
+preview and join button on the holding screen, and the call itself in
+the room — and, once a call exists, the room's two other script-only
+parts: the chat panel and the live admission queue with its Admit
+controls. Both pages still render their title, start time, standing,
+floor notice, the paragraph that says what needs script, and a
+member's navigation with scripts off (spec §9).
+
+ICE servers come from the CARLOS platform relay, delivered as
+`CARLOS_RELAY` and minted fresh for each call at `GET /m/{sqid}/ice`;
+Meet names no STUN or TURN server of its own (suite ruling 6). With no
+relay configured, calls are direct only: two browsers connect when ICE
+can find a working path straight between them, which the holding
+screen states plainly above the join button: "This team's Meet has no
+relay yet. Calls usually connect only between people on the same
+network." Enabling or changing the relay restarts the instance. When
+the relay is in the path it forwards DTLS-SRTP packets encrypted
+between the browsers, so it carries bytes it cannot read; Meet's own
+process never receives a media packet at all. With the relay on, a
+call that still fails says "could not connect, even through the
+relay" and offers retry; it does not diagnose the network. One known
+cause is a network that allows only TCP 443, because the relay's TLS
+listener is deferred by the platform — a possibility, not a verdict
+(spec §0.1, §5.2, §5.5, §5.6, §7.3, §15.13).
+
+## Sign-in
+
+Sign-in is a magic link to an address on the team's allowlist
+(`MEET_ALLOWED_ADDRESSES`). Following a meeting link gives an outsider
+a guest grant at sign-in — never a way into a room without a name —
+and the allowlist otherwise gates sign-in exactly as it does for a
+member (spec §3, §7.1). An operator can also enable Home sign-in with
+all four of `MEET_HOME_ISSUER`, `MEET_HOME_CONSUMER_ID`,
+`MEET_HOME_KEYS_BASE64` and `MEET_HOME_DEK_BASE64` — never a partial
+set — which links an existing member after a fresh, independent local
+sign-in and explicit confirmation; it never creates a person or grants
+membership on its own (spec §7.1.1). Because a member's address can be
+rate-limited on purpose and gets a different answer from an unknown
+one, whether a given address is on the team is not a secret the
+sign-in form can keep; that residue is stated rather than claimed away
+(spec §3.2).
+
+## The room
+
+The room is a peer-to-peer mesh of up to four admitted participants,
+negotiated with the W3C perfect-negotiation pattern over an SSE stream
+and a POST signal per peer. A guest waits in a queue until an admitted
+member lets them in; removing a participant and ending a meeting each
+go through their own confirm page — there is no in-room control that
+skips one (spec §3.5). A member can retrieve the current invitation
+again from the holding screen or the room with Share (`POST
+/m/{sqid}/share`). On a meeting whose only link predates the
+2026-09-12 sharing update, the first Share mints a new recoverable
+invitation alongside the old, still-valid one rather than opening it;
+every Share after that retrieves the same recoverable one. Replacing a
+link revokes every live one. Chat is fanned out live to admitted peers
+and never stored (spec §6). Screen share adds a second tile per sharer
+(spec §5.4). The room lives in one process's memory, so every deploy
+drops every live call (spec §15.11).
+
+## Agents
+
+A member mints an agent's bearer token from Settings; it is shown once
+and the row after it keeps only its hash. The token works on
+`/api/v1` only — never a cookie there, and never a bearer on an HTML
+route — for five operations: list meetings, create one, replace its
+link, end it, and read one meeting's detail. It cannot join a call
+(spec §8). An agent's reach is re-derived from its operator's current
+standing on every request, so an operator who leaves the team takes
+their agent's reach with them at once, with no separate revocation
+step. Requests are limited per agent to one a second sustained, with a
+burst of 60 (spec §8, §15.17).
+
+## Native apps
+
+The SwiftUI Mac and iPhone apps use this same server and a shared Go
+core (`meetcore/`, `protocol/`); there are no embedded webviews. Build
+the Mac app with `make native-mac`. See `native/README.md` and
+`native/ios/README.md` for setup, native sign-in and the native gates.
+
+## Building and gates
+
+`CGO_ENABLED=0 make ci` (vet, gofmt, tests, migration check) is the
+one gate a runner and a push share; `ci` itself does not set the
+variable, so the runner and every push must. Three things ship or run
+under `CGO_ENABLED=1`, each isolated from the server: `make race`
+builds a server test binary, never a release, for any change to
+`internal/meet/hub` or the signalling stream; `make native-test`'s own
+`meetcore` race sub-step does the same for the shared Go core; and the
+native Mac and iPhone/iPad bindings under `native/` bridge Go to
+Objective-C with real cgo, isolated in `native/go.mod` and built with
+`make native-mac`. `make browser` drives a real call across two real
+Chromium contexts, 31 scenarios in about nine minutes; it needs the
+Claude Code bash sandbox off, because the sandbox blocks UDP and every
+call then fails with `frames` stuck at 0. `make release VERSION=<sha>`
+stamps that version into `/api/version`, so a deploy can be checked
+against what it is meant to be running.
+
+## Deploying
+
+Meet deploys as a CARLOS unit route. Build the artifact first — `make
+release VERSION=<sha>` produces `releases/meet-linux-arm64`, stamped
+with the version `/api/version` will report (`strings -a
+releases/meet-linux-arm64 | grep -cx <sha>` confirms the stamp) — then
+deploy it: `carlos deploy --app meet --account oficina --channel edge
+--version <sha> --target linux-arm64 releases/meet-linux-arm64`,
+which waits until `/api/version` reports it — but only once a build
+probe is configured for the app: `carlos apps build-probe --app meet
+--account oficina /api/version`, set on the flagship app since
+2026-09-09. A new app or account needs that command run once before
+the wait means anything; without it `carlos deploy` watches
+`X-Carlos-Version` instead, which can report the new version adopted
+before the running process has actually changed. `--account` is a
+global flag; `carlos ship` uploads only, while `carlos deploy` is ship,
+promote and wait (its own help text). Building for your own
+architecture and shipping that uploads fine and fails to exec on the
+instance.
+
+These are the settings a deploy needs (`config.go`, `standing.go`).
+Five refuse startup when empty outside `-env development`:
+`MEET_ORIGIN` (the external origin, scheme included);
+`MEET_INSTANCE_KEY`; `MEET_DEK` (base64 of exactly 32 decoded bytes,
+not 32 base64 characters); `MEET_ALLOWED_ADDRESSES` (comma-separated,
+non-empty — this is the team); and mail, `MEET_SMTP_*` or the
+platform's `CARLOS_SMTP_*`. Two are optional, each with its own
+default rather than a refusal: `MEET_ZONE` (an IANA zone, defaulting
+to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 —
+only a *malformed* value refuses startup), delivered by `carlos relay
+enable --app meet` rather than set by hand. Secrets —
+`MEET_INSTANCE_KEY`, `MEET_DEK` and `MEET_SMTP_PASS` — go through
+`carlos secrets`; the rest go through `carlos env`. Mail reads
+`MEET_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the
+platform supplies a sender too
+(`CARLOS_SMTP_FROM`), which `MEET_SMTP_FROM` can optionally override,
+so `carlos email enable` alone is enough to send mail. `carlos relay
+enable --app meet` turns on the platform relay. Every deploy, and
+every relay change, restarts the instance and drops every live call —
+no drain exists, so deploy outside meeting hours. Migrations apply
+once each at boot (`msg=migrate` in the log) and are never re-run; a
+database that already applied a migration file under an earlier name
+fails at boot rather than silently diverging. The deploy wait can time
+out before the hand-over while the instance then serves the new
+version a minute later — check `/api/version` before retrying.
+
+Canary a build on its own host: `carlos canary --app meet --name
+slice2` composed `slice2.meet.bds.oncarlos.com`, and the console
+refused it ("invalid host, an instance host is one DNS label followed
+by .bds.oncarlos.com") — as recorded on 2026-09-10 in platform
+discussion #124, open as of that record. The recipe actually used:
+`carlos instances create --app meet --account oficina --host
+slice2-meet.bds.oncarlos.com --channel canary/slice2 --environment
+slice2`. The `slice2` environment bundle must already exist, created
+separately, carrying `MEET_ORIGIN` for the canary host; without it
+sign-in refuses on the canary while GETs look healthy. Then `carlos
+deploy --account oficina --app meet --channel canary/slice2 --host
+slice2-meet.bds.oncarlos.com --version <sha>
+releases/meet-linux-arm64`.
+
+## Where things are written down
+
+The design lives in `docs/superpowers/specs/2026-09-07-meet-design.md`,
+the slice plans in `docs/superpowers/plans/`, and each slice's review
+in `docs/superpowers/reviews/`. `PRODUCT.md` covers what Meet is for
+and what it deliberately leaves out; `AGENTS.md` covers the
+conventions the code is held to. Spec §15.18 is the disposition of
+record for what slice 6 delivered, deferred (the acknowledgements
+page) and declined (the Estilo theme pin, the production promotion).
diff --git a/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md b/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md
new file mode 100644
index 0000000..dbbce1a
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md
@@ -0,0 +1,157 @@
+# Meet Slice 6: Deploy Docs 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:** Close the Meet programme (spec §12 item 6) with the documents a deployed app owes: README, PRODUCT.md, the *Consumed suite rulings* commit for suite Appendix A.6, AGENTS.md's final notes, and the slice record, each true of main as it is on 2026-09-14.
+
+**Architecture:** Documentation only. Nothing under `internal/`, `native/`, `meetcore/` or `protocol/` changes. Every sentence about behaviour is checked against the code on main `9225ec2` before it is written, and cites the spec section or file it comes from. Items of the programme's slice 6 that other sessions have already delivered or are delivering are recorded as such, not redone.
+
+**Tech Stack:** Markdown; the spec at `docs/superpowers/specs/2026-09-07-meet-design.md` (revision 9 plus the 2026-09-10 and 2026-09-12 amendments on main); the suite sharing doctrine at `oficina/suite` `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md` revision 8, main `1ac4323`.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` §7.3, §9, §10, §11, §12 item 6, §0.2. Programme: `docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md` (slice 6 line and §5).
+
+## Global Constraints
+
+- Branch `slice/6-deploy` from main `9225ec2`; docs only; `make ci` stays green (it is unaffected by Markdown, run it once at the end).
+- No user-facing app string changes. Copy review is waived for this round by Paul's steering on `oficina/suite` discussion 5 ("copy review is explicitly waived for this entire massive round"); README and PRODUCT.md prose is catalogued in the record, not reviewed.
+- Every claim about behaviour is verified against the code on `9225ec2` (not against memory of earlier slices) and cites its source. A claim that cannot be verified is not written.
+- The README's first paragraph carries the scriptless-floor sentence (spec §9: browser media participation needs script, everything else works with scripts off) and the second paragraph is the relay paragraph (spec §5.2, §5.5: ICE servers come from the platform relay through `CARLOS_RELAY`, Meet names no STUN or TURN server of its own; with no relay, calls are direct only).
+- Nothing here restates a suite ruling; §0.2 names rulings by number (doctrine Appendix A intro: "delete the restatement, name the ruling").
+
+## What slice 6 was, and what it is now
+
+The programme (2026-09-09) listed for slice 6: PRODUCT.md, the README with the floor and relay paragraphs first, AGENTS.md's final notes, the acknowledgements page, the *Consumed suite rulings* commit for suite Appendix A.6, the Estilo theme pin if a tagged release exists, and the production promotion with its notification. Since slice 5 shipped (a283c53, 2026-09-11) other sessions landed on main: Home sign-in (`7.1.1`, `internal/meet/home.go`), the shared Estilo day theme and brand (`f9cb47f`, `34fc367`; `amadan.net/oficina/estilo.git` as an indirect pseudo-version), the invitation `Share` route (`POST /m/{sqid}/share`, 2026-09-12 amendment), and the native Mac, iPhone and iPad apps (`native/`, `meetcore/`, `protocol/`, tag `v0.1.0` at `9225ec2`). Therefore:
+
+| Programme item | Disposition in this slice |
+|---|---|
+| PRODUCT.md | Task 2, here. |
+| README with floor and relay paragraphs first | Task 1, here. |
+| AGENTS.md final notes | Task 1, here (deploy and gate notes only; the file already carries the native and sharing notes added on main). |
+| Acknowledgements page | **Not built here.** `oficina/meet` discussion 10, branch `feat-open-source-credits` (`117b6e4`, `bbc126e`): a shared Estilo credits page with a runtime dependency inventory, Fable-approved, verified live on the overnight canary, not yet merged to main. Slice 6 records it and defers to it; building a second one would be the duplication the programme forbids. |
+| Estilo theme pin if a tagged release exists | **No tag exists** (`git ls-remote --tags` on `oficina/estilo` is empty on 2026-09-14). The theme is nevertheless already adopted on main at a pseudo-version by the Home integration. Recorded in §15.18; not changed. |
+| Consumed suite rulings commit | Task 3, here: §0.2 refreshed against doctrine main `1ac4323` (still revision 8) and against what main now consumes (Home hand-off, the Share amendment), in one commit titled so Appendix A.6 can point at it. |
+| Production promotion with its notification | **No deploy from this slice.** The change is documentation; edge (`meet.oficina.works`) is being promoted by hand by Paul (`651cff2` on 2026-09-12) with other sessions' work queued behind live checks (discussion 14). A docs-only redeploy would drop live calls for nothing. The notification posted on discussion 3 says so in those words; a queued promotion is never announced as live (programme §5). |
+
+---
+
+### Task 1: README.md and AGENTS.md's final notes
+
+**Files:**
+- Create: `README.md`
+- Modify: `AGENTS.md` (append a short "Deploying" section after "Serving"; do not touch existing lines)
+
+**Interfaces:**
+- Consumes: spec §3.3/§3.4 (the floor sentence on the holding page and room: find the exact shipped sentence in `internal/meet/templates/join.html` and `room.html`), §5.2/§5.5 (relay), §7.1.1 (Home), §7.3 (deploy), §8 (agents), `native/README.md`, `Makefile` targets, `cmd/meet/main.go` (env vars), `internal/meet/config.go` (which are refused when empty outside development).
+- Produces: the README that PRODUCT.md (Task 2) and the record (Task 4) link to.
+
+- [ ] **Step 1: Verify each fact you will state** by reading the file it comes from on this branch. Keep a list of `claim → file:line` for the report.
+
+- [ ] **Step 2: Write `README.md`** with this shape (the paragraphs in this order; write them as prose, not as this outline):
+
+```markdown
+# Meet
+
+<Paragraph 1: what Meet is (video calls for one team: create or schedule a
+meeting, share a link, a holding screen, a room of up to four, chat, screen
+share, an agent API), and the floor sentence: every screen works with
+JavaScript disabled except browser media participation, the camera preview
+and join button on the holding screen and the call itself in the room; both
+pages render their title, standing, floor notice and navigation without
+script (spec §9).>
+
+<Paragraph 2: the relay. ICE servers come from the CARLOS platform relay,
+delivered as CARLOS_RELAY and minted per call at GET /m/{sqid}/ice; Meet
+names no STUN or TURN server of its own (suite ruling 6); with no relay
+configured, calls are direct only and the holding screen says so. Enabling
+or rotating the relay restarts the instance (spec §5.2, §5.5, §15.13).>
+
+## Sign-in
+<magic link to an allowlisted address (MEET_ALLOWED_ADDRESSES); a meeting
+link gives an outsider a guest grant at sign-in, never a way in without a
+name; optional Home sign-in with the four MEET_HOME_* settings, all or
+none (spec §7.1.1).>
+
+## The room
+<P2P mesh of up to four, perfect negotiation, SSE stream + POST signal,
+waiting queue for guests, removal and takeover through confirm pages,
+chat fanned out and never stored, screen share as a second tile; every
+deploy drops live calls because the hub is in-process (spec §4, §5, §6).>
+
+## Agents
+<Settings mints a token shown once; bearer-only /api/v1 (list, create,
+replace links, end, get); reach re-derived per request; 60/min per agent;
+spec §8 and §15.17.>
+
+## Native apps
+<one paragraph pointing at native/README.md and native/ios/README.md;
+make native-mac; same server and shared Go core; no embedded webviews.>
+
+## Building and gates
+<make ci (CGO_ENABLED=0), make race (the cgo exception, tests only),
+make browser (real Chromium, 31+ scenarios, about nine minutes, must run
+with the Claude Code bash sandbox off: UDP is blocked inside it), make
+native-test; make release VERSION=<sha> stamps /api/version.>
+
+## Deploying
+<unit route on CARLOS; carlos deploy --app meet --account oficina with the
+build probe on /api/version; secrets MEET_INSTANCE_KEY, MEET_DEK (32 base64
+bytes), mail via MEET_SMTP_* with CARLOS_SMTP_* as fallback; env
+MEET_ORIGIN, MEET_ALLOWED_ADDRESSES, MEET_ZONE; carlos relay enable --app
+meet; migrations apply at boot once each (log line msg=migrate); every
+deploy restarts the instance and drops live calls, so deploy outside
+meeting hours; the canary recipe (carlos instances create + carlos deploy
+--channel) because carlos canary forms a two-label host, platform #124.>
+
+## Where things are written down
+<spec, programme plan, the slice records under docs/superpowers/reviews/,
+PRODUCT.md, AGENTS.md.>
+```
+
+- [ ] **Step 3: Append to `AGENTS.md`**, after the "Serving" section, a "Deploying" section of at most eight lines: unit route; `carlos deploy` with the probe; migrations at boot, never re-run (already in the rules; say only that a database which applied a differently named migration file fails at boot); every deploy and every relay config change restarts the instance and drops live calls; the `carlos deploy` wait can time out before the hand-over, check `/api/version` before retrying; the canary recipe. No other change to AGENTS.md.
+
+- [ ] **Step 4: Check** `grep -c '' README.md` is under 140 lines, no sentence claims something you did not verify, no suite ruling is paraphrased where a number will do.
+
+- [ ] **Step 5:** Do not commit. Report the claim list.
+
+### Task 2: PRODUCT.md
+
+**Files:**
+- Create: `PRODUCT.md`
+
+**Interfaces:**
+- Consumes: `oficina/docs` `PRODUCT.md` as the shape (impeccable product schema 1: Platform, Users, Product Purpose, Positioning, Operating Context, Capabilities and Constraints, Brand Commitments, Evidence on Hand, Product Principles, Accessibility & Inclusion), the spec's §1 (goals), §9 (the floor), §11 (deliberately not built), §13 (gates), the slice records.
+- Produces: the section "What is deliberately not here" that spec §9 says slice 6 records the scriptless exception in.
+
+- [ ] **Step 1: Read** `/home/paulca/amadan.net/oficina/docs/PRODUCT.md` in full for the shape and the disclaimer header (written unattended; facts cited to spec or code; readings marked **[inferred — confirm]**).
+
+- [ ] **Step 2: Write `PRODUCT.md`** in that shape, for Meet, citing spec sections and files. It must contain a section **"What is deliberately not here"** listing: the scriptless-floor exception (browser media participation, spec §9); no reactions, dial-in, captions, multi-host signalling, recordings (§11, as amended on main); no STUN/TURN of Meet's own (ruling 6); no passkeys at the instance (as Docs); no SFU (roadmap 4b later); no client-address limiter on the API (§15.17); chat never stored; the room capped at four. Mark inferred readings. Under 240 lines.
+
+- [ ] **Step 3:** Do not commit. Report the claim list.
+
+### Task 3: The *Consumed suite rulings* commit and §15.18
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-09-07-meet-design.md` (§0.2; §12 item 6; a new §15.18)
+
+**Interfaces:**
+- Consumes: doctrine main `1ac4323` revision 8 (`/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`, verified current by `git ls-remote` on 2026-09-14), Appendix A's intro (name the ruling, delete the restatement), spec §7.1.1 and the 2026-09-12 sharing amendment on main, `internal/meet/home.go`, `internal/meet/api.go`.
+- Produces: the commit suite Appendix A.6 will cite (its hash goes on suite discussion 5 in Task 4).
+
+- [ ] **Step 1: Refresh §0.2.** Keep the table's shape. Confirm the doctrine citation (revision 8 at 1ac4323, still main on 2026-09-14). Update the row for rulings 4 and 12: the home now mints assertions and Meet consumes them through `home/connect` (§7.1.1) while the allowlist remains the membership authority; the row must name the rulings and §7.1.1, not restate them. Add a row for ruling 16 as amended by the 2026-09-12 sharing update (invitation tokens are hash-indexed with a sealed copy for members' repeat sharing; agent credentials remain hash-only and shown once). Keep "Also consumed" current (discussion 4; the media roadmap; the relay spec, now live). Do not paraphrase any ruling.
+
+- [ ] **Step 2: §12 item 6:** annotate as delivered on 2026-09-14 with the disposition table from this plan's "What slice 6 was" section in one sentence per item.
+
+- [ ] **Step 3: Add §15.18** "§12 item 6 — what slice 6 delivered, deferred and declined": the README and PRODUCT.md; the acknowledgements page deferred to discussion 10's branch; the Estilo pin (no tag exists; adopted at a pseudo-version by the Home integration on main; the programme's condition is recorded as unmet and the adoption as done by another route); no deploy from a docs-only slice, edge hand-managed at 651cff2.
+
+- [ ] **Step 4:** Do not commit. Report the exact lines changed.
+
+### Task 4: Record, reviews, merge, notification
+
+**Files:**
+- Create: `docs/superpowers/reviews/2026-09-14-slice-6-record.md`
+
+- [ ] **Step 1:** Commit Tasks 1 to 3 as three commits (README+AGENTS; PRODUCT.md; "Consumed suite rulings: §0.2 against doctrine 1ac4323 rev 8, §15.18").
+- [ ] **Step 2:** Whole-branch review (Opus): every claim in the three documents checked against the code on the branch; findings W6-n.
+- [ ] **Step 3:** Codex round(s) until accept.
+- [ ] **Step 4:** Record in the shape of the slice 5 record: rulings, dispositions, carried items (the acknowledgements page, the Estilo tag, the production promotion, the credits inventory freshness gate that discussion 10 adds), the copy catalogue note, the gates (`make ci` on the head; the browser gate is not run: no page or client changed).
+- [ ] **Step 5:** `make ci`, push, remote CI, fast-forward main, the notification on `oficina/meet` discussion 3 and the checkpoint on `oficina/suite` discussion 5 naming the §0.2 commit for Appendix A.6, close the slice discussion, hub tasks Done, memory.
diff --git a/docs/superpowers/reviews/2026-09-14-slice-6-record.md b/docs/superpowers/reviews/2026-09-14-slice-6-record.md
new file mode 100644
index 0000000..85f8a03
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-14-slice-6-record.md
@@ -0,0 +1,209 @@
+# Meet slice 6 — the record
+
+Branch `slice/6-deploy`, `9225ec2..HEAD` (this commit; started on main
+`9225ec2`, tag `v0.1.0`). Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`,
+revision 9 (§15.18 carries this slice's dispositions). Plan:
+`docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md`.
+
+Slice 6 is the programme's closing slice (spec §12 item 6) and is
+documentation only: `README.md`, `PRODUCT.md`, a Deploying section in
+`AGENTS.md`, the *Consumed suite rulings* commit (`fa875c7`, the hash
+suite Appendix A.6 points at) and the spec amendments that commit and
+the review made. No file under `internal/`, `cmd/`, `native/`,
+`meetcore/` or `protocol/` changed. The ledger is
+`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/` (gitignored; this
+file is its substance).
+
+---
+
+## 1. Rulings and decisions
+
+- **The slice was re-scoped against main as it is on 2026-09-14, not
+ as the programme imagined it on 2026-09-09.** Between slice 5
+ (`a283c53`, 2026-09-11) and this slice, other sessions landed Home
+ sign-in (§7.1.1), the shared Estilo day theme and brand, the
+ invitation `Share` route (the 2026-09-12 amendment), and the native
+ Mac, iPhone and iPad apps. The plan's "What slice 6 was, and what it
+ is now" table is the disposition of each programme item; §12 item 6
+ and §15.18 restate it in the spec.
+- **The acknowledgements page is not built here.** `oficina/meet`
+ discussion 10, branch `feat-open-source-credits` (`117b6e4`,
+ `bbc126e`), carries a shared Estilo credits page with a runtime
+ dependency inventory and its freshness gate, Fable-approved and
+ verified live on the overnight canary. Building a second one would
+ be the duplication the programme forbids.
+- **The Estilo theme pin.** The programme's condition was a tagged
+ release; `git ls-remote --tags` on `oficina/estilo` is empty on
+ 2026-09-14. The theme is nevertheless adopted on main at an indirect
+ pseudo-version (`go.mod`), brought in by the Home integration.
+ Recorded as: condition unmet, adoption done by another route,
+ nothing changed here.
+- **No deploy from this slice.** The change is documentation,
+ checkable from this diff. As observed on 2026-09-14 from `carlos
+ channels` and `oficina/meet` discussions 11 and 14, edge
+ (`meet.oficina.works`) was being promoted by hand by Paul (`651cff2`
+ on 2026-09-12), with discussion 14's web UI work queued behind a
+ live check — state as of that observation, not asserted as current
+ here. A redeploy would drop live calls for nothing; a queued
+ promotion is never announced as live (programme §5). The domain was
+ attached before this slice.
+- **Copy review is waived** for this round by Paul's steering on
+ `oficina/suite` discussion 5. README and PRODUCT.md prose is
+ catalogued here (§3) rather than reviewed.
+- **Writers did not commit.** Three writers worked concurrently in one
+ worktree on disjoint files; the controller committed each set. The
+ index is the shared resource, not the files.
+- **Every behavioural claim is verified against the code on the
+ branch and cites its source.** The whole-branch review's finding was
+ that the writers verified the documents against the code but not the
+ code's documents against the documents: four spec sentences on main
+ asserted things about "the README" that the README, once it existed,
+ did not say (§0.1/§5.6's encryption sentence, §5.5's TCP-443 cause
+ and "first paragraph", §3.2's residue). All four now hold, and the
+ fix wave swept every spec sentence naming README, AGENTS.md or
+ PRODUCT.md (twelve remaining, all true).
+- **The two meanings of "floor".** §5.5's floor is "no relay"; §9's is
+ the scriptless floor. §12 item 6 now says which goes where (the
+ scriptless sentence in the README's first paragraph, the relay
+ paragraph carrying §5.5's sentence second); §15.18 notes the two
+ senses.
+- **Platform facts stated from this project's own deploys**, not from
+ the platform source: `carlos canary` composed a host the console
+ refused (platform discussion 124, open); the recipe used is
+ `carlos instances create --host <name>-meet.bds.oncarlos.com
+ --channel canary/<name> --environment <name>` then `carlos deploy
+ --channel …`, with the environment bundle mandatory because a canary
+ without one serves `MEET_ORIGIN` for the main host and refuses
+ sign-in while GETs look healthy. The release command is `carlos
+ deploy --app meet --account oficina --channel edge --version <sha>
+ --target linux-arm64 releases/meet-linux-arm64` after `make release
+ VERSION=<sha>`; the deploy wait can time out before the hand-over
+ while the instance serves the new version a minute later.
+- **Ruling 16** stays cited as on the doctrine's
+ `worktree-worktree-token-at-rest` branch, not doctrine main (whose
+ rulings run 1 to 15 at `1ac4323`, revision 8, confirmed current by
+ `ls-remote` on 2026-09-14), as amended by the 2026-09-12 sharing
+ update for invitation tokens; agent credentials remain hash-only and
+ shown once.
+
+---
+
+## 2. Per-task review dispositions
+
+Three writing tasks (Sonnet) in parallel, then one whole-branch review
+(Opus) and one fix wave, then Codex rounds.
+
+- **Task 1, README and AGENTS.md** (`93f1c2e`): 106 lines, the two
+ paragraphs first. Flagged and acted on: spec §11 still listed a
+ native client as not built (`aa9b227`). Flagged and initially left,
+ then closed by the review: §5.5's "first paragraph" cross-reference;
+ `MEET_SMTP_PASS` as a secret.
+- **Task 2, PRODUCT.md** (`e684b97`): impeccable product schema 1 in
+ `oficina/docs`' shape with the unattended-writing disclaimer; "What
+ is deliberately not here" carries the scriptless exception spec §9
+ asked for.
+- **Task 3, the consumed-rulings commit** (`fa875c7`): §0.2's 4/12 row
+ consumes the home's assertions through §7.1.1 with the allowlist as
+ membership authority; a ruling-16 row as amended; §12 item 6
+ annotated; §15.18 added. Revision line left at 9, following what
+ §15.13 to §15.17 did.
+- **Whole-branch review** (`e684b97`): verdict **merge with named
+ fixes**, W6-1 to W6-15, all applied in `bf3b86b` and `c22cae7`
+ (reflow), plus the handoff's spec sweep, the two-floors
+ disambiguation, §15.11's stale "until slice 3, or until a drain
+ exists" (no drain exists; every deploy drops live calls) and
+ `token_sealed` added to §2.1's `meeting_links` columns. Roughly
+ sixty behavioural claims were traced to file and line and found
+ correct; the fifteen findings were the remainder.
+
+**Codex round 1** (independent, static, scope `9225ec2..c22cae7`):
+verdict **request changes** — thirteen findings, S6-1 through S6-13.
+All applied in one commit (`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/fix-wave-report.md`,
+round-1 section):
+
+- **S6-1 — P2, deployment settings undocumented. Fixed:** README's Deploying section enumerates every required setting and which refuse startup outside `-env development`.
+- **S6-2 — P2, the deploy-wait guarantee had an unstated prerequisite. Fixed:** README and AGENTS.md now state the `carlos apps build-probe` prerequisite and the `X-Carlos-Version` fallback it stands in for.
+- **S6-3 — P2, the scriptless exception was narrower than shipped. Fixed, then completed in Codex round 2 (S6-16):** README, PRODUCT.md and spec §9 name the chat panel and the live admission controls as script-gated too, alongside browser media participation; AGENTS.md's two matching lines, left behind in round 1, were widened the same way, so §9's "the binding file and the code agree" holds again; §15.18 notes both.
+- **S6-4 — P2, the cgo claims didn't match the Makefile. Fixed, then corrected again in Codex round 2 (S6-14):** README and PRODUCT.md say `CGO_ENABLED=0 make ci` and name `make race` and `make native-test`'s `meetcore` sub-step as the two `CGO_ENABLED=1` cases that never ship; the native Mac and iPhone/iPad apps are named separately as the one place real cgo does ship (`MeetBridge`, linked by `MeetKit`), while server releases stay cgo-free.
+- **S6-5 — P2, the SMTP fallback description was backwards. Fixed:** README now says the platform supplies `CARLOS_SMTP_FROM` too, which `MEET_SMTP_FROM` optionally overrides.
+- **S6-6 — P2, suite restatements remained under §0.2. Fixed, then completed in Codex round 2 (S6-15):** rows 3, 6, 9 and 15 cite section and ruling number only; the discussion-4 paragraph does too; the §5.2 paragraph, shortened but not emptied in round 1, is now the bare citation "Doctrine §5.2 is also relied on, for §2."; PRODUCT.md's actor sentence likewise; ruling 16's off-main provenance kept as before.
+- **S6-7 — P2, PRODUCT.md cited a section that denies native clients shipped. Fixed:** spec §8.4 gained a 2026-09-14 amendment recording the shipped milestone (`v0.1.0` at `9225ec2`) and the browser-approval handoff (`86ed9f3`); PRODUCT.md's two native-client citations now point at that amendment.
+- **S6-8 — P2, the shipped same-network sentence was missing from the README. Fixed:** the relay paragraph now quotes `join.html`'s floor sentence verbatim.
+- **S6-9 — P2, the route inventory undercounted confirmation pairs. Fixed:** PRODUCT.md now says "five destructive confirmation pairs"; spec §9 now says "every confirm page (five)".
+- **S6-10 — P3, Share's legacy behavior was omitted. Fixed:** README now distinguishes a legacy meeting's first Share (mints a new recoverable invitation alongside the old one) from every retrieval after.
+- **S6-11 — P3, "60 requests a minute" obscured the permitted burst. Fixed:** README and PRODUCT.md now say "one request a second sustained, with a burst of 60".
+- **S6-12 — P2, the canary creation command relied on undocumented app/account defaults. Fixed:** README's `instances create` now carries `--app meet --account oficina` and states the named environment bundle must already exist, created separately.
+- **S6-13 — P3, disposition coverage and external-state attribution were incomplete. Fixed, then completed in Codex round 2 (S6-17):** README and PRODUCT.md link to spec §15.18 as the disposition of record; the platform-124, no-Estilo-tag and credits-branch-canary claims are attributed to dated records. Round 1 left the production-promotion state itself (edge's served commit, the queued work) asserted as current fact in §15.18 and this record's own §3; both now attribute it to what `carlos channels` and `oficina/meet` discussions 11 and 14 showed on 2026-09-14, keeping "this slice changes documentation only" as the one claim checkable from the repository.
+
+**Codex round 2** (independent, static, scope `2a6eee4`): verdict
+**request changes** — four findings, S6-14 through S6-17, against
+round 1's own fixes; the other twelve confirmed fixed as claimed
+(table in the round's own report). All four applied in one commit
+(`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/fix-wave-report.md`,
+round-2 section):
+
+- **S6-14 — P2, PRODUCT.md still claimed the native cgo bridge never ships. Fixed:** "three things run under `CGO_ENABLED=1`, none of them shipped" is now two — `make race` and `make native-test`'s `meetcore` sub-step, both test-only — with the native Mac and iPhone/iPad apps named separately as the one place real cgo does ship (`MeetBridge`, a `native/apple/Package.swift` binary target `MeetKit` links, built by `native/scripts/build-mac.sh`), while server releases stay `CGO_ENABLED=0`. This record's own S6-4 line is corrected to match.
+- **S6-15 — P2, the guest-grant sentence was shortened, not removed. Fixed:** "Doctrine §5.2 is also relied on, for §2: a guest grant is stored, not asked for" drops its second clause — still a paraphrase of doctrine §5.2's grant authority — leaving the bare citation "Doctrine §5.2 is also relied on, for §2."
+- **S6-16 — P2, the widened scriptless exception left `AGENTS.md` behind. Fixed:** `AGENTS.md`'s two scriptless lines (the "Screens work with JavaScript disabled" rule and the UX-conventions sentence) now name the chat panel and admission controls alongside browser media participation, matching §9, README and PRODUCT.md; §9's own "the binding file and the code agree" is corrected to say when each half landed; §15.18 records the correction.
+- **S6-17 — P3, production state was asserted as current rather than attributed. Fixed:** §15.18's production-promotion bullet and this record's own §1 and §3 now attribute edge's served commit and the queued web UI work to what `carlos channels` and `oficina/meet` discussions 11 and 14 showed on 2026-09-14, rather than presenting it as established from this repository; "this slice changes documentation only" stays stated as fact, since the diff itself proves it.
+
+**Codex round 3** (independent, static, scope `cd02a59`): verdict
+**accept** — S6-14 to S6-17 closed, no new finding, "fit to merge as
+documentation of the code on this branch". Three rounds in all: 13, 4,
+0 findings.
+
+---
+
+## 3. Carried items, and where they go
+
+- **The acknowledgements page.** Discussion 10's branch
+ `feat-open-source-credits`, not yet on main. **Destination: that
+ branch's owner**; when it merges, the README's "Where things are
+ written down" section should gain the credits page's path (one
+ line).
+- **The Estilo theme tag.** No tagged release exists; the pseudo-version
+ pin stays until one does. **Destination: oficina/estilo.**
+- **The production promotion of main's head.** As observed on
+ 2026-09-14 from `carlos channels` and `oficina/meet` discussions 11
+ and 14, edge was serving `651cff2` with discussion 14's web UI work
+ queued behind it — state as of that observation, not asserted as
+ current here. Main is at `9225ec2` plus this slice, checkable from
+ this repository: `9225ec2` changed native code only, and this slice
+ changes documents only. **Destination: Paul**, who promotes by hand.
+- **Unauthenticated flood control on `/api/v1`** (from slice 5).
+ **Destination: carlos/platform**, unchanged.
+- **Platform discussion 124** (`carlos canary` and the one-label host).
+ Open. The README carries the recipe that works. **Destination:
+ carlos/platform.**
+- **Spec §9's "four confirm pages" is five** (remove, takeover,
+ replace-link, end, revoke-agent). **Resolved in Codex round 1
+ (S6-9):** §9 now says "every confirm page (five)" and PRODUCT.md
+ says "five destructive confirmation pairs".
+- **Copy catalogue** (copy review waived): the README's and
+ PRODUCT.md's prose are the new user-facing text this slice adds;
+ neither is an app string. Nothing in `internal/meet/templates`
+ changed.
+- **The ledger's process note.** A writer's flag should become a task,
+ not a paragraph: two of the three task reports flagged the right
+ problems (W6-3, W6-6) in their "not corrected" sections and the
+ controller took them as recorded rather than as work.
+
+---
+
+## 4. Deploying this slice
+
+Nothing to deploy. No binary, migration, template, client or
+configuration changed. When main is next promoted, the README's
+Deploying section is the recipe, and the `carlos deploy` wait note in
+§1 applies.
+
+---
+
+## 5. The gates, and how to run them
+
+| Gate | Command | This slice |
+|---|---|---|
+| CI | `CGO_ENABLED=0 make ci` | Green on every commit of the branch; remote CI passed on `e684b97` and `c22cae7`. Markdown cannot fail it; it is run so the branch's head is known green. |
+| Race | `make race` | Not run: no change under `internal/meet/hub` or the stream. |
+| Browser | `make browser` (sandbox off) | Not run: no page, template or client file changed. The last run on a shipping head is slice 5's, 31 of 31 on `4f896c2`. |
+| Native | `make native-test` | Not run: nothing under `native/`, `meetcore/` or `protocol/` changed. |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index f4a7dba..8eae61f 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -89,20 +89,17 @@ This app cites the suite sharing doctrine (`oficina/suite`,
| Ruling | Used for |
|---|---|
| 1, 5 | One instance per team; the first deploy is the **lone shape** (§7.1). |
-| 3 | Authorization is asked per request and never cached. Standing (member, guest, neither) is derived at each request and re-derived on every hub tick (§2.3). |
-| 4, 12 | Identity and roster belong to the home. Until the home mints assertions, the lone instance keeps its own allowlist exactly as Docs and Slides do. |
-| 6 | No product-wide egress. Meet never names a STUN or TURN server of its own; ICE servers come from the platform (§5.2) or there are none (§5.5). |
-| 9 | Every write names its real actor: a member, a guest who signed in, or an agent (§8). |
-| 15 | Three rungs plus a read-only link. A meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name (§3). Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. |
-| 16 | A link token is stored hashed and shown once (§3.1). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not yet on main; Meet adopts it now so it never has the Docs defect that produced it. |
-
-Doctrine §5.2 is also relied on: a guest grant held by the instance is
-authoritative for that grant, which is why §2 stores it and does not
-ask anyone.
-
-Also consumed: `oficina/suite` discussion 4 (agents as their own
-principal, on behalf of a person, bound to the operator's reach and no
-more) for the drive surface (§8); the CARLOS media roadmap
+| 3 | §2.3: standing (member, guest, neither) is derived at each request and re-derived on every hub tick. |
+| 4, 12 | The home now mints assertions; Meet consumes them through `home/connect`'s bridge, resolving to the existing person and re-checking membership on every request, while the allowlist remains the membership authority throughout (§7.1.1). |
+| 6 | §5.2, §5.5: Meet never names a STUN or TURN server of its own; ICE servers come from the platform, or there are none. |
+| 9 | §8: a member, a guest who signed in, or an agent. |
+| 15 | §3: a meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name. Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. |
+| 16 | The token at rest (§3.1), as amended by the 2026-09-12 sharing update at the top of this file: invitation tokens are hash-indexed with a sealed copy so a member can share the current one again; agent credentials stay hash-only and shown once (§15.17). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not on main on 2026-09-14; Meet adopts it early so it never has the Docs defect that produced it. |
+
+Doctrine §5.2 is also relied on, for §2.
+
+Also consumed: `oficina/suite` discussion 4, for the drive surface
+(§8); the CARLOS media roadmap
(`carlos/platform`,
`docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`,
merged at 8291cb2) and the stage 4a relay spec
@@ -171,9 +168,12 @@ that the subject is an address, not an id. Standing is not a column
its links answer 404 exactly as a revoked one does.
**`meeting_links`** — `id`, `meeting_id`, `token_hash` (SHA-256 of the
-token, ruling 16), `created_by`, `created_at`, `revoked_at`. One meeting
-may hold several links so a revoked one can be replaced. The token is
-returned once, in the response that minted it, and never again.
+token, ruling 16), `token_sealed` (nullable; added by the 2026-09-12
+sharing amendment, top of file, so a member can retrieve a current
+invitation again), `created_by`, `created_at`, `revoked_at`. One
+meeting may hold several links so a revoked one can be replaced. The
+token is returned once, in the response that minted it, and again on
+demand through `POST /m/{sqid}/share` once a sealed copy exists.
**`pending_joins`** — `id`, `meeting_id`, `link_id` (the
`meeting_links` row the browser followed), `address_fingerprint` (keyed
@@ -823,9 +823,10 @@ is usually true on a permissive local network, usually false across the
internet, and false on a local network with client isolation. The
pre-join screen says, above the button: "This team's Meet has no relay
yet. Calls usually connect only between people on the same network."
-The README's first paragraph says the same. An operator enables the
-platform relay with `carlos relay enable --app meet`, after which this
-paragraph no longer applies to that instance.
+The README's second paragraph, the one that introduces the relay, says
+the same; its first paragraph carries the scriptless floor (§9). An
+operator enables the platform relay with `carlos relay enable --app
+meet`, after which this paragraph no longer applies to that instance.
With `CARLOS_RELAY` present, a failed connection's tile says "could not
connect, even through the relay" and offers retry. It does not diagnose
@@ -974,6 +975,19 @@ A thin adapter over §8.2, deferred until a consumer asks by name.
### 8.4 Native clients
+**2026-09-14 amendment.** The SwiftUI Mac, iPhone and iPad apps this
+section specifies shipped on `main` (`native/`, `meetcore/`,
+`protocol/`, tag `v0.1.0` at `9225ec2`), closing the authentication gap
+the bullets below name with a browser-approval hand-off
+(`native/README.md`): a five-minute, one-use authorization bound to a
+proof secret held only by the app; approving in the browser creates an
+ordinary framework session, no cookie copied and no new bearer type
+introduced. The production handoff endpoints were deployed in
+`86ed9f3`. This supersedes the closing paragraph's "version one does
+not ship a native client" below; the rest of this section, written
+before that milestone, otherwise stands as the contract the shipped
+apps speak.
+
Ruling 15: a native app is the next client after the browser, and
nothing in Meet's server contracts may assume the browser. Concretely,
in version one:
@@ -1000,19 +1014,25 @@ until an OpenAPI document earns its place.
## 9. The scriptless floor
-The exception is **browser media participation** and nothing wider:
-reaching a camera and another browser needs script. Every other screen
-works with scripts off, including the meeting list, creating a meeting,
-seeing and copying a link, all four confirm pages, sign-in and
-continuation, the `/welcome` page, the agent token page, and the server-rendered
-parts of the holding screen and the room (§3.3, §3.4): title, start
-time, standing, floor sentence, the paragraph that says what needs
-script, and navigation to everything that does not.
-
-`AGENTS.md`'s line "screens work with JavaScript disabled" gains, in
-slice 2, the one exception in those words, so the binding file and the
-code agree from the commit that introduces the exception. `PRODUCT.md`
-records it in "what is deliberately not here" in slice 6.
+The exception is **browser media participation**: reaching a camera
+and another browser needs script. Inside a room that exists, script
+also gates the chat panel (`chat.html`'s whole panel is rendered
+`hidden` until script un-hides it, because a Send button with no
+script behind it would be a lie, §6, §10) and the admission queue's
+Admit controls (`room.js`), since neither can do anything before a
+call does. Every other screen works with scripts off, including the
+meeting list, creating a meeting, seeing and copying a link, every
+confirm page (five), sign-in and continuation, the `/welcome` page,
+the agent token page, and the server-rendered parts of the holding
+screen and the room (§3.3, §3.4): title, start time, standing, floor
+sentence, the paragraph that says what needs script, and navigation to
+everything that does not.
+
+`AGENTS.md`'s line "screens work with JavaScript disabled" gained, in
+slice 2, the one exception in those words; on 2026-09-14, alongside
+this section's own widening, it gained the other two (§15.18), so the
+binding file and the code agree again. `PRODUCT.md` records it in
+"what is deliberately not here" in slice 6.
Hand-written static JavaScript, no bundler, no npm, in three files:
`devices.js` (the holding screen), `room.js` (signalling, resume and
@@ -1040,8 +1060,7 @@ are drafts for that review, not approved copy.
Recurring meetings. Calendar integration. Recording. Attendance
records. A relay of Meet's own. Rooms above four. Persisted chat.
Reactions. Dial-in. Captions. Multi-host signalling.
-Push-to-talk. Virtual backgrounds. A native client and its sign-in
-(§8.4). Denying a knock (a guest who is not
+Push-to-talk. Virtual backgrounds. Denying a knock (a guest who is not
admitted simply waits or leaves). Each is named so the next session
does not rediscover it.
@@ -1082,9 +1101,22 @@ own gates from §13; nothing is deferred to the deploy slice.
5. **Drive surface.** `agents`, the five operations, the per-request
reach check, the badge.
6. **Deploy.** Domain, secrets, canary, `PRODUCT.md`, the README with
- the floor sentence in its first paragraph and the relay paragraph,
- and the `Consumed suite rulings` commit that suite Appendix A.6 will
- point at.
+ the scriptless-floor sentence (§9) in its first paragraph and the
+ relay paragraph, carrying §5.5's no-relay sentence, second, and the
+ `Consumed suite rulings` commit that suite Appendix A.6 will point
+ at.
+
+ **Delivered 2026-09-14 (§15.18).** The README, with the floor and
+ relay paragraphs first, was written. `PRODUCT.md` was written.
+ AGENTS.md's deploy notes were appended. This is the `Consumed suite
+ rulings` commit, refreshing §0.2 against doctrine main `1ac4323`
+ revision 8. The acknowledgements page is deferred to `oficina/meet`
+ discussion 10's branch `feat-open-source-credits`, not duplicated
+ here. The Estilo theme pin did not happen, because no tagged release
+ exists; the theme is nonetheless already adopted on main at a
+ pseudo-version through the Home integration. No production
+ promotion happened, because this slice is documentation only; edge
+ stays hand-managed by Paul.
---
@@ -1482,8 +1514,9 @@ they were otherwise recorded only in an untracked ledger:
not solved). Slice 2 is the first release where that has a
user-visible cost: **every deploy drops every live call.** Members
rejoin automatically; guests go back to waiting and must be admitted
- again. Deploys should avoid meeting hours until slice 3, or until a
- drain exists.
+ again. No drain exists, and slice 3 shipping did not change that:
+ every deploy still drops every live call, so deploying during
+ meeting hours means exactly that.
### 15.12 §2.5 — the last row corrected: standing is not consulted *for a guest*
@@ -2130,3 +2163,58 @@ truncated before the `~` — the badge's icon needs no separate
every icon "sits beside its own visible text label") is already
satisfied by the badge's own word, alongside AGENTS.md's own line —
"State — never colour alone — always a text label too."
+
+### 15.18 §12 item 6 — what slice 6 delivered, deferred and declined
+
+Slice 6 is documentation only: nothing under `internal/`, `native/`,
+`meetcore/` or `protocol/` changed. Against the programme's slice 6
+line (`docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md`
+§5) and this spec's own §12 item 6:
+
+The word "floor" names two different things in this spec: §5.5's "The
+floor" is the no-relay condition, and §9's "The scriptless floor" is
+the JavaScript exception; item 6's bullet above now names each rather
+than leaving the bare word to carry both.
+
+§9's exception was also corrected here, on Codex round 1: browser
+media participation was never the only script-gated part of a room —
+the live admission controls have needed script since slice 2 and the
+chat panel since slice 4 — and §9, the README and `PRODUCT.md` now say
+so rather than "and nothing wider." Codex round 2 found the binding
+file left behind: `AGENTS.md` still named only the media exception, so
+§9's own "the binding file and the code agree" was false again the
+moment §9 widened. `AGENTS.md`'s two scriptless lines were widened to
+match on 2026-09-14, in the same commit.
+
+- **Delivered.** `README.md`, with the scriptless floor sentence and
+ the relay paragraph first. `PRODUCT.md`. `AGENTS.md`'s deploy notes.
+ §0.2 above, refreshed against doctrine main `1ac4323`, still
+ revision 8 on 2026-09-14 — this is the `Consumed suite rulings`
+ commit suite Appendix A.6 will point at. The domain was already
+ attached outside this slice — `meet.oficina.works` serves the
+ flagship instance (§7.3's `carlos domains attach`) — so item 6's
+ domain row needed nothing from slice 6.
+- **Deferred, not duplicated.** The acknowledgements page lives on
+ `oficina/meet` discussion 10, branch `feat-open-source-credits`
+ (`117b6e4`, `bbc126e`): a shared Estilo credits page with a runtime
+ dependency inventory. As recorded on that branch's own review, it is
+ Fable-approved and was verified live on the overnight canary, not
+ yet merged to main — not independently reverified here. Building a
+ second one here would be the duplication the programme forbids.
+- **Declined: the Estilo theme pin.** The programme's condition for
+ pinning was a tagged release; `git ls-remote --tags` against
+ `oficina/estilo` is empty on 2026-09-14, so no tag exists and no pin
+ was made. The theme is adopted on main anyway, at a pseudo-version
+ (`amadan.net/oficina/estilo.git`), by the Home integration
+ (`f9cb47f`, `34fc367`) — the programme's condition is recorded here
+ as unmet, and the adoption as done by another route, not as the
+ condition having been met after all.
+- **Declined: the production promotion.** A docs-only slice has
+ nothing to promote that changes behaviour, and a redeploy would drop
+ every live call for no reason (§15.11). As observed on 2026-09-14
+ from `carlos channels` and `oficina/meet` discussions 11 and 14,
+ `meet.oficina.works` was being promoted by hand by Paul (`651cff2`,
+ 2026-09-12) with other sessions' work queued behind live checks —
+ state as of that observation, not asserted as current here. This
+ slice does not touch that queue or announce a deploy of its own,
+ which is checkable from this diff alone.
diff --git a/internal/meet/icons/icons.go b/internal/meet/icons/icons.go
index ce5c344..0289e41 100644
--- a/internal/meet/icons/icons.go
+++ b/internal/meet/icons/icons.go
@@ -20,11 +20,12 @@ package icons
import "html/template"
var icons = map[string]template.HTML{
- "mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`,
- "video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`,
- "monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
- "message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`,
- "log-out": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`,
+ "mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`,
+ "video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`,
+ "monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
+ "message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`,
+ "sliders-horizontal": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>`,
+ "log-out": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`,
"alert-triangle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`,
// class carries "lucide-bot" alongside the app's own "icon" so the
diff --git a/internal/meet/static/devices.js b/internal/meet/static/devices.js
index 40ad506..f824cc4 100644
--- a/internal/meet/static/devices.js
+++ b/internal/meet/static/devices.js
@@ -25,6 +25,12 @@
const pickCamera = $('pick-camera');
const pickMic = $('pick-mic');
const pickSpeaker = $('pick-speaker');
+ const rawRow = $('raw-row');
+ // field-check renders a real checkbox inside its label and gives it no
+ // id of its own, so it is reached through the row the page wraps it in
+ // rather than by an id this file would have to agree with twice.
+ const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null;
+ const rawHelp = $('raw-help');
const muteButton = $('toggle-mute');
const cameraButton = $('toggle-camera');
const muteState = $('toggle-mute-state');
@@ -61,6 +67,70 @@
}
}
+ // audioConstraints builds the microphone half of a getUserMedia
+ // request out of the stored preferences. It is a pure function of
+ // `prefs` so the gate can lift it out of both files and check the two
+ // copies still agree (test/browser/audio.test.mjs). room.js carries
+ // it verbatim, because the room asks for a stream of its own at join
+ // — the holding screen's is stopped by then — and a choice that held
+ // on one page and not the other would be worse than no choice.
+ //
+ // The three flags are the browser's voice pipeline: echo
+ // cancellation, noise suppression and automatic gain. Every browser
+ // turns all three on for a microphone unless told otherwise, which is
+ // right for a laptop on a kitchen table and wrong for an interface in
+ // a room someone has treated — gain riding a held note, noise
+ // suppression taking a cello for a fan. `rawAudio` is the person
+ // saying which of those two rooms they are in.
+ //
+ // They are asked for as ideals and never as `exact`. A browser that
+ // cannot turn its processing off should hand over a processed
+ // microphone, not refuse getUserMedia and cost the person their
+ // voice — the same rule the device ladder is built on. It is also why
+ // the holding screen checks afterwards what it was actually given
+ // (rawHonoured) instead of trusting that it got what it asked for.
+ //
+ // voiceIsolation is the newer, heavier filter in Safari and Chrome,
+ // and it is not everywhere. An unknown constraint name in this basic,
+ // non-exact form is dropped rather than refused, so naming it costs
+ // nothing in a browser that has never heard of it.
+ function audioConstraints(prefs) {
+ const processed = !prefs.rawAudio;
+ const constraints = {
+ echoCancellation: processed,
+ noiseSuppression: processed,
+ autoGainControl: processed,
+ voiceIsolation: processed,
+ };
+ if (prefs.mic) constraints.deviceId = { exact: prefs.mic };
+ return constraints;
+ }
+
+ // rawHonoured answers whether the stream really did come back
+ // unprocessed: true, false, or null when the browser reports none of
+ // the settings and there is nothing honest to say. getSettings is the
+ // only place the answer lives, because audioConstraints asks as an
+ // ideal and an ideal may simply be ignored. Someone who turned the
+ // processing off for an interface deserves to be told it did not take,
+ // rather than spending the call hunting for the filter in their own
+ // gear.
+ function rawHonoured(stream) {
+ const track = stream && stream.getAudioTracks()[0];
+ if (!track || typeof track.getSettings !== 'function') return null;
+ const settings = track.getSettings();
+ const reported = ['echoCancellation', 'noiseSuppression', 'autoGainControl']
+ .filter((key) => typeof settings[key] === 'boolean');
+ if (!reported.length) return null;
+ return reported.every((key) => settings[key] === false);
+ }
+
+ // rawNotice is the sentence startPreview ends on: empty when there is
+ // nothing to report, which is the normal case both ways round.
+ function rawNotice() {
+ if (!prefs.rawAudio || rawHonoured(stream) !== false) return '';
+ return 'This browser kept its own echo cancellation and noise suppression on, so studio audio had no effect here.';
+ }
+
function say(text) {
status.textContent = text;
}
@@ -104,6 +174,22 @@
savePrefs();
applyToggles();
});
+ // Studio audio is a capture setting and not a call control, so it is a
+ // checkbox in the pickers rather than a fourth button in the row of
+ // toggles: it changes what the microphone sends, not whether it is
+ // sending. Changing it re-asks for the stream, because the processing
+ // is chosen when the track is opened and not afterwards.
+ if (rawInput) {
+ if (rawHelp) rawInput.setAttribute('aria-describedby', 'raw-help');
+ rawInput.checked = !!prefs.rawAudio;
+ rawInput.addEventListener('change', () => {
+ prefs.rawAudio = rawInput.checked;
+ savePrefs();
+ startPreview().catch((err) => {
+ console.error('meet: preview after a studio audio change', err);
+ });
+ });
+ }
joinButton.addEventListener('click', () => {
stopPreview();
const href = root.dataset.joinHref || '';
@@ -146,7 +232,7 @@
stopPreview();
say('Asking for your camera and microphone…');
const constraints = {
- audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true,
+ audio: audioConstraints(prefs),
video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true,
};
let result;
@@ -183,7 +269,7 @@
applyToggles();
joinButton.disabled = false;
await fillPickers().catch(() => {});
- say('');
+ say(rawNotice());
}
// disambiguate settles which remembered device id is stale, then
@@ -260,6 +346,8 @@
// reflected immediately.
cameraRow.hidden = cameraCount === 0;
micRow.hidden = micCount === 0;
+ // No microphone is no processing to choose about.
+ if (rawRow) rawRow.hidden = micCount === 0;
speakerRow.hidden = !hasSinkId || speakerCount === 0;
}
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
index 003c4b1..bdb9dc9 100644
--- a/internal/meet/static/meet.css
+++ b/internal/meet/static/meet.css
@@ -114,6 +114,22 @@
min-height: 1.2em;
}
+/* Studio audio takes a line of its own below the device selects: it
+ carries a sentence of explanation, and in the pickers' flex row that
+ sentence would otherwise read as a caption to whichever select it
+ happened to land beside. flex-basis and not display, so the hidden
+ attribute still hides the row — see the note at the top of this file. */
+.meet-raw-row {
+ flex-basis: 100%;
+}
+
+.meet-raw-help {
+ margin-block: var(--rst-sp-1) 0;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ max-inline-size: 42rem;
+}
+
.meet-tile-name > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
@@ -181,6 +197,18 @@
margin-inline-start: var(--rst-sp-1);
}
+/* The device panel's own live region (a camera that would not open, a
+ microphone another app is holding). Never colour alone: the sentence
+ is the whole signal and the weight is a second cue on top of it. No
+ display of its own, so the hidden attribute still hides it — see the
+ note at the top of this file. */
+.meet-device-note {
+ color: var(--rst-text);
+ font-weight: 600;
+ flex-basis: 100%;
+ margin-block: var(--rst-sp-2) 0;
+}
+
/* The relay warning (spec §5.3 step 1) sits under the status line in a
live region of its own. Never colour alone: the sentence is the whole
signal and the weight is a second cue on top of it. No display of its
@@ -226,6 +254,21 @@
.meet-pickers { margin-block-start: 0; }
.meet-pickers > div { flex: 1 1 12rem; min-inline-size: 0; }
.meet-pickers select { max-inline-size: 100%; }
+/* The rule above makes every picker a ~12rem flex item, which is right
+ for the three selects and wrong for the studio switch: it carries a
+ sentence of explanation, and squeezed into a select's column that
+ sentence sets in a ribbon. More specific than the line above rather
+ than ordered after it, so it survives the two being reordered. */
+.meet-pickers > .meet-raw-row { flex: 1 1 100%; }
+/* The room's device panel takes a row of its own under the controls
+ that opened it. Spanning both columns keeps it out of the sidebar,
+ where it would argue with chat over a 20rem column; grid-row 3 puts
+ it under the controls (row 2) rather than letting auto-placement
+ drop it beside the stage. */
+#call-workspace #room-devices { grid-column: 1 / -1; }
+@media (min-width: 64rem) {
+ #call-workspace #room-devices { grid-row: 3; }
+}
.meet-queue li { flex-wrap: wrap; overflow-wrap: anywhere; }
@media (min-width: 64rem) {
.meet-device-layout { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); }
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 234d5f8..71e11b8 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -67,6 +67,21 @@
// The chat panel's root (§6). chat.js owns everything inside it; this
// file owns the wire under it and the moment it becomes usable.
const chatEl = $('chat');
+ // The device panel and its controls. Every one of these is optional
+ // as far as this file is concerned — the room renders without them in
+ // a browser that cannot enumerate devices, and switchDevice and
+ // fillDevices both check before they reach for one.
+ const devicesToggle = $('devices-toggle');
+ const devicesPanel = $('room-devices');
+ const deviceNote = $('device-note');
+ const pickCamera = $('pick-camera');
+ const pickMic = $('pick-mic');
+ const pickSpeaker = $('pick-speaker');
+ const cameraRow = $('camera-row');
+ const micRow = $('mic-row');
+ const speakerRow = $('speaker-row');
+ const rawRow = $('raw-row');
+ const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null;
const DEVICE_KEY = 'meet.devices';
const BACKOFF_MIN = 500;
@@ -180,6 +195,12 @@
// Reading any advance at all as health let such a tile reset the
// budget below for ever and never earn its reset.
const RENDER_MOVED = 0.25;
+ // The ceiling studio audio asks for, in bits per second. Opus is
+ // transparent for speech well below it; the number is picked so a
+ // microphone someone turned the processing off for is not then
+ // squeezed down a pipe built for speech. It is a ceiling and not a
+ // floor — the encoder still spends only what the link will carry.
+ const RAW_AUDIO_BITRATE = 128000;
// §5.4: the tile key a peer's screen gets, built by shareKey.
const SHARE_SUFFIX = '#share';
const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
@@ -315,6 +336,96 @@
// Local media (§3.3's stored choices, §5.1's tracks)
// ---------------------------------------------------------------
+ // audioConstraints builds the microphone half of a getUserMedia
+ // request out of the stored preferences. It is a pure function of
+ // `prefs` so the gate can lift it out of both files and check the two
+ // copies still agree (test/browser/audio.test.mjs). room.js carries
+ // it verbatim, because the room asks for a stream of its own at join
+ // — the holding screen's is stopped by then — and a choice that held
+ // on one page and not the other would be worse than no choice.
+ //
+ // The three flags are the browser's voice pipeline: echo
+ // cancellation, noise suppression and automatic gain. Every browser
+ // turns all three on for a microphone unless told otherwise, which is
+ // right for a laptop on a kitchen table and wrong for an interface in
+ // a room someone has treated — gain riding a held note, noise
+ // suppression taking a cello for a fan. `rawAudio` is the person
+ // saying which of those two rooms they are in.
+ //
+ // They are asked for as ideals and never as `exact`. A browser that
+ // cannot turn its processing off should hand over a processed
+ // microphone, not refuse getUserMedia and cost the person their
+ // voice — the same rule the device ladder is built on. It is also why
+ // the holding screen checks afterwards what it was actually given
+ // (rawHonoured) instead of trusting that it got what it asked for.
+ //
+ // voiceIsolation is the newer, heavier filter in Safari and Chrome,
+ // and it is not everywhere. An unknown constraint name in this basic,
+ // non-exact form is dropped rather than refused, so naming it costs
+ // nothing in a browser that has never heard of it.
+ function audioConstraints(prefs) {
+ const processed = !prefs.rawAudio;
+ const constraints = {
+ echoCancellation: processed,
+ noiseSuppression: processed,
+ autoGainControl: processed,
+ voiceIsolation: processed,
+ };
+ if (prefs.mic) constraints.deviceId = { exact: prefs.mic };
+ return constraints;
+ }
+
+ // tuneAudioSender raises the audio ceiling on one connection, and is
+ // the other half of what studio audio promises: turning the filters
+ // off buys nothing if the result then goes out through Opus at its
+ // default of roughly 32 kbit/s mono, which is a codec a good
+ // microphone is heard *through* rather than heard.
+ //
+ // It is a sender-side change, so it needs no SDP of our own and no
+ // renegotiation, and §5.1's perfect negotiation above never learns it
+ // happened. Stereo is deliberately not attempted: it lives in the
+ // fmtp line, and reaching it would mean building offers by hand
+ // instead of the argumentless setLocalDescription §5.1 is written on
+ // — a large risk to the one part of this file that must not wobble,
+ // bought for a microphone that is almost always mono anyway.
+ //
+ // Every step is optional in some browser that is otherwise fine, so
+ // the whole thing is best effort: a browser that will not take the
+ // parameters carries the call at its own bitrate rather than losing
+ // the connection over it. The encodings array is read right after
+ // addTrack, which is where Chromium has already built it; a browser
+ // that fills it in later simply keeps its default here, and that is a
+ // quieter microphone and not a broken one.
+ function tuneAudioSender(pc) {
+ let sender;
+ try {
+ sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio');
+ } catch {
+ return;
+ }
+ if (!sender || typeof sender.getParameters !== 'function') return;
+ let params;
+ try {
+ params = sender.getParameters();
+ } catch {
+ return;
+ }
+ // A sender the browser has not built encodings for yet is not one to
+ // invent an encodings array for: that is a different request, and
+ // not the one this function is making.
+ if (!params.encodings || !params.encodings.length) return;
+ // Both directions. Studio audio turned off mid-call has to give the
+ // ceiling back, and `undefined` — not zero, which is a request to
+ // send nothing — is how the API spells "the encoder's own default".
+ if (prefs.rawAudio) params.encodings[0].maxBitrate = RAW_AUDIO_BITRATE;
+ else delete params.encodings[0].maxBitrate;
+ Promise.resolve().then(() => sender.setParameters(params)).catch(() => {
+ // Chromium refuses parameters that did not come from the most
+ // recent getParameters, which a renegotiation in flight can cause.
+ // The call is unharmed, and the next connection gets its own try.
+ });
+ }
+
// startLocal walks down a ladder: the remembered devices, then the
// browser's defaults, then audio alone. A camera that is missing or
// in use should not cost the call its microphone as well, and a
@@ -327,10 +438,16 @@
}
const exact = (id) => ({ deviceId: { exact: id } });
const remembered = !!(prefs.mic || prefs.camera);
+ // The processing choice rides every rung. Falling back to the
+ // browser's default *device* is not a reason to put the voice
+ // pipeline back on someone who asked for it off, so only the
+ // remembered device id is dropped on the way down — by
+ // forgetDevices, which touches mic and camera and never rawAudio.
+ const raw = { rawAudio: prefs.rawAudio };
const attempts = [
- { audio: prefs.mic ? exact(prefs.mic) : true, video: prefs.camera ? exact(prefs.camera) : true },
- { audio: true, video: true },
- { audio: true, video: false },
+ { audio: audioConstraints(prefs), video: prefs.camera ? exact(prefs.camera) : true },
+ { audio: audioConstraints(raw), video: true },
+ { audio: audioConstraints(raw), video: false },
];
for (let i = 0; i < attempts.length; i++) {
try {
@@ -385,6 +502,323 @@
local = null;
}
+ // ---------------------------------------------------------------
+ // Changing a device mid-call (§3.3's stored choice, made movable)
+ // ---------------------------------------------------------------
+ //
+ // The holding screen picks the devices; until now that was the only
+ // place they could be picked, and a microphone chosen wrongly — or
+ // unplugged, or wanted for a headset halfway through — cost the
+ // person the call to change. Everything below is that choice, applied
+ // to a call already running.
+ //
+ // The whole thing rests on replaceTrack, which swaps the media a
+ // sender is sending without touching the m-line it sends on. That is
+ // the part §5.1 never has to hear about: no offer, no answer, no
+ // rollback, and no chance of the glare the negotiation above spends
+ // 300 ms avoiding. The one case that does renegotiate is named at
+ // sendTrack, and it is a direction change on an m-line that already
+ // exists rather than a new one.
+
+ // Two clicks on a picker are two switches, and they must not run at
+ // once. devices.js can settle that with a generation counter alone
+ // because the only thing it is racing for is one <video>; here the
+ // loser would be racing for what every sender in the mesh is holding,
+ // and interleaved replaceTracks leave that decided by whichever
+ // getUserMedia happened to settle last.
+ //
+ // So they are serialised, and the counter only decides which queued
+ // run is still wanted: three clicks queue three runs, the first two
+ // find themselves superseded before their turn and return without
+ // opening anything, and the third does the work. One switch is in
+ // flight at a time and the last click wins, which is what clicking
+ // means.
+ // One counter per kind, not one between them: a camera and a
+ // microphone are two independent choices, and a shared counter made
+ // changing the camera silently cancel a microphone change whose
+ // getUserMedia had not settled — the select snapping back with
+ // nothing said about why.
+ const switching = { audio: 0, video: 0 };
+ let switchQueue = Promise.resolve();
+
+ // senderFor finds the transceiver carrying one kind for this call —
+ // the camera or the microphone, never the screen. entry.shareTx is
+ // video too, and replacing *its* track with a camera would put the
+ // camera on the m-line the far side has already labelled a screen
+ // (§5.4), which is the one mistake here that would look like a bug in
+ // someone else's browser.
+ function senderFor(entry, kind) {
+ let transceivers;
+ try {
+ transceivers = entry.pc.getTransceivers();
+ } catch {
+ return null;
+ }
+ for (const tx of transceivers) {
+ if (tx === entry.shareTx || tx.stopped) continue;
+ // receiver.track is present on every transceiver and carries the
+ // kind even when nothing is being sent yet, which is exactly the
+ // recvonly case this has to find.
+ const known = (tx.sender && tx.sender.track) || (tx.receiver && tx.receiver.track);
+ if (known && known.kind === kind) return tx;
+ }
+ return null;
+ }
+
+ // sendTrack puts one track on one connection.
+ //
+ // The replaceTrack is the whole job when this browser was already
+ // sending that kind. When it was not — it came down startLocal's
+ // ladder with no camera, or joined with nothing at all — createPC
+ // left a `recvonly` transceiver for the kind, and that m-line is
+ // already in the right place in the order. Turning it round is a
+ // direction change on an existing m-line, so no m-line is added and
+ // none moves: the hazard addShareTo carries (an unassociated
+ // transceiver claiming the m-line a remote offer meant for something
+ // else) is not in this path at all. It still waits for `stable`,
+ // because a direction changed mid-negotiation is a change the offer
+ // in flight does not carry, and the deferred ones are picked up by
+ // the connection's own signalingstatechange.
+ async function sendTrack(entry, kind, track) {
+ const tx = senderFor(entry, kind);
+ if (!tx || !tx.sender || typeof tx.sender.replaceTrack !== 'function') return;
+ try {
+ await tx.sender.replaceTrack(track);
+ } catch {
+ // A connection closing under us, or a browser refusing the track.
+ // Every other peer still gets it, and this one is the reset path's
+ // problem rather than this function's.
+ return;
+ }
+ // classifyTrack reads entry.sentVideo to tell an arriving camera
+ // from an arriving screen, and a browser that starts sending video
+ // for the first time here has just changed that answer.
+ if (kind === 'video') entry.sentVideo = true;
+ if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') return;
+ if (entry.pc.signalingState !== 'stable') {
+ entry.upgrades.add(kind);
+ return;
+ }
+ try {
+ tx.direction = 'sendrecv';
+ } catch {
+ // Older browsers make direction read-only. The track is on the
+ // sender either way; it simply is not sent until the next full
+ // renegotiation builds the m-line sending.
+ }
+ }
+
+ // upgradeWhenQuiet runs the direction changes sendTrack could not make
+ // because the connection was mid-negotiation. Called from the same
+ // signalingstatechange that adds a deferred share transceiver.
+ function upgradeWhenQuiet(entry) {
+ if (!entry.upgrades.size || entry.pc.signalingState !== 'stable') return;
+ for (const kind of Array.from(entry.upgrades)) {
+ entry.upgrades.delete(kind);
+ const tx = senderFor(entry, kind);
+ if (!tx || !tx.sender || !tx.sender.track) continue;
+ if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') continue;
+ try {
+ tx.direction = 'sendrecv';
+ } catch {
+ // As above: nothing to do about it, and nothing broken by it.
+ }
+ }
+ }
+
+ // switchDevice is the gesture: one kind, one device id, applied to
+ // every connection and then to the local stream.
+ //
+ // Order matters twice here. The new track is disabled before it is
+ // anywhere near a sender, because a track that goes live for even one
+ // frame while the person is muted has broken the only promise the
+ // mute button makes. And the old track is stopped only once the new
+ // one is on every sender: stopping it first would take the camera
+ // light off a beat early and send black frames in the gap.
+ function switchDevice(kind, deviceId) {
+ const myGen = ++switching[kind];
+ const run = switchQueue.then(() => applySwitch(kind, deviceId, myGen));
+ // The queue must not break on a failure, or one rejected switch
+ // would swallow every switch after it. The caller still sees the
+ // rejection through `run`.
+ switchQueue = run.catch(() => {});
+ return run;
+ }
+
+ // Returns true when the call is actually on the new device, so a
+ // caller that changed a preference to ask for the switch can put it
+ // back when the switch did not happen.
+ async function applySwitch(kind, deviceId, myGen) {
+ // Superseded before this run's turn came round, or the call ended
+ // while it waited. Nothing has been opened yet, so there is nothing
+ // to put back.
+ if (myGen !== switching[kind] || state.stopped) return false;
+ const wanted = kind === 'audio'
+ ? { audio: audioConstraints(Object.assign({}, prefs, { mic: deviceId })) }
+ : { video: deviceId ? { deviceId: { exact: deviceId } } : true };
+ let fresh;
+ try {
+ fresh = await navigator.mediaDevices.getUserMedia(wanted);
+ } catch (err) {
+ if (myGen !== switching[kind]) return false;
+ note(deviceError(kind, err));
+ // The choice is not saved: the pickers are put back to the device
+ // actually in use, so the select never claims a device the call is
+ // not on.
+ fillDevices().catch(() => {});
+ return false;
+ }
+ const track = fresh.getTracks()[0];
+ if (myGen !== switching[kind] || !track || state.stopped) {
+ for (const t of fresh.getTracks()) t.stop();
+ return false;
+ }
+ track.enabled = kind === 'audio' ? !prefs.muted : !prefs.cameraOff;
+ for (const entry of pcs.values()) {
+ // Sequential and not Promise.all: replaceTrack is cheap, the mesh
+ // is four, and a failure on one connection should not abandon the
+ // rest through a rejected race.
+ await sendTrack(entry, kind, track);
+ }
+ // No *generation* check here, and that is deliberate. From the loop
+ // above onwards this run owns what every sender is holding, so
+ // stopping the track to defer to a newer choice would stop the
+ // media the call is sending — until that newer choice arrives, which
+ // it may never do if its own getUserMedia fails, leaving every peer
+ // watching a track that ended. The queued run replaces the senders
+ // and `local` together when its turn comes; until then this is the
+ // truth and is recorded as such.
+ //
+ // The call *ending* is the other thing entirely, and does need
+ // checking: terminal() leaves the page alive, so without this the
+ // line below would build a fresh MediaStream around a camera that
+ // was opened for a call that is already over, and nothing would
+ // ever stop it. A light left on after the meeting ended is the one
+ // way this file can be rude.
+ if (state.stopped) {
+ track.stop();
+ return false;
+ }
+ if (!local) local = new MediaStream();
+ for (const old of kind === 'audio' ? local.getAudioTracks() : local.getVideoTracks()) {
+ local.removeTrack(old);
+ old.stop();
+ }
+ local.addTrack(track);
+ // A track that ends on its own — the device unplugged mid-call —
+ // should leave the pickers telling the truth about what is left.
+ track.addEventListener('ended', () => {
+ fillDevices().catch(() => {});
+ });
+ // An id that names nothing is the browser's default, and the
+ // absence of a remembered device is how the rest of this file and
+ // devices.js both spell that.
+ const key = kind === 'audio' ? 'mic' : 'camera';
+ if (deviceId) prefs[key] = deviceId;
+ else delete prefs[key];
+ savePrefs();
+ note('');
+ applyToggles();
+ renderTiles();
+ // The audio ceiling follows the choice and not the connection: a
+ // studio switch flipped mid-call has to reach the connections that
+ // already exist, and flipped back has to let them go again.
+ // tuneAudioSender reads the preference itself, so this is the same
+ // call in both directions.
+ if (kind === 'audio') {
+ for (const entry of pcs.values()) tuneAudioSender(entry.pc);
+ }
+ await fillDevices().catch(() => {});
+ return true;
+ }
+
+ // deviceError is the sentence for a device that would not open. The
+ // names are getUserMedia's own; the wording says what the person can
+ // do about it, and never that the call is over, because it is not —
+ // the old device is still on every sender.
+ function deviceError(kind, err) {
+ const thing = kind === 'audio' ? 'microphone' : 'camera';
+ if (err && err.name === 'NotAllowedError') {
+ return 'Meet needs permission to use that ' + thing + '. The call is still on the previous one.';
+ }
+ if (err && err.name === 'NotReadableError') {
+ return 'That ' + thing + ' is in use by another app. The call is still on the previous one.';
+ }
+ return 'That ' + thing + ' could not be opened. The call is still on the previous one.';
+ }
+
+ // note writes the device panel's own live region. It is not `say`:
+ // renderStatus owns #status and rewrites it on every render, so a
+ // sentence put there would be gone by the next roster.
+ function note(text) {
+ if (!deviceNote) return;
+ deviceNote.textContent = text;
+ deviceNote.hidden = !text;
+ }
+
+ // fillDevices is devices.js's fillPickers, for the room. The two are
+ // deliberate mirrors — same ids, same rules, same reason for each —
+ // and test/browser/devices.test.mjs holds them to it. Labels only
+ // exist once permission has been granted, which in the room it always
+ // has by the time this runs.
+ async function fillDevices() {
+ if (!pickCamera || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
+ let devices;
+ try {
+ devices = await navigator.mediaDevices.enumerateDevices();
+ } catch {
+ devices = [];
+ }
+ const fillOne = (select, kind, chosen) => {
+ select.replaceChildren();
+ let count = 0;
+ for (const device of devices) {
+ if (device.kind !== kind) continue;
+ count++;
+ const option = document.createElement('option');
+ option.value = device.deviceId;
+ option.textContent = device.label || labelFor(kind, count);
+ if (device.deviceId === chosen) option.selected = true;
+ select.append(option);
+ }
+ return count;
+ };
+ const cameraCount = fillOne(pickCamera, 'videoinput', liveDeviceId('video') || prefs.camera);
+ const micCount = fillOne(pickMic, 'audioinput', liveDeviceId('audio') || prefs.mic);
+ const speakerCount = HAS_SINK_ID ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0;
+ // A field with nothing to choose from is clutter, not help. Unlike
+ // the holding screen, the camera and microphone rows can be empty
+ // here too — a call joined with neither is a call this file is
+ // built to allow.
+ if (cameraRow) cameraRow.hidden = cameraCount === 0;
+ if (micRow) micRow.hidden = micCount === 0;
+ if (speakerRow) speakerRow.hidden = !HAS_SINK_ID || speakerCount === 0;
+ if (rawRow) rawRow.hidden = micCount === 0;
+ }
+
+ function labelFor(kind, count) {
+ return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count;
+ }
+
+ // liveDeviceId is what the call is *actually* on, which is the honest
+ // thing for a select to be showing: prefs can name a device that
+ // startLocal's ladder never managed to open.
+ function liveDeviceId(kind) {
+ const track = local && local.getTracks().find((t) => t.kind === kind && t.readyState === 'live');
+ if (!track || typeof track.getSettings !== 'function') return '';
+ return track.getSettings().deviceId || '';
+ }
+
+ // applySinkAll re-routes every remote tile after a speaker change.
+ // applySink does one element and is what a new tile gets; this is the
+ // same thing for the tiles that already exist.
+ function applySinkAll() {
+ for (const [key, t] of tiles) {
+ if (t.self || key === 'self') continue;
+ applySink(t.video);
+ }
+ }
+
// ---------------------------------------------------------------
// The stream (§4.1, §4.5)
// ---------------------------------------------------------------
@@ -1106,6 +1540,9 @@
// m-line it built for nobody. classifyTrack's direction rule turns
// on which of the two is true.
sentVideo: false,
+ // Kinds whose transceiver switchDevice turned round while the
+ // connection was mid-negotiation, waiting for it to settle.
+ upgrades: new Set(),
sharing: false,
share: null,
// The mid the peer's screen arrives on, so the getStats fallback
@@ -1164,6 +1601,7 @@
// rollback — and then the screen is being sent on the m-line the
// far side means for something else.
addShareTo(entry);
+ upgradeWhenQuiet(entry);
adoptWhenQuiet(id, entry);
// And it is when an ask the negotiation rounds ran out on gets
// its turn (Codex S4-8): a connection that was mid-negotiation is
@@ -1195,6 +1633,7 @@
// here, or a device that went away — is not worth the connection.
// The recvonly transceivers below cover what it would have been.
}
+ tuneAudioSender(pc);
for (const kind of ['video', 'audio']) {
if (hasLiveTrack(kind)) continue;
try {
@@ -3517,6 +3956,14 @@
// enabled — the same rule the two device toggles follow when there
// is no track for them to move.
if (shareButton) shareButton.disabled = !on || !HAS_DISPLAY_MEDIA;
+ // The device panel goes the same way. A terminal screen's pickers
+ // are inert anyway — applySwitch refuses once state.stopped — but a
+ // live-looking select that silently does nothing is the worse half
+ // of that, so they are disabled and say so.
+ if (devicesToggle) devicesToggle.disabled = !on;
+ for (const el of [pickCamera, pickMic, pickSpeaker, rawInput]) {
+ if (el) el.disabled = !on;
+ }
}
// ---------------------------------------------------------------
@@ -3857,6 +4304,98 @@
applyToggles();
});
+ // The device panel. The button is hidden by the server and revealed
+ // only where there is something behind it: a browser with no
+ // enumerateDevices has no list to draw and no switch to offer, and a
+ // control that does nothing is worse than no control.
+ if (devicesToggle && devicesPanel && navigator.mediaDevices &&
+ navigator.mediaDevices.enumerateDevices) {
+ devicesToggle.hidden = false;
+ devicesToggle.addEventListener('click', () => {
+ const open = devicesPanel.hidden;
+ devicesPanel.hidden = !open;
+ devicesToggle.setAttribute('aria-expanded', String(open));
+ // Filled on opening rather than at load: the list is only worth
+ // having when it is about to be looked at, and a device plugged
+ // in during the call is then always in the one that is shown.
+ if (open) {
+ fillDevices().catch((err) => {
+ console.error('meet: devices', err);
+ });
+ if (pickCamera && cameraRow && !cameraRow.hidden) pickCamera.focus();
+ }
+ });
+ if (rawInput) {
+ if (deviceNote || $('raw-help')) rawInput.setAttribute('aria-describedby', 'raw-help');
+ rawInput.checked = !!prefs.rawAudio;
+ }
+ navigator.mediaDevices.addEventListener?.('devicechange', () => {
+ if (!devicesPanel.hidden) fillDevices().catch(() => {});
+ });
+ // Filled once now, as well as on every open. Opening reveals the
+ // panel synchronously and fills it a microtask later, so without
+ // this the first open is a flash of three empty selects — and the
+ // focus below would be deciding on rows whose hidden state nothing
+ // had set yet.
+ fillDevices().catch(() => {});
+ }
+
+ // The three pickers and the studio switch. Each one is a getUserMedia
+ // and a replaceTrack per connection (switchDevice), except the
+ // speaker, which never touches the call at all: it is where this
+ // browser plays what it has already received.
+ if (pickCamera) {
+ pickCamera.addEventListener('change', () => {
+ switchDevice('video', pickCamera.value).catch((err) => {
+ console.error('meet: camera', err);
+ });
+ });
+ }
+ if (pickMic) {
+ pickMic.addEventListener('change', () => {
+ switchDevice('audio', pickMic.value).catch((err) => {
+ console.error('meet: microphone', err);
+ });
+ });
+ }
+ if (pickSpeaker) {
+ pickSpeaker.addEventListener('change', () => {
+ prefs.speaker = pickSpeaker.value;
+ savePrefs();
+ applySinkAll();
+ });
+ }
+ if (rawInput) {
+ // Studio audio is chosen when the track is opened and cannot be
+ // changed on a track already running, so this re-opens the
+ // microphone that is already in use — the same switch as picking it
+ // again from the list, with a different set of constraints behind
+ // it.
+ rawInput.addEventListener('change', () => {
+ const was = !!prefs.rawAudio;
+ prefs.rawAudio = rawInput.checked;
+ // The remembered microphone and not the live one. Passing the id
+ // of whatever happens to be open would write it into prefs.mic
+ // and pin someone who had been following the system default to
+ // one device, for ever, because they touched an audio setting.
+ switchDevice('audio', prefs.mic || '').then((ok) => {
+ if (ok) {
+ savePrefs();
+ return;
+ }
+ // The microphone would not re-open, so the call is still on the
+ // processed track and saying otherwise — in the checkbox or in
+ // localStorage — would be a lie the next call would inherit.
+ prefs.rawAudio = was;
+ rawInput.checked = was;
+ }).catch((err) => {
+ prefs.rawAudio = was;
+ rawInput.checked = was;
+ console.error('meet: studio audio', err);
+ });
+ });
+ }
+
// §5.4's gesture. The handler is not async: a rejection escaping one
// goes where neither a person nor a gate could see it, so the work is
// a named function with a catch of its own — the same shape as the
@@ -4152,8 +4691,23 @@
const sender = tx && tx.sender;
const track = sender && sender.track;
if (!sender || !track || typeof sender.replaceTrack !== 'function') return;
+ // Which camera the call is on, captured before the awaits below.
+ // Asking the sender afterwards would be the more obvious check and
+ // is the weaker one: it reads a browser's own bookkeeping to infer
+ // whether something else has been here, where this asks the
+ // question directly.
+ const generation = switching.video;
try {
await sender.replaceTrack(null);
+ // A device switch that started in this gap owns this sender now,
+ // and the track captured above is the one it is retiring — it
+ // stops it. Putting it back would undo the switch and freeze this
+ // peer on a track that has ended, which is a worse fault than the
+ // stall being repaired. The switch sets its own track on every
+ // sender, so leaving is safe; and if that switch then fails, the
+ // stall detector is still watching and asks again on the next
+ // poll.
+ if (switching.video !== generation || track.readyState === 'ended') return;
await sender.replaceTrack(track);
} catch {
// A connection that is closing. The reset is behind this anyway.
@@ -4386,6 +4940,26 @@
}
window.meet = {
+ // local() is what this tab is actually sending: one entry per track
+ // on the local stream, with its kind and readyState. It is the fact
+ // a device switch has to be judged on and that the DOM cannot
+ // answer — a self tile looks exactly the same whichever microphone
+ // is behind it.
+ //
+ // No device id and no label. devices.js's promise is that neither
+ // ever leaves this browser, and a test hook is not an exception
+ // worth carving into it. Whether a device was *chosen* is a
+ // boolean, which is all a gate needs to tell a switch from a
+ // default.
+ local() {
+ if (!local) return [];
+ return local.getTracks().map((t) => ({
+ kind: t.kind,
+ state: t.readyState,
+ enabled: !!t.enabled,
+ chosen: !!(typeof t.getSettings === 'function' && t.getSettings().deviceId),
+ }));
+ },
// media() is the identity reading the fix wave's second round asked
// for: which track each tile is bound to, which tracks its element
// is actually holding, and what the connection's own receivers say
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html
index ed0e2e4..8575035 100644
--- a/internal/meet/templates/join.html
+++ b/internal/meet/templates/join.html
@@ -19,6 +19,14 @@
<div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div>
<div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div>
<div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
+ {{/* Studio audio: the unprocessed capture option. field-check is the
+ vendored toggle (AGENTS.md's field rule), and the sentence under
+ it is wired to the input with aria-describedby by devices.js,
+ because field-check gives its checkbox no id to write here. The
+ row starts hidden like the speaker row: devices.js reveals it
+ once it knows there is a microphone to apply it to. */}}
+ <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
+ <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div>
</div>
<div class="meet-controls" estilo-call-actions>
<button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
index 6bc84be..ee1d504 100644
--- a/internal/meet/templates/room.html
+++ b/internal/meet/templates/room.html
@@ -28,10 +28,37 @@
<button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
<button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
<button type="button" id="share" rst-btn aria-pressed="false">{{icon "monitor"}}Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button>
+ <button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>{{icon "sliders-horizontal"}}Devices</button>
{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}
<button type="button" id="toggle-chat" rst-btn aria-expanded="true" aria-controls="chat" hidden>{{icon "message-circle"}}Chat<span id="chat-unread" aria-hidden="true"></span></button>
<button type="button" id="leave" rst-btn="danger">{{icon "log-out"}}Leave</button>
</div>
+ {{/* The device panel, closed until asked for. A call's own screen is
+ the video and the controls; three selects permanently under them
+ would be furniture the whole call pays for so that someone can
+ change a microphone once. room.js reveals the button — the panel
+ is useless in a browser that cannot enumerate devices — and fills
+ the selects from the same enumeration the holding screen uses.
+ The ids are devices.js's own on purpose: the two fill routines
+ are mirrors, and a reader comparing them should not also have to
+ translate names.
+
+ It sits after the controls in the workspace, which is where
+ meet.css gives it a row of its own under them: a disclosure
+ belongs beneath the control that opened it, and putting it in
+ the sidebar column would have it argue with chat for the space. */}}
+ <div id="room-devices" class="meet-pickers" hidden>
+ <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div>
+ <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div>
+ <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
+ <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
+ <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div>
+ {{/* Device messages get a live region of their own, like the relay
+ warning: renderStatus owns #status and rewrites it on every
+ render, so a sentence about a camera that would not open has
+ to live somewhere that is not about to be overwritten. */}}
+ <p id="device-note" class="meet-device-note" role="status" hidden></p>
+ </div>
{{template "chat-panel" .}}
</div>
<div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
index 3e64427..03755e0 100644
--- a/internal/meettest/browser_test.go
+++ b/internal/meettest/browser_test.go
@@ -83,6 +83,7 @@ var scenarios = []string{
"layout",
"devices",
"call",
+ "switching",
"share",
"mutualshare",
"laterolls",
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go
index cf3f17d..2ba52c0 100644
--- a/internal/meettest/holding_test.go
+++ b/internal/meettest/holding_test.go
@@ -163,3 +163,43 @@ func TestHoldingPageIs404ForEndedMeetings(t *testing.T) {
t.Fatalf("ended: %d", rec.Code)
}
}
+
+// Studio audio is the unprocessed capture option (the microphone asked
+// for with echo cancellation, noise suppression and automatic gain all
+// off, and a higher Opus ceiling to send it through). Three facts about
+// the markup are worth holding here rather than in a browser, because
+// devices.js only ever moves the checkbox's `checked` and the row's
+// `hidden` and would go on "working" with any of them broken:
+//
+// - it is the vendored field-check and not a hand-rolled checkbox,
+// which AGENTS.md's field rule requires — rst-switch is that
+// partial's own attribute and nothing else in these pages emits it;
+// - the row starts hidden, like the speaker row, so a scriptless
+// browser is not shown a control that cannot do anything;
+// - the help sentence carries the id devices.js wires to the input
+// with aria-describedby, so a rename here that left the script
+// alone would silently unwire the explanation.
+func TestHoldingPageOffersStudioAudioAsAVendoredField(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid + "/join").Body.String()
+ for _, want := range []string{
+ `<div id="raw-row" class="meet-raw-row" hidden>`,
+ `<label rst-switch>`,
+ `name="raw-audio"`,
+ `id="raw-help"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("holding page lacks %q for studio audio: %s", want, body)
+ }
+ }
+ // The sentence has to say what is being turned off. "Studio audio"
+ // alone is a name, not an explanation, and someone reaching for it
+ // on a laptop should be told why not before they hear the echo.
+ for _, want := range []string{"echo cancellation", "noise suppression", "automatic gain"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("studio audio's help sentence does not mention %q: %s", want, body)
+ }
+ }
+}
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
index e2cc078..08faa4f 100644
--- a/internal/meettest/room_test.go
+++ b/internal/meettest/room_test.go
@@ -602,3 +602,50 @@ func TestNativeRoomInfoUsesRoomAuthority(t *testing.T) {
t.Fatal("metadata leaked another meeting")
}
}
+
+// The room's device panel (mid-call switching). The server renders it
+// closed and renders the button that opens it hidden: room.js reveals
+// the button only in a browser that can enumerate devices, so a
+// scriptless page — and a browser with no enumeration — shows neither a
+// panel nor a control that would do nothing. The panel reuses the
+// holding screen's ids on purpose, because the two fill routines are
+// mirrors of each other and test/browser/devices.test.mjs holds them to
+// it; asserting them here is what stops a rename on one page from
+// quietly unwiring the other.
+func TestRoomRendersItsDevicePanelClosed(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid).Body.String()
+ for _, want := range []string{
+ `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>`,
+ `<div id="room-devices" class="meet-pickers" hidden>`,
+ `id="pick-camera"`,
+ `id="pick-mic"`,
+ `id="pick-speaker"`,
+ `id="device-note"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("room page lacks %q for the device panel: %s", want, body)
+ }
+ }
+}
+
+// The device panel's own live region is not #status. renderStatus owns
+// #status and rewrites it on every roster, so a sentence about a camera
+// that would not open would be gone within the tick — which is exactly
+// the moment someone is reading it.
+func TestRoomDeviceNoteIsNotTheStatusLine(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid).Body.String()
+ note := strings.Index(body, `id="device-note"`)
+ status := strings.Index(body, `id="status"`)
+ if note < 0 || status < 0 {
+ t.Fatalf("room page is missing one of the two regions: note=%d status=%d", note, status)
+ }
+ if note == status {
+ t.Fatal("the device note and the status line are the same element")
+ }
+}
diff --git a/test/browser/audio.test.mjs b/test/browser/audio.test.mjs
new file mode 100644
index 0000000..fe83d1e
--- /dev/null
+++ b/test/browser/audio.test.mjs
@@ -0,0 +1,130 @@
+// audio.test.mjs — the studio audio choice, gated without a browser.
+//
+// node --test test/browser/audio.test.mjs
+//
+// `audioConstraints` decides what the microphone is asked for, and it
+// is written twice on purpose: the holding screen opens a stream and
+// stops it, and the room opens its own at join, so devices.js and
+// room.js each carry a copy. Duplication between those two files is
+// this app's existing shape (forgetDevices is the same arrangement),
+// and the cost of it is drift — a choice that holds on the holding
+// screen, is silently dropped at join, and leaves someone listening to
+// noise suppression they turned off one page earlier.
+//
+// So the first test here is not about constraints at all. It is that
+// the two copies are still the same source. The rest is lifted out of
+// devices.js and run, the way negotiation.test.mjs runs offerCollision,
+// so what is under test is the shipped function and not a copy that can
+// go stale quietly.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+
+// lift returns the source of one `function name(...) {...}` by matching
+// braces from its declaration — negotiation.test.mjs's helper, and it
+// fails loudly on a rename rather than passing on a stale duplicate.
+function lift(source, name, where) {
+ let start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing');
+ if (source.slice(start - 6, start) === 'async ') start -= 6;
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of ' + where);
+}
+
+const devicesSrc = readFileSync(devicesJS, 'utf8');
+const roomSrc = readFileSync(roomJS, 'utf8');
+
+const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js');
+const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js');
+
+test('devices.js and room.js still ask for the microphone the same way', () => {
+ // If this fails, one of the two was edited and the other was not. Copy
+ // the change across rather than relaxing the assertion: the point of
+ // the pair is that the holding screen's choice survives the join.
+ assert.equal(fromDevices, fromRoom);
+});
+
+// eslint-disable-next-line no-new-func
+const audioConstraints = new Function(fromDevices + '; return audioConstraints;')();
+
+test('the voice pipeline is on unless it is turned off', () => {
+ const c = audioConstraints({});
+ assert.equal(c.echoCancellation, true);
+ assert.equal(c.noiseSuppression, true);
+ assert.equal(c.autoGainControl, true);
+ assert.equal(c.voiceIsolation, true);
+});
+
+test('studio audio turns all of it off', () => {
+ const c = audioConstraints({ rawAudio: true });
+ assert.equal(c.echoCancellation, false);
+ assert.equal(c.noiseSuppression, false);
+ assert.equal(c.autoGainControl, false);
+ assert.equal(c.voiceIsolation, false);
+});
+
+test('the processing is asked for as an ideal and never as exact', () => {
+ // The safety property, and the reason rawHonoured exists. `exact` here
+ // would mean a browser that cannot turn its filters off refuses
+ // getUserMedia outright — costing someone their microphone over a
+ // preference, which is the opposite of what the ladder in either file
+ // is built to do. Booleans are ideals; objects are not.
+ for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) {
+ const c = audioConstraints(prefs);
+ for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) {
+ assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key]));
+ }
+ }
+});
+
+test('a remembered microphone is still asked for exactly', () => {
+ // The device id is the one thing that *is* exact: a remembered device
+ // that has gone should fail so the caller can notice and forget it,
+ // which is what disambiguate and forgetDevices are for.
+ const c = audioConstraints({ mic: 'mic-1' });
+ assert.deepEqual(c.deviceId, { exact: 'mic-1' });
+});
+
+test('no remembered microphone names no device at all', () => {
+ assert.equal('deviceId' in audioConstraints({}), false);
+});
+
+test('studio audio and a remembered microphone are independent', () => {
+ const c = audioConstraints({ mic: 'mic-1', rawAudio: true });
+ assert.deepEqual(c.deviceId, { exact: 'mic-1' });
+ assert.equal(c.noiseSuppression, false);
+});
+
+test('the room lifts the audio ceiling, and only for studio audio', () => {
+ // Turning the filters off buys nothing if Opus then runs at its
+ // default of roughly 32 kbit/s, so the two ship together. The number
+ // is read out of room.js rather than restated here.
+ const m = /const RAW_AUDIO_BITRATE = (\d+);/.exec(roomSrc);
+ assert.notEqual(m, null, 'RAW_AUDIO_BITRATE is not a number constant in room.js any more');
+ assert.ok(Number(m[1]) >= 64000, 'a ceiling below 64 kbit/s is not worth the switch');
+
+ // The ceiling follows the preference, not the connection. tuneAudioSender
+ // runs for every connection and decides inside itself, so studio audio
+ // flipped mid-call reaches the connections that already exist — and
+ // flipped back lets them go again. Gating the *call* on prefs.rawAudio
+ // was the half-feature: it only ever raised, and only on connections
+ // built after the switch.
+ const tune = /function tuneAudioSender\(pc\) \{[\s\S]*?\n \}/.exec(roomSrc);
+ assert.notEqual(tune, null, 'tuneAudioSender is not in room.js any more');
+ assert.match(tune[0], /if \(prefs\.rawAudio\)/, 'tuneAudioSender must read the preference itself');
+ assert.match(tune[0], /delete params\.encodings\[0\]\.maxBitrate/,
+ 'tuneAudioSender must give the ceiling back when studio audio goes off');
+ assert.match(roomSrc, /for \(const entry of pcs\.values\(\)\) tuneAudioSender\(entry\.pc\);/,
+ 'an audio switch must re-tune the connections that already exist');
+});
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 6b3143b..376e8c1 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1193,6 +1193,113 @@ const run = {
await ctxB.close();
},
+ // Changing a device without leaving the call. The panel opens, the
+ // microphone and then the camera are chosen again from the list, and
+ // the far side's media is watched across both.
+ //
+ // Picking the same device again is the point rather than a weakness
+ // of the test. What is under test is the swap — getUserMedia, a
+ // replaceTrack on every connection's sender, the local stream rebuilt
+ // under the self tile — and none of that cares whether the id it is
+ // handed differs from the one already in use. Chromium's fake capture
+ // offers one camera and one microphone, so a "different" device here
+ // would be a second fake and not a second assertion.
+ //
+ // The assertion that matters is the negative one: a swap must not
+ // cost the call anything. replaceTrack does not renegotiate, so a
+ // regression that reached for addTrack, or that stopped the old track
+ // before the new one was on the senders, shows up here as the far
+ // side's frames stopping.
+ async switching() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) {
+ await ctxA.close();
+ await ctxB.close();
+ return;
+ }
+
+ // room.js reveals the button only where there is an enumeration
+ // behind it, so its being visible is itself the feature detection
+ // working.
+ const shown = await until(async () => await pageA.locator('#devices-toggle').isVisible(), 10000);
+ check(shown, 'the Devices button is revealed in the room');
+ await pageA.click('#devices-toggle');
+ check(await pageA.locator('#room-devices').isVisible(), 'the device panel opens');
+ check(await pageA.locator('#devices-toggle').getAttribute('aria-expanded') === 'true',
+ 'the Devices button reports itself expanded');
+
+ const mics = await until(async () => {
+ const n = await pageA.locator('#pick-mic option').count();
+ return n > 0 ? n : false;
+ }, 10000);
+ check(mics, 'the room lists at least one microphone (got ' + mics + ')');
+ const cameras = await pageA.locator('#pick-camera option').count();
+ check(cameras > 0, 'the room lists at least one camera (got ' + cameras + ')');
+
+ const sides = [
+ { page: pageB, label: 'member B', keys: [pair.a.peer] },
+ { page: pageA, label: 'member A', keys: [pair.b.peer] },
+ ];
+ for (const [selector, what] of [['#pick-mic', 'microphone'], ['#pick-camera', 'camera']]) {
+ const value = await pageA.locator(selector + ' option').first().getAttribute('value');
+ await pageA.selectOption(selector, value);
+ // The swap is asynchronous behind the change event: one
+ // getUserMedia and a replaceTrack per connection. Nothing in the
+ // page announces it finishing, so what is waited for is the thing
+ // that must be true afterwards — a live local track of that kind
+ // — and then the media is watched over a duration.
+ const live = await until(async () => await pageA.evaluate((kind) => {
+ const tracks = (window.meet && window.meet.local && window.meet.local()) || [];
+ const of = tracks.filter((t) => t.kind === kind);
+ return of.length === 1 && of[0].state === 'live' && of[0].chosen;
+ }, what === 'microphone' ? 'audio' : 'video'), 15000);
+ check(live, 'member A holds exactly one live ' + what + ' track after the switch');
+
+ check((await pageA.locator('#device-note').textContent() || '').trim() === '',
+ 'switching the ' + what + ' reported no trouble (' +
+ (await pageA.locator('#device-note').textContent()) + ')');
+
+ await watchMedia(sides, 'after the ' + what + ' was switched: ');
+ }
+
+ // Three changes with nothing awaited between them, which is one
+ // person clicking through a list rather than choosing from it. Each
+ // is a getUserMedia and a replaceTrack across the mesh, and run at
+ // once they would be two loops writing to the same senders — what
+ // every peer then receives decided by whichever device opened
+ // fastest. Serialised, the last click wins and the media never
+ // stops; the failure this catches is the middle run stopping a
+ // track the senders were still holding, which reads at the far end
+ // as the call simply ending.
+ const camera = await pageA.locator('#pick-camera option').first().getAttribute('value');
+ await Promise.all([
+ pageA.selectOption('#pick-camera', camera),
+ pageA.selectOption('#pick-camera', camera),
+ pageA.selectOption('#pick-camera', camera),
+ ]);
+ const settled = await until(async () => await pageA.evaluate(() => {
+ const tracks = (window.meet && window.meet.local && window.meet.local()) || [];
+ const video = tracks.filter((t) => t.kind === 'video');
+ return video.length === 1 && video[0].state === 'live';
+ }), 20000);
+ check(settled, 'member A holds exactly one live camera after three switches at once — ' +
+ JSON.stringify(await pageA.evaluate(() => window.meet.local())));
+ await watchMedia(sides, 'after three switches at once: ');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// §5.4 and §13's slice-4 sentence: "the higher-id context shares its
// screen and the lower receives a second video track, then stops, the
// received track ends, the share tile is removed and the camera track
diff --git a/test/browser/devices.test.mjs b/test/browser/devices.test.mjs
new file mode 100644
index 0000000..6b6c4bc
--- /dev/null
+++ b/test/browser/devices.test.mjs
@@ -0,0 +1,116 @@
+// devices.test.mjs — the two device pickers, held to each other.
+//
+// node --test test/browser/devices.test.mjs
+//
+// The holding screen (devices.js) and the room (room.js) both enumerate
+// devices and fill the same three selects, and they are separate
+// routines because they want different things from the answer: the
+// holding screen is choosing what to open, the room is showing what is
+// already open. What they must not differ on is the rules — which row
+// is hidden when, and what an unlabelled device is called — because a
+// person who saw "Microphone 2" on one page and something else on the
+// next would reasonably think they had changed device when they had
+// not.
+//
+// This is the cheap half of that, checked without a browser. The
+// expensive half is the `switching` scenario in call.mjs, which does a
+// real swap on a real call.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url));
+const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url));
+
+function lift(source, name, where) {
+ let start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing');
+ if (source.slice(start - 6, start) === 'async ') start -= 6;
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of ' + where);
+}
+
+const devicesSrc = readFileSync(devicesJS, 'utf8');
+const roomSrc = readFileSync(roomJS, 'utf8');
+
+test('both pages name an unlabelled device the same way', () => {
+ assert.equal(lift(devicesSrc, 'labelFor', 'devices.js'), lift(roomSrc, 'labelFor', 'room.js'));
+});
+
+// eslint-disable-next-line no-new-func
+const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')();
+
+test('an unlabelled device is numbered, not blank', () => {
+ // enumerateDevices returns empty labels until permission has been
+ // granted, and on some browsers for some devices even after. A select
+ // full of blank options is a control nobody can use.
+ assert.equal(labelFor('videoinput', 1), 'Camera 1');
+ assert.equal(labelFor('audioinput', 2), 'Microphone 2');
+});
+
+test('both pages hide a row with nothing in it', () => {
+ // A field with no options is clutter, not help — the rule the holding
+ // screen has always followed, and the room now has to as well.
+ for (const [src, where] of [[devicesSrc, 'devices.js'], [roomSrc, 'room.js']]) {
+ for (const row of ['cameraRow', 'micRow']) {
+ assert.match(src, new RegExp(row + '\\.hidden = '),
+ where + ' never hides ' + row);
+ }
+ }
+});
+
+test('both pages gate the speaker row on setSinkId', () => {
+ // A speaker picker in a browser that cannot route output is a control
+ // that does nothing, which is the one thing worse than no control.
+ assert.match(devicesSrc, /speakerRow\.hidden = !hasSinkId \|\| speakerCount === 0;/);
+ assert.match(roomSrc, /speakerRow\.hidden = !HAS_SINK_ID \|\| speakerCount === 0;/);
+});
+
+test('the room only ever swaps a track, never adds an m-line', () => {
+ // The whole reason a mid-call switch is safe is that replaceTrack does
+ // not renegotiate. A regression that reached for addTrack here would
+ // put an offer in flight on every device change, straight into the
+ // glare §5.1 spends 300 ms avoiding — and it would pass every test
+ // that only looked at the local stream.
+ const sendTrack = lift(roomSrc, 'sendTrack', 'room.js');
+ assert.match(sendTrack, /replaceTrack\(track\)/);
+ assert.equal(/addTrack|addTransceiver/.test(sendTrack), false,
+ 'sendTrack must not add tracks or transceivers — replaceTrack is what makes this free');
+});
+
+test('switches are serialised, so two clicks cannot interleave', () => {
+ // Two switches running at once are two replaceTrack loops over the
+ // same senders, and what every peer ends up receiving is then decided
+ // by whichever getUserMedia happened to settle last. The queue is what
+ // makes the last click win instead of the fastest device.
+ const src = lift(roomSrc, 'switchDevice', 'room.js');
+ assert.match(src, /switchQueue\s*=/, 'switchDevice must chain onto the queue, not run immediately');
+ assert.match(src, /switchQueue\.then\(/, 'switchDevice must wait for the switch before it');
+ // A rejected switch must not take every later switch with it.
+ assert.match(src, /\.catch\(/);
+});
+
+test('a switch superseded before it opens anything puts nothing back', () => {
+ // The three generation checks, and the one place there deliberately
+ // is not one. Before getUserMedia there is nothing to tidy; between
+ // getUserMedia and the sender loop the track is this run's alone and
+ // is stopped; *after* the sender loop it belongs to every sender in
+ // the mesh, and stopping it there would stop the call's own media
+ // until a newer switch arrived — which it might never do.
+ const src = lift(roomSrc, 'applySwitch', 'room.js');
+ const guards = src.match(/myGen !== switching/g) || [];
+ assert.equal(guards.length, 3, 'applySwitch checks its generation ' + guards.length + ' times, expected exactly 3');
+ assert.match(src, /for \(const t of fresh\.getTracks\(\)\) t\.stop\(\);/,
+ 'the superseded path must stop the tracks getUserMedia handed it');
+ const afterLoop = src.slice(src.indexOf('await sendTrack('));
+ assert.equal(/myGen !== switching/.test(afterLoop), false,
+ 'there must be no generation check after the sender loop — see the comment there');
+});
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
index dc234e2..68dd7fe 100644
--- a/test/browser/room-lane.test.mjs
+++ b/test/browser/room-lane.test.mjs
@@ -1228,12 +1228,19 @@ test('a muted or ended track is nobody here to repair', () => {
const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS');
const CAMERA_REPAIRS = pin('CAMERA_REPAIRS');
+// `switching` is the room's per-kind device-switch counter, injected
+// rather than stubbed inside the box: repairCamera reads it to find out
+// whether a device switch took ownership of the sender while it was
+// awaiting, and a test that could not move it could not exercise the
+// one case where the repair must stand down.
+const switching = { audio: 0, video: 0 };
+
const cameraBox = new Function(
- 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS',
+ 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', 'switching',
[lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') +
'\nreturn { judgeCamera, cameraReport, repairCamera };',
);
-const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS);
+const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching);
// A camera transceiver as the watch sees one, and the record of what
// was done to its sender.
@@ -1379,6 +1386,37 @@ test('the repair takes the track off its sender and puts it back', async () => {
'the encoder is rebuilt by removing the track and restoring it, in that order');
});
+test('a repair overtaken by a device switch leaves the new track alone', async () => {
+ // The gap between the two replaceTrack calls is the only place a
+ // device switch can do damage here: the track captured on the way in
+ // is the one that switch is retiring, and putting it back would undo
+ // the switch and leave every peer watching a track that has ended.
+ // The stall that prompted this repair is still being watched, so
+ // standing down costs a poll and not a camera.
+ const c = cameraTx();
+ const replace = c.tx.sender.replaceTrack;
+ c.tx.sender.replaceTrack = async (t) => {
+ const out = await replace(t);
+ if (t === null) switching.video += 1;
+ return out;
+ };
+ await cam.repairCamera(c.tx);
+ assert.deepEqual(c.calls, ['null'], 'the repair stops once a switch owns the sender');
+ switching.video = 0;
+});
+
+test('a repair does not restore a track that has ended under it', async () => {
+ const c = cameraTx();
+ const replace = c.tx.sender.replaceTrack;
+ c.tx.sender.replaceTrack = async (t) => {
+ const out = await replace(t);
+ if (t === null) c.track.readyState = 'ended';
+ return out;
+ };
+ await cam.repairCamera(c.tx);
+ assert.deepEqual(c.calls, ['null'], 'an ended track is not worth putting back');
+});
+
test('a sender that cannot replace its track is left alone', async () => {
const c = cameraTx({ noReplace: true });
await assert.doesNotReject(() => cam.repairCamera(c.tx));