Files
rdbms-playground/docs/adr/0047-demonstration-overlay-layer.md
T
claude@clouddev1 2d0f4b2958 feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4)
Render the keystroke badge and step caption as a solid yellow rectangle
with no border glyphs and a one-cell text margin, instead of a
rounded-border box — deliberately unlike the app's bordered panels so
the demo overlays read as a distinct, eye-catching callout. Shared
fill_overlay_rect helper (borderless Block fill + inset Paragraph).
Snapshots regenerated; ADR-0047 D4 wording updated.
2026-06-11 08:40:07 +00:00

394 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 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.
</content>
</invoke>