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:
@@ -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
|
||||||
|
**3–5 rows** tall (1–3 text rows + 2 border), its height varying with
|
||||||
|
the text — a full sentence fits without forcing the author to split it,
|
||||||
|
while the 3-line cap keeps it corner-sized. The **badge** box is always
|
||||||
|
a single short token (`[TAB]` … `[SHIFT-TAB]`), so it is a fixed **3
|
||||||
|
rows** (1 text + 2 border), narrow.
|
||||||
|
|
||||||
|
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
|
||||||
|
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
|
||||||
|
so on a short terminal they could exceed it. Both boxes are **clamped to
|
||||||
|
the output inner area**: width to `output_inner_width`, the caption's
|
||||||
|
wrap-line count reduced so the stack fits the available height (badge
|
||||||
|
first — it is the time-critical one), and if a box cannot fit at all
|
||||||
|
(pathologically small terminal) it is **not drawn** rather than
|
||||||
|
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
|
||||||
|
guard only protects a real user who runs `--demo` in a tiny window.
|
||||||
|
|
||||||
|
### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
|
||||||
|
|
||||||
|
- **Keystroke badge:** auto-expires on a **time-based TTL**, default
|
||||||
|
**1.5 s** (a single tunable constant; the user asked for 1–2 s). This
|
||||||
|
matters for both media: in a cast the badge fades on its own so a
|
||||||
|
trailing `wait` ends on a clean frame, and in live teaching the badge
|
||||||
|
clears without the presenter needing another key. A new badge replaces
|
||||||
|
the current one and resets the timer.
|
||||||
|
- **Caption:** persists **until the next keystroke**, which clears it
|
||||||
|
and is then processed normally (or until an explicit empty-`Ctrl+]`
|
||||||
|
dismiss, or replacement by a new caption).
|
||||||
|
|
||||||
|
The timer reuses the runtime's existing time-boxed-`recv` pattern: the
|
||||||
|
loop already arms a `tokio::time::timeout` for the indicator debounce.
|
||||||
|
|
||||||
|
**Arm-condition extension (runda finding).** Today the loop time-boxes
|
||||||
|
`recv` **only while `debounce.is_armed()`** — and the debounce settles
|
||||||
|
at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL.
|
||||||
|
So the arm condition becomes **`debounce.is_armed() || badge_pending`**,
|
||||||
|
and the loop waits on the **nearest deadline** of the two. On a wake it
|
||||||
|
checks each independently: at the 1000 ms debounce deadline it settles
|
||||||
|
the indicator **without clearing the badge**; at the 1500 ms badge
|
||||||
|
deadline it clears the badge; then redraws. The pure "nearest deadline"
|
||||||
|
computation is unit-testable on its own.
|
||||||
|
|
||||||
|
The badge's expiry `Instant` lives in the **runtime** (so `App` stays
|
||||||
|
clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing
|
||||||
|
out of `App`); `App.demo_badge: Option<&'static str>` is the render
|
||||||
|
mirror, **set by the runtime** on a significant key and cleared on timer
|
||||||
|
elapse.
|
||||||
|
|
||||||
|
**Badge suppression during capture (runda finding).** Because the
|
||||||
|
runtime sets badges from the raw key independently of `App` state, it
|
||||||
|
must **not** badge a key that capture swallowed (e.g. an inert `Tab`
|
||||||
|
while a caption is being typed would otherwise flash `[TAB]` for a
|
||||||
|
no-op). The runtime sets a badge only when **`!app.demo_capturing`**.
|
||||||
|
|
||||||
|
**Ownership note.** `demo_caption` is mutated inside `update()`
|
||||||
|
(input-driven) while `demo_badge` is mutated by the runtime
|
||||||
|
(timing-driven). This split is deliberate and mirrors the existing
|
||||||
|
`input` (set in `update()`) vs `input_indicator` (set by the runtime
|
||||||
|
from `IndicatorDebounce`) pair — not an inconsistency.
|
||||||
|
|
||||||
|
### D6 — Help text and strings
|
||||||
|
|
||||||
|
The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo`
|
||||||
|
line. User-facing wording obeys the house rules (no engine name, no
|
||||||
|
"DSL"): *"Demonstration mode — show on-screen badges for otherwise-
|
||||||
|
invisible keys (Tab, Enter, …) 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>
|
||||||
@@ -52,3 +52,4 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
- [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||||
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
||||||
- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec<String>`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
|
- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec<String>`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
|
||||||
|
- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10** (Gitea **#22**; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a `/runda` pass that produced 10 tightening findings). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating black-on-yellow boxes at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions)
|
||||||
|
|||||||
Reference in New Issue
Block a user