# Presentation layer — preferences

This file is the single source of truth for how the project renders in a browser — HTML, CSS, JS architecture, aesthetic, mobile behaviour. `CLAUDE.md` is the orientation map; this file owns the presentation layer. The room app's live UI is `lib/room-ui.html`, whose canonical reference is the **Style Guide** (`docs/style-guide.html`); the room2 design pages live under `docs/`; the three markdown viewers render the project's `.md` files.

---

## Markdown renderers — default (accented + TTS) and accented fallback

Two sibling templates render project `.md` files. Identical core pipeline (marked.js 12.0.2, YAML frontmatter → metadata box, `<!-- … -->` stripped).

- **`lib/md-viewer.html`** — THE default viewer: accented styling **plus** browser-side read-aloud via an OpenAI-compatible TTS endpoint. (Historically named `md-viewer-tts.html`; the two had converged byte-identical, and the twin was removed 2026-07-11 — systemic-check #24. The old "plain" baseline no longer exists.)
- **`lib/md-viewer-accented.html`** — accented template without TTS. Bold labels and italic distinguished terms render in coral; italic dialog quotes stay ink. The fallback if TTS misbehaves.

**Accent rules (in `md-viewer.html` and `md-viewer-accented.html`):**

- All `<strong>` → coral. This catches every labeled construct — `**Essence (one line):**` in the lede blockquote, `**Age** / **Pronouns** / **Lives in**` table-key cells, `**1992 (November)**` timeline list-item leaders, `**She says things like:**` and other section labels inside lists, `**Embellishment:** low.` style label-leader-then-value patterns.
- `<em>:not(.quote-em)` → coral. Catches italic foreign and distinguished phrases — *magna cum laude*, *tengo miedo*, *gallo pinto*, *The Vegetarian* (book titles) — and stylized prose.
- `<em>.quote-em` → inherits ink colour. The `quote-em` class is added by a small post-render JS pass that walks all `<em>` nodes and flags any whose first non-space character is a quotation mark (`"`, `"`, `'`, `「`, `『`, `《`). This is how italic dialog like `*"It's positive."*` stays un-accented while italic Latin like `*magna cum laude*` gets the accent.

**Read-aloud (TTS) — in `md-viewer.html`:**

- Speaker SVG icon in the topbar opens a settings modal (inline SVG rather than the 🔊 emoji because emoji render differently on every device). **All TTS settings now persist server-side in CF KV** — they sync across phone/laptop/any device on first save:
  - `baseUrl`, `apiKey`, `provider`, `speed` — shared playback settings.
  - `voiceMd` — the markdown viewer's single voice.

  The apiKey is sent back to the browser on GET only because the page is gated by CF Access; the browser holds it in JS memory only — **never** localStorage.

  *Legacy:* an older build kept voices/speed in `localStorage['mad-tts-prefs']`. The viewers still **read** that key as a one-time fallback (so users coming from the old build don't lose their prefs), but they **never write** it. Next Save promotes whatever was in localStorage to CF KV. A legacy `model` field may also still exist in saved CF KV state from earlier versions; it is ignored — model id is derived from the active provider. Tone instructions (`instructions` field) were removed in May 2026; any old localStorage `instructions` value is silently dropped.
- **401 retry on TTS calls.** When OpenRouter returns 401 on a TTS request, the page assumes the apiKey in its JS memory is stale (updated on another tab or another device, with CF KV's eventual-consistency briefly serving a stale read). It re-fetches `/api/tts-config` and retries the TTS call exactly once. This catches both cross-tab updates and the "I rotated the key on OpenRouter and pasted the new one on the other page" scenario without forcing a separate paste on every page.
- **Two providers, one API key.** The settings modal has a *TTS provider* selector with two options:
  - **OpenAI** — model id `openai/gpt-4o-mini-tts-2025-12-15`, 7 voices (`alloy`, `echo`, `fable`, `marin`, `onyx`, `nova`, `shimmer`).
  - **Kokoro** — model id `hexgrad/kokoro-82m`, 51 voices grouped by language and gender (American/British English, Mandarin Chinese, Japanese, Hindi, Italian, Spanish, French, Brazilian Portuguese), voice labels include the v1.0 quality grade.

  Both providers route through the same OpenRouter API key. The model id is determined entirely by the provider — there is no separate model field in the UI. **Voice timbre is fixed per voice** for both providers; tone-steering instructions were removed (they only ever worked for OpenAI, weakly, and added complexity without clearly helping). Pick the right voice rather than steering it.
- The shared voice list, provider metadata, and the spoken-number normalizer live in `lib/tts-providers.js` (included by `md-viewer.html`). When the user switches providers in the modal, the voice select rebuilds; when fetching audio, the call body's `model` is conditioned on the active provider.
- **Cache-buster.** The viewer includes the shared file as `<script src="tts-providers.js?v=N">`. The browser caches the file by URL, so a fix wouldn't show until a hard refresh — **bump `N` whenever `tts-providers.js` changes** and the new URL forces a fresh fetch. The `?v=` query is ignored by the static server / CF Pages. This is a manual, monotonic version — *not* a per-load token, which would defeat caching entirely. The API key is unaffected: it lives in CF KV server-side and is re-fetched into memory on every load, independent of this file's caching.
- **Speed test (latency probe).** The settings modal has a *Speed test* button (next to Save) that times one short request to `{baseUrl}/audio/speech` using the current modal selections (provider/voice/speed/key — even unsaved ones) and prints `first byte Xs · full Ys · Z KB`. Shared helper `window.ttsSpeedTest()` in `lib/tts-providers.js`; it never throws (returns `{ok:false,status,error}` on failure) and does a single 401-refresh-and-retry like the playback path. **first byte** is when the response headers arrive — for this endpoint the mp3 is generated before the response begins, so it approximates the model's generation time; **full** adds the audio download. Use it to tell whether slow read-aloud is the API/model (high *first byte*) vs. the network (gap between *first byte* and *full*). Only works where the key exists (the deployed, CF-Access-gated site) — a local `python http.server` doesn't serve `/api/tts-config`.
- Three ways to trigger **Read from here**, all bound to readable blocks (`<p>`, `<li>`, `<h1–h4>`, `<blockquote>`):
  - **Desktop right-click** → 1-item context menu.
  - **Touch long-press** → same context menu (browsers fire `contextmenu` on long-press).
  - **Touch double-tap** → starts reading immediately, no menu. Desktop double-click is *not* hijacked (kept for word-selection); the handler checks `pointerType` from a tracked `pointerdown` and only acts on `touch`/`pen`. Readable blocks get `touch-action: manipulation` so double-tap doesn't trigger browser zoom.

  Native browser menus are suppressed only when the click target is a readable block — leaves them alone over the topbar, frontmatter, code blocks, tables.
- **Spoken-number normalization.** Before text is sent to TTS, money, percentages, and approximations are rewritten the way a person says them — otherwise the engine reads the bare `$`, treats `M` as a letter, and splits ranges at each `$`. The shared `window.speakableText()` in `lib/tts-providers.js` (called by the md viewer's `blockText()`) applies the **"natural finance"** style. The rules:
  - **`~` → "about"** (it always means *approximately*): `~$4M` → "about 4 mil"; `~4.5m` → "about 4.5 mil"; `~9 years` → "about 9 years".
  - **Scale word, abbreviated *or* spelled out:** `k` / `thousand` **stay `k`** (spoken "kay"); `m` / `M` / `mm` / `mil` / `million` → **"mil"**; `b` / `billion` → "billion". The scale **replaces** the `$`, and *dollars* is dropped when a scale is present.
  - **No scale word:** a **decimal** amount reads as the **bare number** — `$5.45` → "5.45", *never* "5 dollars 45 cents". In these finance dialogs a `$`+decimal is always a scale-implied (millions) figure, so "dollars" would be both wrong-sounding and wrong-valued. A **whole** amount keeps *dollars* — `$600` → "600 dollars".
  - `$3.2M` → "3.2 mil" · `$5 million` → "5 mil" · `$400 thousand` → "400k" · `$975k` → "975k" · `$27,000` → "27,000 dollars" · `$5.45` → "5.45" · `36%` → "36 percent".
  - **Ranges collapse to one phrase** (connectors `to` / `through` / `–` / `—` / `-`), and a scale on either number applies to both: `$5.3 to $5.7 million` → "5.3 to 5.7 mil"; `$5.45 to $5.55` (no scale, decimals) → "5.45 to 5.55"; `$250 to $400k` → "250 to 400k"; `$5.45–$5.55M` (en-dash) and `$250k to $400k` (scale on both) handled too; mixed scales keep each side's word (`$900k to $1.1M` → "900k to 1.1 mil"). **`and` is *not* a range connector** — `$18,000 and $5,000` is two separate amounts → "18,000 dollars and 5,000 dollars".
  - **Only `$`-, `~`-, and `%`-anchored tokens are touched** — years (`1992`), `3,000 square feet`, `2 kids`, `3 km`, `5 minutes` are left alone; a scale word followed by more letters isn't a suffix (`$5 monthly` → "5 dollars monthly", not "5 mil"). The number pattern requires commas between digit-triples, so a trailing comma in `$27,000, $600` isn't swallowed.
  - **Edge:** a scale-less **whole** range that *meant* millions (`$5 to $6` for "$5–6M") can't be inferred and reads "5 to 6 dollars". Vanishingly rare — whole-dollar talk in these dialogs is literal; millions always carry a decimal or a scale word, both of which are handled.
- On trigger: builds a queue from the clicked element to the end of the article (skipping `<pre>`, `<table>`, `.frontmatter`, and any readable that's a DOM descendant of an already-queued readable, so nested list items aren't read twice). Strips `*[stage directions]*` from each block's text before sending. Calls `POST {baseUrl}/audio/speech` (browser → OpenRouter, direct) with `{model, input, voice, speed, response_format: 'mp3'}` and the in-memory apiKey as bearer. Pipes the returned audio blob into an `<audio>`. Pre-fetches one block ahead while the current plays.
- While reading: currently-spoken block gets a 1.5px coral inset ring + soft coral bg; floating coral pill bottom-right is a 4-button player — **prev / pause-or-resume / next / stop**, all inline SVG (no emoji, no icon-font dependency, identical rendering across devices). Pause/resume toggles the current audio in place; prev/next discard prefetched URLs and re-fetch the new block. Prev is disabled at the first block of the queue; next at the last. Stop is always enabled. Page auto-scrolls to keep the spoken block centered.
- **Loading state:** while a block's audio is being fetched, the prev/pause/next buttons hide and the pill swaps to a pulsing-dot + **"Generating"** label (white text on grey pill). Stop stays visible. Once the audio arrives, the buttons swap back in and the pill returns to coral.

**Defaults (server-side in CF KV):**

| Field | Default | Notes |
|---|---|---|
| `baseUrl` | `https://openrouter.ai/api/v1` | |
| `apiKey` | none — user-supplied | |
| `provider` | `openai` | |
| Model id | derived from provider, not user-configurable | `openai/gpt-4o-mini-tts-2025-12-15` for `openai`; `hexgrad/kokoro-82m` for `kokoro` |
| `voiceMd` | `fable` | md viewer's single voice |
| `speed` | `1.15×` | |

Speed control is a horizontal stepped slider with clickable tick labels: `0.75 · 0.9 · 1.0 · 1.15 · 1.25 · 1.5×`. The currently-selected tick is highlighted in coral; clicking any tick snaps the slider there.

**Server-side architecture** — one CF Pages Function plus one KV namespace:

- `functions/api/tts-config.js` — `GET` returns `{ hasKey, baseUrl, model, provider, voiceMd, voicesDialog, speed, apiKey }`. The apiKey *is* returned because the endpoint is gated by CF Access; whoever reaches this endpoint has already authenticated, so giving them the saved key is equivalent to them having typed it themselves on this device. The browser holds it in JS memory only — **never** localStorage. `POST` merges the body into the saved config; an empty/missing `apiKey` field in the POST body leaves the saved key untouched, so the user can change other fields without re-typing the key.
- **KV binding:** the Pages project needs a KV namespace bound under variable name `MAD_TTS_KV` (Pages → Settings → Functions → KV namespace bindings). Without it the endpoint returns a 500 with a clear message.

**Why browser → OpenRouter direct instead of a same-origin proxy?** We tried the proxy first (`functions/api/audio/speech.js`) — it works most of the time but fails intermittently with `Provider returned 403`/`404` errors. The CF Pages Function calls openrouter.ai, both endpoints sit on Cloudflare's edge network, and OpenRouter appears to throttle or reject some Worker-originated traffic. Curl from outside CF works reliably with the same key + headers + body. So the viewer calls OpenRouter directly from the browser. The apiKey appears briefly in JS memory but never on disk; CF Access still gates who can ever load the page or hit `/api/tts-config`.

**Access (Zero Trust):** the deployment is gated by CF Access at the edge, which covers `/api/*` automatically. The Pages Function does NOT do its own auth — it trusts that anything reaching it has cleared Access. If you ever expose this deployment publicly, lock down `/api/tts-config` GET (it returns the apiKey) by adding a shared-secret check or per-user identity check via the `Cf-Access-Jwt-Assertion` header.

**Backends:** any OpenAI-compatible `/audio/speech` endpoint that allows browser CORS works. OpenRouter (`https://openrouter.ai/api/v1`) allows CORS and is the default. OpenAI direct (`https://api.openai.com/v1`) does NOT allow browser CORS — using it would require a same-origin proxy. Supported OpenRouter TTS models: `GET https://openrouter.ai/api/v1/models?output_modalities=speech` — `openai/gpt-4o-mini-tts-2025-12-15` and `mistralai/voxtral-mini-tts-2603` are the current openai/mistral options; others (Google, Grok, Zyphra, Kokoro, etc.) are listed there too.

**One-time setup (per Pages deployment):**

1. CF dashboard → **Workers & Pages → KV** → Create namespace (any name, e.g. `mad-tts-config`).
2. Pages project → **Settings → Functions → KV namespace bindings** → Add binding with variable name **`MAD_TTS_KV`** (exact spelling, case-sensitive) bound to the namespace from step 1. Add for **Production**; add for **Preview** too if you want preview deploys to work.
3. CF Access → ensure the policy on this Pages project covers `/api/*` (default behaviour, but worth confirming — if it only covers HTML routes, the proxy endpoint is left unauthenticated).
4. Push or retry-deploy so the new KV binding takes effect.
5. On any device: open a doc → speaker icon in the topbar → paste an OpenRouter API key → Save. Other devices automatically pick up the saved key via the GET above.

**Mobile-Chrome CORS gotcha — already mitigated:** the request to `/audio/speech` sends only `Authorization` and `Content-Type` headers, intentionally omitting OpenRouter's optional `HTTP-Referer` / `X-Title` attribution headers. Including them triggers a CORS preflight that some Android Chrome builds reject with "Failed to fetch". Cost of the omission: requests show up in OpenRouter's activity log as App = "Unknown". Worth it.

**When to use which:**

- `md-viewer.html` is the default (accented + TTS).
- `md-viewer-accented.html` stays available as a fallback if TTS misbehaves — swap the link with one find-replace and you're back to plain accented rendering.
- Both share the same `?file=<project-relative.md>` contract, so swapping is always a one-word link edit.

---

## Hosting

Designed for Cloudflare Pages. For local preview a static server is required:

- VS Code "Live Server" extension, or
- `python -m http.server 8000` from the project root

Browsers block `fetch()` over `file://`, so double-clicking the HTML from disk will show a friendly error state explaining how to serve locally.

---

## Aesthetic

- **Palette:** cream `#F0EEE6` background, coral `#CC785C` accent. Anthropic-style.
- **Themes:** persisted in `localStorage` under key `mad-theme`; every colour is a CSS variable so a theme swap is instant.
  - **The App is light/dark only — no e-ink.** Everything shipping to `chat.xbbapp.com` (`room-ui.html`, `builder-ui.html`, `login.html`, `admin.html`, `personas.html`, the md-viewers, `seen_template.html`, …) uses `themes = ["light","dark"]`. **E-ink exists ONLY in room2** — the design-doc pages under `docs/` + `index.html` keep the 3-way light/dark/e-ink toggle for reading. `docs/style-guide.html` is a room2 page but *mirrors* the app, so it is light/dark only too. (Decided 2026-07-10, superseding the 2026-06-05 "every other page keeps all three".)
  - **Dark/light is universal** — one toggle themes the WHOLE app at once, no per-view theme. The embedded Studio (`/builder` iframe) follows via a `MutationObserver` on `<html data-theme>` in room-ui that postMessages the frame; any new embedded surface must honour `<html data-theme>` the same way.
- **Fonts:** Source Serif 4 (serif), Inter (sans), JetBrains Mono (mono) — **self-hosted woff2 from `/fonts` on every app page** (the console + md-viewers left the Google CDN 2026-07-11; the md-viewers use RELATIVE `fonts/` paths so they work from room2 too).
- **The shared layer (2026-07-11):** `lib/theme.css` owns the palette, font stacks, the radius/space/shadow/avatar token scales, and the once-defined components (the dialog scaffold — scrim `rgba(0,0,0,.40)` + the 580 card — `.dialog-x`/`.dialog-back`, search chrome, popover base, pills, toast); `lib/fmt.js` owns the one display voice (ago · row time · en-GB dates · `1.2k` tokens · adaptive money · durations). Every app page links both **before** its own styles/scripts with a `?v=N` buster — **bump it alongside BUILD** whenever either file changes. Page files keep only page-specific tokens + z-ladders.
- **Title rhythm:** `Dialogue · <Page name>` in the topbar's left, with the `·` in coral. The word "Dialogue" is a link to `../` (home / index) on every page; subtle bottom-border underline on hover.

---

## Font fallbacks — Chinese on mobile

Latin fonts (Source Serif 4, Inter, JetBrains Mono) carry no Chinese glyphs, so the cascade for each font variable ends with explicit modern Chinese fonts followed by `system-ui, sans-serif`:

```
'Source Serif 4', Georgia,
'PingFang SC',          /* Apple   */
'Microsoft YaHei',      /* Windows */
'Noto Sans CJK SC',     /* Android (CJK alias) */
'Noto Sans SC',         /* cross-platform Noto */
system-ui, sans-serif
```

**The final generic must be `sans-serif`.** If it ends with `serif`, Android resolves the generic to Noto Serif CJK (Song-style / 宋体), which we want to avoid.

Applied identically across the markdown viewers and `index.html`.

**v1073–v1076 (owner, 2026-09-04) — the app ships its own Chinese faces.** System fallbacks gave two different Chinese fonts: iOS has no serif CJK, so an essay fell to PingFang (a sans); Android fell to its Noto Serif CJK (a songti). The app now self-hosts **Noto Serif SC** (600 for the body, 800 for titles — 400/700 read thin on a phone in light mode) and **Noto Sans SC** (500, the interface), with `lib/fonts/noto-sc.css` linked from `room-ui.html`. The stacks put the Noto face **before** the system names (`'Inter','Noto Sans SC','PingFang SC',…` · `'Source Serif 4',Georgia,'Noto Serif SC',…`), so every phone paints the same glyphs; the system name is the swap-in while a file loads (`font-display: swap`). **The files are THREE frequency tiers per weight** (`lib/fonts/noto/*.a|b|c.woff2`, ~17 MB, built by `scripts/build_noto_tiers.py` from the two variable fonts; the tiers follow Google Fonts' own slice ranking, kept beside the script as `noto-ranges-*.css`): **a** everyday (Google's top 20 slices — 98% of the shelf's hanzi, ~1.8 MB), **b** less common (~1 MB), **c** rare (~3.6 MB). ⚠ v1073–v1075 used Google's ~100 slices per weight, and a phone reading from China lost a few of the forty requests an essay made — a lost slice leaves its characters in the fallback font, which the owner saw as「some bold, some regular」. One request per essay is the cure; `font-display: swap` never retries a failed file. Google Fonts is blocked in China — never link it; that is why the files are self-hosted.

**v1089 (owner, 2026-09-07) — REVERSED: the system Chinese face again.** Even in tiers the faces took too long to arrive on a phone (the owner:「the new font takes a long time to load … let's go back to the system font for Chinese, so that we don't waste time in loading」). The `noto-sc.css` link, the preload/precache entries and the 17 MB of `lib/fonts/noto/` are gone; the stacks are the pre-v1073 ones above (system names, `'Noto Sans SC'` last as the cross-platform name only). The build script and the range files stay in `scripts/` as the recipe, should a self-hosted face ever be wanted again — if so, the lesson is that a phone must never wait on it: subset to the edition's own characters, or ship it only over the LAN/Wi-Fi. **v1090 (owner, same day):「songti on Android is awful」— the essay stacks (`--inkserif`, `--inkdisp`) and the persona page's `P_SERIF` now end in `sans-serif`, per the rule above: Android paints 思源黑体 or the brand's sans (MiSans · HarmonyOS Sans · OPPO Sans · vivo Sans · Samsung's), iOS PingFang, and the Latin glyphs still come from our serif webfonts. Stock Android ships exactly two Chinese faces — Noto Sans CJK and Noto Serif CJK — so「another font」on Android means the sans. (`seen_template.html` keeps its deliberate Songti/Kaiti essay stack — a different surface, untouched.)

---

## UI language (i18n)

`lib/i18n.js` (v382) makes the app UI multi-language. The design in one breath: **the English
string IS the key** (gettext-style — English needs no dictionary, and a missing entry falls back
to English, never a raw key); one strict-JSON dict per language lives inside the file; and the
account's **"Default language" rules both axes** — what the panel speaks in new chats AND the UI
locale (`users.default_lang` → the `MAP` table; unset → the OS language; the resolved locale is
mirrored to `localStorage mad-ui-lang`, applied pre-paint in `<head>`, one reload on change).
An **invite code may carry a language** (v1033: console › Invites › New code): `/api/invite-check`
returns it, the signup door flips to it via `I18N.syncFromMe` before the form is drawn, and
signup stamps it as the admitted account's `default_lang` — so an invitee meets the app in the
inviter's chosen language from the first paint, on every device.

Rules when touching UI copy:

- **Static markup:** write plain English. `I18N.apply(document.body)` (runs right after the body
  parses) swaps any text node — or `placeholder` / `aria-label` / `title` / `data-ph` / `alt` —
  whose exact trimmed text is a dict key. Not in the dict → untouched, so brand names
  (Dialogue · Studio · Vibes · Seen) never translate, by construction.
- **JS-built strings:** wrap at the build site — `t("Added {name}", {name})`; plurals are the
  English pair `tn(n, "…device…", "…devices…")` (keep tn's first argument comma-free — the
  audit's extractor reads the two literals). A local `const t` shadows the helper — rename the
  local or call `I18N.t`. Keys reached through a *variable* (`t(cap.reason)`) must be listed in
  the audit's `DYNAMIC` set.
- **Dates/times:** `fmt.js` follows `<html lang>` (i18n.js stamps it before fmt loads) — zh reads
  刚刚 / 5分钟前 / 昨天 / `2026年7月11日`. Pages without i18n.js (the console, the Studio) stay
  `lang="en"` → the English voice, by design.
- **The audit is the contract:** `python lib/i18n_audit.py` diffs code ↔ dicts. A `t()` key with
  no entry = FAIL; static untranslated must stay ≤ the ratchet (`STATIC_BUDGET` — only ever
  lowered, 0 when phase 1 completes); a stale dict entry (copy edited away) = FAIL. Smoketest's
  "i18n catalog" section gates all three.
- **Adding a language:** one dict block + one `MAP` line (+ an `osLocale` case + a fmt.js locale
  pack); the audit then prints that language's full TODO list. No code sites change.
- **Ship ritual:** bump the `?v=` on `/i18n.js` (and `/fmt.js` when touched) in every include
  AND `sw.js`'s SHELL, plus the SW CACHE — the same lockstep as theme.css.

Server-sent strings stay English on the wire; client display sites translate them (`t(d.error)`)
when an entry exists. The room's *content* language (what personas write) is the separate,
pre-existing axis — untouched by all of this.

---

## Text fields — never a raw system box

**Every place a person types, in the app and in the docs, wears house CSS.** A default
`<input>` / `<textarea>` renders with the OS's own border, radius, font and focus ring, and
next to our cream-and-coral surfaces it reads as a foreign object dropped into the page. It
is one of the fastest ways to make a careful screen look unfinished.

The contract is **visual, not structural** — either element is allowed, provided it meets it:

* **A restyled `<input>`** must set `appearance:none`, its own border / radius / padding,
  `font:inherit` (the OS default is *not* our stack), a house focus treatment, and
  `::placeholder` in `var(--line)`. Also kill the extras: `-webkit-search-decoration`,
  the number spinners (`::-webkit-inner-spin-button`), and the iOS inner shadow
  (`-webkit-appearance:none` on `type=search`/`number`). Miss one and it shows.
* **A `contenteditable` span or div** inherits the house styling for free and is what the
  setting-sheet mockup uses (`docs/setting-sheet.html`). Its costs are real and must be
  handled: no `placeholder` (draw one with `:empty::before`), no `maxlength` (clamp on
  `input`), pasting carries rich text (strip to `textContent`), and `Enter` inserts a
  `<div>` unless you intercept it. Prefer it for **short, in-line values inside a styled
  pill** — a stepper's number, a deck name, one option in a list.

Rule of thumb: a value that lives *inside* something we already drew → `contenteditable`.
A standalone multi-line field → a properly restyled `<textarea>`. Never the bare element.

Always set `inputmode` on a numeric field so a phone raises the number pad, and
`spellcheck="false"` on anything that is not prose.

## General HTML rules

- **THE RE-RENDER FLASH — every `<img>` the app re-renders is `decoding="sync"`, and `loading="lazy"` is for scrolled lists ONLY.** (v576, owner: *"common problem, get rid of it App wise"*.) An `innerHTML` rebuild **destroys and re-creates** every image inside it. A `loading="lazy"` image is not repainted in the same frame — not even straight from cache — so it pops out and back: the avatar flash you see when any card, row or header repaints. Avatars carry `loading="eager" decoding="sync"`; house emoji (`tEmo`, `emoImg`) carry `decoding="sync"`. The **one** place `lazy` still earns its keep is the emoji picker's 1,897-cell grid, which you genuinely scroll into.
  - **The stronger form of the same rule: don't re-render what didn't change.** The flash was found on a two-tap arm that rebuilt an entire card to change one button's label. A repaint that re-mints avatars and glyphs to update a word is the wrong repaint — write the word. This is the third member of a family worth naming: **never relayout under a finger** (v477), **never destroy the focused node** (v574), **never destroy a loaded image** (v576).

- **Anything that arrives in the message stream lands like a message.** Content first, then `notifyAppend()` — scroll if the reader is at the bottom, otherwise raise the same "↓ New messages" nudge a bubble raises. ⚠ Scrolling at *creation* of a container whose content lands one step later measures a **zero-height node and moves nothing**; that reads as "it works on mobile but not desktop", because a short viewport is often already pinned to the bottom and never needed the scroll.

- **Never nest `<a>` inside `<a>`.** The browser silently auto-closes the outer `<a>` at the first nested `<a>`, leaving a card-shaped ghost element that no CSS can remove. If a card needs its own sub-links, make them a **sibling** of the card's anchor inside a shared wrapper — never children of it.

---

## Diagrams — SVG, never ASCII

Diagrams in the room2 docs (and any HTML deliverable) are **inline SVG**, never ASCII art in `<pre>` / `.code` blocks. ASCII boxes are fine for thinking in the terminal or chat, but a *published page* renders every diagram as SVG so it:

- **themes correctly** — every colour is a CSS variable (`var(--ink)`, `var(--coral)`, `var(--line)`, `var(--surface)`, `var(--surface-2)`, `var(--muted)`…), so light / dark / e-ink all work. **Never hard-code a hex inside an SVG.** The one exception: `#fff` text on a coral-*filled* button/chip, which reads in every theme.
- **stays legible and aligned** — no CJK / box-drawing misalignment, no monospace-width gambling.
- **scales** — author against a `viewBox`; the figure is `width:100%; height:auto` inside a `figure.fig` wrapper (capped at `max-width:82ch`, or a tighter inline `max-width` for a narrow UI mock-up).

**Fonts inside SVG:** `var(--sans)` for labels, `var(--mono)` for code tokens. **Minimum font-size 11** for standalone labels, **9** for in-box labels — never below 9 (mirrors the CLAUDE.md SVG-font floor).

**⚠ Never put an HTML element inside an SVG `<text>`** — no `<code>`, `<b>`, `<em>`, `<span>`. The HTML parser terminates the SVG foreign-content context the moment it hits one, and **the rest of the SVG spills into the page body as flowing prose** (2026-07-23: a `<code>unique=no</code>` in a mock-up's title dumped the whole second figure's labels below it). For a mono code token in a label, use `class="… t-mono"` on the `<text>` itself, or a `<tspan font-family="var(--mono)">` — never `<code>`. Validate before shipping: extract each `<svg>…</svg>` and `ET.fromstring()` it — clean XML parse = it will render.

Reuse the shared `.fig` classes (defined in each doc's `<style>` block): `.box` / `.seg` / `.box-k` (the coral-accented "key" element) for shapes, `.arrow` + `.head` (a `<polygon>` triangle) for flow, `.t-b` / `.t-mut` / `.t-cor` / `.t-mono` for text. **`docs/roadmap.html` is the reference implementation** — copy its figure pattern. `.code` blocks stay correct for **actual code, formulas, or pseudocode**; the rule is about *diagrams*, not all monospace text.

---

## What lives where (presentation-layer files)

```
045MultiUser/
├── index.html                 ← room2 landing (the design-docs site entry)
├── docs/                  ← the room2 design-doc pages (incl. the Style Guide)
├── functions/api/             ← CF Pages Functions (e.g. tts-config.js — KV-backed TTS config)
└── lib/
    ├── PRESENTATION.md         ← this file
    ├── room-ui.html            ← the live chat-room UI (the prototype)
    ├── theme.css               ← THE shared design layer (palette · fonts · token scales + scaffold components, #21)
    ├── fmt.js                  ← the one display voice (time · tokens · money · durations; locale packs)
    ├── i18n.js                 ← the UI-language layer (English-as-key dicts + t()/tn()/apply; audited)
    ├── md-viewer.html          ← THE markdown viewer (accented + read-aloud; settings synced via /api)
    └── md-viewer-accented.html ← accented, no TTS (the fallback)
```

The room UI's components are catalogued in the Style Guide (`docs/style-guide.html`), kept in sync with `room-ui.html` on every ship.

**`docs/composer-mockups.html` — frozen design specimen, not a living doc.** Thirteen archived composer studies, linked from room2 under *Front end · Composer studies*. It does **not** track code: not synced, not audited by `room2-docs-sync`, and **a CC session should not read it unless explicitly asked** (it would only waste tokens). The shipped landing composer is "Coral flow"; the canonical UI reference remains the Style Guide.
