oficina / meet Public

chat: pin the panel's contract, and answer only a true send

Review round 1. Only a send answering exactly true echoes locally — a fetch Response is truthy at any status, and !ok would have echoed a message the server refused. Editing the draft now clears the refusal sentence as it already cleared the length one; a trim compensates scrollTop so a scrolled-up reader keeps the same text under their eyes; the whole date rides in the time element's title beside the clock.

The message box drops required. This form never reaches a server, so it bought only the browser's own validation bubble on an empty click of Send — a sentence this app's copy list cannot catalogue or translate — and Enter could not raise it anyway. Both paths now return in silence. The decision is pinned by a comment and by an assertion whose failure gives the reason.

Tests: a Go test that no app partial shadows a vendored ui partial name, since buildPage parses both into one set and a duplicate define wins silently. The room test now asserts aria-describedby, that chat.js precedes room.js in the head, and no required. chat.test.mjs reads the shipped constants and sentences out of the source, then pins them, and lifts mount for a suite against a fake DOM with no innerHTML: 11 tests to 28.
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 43d381cb700c2fb26d0947b6752b06de06c7adf3 parent 012fb78
6 files changed, +639 −59
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +9 −1
  • internal/meet/render_test.go +65 −0
  • internal/meet/static/chat.js +47 −4
  • internal/meet/templates/partials/chat.html +11 −2
  • internal/meettest/room_test.go +36 −0
  • test/browser/chat.test.mjs +471 −52

CI passed — run details

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 dddef6f..1164b29 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -233,9 +233,17 @@ sentence changed in two products.
| 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). |
+| Could not send. Try again. | `chat.js`, `#chat-status` — a `role="status"` paragraph inside the form | Shown whenever the host's `send` answers anything but exactly `true`, throwing included: 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. Editing the draft clears the sentence, because it was about text that no longer exists. 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. |
+Two strings on a chat line are generated, not authored, and are listed
+only so this file stays a complete record: the visible clock (`HH:MM`,
+the reader's own local time) and the `title` on the same `<time>`
+element, which is `Date.prototype.toLocaleString()` — the whole date and
+time in the reader's own locale and wording, per AGENTS.md's dates
+convention. Neither is text anyone here wrote, and neither is
+translatable by this app.
+
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
diff --git a/internal/meet/render_test.go b/internal/meet/render_test.go
new file mode 100644
index 0000000..7071e35
--- /dev/null
+++ b/internal/meet/render_test.go
@@ -0,0 +1,65 @@
+package meet
+
+import (
+ "html/template"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/ui"
+
+ "amadan.net/oficina/meet/internal/meet/icons"
+)
+
+// buildPage parses rastrillo's vendored ui partials and this app's own
+// templates/partials/ into one template set, and html/template resolves
+// a name by taking the last definition it parsed. So an app partial that
+// happens to define a name the framework already defines does not
+// collide, does not warn and does not fail to build: it silently
+// replaces the vendored one for every page in the app, and the first
+// anyone hears of it is a screen that looks wrong somewhere else
+// entirely.
+//
+// The names are not obscure ones to reach for either — "field",
+// "badge", "person", "stat", "callout" are all vendored, and all are
+// exactly what someone would call a new partial of their own. This test
+// is the warning that parse order will not give.
+//
+// It compares every defined name, not just the {{define}} blocks:
+// ParseFS also defines a template per file under its base name, so
+// naming an app partial field-text.html would shadow the vendored
+// partial's file entry the same way.
+func TestAppPartialsDoNotShadowAVendoredUIPartial(t *testing.T) {
+ funcs := func(tpl *template.Template) *template.Template {
+ return tpl.
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path})
+ }
+
+ const root = "shadow-probe"
+ vendored := template.Must(funcs(template.New(root)).ParseFS(ui.Templates(), "*.html"))
+ taken := map[string]bool{}
+ for _, tpl := range vendored.Templates() {
+ if tpl.Name() != root {
+ taken[tpl.Name()] = true
+ }
+ }
+ if len(taken) == 0 {
+ t.Fatal("no vendored ui partials were parsed — this test would pass on anything")
+ }
+
+ own := template.Must(funcs(template.New(root)).ParseFS(appFS, "templates/partials/*.html"))
+ found := 0
+ for _, tpl := range own.Templates() {
+ name := tpl.Name()
+ if name == root {
+ continue
+ }
+ found++
+ if taken[name] {
+ t.Errorf("templates/partials/ defines %q, which rastrillo's ui already defines: "+
+ "buildPage parses ui first, so this app's definition silently wins for every page. Rename it.", name)
+ }
+ }
+ if found == 0 {
+ t.Fatal("no app partials were parsed — templates/partials/ moved, and this test now proves nothing")
+ }
+}
diff --git a/internal/meet/static/chat.js b/internal/meet/static/chat.js
index 91d39bb..43366b0 100644
--- a/internal/meet/static/chat.js
+++ b/internal/meet/static/chat.js
@@ -78,6 +78,11 @@
const time = doc.createElement('time');
time.className = 'chat-time';
time.setAttribute('datetime', at.toISOString());
+ // The clock is the short form a call wants; the whole date and time
+ // sits in title for anyone who needs to know which day this was —
+ // AGENTS.md's dates convention, and the reason the visible text can
+ // stay four digits wide.
+ time.setAttribute('title', at.toLocaleString());
const hh = String(at.getHours()).padStart(2, '0');
const mm = String(at.getMinutes()).padStart(2, '0');
time.textContent = hh + ':' + mm;
@@ -129,8 +134,15 @@
// does not re-enable a button over an over-long draft.
let enabled = true;
let sending = false;
+ // What the status line is currently saying, tracked here rather
+ // than read back off the element: the element is the host's to
+ // restyle or replace, and a panel that decides what to clear by
+ // string-matching its own DOM is one stray space from never
+ // clearing anything again.
+ let notice = '';
function say(sentence) {
+ notice = sentence;
if (status) status.textContent = sentence;
}
@@ -143,11 +155,25 @@
input.disabled = !enabled;
if (button) button.disabled = !enabled || sending || over;
if (over) say(TOO_LONG);
- else if (status && status.textContent === TOO_LONG) say('');
+ else if (notice === TOO_LONG) say('');
+ }
+
+ // Editing the draft answers both sentences the panel can be saying:
+ // what is in the box now is under the limit, or it is not the thing
+ // that was refused. Either way the old sentence is about text that
+ // no longer exists.
+ function onInput() {
+ if (notice === SEND_FAILED) say('');
+ refresh();
}
function submit() {
if (!enabled || sending) return;
+ // An empty box is not an error, it is nothing to send: both paths
+ // answer it in silence. See the note about `required` in
+ // partials/chat.html — the control deliberately does not carry it,
+ // so a click on Send and a press of Enter behave identically here
+ // instead of one of them raising a browser validation bubble.
const text = input.value.trim();
if (!text) return;
if (chatBytes(text) > MAX_BYTES) {
@@ -165,10 +191,15 @@
// 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.
+ //
+ // `ok !== true` and not `!ok` on purpose: the obvious wrong
+ // implementation of send is `fetch(...)`, whose Response is
+ // truthy whatever the status, and a truthy answer here would echo
+ // a message the server refused. Only the documented `true` counts.
Promise.resolve()
.then(() => (options.send ? options.send(text) : false))
.then((ok) => {
- if (!ok) {
+ if (ok !== true) {
say(SEND_FAILED);
return;
}
@@ -192,8 +223,20 @@
const pinned =
log.scrollHeight - log.scrollTop - log.clientHeight <= PINNED_PX;
log.appendChild(chatLine(doc, entry));
+ const before = log.scrollHeight;
trimLog(log, MAX_LINES);
- if (pinned) log.scrollTop = log.scrollHeight;
+ if (pinned) {
+ log.scrollTop = log.scrollHeight;
+ return;
+ }
+ // Not pinned: someone is reading something older, and the trim
+ // just cut that many pixels off the top of the document under
+ // them. Leaving scrollTop alone would slide the text they are
+ // reading up and out of the box on every arrival once the log
+ // is at its cap. Subtracting what was removed keeps the same
+ // lines under the same eyes.
+ const removed = before - log.scrollHeight;
+ if (removed > 0) log.scrollTop = Math.max(0, log.scrollTop - removed);
},
// setEnabled is the host's gate: a participant who is not
// admitted, or whose stream has gone, can read what is there and
@@ -221,7 +264,7 @@
submit();
});
- input.addEventListener('input', refresh);
+ input.addEventListener('input', onInput);
refresh();
return api;
}
diff --git a/internal/meet/templates/partials/chat.html b/internal/meet/templates/partials/chat.html
index a08e6d1..018dcf9 100644
--- a/internal/meet/templates/partials/chat.html
+++ b/internal/meet/templates/partials/chat.html
@@ -21,13 +21,22 @@
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. */}}
+ field and not only as a live region.
+
+ No `required`, deliberately. This form never reaches a server, so
+ the attribute would buy exactly one thing: the browser's own
+ validation bubble on an empty click of Send — a sentence in wording
+ and tone nobody here chose, and one this app's copy list could not
+ catalogue. Enter cannot raise that bubble anyway without routing
+ through requestSubmit(), so keeping it would leave the two ways of
+ sending behaving differently on an empty box. Without it both do
+ the same nothing, which is what every chat box does. */}}
{{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>
+ <textarea id="chat-text" name="text" rows="2" maxlength="2048" rst-input data-chat-text aria-describedby="chat-status"></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>
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
index 2d13c2d..7098360 100644
--- a/internal/meettest/room_test.go
+++ b/internal/meettest/room_test.go
@@ -500,6 +500,18 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
if !strings.Contains(head, `<link rel="stylesheet" href="/static/chat.`) {
t.Fatalf("chat.css must load from the head block:\n%s", head)
}
+ // Two deferred scripts run in document order, and room.js mounts the
+ // panel through window.Chat — which chat.js has to have defined by
+ // then. Swapping these two lines breaks the room in a way no unit
+ // test would otherwise notice.
+ chatJS := strings.Index(head, `<script defer src="/static/chat.`)
+ roomJS := strings.Index(head, `<script defer src="/static/room.`)
+ if roomJS < 0 {
+ t.Fatalf("room.js is not in the head block any more:\n%s", head)
+ }
+ if chatJS > roomJS {
+ t.Fatalf("chat.js must be loaded before room.js: chat.js at %d, room.js at %d\n%s", chatJS, roomJS, head)
+ }
panel := strings.Index(body, `<section id="chat"`)
if panel < 0 {
@@ -526,11 +538,18 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
// 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.
+ //
+ // aria-describedby is asserted whole rather than by attribute name:
+ // it is the wiring that makes the status line part of the field for
+ // a screen reader instead of a paragraph that happens to be nearby,
+ // and it is the one attribute here that a person editing the markup
+ // would not miss the loss of by looking at the screen.
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"`,
+ `aria-describedby="chat-status"`,
`<button type="submit" id="chat-send"`,
`<p id="chat-status" class="chat-status" data-chat-status role="status"></p>`,
} {
@@ -538,4 +557,21 @@ func TestRoomPageMountsTheChatPanelHiddenWithItsScriptInTheHead(t *testing.T) {
t.Fatalf("chat panel: missing %q\n%s", want, body[panel:])
}
}
+
+ // And no `required`, which is a decision rather than an omission —
+ // see the note in partials/chat.html. This form never reaches a
+ // server, so the attribute would buy only the browser's own
+ // validation bubble on an empty click of Send: a sentence nobody
+ // here wrote, that the copy list cannot catalogue, and that Enter
+ // could not raise anyway. Without it both ways of sending an empty
+ // box do the same nothing.
+ field := strings.Index(body[panel:], "<textarea")
+ if field < 0 {
+ t.Fatalf("no textarea in the chat panel:\n%s", body[panel:])
+ }
+ tag := body[panel+field:]
+ tag = tag[:strings.Index(tag, ">")]
+ if strings.Contains(tag, " required") {
+ t.Fatalf("the message box must not be `required`: an empty Send would raise a browser validation bubble that Enter cannot, and that this app's copy list does not own: %q", tag)
+ }
}
diff --git a/test/browser/chat.test.mjs b/test/browser/chat.test.mjs
index 229caca..3372d1c 100644
--- a/test/browser/chat.test.mjs
+++ b/test/browser/chat.test.mjs
@@ -1,15 +1,16 @@
-// chat.test.mjs — the three pieces of chat.js that can be gated
-// without a browser.
+// chat.test.mjs — chat.js 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.
+// is an IIFE that wants a document, so it cannot be imported. Its
+// functions are lifted out of the shipped file by name and evaluated
+// here, `mount` included — which needs its module constants and its
+// three helpers handed to it, so the harness below rebuilds exactly
+// that much of the file and no more. What is gated is therefore the
+// code that ships, not a copy of it that can drift, and renaming or
+// reshaping any of it fails this file loudly instead of passing on a
+// stale duplicate.
//
// What it gates:
//
@@ -23,12 +24,17 @@
// 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.
+// mount the panel's whole contract with its host: one send per
+// submit, a local echo only on a `true` answer, a refusal
+// that keeps the draft, the host's enable gate, Enter
+// versus Shift+Enter versus an IME, and the two sentences.
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));
+const src = readFileSync(chatJS, 'utf8');
// lift returns the source of one `function name(...) {...}` by matching
// braces from its declaration.
@@ -47,12 +53,57 @@ function lift(source, name) {
assert.fail('unbalanced braces reading ' + name + ' out of chat.js');
}
-const src = readFileSync(chatJS, 'utf8');
+// The limits and the sentences are read out of the shipped file rather
+// than retyped here, so this file cannot go on asserting 500 and 2048
+// after someone has changed them — and then pinned to the values the
+// spec and the copy list name, so changing one is a deliberate act with
+// a failing test in front of it.
+function numberConst(name) {
+ const m = new RegExp('const\\s+' + name + '\\s*=\\s*(\\d+)\\s*;').exec(src);
+ assert.ok(m, name + ' is not a plain numeric const in chat.js any more');
+ return Number(m[1]);
+}
+
+function stringConst(name) {
+ const m = new RegExp('const\\s+' + name + "\\s*=\\s*'([^']*)'\\s*;").exec(src);
+ assert.ok(m, name + ' is not a plain single-quoted const in chat.js any more');
+ return m[1];
+}
+
+const MAX_BYTES = numberConst('MAX_BYTES');
+const MAX_LINES = numberConst('MAX_LINES');
+const PINNED_PX = numberConst('PINNED_PX');
+const SEND_FAILED = stringConst('SEND_FAILED');
+const TOO_LONG = stringConst('TOO_LONG');
+
+test('the shipped limits and sentences are the ones everything else was written against', () => {
+ assert.equal(MAX_BYTES, 2048, "spec §4.4's 2 KB, and the control's maxlength in partials/chat.html");
+ assert.equal(MAX_LINES, 500);
+ assert.equal(SEND_FAILED, 'Could not send. Try again.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
+ assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md');
+});
+
const build = (name) => new Function(lift(src, name) + '; return ' + name + ';')();
const chatLine = build('chatLine');
const trimLog = build('trimLog');
const chatBytes = build('chatBytes');
+// mount closes over the module's constants and its three helpers, so
+// the harness hands it exactly those and nothing else. `document` goes
+// in undefined on purpose: mount must reach its document through
+// root.ownerDocument, and a panel that falls back to a global is one
+// that cannot be mounted inside a frame or a template.
+const mount = new Function(
+ 'MAX_BYTES',
+ 'MAX_LINES',
+ 'PINNED_PX',
+ 'SEND_FAILED',
+ 'TOO_LONG',
+ 'document',
+ [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') +
+ '\nreturn mount;',
+)(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined);
+
// --- the smallest document these functions can be driven against ---
//
// Deliberately missing innerHTML: an element here has textContent,
@@ -63,46 +114,132 @@ 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 = [];
+function fakeDoc() {
+ const doc = {
+ activeElement: null,
+ createTextNode: (text) => ({ tag: '#text', text }),
+ createElement(tag) {
+ const node = {
+ tag,
+ ownerDocument: doc,
+ className: '',
+ attrs: {},
+ children: [],
+ own: '',
+ listeners: {},
+ disabled: false,
+ value: '',
+ scrollTop: 0,
+ clientHeight: 0,
+ // Every line is this tall, so scrollHeight follows the log's
+ // length the way a real one does. Zero until a test says
+ // otherwise, which makes every other test's panel "at the
+ // bottom" and keeps the scroll out of their way.
+ lineHeight: 0,
+ };
+ Object.defineProperty(node, 'textContent', {
+ get() {
+ return node.children.length ? node.children.map(textOf).join('') : node.own;
+ },
+ set(value) {
+ node.own = String(value);
+ node.children = [];
+ },
+ });
+ Object.defineProperty(node, 'scrollHeight', {
+ get() {
+ return node.lineHeight * node.childElementCount;
+ },
+ });
+ Object.defineProperty(node, 'childElementCount', {
+ get() {
+ return node.children.filter((c) => c.tag !== '#text').length;
+ },
+ });
+ Object.defineProperty(node, 'firstElementChild', {
+ get() {
+ return node.children.find((c) => c.tag !== '#text') || null;
+ },
+ });
+ Object.defineProperty(node, 'firstChild', {
+ get() {
+ return node.children[0] || null;
+ },
+ });
+ node.setAttribute = (key, value) => {
+ node.attrs[key] = String(value);
+ };
+ node.appendChild = (child) => {
+ node.children.push(child);
+ return child;
+ };
+ node.removeChild = (child) => {
+ node.children.splice(node.children.indexOf(child), 1);
+ return child;
+ };
+ node.contains = (other) => {
+ if (!other) return false;
+ if (other === node) return true;
+ return node.children.some((c) => c.contains && c.contains(other));
+ };
+ node.querySelector = (sel) => {
+ const key = sel.replace(/^\[|\]$/g, '');
+ for (const child of node.children) {
+ if (child.attrs && key in child.attrs) return child;
+ const deeper = child.querySelector && child.querySelector(sel);
+ if (deeper) return deeper;
+ }
+ return null;
+ };
+ node.addEventListener = (type, fn) => {
+ (node.listeners[type] = node.listeners[type] || []).push(fn);
+ };
+ node.focus = () => {
+ doc.activeElement = node;
+ };
+ return node;
},
- });
- node.setAttribute = (key, value) => {
- node.attrs[key] = String(value);
- };
- node.appendChild = (child) => {
- node.children.push(child);
- return child;
};
- return node;
+ return doc;
}
-const doc = {
- createElement: elem,
- createTextNode: (text) => ({ tag: '#text', text }),
-};
+// fire dispatches one event and hands the event object back, so a test
+// can ask whether the handler called preventDefault.
+function fire(node, type, extra) {
+ const ev = Object.assign(
+ {
+ defaultPrevented: false,
+ preventDefault() {
+ ev.defaultPrevented = true;
+ },
+ },
+ extra,
+ );
+ for (const fn of node.listeners[type] || []) fn(ev);
+ return ev;
+}
+// settle drains the microtask queue, which is where mount's send chain
+// lives.
+const settle = () => new Promise((resolve) => setImmediate(resolve));
+
+const bare = fakeDoc();
const partOf = (li, cls) => li.children.find((n) => n.className === cls);
+// --- chatLine: §6's DOM safety ---
+
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') });
+ const li = chatLine(bare, { 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' });
+ const line = chatLine(bare, { 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 li = chatLine(bare, { 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');
@@ -119,7 +256,7 @@ test('chat.js parses no markup anywhere', () => {
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 });
+ const li = chatLine(bare, { name: 'Ann', text: 'hello', at });
assert.equal(li.tag, 'li');
assert.equal(li.className, 'chat-line');
assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
@@ -129,19 +266,27 @@ test('a line carries the name, a local clock time and the machine instant', () =
assert.equal(time.attrs.datetime, at.toISOString());
});
+test('the whole date is in title, since the visible text is only a clock', () => {
+ const at = new Date(2026, 8, 10, 9, 7, 0);
+ const li = chatLine(bare, { name: 'Ann', text: 'hello', at });
+ const time = partOf(li, 'chat-time');
+ assert.equal(time.attrs.title, at.toLocaleString());
+ assert.notEqual(time.attrs.title, time.textContent, 'title must say more than the clock does');
+});
+
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' });
+ const li = chatLine(bare, { name: 'Ann', text: 'hello', at: 'not a date' });
assert.match(partOf(li, 'chat-time').textContent, /^\d\d:\d\d$/);
- assert.doesNotThrow(() => chatLine(doc, {}));
+ assert.doesNotThrow(() => chatLine(bare, {}));
});
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 });
+ const li = chatLine(bare, { 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 ---
+// --- trimLog: the cap ---
function fakeLog(count) {
const kids = [];
@@ -161,22 +306,22 @@ function fakeLog(count) {
};
}
-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);
+test('the log is capped and the oldest go first', () => {
+ const log = fakeLog(MAX_LINES + 140);
+ trimLog(log, MAX_LINES);
+ assert.equal(log.childElementCount, MAX_LINES);
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');
+ assert.equal(log.kids[MAX_LINES - 1].n, MAX_LINES + 139, '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);
+ const log = fakeLog(MAX_LINES);
+ trimLog(log, MAX_LINES);
+ assert.equal(log.childElementCount, MAX_LINES);
assert.equal(log.kids[0].n, 0);
});
-// --- the byte counter ---
+// --- chatBytes: the byte counter ---
test('the byte counter counts UTF-8 bytes, not characters', () => {
assert.equal(chatBytes(''), 0);
@@ -188,17 +333,291 @@ test('the byte counter counts UTF-8 bytes, not characters', () => {
});
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);
+ assert.equal(chatBytes('a'.repeat(MAX_BYTES)), MAX_BYTES);
// 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);
+ assert.equal(chatBytes('🙂'.repeat(MAX_BYTES / 4)), MAX_BYTES);
+ assert.ok(chatBytes('🙂'.repeat(MAX_BYTES / 4 + 1)) > MAX_BYTES, 'over the cap in bytes while well under it in characters');
+ assert.ok(chatBytes('é'.repeat(MAX_BYTES)) > MAX_BYTES);
});
test('the byte counter survives what a textarea can hand it', () => {
assert.equal(chatBytes(null), 0);
assert.equal(chatBytes(undefined), 0);
});
+
+// --- mount: the contract with the host ---
+
+function panel() {
+ const doc = fakeDoc();
+ const root = doc.createElement('section');
+ const log = doc.createElement('ol');
+ const form = doc.createElement('form');
+ const input = doc.createElement('textarea');
+ const button = doc.createElement('button');
+ const status = doc.createElement('p');
+ log.attrs['data-chat-log'] = '';
+ form.attrs['data-chat-form'] = '';
+ input.attrs['data-chat-text'] = '';
+ button.attrs['data-chat-send'] = '';
+ status.attrs['data-chat-status'] = '';
+ form.appendChild(input);
+ form.appendChild(button);
+ form.appendChild(status);
+ root.appendChild(log);
+ root.appendChild(form);
+ return { doc, root, log, form, input, button, status };
+}
+
+// spy records what the host was asked to send and answers with what the
+// test tells it to.
+function spy(answer) {
+ const calls = [];
+ const send = (text) => {
+ calls.push(text);
+ return typeof answer === 'function' ? answer(text) : answer;
+ };
+ return { calls, send };
+}
+
+test('a send in flight swallows every further submit', async () => {
+ const p = panel();
+ let release;
+ const held = spy(() => new Promise((resolve) => { release = resolve; }));
+ mount(p.root, { name: 'Ann', send: held.send });
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ fire(p.form, 'submit');
+ fire(p.input, 'keydown', { key: 'Enter' });
+ await settle();
+ assert.equal(held.calls.length, 1, 'one message, one send');
+ release(true);
+ await settle();
+ assert.equal(held.calls.length, 1);
+ assert.equal(p.input.value, '', 'an accepted message leaves the box');
+});
+
+test('the local echo is appended only when send answers exactly true', async () => {
+ const p = panel();
+ const ok = spy(true);
+ const chat = mount(p.root, { name: 'Ann', send: ok.send });
+ p.input.value = ' hello ';
+ fire(p.form, 'submit');
+ await settle();
+ assert.deepEqual(ok.calls, ['hello'], 'the text is trimmed on the way out');
+ assert.equal(p.log.childElementCount, 1);
+ const li = p.log.firstElementChild;
+ assert.equal(partOf(li, 'chat-name').textContent, 'Ann');
+ assert.equal(partOf(li, 'chat-text').textContent, 'hello');
+ assert.equal(li.className, 'chat-line chat-line-self');
+ assert.equal(p.status.textContent, '');
+ assert.equal(p.input.value, '');
+ assert.equal(typeof chat.append, 'function');
+});
+
+test('a truthy answer that is not true is a refusal, not an echo', async () => {
+ // The obvious wrong send is `fetch(...)`: a Response object is truthy
+ // whatever its status, so `!ok` would echo a message the server
+ // refused with a 413 and nobody else will ever see.
+ const p = panel();
+ const response = spy({ ok: false, status: 413 });
+ mount(p.root, { name: 'Ann', send: response.send });
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(p.log.childElementCount, 0, 'nothing was echoed');
+ assert.equal(p.status.textContent, SEND_FAILED);
+ assert.equal(p.input.value, 'hello', 'the draft stays in the box');
+});
+
+test('a refused send keeps the draft and says so', async () => {
+ for (const answer of [false, undefined, null, Promise.resolve(false)]) {
+ const p = panel();
+ const refused = spy(answer);
+ mount(p.root, { name: 'Ann', send: refused.send });
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(p.log.childElementCount, 0);
+ assert.equal(p.status.textContent, SEND_FAILED);
+ assert.equal(p.input.value, 'hello');
+ assert.equal(p.button.disabled, false, 'and Send is live again for the retry');
+ }
+});
+
+test('a send that throws is a refusal like any other', async () => {
+ const p = panel();
+ const thrown = spy(() => {
+ throw new Error('offline');
+ });
+ mount(p.root, { name: 'Ann', send: thrown.send });
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(p.status.textContent, SEND_FAILED);
+ assert.equal(p.input.value, 'hello');
+});
+
+test('editing the draft clears the refusal sentence', async () => {
+ const p = panel();
+ const refused = spy(false);
+ mount(p.root, { name: 'Ann', send: refused.send });
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(p.status.textContent, SEND_FAILED);
+ p.input.value = 'hello again';
+ fire(p.input, 'input');
+ assert.equal(p.status.textContent, '', 'the sentence was about text that no longer exists');
+});
+
+test('the host gate stops a send and leaves the log readable', async () => {
+ const p = panel();
+ const ok = spy(true);
+ const chat = mount(p.root, { name: 'Ann', send: ok.send });
+ chat.append({ name: 'Bob', text: 'still here' });
+ chat.setEnabled(false);
+ assert.equal(p.input.disabled, true);
+ assert.equal(p.button.disabled, true);
+ p.input.value = 'hello';
+ fire(p.form, 'submit');
+ fire(p.input, 'keydown', { key: 'Enter' });
+ await settle();
+ assert.equal(ok.calls.length, 0, 'not admitted, not sending');
+ assert.equal(p.log.childElementCount, 1, 'what arrived earlier is still readable');
+ chat.setEnabled(true);
+ assert.equal(p.input.disabled, false);
+ assert.equal(p.button.disabled, false);
+ fire(p.form, 'submit');
+ await settle();
+ assert.deepEqual(ok.calls, ['hello']);
+});
+
+test('Enter sends, Shift+Enter does not, and an IME is left alone', async () => {
+ const p = panel();
+ const ok = spy(true);
+ mount(p.root, { name: 'Ann', send: ok.send });
+ p.input.value = 'hello';
+
+ const shift = fire(p.input, 'keydown', { key: 'Enter', shiftKey: true });
+ assert.equal(shift.defaultPrevented, false, 'Shift+Enter must reach the textarea as a newline');
+
+ // Enter mid-composition is how an IME commits characters; taking it
+ // would send half a word and eat the rest.
+ const composing = fire(p.input, 'keydown', { key: 'Enter', isComposing: true });
+ const legacy = fire(p.input, 'keydown', { key: 'Enter', keyCode: 229 });
+ assert.equal(composing.defaultPrevented, false);
+ assert.equal(legacy.defaultPrevented, false);
+
+ const other = fire(p.input, 'keydown', { key: 'a' });
+ assert.equal(other.defaultPrevented, false);
+ await settle();
+ assert.equal(ok.calls.length, 0, 'none of those was a send');
+
+ const enter = fire(p.input, 'keydown', { key: 'Enter' });
+ assert.equal(enter.defaultPrevented, true, 'a plain Enter must not also insert a newline');
+ await settle();
+ assert.deepEqual(ok.calls, ['hello']);
+});
+
+test('an over-long draft says so and disables Send, in bytes not characters', async () => {
+ const p = panel();
+ const ok = spy(true);
+ mount(p.root, { name: 'Ann', send: ok.send });
+
+ // Well inside maxlength as characters, well past the cap as bytes.
+ p.input.value = '🙂'.repeat(MAX_BYTES / 4 + 1);
+ fire(p.input, 'input');
+ assert.equal(p.status.textContent, TOO_LONG);
+ assert.equal(p.button.disabled, true);
+
+ fire(p.form, 'submit');
+ fire(p.input, 'keydown', { key: 'Enter' });
+ await settle();
+ assert.equal(ok.calls.length, 0, 'neither path sends what the server would refuse');
+
+ // Exactly at the cap is inside it.
+ p.input.value = '🙂'.repeat(MAX_BYTES / 4);
+ fire(p.input, 'input');
+ assert.equal(p.status.textContent, '', 'the sentence clears itself when the draft comes back under');
+ assert.equal(p.button.disabled, false);
+ fire(p.form, 'submit');
+ await settle();
+ assert.equal(ok.calls.length, 1);
+});
+
+test('an empty box is nothing to send, the same nothing on both paths', async () => {
+ const p = panel();
+ const ok = spy(true);
+ mount(p.root, { name: 'Ann', send: ok.send });
+ for (const draft of ['', ' ', '\n\n']) {
+ p.input.value = draft;
+ fire(p.form, 'submit');
+ fire(p.input, 'keydown', { key: 'Enter' });
+ }
+ await settle();
+ assert.equal(ok.calls.length, 0);
+ assert.equal(p.status.textContent, '', 'and nothing is said about it');
+});
+
+test('append writes an arrived message and clear empties the log', () => {
+ const p = panel();
+ const chat = mount(p.root, { name: 'Ann', send: () => true });
+ chat.append({ from: 'peer1', name: 'Bob', text: '<not markup>' });
+ chat.append({ from: 'peer1', name: 'Bob', text: 'second' });
+ assert.equal(p.log.childElementCount, 2);
+ assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, '<not markup>');
+ chat.clear();
+ assert.equal(p.log.childElementCount, 0);
+ assert.equal(p.log.children.length, 0);
+});
+
+test('the log stays at its cap through the panel too', () => {
+ const p = panel();
+ const chat = mount(p.root, { name: 'Ann', send: () => true });
+ for (let i = 0; i < MAX_LINES + 3; i++) chat.append({ name: 'Bob', text: 'line ' + i });
+ assert.equal(p.log.childElementCount, MAX_LINES);
+ assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, 'line 3');
+});
+
+test('a reader at the bottom is kept at the bottom', () => {
+ const p = panel();
+ p.log.lineHeight = 20;
+ p.log.clientHeight = 100;
+ const chat = mount(p.root, { name: 'Ann', send: () => true });
+ for (let i = 0; i < 20; i++) chat.append({ name: 'Bob', text: 'line ' + i });
+ assert.equal(p.log.scrollTop, p.log.scrollHeight, 'each arrival scrolls to the newest line');
+ assert.ok(PINNED_PX > 0, 'and there is a tolerance, so a pixel of rounding is not "scrolled up"');
+});
+
+test('a trimmed log does not slide the text a scrolled-up reader is looking at', () => {
+ const p = panel();
+ p.log.lineHeight = 20;
+ p.log.clientHeight = 100;
+ const chat = mount(p.root, { name: 'Ann', send: () => true });
+ for (let i = 0; i < MAX_LINES; i++) chat.append({ name: 'Bob', text: 'line ' + i });
+
+ // The reader scrolls back up to something older. From here every
+ // arrival trims one line off the top, and leaving scrollTop alone
+ // would walk that text up and out of the box.
+ p.log.scrollTop = 200;
+ chat.append({ name: 'Bob', text: 'newest' });
+ assert.equal(p.log.childElementCount, MAX_LINES, 'still at the cap, so a line was removed');
+ assert.equal(p.log.scrollTop, 180, 'scrollTop moves up by the height of the line that went');
+
+ chat.append({ name: 'Bob', text: 'newer still' });
+ assert.equal(p.log.scrollTop, 160);
+});
+
+test('a panel that is not there, or not complete, returns a working no-op', async () => {
+ for (const root of [null, undefined, fakeDoc().createElement('section')]) {
+ const ok = spy(true);
+ const chat = mount(root, { name: 'Ann', send: ok.send });
+ assert.doesNotThrow(() => chat.append({ name: 'Bob', text: 'hi' }));
+ assert.doesNotThrow(() => chat.setEnabled(false));
+ assert.doesNotThrow(() => chat.clear());
+ await settle();
+ assert.equal(ok.calls.length, 0);
+ }
+});