From e9eb1b177ef1a3d2d9d46e6bbd39019dbf576f89 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 22:16:44 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20ADR-0047=20=E2=80=94=20demonstration=20?= =?UTF-8?q?overlay=20layer=20for=20casts/teaching=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/adr/0047-demonstration-overlay-layer.md | 379 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 380 insertions(+) create mode 100644 docs/adr/0047-demonstration-overlay-layer.md diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md new file mode 100644 index 0000000..9d4e78f --- /dev/null +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -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. + + diff --git a/docs/adr/README.md b/docs/adr/README.md index b5f2cba..9157c3f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 ` (one full diagram), `show table ` (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 to [as ]` 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`; 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)