4016c3e5cd
ci / gate (push) Successful in 3m3s
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.
455 lines
24 KiB
Markdown
455 lines
24 KiB
Markdown
# 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 `f879d54` → `2d0f4b2`).** 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()`
|
||
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, 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 **3–5 rows** tall (1–3 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 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.
|
||
|
||
## 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.)*
|