oficina / meet Public

chat: the panel partial and script, estilo's first chat

The panel is three files that know nothing about Meet — the partial, chat.js and chat.css — so a second app adopts all three unchanged and writes only its own send function. Mounted inside #room after the controls, hidden with it; not driven yet.

Two deviations from the brief. ui's field-textarea has no Maxlength key, so the vendored field envelope is written out by hand with the same attributes and wiring plus maxlength=2048, §4.4's courtesy cap on the control the server refuses above. And mount takes opts.name beside opts.send: the panel appends the local echo itself on an accepted send, so it needs a name to put on that line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev c997b9ba6e7380661b44f637557f62575d0e4160 parent fa8b40d
9 files changed, +708 −12
  • Makefile +8 −5
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +30 −0
  • internal/meet/render.go +15 −7
  • internal/meet/static/chat.css +108 −0
  • internal/meet/static/chat.js +230 −0
  • internal/meet/templates/partials/chat.html +34 −0
  • internal/meet/templates/room.html +8 −0
  • internal/meettest/room_test.go +71 −0
  • test/browser/chat.test.mjs +204 −0

CI passed — run details

diff --git a/Makefile b/Makefile
index 75d535d..962231d 100644
--- a/Makefile
+++ b/Makefile
@@ -128,14 +128,17 @@ ci: vet fmt-check test migration-check
# -count=1 because call.mjs is not an input to Go's test cache: node
# opens it, not the test, so an edited browser script would otherwise be
# answered with a cached pass from before the edit.
-# negotiation.test.mjs runs first because it needs no browser at all: it
-# lifts one pure function out of room.js and checks §5.1's glare
-# decision against the W3C truth table, in forty milliseconds. Named
-# explicitly rather than by directory — node's test runner treats
-# everything under test/ as a test file, and call.mjs is not one.
+# negotiation.test.mjs and chat.test.mjs run first because they need no
+# browser at all: they lift pure functions out of room.js and chat.js by
+# name and check §5.1's glare decision against the W3C truth table, and
+# §6's DOM safety, the log cap and §4.4's byte counter, in under a tenth
+# of a second between them. Named explicitly rather than by directory —
+# node's test runner treats everything under test/ as a test file, and
+# call.mjs is not one.
browser:
go vet -tags browser ./internal/meettest/
node --test test/browser/negotiation.test.mjs
+ node --test test/browser/chat.test.mjs
npm install --no-save playwright@1.62.1
npx playwright install chromium
go test -tags browser -count=1 -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/docs/superpowers/copy/2026-09-10-slice-2-strings.md b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
index f730c84..dddef6f 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -210,3 +210,33 @@ console.
| meet: ice | `room.js`, the one-minute retry's rejection handler | Same shape as `meet: reset`, `meet: pageshow`, `meet: start`. Never rendered. |
| meet: renew | `room.js`, the renewal's rejection handlers | As above. |
| meet: roster | `room.js`, `onRoster()` — the roster path is asynchronous now that §5.2's fetch is in front of the first connection | As above. |
+
+## Slice 4
+
+The chat and screen-share slice
+(`docs/superpowers/plans/2026-09-10-meet-slice-4-chat.md`). Copy review
+is waived this round per suite discussion 5; every new string is
+catalogued here instead.
+
+### The chat panel
+
+`internal/meet/templates/partials/chat.html` and
+`internal/meet/static/chat.js` — the suite's first chat panel (spec §6,
+§10). These five strings belong to the panel and not to Meet: they ship
+with the partial and the script, and a second app that adopts those two
+files adopts these words with them. That is the reason to look hard at
+them now — a sentence changed after a second app has taken it is a
+sentence changed in two products.
+
+| String | Where | Note |
+| --- | --- | --- |
+| Chat | `chat.html`, the panel's `<h2 class="chat-title">` and its `aria-label` | The panel's name, in the heading and again on the section so the region is announced by name. One word, and the same word twice on purpose: a label that disagrees with the visible heading is worse than either alone. |
+| Message | `chat.html`, the `<label for="chat-text">` | The control's label. Not "Your message" and not a placeholder: a label that only exists until you type is not a label. |
+| Send | `chat.html`, the submit button | The verb, not "Submit". |
+| Could not send. Try again. | `chat.js`, `#chat-status` — a `role="status"` paragraph inside the form | Shown when the host's `send` resolves false or throws: the message was not accepted, so nobody will ever see it and nothing was echoed into the log. The draft stays in the box, which is what "Try again" means here — press Send again, not retype it. Says what happened and does not diagnose the network (§5.5's rule, applied to chat). |
+| Message is too long. | `chat.js`, `#chat-status` | Shown while the textarea holds more than 2048 **bytes** of UTF-8 (§4.4), counted with `TextEncoder`, and the Send button is disabled behind it. The control's own `maxlength="2048"` counts characters, so for anything outside ASCII the browser will happily allow a message the server would refuse with a 413 — this sentence is what stands in that gap. Present tense because it describes the box as it is right now, and it clears itself when the draft comes back under the limit. |
+
+Neither sentence is carried by colour: `.chat-status` paints in
+`--rst-text`, the ordinary text colour, and the weight it adds is a
+second cue on top of the words rather than the signal itself
+(AGENTS.md, "State — never colour alone").
diff --git a/internal/meet/render.go b/internal/meet/render.go
index 81bcea3..bb393db 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -27,15 +27,23 @@ func init() {
}
}
-// buildPage parses rastrillo's ui partials first, then the layout and
-// the page, so a page can call confirm-form, field, empty-state and the
-// rest by name with a dict.
+// buildPage parses rastrillo's ui partials first, then this app's own
+// partials, then the layout and the page, so a page can call
+// confirm-form, field, empty-state and the rest by name with a dict —
+// and chat-panel, the one partial Meet owns, the same way.
+//
+// templates/partials/ is parsed for every page rather than per page
+// that uses it: a partial is defined-but-unused for free, and the
+// alternative is a second list beside pageNames that has to be kept in
+// step with the first.
func buildPage(name string) *template.Template {
return template.Must(
- template.Must(template.New("layout").
- Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{"asset": assets.Path}).
- ParseFS(ui.Templates(), "*.html")).
+ template.Must(
+ template.Must(template.New("layout").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path}).
+ ParseFS(ui.Templates(), "*.html")).
+ ParseFS(appFS, "templates/partials/*.html")).
ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
}
diff --git a/internal/meet/static/chat.css b/internal/meet/static/chat.css
new file mode 100644
index 0000000..1d9fdcb
--- /dev/null
+++ b/internal/meet/static/chat.css
@@ -0,0 +1,108 @@
+/* chat.css: the panel half of the suite's first chat (spec §6, §10).
+ Companion to templates/partials/chat.html and static/chat.js, and
+ like both of them it names nothing about Meet: every value here is an
+ --rst-* token the theme already defines, so a second app that adopts
+ these three files gets its own theme's colours, spacing and radii
+ without touching a line.
+
+ .chat is given a display of its own, and the UA's display: none for
+ the hidden attribute loses to any author rule that sets display on
+ the same element — so the panel carries its own [hidden] rule, the
+ convention meet.css states at the top of itself. */
+.chat[hidden] {
+ display: none;
+}
+
+.chat {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-2);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ padding: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-4);
+}
+
+.chat-title {
+ margin: 0;
+ font-size: var(--rst-fs-base);
+ color: var(--rst-text-muted);
+}
+
+/* A window on the conversation, not the whole page: the log scrolls
+ inside its own box so the controls under it stay reachable however
+ long the call runs. */
+.chat-log {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-block-size: 16rem;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-1);
+}
+
+.chat-line {
+ line-height: 1.5;
+ /* A message is other people's text: it wraps, including a single
+ unbroken run of characters, rather than widening the panel. */
+ overflow-wrap: anywhere;
+}
+
+.chat-name {
+ font-weight: 600;
+}
+
+.chat-time {
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Your own line is marked by weight and by the name already on it,
+ never by colour alone: the name is the whole signal and this is a
+ second cue on top of it. */
+.chat-line-self .chat-name {
+ font-weight: 700;
+}
+
+.chat-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: var(--rst-sp-2);
+ margin: 0;
+}
+
+.chat-label {
+ flex: 1 0 100%;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ margin-block-end: -0.2rem;
+}
+
+/* The control grows and the button keeps its own width; rst-input's
+ width: 100% is what makes the flex basis behave, so the textarea gets
+ a min-inline-size rather than relying on the intrinsic one. */
+.chat-form textarea {
+ flex: 1 1 12rem;
+ min-inline-size: 8rem;
+ min-height: 3rem;
+}
+
+.chat-form button {
+ flex: 0 0 auto;
+}
+
+/* The two sentences the panel can say. They are sentences, which is the
+ point: colour carries nothing here that the words do not. */
+.chat-status {
+ flex: 1 0 100%;
+ margin: 0;
+ min-height: 1.2em;
+ color: var(--rst-text);
+ font-size: var(--rst-fs-sm);
+ font-weight: 600;
+}
diff --git a/internal/meet/static/chat.js b/internal/meet/static/chat.js
new file mode 100644
index 0000000..91d39bb
--- /dev/null
+++ b/internal/meet/static/chat.js
@@ -0,0 +1,230 @@
+// chat.js: the panel half of the suite's first chat (spec §6, §10).
+//
+// One file, no library, no framework, and — deliberately — no knowledge
+// of Meet. It does not read a sqid, does not touch #room, does not know
+// room.js exists, and never talks to a server: the host hands it a
+// `send` function and feeds it arriving messages. A second app adopts
+// this file, templates/partials/chat.html and static/chat.css unchanged
+// and writes only its own send function.
+//
+// const chat = window.Chat.mount(document.querySelector('[data-chat]'), {
+// name: 'Ann', // whose the local echo is
+// send: (text) => Promise<boolean>, // true when it was accepted
+// });
+// chat.append({ from, name, text, at, self });
+// chat.setEnabled(false); // no send while not admitted
+// chat.clear();
+//
+// House rules, the same ones room.js is written under: no inline script
+// (the page's CSP is `default-src 'self'`), nothing built with
+// innerHTML, and every string that came from another person written
+// with textContent. Chat text arrives from a peer through a server that
+// reads nothing inside it (§4.4), so this file is the last thing
+// standing between that text and the DOM: it sets textContent and it
+// does not linkify (§6, version one).
+//
+// Nothing here is stored. There is no localStorage, no history and no
+// replay: the log starts empty and goes with the tab (§6).
+(() => {
+ 'use strict';
+
+ // §4.4's limit, in bytes of UTF-8, not characters. The control's
+ // maxlength caps characters, which is the courtesy the browser can
+ // offer for free; this is the one the server actually measures, and
+ // for anything outside ASCII the two disagree.
+ const MAX_BYTES = 2048;
+
+ // The log is a window, not a record. Five hundred lines is far past
+ // anything a call produces and small enough that a wedged sender
+ // cannot grow the document without bound.
+ const MAX_LINES = 500;
+
+ // How close to the bottom still counts as "reading the newest line".
+ // Anyone scrolled further up than this is reading something older and
+ // must not be yanked away from it by an arrival.
+ const PINNED_PX = 24;
+
+ const SEND_FAILED = 'Could not send. Try again.';
+ const TOO_LONG = 'Message is too long.';
+
+ // chatBytes is the byte counter: the length of text as UTF-8, which
+ // is what §4.4's 2 KB counts and what the server will refuse above.
+ // Lifted by name in test/browser/chat.test.mjs.
+ function chatBytes(text) {
+ return new TextEncoder().encode(String(text == null ? '' : text)).length;
+ }
+
+ // chatLine builds one line of the log and nothing else: no document
+ // lookups, no state, the document passed in so it can be driven
+ // without a browser. Every string goes in through textContent, so an
+ // angle bracket in a message is an angle bracket on the screen and
+ // never markup. Lifted by name in test/browser/chat.test.mjs.
+ //
+ // The timestamp is local and the machine-readable instant goes in the
+ // datetime attribute, which is what a reader's own tooling wants; an
+ // `at` this file cannot make sense of becomes "now" rather than an
+ // Invalid Date on the screen.
+ function chatLine(doc, entry) {
+ const e = entry || {};
+ const li = doc.createElement('li');
+ li.className = e.self ? 'chat-line chat-line-self' : 'chat-line';
+
+ const name = doc.createElement('span');
+ name.className = 'chat-name';
+ name.textContent = e.name == null ? '' : String(e.name);
+
+ let at = e.at instanceof Date ? e.at : (e.at == null ? new Date() : new Date(e.at));
+ if (isNaN(at.getTime())) at = new Date();
+ const time = doc.createElement('time');
+ time.className = 'chat-time';
+ time.setAttribute('datetime', at.toISOString());
+ const hh = String(at.getHours()).padStart(2, '0');
+ const mm = String(at.getMinutes()).padStart(2, '0');
+ time.textContent = hh + ':' + mm;
+
+ const text = doc.createElement('span');
+ text.className = 'chat-text';
+ text.textContent = e.text == null ? '' : String(e.text);
+
+ li.appendChild(name);
+ li.appendChild(doc.createTextNode(' '));
+ li.appendChild(time);
+ li.appendChild(doc.createTextNode(' '));
+ li.appendChild(text);
+ return li;
+ }
+
+ // trimLog drops the oldest lines until the log is back inside max.
+ // Oldest first, because the newest line is the one being read.
+ // Lifted by name in test/browser/chat.test.mjs.
+ function trimLog(log, max) {
+ while (log.childElementCount > max) {
+ log.removeChild(log.firstElementChild);
+ }
+ }
+
+ // mount wires one panel and hands back the three things a host needs.
+ // A root that is not there gets a working no-op back rather than a
+ // thrown error: a host that renders the panel conditionally should
+ // not have to guard every call site.
+ function mount(root, opts) {
+ const options = opts || {};
+ if (!root) {
+ return { append() {}, setEnabled() {}, clear() {} };
+ }
+
+ const doc = root.ownerDocument || document;
+ const log = root.querySelector('[data-chat-log]');
+ const form = root.querySelector('[data-chat-form]');
+ const input = root.querySelector('[data-chat-text]');
+ const button = root.querySelector('[data-chat-send]');
+ const status = root.querySelector('[data-chat-status]');
+ if (!log || !form || !input) {
+ return { append() {}, setEnabled() {}, clear() {} };
+ }
+
+ // Enabled is the host's word (admitted, still connected); tooLong
+ // is this panel's own. The button is off when either says so, and
+ // the two are tracked apart so re-enabling after a lost connection
+ // does not re-enable a button over an over-long draft.
+ let enabled = true;
+ let sending = false;
+
+ function say(sentence) {
+ if (status) status.textContent = sentence;
+ }
+
+ function tooLong() {
+ return chatBytes(input.value) > MAX_BYTES;
+ }
+
+ function refresh() {
+ const over = tooLong();
+ input.disabled = !enabled;
+ if (button) button.disabled = !enabled || sending || over;
+ if (over) say(TOO_LONG);
+ else if (status && status.textContent === TOO_LONG) say('');
+ }
+
+ function submit() {
+ if (!enabled || sending) return;
+ const text = input.value.trim();
+ if (!text) return;
+ if (chatBytes(text) > MAX_BYTES) {
+ say(TOO_LONG);
+ return;
+ }
+ // Clicking Send disables the button under the pointer, which
+ // drops focus to the body; anyone who was inside the panel when
+ // they sent gets the caret back in the box afterwards.
+ const inPanel = root.contains(doc.activeElement);
+ sending = true;
+ refresh();
+ // The host's send may return a promise, a plain boolean, or throw;
+ // all three end in the same two outcomes. A refusal keeps the
+ // draft in the box — the person can press Send again — and only
+ // an accepted message is echoed locally, because a message the
+ // server never took is one nobody else will ever see.
+ Promise.resolve()
+ .then(() => (options.send ? options.send(text) : false))
+ .then((ok) => {
+ if (!ok) {
+ say(SEND_FAILED);
+ return;
+ }
+ say('');
+ input.value = '';
+ api.append({ name: options.name, text: text, self: true });
+ })
+ .catch(() => say(SEND_FAILED))
+ .then(() => {
+ sending = false;
+ refresh();
+ if (inPanel && enabled) input.focus();
+ });
+ }
+
+ const api = {
+ // append renders one arrived (or locally echoed) message. It
+ // keeps the newest line in view for a reader who is at the
+ // bottom, and leaves the scroll alone for one who is not.
+ append(entry) {
+ const pinned =
+ log.scrollHeight - log.scrollTop - log.clientHeight <= PINNED_PX;
+ log.appendChild(chatLine(doc, entry));
+ trimLog(log, MAX_LINES);
+ if (pinned) log.scrollTop = log.scrollHeight;
+ },
+ // setEnabled is the host's gate: a participant who is not
+ // admitted, or whose stream has gone, can read what is there and
+ // cannot send.
+ setEnabled(value) {
+ enabled = !!value;
+ refresh();
+ },
+ clear() {
+ while (log.firstChild) log.removeChild(log.firstChild);
+ },
+ };
+
+ form.addEventListener('submit', (ev) => {
+ ev.preventDefault();
+ submit();
+ });
+
+ // Enter sends and Shift+Enter is a newline, which is what a chat
+ // box is expected to do — but never mid-composition, where Enter
+ // is how an IME commits the characters being typed.
+ input.addEventListener('keydown', (ev) => {
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.isComposing || ev.keyCode === 229) return;
+ ev.preventDefault();
+ submit();
+ });
+
+ input.addEventListener('input', refresh);
+ refresh();
+ return api;
+ }
+
+ window.Chat = { mount: mount };
+})();
diff --git a/internal/meet/templates/partials/chat.html b/internal/meet/templates/partials/chat.html
new file mode 100644
index 0000000..a08e6d1
--- /dev/null
+++ b/internal/meet/templates/partials/chat.html
@@ -0,0 +1,34 @@
+{{/* chat-panel — the suite's first chat panel (spec §6, §10): one
+ self-contained partial whose only companions are static/chat.js and
+ static/chat.css. It names nothing about Meet — no sqid, no room, no
+ room.js — so a second app adopts all three files unchanged: parse
+ this file, drop {{template "chat-panel" .}} where the panel belongs,
+ load the two assets, and call Chat.mount() with a send function.
+
+ It takes no data. Everything it shows arrives through the mount
+ API at runtime, because chat is never stored and never replayed
+ (§6): the log starts empty on every load and goes with the tab.
+
+ hidden is not decoration. The whole panel is script-only — a Send
+ button with no script behind it is a lie — so the server renders it
+ hidden and the script's host un-hides it when the panel is usable.
+ chat.css gives .chat a display of its own, so it carries the
+ matching .chat[hidden] rule; see the note at the top of that file.
+
+ The control is the vendored field envelope written out rather than
+ called: ui's field-textarea has no Maxlength key, and §4.4's 2 KB
+ is the browser's own courtesy cap on a control the server will
+ refuse above it. Same attributes, same wiring — label bound with
+ for=, the status line named in aria-describedby so the two
+ sentences the panel can say reach a screen reader as part of the
+ field and not only as a live region. */}}
+{{define "chat-panel"}}<section id="chat" class="chat" data-chat aria-label="Chat" hidden>
+ <h2 class="chat-title">Chat</h2>
+ <ol id="chat-log" class="chat-log" data-chat-log aria-live="polite" aria-relevant="additions"></ol>
+ <form id="chat-form" class="chat-form" data-chat-form>
+ <label for="chat-text" class="chat-label">Message</label>
+ <textarea id="chat-text" name="text" rows="2" maxlength="2048" rst-input data-chat-text aria-describedby="chat-status" required></textarea>
+ <button type="submit" id="chat-send" rst-btn data-chat-send>Send</button>
+ <p id="chat-status" class="chat-status" data-chat-status role="status"></p>
+ </form>
+</section>{{end}}
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
index fe05c5e..44bcd0f 100644
--- a/internal/meet/templates/room.html
+++ b/internal/meet/templates/room.html
@@ -1,5 +1,7 @@
{{define "title"}}{{.Title}}{{end}}
{{define "head"}}<link rel="stylesheet" href="{{asset "static/meet.css"}}">
+<link rel="stylesheet" href="{{asset "static/chat.css"}}">
+<script defer src="{{asset "static/chat.js"}}"></script>
<script defer src="{{asset "static/room.js"}}"></script>
{{end}}
{{define "content"}}
@@ -16,6 +18,12 @@
<button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button>
<button type="button" id="leave" rst-btn="danger">Leave</button>
</div>
+ {{/* The chat panel (spec §6): one partial, one script, one
+ stylesheet, none of which know anything about Meet — see the
+ comment in partials/chat.html. chat.js loads before room.js in
+ the head block, and two deferred scripts run in document order,
+ so window.Chat is there when room.js mounts it. */}}
+ {{template "chat-panel" .}}
<div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
<p id="status" class="meet-status" role="status"></p>
</div>
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
index e2ab5b4..2d13c2d 100644
--- a/internal/meettest/room_test.go
+++ b/internal/meettest/room_test.go
@@ -468,3 +468,74 @@ func TestRoomPageLoadsRoomScriptFromTheHeadAndHidesTheRoom(t *testing.T) {
}
}
}
+
+// The chat panel is estilo's first chat (§6, §10): one self-contained
+// partial and one script, so a second app can adopt both files
+// unchanged. Meet's job here is only to mount it — inside #room, after
+// the controls and before the admission queue — and to load its two
+// files the way every other room asset loads, from layout's head block
+// with defer, because the page's CSP is default-src 'self' and nothing
+// about the call may be inline.
+//
+// The panel renders hidden for the same reason #room does: a browser
+// with no script sees the noscript paragraph, never a dead form whose
+// Send button does nothing. room.js un-hides #room, and the panel with
+// it, on admission.
+func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid).Body.String()
+
+ headEnd := strings.Index(body, "</head>")
+ if headEnd < 0 {
+ t.Fatalf("no </head> in the room page:\n%s", body)
+ }
+ head := body[:headEnd]
+ // asset() fingerprints the filename (static/chat.<hash>.js), so the
+ // literal "chat.js" never appears — match the stable prefix.
+ if !strings.Contains(head, `<script defer src="/static/chat.`) {
+ t.Fatalf("chat.js must load deferred from the head block:\n%s", head)
+ }
+ if !strings.Contains(head, `<link rel="stylesheet" href="/static/chat.`) {
+ t.Fatalf("chat.css must load from the head block:\n%s", head)
+ }
+
+ panel := strings.Index(body, `<section id="chat"`)
+ if panel < 0 {
+ t.Fatalf("no chat panel on the room page:\n%s", body)
+ }
+ end := strings.Index(body[panel:], ">")
+ if end < 0 || !strings.Contains(body[panel:panel+end], " hidden") {
+ t.Fatalf("the chat panel must render hidden for the scriptless floor: %q", body[panel:panel+end])
+ }
+ if !strings.Contains(body[panel:panel+end], " data-chat") {
+ t.Fatalf("the chat panel needs its data-chat mount point: %q", body[panel:panel+end])
+ }
+
+ room := strings.Index(body, `<div id="room"`)
+ controls := strings.Index(body, `id="controls"`)
+ queue := strings.Index(body, `id="queue"`)
+ if room < 0 || controls < 0 || queue < 0 {
+ t.Fatalf("room page shape changed: room %d controls %d queue %d", room, controls, queue)
+ }
+ if room > controls || controls > panel || panel > queue {
+ t.Fatalf("the panel belongs inside #room, after the controls and before the queue: room %d controls %d chat %d queue %d", room, controls, panel, queue)
+ }
+
+ // The form's own shape: one labelled control, the browser's own
+ // courtesy cap at §4.4's 2 KB, a polite live region for arrivals and
+ // a status line for the two sentences the panel can say.
+ for _, want := range []string{
+ `<ol id="chat-log" class="chat-log" data-chat-log aria-live="polite" aria-relevant="additions"></ol>`,
+ `<label for="chat-text"`,
+ `id="chat-text"`,
+ `maxlength="2048"`,
+ `<button type="submit" id="chat-send"`,
+ `<p id="chat-status" class="chat-status" data-chat-status role="status"></p>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("chat panel: missing %q\n%s", want, body[panel:])
+ }
+ }
+}
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs
new file mode 100644
index 0000000..229caca
--- /dev/null
+++ b/test/browser/chat.test.mjs
@@ -0,0 +1,204 @@
+// chat.test.mjs — the three pieces of chat.js that can be gated
+// without a browser.
+//
+// node --test test/browser/chat.test.mjs
+//
+// Same trick as negotiation.test.mjs, and for the same reason: chat.js
+// is an IIFE that wants a document, so it cannot be imported. The three
+// functions under test are pure (or take their document as an argument)
+// and their source is lifted out of the shipped file by name and
+// evaluated here. What is gated is therefore the code that ships, not a
+// copy of it that can drift, and renaming or reshaping one of them
+// fails this file loudly instead of passing on a stale duplicate.
+//
+// What it gates:
+//
+// chatLine §6's DOM safety — chat text reaches the page through
+// textContent and nothing else, so an angle bracket in a
+// message is an angle bracket on the screen. No innerHTML,
+// no linkification.
+// trimLog the log is a window, not a record: 500 lines, oldest
+// dropped first.
+// chatBytes §4.4's 2 KB is bytes of UTF-8, not characters. The
+// control's maxlength counts characters, so for anything
+// outside ASCII the two disagree and this is the count that
+// matches what the server will refuse.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const chatJS = fileURLToPath(new URL('../../internal/meet/static/chat.js', import.meta.url));
+
+// lift returns the source of one `function name(...) {...}` by matching
+// braces from its declaration.
+function lift(source, name) {
+ let start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in chat.js 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 chat.js');
+}
+
+const src = readFileSync(chatJS, 'utf8');
+const build = (name) => new Function(lift(src, name) + '; return ' + name + ';')();
+const chatLine = build('chatLine');
+const trimLog = build('trimLog');
+const chatBytes = build('chatBytes');
+
+// --- the smallest document these functions can be driven against ---
+//
+// Deliberately missing innerHTML: an element here has textContent,
+// setAttribute and appendChild and nothing else, so a line built by
+// assigning markup would leave every assertion below reading an empty
+// string rather than quietly passing.
+function textOf(node) {
+ return node.tag === '#text' ? node.text : node.textContent;
+}
+
+function elem(tag) {
+ const node = { tag, className: '', attrs: {}, children: [], own: '' };
+ Object.defineProperty(node, 'textContent', {
+ get() {
+ return node.children.length ? node.children.map(textOf).join('') : node.own;
+ },
+ set(value) {
+ node.own = String(value);
+ node.children = [];
+ },
+ });
+ node.setAttribute = (key, value) => {
+ node.attrs[key] = String(value);
+ };
+ node.appendChild = (child) => {
+ node.children.push(child);
+ return child;
+ };
+ return node;
+}
+
+const doc = {
+ createElement: elem,
+ createTextNode: (text) => ({ tag: '#text', text }),
+};
+
+const partOf = (li, cls) => li.children.find((n) => n.className === cls);
+
+test('a message is written as text, so an angle bracket stays an angle bracket', () => {
+ const nasty = '<img src=x onerror="alert(1)"> & <b>bold</b>';
+ const li = chatLine(doc, { name: 'Ann', text: nasty, at: new Date('2026-09-10T09:07:00Z') });
+ assert.equal(partOf(li, 'chat-text').textContent, nasty);
+ // The whole line reads as text too: nothing was smuggled in as markup
+ // through the name either.
+ const line = chatLine(doc, { name: '<script>', text: 'hi' });
+ assert.equal(partOf(line, 'chat-name').textContent, '<script>');
+});
+
+test('a link is not linkified in version one', () => {
+ const li = chatLine(doc, { name: 'Ann', text: 'see https://example.com/x' });
+ const text = partOf(li, 'chat-text');
+ assert.equal(text.textContent, 'see https://example.com/x');
+ assert.equal(text.children.length, 0, 'the text node was split into elements — something linkified it');
+});
+
+test('chat.js parses no markup anywhere', () => {
+ // The file's own header says it does not; this is the line that makes
+ // that sentence a gate. Matched on the assignment, not the word,
+ // because the word appears in that header.
+ for (const pattern of [/innerHTML\s*=/, /outerHTML\s*=/, /insertAdjacentHTML/, /document\.write/]) {
+ assert.equal(pattern.test(src), false, 'chat.js must build every node, never parse markup: ' + pattern);
+ }
+});
+
+test('a line carries the name, a local clock time and the machine instant', () => {
+ const at = new Date(2026, 8, 10, 9, 7, 0); // local, like a reader's clock
+ const li = chatLine(doc, { name: 'Ann', text: 'hello', at });
+ assert.equal(li.tag, 'li');
+ assert.equal(li.className, 'chat-line');
+ assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
+ const time = partOf(li, 'chat-time');
+ assert.equal(time.tag, 'time');
+ assert.equal(time.textContent, '09:07');
+ assert.equal(time.attrs.datetime, at.toISOString());
+});
+
+test('an unusable timestamp becomes now rather than an Invalid Date on the screen', () => {
+ const li = chatLine(doc, { name: 'Ann', text: 'hello', at: 'not a date' });
+ assert.match(partOf(li, 'chat-time').textContent, /^\d\d:\d\d$/);
+ assert.doesNotThrow(() => chatLine(doc, {}));
+});
+
+test("the sender's own line is marked, and the name is still the signal", () => {
+ const li = chatLine(doc, { name: 'Ann', text: 'hello', self: true });
+ assert.equal(li.className, 'chat-line chat-line-self');
+ assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
+});
+
+// --- the cap ---
+
+function fakeLog(count) {
+ const kids = [];
+ for (let i = 0; i < count; i++) kids.push({ n: i });
+ return {
+ kids,
+ get childElementCount() {
+ return kids.length;
+ },
+ get firstElementChild() {
+ return kids[0];
+ },
+ removeChild(node) {
+ kids.splice(kids.indexOf(node), 1);
+ return node;
+ },
+ };
+}
+
+test('the log is capped at 500 lines and the oldest go first', () => {
+ const log = fakeLog(640);
+ trimLog(log, 500);
+ assert.equal(log.childElementCount, 500);
+ assert.equal(log.kids[0].n, 140, 'the oldest lines are the ones dropped');
+ assert.equal(log.kids[499].n, 639, 'the newest line must survive');
+});
+
+test('a log inside the cap is left alone', () => {
+ const log = fakeLog(500);
+ trimLog(log, 500);
+ assert.equal(log.childElementCount, 500);
+ assert.equal(log.kids[0].n, 0);
+});
+
+// --- the byte counter ---
+
+test('the byte counter counts UTF-8 bytes, not characters', () => {
+ assert.equal(chatBytes(''), 0);
+ assert.equal(chatBytes('hello'), 5);
+ assert.equal(chatBytes('é'), 2);
+ assert.equal(chatBytes('日'), 3);
+ assert.equal(chatBytes('🙂'), 4);
+ assert.equal('🙂'.length, 2, 'a surrogate pair is two characters and four bytes');
+});
+
+test('maxlength is not the limit: 2048 characters can be far more than 2048 bytes', () => {
+ const MAX = 2048;
+ // Exactly at the cap in ASCII, and the control would allow it.
+ assert.equal(chatBytes('a'.repeat(MAX)), MAX);
+ // The same number of characters, four times the bytes: this is what
+ // the server refuses with a 413 and what the panel must catch first.
+ assert.ok(chatBytes('🙂'.repeat(512)) === MAX);
+ assert.ok(chatBytes('🙂'.repeat(513)) > MAX, 'over the cap in bytes while well under it in characters');
+ assert.ok(chatBytes('é'.repeat(MAX)) > MAX);
+});
+
+test('the byte counter survives what a textarea can hand it', () => {
+ assert.equal(chatBytes(null), 0);
+ assert.equal(chatBytes(undefined), 0);
+});