Files
rdbms-playground/docs/adr/0047-demonstration-overlay-layer.md
claude@clouddev1 4016c3e5cd
ci / gate (push) Successful in 3m3s
feat(demo): Ctrl-G as a demo-mode F1 alias for casts (ADR-0047 Amendment 1)
The contextual hint overlay (ADR-0053) opens on F1, but F1 is an escape
sequence the autocast recorder can't emit — so casts (and presenter /
teacher sessions) couldn't trigger the most teaching-relevant overlay.

In demo mode only, Ctrl-G now aliases F1: it runs the same hint logic and
badges AS [F1], so a recording is visually identical to a real F1 press.
Ctrl-G is the only fit — Ctrl+digit (e.g. Ctrl-1) isn't encodable in a
legacy terminal (arrives as a bare `1`), and the kitty protocol that would
encode it needs escape sequences autocast can't send (and the app doesn't
enable keyboard-enhancement flags). Demo-gated, so the shipped keymap
stays F1-only; outside demo mode Ctrl-G is inert.

- app.rs: hint_key guard gains the demo-gated Ctrl-G disjunct;
  demo_badge_label maps Ctrl-G -> [F1]; 3 Tier-1 tests + badge assertion.
- ADR-0047 Amendment 1 + README index; also removed two stray
  </content> / </invoke> lines accidentally committed in the ADR file.

docs: drop three more stale "deferred" entries from CLAUDE.md — readline
shortcuts (I1b, ADR-0049), tab completion (I3), and syntax highlighting
(I4) are all implemented; only multi-line input (I1) remains open.
2026-06-15 21:30:37 +00:00

24 KiB
Raw Permalink Blame History

ADR-0047: Demonstration overlay layer — keystroke badges and step captions

Status

Accepted (2026-06-10); implemented 2026-06-11, phased A→B→C (closes Gitea #22). 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.

Implementation (commits f879d542d0f4b2). Phase A (f879d54): --demo flag + RDBMS_PLAYGROUND_DEMO env → App.demo_mode, mirroring the --no-undo plumbing; help text mentions only the visible badges (the Ctrl+] caption trigger stays low-profile, D6). Phase B (2584e76): automatic keystroke badges — pure demo_badge_label, set in App::update before the modal gate, expired by a ~1.5 s runtime timer via the new nearest_deadline helper that extends the time-boxed-recv arm condition without regressing the ADR-0027 indicator debounce (the rewrite tracks Instant deadlines; verified equivalent). Phase C (241f60c): the stealth Ctrl+] caption buffer in App::update, intercepted before the modal gate so captions work over the load picker. Post-build (2d0f4b2, user decision): the overlays render as flat filled yellow rectangles (no border glyphs, one-cell text margin) to read as a distinct callout. A whole-implementation /runda pass returned PASS with no blockers; the only untested wiring is the run_loop badge timer (not unit-testable in isolation — same posture as the existing IndicatorDebounce; the pure pieces are all tested). One intentional, user-acknowledged behaviour: Ctrl-C is inert while capturing (every non-Ctrl+] key is, by spec; exit capture with Ctrl+]). Tests: 2290 passing / 0 failing / 0 skipped (Tier-1 label fn + caption FSM + nearest_deadline, Tier-2 dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style, CLI parse/env); clippy clean.

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() gatenot 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..=0x1FKeyCode::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, flat filled rectangles 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).

Flat rectangle, not a bordered box (user decision, post-build). The overlays draw as a solid yellow rectangle with no border glyphs and a one-cell margin around the text — deliberately unlike the app's rounded-border panels, so they read as a distinct callout that "stands out nicely" rather than as another panel. Implemented with a borderless Block fill (the paint_background mechanism) plus a Paragraph inset into a one-cell Margin.

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: bold black text on a yellow fill — 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 4) columns, ellipsised beyond the third line. So the caption rectangle is 35 rows tall (13 text rows + a one-cell margin top and bottom), 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 rectangle is always a single short token ([TAB][SHIFT-TAB]), so it is a fixed 3 rows (1 text row + the margin), 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, …), 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.

Amendment 1 — Ctrl-G demo-mode alias for F1 (2026-06-15)

Context. The contextual hint overlay (ADR-0053 / H2) is opened with F1. But F1 reaches the app only as an escape sequence (\eOP / \e[11~), and the autocast recorder used for our screencasts cannot emit escape sequences — so a cast can never trigger F1, and the single most teaching-relevant overlay is unreachable in recordings. The same wall already bit step-captions (which is why Ctrl+], a single control byte, was chosen over Ctrl+!).

Decision. In demo mode only, Ctrl-G is an alias for F1. It runs the exact F1 hint logic (live-input → form hint; empty input → recent-error / getting-started) and is badged as [F1] (not [CTRL-G]) so a recorded cast is visually identical to a genuine F1 press. Ctrl-G is the only viable choice: it is a single legacy control byte autocast can send, whereas Ctrl+digit (e.g. the mnemonic Ctrl-1) is not encodable in a legacy terminal at all — digits have no control byte, so Ctrl-1 arrives as a bare 1; the kitty protocol would encode it but only as an escape sequence (the very thing autocast can't send), and this app deliberately does not enable keyboard-enhancement flags.

Why demo-gated. The shipped keymap stays F1-only — a real user never trips the alias, and demo mode is also the mode teachers/presenters run, so the alias is available exactly where it's wanted. Outside demo mode Ctrl-G falls through to the inert catch-all (the Char(c) insert arm excludes CONTROL, so no g is typed).

Scope. hint_key guard in App::handle_key gains the demo-gated Ctrl-G disjunct; demo_badge_label maps Ctrl-G → [F1] (consulted only in demo mode). Test-first: three app.rs Tier-1 tests (alias fires on input + on empty input; inert when demo off) + the badge-map assertion. The keybinding strip (ADR-0051) is not changed — F1 stays the advertised key; Ctrl-G is a recorder aid, and the badge already reads [F1].

(Editorial: this amendment also removed two stray </content> / </invoke> lines accidentally committed at the end of this file.)