# ADR-0047: Demonstration overlay layer — keystroke badges and step captions ## Status Accepted (2026-06-10). Addresses Gitea **#22**. Builds the in-app overlay/annotation primitive that screencast recording (ADR-website-001 §2, the `autocast` pipeline) and a future guided-lesson system both need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must coexist with) and unblocks the polished version of the assistive-editor and projects (`#24`) casts. All primary forks and the visual placement were **user-confirmed** — including the two follow-ups settled after the first draft: the trigger key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over `Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3 lines**). A `/runda` pass over this ADR ran before implementation and tightened it — its findings are folded in below (caption/badge interception placement, in-capture key disposition, badge suppression during capture, the timer arm-condition, box clamping, the new output-rect field, and the control-code decode note). **Requirements traceability.** There is **no `requirements.md` item** for this work — verified by sweep. It is tracked as Gitea issue **#22** plus this ADR, consistent with the project's convention ("issues are the lightweight tracker; ADRs are the decisions"). The website-side cast scope lives in **ADR-website-001** (website branch), not main's `requirements.md`. ## Context The website records its demos as asciinema `.cast` files driven by **`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in `casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per character, Enter = `^M`**, recorded against the real `target/debug` binary. The hard constraint — the same one that drove `#24` — is that autocast can only emit **typeable characters, ASCII control codes (`^X`), and waits**. It cannot send arrow keys, function keys, or any multi-byte escape sequence. Two classes of on-screen event are therefore invisible or unexplained in a cast: 1. **Keystrokes that cause a visible change but render no glyph of their own** — most acutely **Tab** completion: the command line jumps from `show data bo` to `show data books` with no sign a key was pressed. Enter, the arrows, Ctrl-O, Esc are the same. 2. **Step structure / "what just happened" narration** — a cast is a silent moving picture; there is no channel to separate or explain steps for a visual learner. asciinema-player has no inline keystroke overlay, and a website-side HTML overlay layered on the player would be fragile (its timings would have to track every recording and break on each re-record). The robust place to solve this is **in the app**: if the app renders the overlay, the cast captures it natively and it re-records for free. The same primitive is exactly what a future **guided-lesson** system needs to point at things and narrate steps — so it is built as a general capability, not a cast-only hack (the issue's "pays off twice"). It is also directly useful for a **teacher demonstrating the playground live** — pressing Tab in front of a class has the same invisible-keystroke problem as a cast. The app's renderer is a pure function of `App` state and already draws two kinds of last-pass overlay over the base render with **no layout reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout (the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses — the exact mechanism a self-expiring badge needs. These two existing seams make the feature cheap. ## Decisions ### D1 — Activation: a `--demo` flag (+ env var), off by default Demonstration mode is entered with a **`--demo`** CLI flag, or equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE` pair. It combines freely with every other flag (`--resume`, `--mode`, a positional path); there are no exclusions. When the flag is **off** (the default), none of the key handling or rendering below is active and the app behaves exactly as today — **zero footprint for real users** (R8). `autocast` sets the flag when it launches the binary; a teacher sets it on their own command line. It is framed as a general **demonstration mode**, not "cast mode" — the honest name for what it does, and it reads sensibly in `--help`. The flag is documented in the CLI banner (one line); obscurity is not a security property here and a harmless opt-in flag is better surfaced than hidden. What stays "low-profile" (per #22) is that there is **no normal in-app command** for it and **no persistent on-screen indicator** (see D7) — so a cast frame is never polluted by a `[DEMO]` marker. ### D2 — Keystroke badges: automatic, app-detected In demo mode the app shows a transient badge **automatically** whenever it handles one of a curated set of *otherwise-invisible* keys. The cast does nothing special — it presses the key it was going to press anyway, and the badge re-records for free. The set: | Key | Badge | | Key | Badge | |-----|-------|-|-----|-------| | Tab | `[TAB]` | | Home | `[HOME]` | | Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` | | Enter | `[ENTER]` | | PageUp | `[PGUP]` | | Esc | `[ESC]` | | PageDown | `[PGDN]` | | ↑ | `[UP]` | | Backspace | `[BKSP]` | | ↓ | `[DOWN]` | | Delete | `[DEL]` | | ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` | | → | `[RIGHT]` | | | | Plain character keys render a glyph on the input line already, so they produce **no** badge (that is the definition of the set — "invisible" keys). The badge fires on **key press**, regardless of whether the key had an effect in the current state (e.g. `↑` with no history still shows `[UP]`): simpler, and the demo author controls the script. Badge text is bracketed ASCII (`[TAB]`) per the user's preference — renders on every terminal and is cast-safe, unlike the `⇥` glyph mocked earlier. The label mapping is a **pure function** `demo_badge_label(&KeyEvent) -> Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a timer** (D5). ### D3 — Step captions: a stealth, control-code-delimited input buffer Caption text must arrive through typeable input only (R4). A **single toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`, the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a plain letter chord like Ctrl-G) because it is **maximally non-obvious** — the classic telnet escape, almost never pressed by accident — while still being a single ASCII control byte autocast can emit. It has **no signal or flow-control baggage** and is **multiplexer-safe**. Note collision risk is already near-zero in casts (a fresh `--demo` binary sees only scripted keys); the obscurity mainly protects a live teacher from a stray trigger. - First `Ctrl+]` **opens** capture. The command input line and the output are untouched. If a caption is already visible, opening clears it (you are starting a new annotation). - Subsequent typed characters **accumulate into the caption buffer invisibly** — they do **not** appear on the prompt, do not execute, and do not enter history. **`Backspace`** deletes the last buffered character. **Every other key while capturing — Enter, the arrows, Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]` do anything. - A second `Ctrl+]` **commits** the buffer to the caption box (D4). An **empty** commit (toggle-toggle with nothing typed) clears any visible caption — the author's explicit dismiss. Because nothing about the capture shows on the prompt, the caption "pops" into its box with no ugly typing artifact, while the caption text still lives **inline in `casts.mjs`** at the right spot (one source of truth, no separate notes file to keep ordered). This is all keyboard-stream interpretation, so it lives in the pure-sync `App::update()` (Tier-1 testable) and is **only active in demo mode** — when off, `Ctrl+]` is inert and characters reach the input line normally. **Placement in `handle_key` — before the modal gate (runda finding).** The capture interception (`Ctrl+]` and the accumulating characters) **and** the "clear a visible caption on the next keystroke" check sit at the **very top of `handle_key`, before the `self.modal.is_some()` gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it. This is required so captions can be authored **while a modal is open** — specifically the load-picker, which is exactly the **projects / `#24` cast** (annotating "press j/k to move", with an `[ENTER]` badge as the selection is made). While capturing, the modal is frozen (capture swallows keys), which is the intended behaviour. `App` exposes `demo_capturing` so the runtime can read it (see D5). The control-code path is sound end to end, verified against our crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits `^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F` → `KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/ `0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and `0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5` yields the same byte. *(This is the Unix/Linux decode path — the cast-recording platform; crossterm's separate Windows backend would be confirmed by test if live `--demo` on Windows is exercised.)* ### D4 — Both overlays are floating boxes at the output panel's inner bottom-right The badge and the caption both render as **floating, bordered boxes anchored to the inside of the output panel's bottom-right corner** (inset one cell from the panel's inner edge), drawn **last over the base render** — after modals, so they remain visible while the load-picker (the `#24` cast) or any modal is up, and with **no layout reflow** (consistent with the modal / nav-overlay precedent; honours R8). The top-level `render()` does not currently know the output-panel rect (it is computed inside `render_right_column`), so a **new field `App.last_output_area: Rect`** is set in `render_output_panel` and read at the top-level draw pass to anchor the overlay — the established "renderer reports metrics back to `App`" pattern (sibling to `note_output_viewport`, which stores row counts, not a rect). When **both** are present, the **keystroke badge stacks directly above the caption box** (both right-aligned in the corner) so they never overlap. **Styling — deliberately high-contrast:** **black text on a yellow background**, bold, bordered — hard to overlook, identical in light and dark themes (a fixed high-contrast pair centralised in `theme.rs`, not theme-derived). **Caption sizing (user-confirmed).** The caption is **word-wrapped to at most 3 lines** within a content width of `min(40, output_inner_width − 6)` columns, ellipsised beyond the third line. So the caption box is **3–5 rows** tall (1–3 text rows + 2 border), its height varying with the text — a full sentence fits without forcing the author to split it, while the 3-line cap keeps it corner-sized. The **badge** box is always a single short token (`[TAB]` … `[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text + 2 border), narrow. **Clamping (runda finding).** Stacked, the two boxes are up to 8 rows (5 caption + 3 badge); the output panel's inner height is only `Min(5)`, so on a short terminal they could exceed it. Both boxes are **clamped to the output inner area**: width to `output_inner_width`, the caption's wrap-line count reduced so the stack fits the available height (badge first — it is the time-critical one), and if a box cannot fit at all (pathologically small terminal) it is **not drawn** rather than overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the guard only protects a real user who runs `--demo` in a tiny window. ### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke - **Keystroke badge:** auto-expires on a **time-based TTL**, default **1.5 s** (a single tunable constant; the user asked for 1–2 s). This matters for both media: in a cast the badge fades on its own so a trailing `wait` ends on a clean frame, and in live teaching the badge clears without the presenter needing another key. A new badge replaces the current one and resets the timer. - **Caption:** persists **until the next keystroke**, which clears it and is then processed normally (or until an explicit empty-`Ctrl+]` dismiss, or replacement by a new caption). The timer reuses the runtime's existing time-boxed-`recv` pattern: the loop already arms a `tokio::time::timeout` for the indicator debounce. **Arm-condition extension (runda finding).** Today the loop time-boxes `recv` **only while `debounce.is_armed()`** — and the debounce settles at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL. So the arm condition becomes **`debounce.is_armed() || badge_pending`**, and the loop waits on the **nearest deadline** of the two. On a wake it checks each independently: at the 1000 ms debounce deadline it settles the indicator **without clearing the badge**; at the 1500 ms badge deadline it clears the badge; then redraws. The pure "nearest deadline" computation is unit-testable on its own. The badge's expiry `Instant` lives in the **runtime** (so `App` stays clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing out of `App`); `App.demo_badge: Option<&'static str>` is the render mirror, **set by the runtime** on a significant key and cleared on timer elapse. **Badge suppression during capture (runda finding).** Because the runtime sets badges from the raw key independently of `App` state, it must **not** badge a key that capture swallowed (e.g. an inert `Tab` while a caption is being typed would otherwise flash `[TAB]` for a no-op). The runtime sets a badge only when **`!app.demo_capturing`**. **Ownership note.** `demo_caption` is mutated inside `update()` (input-driven) while `demo_badge` is mutated by the runtime (timing-driven). This split is deliberate and mirrors the existing `input` (set in `update()`) vs `input_indicator` (set by the runtime from `IndicatorDebounce`) pair — not an inconsistency. ### D6 — Help text and strings The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo` line. User-facing wording obeys the house rules (no engine name, no "DSL"): *"Demonstration mode — show on-screen badges for otherwise- invisible keys (Tab, Enter, …), for screencasts and live teaching."* The help text **deliberately mentions only the visible badges, not the `Ctrl+]` step-caption mechanism** (user decision): the caption trigger stays low-profile, true to #22's "secret trigger" framing — a cast author or lesson script knows it; a casual `--help` reader is not pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not localised; caption content is author-supplied free text and likewise not a catalog string. ## Alternatives considered - **Scripted badges** (cast pushes each badge explicitly) — rejected: the app already sees every key, so automatic detection (D2) is more robust and re-records for free. *(User-confirmed.)* - **Typed hidden command for captions** (a secret-prefixed line) — rejected: the command is briefly visible being typed on the prompt. **Preloaded notes file + advance key** — rejected: a separate file that must stay ordered/in-sync with the cast. The **stealth buffer** (D3) is self-contained in the cast script *and* leaves the prompt clean. *(User-confirmed.)* - **Fixed-corner HUD badge / badge by the input line** — rejected in favour of a floating box at the output panel's bottom-right; **top banner / subtitle band** for captions — rejected in favour of the matching floating box. *(User-confirmed via mockups.)* - **A persistent `[DEMO]` status-bar marker** — rejected: it would show in every cast frame. Demo mode is silent except for the transient overlays (D7). - **Caption persists for a fixed time** (instead of until next keystroke) — noted as a one-constant change if the next-keystroke rule proves too eager in practice; the user chose next-keystroke. - **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not representable as a single ASCII control byte, so autocast cannot send it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter chord** — workable but less non-obvious; the user chose the maximally-obscure `Ctrl+]` from the valid single-byte set. - **Single-line ellipsised caption** — rejected in favour of wrap-to-3- lines so a full sentence fits. *(User-confirmed via mockups.)* ## Consequences - A general overlay primitive exists that the cast pipeline uses now and the guided-lesson system can reuse later (`App.demo_caption` and the badge channel are the seam). - `autocast` casts gain a real Tab-completion moment, key indicators for the projects/`#24` round-trip, and step captions — all by adding `key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then re-running `pnpm casts`. No website-side overlay machinery. - Teachers get the same affordance live via `--demo`. - One new control-code binding (`Ctrl+]`) is consumed, but only inside demo mode — normal sessions are unaffected, so it does not encroach on the reserved readline chords (I1b). - The renderer must expose the output-panel rect to `App`; a small, pattern-consistent addition. ## Scope / non-goals (OOS) - **Manual/scripted badge push** and **badges for plain character keys** — out; badges are automatic over the fixed invisible-key set. - **Configurable overlay styling or placement** — out; fixed black-on-yellow boxes at the output panel's bottom-right. - **The guided-lesson / tutorial system itself** — out (its own ADR); this ADR only builds the primitive it will reuse. - **Persisting demo mode across project switches / sessions** — out; it is a per-run flag. - **Localising caption content** — out; captions are author-supplied free text. - **Output-pane scroll-in-casts** and other arrow-only interactions — out (separate enhancement; same autocast limitation as noted in #24). ## Testing Per ADR-0008 and the project's test discipline (test-first; green, no skips): - **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`); the stealth-caption state machine — open on `Ctrl+]`; characters accumulate with the **input line unchanged**; `Backspace` edits the buffer; **non-typing keys inert while capturing**; commit sets the caption; empty commit clears; opening over a visible caption clears it; next keystroke clears a visible caption **then processes normally**; capture works **with a modal open** (caption set while the load-picker modal is up, picker state untouched); the **demo-off gate** (`Ctrl+]` inert, characters reach the input, no caption/badge state ever set); the pure "nearest deadline" helper. - **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both stacked, at 90×26 in light and dark — verifying the bottom-right anchor, the stack order, and the black-on-yellow styling; plus a short-terminal case exercising the clamp/skip guard. - **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a significant-key event sets `app.demo_badge` and a swallowed key during capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets `app.demo_caption` without touching `app.input`. - **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the `RDBMS_PLAYGROUND_DEMO` env fallback; default-off. **Honest coverage limit.** The badge **timer-expiry wiring** runs inside `run_loop` (terminal + db worker), which is not unit-testable in isolation; it is a thin reuse of the already-proven `IndicatorDebounce` time-boxed-`recv` path. We therefore test the **pure pieces** exhaustively (label fn, capture state machine, nearest-deadline helper) and assert plumbing via Tier-3, rather than over-claiming an integration test of the `tokio` timeout itself.