# Exit-egui playbook

The app is leaving egui. The UI toolkit that replaces it is not chosen yet,
so this is not a port: it is the refactor that makes the port small. The
work is done on the `exit-egui` branch, one phase per commit, with
`cargo xtask ci` green after every phase — the current egui app keeps
running throughout.

## The finding

Of the app's ~90k lines, the toolkit reaches only two places directly:

1. **The paint vocabulary.** Every drawing call is already routed through
   the `Renderer` trait (on-screen painter, SVG exporter, and the measuring
   `Extent` all implement it) and the theme's `Style<R>` adapter. What
   leaks is the *types* on that trait: `egui::{Pos2, Vec2, Rect, Align2,
   FontId}`, `Color32`, and `anim_target()` handing back an `egui::Context`.
2. **The chrome.** `app.rs` (9.5k lines), `shell/*`, and the panels that
   live under `tools/` but are widgets, not tools — `overlay`, `palette`,
   `nav_tree`, `history_panel`, `notices`, `chrome` — plus the editor
   windows, the pickers, and `View` (pan/zoom, the in-place text editor).

Everything else — the document, the store and on-disk format, the router,
the pure op emitters, the `Drawing` waist, the presentation caches, the
shapes and the render path, and the tools themselves — is toolkit-neutral
in *substance* and toolkit-bound only in *spelling*: `egui::Pos2` where a
point is meant, `egui::Id` where a hash key is meant, `egui::CursorIcon`
where a cursor is meant, `painter.ctx().animate_value_with_time(..)` where
"the current value of a keyed easing" is meant.

**On the paradigm question.** The core is independent of immediate vs.
retained mode, with one honest caveat. A tool is a per-frame function
`(state, interaction, document) → (preview draws, Option<Action>)`; a
retained-mode host calls it exactly the same way and treats the `Renderer`
it hands in as a display-list recorder. The three immediate-mode habits the
core has — polling animation (`animate_value_with_time`), requesting an
in-place text editor as a struct the host renders next frame, and
`request_repaint()` — are each one method on the `Canvas` trait below, and
a retained host implements them with a timer, a widget, and a no-op. The
caveat is the chrome: it is written *as* egui widgets and will be
rewritten per toolkit. That is the "small-ish shell crate" of the goal, and
after this refactor it is the only crate that mentions egui besides the
backend. No further spike is needed; the proof is the crate graph, checked
by CI, and a second `Canvas` implementation that never touches egui.

## The target crate graph

```text
blockworx-doc      the document model (exists; unchanged)
blockworx-geom     world-space geometry: Pos2/Vec2/Rect/Rangef/Align/Align2,
                   lerp/remap, WorldPx/Bounded, the grid metric and its bridge
                   to the document grid (so it depends on blockworx-doc)
blockworx-store    .bwx container, manifest, revs, projection, Doc handle,
                   document_file, naming, atomic writes, `Worked`
blockworx-router   the wire router
blockworx-paint    Color, Font/FontFamily, Renderer + Canvas traits,
                   Palette/Swatch, Theme/Role/Style, Zoom, Extent,
                   Interaction/Event/Press, EditText, Cursor, AnimKey,
                   TextLayout trait, FontChoice + font bytes, Scheme,
                   RecordingCanvas (test-support)
blockworx-egui     THE ONLY BACKEND: Painter (Renderer+Canvas), View,
                   compute_interaction, ImageRegistry, Icons, build_fonts,
                   egui_visuals, EpaintTextLayout, key/cursor conversions,
                   settle/headless test drivers (feature `test-support`)
blockworx-editor   grid-aware document editing: path, state, presentation,
                   edit (op emitters), gesture, widget (Drawing and its
                   impls), shape, render (element drawing), title_block,
                   content_path, nav_tree queries, import parsing
blockworx-tools    the tools, ToolTrait/Tool, commands (own Key/Chord),
                   history (own undo stack), spotlight animation, headless
                   tool driver (dev)
blockworx-export   SvgRenderer (generic over TextLayout), PDF export
blockworx          the shell: app.rs, main.rs, shell/*, the widget panels,
                   pickers, editor windows, preferences persistence, file
                   dialogs, export/import spawning
```

Dependency direction is strictly downward in the list, with two rules:

- **No core crate depends on egui, epaint, emath, ecolor, eframe,
  egui_extras, wgpu or winit in its normal dependency tree.** `cargo xtask
  ci`'s `headless` gate is generalised from `blockworx-doc` to every crate
  except `blockworx-egui` and `blockworx`. Dev-dependencies are exempt for
  the crates above geom and paint: the editor, tools and export test suites
  drive real text metrics through `blockworx-egui`, because a fake font
  would change which tests pass. `blockworx-geom` and `blockworx-paint` are
  not exempt — nothing about a coordinate or a byte of colour needs a font,
  so their gate covers `normal,build,dev`.
- **`blockworx-egui` depends only on geom, paint, doc and store-record
  types.** It never depends on editor, tools or export — the backend knows
  how to paint, not what a block is.

### `blockworx-geom` and `blockworx-paint`

First-class structs, not a re-export of emath: the next toolkit will not
use emath either (kurbo, iced's own, slint's own …), so the core must own
its coordinates. The API is method-for-method the subset of emath the app
uses, with emath's implementations copied where semantics matter (`Rect`
union/intersection with `NOTHING`/`EVERYTHING`, `Align2::anchor_size`,
lerp/remap), under emath's MIT attribution. `WorldPx`/`Bounded` live here
too, and so does `grid`: it is the world-space metric (cell size, pitch,
snap), and the router needs it.

`blockworx-paint` holds what a mark is made *of* rather than where it goes:
`Color` (`rgba8`, premultiplied like ecolor's `Color32`, with exactly the
operations the palette and the SVG writer use) and `Font { size, family }`
with `FontFamily { Proportional, Monospace, Named(Arc<str>) }` plus
`Font::canvas(size)` for the diagram family. Phase 4 grows it into the
Renderer/Palette/Theme/Style crate above.

**Neither crate names an egui-family crate at all** — not as a normal
dependency, not as an optional one behind a feature, not as a
dev-dependency. The conversions therefore cannot be `From` impls (neither
side would be local to the crate that owns them): they live in the backend
as two extension traits, `IntoEgui::egui()` and `IntoGeom::geom()`, so a
boundary call site reads as the crossing it is. The property tests that
prove each method agrees with emath's, and each conversion round-trips,
live beside those traits.

### `blockworx-paint`: the `Canvas` trait

`Renderer` keeps its shape over geom types. `anim_target`, which handed back
an `egui::Context` and a pointer, becomes an accessor for the frame-driven
half — split out so the generic render path can reach it on a `&dyn` while an
offline backend answers `None`:

```rust
pub trait Renderer {
    // … the draw calls, unchanged …
    fn animator(&self) -> Option<&dyn Animator> { None }
}

pub trait Animator {
    fn animate(&self, key: AnimKey, goal: f32, over: Duration) -> f32;
    fn pointer_world(&self) -> Option<Pos2>;
}
```

The on-screen-only conveniences tools reached through
`impl Style<'_, Painter>` become the live-canvas trait:

```rust
pub trait Canvas: Renderer + Animator {
    fn set_cursor(&mut self, cursor: Cursor);
    fn cursor(&self) -> Option<Cursor>;
    fn set_edit_text(&mut self, edit: EditText);
    fn remap_rect(&self, world: Rect) -> Rect;          // world → screen
    fn request_repaint(&self);
    fn request_repaint_after(&self, after: Duration);
    fn now(&self) -> Duration;                          // the frame clock
    fn pointer_kind(&self) -> PointerKind;              // Mouse | Touch
    fn image(&self, rect: Rect, handle: &ImageHandle);
}
```

`ToolTrait::{suppose, widget}` take `&mut Style<'_, C>` with `C: Canvas`;
enum_dispatch forwards the generic methods, so the 26-variant `Tool` needed
no hand-written dispatch and neither trait had to become object-safe.
`AnimKey` and `EditId` are `u64` hashes minted from tuples the way
`egui::Id::new` was used, so the keys the backend sees are stable across
frames. `Cursor` is the seven cursors the tools actually set.

`TextLayout` is the one place the core needs the toolkit's text engine
(CLAUDE.md: consume the toolkit's layout, never approximate it):

```rust
pub trait TextLayout {
    fn typeface(&self) -> FontChoice;
    fn layout(&self, text: &str, font: &Font, wrap: WorldPx) -> Layout;
}
pub struct Layout { pub size: Vec2, pub rows: Vec<Row> }
pub struct Row { pub pos: Vec2, pub ends_with_newline: bool, pub glyphs: Vec<Glyph> }
pub struct Glyph { pub chr: char, pub pos: Vec2, pub advance: f32, pub ascent: f32 }
```

`wrap` is `WorldPx::UNBOUNDED` for an unwrapped run — epaint's
`layout_no_wrap` *is* `layout` at an infinite width, so no second method is
needed. `typeface()` is what makes the SVG exporter's outlines the glyphs the
layout placed: it traces them from that face's own bytes, and a separate font
argument beside the engine would be a second source of truth for one fact.
The SVG and PDF exporters take one (they re-shape by paragraph over the same
bytes for glyph identity, as today); `blockworx-egui` implements it as
`EpaintLayout` over a standalone `epaint::text::Fonts`. Goldens stay
byte-identical.

### What the tools stop doing

- `spawn_image_dialog(ctx)` → the tool emits `Action::PickImage`; the dialog
  lives in the shell, and what it picks comes back as `Action::ImagePicked`.
  No tool owns a channel, and `Canvas` carries no way to wake a thread.
- `grab_radius(ctx)` touch sniffing → `Canvas::pointer_kind()`.
- Keyboard chords in `commands.rs` → own `Key`/`Modifiers`/`Chord`; the
  shell's egui binding reads them through one conversion.
- `history.rs` on `egui::util::undoer::Undoer` → an in-crate stack with
  the same two settings (depth, coalescing window); the equality-driven
  coalescing rule is already ours.

## The target beyond this branch: a kernel and a view

The direction the next branch takes (decided 2026-09-10): a model/view split
in which the view is owned by the UI stack and the model by the existing
Rust code, shaped as

```text
kernel(&mut State, events, &impl TextLayout) -> View
```

`events` is the union of what the front end can say — pointer and key
input (`Interaction`/`Event`), tool activations and commands (`CommandId`),
the actions overlays and tools emit (`Action`), text-editor edits, file
operations, and a clock tick — and `View` is what the front end should
show: a display list (the tools' preview paints, recorded through a
`Renderer` that records instead of drawing), the cursor, the in-place edit
request, and a model of the chrome (available commands, selection, history
rows, the nav tree, the status line) that the shell widgets compute inline
today by reading `App`.

Most of this exists: the tools are already `(state, interaction) →
(paints, Option<Action>)`, the gesture bracket and `dispatch_action` are
the kernel's write path, and the `Renderer` seam makes the display list a
recorder rather than a rewrite. Three immediate-mode leaks must invert:

1. **Animation by polling.** `Canvas::animate` asks the host for a keyed
   easing's current value, so a view depends on wall time. Time becomes an
   event (`Tick(now)`) and the core owns the easing table — which also
   makes every backend animate identically. Pulled forward into Phase 7,
   where the tools are being made generic anyway.
2. **In-place text editing.** The buffer is an `Rc<RefCell<String>>` shared
   with the toolkit's text widget. The request stays view output; the edits
   become events (`TextChanged`, `Commit`, `Cancel`).
3. **Text metrics.** Label hit tests and text-box extents need real layout,
   so the kernel takes a `TextLayout` from the view stack. Inherent, and
   already the boundary the export needs (D2).

Phase 9 is therefore the kernel spike rather than a bare second-`Canvas`
proof: define `View` and the event union, implement `kernel()` over the
existing `App` state with a recording canvas, and drive the existing test
scenes through it with the egui shell untouched. The next branch then
teaches the egui shell to consume `View`, which is the moment the shell
becomes swappable.

## Phases

Each phase is one subagent task, reviewed before the next starts, and
lands as one commit (plus a `todo.md` entry) with `cargo xtask ci` green.
Phases are bottom-up by dependency so every intermediate tree builds.

| # | Phase | Produces | Knots to untie |
|---|-------|----------|----------------|
| 0 | Branch, baseline, this playbook | `exit-egui`; baseline 1139 tests green in 87 s | — |
| 1 | `blockworx-geom`, and `blockworx-paint` seeded with `Color`/`Font` | both crates; every non-boundary module imports their types; the boundary converts through `.egui()`/`.geom()` | `units`, `bounded`, `grid` move; `Rect`/`Align2` method inventory; property-test equivalence with emath/ecolor; `Zoom`/`Progress` become newtypes |
| 2 | `blockworx-store` | store/*, `doc.rs`, `document_file`, `naming`, `atomic`, `worked` | `Viewing::saturation` → tools; `Authoring::of(InterfaceLock)` → editor; `spotlight::Worked` → store |
| 3 | `blockworx-router` | router/* over geom; the grid↔world bridge (`edit/lower.rs`'s `grid_point`/`px_point`/`px_rect`/`grid_vec`/`px_vec`/`grid_size_ceil`/`screen_rect`/`artwork_rect`/`grid_rect`) moves to `geom::grid` | `Point ↔ Pos2` via geom; the router's `coord.rs` lattice and the bridge agree in one place; `block_rect`/`slot_capacity` stay in the editor |
| 4 | `blockworx-paint` grown out | traits, palette, theme, style, zoom, extent, interaction types, EditText, Cursor, AnimKey, FontChoice + bytes, Scheme; tools generic over `Canvas`; `Painter` implements `Canvas` in place | `Palette::egui_visuals` → backend fn; `anim_target` → `animate`; `preferences` split (enums down, persistence stays) |
| 5 | `blockworx-egui` | Painter, View, compute_interaction, ImageRegistry, Icons, build_fonts, visuals, EpaintTextLayout, conversions, `test-support` (settle, headless painter) | `View ↔ store::record::Camera` conversion → shell; `tools::settle` → backend |
| 6 | `blockworx-editor` | path, state, presentation, edit, gesture, widget, shape, render, title_block, content_path, nav_tree queries, import parsing | `Supposing` → widget; `RouteEdgeExt`/`materialize_document` cycle; `render/bounds` ↔ `auto_route::Wire`; `io_pin_picker` stays in shell; text-metric tests dev-depend on `blockworx-egui` |
| 7 | `blockworx-tools` | tools, ToolTrait, commands (own keys), history (own stack), spotlight animation, headless driver; the easing table moves into the core (`Animator` driven by a clock the host ticks, not by the host) | the waist gate becomes the crate boundary (`Gesture::author` unnameable); the six widget panels stay in the shell |
| 8 | `blockworx-export` | `TextLayout` (defined where its first consumer is), SvgRenderer over it, PDF | `spawn_export` (status line/toast) → shell; goldens byte-identical |
| 9 | Kernel spike + gates + sweep | `headless` gate over every core crate; `View`, the event union and `kernel()` over the existing `App` state with a recording canvas, driven by the existing test scenes; stale-comment sweep; docs | **done** — 9a the kernel spike, 9b the gates, the sweep and the close-out |

### Acceptance, every phase

- `cargo xtask ci` green (snapshots too, when a GPU is present).
- `cargo tree -p <new crate> -e normal` names none of egui, epaint, emath,
  eframe, egui_extras, wgpu, winit.
- No test outcome changes: the phase is behavior-preserving. Goldens are
  not regenerated. If a test must move crates it keeps its name.
- Comments in every file touched are load-bearing and current (below).
- `todo.md` gets the phase's entry; the commit message says what moved and
  what was untangled.

### Comment hygiene (applies to every file a phase touches)

A comment earns its place only by explaining behavior that is surprising
*today*. Delete, in the modules a phase moves or edits:

- References to architectures and features that no longer exist: the
  server, collaboration/peers/sync, websockets, KDL, tutorials, the
  choreographer, the flag-day series, the editor-swap steps, "legacy"
  ids/formats.
- History narration: "used to", "until 2026-…", "renamed from", "was …
  before", dated asides, "(R53)"/"(D21)"/"(P4)"/"(S6)" pointers whose only
  content is *when* something changed.
- Narration of what the code does, or why a change was correct.

Citations are stripped and the sentence is kept: a reader of the code cannot
resolve `(R19)`, `D9:`, `F6`, `S6` or a bare `§8.1` without archaeology, so the
tag goes and the sentence is reworded to state the rule on its own — deleted
outright if the tag was all it carried — and the only pointer that survives is
one that names a document by path (with its section number only where that
heading really exists in that file).

Rustdoc that describes a public type's contract stays. If a function needs
a paragraph to be understood, that is a refactor, not a comment.

## Decisions

- **D1 Own geometry and colour, not emath and ecolor.** See
  `blockworx-geom` and `blockworx-paint` above. Neither carries an
  egui-family dependency in any form, dev-dependencies included, so the
  gate is unconditional and there is no feature under which the core can
  reach for emath by accident. The conversions are extension traits
  (`.egui()` / `.geom()`) in the backend rather than `From` impls, and the
  equivalence property tests sit beside them. Cost: one conversion layer at
  the backend; benefit: no egui-family crate below it, and the next toolkit
  is one trait-impl set away.
- **D2 Text layout is a trait the backend implements**, not a dependency
  the export carries. The export therefore cannot run without *some*
  backend; that is correct — a diagram exported with different line breaks
  than the canvas showed is a wrong export.
- **D3 Tests may drive the egui backend as a dev-dependency.** The 1139
  tests are the safety net for this refactor; re-goldening them against a
  fake font would spend the net to save the gate. When the next backend
  exists, the dev-dependency swaps.
- **D4 The waist gate becomes a crate boundary.** `Gesture::author` and
  `CommitBuilder` are unnameable from `blockworx-tools` by visibility,
  which retires the source-grep gate.
- **D5 The widget panels under `tools/` are shell.** `overlay`, `palette`,
  `nav_tree` (UI half), `history_panel`, `notices`, `chrome` are egui
  widgets and move with the shell; their pure query halves move down.
- **D6 `blockworx-edit` is not split out yet.** The op emitters are pure
  but reach into `path`, `shape` and `store` types; they travel inside
  `blockworx-editor` and can be lifted later without disturbing anything
  above them.

## Follow-ups (not on this branch)

- **World and screen as types.** Both are `Pos2` today; only lengths are
  typed (`WorldPx`). A `World<T>`/`Screen<T>` split belongs in geom, and
  after Phase 5 the `.egui()` call sites are exactly the crossings a screen
  type would anchor on. Deferred: it is a type-strengthening pass across
  several hundred signatures, orthogonal to the crate split.
- **`blockworx-edit`** as its own crate (D6).

## Worklog

Filled in as phases land: phase, commit, what moved, what was untangled,
CI time.

**Phase 1 — `blockworx-geom` + `blockworx-paint` (2026-09-10).** Moved:
`src/units.rs`, `src/bounded.rs` and `src/grid.rs` into geom unchanged;
`Color32` → `blockworx_paint::Color` and `FontId` → `blockworx_paint::Font`
everywhere, with `CANVAS_FAMILY` behind `Font::canvas`. The API came from an
inventory of what the app actually calls: 112 methods and free functions, 24
consts, 48 operator/`From`/`Debug` impls. Every module that is not the egui
boundary — `render/`, `shape/`, `widget/`, `edit/`, `presentation/`,
`router/`, the tools proper, `theme/`, `canvas/{palette,extent,zoom,svg,
interaction}`, `history`, `spotlight`, `state`, `export/` — imports geom and
paint; `canvas/{painter,view}`, the shell, `app.rs` and the widget panels
convert at the point an egui API is called. Untangled beyond the plan:
`Zoom` and `Progress` became newtypes over `Bounded`, since an inherent impl
on a type alias is illegal once the alias points into another crate;
`Theme::canvas_font`/`canvas_font_at` collapsed into `Font::canvas`;
`ImageHandle::size` and the `Chrome`/headless test drivers turned over to
geom so core tests keep reading them; the `headless` gate generalised to all
three core crates over `normal,build,dev`; the `palette` gate learned the
new colour spelling. No behavior change, no golden regenerated: 1156 tests
(1139 + 17 new equivalence tests), `cargo xtask ci --no-snapshots` green in
57 s.

**Phase 2 — `blockworx-store` (2026-09-10).** Moved: every `src/store/*`
module, `src/doc.rs` (the store's write door — `store/handle.rs` already
called `doc::stepped`), `src/document_file.rs`, `src/naming.rs`,
`src/atomic.rs`, the path half of `src/file.rs`, and `spotlight::Worked`.
What stayed in the app: `RecentFiles` (an `eframe::Storage` seam),
`FileRequest`/`FilePick`/`pick`/`spawn_file_dialog` (rfd + `egui::Context`),
`SaveScope`, and the animation half of `spotlight.rs`.

Untangled: **`Worked.scope` is the wire `BlockId`** the manifest row already
holds rather than `path::Scope`, so `Scope` — an editor concept — stays above
the store and the one conversion happens where the ring is drawn.
`worked::Step` and the subject→scope walk (`scope_of`/`container`/`parent_of`)
went down *with* `Worked`, because `Worked::of` and the animation's `in_scope`
are the same walk and the alternative was two copies of one match.
`Viewing::saturation` became a free `saturation(Viewing) -> Saturation` in
`app.rs` — what a lens does to a palette is the canvas's business — and the
test that pins it followed. `Authoring`, with `of(Writability, InterfaceLock)`
and `From<Writability>`, moved to `src/edit/naming.rs` beside the lock it
reads; it becomes editor-crate API in Phase 6. `document_name` stayed in the
app's `file.rs`: it stems a name through `import::file_stem`, which the web
build needs and the store's native-only `file` module cannot offer.
`fixture` and `temp` (the `TempDir` the app's suites write real containers in)
are behind a `test-support` feature, the way `blockworx-doc`'s `fixtures` is.
`jiff` joined `[workspace.dependencies]`; `blake3`, `miette`, `timeago`,
`uuid`, `dirs`, `zip` and `zstd` left the root for the store, and `chrono` —
which nothing had called since the store stopped using it — left altogether.
Comment hygiene took the phase/decision-history narration out of every moved
file (`Phase 8`, `since P5`, `P3's backfill used to repair`, `legacy`, the
flag-day pointer in `export/mod.rs`); spec pointers that still govern the code
(`§10.1`, `S4`, `D20`) stayed. `docs/json-format.md`'s code pointers were
repointed at `crates/store/src/`.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 48 s.

**Phase 3 — `blockworx-router` + the grid↔world bridge (2026-09-10).** Moved:
`src/router/{mod,block,channel,coord,cost,event,point,segment,turtle}.rs` into
`crates/router`, and `edit/lower.rs`'s nine coordinate conversions
(`grid_point`, `px_point`, `grid_size_ceil`, `grid_vec`, `px_vec`, `px_rect`,
`screen_rect`, `artwork_rect`, `grid_rect`) into `blockworx_geom::grid` as
`grid/bridge.rs`, re-exported from `grid` so a call site reads one namespace.
The router's external deps are `petgraph`, `pathfinding`, `tracing` (the
`router_rebuild` span), geom and `blockworx_doc::geometry::GridPoint`; nothing
else in the app called `petgraph`/`pathfinding`, so both left the root. geom now
depends on `blockworx-doc` — intended: geom is the world coordinate system *and*
its bridge to the document grid, and the alternative was a third crate between
two that already have to agree. `rand` joined `[workspace.dependencies]` for the
router's line-sweep stress test.

Untangled: **the lattice and the bridge now round in one place.** `CoordX`/
`CoordY`'s `From<f32>` — a naked-`f32` conversion whose only caller was
`From<Pos2> for Point` — is deleted; `Point ↔ Pos2` goes through
`grid::grid_i32` and `grid::px`, the same helpers `grid_point`/`px_point` use, so
a wire previewed and a wire stored cannot disagree about which cell they are on.
geom's world→world snap and the bridge's two-corner rect both spelled
`grid_rect`; the snap is now `snap_rect`, beside `snap_to_grid` and
`snap_offset` (6 call sites in `shape/`). The sweep's `ci_stats` counters became
a module behind a `test-support` feature, because `#[cfg(test)]` stops reaching
the app's profiling test once the router is its own crate — the app dev-depends
on the feature, so the counters are live in exactly the builds they were before.
`RouterNG::debug_marks` grew the `# Panics` section its assert always deserved,
and `expand_x`/`expand_y`/`min`/`max` grew `#[must_use]`: pedantic lints that a
`pub(crate) mod` had been hiding. `block_rect`, `slot_capacity`,
`role_from_accent`/`accent_from_role`, `shape_label` and `asset_within_limit`
stayed in `edit/lower.rs`, whose module doc now says what it is (editor rules)
rather than what it was (the conversion layer). `TUNING.md`'s two code pointers
were repointed at `crates/router/src/lib.rs`.

Deviation: the playbook says repoint call sites at `blockworx_geom::grid::…`;
they are spelled `crate::grid::…`, which is the same module — `lib.rs` has
`pub(crate) use blockworx_geom::grid` from Phase 1, and every other name in that
module (`GRID_SIZE`, `snap_to_grid`, `px`, `grid_i32`) is already reached that
way. A second spelling for one module would be the drift the alias exists to
prevent.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 66 s.

**Phase 4 — `blockworx-paint` grown out (2026-09-10).** Moved: `canvas/mod.rs`'s
`Renderer`, `canvas/{palette,zoom,extent}.rs`, `canvas/interaction.rs`'s types
(`Interaction`/`Press`/`Event`), `canvas/painter.rs`'s `EditText`/`EditColors`,
`canvas/image.rs`'s `ImageHandle`/`ImageImportError`, the whole of `theme/`
(`Theme`, `Role`, `RoleStroke`, `FontSizes`, `accent_role`, `Style<R>`, and the
embedded `theme.json`/`font_sizes.json`), and `preferences.rs`'s three pure
enums — `Theme` renamed to `Scheme`, since paint already has a `Theme` — with
`src/font.rs`'s four `.ttf` files behind them. New in paint: `Cursor`,
`PointerKind`, `EditId`, `AnimKey`, `Waker`, `Animator` and `Canvas`. What
stayed in the app: `compute_interaction`/`compute_event` (the egui shim),
`ImageRegistry` and the SVG/PNG intrinsic-size parsers (they read the `svg`
crate), `Preferences` and `build_fonts`, `theme/role_picker.rs` — now
`src/role_picker.rs`, since it is a shell picker — and `Painter`, which
implements `Renderer + Animator + Canvas` in place until Phase 5.

Untangled: **the tools no longer name a toolkit at all.** `ToolTrait::suppose`
and `widget` — and `tools::tool::frame` and every tool body — are generic over
`C: Canvas`; `enum_dispatch` forwards the generic methods, so the 26-variant
`Tool` needed no hand-written dispatch and neither trait had to become
object-safe. `egui::CursorIcon` became `Cursor`, `egui::Id::new(tuple)` became
`EditId::of`/`AnimKey::of`, `ctx().animate_value_with_time` became
`Style::animate` over a `Duration`, `grab_radius(ctx)`'s touch sniffing became
`Canvas::pointer_kind`, and `spawn_image_dialog(ctx)` became
`spawn_image_dialog(Waker)` — one `Waker` constructor in `egui_compat`, used by
both the tool path and the drop path. `Palette::egui_visuals` became
`canvas::egui_compat::visuals(&Palette)`, which retired `App::egui_visuals` (a
one-line wrapper with one caller) with it. Two additions beyond the plan:
`Canvas::request_repaint_after`, because two tools poll a file dialog on a
timer, and `Canvas::now`, because the drag-to-route hint is a free-running
sawtooth off the frame clock rather than an easing toward a goal —
`route_hint_phase` now takes that `Duration`. `Renderer::animator` returns
`&dyn Animator` rather than an owned pair, so `draw_selection_frame` reads all
four corners' growths before it draws any of them (the animator is borrowed
from the backend the draws go to). `Style::icon` is gone — it was dead code and
the icons are the backend's own; `Painter::icon` keeps them for the planned
toolbar. The `palette` gate's walk now covers `crates/paint/src` as well as
`src/`, with the base16 tables exempted at their new path.

Deviation: one test name follows the type rename —
`every_theme_has_a_dark_and_a_light_variant` is
`every_scheme_has_a_dark_and_a_light_variant` in `paint::scheme`, because the
type it iterates is no longer called `Theme` and paint's `Theme` is a different
thing. The three `Style::with_opacity` tests moved to `src/canvas/style_tests.rs`
under their own names: they assert on exported SVG, and the SVG backend does not
reach paint.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 50 s.

**Phase 5 — `blockworx-egui`, the only backend (2026-09-10).** Moved:
`canvas/painter.rs` (`Painter`, its `Renderer`/`Animator`/`Canvas` impls,
`ScriptedInput`), `canvas/view.rs` (`View`, `Vantage`, `Camera`, `Framing`,
`CanvasChrome`, the grid, pan/zoom/touch and the in-place `TextEdit`),
`canvas/interaction.rs`'s `compute_interaction`/`compute_event`,
`canvas/image.rs` (`ImageRegistry` and the SVG/PNG intrinsic-size parsers),
`icons.rs`, `font.rs`'s `build_fonts`, `tools/settle.rs`, and
`canvas/egui_compat.rs` — renamed `convert`, since "compat" named a migration
rather than what the module does — with its emath/ecolor equivalence proptests.
`canvas/svg.rs` and `canvas/style_tests.rs` went to `export/`, where Phase 8
will find them, and `src/canvas/` is gone: `lib.rs` says
`pub(crate) use blockworx_egui as canvas`, the way Phases 1 and 4 alias `grid`
and `theme`, so the shell's 60-odd `crate::canvas::…` paths did not move at all.

Untangled: **the backend does not know that a camera is written down.**
`Vantage::recorded`/`of_recorded` converted to and from
`blockworx_store::record::Camera`, which would have put the store under the
backend; they are now `camera::recorded`/`camera::vantage` in the app, above
both crates, and `record::Camera`'s own doc points there. `Painter::{new,
headless, take_edit_text, set_scripted}` and `settle::{probe, assert_settles}`
became `pub` — the first two because the shell and the render bench construct
painters, the rest behind a `test-support` feature the app dev-depends on, since
`cfg(test)` stops reaching across a crate line. `View::new` became
`View::default`: an argument-free constructor is what `Default` is, and
`pub` made clippy say so. `emath` left the root for the backend's
dev-dependencies — the proptests are the only thing that names it; `svg` is in
both, because the export still parses one. Two `#[allow(dead_code)]`s went with
the move: `Icons` and `Painter::icon` are reachable API of a library crate now,
so the reason they exist (the toolbar's text buttons are to become icons) is all
that was left to keep. Finding: the `palette` gate's per-file scan stops at the
first `#[cfg(test`, and `painter.rs` had one two thirds of the way up — the
white image tint below it had never actually been checked. It is exempt for a
real reason (egui's tint is a multiplier whose identity is white), now spelled
where the gate can see it.

New gate, beside `core_crates_stay_headless`: `backend_knows_only_paint`, which
reads `cargo tree -p blockworx-egui -e normal` and fails on any `blockworx-`
crate but doc, geom and paint. The `palette` gate learned `crates/egui/src`.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 66 s.

**Phase 6 — `blockworx-editor` (2026-09-10).** Moved: `src/path.rs`,
`src/state.rs`, `src/gesture.rs`, `src/presentation/*`, `src/edit/*`,
`src/shape/*`, `src/render/*`, `src/widget/*`, `tools/{title_block,
content_path,names}.rs` and the parsing half of `src/import.rs`. What stayed
in the app: `spawn_import_dialog` (both `cfg`s — rfd plus an `egui::Context`),
`widget/io_pin_picker.rs` as `src/io_pin_picker.rs` (an egui popup), and three
suites that reach *above* the editor — `render_path_tests` and the level entry
they drive (`render_svg`/`render_level`/`RenderedLevel`, now
`src/export/level.rs`), which construct an `SvgRenderer`; `render_bench`, which
tessellates through egui; and `closed_router_tests`'s `lazy_edge_drag` module,
which drives the `EditRoute` tool. `lib.rs` says
`pub(crate) use blockworx_editor::{edit, gesture, path, presentation, render,
shape, state, widget}`, so the shell's `crate::…` paths did not move.

Untangled: **the phase token moved to the writers it gates.** `Supposing` is
`widget::Supposing`, beside `Drawing`'s preview writers and `routing.rs`'s
twenty uses; minting it is `Supposing::frame()`, public because the driver that
mints it (`tools::tool::frame`) is above this crate until Phase 7.
`Deletable` and `RoleTarget` left `tools/tool.rs` for `shape`: they are a set
of shapes and an accented shape, and `Drawing::{delete,set_role}` take them —
`tools::tool` re-exports both, so the tools' call sites are unchanged. The
handle affordance — `NEW_PIN_INACTIVE_SCALE`, `NEW_PIN_GROW_RANGE`,
`NEW_PIN_ANIM_TIME` — moved from `tools/new_pin.rs` into `render/selection.rs`,
which draws the resize handles *from* them; `new_pin.rs` re-exports them, so
the markers and the handles still rest, grow and animate as one. `names.rs`
went down whole and `tools` re-exports it (`ToolName` is a name, not a
behavior, and `edit::describe` reads its verbs); `nav_tree`'s `tree_root` is
`path::tree_root`, since it names the document's designated top — the PDF's
first page and the navigator's invisible root — and the export will want it in
Phase 8 without reaching into the shell. `Gesture::author` is `pub(crate)`: a
gesture is authored into through a `Drawing` method, never by the tool holding
it. `Gesture::seal` stayed public, because sealing what nothing authored writes
nothing. `test_fixtures` is behind a `test-support` feature (with `xtask`, its
scale-scene generator, as an optional dependency), the way store's and router's
are.

Text metrics: `blockworx-egui` grew `measure::Measured` behind `test-support` —
a headless context with the app's fonts installed and its first frame run,
handing out a `Painter` — so the four suites that measured by hand-rolling an
`egui::Context` (and two that borrowed the SVG backend for its fonts) share one
helper and the editor names no egui-family crate, not even in a test.

The `headless` gate is now a list of `(crate, edges)` pairs: everything at or
below paint is checked over `normal,build,dev`, and `blockworx-editor` over
`normal,build`, because D3 puts the backend in its dev-dependencies. The
`palette` gate learned `crates/editor/src`. `rstar` and `indexmap` left the root
for the editor; `derive_more`, which nothing had called, left altogether;
`implicit_hasher` joined the workspace's allow-list with its reason (the app
picks `ahash` once and passes those maps between its own modules), and eleven
builder methods that a `pub(crate) mod` had been hiding grew `#[must_use]`.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 43 s.

**Phase 7 — `blockworx-tools` (2026-09-10).** Moved: every tool under
`src/tools/` plus `tool.rs`, `commands.rs`, `stamp.rs`, `selection_bounds.rs`,
`block_edit.rs` and the four tool suites with the `headless` driver;
`src/history.rs` and `src/spotlight.rs`; the pure-data half of `src/file.rs`
(`FileRequest`, `FilePick`, `PickReceiver`, `SaveScope`) as the crate's own
`file.rs`; and `ExportFormat`/`ExportScope`, which are command vocabulary and
now live in `commands.rs` (the shell's `export/mod.rs` re-exports them under
their old names, and the two private `filter_name`/`mime` arms — dialog and
Blob concerns — became free functions there). The six widget panels D5 names,
with `file_menu` and the `painted` test chrome, are `src/panels/`: the tools
directory is now tools. `lib.rs` says
`pub(crate) use blockworx_tools::{self as tools, history, spotlight}`, so the
shell's paths did not move. `rfd` is a normal dependency of the crate — a file
dialog is a platform affordance, not a toolkit, and the tools own the pending
dialog's `Receiver`; when a dialog becomes an event in the kernel spike it goes
back up.

Untangled: **the keyboard is paint's, not the registry's.** `Key`, `Modifiers`
and `Chord` are `blockworx_paint::interaction`'s, beside `Interaction`/`Event`
— the deviation from the phase's plan, which put them in `commands.rs`: the
backend gate forbids `blockworx-egui` from naming any crate above paint, and
the `IntoEgui` impls have to be in the backend (neither the shell nor the tools
crate is local to both sides of that impl). `BINDINGS`/`binding`/`chords` are
over `Chord`; `consume_binding` and a `spelled` formatter are the shell's
`src/keys.rs`, one conversion per call. `bound_chords_consume_from_the_input`
moved with the function, under its own name. **The undo stack is ours**:
`history/undoer.rs` reimplements egui's `Undoer` over `Duration` (`max_undos`,
`stable_time`, `auto_save_interval`, `feed_state`, `add_undo`, `undo`, `redo`)
under its MIT/Apache notice; `history.rs`'s own tests pass unchanged, which is
the proof. **`Vantage` went down to paint**, because an undo entry restores one
and the stack is not the backend's — `blockworx_egui` re-exports it, so
`canvas::Vantage` still reads. The spotlight's `light`/`ring` are a
`Spotlighter` value on `App` (`light(now, spotlight)`, `ring(scope, style)`),
reading `Canvas::now`/`request_repaint_after` instead of egui's temp-data slot
and clock. `Progress` moved to paint beside the easing it measures — it is
animation vocabulary and only `new_pin` reads it. `enum_dispatch` left the root
for the tools crate; `expect-test` and `egui` are dev-deps there.

**The easing table is the core's (playbook item 1, pulled forward).**
`blockworx_paint::Easing` is a keyed `AnimKey -> { from, to, toggled_at }`
table whose `animate(Tick, key, goal, over) -> Animated` reproduces egui 0.36's
`AnimationManager::animate_value` exactly — first sight answers the goal,
otherwise `remap_clamp(t + predicted_dt/2, 0..=over, from..=to)` restarting
from the current value on a goal change, and `over == 0` snaps — and says
whether the easing is still running so the host can ask for a frame. `Tick`
bundles the two clock readings, which is also what keeps `animate` inside
clippy's argument budget. The session's table is the `View`'s (it is what
outlives a frame's `Painter`), handed to `Painter::new` and to
`Painter::headless` as an `Rc<RefCell<_>>`; the headless tool driver and
`measure::Measured` each keep one across their frames, so an animation that
started on one frame is read back on the next exactly as it was. `Animator` is
now a clock adapter — it reads `now`/`predicted_dt` off the egui input and
requests a repaint — rather than a host feature.

Gates: `blockworx-tools` joins the `headless` list over `normal,build`, and the
`palette` gate learned `crates/tools/src`. The **`waist` gate is retired**
(D4): it grepped `src/tools/` for `CommitBuilder` and `Gesture`, and the crate
boundary says it now — the setters are `blockworx-editor`'s, `Gesture::author`
is `pub(crate)` there, and nothing in `crates/tools/src` names either type in
code (checked before deleting it). What is left in `xtask` is a paragraph
saying why a gate that can only agree with the type system is a second copy of
it.

No behavior change, no golden regenerated: 1156 tests, `cargo xtask ci
--no-snapshots` green in 69 s.

**Phase 8 — `blockworx-export` (2026-09-10).** Moved: `src/export/{svg,
level,pdf}.rs` with `pdf/tests.rs`, the PDF goldens, the SVG snapshot and both
test suites (`render_path_tests`, `style_tests`) into `crates/export`, plus
`ExportContent`, `bytes_for` and `render_png`. What stayed in the shell as
`src/export.rs`: `ExportPayload`, both `spawn_export` bodies (thread /
`egui::Context` / rfd / the status line and toast, or the wasm Blob and
`<a download>`), `filter_name`/`mime`, and the `ExportContent → ExportFormat`
mapping. `krilla`, `krilla-svg`, `usvg`, `resvg`, `svg`, `ttf-parser`,
`harfrust`, `skrifa`, `base64` and (dev) `lopdf` left the root with them.

**`TextLayout` is paint's, and it says which typeface it lays out in.** Beyond
the sketch above, the trait answers `typeface() -> FontChoice`: the SVG
exporter traces its outlines from that face's bytes and re-shapes over them for
glyph identity, so an engine and a font choice passed side by side would be two
sources of one truth — one call site pairing the wrong two would place Roboto's
glyphs at Excalifont's pen positions and nothing would say so.
`SvgRenderer<L: TextLayout>` is therefore constructed with `(Palette, layout)`,
`render_svg`/`render_level` and `pdf::Scene` carry a `&dyn TextLayout` where
they carried a `FontChoice`, and `blockworx_paint::TextLayout for &T` lets one
engine serve a page's drawing and its title block at once. `Measured` reads it
through `&self` (the `&mut Fonts` it threaded is gone), and `Ink` — a width and
a resolved colour — replaced the `egui::Stroke` the SVG writer resolved to.

**`ExportFormat` stayed in `blockworx-tools`.** The export crate never needed
it: it names *what it rendered* (`ExportContent`), and the shell — where
`ExportPayload` is built and where the toolbar's word for a format comes from —
maps the one to the other in a four-arm `format()`. The alternative edges were
both wrong: the tools crate would have carried krilla, or the export crate a
dependency on tools it otherwise has no use for (`title_block` and
`content_path` are `blockworx-editor`'s since Phase 6).

Untangled: **an image's intrinsic size is not the backend's**. The SVG exporter
answers `Renderer::image_intrinsic_size`, and the parsers behind it
(`image_intrinsic_size`, `svg_intrinsic_size`, `png_intrinsic_size`) sat in
`blockworx-egui`, which the export cannot name. They are about an `Asset`, not
about a toolkit, so they went down to `blockworx_paint::image` beside
`ImageHandle` and `ImageImportError` — paint gains the `svg` crate, the registry
that hands bytes to egui's loader stays in the backend. The `kittest`
tessellation snapshots left `render_path_tests` for the shell's own
`src/tessellation_snapshots.rs`: they drive egui's tessellator and write into
`tests/snapshots`, so they are the shell's, and the file they left is now what
its name says. `preferences`'s `every_bundled_font_parses` moved to the SVG
exporter's tests, where the tracer that would fail lives — the root's last
`ttf-parser` call site.

Gates: `blockworx-export` joins the `headless` list over `normal,build` (D3
puts the backend in its dev-dependencies), and the `palette` gate learned
`crates/export/src` and the two test-only files that moved. Phase 5's finding
struck again — the gate's per-file scan stops at the first `#[cfg(test`, and
`pdf.rs` had one two thirds of the way up, so the krilla `rgb::Color` the PDF
paints through had never been checked. Making `page_scopes` public (the shell's
nav-tree test reads it across the crate line now) moved that cut and exposed it.
It is exempt for a real reason — krilla's colour type is the sink for a colour
the theme already resolved — now spelled where the gate can see it.

No behavior change, no golden regenerated — the PDF goldens and the SVG
snapshot moved as pure renames: 1157 tests (1156 + one pinning
`layout(.., UNBOUNDED)` to epaint's `layout_no_wrap`), `cargo xtask ci
--no-snapshots` green in 66 s.

**Phase 9a — the kernel spike (2026-09-10).** New: `crates/kernel`
(`blockworx-kernel`), holding `Session` — everything the editor is apart from
the surface it is shown on — and

```rust
kernel(&mut Session, events: Vec<Event>, layout: &impl TextLayout, tick: Tick,
       viewport: Rect) -> View
```

`Event` is `Pointer(Interaction) | Action(Action) | Command(CommandId) | Tick`;
`View` is `{ paints: Vec<Paint>, cursor, edit_text, selection_bounds, commands,
title, repaint }`. `Session` took exactly the non-UI fields `App` held: `doc`,
`identity`, `path`, `doc_index`, `gesture`, `undo_stack`/`recorded_through`/
`recording`/`moved`, `spotlight`, `theme`, `presentation`, `spatial`, `tool`,
`time_machine`, the keyed `Easing`, and where the pointer last rested.

**The frame logic was extracted, not forked.** `App` owns a `Session` field and
delegates: `show_canvas` is now the grid chrome, the owed fit, and one call to
`Session::canvas_frame` over the egui `Painter`; `dispatch_action` is
`Session::dispatch` plus the arms only a surface can answer. The gesture
bracket, `submit`, `commit_gesture`, `consequences`, `step_history`,
`walk_document`, `record_history`, `restore_view`, `nudge_selection`,
`available_commands`, `window_title`, `paste`/`insert_document`/
`handle_imported`, `nav_select`, `view_rev`/`view_head`/`tag_rev` and
`src/camera.rs`'s `Vantage ↔ record::Camera` pair all moved down whole. The
kernel runs the same `Session::canvas_frame` over a recorder, so there is one
editor and one frame, not one per surface.

**The second `Canvas`: `blockworx_paint::record::Recording<L: TextLayout>`.** It
implements `Renderer` by appending to a `Vec<Paint>` (`Rect`, `LineSegment`,
`Line`, `Circle`, `ConvexPolygon`, `Text`, `RotatedText`, `TextWrapped`,
`Image`) in screen space with every swatch already resolved through the same
`Palette` the painter and the SVG writer resolve through; `Animator` over the
session's `Easing` and the `Tick` it is given; and `Canvas` — cursor and
edit-text stored and read back, `remap_rect` through the `Vantage`,
`request_repaint`/`request_repaint_after` keeping the earliest delay, `now` the
tick, `waker` a `Waker` that sets a flag. `finish()` hands back `Recorded`.
Text measurement is the `TextLayout`'s at `font.size * zoom`, which is where
the painter rasterizes, so the recorder measures the run the backend would
draw.

Untangled: **the transform is paint's now.** `Vantage` grew
`world_to_screen`/`screen_to_world`/`remap_rect`/`remap_len`/`remap_font`,
`visible`, `framing` and `zoomed_about`, and `Zoom` grew `framing` and the
`WORKED_MIN`/`WORKED_MAX` band the wheel, the pinch and the keyboard step all
land in. `Painter` holds a `Vantage` instead of a loose zoom and translation,
and `View::{world_to_screen, screen_to_world, visible_world_rect, frame_rect,
zoom_about}` delegate — the painter and the recorder cannot place a mark
differently, because the arithmetic is written once.

**The camera stays the shell's.** A `View` eases framings into place, reads the
wheel and holds the safe region; a session that also held a camera would be a
second one. So the session is *told* where the camera stands — one `Sighting`
(vantage, viewport, safe region, whether the user worked it, where the pointer
is) pushed by `App::sync_camera` — and asks for moves by leaving a `Framing`
(`StandAt`, `Fit`, `BringIntoView`, `FocusOn`, `Zoom`, each with a `Glide` of
`Eased` or `Snap`), which `App::apply_framings` hands to the view and the
kernel applies to its own vantage. The push happens at the top of
`shell_frame`, after the canvas pass, and after every dispatch; the test
helpers that stand in for a frame make the same calls.

Tests, in `crates/kernel/src/tests.rs`, over
`blockworx_editor::widget::test_fixtures` with `EpaintLayout` as the
dev-dependency `TextLayout` (D3): arming `NewBlock` through
`Event::Command(CommandId::Arm(..))` and dragging a box writes a block, paints
it the frame after the gesture sealed, and leaves an undo step; an
`Event::Action(Action::Undo)` restores the document; a hover over a title
answers `Cursor::PointingHand` where an empty hover answers `Cursor::Default`;
and a resize handle's growth differs across two ticks of the *same* hover,
which is the easing table being the session's rather than a host's.

Gates: `blockworx-kernel` joins the `headless` list over `normal,build` (D3
puts the backend in its dev-dependencies) and the `palette` gate's walk covers
`crates/kernel/src`. `cargo tree -p blockworx-kernel -e normal,build` names
none of egui, epaint, emath, ecolor, eframe, egui_extras, wgpu, winit or tokio.

Deviations: `kernel` takes `events: Vec<Event>` rather than `&[Event]` — an
`Action` carries a `Tool`, and a tool in flight owns a receiver waiting on a
file dialog and the buffer a text editor is typing into, none of which clone.
`Action::SaveProjection` is handed back to the shell rather than answered in
the kernel: it needs the notice channel a refusal is reported through, which is
`App`'s. `blockworx-router` is a normal dependency of the kernel, for the
`Mark` the router's debug overlay is drawn from.

No behavior change, no golden regenerated: 1161 tests (1157 + 4), `cargo xtask
ci --no-snapshots` green in 68 s.

**Phase 9b — the closing sweep (2026-09-10).** No crate moved. What changed is
the three things a reader arriving cold would otherwise have to take on trust.

**The `palette` gate stopped truncating.** Its per-file scan ended at the first
`#[cfg(test`, so everything below a mid-file test module had never been read —
the finding that struck twice, in `painter.rs` (Phase 5) and `pdf.rs` (Phase 8),
each time only because an unrelated edit moved the cut. It now skips the *item*
the attribute gates, tracking brace depth from the line that opens it (or the
`;` of a braceless one), and keeps the rest of the file. That is 1,519 more
lines over 81 files — the whole of `crates/kernel/src/lib.rs` below its tests,
`commands.rs`, `nav_tree.rs`, `tool_cluster.rs`, `svg.rs` — and it finds
nothing: the two existing exemptions (egui's white image tint, krilla's
`rgb::Color`) are still the only two, and both were already spelled where the
gate can see them. Reported line numbers are the file's own, since the scan
carries them rather than counting kept lines.

**A third dependency gate, `shell_is_the_only_egui_user`.** `headless` reads
nine crates and `backend` reads one; both are lists, and a tenth crate added
without an entry would be checked by neither. This one reads the toolkit
instead — `cargo tree -p blockworx -e normal --invert egui` — and fails if any
`blockworx-` crate but `blockworx` and `blockworx-egui` appears in it, so the
property is now checked from both ends. The `headless` step also names the
crates and edges it covers in its own log line, so a CI log says what was
proved rather than that something was.

**The editor stopped depending on `xtask`.** `crates/editor`'s `test-support`
pulled the build tool in as a library for one function — the scale-scene
generator — which put `clap` and `duct` in a library crate's test tree. The
generator was never a build-tool concern: it builds a `Document` and serializes
it, so it is `blockworx_doc::fixtures::scale` behind the `fixtures` feature,
and `xtask autogen` is the CLI over it. `cargo xtask autogen scale N` writes
the same bytes it did before (checked at N=1 and N=4). The root's own `xtask`
dev-dependency went with it — nothing in `src/` had called it since Phase 6
moved the load tests down.

**The comment sweep.** The census (`collab|server|websocket|peer|tutorial|
choreograph|flag-day|editor-swap|KDL|legacy|used to|until 2026|renamed from|
Phase [0-9]|R[0-9][0-9]|P[0-9]|S[0-9]` over comment lines) was 111 in `src`,
18 in tools, 18 in doc, 14 in store, 12 in editor, 6 in kernel, 4 in paint,
2 in xtask; it is now 95 / 16 / 9 / 14 / 9 / 6 / 4 / 2. Most of what the grep
catches is a live decision pointer *with its effect stated* — "disabled rather
than hidden on R19's rule", "S6: a step adopts the rev copy" — and the
playbook's rule keeps those: `docs/single-author-playbook.md` and
`docs/log-vs-snapshot.md` still govern the code they point at. What went, in 55
edits across 26 files, is the other kind: history narration ("the menu that
used to carry Rename does not any more", "R33's ellipsis is superseded by",
"spec v2 scattered across four floating berths", "reversing R28", "R43 moved
the routine confirmations", "Phase G hid the bar here", "since P2", "Donor:",
"CP3/CP5 regression", "the eager plan was then thrown away"), and references to
things that do not exist — the `legacy` model behind `port_orientation`, the
`legacy` `top_id` the nav tree once matched, "the editor-swap addition
(2026-08-16)", "a serverless boot", "Phase 8 owns web persistence". Nothing
outside `src` and `crates` was touched; the historical docs are records. No
`TODO`, `FIXME` or `XXX` exists anywhere in the tree. Two rustdoc blocks in
`xtask` sat over the wrong function — "Run one CI step" over `have_nextest`
and "A command rooted at the workspace" over `run` — and were moved onto
`step` and `command`, which are what they describe.

`CLAUDE.md`'s project section is now the crate graph as a table, the four CI
gates and what each proves, "the shell is `src/`; nothing below it names egui",
where the kernel's entry point is and where the next branch starts, and the
`test-support` convention — plus the bullets that were still true.

No behavior change, no golden regenerated: 1161 tests, unchanged in outcome,
`cargo xtask ci --no-snapshots` green in 42 s from a touched shell (14 s fully
warm, 289 s from an empty target directory).

**Phase 9c — the image dialog as two events (2026-09-10).** A drawing trait
carried an execution model: `Canvas::waker()` handed out a `Waker(Arc<dyn Fn()
+ Send + Sync>)`, the image and icon tools spawned `rfd` on a thread with it,
held the `Receiver` in a `Pending` state and polled it every frame behind a
100 ms repaint. So `blockworx-tools` had `rfd` and `wasm-bindgen-futures` as
normal dependencies, and a tool in flight owned something unclonable.

A request is an action and a result is an action, which is what the File flow
and `Import` already were. `Action::PickImage(ImageTarget)` leaves the session;
the shell runs the dialog (`spawn_image_dialog` beside `spawn_import_dialog` in
`src/import.rs`, taking an `&egui::Context` like the others) and dispatches
`Action::ImagePicked { target, asset }` back in. `ImageTarget` is
`Place(Placement) | Icon(BlockId)` — exactly what the two `Pending` arms
carried — and `Session::dispatch` does what they did: place the image through
`Drawing::add_image` and settle on `ResizeBlock::Selected`, or set the block's
icon and select it, or, on a refusal of any kind, land where a cancel always
landed. The drop path folded in with it: the kernel turns an image `StampTool`
into `PickImage(Place(Centered(at)))`, so the shell has one image arm instead
of two.

Deleted: `Waker` from paint (`canvas.rs`, the `lib.rs` re-export, `Style::waker`,
`Recording::waker` and the `woken` flag on `Recorded`), `Canvas::waker`,
`Painter::waker`, `blockworx_egui::convert::waker`, both `spawn_image_dialog`
bodies and `read_image` from the tools crate, and `NewImage::Pending` /
`IconTool::Pending`. `Canvas::request_repaint_after` stays: the spotlight's
ring is still on a timer. `cargo tree -p blockworx-tools -e normal,build` names
no `rfd`.

Tests: the three that drove a `Pending` state through a channel they owned the
other end of are gone — `a_picked_image_becomes_an_image_in_the_view`,
`a_picked_icon_is_attached_to_its_block`, `a_cancelled_pick_writes_nothing` —
with the `picked_png`/`answered` helpers they shared. What the *tools* now do
is asserted in their place: `the_image_tool_asks_for_an_image_fitted_to_the_box_it_drew`
and `the_icon_tool_asks_for_an_image_for_the_block_clicked`, through a new
`headless::Canvas::asked`, which hands back what one frame asked the surface
for. What the *answer* writes moved to the kernel, where it now lives:
`a_picked_image_lands_in_the_box_that_asked_for_it`,
`a_picked_icon_lands_on_the_block_that_asked_for_it` and
`a_cancelled_pick_lands_nothing`. This is the one place on the branch where a
test changed shape. No golden regenerated: 1163 tests,
`cargo xtask ci --no-snapshots` green in 85 s from a touched paint crate, 14 s
fully warm.

**Phase 9d — the shell decomposed (2026-09-10).** `src/app.rs` was 8,514
lines: one `App` struct with 27 fields and ~90 methods mixing six concerns —
documents on disk, appearance, file exchange, the chrome's frame state, the
canvas `View` and the kernel `Session` — and a `shell_frame` that interleaved
them in an order nothing enforced. `App` is now five parts and nothing else:

- **`session: Session`** (kernel, unchanged).
- **`surface: Surface`** (`src/surface.rs`, 10 fields) — `canvas`, `popup`,
  `overlay_top_right`, `selection_screen_bounds`, `palette`, `workspace`,
  `workspaces`, `safe`, `history_search`, `images_loaded`. Owns the camera
  sync and framings, the bands, the popups and their pickers, the navigator,
  the status line, the keyboard, and the tool-cluster drop. Answers
  `OpenRolePicker`, `OpenPinTypePicker`, `Camera`.
- **`exchange: Exchange`** (`src/exchange.rs`, 2 fields) — `pending_import`,
  `pending_image`. Owns the export content (JSON, SVG/PNG, PDF), the selection
  repo, the export text layout, and the two dialog polls. Answers `Export`,
  `ExportRev`, `Import`, `PickImage`.
- **`library: Library`** (`src/library.rs`, 9 fields) — `opened`,
  `opened_from`, `recent`, `documents`, `unclaimed`, `rename_draft`,
  `pending_file`, `head_moved`, `failures`. Owns the startup open, every
  container door, the projection refresh, the claim/sweep of born containers,
  and the file pick poll. Answers `NewDocument`, `RenameDocument`, `PickFile`,
  `OpenRecent`, `SaveProjection`.
- **`appearance: Appearance`** (`src/appearance.rs`, 5 fields) —
  `preferences`, `applied`, `applied_title`, `theme_editor`, `font_editor`.
  Owns the push to the toolkit, the window title, the two dev editors and the
  source-tree writes they do, and its half of the storage DB.

A part never holds a reference to another: a method that needs one takes it as
a parameter (`&mut Session`, `&mut Surface`, `&Preferences`), and the steps
that read two parts at once — `document_name`, `window_title`, `title_block`,
`sheet`, `notices` — stay on `App`, which borrows the parts disjointly. Three
small structs carry what would otherwise overrun clippy's five-argument
threshold: `BarState` (what the top bar reads that the surface does not own),
`OverCanvas` (what the canvas pass left the chrome floating over it) and
`Sheet` (what an export says about the document).

`shell_frame` is now five calls — `sync`, `ahead_of_the_canvas`, `gather`,
`dispatch_action`, `settle`, `poll` — in that order, and `dispatch_action` is
a chain: session, surface, exchange, library, ending in the same
`unreachable!`. `failures` went to the `Library` as a `Notices` newtype (in
`src/panels/notices.rs`, beside the strip that draws it), because every
failure reported anywhere in the shell is a file door's, and the standing half
of the list — read-only, an unrecognized projection — is the library's too.

The ~6,100 lines of tests split by their own submodules into
`src/app/tests/{frame,hand_off,drag_out,two_kinds_one_stack,step_lands,
time_machine,provenance,containers,kittest_visual}.rs`, with the fixtures and
drivers (`app_on`, `shell_ctx`, `shell_frames`, `shell_text`, `press_at`,
`screen_of`, `block_rect_of`, `copied`, `live_blocks`) in `tests/mod.rs`. No
test name or body changed except the paths it reaches (`app.surface.canvas`,
`app.library.recent`, `app.library.save_as_container(..)`); the free tests
gained a `frame::` module segment, which the split cannot avoid.
`a_step_lands_in_sight` keeps its name over a `step_lands.rs` file through
`#[path]`, so its test paths are unchanged.

No behavior change, no golden regenerated: 1163 tests, unchanged in outcome,
`cargo xtask ci --no-snapshots` green in 45 s.

**Phase 9e — the comments stop citing documents nobody has (2026-09-10).** No
code changed. 725 comment lines across `src/`, `crates/*/src/`, `xtask/src/`
and the manifests cited the single-author playbook (`D`/`F`/`P`/`S` numbers),
the CAD shell playbook's review rounds (`R` numbers), `docs/cad-ui-spec.md`
sections (`§`) and `docs/log-vs-snapshot.md` — tags a reader of the code cannot
resolve, on sentences that already carried the reason. Every tag is gone and
every sentence stands on its own: `"rings with them or one of the two stops
reading (R18)"` loses the parenthetical, `"D9's attribution identity"` becomes
`"The attribution identity"`, `"§2.3 forbids them categorically"` becomes
`"Hairlines are forbidden categorically"`, and a line whose only content was
the citation — `// (R31)`, `/// D19.` — is deleted rather than left restating
the code beneath it. A pointer survives only where it names a document by path
that still exists, with its section suffix kept only where that heading really
exists in that file: ten of them do (`docs/log-vs-snapshot.md` §5.1, §8, §10,
§10.1, §14.2; `docs/cad-ui-spec.md` §2; the bare playbook paths), and the
`S6`/`S2`/`P5`/`D10`/`§12.7` suffixes hanging off those same paths do not. The
one surviving history line in `src/shell/top_bar.rs` — *"The menu that used to
carry Rename does not any more"* — collapsed to the rule it was narrating,
*"One door, not two: the name is the only way to rename."* The census regex
goes 725 → 37, and all 37 are the ten document pointers, one `RFC 4648 §4`,
two `// Phase 1:`/`// Phase 2:` algorithm step labels in `routing.rs`, and
twenty-four present-tense uses of "used to" (meaning *employed to*) and "no
longer" (a payload the container no longer holds). Assert messages still quote
spec sections — those are code, and a failing assertion naming `§2.3` is read
by whoever is holding the spec open.

No behavior change, no golden regenerated: 1163 tests, unchanged in outcome,
`cargo xtask ci --no-snapshots` green in 284 s.

**Phase 9f — commands in, delegations out (2026-09-10).** Phase 9c made the
image pick two actions, and the second of them was a question the session had
asked and was waiting on: `Action::ImagePicked { target, asset: Option<Asset> }`
carried a *cancel* into the core, where `image_picked` decided what a cancel
meant (`Select` after a placement, the block re-selected after an icon). The
editor reacts to commands instead. `ImagePicked` is gone, replaced by two
commands the session executes and never hands back — `Action::SetIcon { block,
asset }` and `Action::PlaceImage { placement, asset }`, the asset not optional —
and `Action::PickImage` is renamed `Action::ImageWanted`, a *delegation*: the
session says an image is wanted, keeps no state about it, and expects no answer.
Every substate of the dialog is the shell's, `Exchange::picked` included, which
is where a cancel is now read: it sends the tool switch the shell wants
(`SwitchTool`) and nothing else, so what is on screen after a cancel is
unchanged. The asset limit stays in the kernel, where the write is: over-limit
artwork is reported exactly as before and lands where a refusal always landed.

`CommandId::AddIcon` yields `ImageWanted(Icon(block))` directly rather than
arming a tool that would ask on the next frame, so `IconTool::Armed` is deleted
and `IconTool` — one state left — is a unit struct like `SelectTool`. The
dialog now opens on the frame the button is pressed; the tool underneath it
stays the block's resize selection instead of passing through `Icon`, which is
the one visible difference and only while the picker is open. `Session::dispatch`'s
hand-back arm is now precisely the delegations — `Camera`, `Export`,
`ExportRev`, `Import`, `SaveProjection`, `OpenRolePicker`, `OpenPinTypePicker`,
`ImageWanted` and the native file actions — and carries the rule as a comment.

Tests: the three kernel tests that dispatched an answer are replaced by three
that dispatch a command —
`a_picked_image_lands_in_the_box_that_asked_for_it` →
`a_place_image_command_lands_the_image`,
`a_picked_icon_lands_on_the_block_that_asked_for_it` →
`a_set_icon_command_attaches_the_icon`, and `a_cancelled_pick_lands_nothing` →
`an_oversized_asset_is_refused`, which drives the limit check both commands
share. The cancel it used to prove is shell behavior now and is proved there:
`a_cancelled_image_pick_leaves_the_tool_where_it_was`
(`src/app/tests/frame.rs`) runs a cancelled icon pick through the real
dispatcher and asserts the tool, the selection, the icon and the rev are all
where they were. `add_icon_asks_for_an_image_for_the_block_it_was_offered_for`
(`crates/tools/src/commands.rs`) pins the registry's new action. The two 9c
tools tests stand unchanged. No golden regenerated: 1165 tests (1163 + 3 new
− 3 removed + 2 new), `cargo xtask ci --no-snapshots` green in 289 s.

**Phase 9g — the icon tool retired, the image drop the shell's
(2026-09-11).** Two leftovers of 9f. Nothing armed `IconTool` any more, since
`CommandId::AddIcon` raises `ImageWanted(Icon(block))` itself, so the tool is
deleted: `crates/tools/src/icon.rs`, the `Tool::Icon` variant and its
`enum_dispatch` arm, and the tool test
`the_icon_tool_asks_for_an_image_for_the_block_clicked` — the registry test
9f added stands in its place. `ToolName::Icon` *stays*, as a name with no tool
behind it: it is the verb an icon is committed under (`Action::SetIcon` reads
`ToolName::Icon.verb()`), and the label audit's `c5_add_icon` golden pins the
label that verb produces. The variant carries that in one line, and the two
exhaustive matches that still name it (`from_name`, `displayed_tool`) answer
`Select` as they always did. Nothing the user could see listed the tool: it was
never in `BAND_TOOLS`, has no `command_name`, and the registry never offered
`Arm(Icon)`, so the palette, the rail and the digit bindings are untouched.

The image cell's drag-out is the shell's own gesture and the shell holds the
drop point, so the shell opens the pick: `Surface::dropped` answers a
`NewImage` drop with `ImageWanted(Place(Centered(at)))` and every other cell's
drop with `StampTool`, exactly as before. The kernel arm that used to make
that conversion is deleted, and with it the `NeedsApp` hand-back in
`apply_scripted` that fed it — a `NewImage` stamp below the shell is now what a
route or a Select drop is, a drop that writes nothing — which leaves
`Session::dispatch`'s `StampTool` arm `unreachable!` for every stamp, as its
comment claims.

Tests: `an_image_drop_is_handed_back_carrying_its_drop_point` (`crates/tools`)
asserted the kernel-side conversion and is replaced by
`dropping_the_image_tool_asks_for_an_image_centred_on_the_drop`
(`src/app/tests/drag_out.rs`), which asserts the drop opens a pick centred on
the world point under it, that a block's drop is still a stamp for the session,
and that the log did not move. It drives `Surface::dropped` rather than the
pointer because the action it returns opens a native file dialog one dispatch
later. No golden regenerated: 1164 tests (1165 − 2 + 1), `cargo xtask ci
--no-snapshots` green in 140 s.

**Phase 9h — the registry is permission, the shell performs its own commands
(2026-09-11).** The registry had been scripting flows it has no business
knowing: `Command.action` was an `Action`, so "Add icon" meant *the core is
asked for an image* rather than *the shell may pick artwork for this block*,
and `Session::dispatch` handed nine variants back for the shell to finish. The
command's act is now split in two — `Act::Edit(Action)`, which the core
executes, and `Act::Effect(Effect)`, which the shell performs — and the
registry supplies availability and target for both and nothing more.
`CommandSet::{take, take_by_name}` answer an `Act`; `apply_scripted` takes one
and reports an effect as `NotApplicable` (a headless driver has no dialog to
open). The shell's own raisers — the file menu, the top bar, the history
panel, the palette, the tool cluster's drop — emit `Act` too, so `gather`
hands `App::act` one value and it has two arms: `dispatch_action` for an
action, `perform` for an effect, the latter calling the part that owns the
flow (`Surface`, `Exchange`, `Library`) with no chain and no session in the
way. `Action` is therefore exactly what the core executes: `OpenRolePicker`,
`OpenPinTypePicker`, `Camera`, `Export`, `ExportRev`, `Import`,
`SaveProjection`, `NewDocument`, `RenameDocument`, `PickFile`, `OpenRecent`
and `ImageWanted` are gone from it, `Session::dispatch` returns `()`, and
`Unhandled` with it.

**Behavior change: the image tool is retired, and artwork is picked and then
placed.** Drag-a-box-then-pick is gone as a feature — `crates/tools/src/
new_image.rs` with it, and `ImageTarget`/`Action::ImageWanted`, which were the
last thing the core said to the shell that was not a field of the view it
hands back. Pressing the rail's image cell (or its digit, or Cmd-I, or
dragging the cell onto the canvas) opens the picker straight away, and what
comes back is `Action::PlaceImage { asset }` — no placement, because the
picker knows nothing about the canvas: the session centres the artwork where a
paste lands, at the file's own aspect through the `Placement::Centered` path
that already existed, and leaves it on `ResizeBlock::Selected` so the next
gesture sizes it. The asset-limit refusal stays in the kernel. `ToolName::
NewImage` stays as a name with no tool behind it, the way `ToolName::Icon`
does: it is the verb an image is committed under (`Action::PlaceImage` reads
`ToolName::NewImage.verb()`) and the label audit's `c4_add_image` golden pins
what that verb says. The rail cell is no longer `CommandId::Arm(NewImage)` but
`CommandId::AddImage`, typed the same ("add-image") and carrying the same
digit; `commands::band_command` is the one answer to "what does this cell
invoke", read by the rail, the registry and the chord table.

**A dialog seam, because a suite must not open one.** Making the image cell a
picker turned one existing test into a dialog storm: `drag_out`'s cell scan
presses every cell down the column, and each press on the image cell opened a
real `rfd` window — one test, a window per press, waiting for a person.
`src/dialogs.rs` is the seam: `Dialogs::{Native, Scripted}`, owned by `App`
and handed to the part that opens something. `Native` calls the
`spawn_*_dialog` bodies that already existed; `Scripted` records what it was
asked for (`Asked::{Image, Import, File, Export}`) and answers from a queue a
test filled, an empty queue answering the way a cancel does — it opens
nothing and spawns no thread. `Dialogs::default()` is `Scripted` under
`cfg!(test)`, so the choice cannot be forgotten at one of the twenty-odd
places a test builds an `App`. Probed with a `panic!` in all four spawners:
no test reaches one.

Tests:
- removed: `the_image_tool_asks_for_an_image_fitted_to_the_box_it_drew`
  (`crates/tools/src/authoring_tests.rs`) — with the feature; `NewImage` also
  left the lists in `no_armed_creator_writes_on_a_bare_click` and
  `a_read_only_session_arms_no_authoring_tool`, since it arms nothing to test
- renamed: `dropping_the_image_tool_asks_for_an_image_centred_on_the_drop` →
  `dropping_the_image_cell_asks_for_an_image` (`src/app/tests/drag_out.rs`),
  now driven through the real drag rather than `Surface::dropped`, because with
  the seam in place a dispatch no longer opens a window
- moved: `a_cancelled_image_pick_leaves_the_tool_where_it_was`
  (`src/app/tests/frame.rs` → `src/app/tests/dialogs.rs`), rewritten through
  the real dispatcher: the command opens the pick, the frame polls it, and a
  cancel sends the session nothing at all
- added: `each_shell_command_opens_the_one_dialog_it_is_about`,
  `a_picked_icon_lands_on_the_block_the_command_named`,
  `an_imported_image_lands_through_the_pick_that_answered`
  (`src/app/tests/dialogs.rs`)
- added: `opening_a_container_through_the_picker_attaches_it`
  (`src/app/tests/containers.rs`) — the File ▸ Open door, end to end, which
  had no test because it could not be driven without a dialog
- rewritten in place: the registry and shell tests that matched an `Action`
  now match an `Act` (`take_consumes_the_action`,
  `add_icon_asks_for_an_image_for_the_block_it_was_offered_for`, the top bar's
  `fired` list, the history panel's `Picked`, the tool cluster's cell scan);
  `every_toolbar_tool_is_bound_and_no_chord_is_shared`,
  `every_id`, `base_band_tools_map_to_themselves`,
  `overlay_armed_tools_map_to_select`, `every_tool_on_the_band_has_a_spelling`
  and `toolbar_tools_round_trip_through_from_name` learned that one band cell
  arms nothing
- `a_place_image_command_lands_the_image` (`crates/kernel/src/tests.rs`) now
  asserts the image lands centred on the paste target at the artwork's aspect

No golden regenerated: 1167 tests (1164 − 1 removed + 4 new), `cargo xtask ci
--no-snapshots` green in 155 s (47 s fully warm).

**Phase 9i — a command in, a result in the slot (2026-09-11).** Phase 9h made
the export a shell effect, which put document work above the editor:
`src/exchange.rs` read the session synchronously to build an `ExportContent`,
so the shell had to know how a diagram is rendered and how a rev is folded
before it could offer a save dialog. The rule now: **the shell never queries
the editor.** It sends a command, and at some point the editor leaves the
result in one pass-back slot the shell polls.

`Action::Export { format, selection }` and `Action::ExportRev { at, to }` are
core actions again, raised as `Act::Edit` by the registry, by the top bar's
Export menu and by the history row's Copy / Export rev…; `Effect` loses its two
export variants and is now exactly the dialogs, the pickers and the document
doors. `Session::dispatch` takes `layout: &dyn TextLayout` and executes them
through the new `crates/kernel/src/export.rs`, which is `exchange.rs`'s
`export_content`/`export_pdf`/`export_source`/`selection_repo` moved down
unchanged. The kernel gains `blockworx-export` as a normal dependency and the
headless gate stays green, because nothing in the export names a toolkit.

**The slot.** `Session` holds `handoff: Option<Handoff>` where it held
`clipboard: Option<String>`, and `Handoff` is
`Export { content, name } | Clipboard(String)` — one channel for every kind of
result, so a new kind travels the way these two already do, and a kernel that
computes one off the frame fills the slot when the work lands without the poll
on the other side changing. `Session::take_handoff` drains it and `View`
carries `handoff`, filled from `take_handoff` on every `kernel()` call;
`take_clipboard` and `put_on_clipboard` are gone and `hands_back(Handoff)` is
the one writer. One result at a time, because every command that answers is a
menu press or a keystroke and a frame carries one.

**The shell polls.** `App::poll` ends with `hand_off`, which takes the slot and
gives a `Handoff::Export` to `Dialogs::export` (the save dialog / browser
download, unchanged) and a `Handoff::Clipboard` to `ctx.copy_text`. It runs
after everything that could have filled the slot, and it runs whether or not
this frame dispatched anything — which is what lets a later, threaded answer
arrive with no change here.

**The PDF's sheet inputs travel in session state, not in the action**, because
the registry builds the export command and knows nothing about the container's
name or the user's preferences. `Sheet { name, block, scheme }` moved into the
kernel and is a field on `Session`, stated by the shell —
`App::dispatch_action` assigns `self.sheet()` before every dispatch — the way
`sees` states the camera and `ticks` states the clock. `Sheet::font` is gone:
the typeface is the text engine's own (`TextLayout::typeface`), and a second
statement of it would be a second source of truth for the glyphs an export
places. `Appearance::layout()` holds the shell's `EpaintLayout`, rebuilt only
when the preferred typeface changes, because standing one up parses a font and
fills an atlas.

Tests:
- added: `an_export_command_hands_back_the_svg`,
  `an_export_rev_command_hands_back_that_rev` (`crates/kernel/src/tests.rs`) —
  the first asserts the view's slot carries SVG named after the document and
  that the slot is empty afterwards; the second that the rev export is stamped
  with the rev asked for, named `<doc>-r1`, and carries that rev's fold rather
  than the head's. `command` now answers the `View` it drove
- added: `an_export_reaches_the_save_dialog_through_the_hand_off`
  (`src/app/tests/dialogs.rs`) — the export case split out of
  `each_shell_command_opens_the_one_dialog_it_is_about`, which is about the
  shell's own commands and an export is no longer one: it dispatches the
  action, asserts no dialog opened on the dispatch, and asserts the save dialog
  opened on the poll
- rewritten in place: `copy_rev`
  (`src/app/tests/provenance.rs`) dispatches `Action::ExportRev`;
  `export_writes_the_rev_on_the_canvas` (`src/app/tests/time_machine.rs`) and
  `a_json_export_imports_back_through_the_real_dispatch`
  (`src/app/tests/containers.rs`) read the bytes off the scripted save dialog
  through a new `exported` helper instead of calling `export_content`; the
  history panel's `fired` and its Export-rev case match `Act::Edit`
- `copied` (`src/app/tests/mod.rs`) runs the frame's poll before reading the
  frame's output, so the eight clipboard tests still read the clipboard through
  the whole path; `Scripted` keeps the `ExportPayload` it was handed, which is
  what lets a test read exported bytes with no disk in sight

No golden regenerated, kittest snapshots untouched: 1170 tests (1167 + 3),
`cargo xtask ci --no-snapshots` green in 45 s (warm).

**Phase 9j — time and the viewport are events (2026-09-11).** The kernel took
`events`, and then took `tick: Tick` and `viewport: Rect` beside them: two ways
to say one thing, and the one way is the batch. The rule now: **everything the
front end says is an event, time and the viewport included.**

```rust
pub enum Event {
    Pointer(Interaction),
    Action(Action),
    Command(CommandId),
    Tick(Tick),
    Viewport(Rect),
}

pub fn kernel(session: &mut Session, events: Vec<Event>, layout: &impl TextLayout) -> View
```

A batch is one frame: `kernel` takes the clock, the viewport and the pointer off
it in the order the front end said them, so a `Tick` ahead of a `Pointer` dates
that pointer. Nothing is owed every call — a batch with no `Tick` is a batch time
did not pass during, and one with no `Viewport` runs at the size the surface was
last stated to be, because `Session::ticks` and `Session::sees` already hold
both. Those two stay exactly what they were, the internal setters the events land
in; what left is `kernel`'s two parameters and the `pointer_interaction` scan,
replaced by one `observe` pass that applies the batch's preamble and answers the
frame's `Interaction`.

**`Tick { now, predicted_dt }` stays the typed clock, and the prediction is
optional rather than absent.** A host with a frame rate of its own states it
(`Tick::predicting(now, predicted_dt)` — what `blockworx-egui`'s `Animator` reads
off `egui::InputState`, unchanged, so the easing table answers bit-identically
under the shell); a front end that knows only what time it is says `Tick::at(now)`
and `Session::ticks` fills the prediction in from the interval since the last
tick (`Tick::after`), which is what egui derives its own from. The fields are
private behind `now()`/`predicted_dt()` so an unstated prediction is a state the
type carries rather than a zero a call site has to mean something by, and
`Easing::animate`'s arithmetic is untouched.

Deviation from the plan: **the egui shell has no per-frame batch to put a `Tick`
and a `Viewport` in front of.** It drives `Session` directly — `canvas_frame`,
`dispatch`, `record_history` — and reads egui's clock in `Painter`'s `Animator`;
`kernel()`'s only callers are its own tests. So `App` and `Surface` are untouched
and the shell's behavior is unchanged by construction. Finding while establishing
that: the shell has never called `Session::ticks`, so `Session::now()` is zero
under it and the spotlight ring's fade is dated from zero — the ring is
effectively invisible after the first `HOLD + FADE` of a run. Stating the clock
there is a behavior change, so it belongs to the branch that teaches the shell to
consume `View`, not here.

Tests (`crates/kernel/src/tests.rs`):
- rewritten mechanically: every existing call now sends `batch(millis, events)`,
  which puts `Event::Tick(at(millis))` and `Event::Viewport(VIEWPORT)` in front
  of what happened; `a_handle_at_rest` factors out the resize selection and the
  frame at rest that three tests now share, so
  `an_affordance_grows_across_two_ticks_of_the_same_hover` keeps its name and its
  assertions
- added: `time_does_not_advance_without_a_tick` — an easing in flight, then a
  batch carrying only the hover: the clock stands at 32 ms and the frame's paints
  are the previous frame's exactly
- added: `a_tick_before_a_pointer_dates_it` — three sessions over one scene. A
  tick and a hover in one batch grow the handle exactly as the same tick sent in
  its own earlier call does, and differently from a hover that no tick preceded

No golden regenerated, kittest snapshots untouched: 1172 tests (1170 + 2),
`cargo xtask ci --no-snapshots` green in 178 s, 145 s of which is the doc
step.

## State at the end of the branch

**The crate graph as landed**, each crate's `blockworx-` normal dependencies
read off `cargo tree`:

```text
blockworx-doc      —
blockworx-geom     doc
blockworx-paint    doc, geom
blockworx-store    doc
blockworx-router   doc, geom
blockworx-editor   doc, geom, paint, router, store
blockworx-tools    doc, geom, paint, router, store, editor
blockworx-export   doc, geom, paint, router, store, editor
blockworx-kernel   doc, geom, paint, router, store, editor, tools, export
blockworx-egui     doc, geom, paint
blockworx          all ten
```

Eleven crates where there was one. **1163 tests**, from a baseline of 1139.
Twenty-two of the twenty-four added are new coverage (geom/paint equivalence,
the epaint layout pin, the kernel scenarios); the other two, and the three they
replace, are Phase 9c's move of the image pick from a tool's channel to a pair
of actions — the one place on the branch where a test changed shape, and
nothing else did in ten phases. No golden was ever regenerated.
`cargo xtask ci --no-snapshots` runs in **42 s** with the shell touched, 14 s
fully warm.

**The residue.** `grep -rn egui src | wc -l` is 984 lines across 32 files:

| Group | Lines |
|---|---|
| `shell/*` — the chrome (`top_bar` 114, `glass` 94, `navigator` 38, `tool_cluster` 33, `mod` 19, `toast` 16, `status_line` 13, `picture` 7, `insets` 2) | 336 |
| `panels/*` — the widget panels (`overlay` 98, `history_panel` 87, `painted` 64, `nav_tree` 57, `chrome` 18, `palette` 15, `notices` 11, and two single lines) | 352 |
| `app.rs` — the frame, the dispatcher and its suites | 193 |
| Pickers and editor windows (`io_pin_picker`, `theme_editor`, `role_picker`, `font_editor`, `preferences_menu`) | 51 |
| I/O glue and entry (`keys`, `main`, `lib`, `import`, `export`, `file`) | 31 |
| Tests and benches (`render_bench`, `tessellation_snapshots`) | 22 |

That is the whole of what a second toolkit has to rewrite, and none of it is
below the shell.

**Follow-ups, in the order they pay.**

- **Teach the egui shell to consume `View`** — `docs/shell-on-kernel-playbook.md`, branch `shell-on-kernel`. The kernel exists and is tested;
  the shell still computes its chrome inline from `App`. Doing this is the
  moment the shell becomes swappable, and it is the next branch.
- **The chrome model into `View`.** History rows, the nav tree, the status line
  and the title block are computed by the widgets that draw them; the available
  commands and the selection bounds already are not. Same shape, six more
  fields.
- **Text-editor edits as events** (`TextChanged`/`Commit`/`Cancel`), which
  retires the `Rc<RefCell<String>>` shared with egui's widget — the second of
  the three immediate-mode leaks, and the last one still standing.
- **World and screen as types.** Both are `Pos2`; only lengths are typed. The
  `.egui()` call sites are exactly the crossings a `Screen<T>` would anchor on.
- **`blockworx-edit`** as its own crate (D6).

**Question 10, "is the code paradigm-independent?" — yes, and the kernel spike
is the proof rather than the argument.** `kernel(&mut Session, events, &impl
TextLayout) -> View` runs the *same* `Session::canvas_frame`
the egui shell runs, over a `Recording` canvas that appends to a `Vec<Paint>`
instead of drawing, and the existing test scenes drive it: a tool arms, a drag
writes a block, the block is painted the following frame, an undo restores the
document, a hover answers a cursor, and a resize handle's growth differs across
two ticks of one hover. A retained-mode host is that recorder plus a diff — it
calls the same function, keeps the display list, and reconciles. The three
immediate-mode habits the core had are gone as habits: the easing table is the
session's and is driven by a `Tick` the host supplies (so every backend
animates identically), `request_repaint` is a flag on the output rather than a
call into a context, and text metrics arrive as a `TextLayout` the host owns —
which is inherent, not a leak, since the export needs the same seam (D2). What
is *not* paradigm-independent is the chrome, and that was never claimed: it is
written as egui widgets and will be rewritten per toolkit. The honest statement
is the one the crate graph makes and CI checks: 981 lines of one crate know
what a toolkit is, and the ten crates under it do not.

### Kernel: what still lives in the shell

> **Read as the egui shell's list, 2026-09-18.** That shell is deleted
> (`docs/retire-egui-playbook.md`); the *division* below is what carried over —
> each item is still the front end's rather than the kernel's — but the web
> shell answers each its own way: origin storage and a file input for the file
> doors, a download or the clipboard for delivery, its own overlay for the
> pickers, `localStorage` for preferences. Its shape is in
> `docs/dioxus-web-shell-playbook.md`.

One line each, and why. The `shell-on-kernel` branch
(`docs/shell-on-kernel-playbook.md`) moved the first four down: the camera
is the session's (`Session.camera`, `Event::Move`), the pointer is resolved
in the kernel (`pointer::Resolver` over `Event::Pointer(Raw)`), the in-place
edit is the session's (`Editing`, `Event::Text`, painted by the recorder),
and the chrome model is on `View` (`blockworx_kernel::chrome`). The egui
`View` keeps only the canvas rect, the gesture reading, the invisible
`TextEdit` capture, the grid and the replay. *Since `ui-ux-split-completion`
(`docs/ui-kernel-split-completion-playbook.md`): the editor is the front
end's — a visible field over the diagram, asked for by `View.edit_text` and
answered with `Event::Text` (E3) — so the session keeps no `Editing` and the
invisible `TextEdit` is gone; what a call produces leaves in the
`View.handoffs` list (E2).* What is still the shell's:

- **The commands the shell performs itself.** `PickFile`, `OpenRecent`,
  `NewDocument`, `RenameDocument`, `Import`, `AddImage`, `AddIcon` — rfd plus
  an `egui::Context` (behind `Dialogs`, so a test answers a picker rather than
  opening one), and the receivers polled each frame. The registry offers each
  one and says what it is *on*; everything else about it is this side's, what a
  cancel means included, and what a flow ends in comes in as an ordinary
  command (`SetIcon`, `PlaceImage`). Nothing below the shell waits on a
  dialog, and nothing below it is asked a question.
- **Export delivery.** `spawn_export`, and the status line and toast it writes
  to. The rendering is the editor's own (`Action::Export`); what arrives here
  is bytes and a name, out of `View.handoffs`.
- **`SaveProjection`.** Needs the notices list, which the `Library` owns
  along with every other fact about the files.
- **The pickers and popups.** `Accent`/`PinType` open egui popups anchored on
  the overlay corner the chrome measured.
- **The clipboard.** The session leaves the JSON in `View.handoffs`; putting
  it on the system clipboard is `ctx.copy_text`.
- **`claim_if_written`.** A born container joins the recent list when its first
  record lands; `unclaimed` and `recent` are the `Library`'s.
- **Identity and preferences persistence.** Read from and written to the eframe
  storage DB by `Appearance`, `Surface` and `Library`, a key each; the
  `Identity` value itself is the session's.
- **The theme.** `Appearance` owns it; the session is told its palette by
  the command `Action::SetPalette` when it changes (E6), and the widgets
  draw from the appearance's, the diagram from the session's.
