docs: ADR-0047 — demonstration overlay layer for casts/teaching (#22)

Accepted decision record for the in-app demo overlay: a --demo mode
that shows automatic keystroke badges ([TAB], [ENTER], …) and a
stealth Ctrl+]-delimited step-caption buffer, both as floating
black-on-yellow boxes at the output panel's bottom-right. All forks
user-confirmed; a /runda pass contributed 10 tightening findings.
Indexed in docs/adr/README.md.
This commit is contained in:
claude@clouddev1
2026-06-10 22:16:44 +00:00
parent 638b4c9664
commit e9eb1b177e
2 changed files with 380 additions and 0 deletions
@@ -0,0 +1,379 @@
# 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
**35 rows** tall (13 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 12 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, …) and enable scripted step captions, for
screencasts and live teaching."* 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.
</content>
</invoke>