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:
claude@clouddev1
2026-06-10 22:16:44 +00:00
parent 638b4c9664
commit e9eb1b177e
2 changed files with 380 additions and 0 deletions
@@ -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
**35 rows** tall (13 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 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, …) 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>
+1
View File
@@ -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 ~4050 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 ~4050 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** (35 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)