Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)

Brings a large batch of app work onto the website branch so the docs (and
casts) can reflect it:

- #24 vi-style j/k/g/G navigation in the load picker (ADR-0047 era) — unblocks
  a scriptable projects cast (autocast can send j/k; not arrows)
- #22 demonstration overlay layer (ADR-0047): `--demo` mode, keystroke badges,
  and step-caption info banners — usable from casts to highlight key moments
- C4 m:n convenience command (ADR-0045): `add m:n relationship … via <junction>`
- ADR-0046 UI: width-derived schema sidebar + Ctrl-O nav mode, responsive
  two-row input + horizontal scroll, geometry-fixed hint panel
- X1 comprehensive logging sweep across worker/parser/app/persistence/runtime
- FK fixes: compound-FK violation message names every column pair; inline FK
  referencing a compound PK points at the table-level form

Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new
main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed,
0 failed (1 ignored doctest, inherited from main).
This commit is contained in:
claude@clouddev1
2026-06-11 10:06:18 +00:00
66 changed files with 6174 additions and 316 deletions
+300
View File
@@ -0,0 +1,300 @@
# ADR-0045: `create m:n relationship` convenience command (C4)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**. Closes
`requirements.md` **C4**.
**Implementation note — a corrected ADR premise.** The plan claimed
"the walker already dispatches multiple nodes per entry word" and used
that to justify a *separate* `CREATE_M2N` node. That is true only in
**advanced** mode. The build hit **two** places hard-coded to assume
**≤1 DSL form per entry word in simple mode**: (1) the dispatcher
(`decide`) committed `simple.first()` unconditionally, so `create
table` shadowed `create m:n`; (2) the completion continuation-merge was
gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a
candidate. Both were generalized to support multiple DSL forms per
entry word — **behaviour-preserving for every existing single-form
command** (the dispatch reduces to the old single-candidate commit; the
completion merge is gated on `simple_count > 1`). Verified: zero ripple
beyond the new command's own surfaces. The teaching echo (advanced-mode
DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the
generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction
description (round-trips as valid SQL).
A second `/runda` DA pass (pre-commit) closed five coverage gaps
(highlighting, persistence round-trip, junction rename, name-collision,
missing-parent — the first two had been wrongly claimed verified) and
found two more issues: (a) `create m:n … as __rdbms_*` was accepted —
a hidden-orphan hole the new `as` slot **exposed**, but rooted in the
simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so
plain `create table __rdbms_*` had it too). Fixed at the **root**
a `reject_internal_table_name` guard in the shared `do_create_table`,
closing every path (the advanced-SQL path already rejected at parse).
(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n`
opener but not `m:n`, so `create m:n …` resolved to no usage form —
fixed with an explicit `m:n` branch. All four
design forks were escalated and user-confirmed at the recommended
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
optional `as`; both modes). Two follow-up points were also confirmed
in a `/runda` DA pass: **self-referential m:n is refused outright**
(user: "refuse — full stop"; it is a beginner-facing convenience, not
the place for directional-naming complexity), and the FK column naming
is **`{parent_table}_{pk_column}`**. The DA pass additionally
established — against the user's initial assumption — that **PK-less
tables *are* reachable** (advanced-mode SQL `create table t (a int)`
declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the
parent-PK guard (D7) is retained as a correctness check.
Builds on ADR-0013 (named 1:n relationships, the relationship
metadata table, the rebuild-table primitive), ADR-0043 (compound,
list-based FK references — the junction may reference compound parent
PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and
the existing `do_create_table` executor (which already accepts
`foreign_keys: Vec<SqlForeignKey>` and writes relationship metadata
per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions),
ADR-0002 (no engine name in user-facing strings), and ADR-0024
(unified grammar / `CommandNode` registration, completion, hints,
help-id, usage-id wiring).
## Context
A many-to-many relationship is modelled in a relational database by a
**junction table** (a.k.a. associative / bridge table) that holds one
foreign key to each of the two parents. Today a learner can build this
by hand: `create table` the junction, then `add 1:n relationship`
twice. That is three commands and requires the learner to already know
the junction-table pattern — exactly the concept C4 is meant to
*teach*.
C4 (`requirements.md`): *"`create m:n relationship from <T1> to <T2>`
produces an auto-named junction table the user can rename; pulls
primary keys and FK definitions automatically."*
The relationship machinery this builds on is freshly solid: ADR-0043
made the relationship model list-based (compound-aware) across six
layers, and ADR-0044 gave relationships a visual representation. C4 is
the natural convenience layer on top.
## Decision
Add a DSL command:
```
create m:n relationship from <T1> to <T2> [as <name>]
```
It generates a **junction table** with one FK column per primary-key
column of each parent, a **compound primary key** over all of those FK
columns, and **two 1:n relationships** (junction → T1, junction → T2),
all in a single transaction (= one undo step). The junction is a
normal table: `rename table`, `drop table`, `show table`, `insert`,
etc. all work on it afterward.
### D1 — Junction primary key: compound over the FK columns (fork, user-chosen)
The junction's PK is the **combination of all its FK columns**. For
`create m:n relationship from Students to Courses` (both PK `id`):
```
Students_Courses
Students_id int ┐ PRIMARY KEY (Students_id, Courses_id)
Courses_id int ┘
FOREIGN KEY (Students_id) REFERENCES Students(id)
FOREIGN KEY (Courses_id) REFERENCES Courses(id)
```
This is the textbook junction: the `(Students_id, Courses_id)` pair is
unique, so a student cannot be linked to the same course twice. It is
the most pedagogically correct model and needs no surrogate key.
*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a
key the learner did not ask for); two FK columns with no PK (allows
duplicate links — wrong lesson).
### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen)
Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A
junction row is meaningless without both ends, so deleting a parent
(a Student or a Course) removes its link rows automatically — the
natural junction semantics, and a clean teaching demonstration of
cascade. There is no syntax in this command to override the actions;
the learner who wants different actions builds the junction by hand
(or drops + re-adds a relationship). This keeps the convenience
command convenient.
### D3 — Naming: auto-name `<T1>_<T2>`, optional `as <name>` (fork, user-chosen)
The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`).
An optional `as <name>` clause overrides it — consistent with
`add 1:n relationship [as <name>]` and saving a follow-up
`rename table`. The two generated relationships are auto-named by the
existing relationship-name resolver (e.g.
`Students_id_to_Students_Courses_Students_id`), exactly as `create
table` with inline FKs already names them.
### D4 — FK column naming: `{parent_table}_{pk_column}`
Each FK column is named `{parent_table}_{pk_column}` — one per PK
column of each parent. This disambiguates the common case where both
parents share a PK column name (both `id``Students_id`,
`Courses_id`), and generalises to compound parent PKs: a parent
`Sections(course_id, term)` contributes `Sections_course_id` and
`Sections_term`. Column types come from each parent PK column's
`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`,
others identity — so the junction columns are plain storable types,
never auto-generating.
### D5 — Mode availability: both simple and advanced
`create m:n relationship` is a `CommandCategory::Simple` DSL command,
reachable in **both** input modes — the same posture as the sibling
relationship commands (`drop relationship … Mode::Advanced` is a tested
path today). There is no SQL spelling for it; an advanced-mode user who
prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command
is purely additive teaching sugar, so making it available everywhere is
harmless and consistent.
### D6 — Implementation: one `do_create_table` call, not a batch
The junction table and both relationships are created by **building a
single `Command`/worker request that `do_create_table` already
handles**: `do_create_table` accepts `columns`, `primary_key`, and
`foreign_keys: Vec<SqlForeignKey>`, and already inserts relationship
metadata for each FK. So the executor:
1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each
parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:**
if either parent's `primary_key` is empty, error with a friendly
"`<table>` has no primary key, so it cannot anchor an m:n
relationship" *before* building any FK. This is a real case — a
table created via advanced-mode SQL `create table t (a int)` has no
PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) —
not a theoretical one, so the guard is a correctness requirement,
not defensive padding.
2. Builds the junction `ColumnSpec`s (one per parent PK column, typed
via `fk_target_type`), the compound `primary_key` list, and two
`SqlForeignKey` values (`on_delete = on_update = Cascade`).
3. Calls the create-table path, which creates the table + both FKs +
all metadata in **one transaction** — naturally one undo step, no
`BeginBatch`/`EndBatch` bracketing needed.
This reuses the most-tested machinery and inherits its persistence,
metadata, and FK-validation behaviour for free.
A new typed command variant `Command::CreateM2nRelationship { t1, t2,
name }` carries the parsed form; the runtime/executor expands it to the
junction definition. (We do **not** lower it to `Command::CreateTable`
at parse time — keeping a distinct command preserves command identity
per the X5 "unique commands for every unique case" principle, and lets
the teaching echo speak in m:n terms.)
## Grammar, AST, and cross-cutting wiring
A new command is not done when it parses — it must light up every
surface a learner touches. Enumerated here so none is missed
(verification in **Testing**).
- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode`
with `entry: create`, shape `m:n relationship [as <name>] from <T1>
to <T2>`, registered in `REGISTRY` as `CommandCategory::Simple`. A
separate node (not a branch inside `CREATE`) keeps the tested
create-table builder untouched; the walker already dispatches
multiple nodes per entry word. The `m:n` opener mirrors `1:n`
(`Word("m")`, `Punct(':')`, `Word("n")`).
- **AST builder** `build_create_m2n``Command::CreateM2nRelationship`.
- **Command + worker plumbing:** `Command::CreateM2nRelationship`
variant; `Request::CreateM2nRelationship`; runtime
`execute_command_typed` arm; `Database::create_m2n_relationship`
public API; executor `do_create_m2n_relationship` (per D6).
- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES`
(`completion.rs`) so Tab on `create m` offers `m:n` as one fluent
piece (exactly as `("1", "1:n")` does for `add`). Identifier slots
for `<T1>`/`<T2>` inherit table-name completion from the walker's
`IdentSource::Tables` automatically.
- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient
hint panel guides `from` / table / `to` / table / optional `as`,
matching the `add 1:n relationship` hinting.
- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven
with no per-command special-casing; verify the line highlights.
- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in
`help` automatically (REGISTRY iteration) and under `help create`;
add the `help.ddl.create_m2n` catalog string.
- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]`
→ a malformed `create m:n …` shows the form in the usage block; add
the `parse.usage.create_m2n` catalog string. Add the near-miss cases
to the `parse_error_pedagogy` matrix (ADR-0042) for the `create`
entry word.
- **Teaching echo** (`echo.rs` + `build_schema_echo`): a
`CreateM2nRelationship` arm that echoes the generated junction —
the `create table` it built plus the two `FOREIGN KEY` lines — so the
learner sees the pattern the convenience expanded to.
- **Structure render:** the executor returns the junction's
`TableDescription`; the ADR-0044 render path already draws its
relationships as diagrams on the create echo? No — incidental
`create table` echoes keep prose (ADR-0044 reach); the m:n echo shows
the junction structure with its outbound FKs in the standard prose
form. (A future enhancement could draw both relationships as
diagrams; out of scope here.)
## Genuine forks (escalated, all resolved 2026-06-10)
1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial +
UNIQUE vs no PK. → D1.
2. **Referential actions**`CASCADE` (chosen) vs `NO ACTION` vs
`RESTRICT`. → D2.
3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only.
→ D3.
4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5.
## Testing
Integration (`tests/it/`), test-first:
- **Functional:** `create m:n relationship from A to B` creates table
`A_B` with the two FK columns, compound PK over them, and two
enforced FKs; inserting a junction row with a non-existent parent is
refused; the `(fk1, fk2)` pair is unique (duplicate link refused).
- **`as <name>`** overrides the junction name.
- **Compound parent PK:** a parent with a 2-column PK contributes two
FK columns; the junction PK spans all of them; FKs enforce per pair.
- **Cascade:** deleting a parent row removes its junction rows.
- **Undo:** one `create m:n` is exactly one undo step (table + both
relationships gone after `undo`).
- **Persistence round-trip:** the junction + both relationships survive
a save → rebuild-from-text.
- **Errors:** missing parent table; parent without a PK; junction-name
collision with an existing table; (self-m:n → OOS error, below).
Cross-cutting (the surfaces a new command must light up):
- **Completion:** Tab after `create ` surfaces `m:n relationship`;
table-name completion fires at the `<T1>`/`<T2>` slots.
- **Help:** `help` lists the command; `help create` includes the m:n
form; the catalog string renders.
- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage
block; near-miss matrix entries added (`parse_error_pedagogy`).
- **Hints + highlighting:** ambient hint progression through the form;
the line highlights (snapshot or assertion as the sibling commands
use).
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **Self-referential m:n** (`from T to T`) — **refused outright**
(user-confirmed, "full stop"): the two FK column sets would collide
on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`)
is more complexity than this beginner-facing convenience warrants.
The executor detects `t1 == t2` (on the canonical names) and errors
with a friendly pointer ("an m:n relationship needs two different
tables — to link a table to itself, add the junction by hand").
Not a deferred follow-up; a deliberate non-goal.
- **Per-relationship action overrides** in the command syntax (D2 fixes
`CASCADE`); use a hand-built junction for other actions.
- **Extra junction columns** (payload attributes on the link, e.g. an
enrolment date) — add them afterward with `add column`.
- **m:n visualization as diagrams** on the create echo (ADR-0044 reach
keeps incidental create echoes in prose).
- **Renaming the auto-generated *relationships*** (only the table is
`as`-nameable); drop + re-add covers it.
@@ -0,0 +1,556 @@
# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23)
## Status
Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C**
(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470`
(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) ·
`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea
issues **#20** (hint-panel height jumpiness), **#21** (database-structure
/ left-column improvements), and **#23** (long command input). Issue
#23's own note ("handle after #21 is decided") is honoured: the input
work is split so the part that depends on the sidebar's width budget
lands with it.
Two decisions landed differently from the original draft and are
recorded inline: the relationship data lives on **`App`, not
`SchemaCache`** (DB2), and the navigation overlay clears **only the
sidebar strip + a one-column gutter** (panels stay visible behind),
not the whole area (DC2).
Builds on and honours: **ADR-0003** (the persistent Simple/Advanced
mode model — navigation mode is *not* a third input mode, see DC1),
**ADR-0027** (the input validity indicator's reserved 6 right columns —
horizontal scroll and 2-line display preserve that reserve),
**ADR-0044** (relationship visualization — the relationships panel
renders the same `RelationshipSchema` data the `show relationship`
diagram already consumes), **ADR-0013 / ADR-0043** (the
`RelationshipSchema` model: name, parent/child tables, list-based
compound columns, referential actions), **ADR-0015** (project file
format — sidebar visibility is **session-only**, so the format is
untouched), and **ADR-0002** (no engine name in user-facing strings).
Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md):
visual changes here are driven either by new `App` *state* fields
(mutated in `update()`) or by pure *render-time* functions of the frame
geometry (see State section); `update()` stays pure-sync.
**Requirements & issues touched (verified against `requirements.md`).**
Evolves **S1** (the always-present three-region layout — the left items
region becomes width-optional, DB1). **Overrides S2**, which planned
additional element kinds as *nested* items in the tables list;
relationships get their own panel instead (DB2/DB4 — see Genuine forks
§11). **Corrects S4**: its "keyboard-toggleable hint area" was never
implemented (no toggle keybinding exists in the code) and is not wanted
— the hint panel became indispensable once completion moved into it
(ADR-0022) — so the toggle phrase is struck from `requirements.md` and
no toggle is added here. Extends **I1a** (single-line cursor editing →
horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column
validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind
(DC3) does **not** regress **V4**'s output scroll, which stays live in
input mode. Adjacent but separate: Gitea **#22** (an in-app
overlay/annotation layer for casts and guided lessons — its own ADR)
shares the overlay-render and screencast context with DC2's `Clear`
overlay; the two are meant to coexist, not merge.
## Context
Three UI issues were raised together because they are coupled through
the terminal's width and height budget; treating them as one decision
avoids three conflicting partial fixes.
**Current layout (verified in `src/ui.rs`).** `render()` splits
vertically into `Min(8)` main / `Length(1)` project label / `Length(1)`
status. The main area splits horizontally into a **fixed
`Length(28)`** left column (`render_items_panel`, a "Tables" list with
indented index names) and a `Min(20)` right column. The left column's
block has `Borders::ALL`, so its **usable inner width is 26 columns**
(28 2 borders). The right column splits vertically into output
(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`).
**#20 — hint jumpiness.** `hint_content` is recomputed **every frame**
as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 35 rows. As the
user types and hint strings appear, grow, and vanish, the hint panel
resizes and **shoves the input and output panels**, producing the
flicker visible in screencasts. The root cause is that height tracks
*content* rather than terminal *geometry*.
The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the
two longest strings are `value_literal_slot` (106 chars) and
`create_table_element` (102); four more are 5057; the rest ≤ 50. The
wrapping consequence is sharp: at a right-column inner width ≥ ~54
columns the worst string needs **at most 2 lines**; a **3rd line is
only ever required when the right column is narrower than ~54** (a
sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column
terminal). On the project's screencasts (90 columns wide, sidebar
hidden — see DB1) two lines are provably sufficient.
**#21 — the left column.** A persistent 26-column Tables list is rarely
filled even half-way by a teaching database, yet it permanently costs
horizontal space the output and input panels want — acutely so on the
90-column screencasts. The pedagogical value of an *always-visible*
schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column
is **kept but made optional and more useful**, not deleted.
**#23 — long input.** The command input is a **single logical `String`**
rendered by a `Paragraph` with no wrap and no horizontal scroll
(`render_input_panel`); text past the panel width **clips silently**.
The cursor is a byte offset on a char boundary; Up/Down drive history.
The fix needs the width the sidebar's removal frees, hence the coupling.
**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion),
Enter (submit), Up/Down (history), Left/Right/Home/End (cursor),
PageUp/PageDown (output scroll), Backspace/Delete, Esc
(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred
(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input.
Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control),
Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G
(BEL). This leaves a narrow band of safe combinations for new controls.
## Decision — phasing
The work ships in three phases so the screencasts benefit from the
least-controversial part first and the riskiest part (the focus/scroll
model) is isolated:
- **Phase A — input & hint (DA1DA4):** self-contained, no sidebar
dependency; fixes #20 and the baseline of #23.
- **Phase B — optional, richer sidebar (DB1DB3):** visibility model +
relationships panel + schema-cache enrichment.
- **Phase C — navigation mode (DC1DC4):** the Ctrl-O focus/scroll/
expand model that makes the sidebar browsable.
Each phase is independently shippable and independently green.
## Decision — Phase A: responsive input & hint heights
### DA1 — Hint height is a function of terminal geometry, fixed between resizes
The hint panel's height is **decoupled from hint content**. It is
computed from the terminal's width and height **once per resize** and
held constant as the user types. Because the panel no longer resizes on
every keystroke, it never shoves the input/output panels — the #20 jump
is eliminated at the source, not damped. Content that exceeds the fixed
height is ellipsized (the existing `clamp_wrapped` truncation), which is
now a rare, width-driven event rather than a per-keystroke one.
### DA2 — Responsive height buckets
Heights are chosen by terminal **height** (rows), with the hint's
optional 3rd line gated on right-column **width** (per the Context
measurement):
| Terminal height | Input content rows | Hint content rows |
| --- | --- | --- |
| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 |
| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) |
A safety degradation protects tiny terminals: the output panel's
`Min(5)` is honoured first; if rows are insufficient, the hint shrinks
to 1, then the input to 1. The `40`-row threshold is a tunable constant.
### DA3 — Input horizontal scroll (single logical line)
The input keeps its **single-`String`** model (no embedded newlines —
this is explicitly *not* multi-line input, see Out of scope). A new
`App` field `input_scroll_offset: usize` tracks the first visible
column; the renderer shows a window of the line and keeps the cursor in
view, mirroring the candidate-line horizontal-scroll markers already in
`render_candidate_line`. The ADR-0027 6-column indicator reserve is
preserved (the scroll window is the text area = `inner.width 6`, not
the full inner width). Because `update()` does not know the panel width,
the renderer feeds it back via a `note_input_viewport(text_width)` call
(the analogue of the existing `note_output_viewport`), against which the
offset is clamped to keep the cursor visible. `input_scroll_offset`
**resets to 0** whenever the buffer is replaced wholesale — on `submit`,
on history navigation (Up/Down), and on any clear. This is the baseline
#23 fix and is sufficient on its own for the compact (1-row) layout.
### DA4 — Two-line input display when tall (`H ≥ 40`)
On comfortable terminals the input renders across **2 visual rows** by
soft-wrapping the single logical line, with the cursor mapped to a
(row, col) within the two rows. Content longer than two rows scrolls
the two-row window horizontally (DA3) so the cursor stays visible. The
**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge
of the *first* row** (its 6-column reserve applies to row 1; the soft-
wrap on row 1 stops 6 columns short, row 2 uses the full text width) —
S6 is preserved.
This is display-only over the same single-`String` model — distinct
from the deferred true multi-line-input feature (I1, which adds
*multiple logical lines* with Enter-inserts-newline). **Forward-compat
note:** I1, when built, should reuse DA4's row-rendering and cursor
(row, col) mapping rather than introduce a parallel one — DA4 is the
substrate, not a competitor.
## Decision — Phase B: optional, richer sidebar
### DB1 — Width-derived visibility plus transient peek (session-only)
Sidebar visibility is **derived, not stored**: the sidebar is visible
iff the terminal **width > 90** *or* navigation mode is currently
focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed
every frame from terminal width and `NavFocus`; nothing persists to
`project.yaml` (ADR-0015 untouched), so it is session-only by
construction — and there is no stored visibility field to keep in sync.
At ≤ 90 columns the sidebar is hidden by default — so the 90-column
screencasts never show it and the output panel gets the full width it
needs there — but `Ctrl-O` temporarily reveals it for the duration of a
browse and re-hides it on exit (DC1).
**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue
#21's original wording asked for "a keystroke to show and hide it"; the
Ctrl-O peek covers that need, so no separate toggle and no
force-shown/force-hidden override is added. Visibility stays a pure
function of `(terminal width, NavFocus)` — the simplest model that
satisfies the requirement. Should pinning ever prove necessary, a
persistent override is an additive follow-up (see Out of scope).
### DB2 — Add a relationships panel; enrich the schema cache
The left column gains a **second panel** below Tables: a list of the
project's relationships. This is a deliberate **override of S2**, whose
note proposed additional element kinds (relations, views) as *nested*
items inside the existing tables list. Relationships are *cross-table*,
not per-table, so nesting them under a single table reads wrong; a
sibling panel is the honest shape (user-confirmed 2026-06-10). S2's
"without restructuring" intent is still met — the items column simply
holds two stacked panels (DB4) instead of one.
The panel needs the full `RelationshipSchema` (name, parent/child
tables, list-based columns, on-delete/on-update actions) that the `show
relationship` path already fetches.
**Data home — `App`, not `SchemaCache` (revised at implementation,
2026-06-10).** The design first proposed an additive
`SchemaCache.relationship_details: Vec<RelationshipSchema>` field.
Implementation revised this to a **parallel `App.relationships:
Vec<RelationshipSchema>`** field for two reasons: (1) `SchemaCache` is
*walker/completion-facing* — it needs only relationship **names**
(unchanged in `SchemaCache.relationships`, still borrowed as
`&Vec<String>` by `IdentSource::Relationships`); the full records are
**UI-only**, so `App` is the architecturally correct home, mirroring
`app.tables` (which the items panel already reads alongside the cache).
(2) Adding a field to `SchemaCache` would force edits to ~23 full
struct literals across the test suite, whereas `App` gains one field.
The /runda guard it answered — *don't break completion by retyping
`relationships`* — is fully honoured either way. Delivery: a worker
`Request::ReadAllRelationships` (→ `Database::read_all_relationships`,
returning `Vec<RelationshipSchema>` via the existing
`read_all_relationships(conn)`); the runtime's `refresh_schema_cache`
posts a new `AppEvent::RelationshipsRefreshed` alongside
`SchemaCacheRefreshed`, and the `App` stores it. No behavioural
difference from the original design.
The panel has **two display states** keyed off focus (DC2):
- **Unfocused (26-col)** — an ambient glance. Per relationship: the
name (ellipsized past the inner width), and the endpoints broken at
the arrow to fit a narrow column:
```
Customers_Orders
Customers.id ->
Orders.customer_id
```
- **Focused + expanded (4050 col, DC2)** — a browse view. At the wider
width the endpoints fit on one line
(`Customers.id -> Orders.customer_id`); the arrow-break is used only
when even the expanded width cannot hold a (possibly compound)
endpoint pair. The wider width minimises horizontal truncation so the
panel needs **mainly vertical scrolling** (DC3).
### DB3 — Sidebar width unchanged when unfocused
The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening
happens only on focus (DC2), as an overlay, so the unfocused layout and
the right-column reflow are unchanged from today.
### DB4 — Vertical split of the two left-column panels
The items column stacks **Tables (top)** and **Relationships (bottom)**.
The Relationships panel's height is content-driven within bounds, so it
stays small when there is little to show and never dominates the column
(user-chosen 2026-06-10):
- **No relationships:** fixed at **5 rows** (3 content + 2 border),
rendering a single `None` line. This is the floor.
- **With relationships:** grows with content (`content_rows + 2`, where
the unfocused format is ~3 rows per relationship) up to a **cap of
50 % of the column height**; beyond the cap the panel **scrolls**
(DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`.
- **Tables** takes the remainder (`col_h rel_h`) and scrolls if it
overflows (it, too, is a focusable, scrollable panel — DC3).
- **Degradation:** on a column too short to honour the 5-row floor plus
a usable Tables panel (`col_h < ~10`), the floor yields first so
Tables keeps at least its border + one row; both panels stay
renderable. The `50 %` cap and `5`-row floor are tunable constants.
Heights are a pure render-time function of the column height and the
cached relationship count, so they are unit-testable without a terminal
(see Testing).
## Decision — Phase C: navigation mode
### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode
`Ctrl-O` enters a **navigation mode** that is orthogonal to the
Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes
go*, not *how commands parse*. It drives a focus cycle:
1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if
it is currently hidden — a temporary peek).
2. **Press 2 →** focus the **Relationships** panel.
3. **Press 3 →** leave navigation mode: restore the sidebar width,
re-hide it if the peek revealed it, and return focus to the command
input.
`Esc` exits navigation mode directly from any focused panel (a
short-cut for step 3); `Esc` is otherwise only completion-undo, which
does not apply while browsing.
**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix*
and `Ctrl-A` is *screen's* — a multiplexer eats them before the app
sees them, so either would make navigation mode unreachable for the many
students who run inside tmux/screen. `Ctrl-O` is not a multiplexer
prefix; in the raw mode the TUI sets, its legacy line-discipline meaning
(discard-output) is disabled, so it reaches the app. It is free in the
app today (the main key handler's catch-all, `app.rs:1001`). The
mnemonic is weak ("**O**utline"); reachability won over mnemonic.
**Routing.** Navigation mode is handled inside the **main** key handler,
which runs only when no modal is open (`app.rs:919` gates on
`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while
a modal dialog is active** — modals keep full keyboard ownership. Within
the main handler, a `NavFocus != Input` branch precedes the normal
input-editing arms and routes keys per DC3/DC4.
### DC2 — Expand-on-focus as an overlay
A focused sidebar panel widens to a **45-column** overlay
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
paints the wide panel on top. The output/input/hint panels underneath
keep their exact layout — **unused and unchanging** while browsing,
**still visible to the right** of the overlay (just partially occluded
on the left) — and are restored fully by the next frame on exit. The
gutter keeps them from butting against the expanded panel's border so
the overlay edge reads cleanly. This is cheap because the renderer is a
pure function of `App` state: focus state selects the width and the
overlay path. (The input underneath is inactive in navigation mode.)
*Implementation note (2026-06-10):* a full-area clear (hiding the base
panels entirely during browse) was tried first and rejected — leaving
the base visible is truer to "underneath keep their layout," and the
one-column gutter resolves the only wrinkle (the panels' left edges
being cut by the overlay reading harshly without separation).
### DC3 — Scroll the focused panel; focus highlight
While a sidebar panel is focused it scrolls, reusing the output panel's
proven mechanism (a `usize` offset clamped against a renderer-reported
viewport via a `note_*_viewport` call):
- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel;
user-chosen 2026-06-10).
- **PageUp / PageDown — page** scroll.
This is a context-sensitive rebind: Up/Down drive *history* and
PageUp/PageDown scroll the *output* in input mode, whereas in navigation
mode they scroll the *focused sidebar panel*. The two contexts never
apply simultaneously (`NavFocus` selects which). The focused panel shows
an **accent border** so it is obvious where keys are going (lazygit
convention).
### DC4 — Other keys are inert in navigation mode
The command input is visibly occluded by the overlay while browsing, so
keys that have no navigation meaning are **inert** rather than acting on
the hidden input. Specifically, **only** `Ctrl-O` (advance focus),
Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live;
printable characters, Enter, Tab, Backspace/Delete, Left/Right, and
Home/End all do nothing until navigation mode is exited. The occlusion
signals "not typing," so swallowing these is clearer than letting them
silently edit an invisible buffer.
## State, keybindings, and cross-cutting wiring
**Stored `App` state** (mutated in `update()`, read by the renderer):
- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav /
clear.
- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the
navigation-mode focus cursor; `Input` ≙ not in navigation mode.
- Per-panel scroll offsets for the Tables and Relationships panels, each
clamped against a renderer-reported viewport (DC3), mirroring
`output_scroll` / `note_output_viewport`.
- **`App.relationships: Vec<RelationshipSchema>`** (DB2) — the full
relationship records for the sidebar panel, delivered by
`AppEvent::RelationshipsRefreshed` from the runtime's schema refresh.
`SchemaCache.relationships: Vec<String>` (names, for completion) is
unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.)
**Render-time derived** (pure functions of `frame.area()` + cached
counts — *not* stored fields; this keeps the pure-render invariant and
makes the geometry logic unit-testable without a terminal):
- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel`
(DB1).
- Input/hint row counts — a pure helper `panel_heights(area) ->
(input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and
the Tier-1 tests call.
- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)`
(DB4).
- Input width fed back to `update()` via `note_input_viewport`
(DA3), since `update()` cannot read `frame.area()`.
Keybindings introduced/affected:
| Key | Input mode | Navigation mode |
| --- | --- | --- |
| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) |
| `Up` / `Down` | history (unchanged) | line-scroll focused panel |
| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel |
| `Esc` | completion-undo (unchanged) | exit nav mode directly |
| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert |
All nav keys are inert while a modal is open (the main handler is gated
on `!modal.is_some()`, `app.rs:919`).
Renderer changes (`src/ui.rs`): geometry-driven hint/input height
(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap
with row-1 indicator (DA4), the relationships panel + two-panel split
(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay
(DC2/DC3); `note_input_viewport` feedback added alongside the existing
`note_output_viewport`.
## Genuine forks (escalated, resolved 2026-06-10)
1. **Left column fate** — remove entirely vs narrow vs **keep + make
optional and richer** (chosen, user). → DB1/DB2.
2. **Focus/scroll model** — a navigation mode (chosen, user) vs
modeless modifier-key scroll vs deferring scroll. → DC1.
3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B`
*rejected on review* (it is the default tmux prefix → unreachable
inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded.
→ DC1.
4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen,
keeps the right panels unchanging) vs re-splitting the layout (would
reflow output). → DC2.
5. **Navigation-mode printables** — **ignore** (chosen, user) vs
drop-to-input-and-type. → DC4.
6. **Hint anti-jump** — **fix height to terminal geometry** (chosen)
vs damping/hysteresis vs always-reserve-max. → DA1.
7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with
1/2 and 2/2 splits (chosen, user). → DA2.
8. **Visibility persistence** — **session-only** (chosen, user) vs
per-project in `project.yaml`. → DB1.
9. **Persistent show/hide toggle** — **deferred** (chosen, user): the
Ctrl-O peek covers #21's "keystroke to show and hide", so visibility
stays width-derived with no override. → DB1.
10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen,
user) vs leaving scroll to PageUp/PageDown only. → DC3.
11. **Relationships placement** — **a separate sibling panel** (chosen,
user — *overrides S2*) vs nesting relations inside the tables list
per S2's documented extension model. → DB2/DB4.
12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint
panel is indispensable since completion moved into it; S4's stale
"keyboard-toggleable" claim (never implemented) is struck from
`requirements.md`. → Status (Requirements & issues touched).
## Testing
Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta`
snapshots, `src/snapshots/`) for the visual surfaces** — this change is
heavily render-side, so the geometry/format/overlay assertions belong in
snapshots, not only behavioural tests — and Tier-3 integration.
Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the
DB4 split, visibility) are **pure functions** exercised directly in
Tier-1 without a terminal.
Phase A:
- **Hint anti-jump:** `panel_heights(area)` is invariant under changing
hint content at a fixed terminal size (assert it does not change as
`app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40`
boundary and the width-< 54 boundary.
- **Height buckets:** compact → input 1 row / hint 2; comfortable →
input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal
degradation honours output `Min(5)`.
- **Input horizontal scroll:** a line longer than the panel keeps the
cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column
reserve is intact; no characters are lost (buffer = full string);
`input_scroll_offset` resets on submit / history-nav / clear.
- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders
both rows with correct cursor (row, col), the `[ERR]`/`[WRN]`
indicator on row 1's right edge (Tier-2 snapshot); a longer line
scrolls.
Phase B:
- **Relationship data path:** `Database::read_all_relationships`
returns full records through the worker thread (integration test, real
DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates
`App.relationships`; `SchemaCache.relationships` names are undisturbed
(completion still resolves them).
- **Relationships panel render (Tier-2):** empty → a single `None` line
at the 5-row floor; the unfocused narrow format (name + arrow-break,
ellipsis past inner width); a compound endpoint pair arrow-breaks
correctly.
- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5,
⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %;
Tables takes the remainder; degrades sanely at `col_h < 10`.
- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed
on resize (the peek interaction is covered under Phase C).
Phase C:
- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships →
Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit;
a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert
while a modal is open.
- **Expand overlay (Tier-2):** focusing widens to the expanded width;
the underlying output/input/hint state is unchanged across enter/exit
(no reflow); the focus accent border marks the focused panel.
- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown
page-scroll the focused panel (clamped to its viewport); in input mode
Up/Down still drive history and PageUp/PageDown still scroll output
(no V4 regression); inert keys (printable/Enter/Tab/Backspace) do
nothing in nav mode.
All tiers green, zero skips; clippy clean (nursery).
## Out of scope
- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter-
submits over a multi-logical-line buffer. DA3/DA4 keep a single
logical line; this remains a separate, deferred feature.
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
not touched here.
- **Cross-session sidebar persistence** — visibility is session-only
(DB1); persisting it would amend ADR-0015.
- **The output panel as a third navigation focus target** — navigation
mode cycles the two sidebar panels only; output keeps its input-mode
PageUp/PageDown scroll.
- **Relationship search / filtering within the panel** — the panel is a
scrollable list; no query box.
- **Relationship rename / edit from the panel** — it is read-only;
mutation stays with the DSL/SQL commands.
- **A persistent show/hide toggle / force-shown override** (DB1,
resolved deferred) — visibility is width-derived + Ctrl-O peek; a
pin/force override is an additive follow-up if ever needed.
- **A hint-area toggle (old S4 wording)** — not implemented today and
not wanted (the hint panel is indispensable since completion moved in;
fork §12). The stale "keyboard-toggleable" phrase is removed from S4.
- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a
separate feature with its own ADR; DC2's `Clear` overlay is built to
coexist with it, not to provide it.
## Accepted consequences
- **Width-threshold discontinuity.** Because `Auto` visibility flips at
width 90 and the sidebar costs 28 columns, widening a terminal across
the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner)
as the sidebar appears. This is inherent to any width-gated auto-hide
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
@@ -0,0 +1,418 @@
# ADR-0047: Demonstration overlay layer — keystroke badges and step captions
## Status
Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes
Gitea **#22**). Addresses Gitea **#22**. Builds the in-app
overlay/annotation primitive that screencast recording (ADR-website-001
§2, the `autocast` pipeline) and a future guided-lesson system both
need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must
coexist with) and unblocks the polished version of the assistive-editor
and projects (`#24`) casts.
**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A
(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env →
`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions
only the visible badges (the `Ctrl+]` caption trigger stays
low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure
`demo_badge_label`, set in `App::update` before the modal gate, expired
by a ~1.5 s runtime timer via the new `nearest_deadline` helper that
extends the time-boxed-`recv` arm condition **without** regressing the
ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines;
verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]`
caption buffer in `App::update`, intercepted before the modal gate so
captions work over the load picker. Post-build (`2d0f4b2`, user
decision): the overlays render as **flat filled yellow rectangles** (no
border glyphs, one-cell text margin) to read as a distinct callout. A
whole-implementation `/runda` pass returned **PASS** with no blockers;
the only untested wiring is the `run_loop` badge timer (not unit-testable
in isolation — same posture as the existing `IndicatorDebounce`; the
pure pieces are all tested). One intentional, user-acknowledged
behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is,
by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing /
0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2
dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style,
CLI parse/env); clippy clean.
All primary forks and the visual placement were **user-confirmed**
including the two follow-ups settled after the first draft: the trigger
key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over
`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3
lines**). A `/runda` pass over this ADR ran before implementation and
tightened it — its findings are folded in below (caption/badge
interception placement, in-capture key disposition, badge suppression
during capture, the timer arm-condition, box clamping, the new
output-rect field, and the control-code decode note).
**Requirements traceability.** There is **no `requirements.md` item**
for this work — verified by sweep. It is tracked as Gitea issue **#22**
plus this ADR, consistent with the project's convention ("issues are
the lightweight tracker; ADRs are the decisions"). The website-side cast
scope lives in **ADR-website-001** (website branch), not main's
`requirements.md`.
## Context
The website records its demos as asciinema `.cast` files driven by
**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in
`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per
character, Enter = `^M`**, recorded against the real `target/debug`
binary. The hard constraint — the same one that drove `#24` — is that
autocast can only emit **typeable characters, ASCII control codes
(`^X`), and waits**. It cannot send arrow keys, function keys, or any
multi-byte escape sequence.
Two classes of on-screen event are therefore invisible or
unexplained in a cast:
1. **Keystrokes that cause a visible change but render no glyph of
their own** — most acutely **Tab** completion: the command line
jumps from `show data bo` to `show data books` with no sign a key
was pressed. Enter, the arrows, Ctrl-O, Esc are the same.
2. **Step structure / "what just happened" narration** — a cast is a
silent moving picture; there is no channel to separate or explain
steps for a visual learner.
asciinema-player has no inline keystroke overlay, and a website-side
HTML overlay layered on the player would be fragile (its timings would
have to track every recording and break on each re-record). The robust
place to solve this is **in the app**: if the app renders the overlay,
the cast captures it natively and it re-records for free. The same
primitive is exactly what a future **guided-lesson** system needs to
point at things and narrate steps — so it is built as a general
capability, not a cast-only hack (the issue's "pays off twice"). It is
also directly useful for a **teacher demonstrating the playground
live** — pressing Tab in front of a class has the same
invisible-keystroke problem as a cast.
The app's renderer is a pure function of `App` state and already draws
two kinds of last-pass overlay over the base render with **no layout
reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event
loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout
(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses —
the exact mechanism a self-expiring badge needs. These two existing
seams make the feature cheap.
## Decisions
### D1 — Activation: a `--demo` flag (+ env var), off by default
Demonstration mode is entered with a **`--demo`** CLI flag, or
equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set
truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE`
pair. It combines freely with every other flag (`--resume`, `--mode`, a
positional path); there are no exclusions.
When the flag is **off** (the default), none of the key handling or
rendering below is active and the app behaves exactly as today — **zero
footprint for real users** (R8). `autocast` sets the flag when it
launches the binary; a teacher sets it on their own command line.
It is framed as a general **demonstration mode**, not "cast mode" — the
honest name for what it does, and it reads sensibly in `--help`. The
flag is documented in the CLI banner (one line); obscurity is not a
security property here and a harmless opt-in flag is better surfaced
than hidden. What stays "low-profile" (per #22) is that there is **no
normal in-app command** for it and **no persistent on-screen indicator**
(see D7) — so a cast frame is never polluted by a `[DEMO]` marker.
### D2 — Keystroke badges: automatic, app-detected
In demo mode the app shows a transient badge **automatically** whenever
it handles one of a curated set of *otherwise-invisible* keys. The cast
does nothing special — it presses the key it was going to press anyway,
and the badge re-records for free. The set:
| Key | Badge | | Key | Badge |
|-----|-------|-|-----|-------|
| Tab | `[TAB]` | | Home | `[HOME]` |
| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` |
| Enter | `[ENTER]` | | PageUp | `[PGUP]` |
| Esc | `[ESC]` | | PageDown | `[PGDN]` |
| ↑ | `[UP]` | | Backspace | `[BKSP]` |
| ↓ | `[DOWN]` | | Delete | `[DEL]` |
| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` |
| → | `[RIGHT]` | | | |
Plain character keys render a glyph on the input line already, so they
produce **no** badge (that is the definition of the set — "invisible"
keys). The badge fires on **key press**, regardless of whether the key
had an effect in the current state (e.g. `↑` with no history still shows
`[UP]`): simpler, and the demo author controls the script. Badge text is
bracketed ASCII (`[TAB]`) per the user's preference — renders on every
terminal and is cast-safe, unlike the `⇥` glyph mocked earlier.
The label mapping is a **pure function** `demo_badge_label(&KeyEvent) ->
Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a
timer** (D5).
### D3 — Step captions: a stealth, control-code-delimited input buffer
Caption text must arrive through typeable input only (R4). A **single
toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth
capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`,
the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the
signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a
plain letter chord like Ctrl-G) because it is **maximally non-obvious**
— the classic telnet escape, almost never pressed by accident — while
still being a single ASCII control byte autocast can emit. It has **no
signal or flow-control baggage** and is **multiplexer-safe**. Note
collision risk is already near-zero in casts (a fresh `--demo` binary
sees only scripted keys); the obscurity mainly protects a live teacher
from a stray trigger.
- First `Ctrl+]` **opens** capture. The command input line and the
output are untouched. If a caption is already visible, opening clears
it (you are starting a new annotation).
- Subsequent typed characters **accumulate into the caption buffer
invisibly** — they do **not** appear on the prompt, do not execute,
and do not enter history. **`Backspace`** deletes the last buffered
character. **Every other key while capturing — Enter, the arrows,
Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]`
do anything.
- A second `Ctrl+]` **commits** the buffer to the caption box (D4).
An **empty** commit (toggle-toggle with nothing typed) clears any
visible caption — the author's explicit dismiss.
Because nothing about the capture shows on the prompt, the caption
"pops" into its box with no ugly typing artifact, while the caption text
still lives **inline in `casts.mjs`** at the right spot (one source of
truth, no separate notes file to keep ordered).
This is all keyboard-stream interpretation, so it lives in the
pure-sync `App::update()` (Tier-1 testable) and is **only active in demo
mode** — when off, `Ctrl+]` is inert and characters reach the input
line normally.
**Placement in `handle_key` — before the modal gate (runda finding).**
The capture interception (`Ctrl+]` and the accumulating characters)
**and** the "clear a visible caption on the next keystroke" check sit at
the **very top of `handle_key`, before the `self.modal.is_some()`
gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it.
This is required so captions can be authored **while a modal is open**
specifically the load-picker, which is exactly the **projects / `#24`
cast** (annotating "press j/k to move", with an `[ENTER]` badge as the
selection is made). While capturing, the modal is frozen (capture
swallows keys), which is the intended behaviour. `App` exposes
`demo_capturing` so the runtime can read it (see D5).
The control-code path is sound end to end, verified against our
crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits
`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F`
`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in
the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the
pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/
`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and
`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The
canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5`
yields the same byte. *(This is the Unix/Linux decode path — the
cast-recording platform; crossterm's separate Windows backend would be
confirmed by test if live `--demo` on Windows is exercised.)*
### D4 — Both overlays are floating boxes at the output panel's inner bottom-right
The badge and the caption both render as **floating, flat filled
rectangles anchored to the inside of the output panel's bottom-right
corner** (inset one cell from the panel's inner edge), drawn **last over
the base render** — after modals, so they remain visible while the
load-picker (the `#24` cast) or any modal is up, and with **no layout
reflow** (consistent with the modal / nav-overlay precedent; honours
R8).
**Flat rectangle, not a bordered box (user decision, post-build).** The
overlays draw as a **solid yellow rectangle with no border glyphs** and
a one-cell margin around the text — deliberately *unlike* the app's
rounded-border panels, so they read as a distinct callout that "stands
out nicely" rather than as another panel. Implemented with a borderless
`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset
into a one-cell `Margin`.
The top-level `render()` does not currently know the output-panel rect
(it is computed inside `render_right_column`), so a **new field
`App.last_output_area: Rect`** is set in `render_output_panel` and read
at the top-level draw pass to anchor the overlay — the established
"renderer reports metrics back to `App`" pattern (sibling to
`note_output_viewport`, which stores row counts, not a rect).
When **both** are present, the **keystroke badge stacks directly above
the caption box** (both right-aligned in the corner) so they never
overlap.
**Styling — deliberately high-contrast:** **bold black text on a yellow
fill** — hard to overlook, identical in light and dark themes (a fixed
high-contrast pair centralised in `theme.rs`, not theme-derived).
**Caption sizing (user-confirmed).** The caption is **word-wrapped to at
most 3 lines** within a content width of `min(40, output_inner_width
4)` columns, ellipsised beyond the third line. So the caption rectangle
is **35 rows** tall (13 text rows + a one-cell margin top and bottom),
its height varying with the text — a full sentence fits without forcing
the author to split it, while the 3-line cap keeps it corner-sized. The
**badge** rectangle is always a single short token (`[TAB]`
`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin),
narrow.
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
so on a short terminal they could exceed it. Both boxes are **clamped to
the output inner area**: width to `output_inner_width`, the caption's
wrap-line count reduced so the stack fits the available height (badge
first — it is the time-critical one), and if a box cannot fit at all
(pathologically small terminal) it is **not drawn** rather than
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
guard only protects a real user who runs `--demo` in a tiny window.
### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
- **Keystroke badge:** auto-expires on a **time-based TTL**, default
**1.5 s** (a single tunable constant; the user asked for 12 s). This
matters for both media: in a cast the badge fades on its own so a
trailing `wait` ends on a clean frame, and in live teaching the badge
clears without the presenter needing another key. A new badge replaces
the current one and resets the timer.
- **Caption:** persists **until the next keystroke**, which clears it
and is then processed normally (or until an explicit empty-`Ctrl+]`
dismiss, or replacement by a new caption).
The timer reuses the runtime's existing time-boxed-`recv` pattern: the
loop already arms a `tokio::time::timeout` for the indicator debounce.
**Arm-condition extension (runda finding).** Today the loop time-boxes
`recv` **only while `debounce.is_armed()`** — and the debounce settles
at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL.
So the arm condition becomes **`debounce.is_armed() || badge_pending`**,
and the loop waits on the **nearest deadline** of the two. On a wake it
checks each independently: at the 1000 ms debounce deadline it settles
the indicator **without clearing the badge**; at the 1500 ms badge
deadline it clears the badge; then redraws. The pure "nearest deadline"
computation is unit-testable on its own.
The badge's expiry `Instant` lives in the **runtime** (so `App` stays
clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing
out of `App`); `App.demo_badge: Option<&'static str>` is the render
mirror, **set by the runtime** on a significant key and cleared on timer
elapse.
**Badge suppression during capture (runda finding).** Because the
runtime sets badges from the raw key independently of `App` state, it
must **not** badge a key that capture swallowed (e.g. an inert `Tab`
while a caption is being typed would otherwise flash `[TAB]` for a
no-op). The runtime sets a badge only when **`!app.demo_capturing`**.
**Ownership note.** `demo_caption` is mutated inside `update()`
(input-driven) while `demo_badge` is mutated by the runtime
(timing-driven). This split is deliberate and mirrors the existing
`input` (set in `update()`) vs `input_indicator` (set by the runtime
from `IndicatorDebounce`) pair — not an inconsistency.
### D6 — Help text and strings
The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo`
line. User-facing wording obeys the house rules (no engine name, no
"DSL"): *"Demonstration mode — show on-screen badges for otherwise-
invisible keys (Tab, Enter, …), for screencasts and live teaching."*
The help text **deliberately mentions only the visible badges, not the
`Ctrl+]` step-caption mechanism** (user decision): the caption trigger
stays low-profile, true to #22's "secret trigger" framing — a cast
author or lesson script knows it; a casual `--help` reader is not
pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not
localised; caption content is author-supplied free text and likewise
not a catalog string.
## Alternatives considered
- **Scripted badges** (cast pushes each badge explicitly) — rejected:
the app already sees every key, so automatic detection (D2) is more
robust and re-records for free. *(User-confirmed.)*
- **Typed hidden command for captions** (a secret-prefixed line) —
rejected: the command is briefly visible being typed on the prompt.
**Preloaded notes file + advance key** — rejected: a separate file
that must stay ordered/in-sync with the cast. The **stealth buffer**
(D3) is self-contained in the cast script *and* leaves the prompt
clean. *(User-confirmed.)*
- **Fixed-corner HUD badge / badge by the input line** — rejected in
favour of a floating box at the output panel's bottom-right; **top
banner / subtitle band** for captions — rejected in favour of the
matching floating box. *(User-confirmed via mockups.)*
- **A persistent `[DEMO]` status-bar marker** — rejected: it would show
in every cast frame. Demo mode is silent except for the transient
overlays (D7).
- **Caption persists for a fixed time** (instead of until next
keystroke) — noted as a one-constant change if the next-keystroke rule
proves too eager in practice; the user chose next-keystroke.
- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not
representable as a single ASCII control byte, so autocast cannot send
it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter
chord** — workable but less non-obvious; the user chose the
maximally-obscure `Ctrl+]` from the valid single-byte set.
- **Single-line ellipsised caption** — rejected in favour of wrap-to-3-
lines so a full sentence fits. *(User-confirmed via mockups.)*
## Consequences
- A general overlay primitive exists that the cast pipeline uses now and
the guided-lesson system can reuse later (`App.demo_caption` and the
badge channel are the seam).
- `autocast` casts gain a real Tab-completion moment, key indicators for
the projects/`#24` round-trip, and step captions — all by adding
`key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then
re-running `pnpm casts`. No website-side overlay machinery.
- Teachers get the same affordance live via `--demo`.
- One new control-code binding (`Ctrl+]`) is consumed, but only inside
demo mode — normal sessions are unaffected, so it does not encroach on
the reserved readline chords (I1b).
- The renderer must expose the output-panel rect to `App`; a small,
pattern-consistent addition.
## Scope / non-goals (OOS)
- **Manual/scripted badge push** and **badges for plain character
keys** — out; badges are automatic over the fixed invisible-key set.
- **Configurable overlay styling or placement** — out; fixed
black-on-yellow boxes at the output panel's bottom-right.
- **The guided-lesson / tutorial system itself** — out (its own ADR);
this ADR only builds the primitive it will reuse.
- **Persisting demo mode across project switches / sessions** — out;
it is a per-run flag.
- **Localising caption content** — out; captions are author-supplied
free text.
- **Output-pane scroll-in-casts** and other arrow-only interactions —
out (separate enhancement; same autocast limitation as noted in #24).
## Testing
Per ADR-0008 and the project's test discipline (test-first; green, no
skips):
- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full
key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`);
the stealth-caption state machine — open on `Ctrl+]`; characters
accumulate with the **input line unchanged**; `Backspace` edits the
buffer; **non-typing keys inert while capturing**; commit sets the
caption; empty commit clears; opening over a visible caption clears
it; next keystroke clears a visible caption **then processes
normally**; capture works **with a modal open** (caption set while the
load-picker modal is up, picker state untouched); the **demo-off
gate** (`Ctrl+]` inert, characters reach the input, no caption/badge
state ever set); the pure "nearest deadline" helper.
- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both
stacked, at 90×26 in light and dark — verifying the bottom-right
anchor, the stack order, and the black-on-yellow styling; plus a
short-terminal case exercising the clamp/skip guard.
- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a
significant-key event sets `app.demo_badge` and a swallowed key during
capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets
`app.demo_caption` without touching `app.input`.
- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the
`RDBMS_PLAYGROUND_DEMO` env fallback; default-off.
**Honest coverage limit.** The badge **timer-expiry wiring** runs inside
`run_loop` (terminal + db worker), which is not unit-testable in
isolation; it is a thin reuse of the already-proven `IndicatorDebounce`
time-boxed-`recv` path. We therefore test the **pure pieces**
exhaustively (label fn, capture state machine, nearest-deadline helper)
and assert plumbing via Tier-3, rather than over-claiming an integration
test of the `tokio` timeout itself.
</content>
</invoke>
+3
View File
@@ -57,3 +57,6 @@ This directory contains the project's ADRs, recorded per
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~1520 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
- [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-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; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54``2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). 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 flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **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 (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. 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); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle