# The tutorial player: a real UI playing in a window

> **Superseded (2026-08-31, choreographer playbook 7·12).** The engine
> this document describes — the scripted session, `SimDriver`, the
> recorded-input pipeline — was deleted at 7·10. Tutorials are now `.bwx`
> containers played through the choreographer
> (`docs/choreographer-playbook.md`); the player UI is rebuilt on the
> winning chrome as the Phase 7 UI half. Kept as the design record.


## The model

The tutorial is a **video player in an on-surface window** — except the
"video" is not pixels, it is a second, complete, read-only instance of the
editing UI driven by pre-recorded events. The window shows the whole thing:
canvas, toolbar chrome, the demo cursor and mouse badge, the in-place editor
filling in — playing on a loop, pausable, speed-adjustable, restartable.

Tutorials are **open-loop**. The user gets the initial document in their real
editor and the video beside it; they follow along or not. No checkers, no
gates, no checklist, no confetti, and no ghost template (a template can only
depict one level of the hierarchy — navigate anywhere else and it reads as
noise). This dissolves the synchronization problem that killed the previous
designs *by removing the requirement*: the video never points at the user's
objects or chrome — it demonstrates on its own full UI, which is always
exactly in the state its own events produced.

## Window anatomy

An `egui::Window` (on-surface, so it works on web; resizable, movable,
closable) containing:

1. **Title** — the level title.
2. **Instructions pane** — the level's prose, above the video.
3. **The video pane** — an `egui::Scene` holding the embedded UI at a fixed
   virtual resolution, scaled to fit the pane. Scene puts content on a
   sublayer with a layer transform, so widgets and our canvas `Painter`'s
   shapes scale together as long as everything stays on that layer.
4. **Narration overlay** — the current step's sentence, drawn over the
   video's lower edge (see *Steps and narration*).
5. **Transport controls** — pause/play, speed (e.g. 0.5×/1×/2×), restart.
   No seek bar: the frame at time T only exists by replaying 0..T. (But see
   *step seek* under Future — silent fast-forward makes coarse seek cheap.)
6. **Level navigation** — Previous / Next / a popup listing all levels for
   random access. Switching levels also loads that level's initial document
   into the user's real editor (their own document was stashed at tutorial
   entry, restored at exit — unchanged).

## Architecture

### The shared engine: `Session`

The headless runner already contains the core: a document + path + `Tool`,
stepped by `SimFrame`s (synthetic events, tool switches, editor typing
through the `take_edit_text` buffer seam). Extract it as `Session` with the
painter as a parameter:

- **Headless** (tests): discard painter, as today.
- **On-screen** (the player): a `canvas::Painter` built over the Scene
  layer's `egui::Painter`, so each step's `widget()` call *is* the video
  frame — previews, selection frames, everything the real tools draw.

`lowering.rs` is unchanged: `Script` → fixed-dt `SimFrame`s, doc-anchored
targets resolved as each step begins (against the video's own document).

### The player

`tutorial/player.rs` owns a `Session`, the level's step list, and a sim
clock:

- Each real frame while playing: advance the clock by `real_dt × speed`,
  feed the due `SimFrame`s (all but the last against a discard painter, the
  last against the Scene painter — no double-draw), draw the cue overlays
  (cursor, mouse badge, click flash, typing box — the existing `cues.rs`
  drawing, now aimed at the video pane) and the narration line.
- **Pause** stops the clock. **Speed** scales it. **Restart** rebuilds the
  `Session` from the initial KDL — sub-millisecond. **Loop**: restart when
  the last step ends, after a beat.
- The pane is non-interactive: an input-blocking widget sits over the video
  rect so embedded widgets never see the real pointer.

### Chrome: from `Area` to in-`Ui` widgets

The app's chrome currently floats in screen-global `egui::Area`s. Those
can't live inside a Scene, so the chrome migrates to **plain widgets placed
in the current `Ui`** — child `Ui`s at computed rects, drawn after the
canvas. Same layer, later paint order: they draw on top and win hit-testing
at their position, and inside a Scene they transform automatically. This
makes chrome **embeddable by construction**: the *same function* renders in
the live app (real state in, `Action`s applied) and in the video (session
state in, actions dropped, input blocker on top) — no presentational fork,
no drift.

Triage of the six `Area` uses:

- **Migrate** (canvas chrome, conceptually anchored to the canvas):
  `mode_toolbar`, `history_overlay`, `selection_buttons`.
- **Convert to real dialogs** (they were never canvas chrome): `nav_tree`
  becomes an `egui::Window`; the role / pin-type pickers become anchored
  popups. Dialogs stay screen-global — correct, since they never appear in
  a video. Independent cleanup, not on the tutorial's critical path.

Engineering notes:

- **Ids must become `Ui`-scoped.** Absolute `Id::new("…")` ids and the
  `tool_button_rect_id` temp stash collide when the chrome renders twice
  (live + video). Derive them from `ui.id()` so `push_id` scoping separates
  the two instances.
- **Hit-test shift.** Chrome moves from `Order::Middle` layers onto the
  panel's own layer, relying on within-layer insertion order for precedence.
  Verify by hand (button clicks over canvas, drags starting under chrome)
  when the toolbar migrates.
- **Popups/menus/tooltips spawned from chrome** land on global layers and
  would neither scale nor clip inside a Scene — irrelevant in the video
  (presentational chrome never opens them) and unchanged in the live app.
- If chrome ever needs true stacking rather than paint order: sublayers
  (`UiBuilder::layer_id` + `set_sublayer`) give z-grouping, but transforms
  do **not** inherit (verified: `layer_transforms` is a flat map) — the
  player would mirror the scene transform onto the chrome layer per frame.
  Prefer paint order until something demands this.

### Levels are KDL

Levels become data — one `.kdl` file per level, embedded with
`include_str!` and parsed at startup, so they are hand-editable and are what
the recorder emits. Sketch (schema documented properly alongside
`docs/kdl-format.md` when implemented):

```kdl
level "first-route" title="Wire two blocks" {
    instructions "Pick the Route tool, then drag from src's out pin to \
                  dst's in pin."
    initial {
        // the level's starting document, inline (same KDL the editor saves)
    }
    solution {
        // golden end state, regenerated by replay (see Tests)
    }
    step key="pick-route-tool" en="Pick the Route tool" {
        highlight tool="route" secs=1.2
        click tool="route"
    }
    step key="drag-pin-to-pin" en="Drag from src's out pin to dst's in pin" {
        move-to at="10,6" secs=0.8
        hover at="10,6" secs=0.8
        drag from="10,6" to="18,6" secs=1.6
    }
}
```

- Step nodes mirror `Script` steps one-to-one; targets are `at="x,y"`,
  `tool="name"`, `block="title"`, `corner="title:rb"`. The Rust
  `Script`/`Step` types stay as the parsed representation; the builder DSL
  becomes an implementation detail of the parser and tests.
- **Narration i18n hook**: each step carries a stable `key` plus `en` text.
  `narration::text(key, en)` returns `en` today; a translation table keyed
  by `key` slots in later without touching level files.
- A parse test walks every embedded level; a bad file fails `cargo test`
  with the KDL error, not at runtime.

### Regression tests: goldens instead of gates

For every level, replay all steps headlessly and assert the final document
equals the level's stored `solution` (compared through the flat schema
projection; replay determinism — same initial doc + same events — makes the
comparison exact). A demo that stops doing what it appears to do fails with
a document diff. Solutions are regenerable: a test-mode flag (or the
recorder) rewrites them from the replay, and reviewing that diff is the
acceptance step.

### The recorder

`blockworx --record-tutorial` (same pattern as `--trace`): the app runs
normally with a small recorder panel.

- **Capture initial** — snapshot the current document into the level's
  `initial` node (existing KDL export path).
- **Step** — insert a narration marker (key + text fields in the panel);
  everything until the next marker belongs to this step.
- **Finish** — snapshot the end document as the `solution` golden and write
  the complete level `.kdl`, ready to drop into the registry.

The recorder taps the world-space `Interaction` stream the canvas already
computes, plus tool switches and editor commits, and distills them into
step nodes (drag start→end becomes one `drag`, clicks become
`move-to`+`click`, editor commits become `type`), normalizing durations
from distance rather than copying human jitter. The golden-replay test then
verifies the recording reproduces — a distillation bug fails CI, not the
user.

## Unwind inventory

**Survives (load-bearing for the player):**
- `Tool::from_name`; `lowering.rs` unchanged; the runner core (refactored
  into `Session` + a thin headless wrapper).
- `Script`/`Step`, `typed_prefix`, doc-anchored `CueTarget`s (resolving
  against the video's own document).
- `cues.rs` drawing (cursor, badge, flash, typing box) — re-aimed at the
  video pane.
- Click-click block placement and the cue-legibility fixes — app features,
  independent of the tutorial model.

**Unwound (closed-loop machinery and the template):**
- `Gate`, `GateCheck`, `UiCheck`, `UiSnapshot`, `tool_is`, `block_selected`;
  `Checker` and the check library; the checklist, `refresh()`, `complete`.
- `Phase::Celebrating`/confetti/`NextLevelModal`/`CELEBRATE_SECS`;
  `TutorialProgress` completion persistence.
- The bottom-right overlay panel (replaced by the player window's own
  title/instructions/controls/nav).
- Cue drawing over the *user's* canvas, the synthetic hover injected into
  the user's tools, `LevelActivity`, the `Tutorial::projection` cache and
  `cues::draw`'s app wiring.
- **The whole template subsystem**: `template.rs`, `ensure_template`/
  `clear_template`, the appearance-change invalidation, the `fit_view`
  template union, `handles_on`, the template PNG dump test.
- Keystone per-stage gate asserts → replaced by golden replay.

## Issues, honestly

1. **The chrome migration** (`Area` → in-`Ui`) is the one real refactor,
   with the id-scoping and hit-test caveats above — but it pays for itself
   by unifying live and video chrome into one code path.
2. **Scene text sharpness.** Layer transforms scale tessellated glyphs; at
   small scales embedded text goes slightly soft. Acceptable for a "video";
   if not, snap the Scene to friendlier scales.
3. **No `View` in the video.** The player paints through the session's
   `Painter` with its own fit transform — pan/zoom/interaction code never
   runs. In-place editors appear as the typing-cue box (as in the runner);
   it can never steal the user's keyboard focus. A level demonstrating
   pan/zoom itself would need camera steps (future). *`--replay` no longer
   has this constraint:* it drives the main `View` directly — the script
   plays through the app's own document, tools, and chrome (see
   `tutorial::replay`); the in-app tutorial keeps this Scene-based player.
4. **Replay cost is small**: a 10-second level is ~600 `widget()` calls on a
   tiny document spread over 10 wall-clock seconds; restart re-parses one
   small KDL.
5. **Level switching reloads the user's editor document** (follow-along
   work on the previous level is discarded; their real document is stashed
   at entry, restored at exit). Same behavior as today; worth a confirm
   dialog eventually.
6. **Seek**: a seek bar is out, but *silent replay* (stepping without
   painting) makes "jump to step N" nearly free — a natural Future item.
7. **Recorder fidelity**: distillation and duration normalization can
   misrepresent a gesture; golden replay catches any recording that doesn't
   reproduce its own end state.

## Phases

**V1 — unwind + KDL levels + the player window (canvas-only video).**
*Landed.* Delete the closed-loop machinery and the template subsystem;
levels move to `.kdl` (parser + parse test + goldens); extract `Session`;
build the window (title, instructions, Scene + canvas video, cue overlays,
narration line, transport controls, prev/next/popup); golden-replay test
replaces the keystone test. Videos show the canvas without chrome (the tool
still switches; the cursor has no toolbar to visit yet — narration carries
it). One addition the plan missed: tools with press-and-hold affordances
read raw pointer state and egui data stashes directly, which would have let
the user's mouse steer the embedded session — they now read through the
`Painter::live_input` seam, absent on a scripted session's painter.

**V2 — chrome migration + chrome in the video.** *Landed.* `mode_toolbar`
(then `history_overlay`, `selection_buttons`) from `Area` to in-`Ui`
widgets with `Ui`-scoped ids; render the toolbar inside the Scene; the
demo cursor visits the embedded buttons — via rects *returned* from the
shared contents function rather than a stash, which removed the id-
collision problem outright. Centering content-hugging chrome without an
`Area` uses last frame's measured width. An input blocker allocated last
in the scene keeps the pointer off the video's widgets. `nav_tree` →
`egui::Window` (movable, closable, horizontal-only resize).

**V3 — the recorder.** *Landed.* `--record-tutorial` mode: event tap,
distillation, narration markers, initial/solution snapshots, level `.kdl`
emission. Beyond the plan: Finish replays the emitted file headlessly on
the spot and reports whether the recording reproduces its own end state.
Not inferred (add by hand): hover dwells and doc-anchored targets.

**V4 — future.** Step-granular seek via silent replay; more chrome in the
video; "watched" markers in the level popup; a real translation table
behind the narration keys.
