Collaborative document rewrite — branch `collab-document-log`.
Worklog and phase status live in `docs/collab-migration-playbook.md`
(design spec: `docs/collab-architecture.md`). Do not duplicate the phase
checklist here; the playbook is the single source of truth for it.
- [x] Phase 0 — branch cut, playbook and spec committed.
- [x] Phase 1 — `src/log/` engine core: ids and the total write order, command
      vocabulary, change envelope + hashing, LWW fold, change DAG, tagged
      encoding with golden bytes, and the proptest convergence suite.
- [x] Mutation inventory — `docs/document_mutations.md`: every editor edit,
      CRUD-classified, with UI-side vs on-disk parameter types and the systemic
      commit-time side effects (waypoint promotion/trim, label re-anchoring,
      block growth). The input to phase 5's command vocabulary.
- [x] `src/doc_ng/` — second-generation document model intended to replace
      `src/log/`'s: registers carry their stamps inline, write vocabularies are
      per-entity-kind enums of `Swap { old, new }` pairs (path/value mismatch
      and old/new variant mismatch both unrepresentable), and the model is
      Option-free under the zero-value principle. Types only so far; no fold.
- [x] Re-scope (2026-08-11) — playbook rewritten: Layer A (command log, undo,
      storage, swap) proceeds; Layer B (merge, sync, leases) deferred behind a
      build-vs-adopt spike against Loro/automerge. Model collapse decided:
      `doc_ng` survives, `src/log` ports onto it (phase 2) and is then
      deleted. Phase-1 review findings re-triaged in the playbook; spec
      deviations recorded in `docs/collab-architecture.md`.
- [x] Port playbook + doc_ng restructure (2026-08-11) — the manual
      step-by-step guide for phase 2 is `docs/doc-ng-port-playbook.md` (14
      commit-sized steps, each with rationale and proof obligations).
      `src/doc_ng` split one-load-bearing-type-per-module (`register`,
      `document`, `block_model`, `geometry`, `values`); design essays moved
      from doc comments to `docs/doc-ng-design-notes.md`; canonicality tests
      added for `FracVal` and the value enums. Open question parked: is
      `PinDir`'s meaningful zero `Input` or `InOut`?
- [x] Ordering decisions (2026-08-11) — `WriteOrder`/`BatchIndex` dropped
      from the design: changes are canonical net diffs (map-keyed builder,
      commands sorted at seal, decode refuses duplicates), registers stay
      keyed by bare `Stamp`, and z-order ceases to be document state — draw
      order becomes a derived chronological policy (max register stamp,
      longest-route-first tie, id) with the router iterating in id order.
      Port playbook steps 1/3 rewritten; migration playbook and design
      notes updated.
- [x] Total order restored + nomenclature (2026-08-12) — net-diff reversed
      after working the stamp-granularity argument: `WriteOrder { stamp,
      seq: Seq }` keys registers (self-contained semilattice); coalescing
      is builder hygiene only. Renames: `Commit`/`CommitHash` (commit.rs),
      `OpCodes` with bundled ids + `Crud<I, U>` (opcode.rs), `*Update`
      vocabularies + inits in operands.rs, Seq newtype (write_order.rs);
      command.rs retired. Ordering tests transplanted (stamp, write-order,
      register). Open in the parking lot: authored vs derived `old` on the
      wire (decide before builder/Invert steps).
- [x] The pivot (2026-08-13) — server-centric replaces local-first: a
      central server owns the log and the folded document, sequences every
      changeset by arrival (`Rev`), folds + validates at ingress; clients
      are edit surfaces (optimistic prediction, client-owned undo).
      Deleted from the design: Lamport clocks, `ActorId`/`Stamp`,
      `CommitHash`/content addressing/parents, the DAG, the Loro/automerge
      spike, offline editing. Kept: LWW registers ("FWW" in the pivot
      request was a slip — confirmed), total `WriteOrder { rev, seq }`,
      ChangeSets (née `Commit` — renamed), the fold/cache/Invert/draw-order
      machinery. New: WebSocket transport (axum server, SQLite changeset
      store, `blockworx-server <file.db>`), three-crate workspace
      (doc/server/client), `Host` + `ClientSession` reconciliation model.
      Spec (`collab-architecture.md`) rewritten; migration playbook
      rewritten (phases 2–8: transplant → workspace split → server →
      client session → editor swap → two-client demo + demolition → main);
      port playbook rewritten (14 → 12 steps: clock and DAG steps deleted,
      `Rev` replaces `Stamp`, `Host`/`ClientSession` replace `Replica`,
      convergence suite becomes the reconciliation suite). `Swap.old`
      parking-lot item resolved the other way after review: `old` never
      crosses the wire — update ops carry only the new value, `Swap` and
      `Invert` leave the vocabularies, and undo baselines are captured
      into the client's session journal at seal time. Exit criterion:
      two native clients live-editing one document through one server;
      wasm client deferred.
- [x] Port steps 1–2 (2026-08-14) — `Rev` replaces `Stamp`: `rev.rs`
      added (own module, keeping one-type-per-module), `stamp.rs`/
      `lamport.rs`/`ActorId` deleted, `WriteOrder { rev, seq }` re-keyed,
      ordering tests re-based. The `Commit` envelope: `Commit { label,
      ops }` with private fields + accessors, `CommitEnvelope::CommitV1`
      versioned wrapper; `CommitHash`/`parents`/`version`/`wall_time`
      deleted (rev + wall time are server-assigned, outside the payload);
      chrono out of the module. Decisions: **`Commit` naming stands**
      (the 2026-08-13 `ChangeSet` rename reversed before any code carried
      it; docs swept back); `order_of` dropped (the fold mints
      `WriteOrder { rev, seq: i }` inline — it is that rule's only
      consumer); **no in-commit write coalescing** — `paths.rs` deleted
      (the register catalog's consumers were coalescing keys and
      collision naming, both gone); `CommitBuilder` deferred until
      sealing has a real caller.
- [x] Port step 3 (2026-08-14) — `Swap` stripped from the wire: update
      variants carry only the new value (`Rect(Swap<GridRect>)` →
      `Rect(GridRect)`); `Swap` and the `Invert` trait deleted from
      operands.rs. `Crud::invert` narrowed to
      `invert_lifecycle() -> Option<Crud>` — `Create`/`Restore` →
      `Delete`, `Delete` → `Restore`, `None` for `Update` (its inverse
      needs the displaced value, which only the client journal holds;
      captured at seal time, lands with `ClientSession` in step 8).
      Asymmetry tests in opcode.rs; journal round-trip proof deferred to
      steps 8/10.
- [x] Port step 4 (2026-08-14) — the entity trait (`entity.rs`):
      `from_init` + `apply` for all seven entity kinds plus the
      `Document` singleton (`Init = ()`; never created by an op) and
      `Label` as the namespace-descent helper; exhaustive matches are the
      compile-time mirror guard between registers, update variants, and
      init fields. `Waypoint` re-homed to geometry.rs;
      `Register::new(value, order)` added (from_init writes at the create
      op's order — BOTTOM stays reserved for the pre-creation zero);
      `Icon`/`ScreenRect` gained PartialEq. Proof tests walk values as
      well as orders (caught a dropped `init.icon`), pin descent
      targeting (Title vs TypeLabel), and the LWW refusal path. The
      `PinDir` zero question narrowed: total inits mean the default never
      appears in a created pin — parked, no longer gating.
- [x] Port step 5, fold half (2026-08-15) — the atomic fold on an
      immutable interior. `Document`'s fields went private
      (`HashMap<Id, Arc<Live<T>>>`; readers get `&Live<T>` through
      getters/iterators, `Arc` never escapes) and the fold is pure:
      `try_apply(&self, commit) -> Result<Document, FoldError>` clones,
      folds with entity-level copy-on-write (`Arc::make_mut`), and
      returns the successor — an `Err` drops the clone, so a
      partially-applied document is unrepresentable and trial-fold *is*
      the ingress check for structural errors. The head `Rev` moved into
      `Document`, minted only on success (`Rev::new` is test-only):
      refusals consume no rev, snapshots are self-describing, and the
      wire rev demotes to the sync layer's contiguity assertion.
      `content_hash()` (blake3 over id-sorted CBOR; ciborium added) is
      the divergence check — rev compares versions cheaply, the hash
      proves contents match the server's fold. Deviations recorded in
      the playbook: duplicate `Create` refuses (fold-level re-apply
      idempotence ceded to the session's contiguity check); tombstoned
      entities absorb updates unconditionally (a liveness gate
      contradicted delete-wins-structural). Still open in step 5:
      `validate`, and the remaining fold proofs.
- [x] Port step 5, validate half (2026-08-15) — validation folded into
      `try_apply`, run once at commit end over the successor: payload
      references (pin/route/text/comment/image owners, route endpoints,
      block parents) must resolve in the final view, so op order within a
      commit is free for references (a pin may precede its owner's
      create); the containment-cycle walk refuses parent loops
      (`FoldError::BlockCycle`). Checks are map presence — "ever
      existed", tombstones included — per absorb-don't-reject; post-apply
      checking is sound because `Err` discards the whole successor.
      Value access API swept: `Register::get` deleted for `AsRef` on
      `Register` and `Live`. Tests: order-free reference resolution,
      orphan-pin and owner-update refusals, and the cycle triple
      (cross-commit, one-commit swap, self-parent). Step 5's donor
      proofs remain open.
- [x] Port step 5 ticked (2026-08-15) — donor proofs ported or re-based
      in `document.rs` tests: seq end-to-end (a twice-written register
      ends on its last write at the last op's order), later-rev
      displaces with untouched orders stable (the either-order property
      under minted revs), delete-tombstones-but-restorable,
      edit-after-delete absorbs into the inner and surfaces on restore,
      and replay-is-a-function-of-the-log (two replays agree by
      `content_hash`). Mutation ritual run — five kills (inverted LWW,
      dropped writes, seq ignored, cycle walk disabled, liveness-gated
      updates), each caught by the suite and reverted; kill counts
      recorded in the playbook. Next: step 6, `DocumentCache`.
- [x] Port step 6, rebuild half (2026-08-15) — the rebuild oracle:
      `From<&Document> for DocumentCache`, single-pass over live
      entities. Suppression landed here (not deferred) and is decided by
      effective liveness — an alive route with a *tombstoned* endpoint
      pin; the presence-based first draft made `suppressed` always-empty
      (entries never leave the maps) and the suppression test now pins
      the distinction. Live children of tombstoned owners are indexed
      nowhere; suppressed routes stay indexed (a flag, not removal).
      Tests: every-kind indexing + containment tree, live-children-only
      (donor `state.rs`), suppression + revive-on-restore. Open in step
      6: incremental maintenance hooks and the `incremental == rebuild`
      oracle property.
- [x] Step 6 re-scoped and closed (2026-08-15) — whole-document
      `DocumentCache` + incremental maintenance dropped (user decision):
      the app reads at block scope, so the derived view is
      `Document::block_cache(id)` → `BlockCache<'a>`, built on demand in
      one linear pass and borrowing the immutable document — it cannot
      drift from the document it borrows; the only hazard is consulting
      a cache from a different document, the wrong-document mistake
      class. `Id::NULL` is the root scope (top-level blocks). The oracle
      property dies with the incremental machinery; donor query
      semantics survive as direct tests (live-children-only, no cache
      for tombstoned blocks, suppression + revive). Special-purpose
      caches for broader queries when they arise.
- [x] Step 6 settled (2026-08-15) — `Document::cache()` →
      `DocumentCache<'a>`: whole-document reverse indexes (top-level,
      per-block members, per-route labels, suppressed) built on demand
      in one linear pass, borrowing the document; client builds once per
      frame and queries any scope, server never builds one. Supersedes
      the same-day block-scoped `BlockCache` (a scope's render needs
      child blocks' pins, so per-scope calls multiply per frame; one
      whole-document pass serves every scope at the same cost). Parking
      lot: route-owner/endpoint-scope validation gap.
- [x] Step 7 spec corrected (2026-08-15) — the routing solver consumes
      the chronological order too (routes place first-come-first-served,
      priority is age), reversing the earlier solver-iterates-in-id-order
      rule; and tie-breaks are unnecessary (`WriteOrder` is total over
      ops, one entity per op, so max orders are distinct) —
      longest-route-first deleted, `id` kept only as a stable final key.
      Accepted consequence: editing a route re-places it at the back of
      the placement queue. Design notes + playbook updated; `max_order`
      itself not yet implemented.
- [x] Port step 7 (2026-08-15) — `Entity::max_order` (fold over every
      register, `BOTTOM`-seeded; namespaces fold through `Label`;
      creation constants excluded) and `Live::max_order` (presence
      joined with the inner). Proofs: per-kind value walks over every
      update variant at distinct ascending orders (variant coverage is
      register coverage — the vocabularies mirror registers 1:1),
      presence raises through delete/restore, untouched entities tie at
      creation. Ritual kill: a register dropped from `Pin::max_order`
      failed exactly the pin walk; reverted. Comparator + consumers are
      editor-swap work. Next: step 8, `Host`/`ClientSession` + journal.
- [x] The `entity!` macro (2026-08-16) — one field list per kind
      (`block_model.rs` invocations; macro in `entity.rs`) generates the
      struct, its total `*Init`, its `*Update` enum, and the `Entity`
      impl: the four-shape mirror is generation, not transcription, so
      the dropped-init and max_order-drift hazards are unrepresentable.
      Wire tags stay explicit in the DSL (`FlipLR`, never derived).
      ~540 lines of hand-synced code across three files became eight
      invocations + one macro; `operands.rs` shrank to `DocumentUpdate`.
      All 46 tests pass unchanged (the walks now prove the template).
- [x] `TitleBlock` (2026-08-16) — the singleton's registers promoted to
      a generated entity (`entity!` with `id ()` in `document.rs`, named
      for a mechanical drawing's documentation block; `Name` today, more
      fields later). The fold's last hand-written register match becomes
      generated dispatch, `content_hash` covers future title-block
      registers automatically, and `operands.rs` is deleted. What stays
      special is semantic, not plumbing: `OpCodes::Document` is
      update-only (no id, no `Crud`, no `Live`) because the document
      cannot be created, deleted, or raced.
- [x] Step 8 spec corrected (2026-08-16) — re-specified for the
      rev-in-document model before writing `session.rs`. Four things the
      step carried are gone: `Host.rev` (= `state.rev()`),
      `ClientSession.confirmed_rev` (= `confirmed.rev()`),
      `RejectReason` (`FoldError` is it; the wire carries its
      `Display`), and "validate → assign → fold" as three host-side
      steps (one `try_apply`, so refusal-sequences-nothing is a property
      of dropping the clone). `Host` keeps the one thing that cannot
      live in the document — the log, a `Vec` precisely because a
      refused commit consumes no rev, so `log[i]` is `Rev(i+1)` (user
      decision). Provisional revs stop being arithmetic: the rebuild's
      successive folds mint them. New hazard recorded: `optimistic`'s
      rev (and content hash) is provisional and must never leave the
      session. Decision (user): a pending commit that no longer folds —
      reachable only via a foreign reparent making a pending reparent a
      cycle — is **skipped by the rebuild with `pending` untouched**; it
      is never re-submitted, and per-connection FIFO makes the local
      prediction agree with the server's verdict before it arrives.
      Journal mechanism pinned: `entity!` generates `invert`, and the
      commit inverse walks the pre-image forward (skipping ops whose
      target it lacks) then reverses. `Nonce(u64)` parked question
      closed. Design notes' §6 and the migration playbook's stale
      `fold::apply`/`validate` diagram swept to match.
- [x] Port step 8 (2026-08-16) — `session.rs`: `Host` (document + log,
      `ingest` = one `try_apply`), `ClientSession`
      (confirmed/pending/optimistic + undo journal), `Nonce`,
      `SessionError`. `Entity::invert` generated by `entity!` from the
      same variant/field pair as `apply`; `Crud::invert_lifecycle` takes
      `&self`. `Optimistic(Document)` is a newtype (user decision): no
      `Serialize`, and closure-only access whose higher-ranked bound
      stops the borrow escaping — a provisional rev cannot be snapshotted
      or mistaken for a confirmed one. Rejected the alternative of
      `try_apply -> (Document, Commit)`: three of four fold sites would
      carry an undo concern; the real hazard (baseline from `confirmed`,
      fold on `optimistic`) is closed by one shared `pre_image` binding.
      Ritual, four kills: inverted `invert` → 6 failures; rejection not
      discarding its journal entry → 1; rebuild dropping all pending →
      2; and **`ops.reverse()` deleted → nothing failed**. That last one
      exposed a wrong rationale inherited from the donor — repeated
      writes to one register are order-independent under LWW-by-seq, and
      what actually needs the reversal is a lifecycle pair in one commit
      (`[Create X, Delete X]` inverting to `[Delete, Restore]` would
      resurrect X). Test added, playbook and step 3's donor note
      corrected.
- [x] `Document<R: RevKind>` (2026-08-16) — the document head's *kind*
      became part of its type, superseding the `Optimistic(Document)`
      newtype landed the same day (user proposal, and the better
      answer). `Confirmed(Rev)` reports its log position;
      `Provisional(Rev)` is scratch — no accessor, no `Deserialize` — so
      "report a prediction's rev" and "snapshot a prediction" are
      unwritable rather than merely discouraged, and a
      `Document<Provisional>` cannot stand in for the authority's copy.
      Both compile failures verified, not assumed. Only the head is
      typed: `WriteOrder`/`Register` stay monomorphic because a
      provisional write order must remain comparable to a confirmed one
      — that comparison *is* the suppression rule. `content_hash` covers
      `minting()`, not the kind, so a drained prediction hashes equal to
      the document it drained to (the useful cross-kind check; whether
      it is meaningful is `pending.is_empty()`, which no type carries).
      `predict()` is the one conversion, per rebuild by design. Timing
      drove it: `Document` had two consumers today and will have every
      tool and widget after the phase-3 editor swap — now or never, not
      now or later. Wart: `Document::rev()` collides with `Iterator::rev`
      in diagnostics ("not an iterator"); rename to `head()` if it
      grates. Also dropped `Serialize` from `Provisional` (unused since
      `content_hash` switched to `minting()`), which makes the
      confinement automatic: a plain `#[derive(Serialize)]` on
      `Document<R>` carries `where R: Serialize`, so a prediction is
      unserializable with no hand-written impl. Verified, then reverted
      — nothing needs the derive until phase 4's snapshots.
      Next: step 9, encoding.
- [x] Port step 9 (2026-08-16) — `encode.rs`: CBOR (`ciborium`)
      `to_bytes`/`from_bytes` over `CommitEnvelope`, **CBOR on the wire**
      (user decision), one codec for wire and log. Considered and
      declined: a hand-rolled deterministic codec generated off `Entity`
      — the determinism argument died at the pivot (nothing is hashed,
      so bytes stopped being identities), a positional format would make
      *reordering* an `entity!` field list silently reinterpret stored
      payloads, `entity!` covers only Init/Update (~20 leaf codecs plus
      the envelope would stay hand-written), and the decoder is the trust
      boundary where CLAUDE.md's prefer-crates rule applies hardest.
      Findings worth keeping: probing the bytes showed `Id`/`Hash`
      encoded their `PhantomData` kind tag as a trailing `null` on every
      id — fixed with `#[serde(transparent)]` + `skip`, before goldens
      pinned it; and ciborium **accepts trailing bytes**, so `from_bytes`
      now checks the reader is drained (the "free but must be tested"
      category catching a real gap immediately). Decode bounds:
      `GRID_LIMIT = 2^20`, `FracVal::LIMIT = 2^48`, via
      `#[serde(try_from)]`, refused never clamped; the authoring side
      must respect them too. Hex serialization for `AssetHash` does not
      port (it existed for JSON legibility). Goldens pin a *prefix* of an
      append-only `every_op()` so adding a variant cuts a new golden
      rather than regenerating v1; a `tag()` match makes a new variant a
      compile error first. Ritual, three kills: `rename_all` on update
      enums → only the golden failed (the round trip passed, which is the
      entire argument for goldens); reverting `Id`'s transparency → the
      golden failed; dropping the coordinate bound → the extent test
      failed. Open for phase 4: whether the SQLite payload column holds
      CBOR or JSON — binary rows kill the sqlite3/ripgrep greppability
      goal, and JSON there costs no second codec since both formats drive
      the same derived impls. Next: step 10, the reconciliation suite.
- [x] Port step 10 (2026-08-16) — `reconcile.rs`: one `Host`, three
      `ClientSession`s, per-client inbox/outbox, interleaving randomized
      across queues but never within one. 8 tests (a proptest over ≤80
      steps + seven scenarios). Fixtures consolidated into `fixtures.rs`.
      **The first ritual kill exposed a real hole**: inverting
      `Register::apply` failed six scenarios but the *proptest passed* —
      convergence and the prediction oracle are both blind to the merge
      rule, since everyone folds one canonical linearization and the
      oracle rebuilds with the same fold (`f(x) == f(x)`, the donor
      suite's original defect, reappearing even though the randomness had
      moved to the optimistic layer). Fixed with `assert_log_semantics`:
      replay the host log with dumb sequential last-wins bookkeeping (no
      `WriteOrder`, no `Register`) and require the fold to agree — an
      independent implementation of the merge rule. After the fix all
      four kills bite: inverted LWW → 7/8; dropped writes → 7/8;
      `Seq::FIRST` for every op → exactly the two seq-sensitive tests;
      rebuild skipping a pending commit → the oracle + the suppression
      scenario. Idempotence restated (fold-level re-apply died in step 5;
      it lives in the register and the contiguity check) and rejection
      safety provoked deliberately rather than randomly, so the random
      pool stays always-valid and the oracle's pending fold stays
      unconditional. Next: step 11, module docs.
- [x] Port step 11 (2026-08-16) — `doc_ng/mod.rs` gets the ported front
      door (the one-way layering plus five load-bearing rules, restated
      for server ordering: order from a process not a protocol; the fold
      pure and the only writer; nothing derived in the log; unknown
      encodings refused at what is now a production trust boundary;
      global invariants as ingress preconditions). Added beyond the
      donor: predictions are a different type, and per-kind structure is
      generated. The donor's `allow(dead_code, unused_imports)` did not
      port. Comment sweep cut ~a third of what steps 8-10 introduced —
      decision narration the playbook already carries — keeping only what
      a reader would misread as a bug (the skipped `Err` in `rebuild`,
      `invert_crud`'s `None`, the reversal's corrected reason,
      `log[i] == Rev(i+1)`, refuse-don't-clamp). 84 tests pass unchanged.
      Next: step 12, delete `src/log`.
- [x] Port step 12 — demolition (2026-08-16). `src/log/` deleted (9
      files, 3201 lines) and `pub(crate) mod log;` dropped from
      `src/lib.rs`; nothing outside the module referenced it, so no other
      code changed and CI was green either side. **Phase 2 of the
      migration playbook is complete** — all twelve steps, 2026-08-14 →
      08-16. Donor citations in both playbooks now resolve in git history
      and are kept deliberately: they record what was ported from where.
      Decisions absorbed into the migration playbook: CBOR on the wire,
      and the still-open question of whether the SQLite payload column
      holds CBOR or JSON (phase 4 decides; binary rows would end the
      sqlite3/ripgrep greppability goal). Still parked: `PinDir`'s
      meaningful zero, and route-owner/endpoint-scope validation.
      Next: phase 3, the workspace split (`crates/doc` + `protocol.rs`).
- [x] Phase 3 — the workspace split (2026-08-16). `src/doc_ng/` moved to
      `crates/doc` (package `blockworx-doc`); intra-crate paths rewritten,
      `mod.rs` became `lib.rs`, the golden's `include_bytes!` and
      regeneration path followed it. `protocol.rs` added
      (`ClientMsg`/`ServerMsg`/`Nonce`); `Nonce` moved there from
      `session.rs`, closing its parking-lot entry. Shared deps hoisted to
      `[workspace.dependencies]` so members cannot drift on a version —
      the doc crate takes `uuid` with `serde` but not `v4`, since the fold
      never mints ids, which keeps uuid's wasm randomness problem out of
      that tree. The reconciliation suite now drives the real `ServerMsg`
      instead of a local stand-in, which put `Welcome` in reach and earned
      a late-joiner test (a client folding the log mid-history lands
      byte-identical with everyone already connected). `cargo xtask ci`
      grew a `headless` step that reads the doc crate's dependency tree
      and fails on egui/eframe/tokio/wgpu/winit — verified by adding egui
      and watching it fail, not merely by watching it pass. 85 tests,
      no behavior change. Next: phase 4, the server.
- [x] Phase 4 — the server (2026-08-16). `crates/server`
      (`blockworx-server`): axum + tokio + rusqlite; one writer task owns
      `(Host, Store, fanout)` and every socket reaches it through a single
      channel, so rev assignment needs no lock and `Welcome` is served
      from the task that appends. **CBOR payloads** (user decision) —
      greppability is consequently unserved until a `blockworx log` dump
      command exists; recorded in the spec rather than dropped.
      `Host::ingest` split into `accept`/`publish` so the server persists
      before publishing: a storage failure becomes an ordinary `Rejected`
      instead of a commit the clients hold and the log does not.
      `encode::to_bytes`/`from_bytes` generalized over `Serialize`, since
      the wire carries protocol messages and the store carries envelopes.
      `Welcome` carries commits without revs (revs follow from position;
      sending them invites a disagreement with no resolution rule) and the
      head rev is the integrity check. Tests: two clients over real
      sockets converge on interleaved edits; a stale client's cycle is
      refused and it re-converges; a late client is welcomed with the log;
      a server on an existing log serves and extends it; restart-and-
      refold, corrupt-row and log-gap startup errors. Writing the
      rejection test found that a client which has *seen* the conflicting
      commit refuses its own edit locally — a server-side rejection needs
      a genuinely stale client, which is the only way that race reaches
      the server at all. Next: phase 5, the client session in the app.
- [x] Phase 5 — the client session (2026-08-16), undo deferred to phase 6.
      `src/collab.rs` holds an `ewebsock` socket and the `ClientSession`
      it feeds: dialled on the first frame (connecting needs an
      `egui::Context` to wake the UI when a commit arrives) and drained
      every frame after. `blockworx --connect ws://.../ws` folds the
      server's log and tracks it live; the window title carries the
      status. **The spec's "fold 5 and 6 together?" call, recorded:
      split, not folded.** The transport is separable and landed; undo
      re-pointing is not, because undo has to change what the user sees
      and the user still sees the legacy document — re-pointing it now
      would submit commits against a document nobody is looking at while
      silently ceasing to undo the one they are. It moves to phase 6's
      commit beside the read-path swap. Dispatch went into
      `ClientSession::deliver` rather than the transport, and the server
      crate's integration tests were rewired onto it — one implementation
      of the protocol, not one per transport. `examples/collab_smoke.rs`
      drives both halves from the console; verified against a live server
      (connect → rev 0, submit → rev 1; a second run started at rev 1,
      proving the log persisted across processes). Parked with incremental
      resume: a second `Welcome` with work in flight is refused rather
      than guessing whether to resubmit, and a decode failure or rev gap
      parks the connection as desynced instead of resyncing.
      Next: phase 6, the editor swap.

- [~] Phase 6 — the editor swap, planned (2026-08-16). The step-by-step
      guide is `docs/editor-swap-playbook.md`: a strangler through the
      `Drawing` waist, in four stages — (A) close the model's five
      authored-state gaps (block role, the pin slot register, the top
      pointer, read-surface indexes/comparator, assets in the log);
      (B) make the waist real on the legacy side under golden protection
      (named setters close all six escape hatches including the
      `pub(super) document` field, derived state moves to `src/derived/`,
      previews stop writing the document); (C) build `CommitBuilder` and
      one pure op emitter per `docs/document_mutations.md` row, plus an
      in-process `LocalHost` and the level-lowering bridge; (D) the
      flag-day series (ids, ownership, undo re-point, projections) and
      the row-by-row coverage sweep. Planning decisions D1-D8 recorded in
      the guide; D5 (serverless boot = in-process host, nothing
      persisted) awaits user ratification.

      Step 1 done (2026-08-16): `Block` gained its `Role` register and
      the two derived pin accents (`pin_accent`, `port_pin_accent`) left
      the `Pin` vocabulary — only the authored `port_accent` remains.
      The decode golden was re-cut (a sanctioned pre-deployment break,
      recorded at the constant) and the retired variants are pinned as
      refusals by a probe that retags `PortAccent`'s real encoding, so
      the assertion survives op-shape changes. New fold test: two role
      writes resolve LWW.

      Step 2 done (2026-08-16): the pin's placement on the block-as-child
      is one atomic register — `slot: PinSlot { side: PinSide, offset:
      u32 }` — so a merge can never land one write's edge under
      another's offset (fold test asserts the pair races whole).
      `flip_lr`'s semantics recorded at the field; the `PinDir`/
      `LabelSide` zero questions closed (inits are total; the zeros
      never reach the wire). Golden re-cut at 65 ops.

      Step 3 done (2026-08-16): the top pointer is a `TitleBlock`
      register (`Top => top: BlockId`, `NULL` = no top yet), so two
      concurrent Wrap Tops converge on the later one's root instead of
      leaving two NULL-parent roots and a tie-break. Validation is
      existence-only (spec §8: stricter checks are breakable by races a
      well-behaved client cannot see). Fold tests: the exact wrap-top
      commit shape folds; an unknown top is refused; `NULL` stays
      legal. `Id` gained a `Default` = `NULL`. Golden re-cut at 66 ops.

      Step 4 done (2026-08-16): the read surface the editor asks for
      every frame. `DocumentCache` gained `routes_by_endpoint` (tested
      against a brute-force oracle, covering the suppressed-route and
      deleted-route rows); the one draw/hit/router order is
      `chronological` — ascending `(max_order, id)`, oldest first, the
      design notes' revised policy (ties cannot occur through the fold;
      the longest-route-first tie-break was deleted 2026-08-15 and the
      sub-playbook's stale quote of it corrected); the legacy
      `coord.rs` rect algebra ported semantics-exact (`contains`
      closed on every edge, `intersects` open on the right, both
      pinned by tests), with `GridVec` deliberately not serializable —
      deltas never cross the wire, structurally.

      Step 6 done (2026-08-17), five commits: every tool/app field-poke
      of the document moved behind named `Drawing` setters, family by
      family — accents/direction/tag/lock (with `InterfaceLock` joining
      `TagVisibility` as a bool's parameter form), the rename family
      (lock guards and widen-to-fit now ride inside the setters;
      `PinPort::widen_to_fit_labels` deduplicates the two tools'
      copies), geometry commits (`add_named_pin` moved out of tools
      with its guard), route labels + interim waypoint setters (step
      8's landing site), and text-box content. The five raw hatches
      demoted to `pub(super)` — compiler-confined to `src/widget/` —
      and a red-tested `waist` CI gate guards the surface. D5 ratified.
      Behavior-preserving throughout: 569 lib tests, outcomes
      unchanged.

      The mutation log (2026-08-18): every named `Drawing` setter
      narrates under the `edit` tracing target — `RUST_LOG=edit=debug`
      for commit-shaped mutations, `=trace` for the per-frame drag
      writers; ids and parameters as fields, payloads never logged. A
      live-capture test proved unfixably racy (tracing caches callsite
      interest process-wide), so the pin is a CI tripwire: the `waist`
      gate fails if drawing.rs's `edit` events drop below a floor.

      Step 7 done (2026-08-17/18), four commits: `src/derived/` owns
      what the document used to smuggle — solver route geometry
      (`RouteGeometry` keyed by route id; the `AutoRouteExt` surface
      split into pure-geometry methods and geometry-taking authored
      ops), fresh per-generation pin-accent propagation (replacing the
      stale-by-design `update_route_roles`; the two derived accent keys
      stop being persisted and self-heal on load), and self-validating
      text extents (`{text, size}`, injected at the `ShapeRef` borrow).
      `Derived` rides every `Drawing` borrow beside the spatial index;
      undo/redo/restore/tutorial-exit rebuild it wholesale via
      `finalize_load`, which now runs where the store's owner is —
      `finish_load` stops materializing, keeping the phase-7-doomed
      schema/storage layers `Derived`-free. Enumerated behavior
      changes: stale persisted accents self-heal; pasted pins shed
      foreign accents; undoing a text edit shows the estimate boundary
      until re-edit. 569 lib tests + 98 doc + 11 server, outcomes
      unchanged.

      Step 8a done (2026-08-18): the preview solve went pure. The old
      "preview" mutated the document heavily — per-frame approach trims,
      backtracking prunes, and `rip_and_reroute_closed`'s
      unlock/retain/dedup of stored waypoints — so an aborted drag had
      already corrupted authored state. Now `RoutePass::Preview` carries
      a `PreviewSpec` (`inside` gesture classifier + rigid `grid_delta`)
      and the trims/prunes are *computed* as `PreviewExclusions` (pure
      twins `approach_waypoint_ids` / `backtracking_waypoints` share the
      policy with the mutating commit-side fns) that the seed loop and
      `reroute_preview_excluding` route around. The pin temp-write dance
      died: `GeometryOverrides` bundles the rect overrides with
      `PinSlotOverride`s, honored at the anchor (`Block::pin_anchor_at`,
      the slot-parameterized core of `anchor_point_with_rect`) and the
      channel seeding. The preview arm takes only immutable borrows of
      the blocks, so the generation stamp itself proves purity — tests
      assert generation-unchanged plus document equality across drag,
      resize, and single/group pin-drag preview frames. Side profit:
      preview frames no longer invalidate the spatial index and accent
      cache every frame. Committed outcomes byte-identical: goldens and
      all 571 lib tests unchanged.

      Step 8b done (2026-08-18): the route editor stopped writing the
      document mid-drag. `RouteEditSession` (widget/routing.rs) plans
      the working corners once on the first `Dragging` frame — the pure
      mirror of the old sequential `add_waypoint` reuse-or-insert, which
      is deleted — and captures label anchors; per frame
      `preview_route_edit` relays the hypothetical corner list into
      derived only (`reconstruct_corners_direct`, the positions-based
      core split out of `reconstruct_route_direct`); on release
      `commit_route_edit(id, session, cursors)` is the edit's one
      document write: apply corners locked, relay, re-anchor labels to
      the *captured* anchors (not per-frame rewrites), promote.
      `pin_waypoint`/`move_waypoint` deleted from the waist. Rendering
      parity: `RouteRenderMode::Editing` (halo without stored
      decorations) plus exported `render_waypoint_handles` /
      `render_text_anchors` / `render_route_label_at` /
      `route_text_color` let the drag overlay draw the working handles
      and pinned labels the document no longer holds mid-drag. The two
      closed-router edit tests now drive the session API and assert the
      mid-drag document holds no waypoints. CI green, 571 lib tests.

      Step 8c done (2026-08-18): the label drag went pure. The slide
      policy is `RouteGeometry::slide_along` (map arc length to world,
      offset, re-project — the deleted `slide_route_label`'s math,
      encoded once); `MoveLabel::Dragging` carries the previewed
      `LinearDistance`, seeded by `MoveLabel::drag_from` (shared with
      select_tool's direct handoff, mirroring `EditRoute::drag_from`);
      the drag overlay suppresses the stored label and draws the
      preview via the 8b render helpers; `DragStopped` commits one
      `place_route_label` write. Enumerated changes: the per-frame
      `update_routes(&[route])` commit pass during a label slide is
      gone (label position never affected wire geometry), and
      move_multi_pin's invalid-placement frames restore route geometry
      with an override-free *preview* pass instead of a commit pass —
      the last per-frame document writers among the drag tools. 574
      lib tests (3 new), CI green.

      Step 8 done — 8d, the proof (2026-08-18): the drag-abort suite,
      `src/tools/drag_abort_tests.rs`. All seven drag flows (block,
      group, resize, pin, multi-pin valid→colliding, route edge, route
      waypoint, label) driven through the real `ToolTrait::widget`
      dispatch on a headless canvas — real `DragStarted` hits
      everywhere except the two states whose entry isn't under test —
      with `Dragging` frames and never a release. Each test asserts
      its preconditions, a non-vacuousness observable (the preview
      really moved derived geometry or tool state), per-frame
      stored-state stability where the old flow wrote (waypoints,
      label distance), and finally document equality plus an
      unchanged generation stamp — which is minted on *any* mutable
      borrow of the blocks, so it proves the preview frames never
      even reached for `&mut`. The suite was validated by injecting
      three writers (a trim, a label commit, and a content-neutral
      mutable borrow) and watching each get caught; the last is
      catchable only by the stamp. 582 lib tests, CI green; playbook
      step 8 ticked.

      Scoped route geometry (2026-08-19) — user-reported regression
      from step 7d: at load and on every scope change the routes drew
      wrong until a click's commit pass fixed them. Root cause: legacy
      ids are minted per block map (`IdMap::insert_value` = max+1), so
      sibling scopes' routes share `RouteId`s, and the flat
      `Derived.routes: HashMap<RouteId, RouteGeometry>` let every
      materialized scope clobber the previous one — `finalize_load`
      materializes block by block, last one wins (the hazard
      `PinAccents` already documented for `PinId`). Fix: `ScopedRoutes`,
      keyed by owning block first (`scope`/`scope_mut`/`get`/
      `remove_scope`); block deletion drops whole scopes, which also
      guards `RectId` reuse. Regression test proves the collision
      precondition and that each sibling scope reads its own wire
      through the real `Drawing` path; verified red under flat-map
      semantics. Interim-only structure: step 12's globally-unique ids
      make a flat map safe again, and the wrapper collapses then. 595
      lib tests, CI green.

      Step 9 done (2026-08-19): `CommitBuilder` in the doc crate —
      `new(label)` / `push` / `extend` / `seal(self) -> Option<Commit>`.
      The donor's seal discipline lands where it was deferred to:
      consumed-on-seal is the signature (a second seal is unwritable),
      an empty builder seals to `None` so a no-op gesture submits
      nothing (the property step 8's one-write-per-gesture drags rely
      on), and push order is `Seq` order, asserted with the label. The
      builder is deliberately blind to the document — non-edit
      filtering stays at step 10's push sites, recorded in the doc
      comment. Port playbook's step-2 open proof obligations closed.
      Next: step 10, the op emitters.

      Step 10 planned (2026-08-19): `docs/op-emitter-playbook.md` —
      seven sub-steps (10a scaffold+exemplars by the session lead, then
      naming/create/geometry/assets/delete/clipboard families delegated
      to Opus sub-agents with reviewed diffs, one commit each). The
      emitter contract is pinned as ground rules E1–E7: pure given
      inputs (solved/measured values are parameters; list arithmetic —
      free slots, growth, trims, translations, cascades — is emitter
      work), pre-minted ids (a `FreshIds` source only for paste),
      push-site non-edit filtering per step 9, absent targets push
      nothing, systemic riders are ordinary ops in the same commit.
      Notable mappings settled in the conversion table: `LineAnchor`
      collapses to `PinId`; `Option<u8>` accents map onto the 9-variant
      `Role`; `LinearDistance` converts to `FracVal`. Deferred step 5
      (assets) closes inside 10e as D7's create-only content-addressed
      payload op. Restore History is marked out of scope — subsumed by
      the log itself, decided at step 12.

      Step 10 done (2026-08-19), seven commits, 10b–10g executed by
      Opus sub-agents against the plan with every diff reviewed and CI
      re-run independently. `src/edit/` now holds every inventory row
      as a pure fold-tested emitter (~45 rows, ~130 new tests): naming
      /flags/text, create (free-slot search + growth, the wire-with-
      fresh-pin rider, wrap-top), geometry (both collision rules
      contrast-pinned, the route riders, flip_lr derivation), assets
      (the doc-crate payload op closing step 5: create-only,
      content-addressed, first-wins fold, golden re-cut 66→68), delete
      (the shared Closure walk, per-pin lock granularity, double-
      delete-bumps-order finding), and clipboard (v2 envelope of init
      structs; move-vs-duplicate derived from document state per spec
      §9 — all-tombstoned means the cut just happened, so paste is
      Restore + re-point; cut walks the delete's closure so frozen
      pins can't poison move detection). Recorded for step-13
      ratification: the lock-guard unification on Slot/FlipLR writes,
      the wrap-top refit fix, the port-slot unit fix, auto-name
      ordinals. Open items: asset payload size bound; an entity!-
      generated to_init to replace clipboard.rs's hand-written
      readers. Next: step 11, the in-process host + lowering bridge.

      Step 11 done (2026-08-19): `LocalHost` — Host + ClientSession
      loopback in src/collab.rs, mirroring the server writer's message
      flow exactly (Welcome, submit→accept→publish→Committed before
      submit returns, Rejected kept as an unreachable-by-construction
      mirror rather than a panic); the app's `Option<Collab>` became
      `Link { Remote, Local }` — one session code path per D5, empty
      Local on serverless boot, title byte-identical (the "nothing
      persisted" marker waits for step 12); and the D8 bridge
      `schema::lower::lower(doc, label) -> Vec<Commit>`, dependency-
      ordered inits with assets first, schema_convert as the semantics
      oracle, per-element graceful degradation with warns. Proof: all
      tutorial levels lower, fold, welcome a real session, and match
      the legacy parse on cue-read fields; LocalHost smoke + boot
      tests. 714 lib tests. Rode along: 10c's block-title side fixed
      to the legacy Bottom default (the bridge's oracle tests caught
      the Top-for-everything porting bug). Next: step 12, the
      flag-day series.

      Step 12 planned (2026-08-19): `docs/flag-day-playbook.md` — the
      series as six commits (ids; reads; writes; undo; projections +
      the file boundary; green + proofs), with D6's red-mid-series
      reality made reviewable through per-commit residual-red
      inventories that the next commit consumes (agents accept against
      the inventory instead of CI; green required only at 12·6).
      Decisions F1–F8 settle what the sketch left open: LineAnchor and
      WaypointId die with the id flip; Drawing re-shapes over
      session.optimistic() with a CommitBuilder sink; ScopedRoutes
      collapses flat as recorded; BlockPath speaks BlockId with NULL
      as the root; files go read-only through the bridge (saving dies
      with the container — the alternative, refusing file args, is
      noted for review); undo re-points at the session journal and the
      Undoer/quiescence/autosave/history-restore complex dies; the
      legacy model stays compiling but unplugged for phase 7; replay
      goldens re-cut onto a projection basis, reviewed cue by cue.
      Proofs at 12·6: lowered-level replays, the two-client
      convergence run with this client's tools driving one side, and
      the step-8d abort suite proving empty seals end to end.

      Step 12 done (2026-08-19): the flag-day series, eleven commits
      12·0–12·6, executed by reviewed Opus sub-agents under the
      residual-red inventory discipline — green only at the end, as
      D6 allows, with every mid-series commit carrying its error
      count, classification, and growth explanation (the inventory
      arc: 544 → 1110 → 1283 → 1201 → 972 → 517 → 65 → 22 → 0 → CI
      green; the two growth spikes were measured exposure of reads
      hiding behind the layers just replaced). The editor now reads
      session.optimistic() through IndexedDocument, writes only
      through the op emitters under gesture seal-and-submit (the
      solve rider keeps trims/re-solves/promotions in the gesture's
      own commit), undoes through the session journal, boots
      serverless onto an in-process host with nothing persisted,
      isolates tutorial levels as their own sessions, and derives
      route geometry from the document by stamp — so foreign commits'
      wires just appear. Three agents stopped-and-reported scope
      rather than half-porting (12·2 split into core + a/b/c-i/c-ii);
      agents found and fixed ten real bugs with tests, including F9's
      rejected alternative sneaking back through boot, and reversed
      one earlier settlement (root ports) with reasons. Proofs at
      12·6: lowered-level replays (golden basis = the id-free
      projection, re-cut provably neutral), the two-client
      convergence with real tools driving one side, sealed-empty
      abort end to end. The step-13 ratification queue is assembled
      in the sub-playbook. Next: step 13, the coverage sweep and
      behavior-change ratification — then phase 7's demolition.

      Plan review amendments (2026-08-19, all three user-driven): F2
      twice — first the by-value cache (an O(document) rebuild every
      frame hiding in borrow-structure fallout) became an owned
      stamp-gated index; then the two-field doc+index split (which
      made the mismatched-pairing hazard representable again) became
      `IndexedDocument<'a>` — the honest rename of `DocumentCache` —
      split into a view over owned `DocIndex` with the pairing
      constructor as the only door, landing green as pre-series
      commit 12·0 with the emitters' redundant doc+cache params
      narrowed to the view. And `src/derived/` renamed to
      `src/presentation/` (struct `Derived` → `Presentation`, ~44
      files): the old name said how the contents are computed, not
      what they are for — where wires run, how big text is, what
      color stubs show. Module doc now leads with purpose; prose
      "derived" survives only as a provenance adjective. CI green.

      Tutorial/presence design note (2026-08-19): spec §13a records
      the gesture-streaming discussion — the wire sees only sealed
      gestures by design (Figma streams intermediates; we chose
      gesture-atomic commits for undo/review/merge), and if live
      intermediates are ever wanted the answer is a presence-channel
      extension, never micro-commits. The symmetry: steps 7/8's
      authored-vs-presentation split is the same line a gesture
      stream draws across the network, and the preview funnel's
      hypothetical-geometry types are already the message vocabulary
      — at which point a tutorial demo ghost IS a synthetic
      collaborator, one rendering path. Near-term harvest scheduled:
      session-isolated tutorial levels join flag-day 12·5 (the
      exit_tutorial swap/restore machinery dies instead of being
      re-pointed); commit-driven cue advancement queues after the
      series (it changes tutorial behavior, which the series must
      not).

      12·6 addendum (2026-08-20): the re-aimed waist gate and the
      `.golden.txt` tutorial scaffolding — described in b23075a's own
      message — were never staged into it. Landed now. The old gate
      happened to still pass (drawing.rs holds 34 edit events, above
      the old floor of 25), but `xtask tutorial init` was seeding
      dead `.golden.kdl` files the runner no longer reads.

      Live-preview regression fixed (2026-08-20, user-reported; Opus
      sub-agent under review): block drag, group drag, resize, and
      route edge/waypoint drags showed frozen wires — each painted
      BEFORE supposing its route preview, so the supposition was
      wiped by the next frame's `Drawing` construction (stamp-gated
      re-derive) without ever being seen. Pre-swap the ordering was
      harmless because preview geometry persisted in document-owned
      maps; the presentation layer made it fatal. All four tools now
      suppose before painting, per the pin tools' pattern (re-suppose
      on every in-drag frame, any event but the stop). Second bug
      caught by the red-first tests: `EditRoute` released — and
      committed — on ANY non-`Dragging` event, so an event-less
      repaint mid-drag ended the edit; it now releases on
      `DragStopped` only, like every other drag tool. Also intentional:
      the dragged shape/overlay previews paint the current frame's
      delta (one-frame lag removed). Six new tests in the drag-abort
      suite drive an event-less mid-drag frame through the real
      dispatch — red-first verified independently. CI green.

      Type-level invariants reviewed (2026-08-20):
      `docs/type-level-invariants.md` classifies the migration's 21
      recorded defects by the invariant each broke and proposes eight
      compile-time constructions, in REVIEW.md's format. Four classes
      are type-system failures (a temporal ordering nobody can see;
      one value with five meanings — `BlockId::NULL`; a key that does
      not carry its scope; two implementations of one policy), one is
      not (fixture staleness, an unstaged diff, pending behavior
      decisions — enumerated as such). Scheduling: P2, P8 and P1+P6
      cut now, against 12·6's green baseline, because none of their
      cost overlaps step 13 or phase 7 and green is the only state in
      which a behavior-preserving refactor can be proved so; P7 and P5
      are the enforcement half of two step-13 ratification items and
      land with them; P4 rides phase 7's traversal.

- [x] P2+P8 done (2026-08-20) — the write door. Landed as one series,
      not two: `Sink` and the staging turned out to be the same seam
      (the opaque handle only earns its keep once it owns the fold, and
      the log only becomes structural once there is one door to emit
      it from), so splitting them would have been churn.

      `Gesture` (src/gesture.rs) replaces the bare `CommitBuilder`:
      ops plus the prediction they imply, re-folded whole onto the base
      at every write — whole, not incrementally, because op order
      within a commit is free for references and a half-gesture can
      fail a validation the finished one passes. `staged` is `None`
      until the gesture writes, so an idle frame pays neither the fold
      nor the index rebuild. `Gesture::author(base, what, emit)` is the
      one way in; `Drawing`'s handle is private and its `author` is
      `pub(super)`, so a tool cannot reach the sink and a write cannot
      skip advancing what the next read sees. B1 is gone structurally:
      the four `Arming` states (rename_title/route/pin, edit_text_box)
      and their same-frame fall-through are deleted, and each tool now
      carries an `action()` that encodes the fallback the `Arming` arm
      used to — one place instead of six call sites.

      `solve_rider` lost its hand-rolled staging (it was the only
      caller that had this machinery; it now takes the gesture's own
      view) and `DocIndex::view_of` is the read-only pairing door that
      makes the staged pair unmistakable, refusing a mismatch the way
      `view` repairs one. The mutation log narrates in `author` from
      `OpCodes::narrate` — kind, target id, lifecycle, never the
      payload, with a doc-crate test pinning that — so `RUST_LOG=edit`
      is 1:1 with ops by construction and the 43 hand-written setter
      lines are gone. The waist gate drops the two field-visibility
      greps and the event floor (all three now structural) and keeps
      the one fact no type states: nothing outside `src/widget/` names
      a sink. Re-aimed to scan code rather than prose (a comment
      saying "gesture" was a false positive) and proven to bite by
      perturbation.

      Behavior changes: none intended. 682 lib tests (+2: a gesture
      reads back exactly what its commit lands, verified red under a
      staging-free `view`; and it reads nothing it did not write),
      113 doc, CI green. `place_route_label` lost a parameter only its
      deleted log line used.
- [x] P1 done (2026-08-20) — supposing before painting is a data
      dependency, not a comment. `Supposing` (tools/tool.rs) is a token
      with a private constructor; the five preview writers on `Drawing`
      require one, and only `ToolTrait::suppose` is handed one, minted
      inside `tools::tool::frame` — the per-frame door both drivers now
      call instead of `widget`. Supposing after the paint no longer
      compiles (verified by perturbation: `Supposing(())` from inside
      `widget` is a private-constructor error), which is the bug
      daaa865 had to fix in four tools at once.

      Six drag tools grew a `suppose`: block, group, resize and route
      edge/waypoint (whose blocks moved out of `widget` verbatim), plus
      the two pin drags, which had to split their event accumulation
      from their rendering to reach the token. That split paid for
      itself — move_pin's drop-candidate search had been written twice
      (once for the preview, once for the drop, with a comment asking
      them to agree) and is now one `drop_candidate`; move_multi_pin
      reads its `moves` helper from both halves. The drag-abort suite
      drives `frame`, so it exercises the driver's order rather than a
      tool's own call sequence.

      Deliberately NOT done, with reasons: the `Presentation` split
      (settled geometry vs a one-frame supposition, which would delete
      `routes_supposed()`) is unnecessary for the bug class now that
      the ordering cannot be got wrong, and it would touch every
      route-geometry read. P6 (`Drawing<Reading>`/`Drawing<Writing>`)
      is deferred: a mode type parameter reaches ~151 `Drawing`
      signature sites across ~50 files, and its entire yield is
      replacing two `debug_assert!`s — one of which P2 already turned
      into an `ops().is_empty()` check on a gesture that cannot be
      sealed. Re-evaluate during phase 7, when those signatures are
      being touched anyway.

      Behavior changes: none intended. 682 lib tests, CI green.

      Release-frame flash fixed (2026-08-21, user-reported: "one frame
      of the unedited route when the edit completes"). A drag's last
      frame paints *and* commits, in that order — `widget` renders at
      its top, then handles `DragStopped` — so what it paints is
      whatever the supposition left behind. Every drag tool skipped
      supposing on the stop (`daaa865` wrote the rule as "any event but
      the stop"; P1 moved that code but kept its semantics), so the
      release painted the wire the frame's `Drawing` had re-derived from
      the un-edited document, one frame before the commit's geometry
      appeared. Not a route-edit bug: measurement showed all six drag
      flows painting the *same* settled polyline on release.

      The guard was simply wrong, so it is gone from all six tools —
      a drag supposes on every frame of the drag, the last one
      included. The two pin tools also had their side/cursor updates
      moved out of `widget` into `suppose`, so the placement the
      release draws and the one it commits are resolved once rather
      than twice.

      Seven release-frame tests join the drag-abort suite, one per
      flow, all verified red against the pre-fix tools. They measure
      inside the frame, at the seam between suppose and act: the
      commit relays its result into the same geometry map, so reading
      it afterwards reports the commit rather than the paint — the
      first version of these tests did exactly that and reported a
      third state that was neither. 689 lib tests, CI green.

- [x] B3 settled and enforced by type (2026-08-24, user ruling). The
      lock protects what a pin *is*, not where it sits: I/O direction,
      name, type line, tag text, and pin/port existence are material;
      slot, side, both flips and tag visibility are presentation. Step
      10's "unification" had been wrong in both directions — widened
      onto layout, and missing `cycle_dir`, the edit that most
      obviously changes a pin's meaning; the two cancelled out enough
      that neither showed.

      `src/edit/lock.rs` states the split once, as capabilities:
      `MaterialPin` and `UnlockedScope`, minted only there. Every
      material emitter takes one and there is no other way to name its
      target — `NewPin`/`NewPort` carry the proof, so one for a locked
      owner is unconstructible, which let `pin_init` drop its `Option`
      entirely (clippy noticed before I did). Presentation emitters
      take plain ids and are offered no proof, so the table reads in
      the signatures. Perturbation: a setter that skips the mint does
      not compile.

      Four tests asserted the old rule and were rewritten to the new
      one rather than deleted — a frozen interface now *takes* every
      slot edit, and refuses its pins' direction. `TRACKER.md` item 4
      amended: its "retitle/retag allowed" is superseded.

- [x] Part B decided (2026-08-24/25). B1, B2, B4, B5, B7, B8, B10a–h,
      B12 kept as recommended; B6 (read-only files) accepted for the
      branch, to revisit before phase 8; B3 ruled and enforced by type
      (above). **B11 was not ruled on** — the only entry still open.
      B5's reasoning is worth keeping: a dangling wire is
      syntactically invalid, so dropping it on load is correct rather
      than merely convenient. B11 confirmed in use 2026-08-25 — the
      flash is gone — which **closes the sheet**: every behavior change
      the editor swap made is now decided. Two of them turned into work
      rather than confirmations (B3's lock split, B9's text extent) and
      both are done; Part C's five engineering items remain.

- [x] B9 fixed rather than ratified (2026-08-25, user-reported): a text
      box's measured extent was written *only* by the editor's commit,
      so text that changed with no editor involved — an undo, a redo, a
      collaborator — left the box drawn and hit-tested at its
      character-count estimate until a double-click started another
      edit cycle. `Drawing::refresh_text_extents` measures every box in
      scope whose cached entry no longer matches its text, sharing
      `measure_box_size` with the editor so the two cannot disagree
      about the size. Called from `tools::tool::frame` — before the
      supposition, since everything below it reads extents — and from
      the SVG export, which is a frame too. `set_text_box_content` lost
      its `size` parameter: there is one place that measures now.
      Cheap by construction — an entry that still matches is not
      measured again, so an ordinary frame pays a comparison per box.
      Red-first verified by neutering the refresh.

- [ ] Step 13 — coverage sweep and behavior-change ratification.
      **Part A ratified 2026-08-24**: A2–A10 confirmed as shipped on
      purpose (A1 was already superseded by B12 — undo restores the
      view). The pivot's ten plan-time behavior changes are settled;
      what remains for the user is Part B's B1–B11, the ones that
      accumulated during execution.
      **The walk is done (2026-08-21)**, recorded as a coverage table in
      `docs/editor-swap-playbook.md`. Every one of `document_mutations.md`'s
      45 rows has an emitter and a tool wiring — nothing lost its edit in
      the swap, no emitter is orphaned — and all 117 emitter fold tests
      stand. Two rows were stale text, corrected in the inventory: Add
      Waypoint was absorbed into `RouteEditSession`/`commit_route_edit` at
      8b, and Restore History was deleted at 12·4. The inventory's
      "Commit point" column keeps its pre-flag-day line numbers with a
      header note saying so, rather than growing a second set that would
      go stale the same way.

      The gap is real-path coverage: ~12 of 45 rows are driven through
      tool dispatch or the command registry; the rest rest on emitter and
      waist tests. Straggler list in the playbook. The command registry's
      half is now closed — its tests proved which commands a selection
      *offers* but never that invoking one writes; two new tests drive
      each document-mutating command through `apply_scripted` and assert
      it authored an edit of its own kind.

      Two vacuity traps found by perturbation rather than by reasoning,
      both worth remembering: `Scene::commit` runs the solve rider, so a
      content-hash assertion around it passes for a command that does
      nothing (neutering `SetBlockLocked` left the first draft green);
      and several dispatch arms ride a route trim beside their own edit,
      so "the gesture is non-empty" is satisfied by the rider (neutering
      `flip_shape_pins` left the second draft green). Hence
      `Scene::authored`, which folds without the rider and reports
      `OpCodes::narrate` lines, and per-command expected write kinds. All
      six covered arms verified to fail when neutered.

      Ratification sheet assembled (2026-08-21):
      `docs/ratification-sheet.md` — Part A confirms the ten changes the
      migration playbook enumerated at plan time, Part B is the eleven
      that accumulated during execution (each with what the legacy did,
      what it does now, where it is observable, and a recommendation),
      Part C carries the five engineering items so none passes phase 6
      silently. Awaiting the user's decisions.

      Straggler pass, first slice (2026-08-21): the headless frame
      driver moved out of the drag suite into `tools/headless.rs` —
      shared, not copied — and grew what non-drag tools need: it
      publishes the tool's `EditText` (so a test types where the user
      types) and routes the tool's returned action through
      `commands::apply_scripted`, the dispatch the app and the scripted
      driver already share. `tools/authoring_tests.rs` covers the
      create tools (comment, text box, pin-slot click, wire label) and
      the in-place editors (title, pin tag, pin type, and the emptied
      text box that deletes itself); all seven setters they reach were
      verified to fail when neutered. Two tests found their own
      fixture bugs first — a one-slot block offers no free pin marker,
      and the markers sit a grid cell outside the edge, so both now ask
      the block where its targets are and assert the precondition.

      The waist gate refused the measurement helper twice (once in
      commands.rs, once here) for naming `Gesture` outside
      `src/widget/`. Both times it was right: the instruments now live
      on `Scene` as `begin`/`seal_narrated`/`authored`, beside the sink
      they measure.

- [x] The undo stack carries editor state (2026-08-23, user-directed).
      A1 is superseded: undo no longer changes only the document. The
      stack in `src/history.rs` interleaves `Step::View` and
      `Step::Edit`, both carrying the `ViewState` (scope + selection +
      a route's overlay anchor) they were made from — so a navigation
      or a selection is one step back, and undoing an edit lands where
      the edit happened rather than wherever the user has since
      wandered. Camera deliberately excluded: zoom/pan is continuous
      and framing has fit-to-view.

      The stack lives in the app, not in the session journal, because
      the journal is headless by construction — `BlockPath` and `Tool`
      cannot enter `crates/doc`. So the app owns the *ordering* and the
      session owns the *inverses*. That pairing is the hazard, so it is
      checked: `UndoStack::document_depth()` must equal
      `ClientSession::undo_depth()`, asserted on every frame including
      the ones that undid. "Did this frame edit" is read off the
      session's depth rather than counted by hand, because a frame can
      submit through more than one door and a second count is the thing
      that would drift.

      Steps name their commit (2026-08-24, user-caught): the first
      cut correlated the two stacks by *position*, guarded by a depth
      assertion. That is not safe. `Journal::discard` removes a
      rejected entry from the MIDDLE of the journal (`retain`, not
      `pop`), so after one rejection the editor's Nth step back and the
      journal's Nth entry are different edits — and the depth check
      only catches the count in debug builds, after the fact. Now
      `Step::Edit` carries the submission's `Nonce`, journal entries
      keep that nonce as identity past the ack (`pending` became a
      separate flag), and `ClientSession::undo(edit)`/`redo(edit)`
      refuse anything but their own top with `UndoRefusal::Stale{top}`.
      A step whose commit was rejected is dead: dropped, and the walk
      continues to the next, which is how the stack heals. `Link`
      reports the nonces it actually put on the wire
      (`drain_submitted`) instead of the app counting depth — and undo
      and redo submissions are deliberately not reported, or a step of
      a step would desynchronise the two. The debug assertion is now
      list-vs-list, not count-vs-count.

      Selection dropped from the stack (2026-08-24, user decision —
      B12's narrower option, taken): only a *navigation* makes a `View`
      step. Selecting is how a user reaches an edit, so a step apiece
      would cost several presses to get back past one edit. The
      selection still rides in every step's `ViewState` and is restored
      with it, so undoing an edit re-selects what it was made with; a
      frame that both selected and edited is one step, not two.

      A step carries both ends of its edit (2026-08-24, user-reported:
      "I don't see the selection active when the undo is pressed").
      Cause was structural, not wiring: the drag tools report *no*
      selection while dragging — `MoveBlock` has no `selection()` at
      all, `ResizeBlock::ResizeRect` and `MultiSelect::Moving` return
      `None` — so the frame that commits a drag *starts* with nothing
      selected, and the step's "before" end was empty by construction.
      But the after-end alone is not the answer either: a delete leaves
      nothing selected, and what its undo restores is exactly what was
      selected when it was made. So `Step::Edit` carries `from` and
      `left`, `selections()` offers them best-first, and `tool_for`
      takes the first the document still holds. `Selection { what,
      anchor }` groups the route anchor with the thing it anchors,
      since an anchor without a selection names nothing.

      `tool_for` is the one selection-to-tool map, so restoring a
      selection and making one cannot disagree; a selection whose target
      the undo just removed falls back to plain select rather than
      leaving a tool pointed at nothing. Ten tests. Ratification sheet
      gains B12 and strikes A1.

      **Dress rehearsal run 2026-08-25** — phase 7's exit criterion early:
      two native `--connect` clients against one `blockworx-server`,
      driven by synthesized input so every edit went out through the real
      tool dispatch. Joining mid-session, foreign commits landing without
      a mouse move, undo travelling as an ordinary commit, restart and
      refold: all held. Concurrency was forced by `SIGSTOP`ping the
      server — both clients queued an unacknowledged move against the
      same rev, and on `SIGCONT` converged to identical canvases. Details
      and findings in `docs/editor-swap-playbook.md`.

      Three findings, all in the boot-and-link seam no test reaches
      because no test opens a window and dials a socket. Two fixed here:
      `--connect` no longer opens the path argument it was going to throw
      away (it named a file the window did not show, left the block path
      pointing into it, and locked a container it never read), and a
      `Welcome` is now a document swap like any other, so the arriving
      document is framed instead of inheriting the boot document's
      camera. A dropped socket now says "disconnected" rather than
      "desynced: <raw websocket error>". The third was a decision, taken
      2026-08-25: the editor keeps authoring after the link dies, into a
      session with no authority to sequence — and the answer is **keep the
      queue and resume**, not the read-only mode first recommended.
      Requiring a live socket is not robust against real connectivity, and
      the fold is already built for a stale queue: deletion is a tombstone
      so a stale commit races rather than refuses, and a commit is stamped
      with the rev it is *sequenced* at, so flushed work outranks what
      landed while the client was away. Needs redial, `Resume`/`Catchup`
      (the deferred incremental resume), and rejection stops being silent.
      Its own piece of work after step 13; details in both playbooks.

      **Ten straggler rows closed 2026-08-25.** Six tool flows in
      `tools/authoring_tests.rs` (add port, move title, move type label,
      rename block type, cycle pin direction, rename route, delete wire
      label); single-wire reroute as another case in the command
      registry's own effect table, where `reroute-block` already lived;
      and three app-level arms in `app.rs` — wrap top, keyboard nudge,
      nudge pins. Those three the walk had called ordinary tool flows and
      they are not: `Action::GoUp` and `Action::Nudge` read app state (the
      block path, the tool's selection), so `apply_scripted` hands them
      back and `dispatch_action` resolves them. Driving that dispatch is a
      different layer from the tool driver, not a second copy of it.
      Every one of the ten verified to fail with its setter neutered.

      **Six more closed the same day**, after the "blocked on the OS
      clipboard / the file dialog" claim turned out to be a failure to
      look. `Action::Cut`/`CutPins` write the document *before* handing
      the payload to `ctx.copy_text` and `Action::Paste` takes the text as
      an argument, so the whole round trip runs against a bare
      `egui::Context` — reading the payload back out of its
      `OutputCommand::CopyText`. And `NewImage::Pending` /
      `IconTool::Pending` each hold a plain `Receiver`, so a test owns the
      other end and answers the pick itself, `None` included.

      Cut-then-paste now pins the move/copy rule down: a cut block returns
      under its own id, while `paste_pins` always mints, so a cut pin
      lands as a new pin on whatever boundary the paste is aimed at.

      **The sweep is closed — all 18 rows.** The last two (set accent, bulk
      pin direction) were closed by moving the write rather than by driving
      the popup, on the user's call: a picker used to call `commit_gesture`
      from inside its own UI callback, which made these the only document
      mutations in the app reachable by no other route. They now report a
      pick as `Action::SetRole` / `Action::SetPinsKind`, applied by
      `apply_scripted` like every other document-scoped action, and the
      popup is an input widget again.

      Two things came free. A script can set an accent, which it never
      could: the nine cells and three directions register as named
      commands (`accent-3`, `io-output`) resolvable through the same
      registry the palette and the script `command` step share. And they
      register *by name only* — a new `Offered` on a `Command`, filtered
      out of `iter()` — since nine more buttons would bury the selection
      bar. Command names now permit digits, which they did not.

      Verified in the running app as well as in tests: the picker still
      paints the accent on click.

      Two Part C items closed 2026-08-25 (user decisions): "rip up and
      autoroute" keeps the visible level's wires and the document-wide
      rule is deleted with `RerouteTarget` itself, so nothing can
      disagree again; and artwork is bounded at 4 MB (`ASSET_LIMIT`),
      enforced by the fold and refused at each of the three pick sites.
      **Part C is closed** — all five, 2026-08-25. The last three:

      - Waypoint-insertion arithmetic unified. The plan and its replay each
        built the ordinal-keyed working list themselves, and `InsertAt(i)`
        counts positions in *that* list, so two constructions were two
        meanings for one index. Both go through `keyed_corners` now, and a
        test plans two insertions and asserts the replay keeps their order.
        The replay's clamp is kept and explained: under collab a
        collaborator's commit can shrink the stored list mid-drag.
      - The dead `lazy_edge_drag` assertion, better than deleted:
        `EditRoute::drag_from` takes `&Drawing`, so "starting a drag stages
        no waypoints" is carried by the borrow rather than by a test. The
        whole test went with it — every assertion in it was about a write
        the signature no longer permits.
      - `Entity::to_init`, generated from the same field list as the
        struct, replacing eight hand-written readers in `clipboard.rs` —
        the exact shape that silently drops the next register somebody
        adds. Proved as a round trip against `from_init` for every kind.

      **Step 13 is done.** 724 lib tests, CI green. Next: tick phase 6 in
      the migration playbook, move the asset (D7) and boot-UX (D5)
      decisions into its Decisions table, and phase 7 opens.

- [x] **Phase 7 planned (2026-08-25): `docs/demolition-playbook.md`.**
      Twelve steps in four stages, written before any of it is executed
      because the phase has a property the earlier ones did not — it is a
      **one-way door for data**. After the codec goes, no `.kdl`/`.json`
      document can be read again by any version of this program, and the
      converter is made of the thing being demolished. So: demo, then
      convert every survivor while the reader exists (level embeds, router
      and render fixtures, autogen grids, the documents at the repo root),
      then an explicit "nothing left to convert" audit, and only then the
      deletions — leaves first, ~8,000 lines across `src/schema/`,
      `src/document/`, `src/storage/`, `src/store.rs` and four CLI flags.

      Six decisions taken at plan time. Five are recommendations on record
      (the level `initial {}` embedding becomes a purpose-built reader over
      the surviving KDL parser; surviving value types re-point to their
      doc-crate twins, and `Lock`/`TagVisibility` turn out to be *literal*
      duplicates of `edit::naming`'s already; fixtures convert to
      checked-in commit logs proved by unmoved router snapshots; the CLI
      loses `path`/`-o`/`--share`/`--no-write`).

      **D1 is open and blocks stage 3:** what `blockworx` with no
      `--connect` does once nothing on disk is readable. D5's literal
      reading — empty scratch, nothing persisted — leaves an editor that
      opens nothing without a server and strands every document at the repo
      root. Recommendation: a one-shot `.kdl`/`.json` → sqlite-log
      converter (fifty lines of existing parts: `schema::lower` for the
      commits, the server's `Store::append` for the rows), shipped in step 4
      and deleted in step 8 — content survives, local mode stays deferred,
      and pulling it forward stays cheap because the documents are already
      logs by then.

      **D7 added on review (2026-08-25)**, answering "does the converter pin
      code we planned to delete?". Not at compile time — it is strictly
      downstream and dies in step 8's commit with everything it uses. But in
      the CLI it would pin the *CLI surface*: step 7 removes four flags, and
      a `--convert` would add a fifth one step before deleting it. So it
      lives in `examples/` beside `collab_smoke.rs` (same shape, covered by
      `clippy --all-targets`, one `git rm` to delete), and it **submits to a
      running server** rather than appending to a `Store` — no new deps, and
      it converts through the real ingress, so a log that converts is one
      the server demonstrably folds. `Store::append` skips `validate` and
      can write a log that fails the server's next startup. What no
      placement fixes: after step 8 the escape hatch only exists in git
      history, so step 5's audit covers the filesystem, not just the repo.

      One finding worth the planning pass on its own: **P4 (`Scope`) was
      scheduled backwards.** The migration playbook put it *inside* the
      demolition to save a traversal, because `schema_convert` and the
      legacy readers hold several `NULL` conversions — but step 8 deletes
      those sites rather than migrating them, so converting them first is
      work on code about to vanish. P4 moves to last, over the smaller tree.


- [x] **Phase 7 step 1 (2026-08-25): the demo passed, and became a command.**
      Two `--connect` editors on one `blockworx-server`. Disjoint edits
      propagate (A's `CPU`, B's `MEM`, rev 4). The sharp case — both editors
      moving the *same* block against rev 4 with the server `SIGSTOP`ped —
      converged on thaw: B sequenced at rev 5, A at rev 6, A won, B dropped
      its optimistic position. Six commits for six authored edits, and the
      two canvases compared with `magick compare -metric AE` at **0
      differing pixels**.

      Three xtask commands now do the setup. `server` and `client` are the
      primitives — one foreground process each, defaults taken from each
      other so a bare pair finds itself (`--db`, `--listen`, `--connect`,
      `--narrate`, trailing app args after `--`). `demo` is the one-terminal
      version: scratch log, N editors, and an `[f]reeze [t]haw` console.

      The freeze is the whole point — two people editing at human speed
      almost never hold unacknowledged commits at the same moment, so the
      concurrent case is the one a live demo never reaches by accident. With
      the server in its own terminal that comes from the shell for free
      (Ctrl-Z / `fg`), which is the better argument for the granular pair
      than convenience is.

      Measured, not assumed: `demo`'s `q` and Ctrl-C both clean up; a
      SIGTERM to xtask alone skips `Drop` and orphans the children. Left
      documented rather than fixed — closing it needs a signal crate or
      `PR_SET_PDEATHSIG` against a crate whose whole dependency list is three
      build helpers, and the primitives spawn nothing in the background.
      (`fn_params_excessive_bools` caught the first refactor passing
      `release`/`narrate` as loose bools; they are `Profile` and `Narration`
      now, and `Profile` names both the cargo flag and the target directory
      so the two cannot disagree.)

      Two findings recorded in the demolition playbook: a frozen server keeps
      the connection open (titles stay `live`, which is what makes the queue
      observable), but freezing *before* the editors connect leaves them
      `[disconnected]` with nothing to reconnect them — R3 again. And
      dropping a block onto another block silently authors nothing: no
      warning, no narration, the drag just has no effect. Single-client
      reproducible, out of this phase's scope, written down.

- Trap single click on labels.
- Double click should check label boundaries.
- Don't require click to deselect.

- Reconsider the serialization format — JSON may be a better fit than KDL.
- Select toolbar button show show selected when subtools are active.
- Delete Waypoints
- Port size/resize/initial creation.

Done - 
- Larger Drag and Drop handles and targets.
- Grab and move in one operation
- Expand and Go Up should be unified somehow.
- New rect should exit to select
- Drag select of 1 thing is different than a single click on 1 thing.  Should be the same.
- Undo/Redo
- Delete of multiple blocks doesn't work
- Route tool can create ports automatically.
- Shift select to extend selection
- Keyboard moves
- Delete key doesn't work on a selected pin
- Shift select to extend selection
- Tab to get to next pin when editing a label.
- When in a text box on pin A, double clicking on pin B should move the focus to pin B's text box.
- Alignment hints with lines for horizontal and vertical alignment with other things
- Editing of a text block left the ! outside the edit box.
- Need hierarchical layer thing.

Copy/Paste Group
- [x] Add a cut action alongside copy on the selection overlay
- [x] Copy group marquee needs to be at larger than it is by ~ 1 GRID_SIZE
- [x] When a thing is pasted, it cannot be dragged to be repositioned (test with a group paste)
- [x] Paste of an object/group should put the top left corner of that obhect/group at the hover position if available, at last click position if available or at the canvas center.
- [x] Pasted group/object does not respond to keyboard moves either.
- [x] Pasteing an "object" into an editing textbox should trigger a regular (deselected) paste, instead of pasteing the text of that paste operation into the textbox.
- [x] Cut and copy actions take routes connected to the currently selected block.  They should only include routes that originate _and_ terminate on the selected block.  If it's a multiple selection, then include routes that originate and terminate within the selection.
- [x] Pasted routes preserve the source's absolute waypoints; shift them with the group so the paste doesn't route through the original waypoint locations.
- [x] Dragging a selection should also move the waypoints of routes fully within the selection.
- [x] Live-drag preview: while dragging, reroute fully-selected wires through their offset waypoints (no snap on release), without mutating the stored waypoints.

Lock/Unlock
- [x] The semantic meaning of the lock/unlock icon on the selection overlay is inverted.  A locked block should have an "unlock" icon shown, and an unlocked blcok should have a "lock" icon shown.
- [x] A locked block should have a different visual appearance, decrease the text color by 1 palette entry, and increase the background/fill color by 1 palette entry to give it a more washed out appearance.
- [x] A visual hint should be included to show that a block is locked.  When a block is selected, draw a "locked" icon drawn in the upper right corner.  The icon should be small and go _inside_ the rect bounding box.  Decrease the text color by 1 palette entry relative to the text of the block so that it does not look like a button.
- [x] The visual role of a locked and unlocked block seem reversed.  Ensure that the theme editor is updated so I can visually tune the role colors.  It looks like there should be new roles for "LockedShapeFill", etc.  In other instances where the palette is being manipulated directly, fix by introducing new roles instead of numerically manipulating the palette.
- [x] The "locked" indicator in the upper right corner of the block looks wrong.  Replace it with the same icon used for the "lock" action icon.

Icons
- [x] Replace expand/contract icons with enter/exit icon showing a small chip icon (rect with pins) and then an arrow going from outside the chip to the center (for enter) or from the center of the chip to the outside (for exit).
- [x] Use trashcan icon instead of Delete
- [x] The enter exit icon is not quite right.  The chip should be rotated 90 degrees (so the legs are horizontal).  Make the chip as large as possible within the icon limit.  The arrow should cross the boundary of the chip and terminate in the center of the icon.  The arrow should originate on the left edge for an "enter" icon.  For an "exit" icon, the arrow should originate in the center of the icon and terminate on the left edge.  In both cases, the arrow should be draw over top of the chip to make the breaking of the boundary clear.

Chrome pass (toolbar / navigator / breadcrumb)
- [x] 1. Debug-marks checkbox behind a non-default `ui_debug` feature (`cargo xtask ci` lints with it on so it cannot rot).
- [x] 2. Toolbar "Import" button becomes an icon (the export icon's conjugate: an open box with the arrow coming in).
- [x] 3. The navigator opens as a popup under the compass button (closes on click-outside/Escape; interacting inside keeps it open), not a movable window.
- [x] 4. Toolbar moves down a little; an address bar (back/forward + the block path `top/Thing 1/Core/…`, styled like a browser URL field) sits centered above it. A path deeper than 5 elides its head to an inert `…`; the trailing `…` is a menu of this level's blocks to descend into.
- [x] 5. Exit Block joins the toolbar next to the compass; the bottom-left cluster keeps undo/redo only (Enter block leaves it too).
- [x] 6. Command palette offers `expand <block>` rows for the blocks on the current level.
- [x] 7. Navigator drops its go-up button; the filter box moves inline with the back/forward arrows.
- [x] 8. Selection overlay drops go-up for a block (Enter block stays — it is a verb about the selection).
- [x] 9. `expand` command in the palette, acting on the selected block (the registry's `enter` renamed to `expand` throughout).
- [x] 10. Breadcrumb segments are clickable: picking one makes it the end of the block path.

- [x] 11. Chrome redesign (the two-row toolbar and the interactive breadcrumb were both too busy): the toolbar is one row again — tools | go-up, compass | export, import | gear, help — and the path is a plain non-interactive string, "Content Path: top/Thing 1/Core" (names only), centered along the canvas bottom on the undo/redo cluster's line. The palette gained `go <path>`, which parses that string back, so navigation is copy-paste rather than click-through.

- [x] 12. The content path drops its "Content Path: " prefix and became a selectable label (it exists to be copied into `go <path>`). `cargo xtask ci`'s snapshot step now runs the kittest tests alone and single-threaded — several wgpu harnesses racing on one adapter segfaulted about one run in three.

- [x] 13. Double-click fit-to-content leaves a 4-cell gutter and is capped at 2x zoom (a nearly empty level used to leap to a huge magnification). Authored camera rects are uncapped — a script asking for a ten-cell window means it — and keep their proportional slack, now expressed as padding on the rect rather than a fudge factor on the computed zoom.

- [x] 14. Navigation cluster in the bottom-right corner (the history cluster's counterpart): compass, path back/forward, enter block (enabled only with a block selected), exit block. The toolbar gives up its compass and go-up; the navigator popup hangs off the corner compass, opening upward when there's no room below. Enter/exit wear Bootstrap-style box-arrow-in-down/box-arrow-up icons. Export leaves the selection overlay unless the selection holds a block — a lone port or text box is an annotation, not a diagram to write out.

- [x] 15. Zoom inputs: the bare wheel still zooms (this canvas has no document flow to scroll), and ctrl/cmd+wheel and trackpad pinch — which egui routes to `zoom_delta`, so they were doing *nothing* — now zoom too, the modifier optional rather than a different gesture. Cmd+`=`/`+`/`-` step the zoom about the pointer and Cmd+0 fits the document, as registry commands (so they carry palette entries: `zoom-in`, `zoom-out`, `fit`). egui's own Cmd+Plus/Minus/0 UI scaling is switched off — it would fight the widget-size preference for `zoom_factor`.

- [x] 19. `camera <x> <y> <w> <h>` is back in the palette (grid cells, the numbers the `--author` footer prints), framing the way a script's `camera` step does. It only shared a commit with the persistence work below; it stands on its own.
- [~] 16-18. Persisting where you were — a `path` node in the document plus a per-document camera in the eframe storage, validated against a document fingerprint — was built and reverted (commits 5d221c4, 7bf0a51, 17a708a). Too many paper cuts in use to justify it. The `camera <x> <y> <w> <h>` palette verb went back with it, being part of the same commit.

Polish
- [x] Canvas pan on a space+left-drag (the drawing/diagram convention — Figma, Illustrator, Inkscape, Miro), alongside the existing right/middle-drag. Latched at drag start so releasing the key mid-drag can't hand a half-finished pan to a tool; the press is withheld from the tools too (it would otherwise arm route-start/new-pin affordances); a grab cursor shows while the key is held. Not active while a text editor owns the keyboard.
- [x] Held-key cue for the tutorials: `hold "ctrl"` / `release` script steps draw a "+ ctrl" chip beside the mouse badge (kittest snapshot). Presentational only — scripted input carries no modifiers, so a pan demo pairs `hold "space"` with a gliding `camera`. If a level ever needs a modifier to actually change what the tools do (shift-marquee), the sim frame has to carry it.
- [x] Resize feedback: a block being resized shows its committed size in grid cells inside its upper-left corner (the pin gutter, the one interior region no label uses); its icon rides the preview at exactly the offset the drop commits (shared `icon_rect_after_resize`) instead of jumping into place on release; and the resize floor grows to hold the icon, so a block never shrinks smaller than the artwork it carries.
- [x] If at the top of a block, the "top" dropdown in the navigation widget should be disabled.
- [x] "Add-type" hint text is really small for a block.
- [x] "+tag" hint text is really small for a pin.
- [x] new block, comment, add_port, route should show crosshairs when active, but only if the cursor is over the active canvas, not if it's over the navigation bar or the toolbar or the selection overlay.
- [x] The toolbar should always show a tool selected.  If the tool active is a selection microtool, then show the select tool as selected.
- [x] Block type should be slightly smaller and a -1 palette color than the block title.
- [x] Dragging the "add port" tool does not seem to do anything.
- [x] The symbol tool should also show a cross hair when active.
- [x] The route tool shows selected when editing a route, which is confusing.  Route editing is still the selection tool.  Only the new route tool should cause the route tool to be active on the toolbar.
- [x] On the selection overlay, the "accent" tool should be a square of the current accent color instead of the word "accent".
- [x] Analogous to the theme_editor, also add a font_editor, that allows the tuning of font sizes via a dialog launched via the command line.  Use the same mechanism of a font_sizes.json to overload the theme.

Coding
- [x] Top blocks are born with a real size (TOP_BLOCK_DEFAULT_WIDTH = 8 by TOP_BLOCK_DEFAULT_HEIGHT = 16 grid cells): a from-scratch document no longer saves `w=0 h=0`, and going up no longer resizes the demoted root to its content bounds — it keeps its own rect (only grown to fit its pins), so the new child is a block you drag to fit rather than one as large as the whole drawing. A legacy `w=0` top demotes to the default width instead of an invisible sliver.
- [x] schema should expose the model, and not reference Document. The schema module will ultimately be published as a crate, and should stand alone.
- [x] Provide the parse and output methods on the schema model, not on Document.  

Nav tree (prototype — branch nav-tree-view)
- [x] Whole-document tree view in the nav overlay: expand/collapse twisties, leaf-aligned gutter, guide lines.
- [x] Keyboard navigation (↑↓ move, → expand/into, ← collapse/out, Enter/Space toggle, Home/End).
- [x] Search generalizes over the whole hierarchy (matches + ancestors revealed).
- [x] Removed per-row "enter into block"; kept "select block" (navigates the canvas to the block's own level first).
- [x] Resizable panel (drag the bottom-right handle) for deep hierarchies.
- [x] Decide whether the top toolbar (history arrows / compress / ancestor dropdown) still earns its place now that the tree navigates everywhere.
- [x] Re-framing the canvas on every arrow keystroke may be jarring — consider select-on-Enter vs select-on-move.

Backlog
- [x] Structure so it can be built as a web app
- [x] Add theme/settings/preferences dialog
- [x] Add import of kdl/json (also png/svg; embeds diagram in png/svg exports; export a selection)
- [x] Export should trigger a file dialog, with option of svg, png, json or kdl. *
- [x] Esc should cancel the current tool. *
- [x] Performance test.
- [x] route edit popup not close to where the route was clicked *
- [x] Route hit test seems finicky *
- [x] Need a "reroute" button on the route selection overlay *
- [x] Drag interactions with new routing methodology.  Block and Group drags.
- [x] Bug in crossings of doubled routes. *
- [x] Resizing a block can lead to labels outside the boundary
- [x] Enforce a minimum block width (4 grid cells) on resize
- [x] The UI should always use the "Basic" font regardless of the theme selected.  Meaning that the font selected only changes the drawing, and not the text used in the navigation widget/tree/overlays.
- [x] When routing between two pins on the same block, or between pins on vertically aligned blocks, the routing does not obey the gutter.  A special check is needed - if the source and destination anchors are vertically aligned, do not simply force them to route, but do an autoroute between the two points with no waypoints.
- [x] Cannot click drag a route that has only a single segment.
- [x] Lock icon still not right.
- [x] Replace Copy, Cut, Flip U/D and Flip L/R, export, Reroute with SVG icons
- [x] Add a reroute button to the block that reroutes all routes that terminate on the block. 
- [x] Remove the Add Icon macro tool from the toolbar.  Keep it on the selection overlay for the block.
- [x] Replace the Select, New Block, Comment, Add Port, Add Image and Route text labels in the toolbar with icons.
- [x] Add the Add Route Label to the route selection overlay.  Remove it as a macro tool from the toolbar.
- [x] In the settings menu put Mode at the same level as Theme, and eliminate Scheme.  So the menu has Mode/Theme/Font/Zoom, with Theme -> The list of themes, and Mode -> Light/Dark/System.  
- [x] Put Undo/Redo/Expand(Enter Block)/Go Up(Exit Block) into another overlay in the lower left of the display, and remove them from the toolbar.  Use icons with help text as to the tool function.
- [x] Allow the navigation widget to be resized horizontally, but constrain it vertically to be the size it is created at.  For horizontal resize, limit the width to double the default, and make the minimum 75% of the default width.
- [x] At one point, the route labels attempted to stay in the same horizontal location if the route was edited and the label was horizontal, and in teh same vertical location if the route was edited and the label was vertical.  Bring that behavior back.
- [x] When a new pin button is clicked outside a block (+), a hint should show up that dragging will start a route.  The hint should be in the form of an animation showing a dragged circle moving away from the (+) with an arrow to direct the user to the route functionality.
- [ ] When creating a route by adding ports and then dragging, the snap back to the source port to edit the name of the port is jarring.   Prefer that the end port be selected for renaming, not the first port.  And then put the first port into the edit chain, so that the user can tab to rename.
- [x] Command registry (tools/commands.rs): every invocable operation is a `Command` — a `CommandId`, label, and resolved `Action` — computed once per frame by `CommandSet::available` over the selection/lock/undo context. The selection overlay renders the set in registry order (toggle pairs like lock/unlock and hide/show-tags are distinct ids, so the wrong direction can't be invoked); the history overlay enables its buttons by set membership; toolbar arming shares the one `arm_available` predicate. Deferred to the palette work: string spellings for ids, `search <block name>` (needs the palette), and the gear/help menus (they edit preferences in place rather than dispatch Actions).
- [x] Command palette (tools/palette.rs): Ctrl+K toggles a centered popup fuzzy-searching the frame's CommandSet (nucleo-matcher scoring against label + stable name) plus a `find <block>` row per document block dispatching NavSelect; Up/Down/Enter/Escape navigate, rows show the typeable name dimmed on the right. `CommandId::name()` fixes the stable kebab-case spellings (Arm commands reuse the script's `tool:` spellings) with a uniqueness test. The canvas's per-widget focus exclusions (nav filter) were generalized: any non-canvas widget holding keyboard focus now suppresses canvas delete/escape, so the palette (and any future text field) needs no special case.
- [x] Rename commands: the in-place editor tools (RenameTitle/RenameBlockType/RenamePin/RetypePin) measure their editor rect at render time instead of construction, so arming needs no painter — and the position now tracks geometry changes while the editor is open instead of being frozen at double-click. The registry offers `rename`/`rename-type` for a titled shape, `rename`/`retype`/`rename-tag` for a single pin or port (absent when the owner is locked), and `edit-text` for a text box — palette-only commands, no overlay buttons. Block-navigation rows are spelled `find <block>` (was `search`).
- [x] `cargo xtask tutorial init <name> --initial <doc.kdl>` scaffolds a level (TODO script, camera framed on the initial's top block, initial inlined), registers it in SOURCES + GOLDENS, seeds the golden from the initial state, and replays to verify green from the start. `cargo xtask tutorial golden [level]` regenerates goldens from the replay, re-verifies, and shows the diff (full diff for the named level); `cargo xtask replay <level>` resolves a level name or path and launches `--replay`.
- [x] Editor-placement bug-class review (after the port rename fix): found and fixed the title/type triangle — the renderer draws block titles and type labels with clamped_block_*_position (a resize can leave a dragged label's stored offset outside the block), but the hit test, the editors, and the debug overlay used the unclamped position, so a resize-after-drag left clicks and editors landing where nothing draws. All sites now consume the clamped (drawn) position, with the type label measured in the font it draws in. Two regression nets: a_clamped_title_is_hit_where_it_draws (render-vs-hit agreement under a stale offset) and every_editor_opens_over_the_label_it_was_summoned_from — a half-cell sweep over a mixed scene (titles, types, pin labels both sides, ports, comment, text box, stale-offset title) that arms each editor through the real editor_at_pos path and asserts its EditText covers the clicked point; verified to fail on the old port math.
- [ ] Remaining placement-drift hazards from the review: CueTarget::PinAnchor manually mirrors Block::anchor_point_with_rect (schema-side, documented in the code) — an equivalence test would pin it; RenameRoute still measures its editor rect at construction (frozen while open, painter-taking constructor) and route labels aren't covered by the editor sweep.
- [x] Window title names the edited file ("basic-nav.kdl - BlockWorx") via the viewport builder; the run_native app name stays fixed so eframe's persistence key doesn't move with the document.
- [x] Port rename editor placement: double-clicking a boundary port's name opened the editor at the block-pin label offset (pin_text_location) instead of over the port's centered label — now anchored on the shape's own pin_text_rect, like the type and tag editors already were. Regression test asserts the editor centers on the port's name rect and that the two placements genuinely disagree.
- [x] Repaint-hammer audit: the three file-dialog polls (import, add-icon, add-image) requested a repaint per frame for as long as the native picker stayed open — now a 100 ms poll cadence. Every other request_repaint site is properly gated (view easing, held-press hints, one-shot commits, replay). The palette's settle test generalized into tools/settle (headless N-frame probe asserting stable shapes + non-immediate repaint delay); guards now cover the palette, the open navigator, and the toolbar + history + selection-overlay chrome.
- [x] Committing an in-place edit keeps the object selected: RenameTitle/RenameBlockType/EditTextBox/RenameRoute now exit to the edited object's selection (ResizeBlock::Selected / EditRoute::Selected) instead of the bare Select tool — matching the convention the pin editors established with select_tool_for_anchor ("a selected pin is the standing target for edits"). Escape already reselected; commit was the odd one out, dating to the original widget_ng code.
- [x] Toolbar key bindings: one const chord->CommandId table in tools/commands.rs (Ctrl+E select, Ctrl+B block, Ctrl+M comment, Ctrl+P port, Ctrl+I image, Ctrl+T text, Ctrl+R route), consumed through the registry each frame (gated off while a text field owns the keyboard; reserved chords consume even when unavailable so they can't leak). Toolbar hover text and palette rows print the chord from the same table. Tests: every toolbar tool bound exactly once, chords consume from real input. Remaining for later: chords for selection verbs (delete/flips/lock), and rebindability if it's ever wanted.
- [x] Images are interned content, not inline copies: `Image` holds an `Asset` (`internment::ArcIntern<ImageData>`), so equal images share one allocation process-wide — cloning a document into an undo snapshot copies refcounts, the per-frame `PartialEq` compares pointers, and the last placement to drop frees the bytes. On disk the payload moved to top-level `asset "iN"` nodes emitted after the blocks, with `image`/`icon` carrying the id they place (ids assigned on save in first-appearance order; unreferenced assets are dropped). The clipboard payload carries its assets the same way, so a paste lands on the copy the document already holds. Breaking: inline `image`/`icon` data is a spanned parse error (the `symbol` alias and the legacy square `size=` went with it), and the JSON form has no back-compat — a one-shot migration script (scratchpad, uncommitted) rewrote the local scratch documents; test3.kdl fell from 92 KB to 47 KB.
- [ ] Follow-up: key `ImageRegistry` by `Asset` instead of a content hash, so `Painter::draw_image` stops re-hashing the bytes every frame (~4 ms/frame for a 4 MB PNG). Needs the synthesized-image call sites (the per-frame tinted selection icon, `icons.rs`) to intern too, and accepts that a drawn image is pinned for the process — measure with `--trace` before and after.

Tutorial
- [x] Center the tutorial template (and the level content) when a level loads.
- [x] Fix level-1 instructions: the name field opens on its own; add a typing cue step to the script.
- [x] Builder DSL for authoring levels (level()/Script::builder()).
- [x] Click-click block placement in the New Block tool (click corner, click opposite corner).
- [x] Make the demo cursor/mouse badge/typing cue legible (palette contrast + size).
- [x] Resize-and-move level, with resize handles drawn in the template.
- [x] Stage-gated tutorial redesign, Phase 1: stages+gates on Level, doc-anchored cue targets, event lowering, headless runner, keystone self-solving-levels test.
- [x] V1 unwind: gates/checkers/celebration/progress/template deleted; levels are single-file KDL (initial/solution documents inline, narrated `step` nodes); golden-replay test replaces the keystone test.
- [x] Tutorial player window (docs/live-demo-plan.md V1): Session extracted from the runner; player window with Scene video, cue overlays, narration subtitle, transport (pause/speed/restart), and level navigation; scripted-input seam so the video's tools never see the real pointer.
- [ ] V2: chrome migration (Area -> in-Ui widgets, Ui-scoped ids) + toolbar inside the video; nav_tree becomes a real Window.
  - [x] mode_toolbar migrated off Area (centered strip in the canvas Ui, width from last frame); toolbar rendered inside the video with demo-cursor cues resolving tool buttons; input blocker over the video.
  - [x] history_overlay + selection_buttons off Area (bottom-up corner child / measured-rect placement).
  - [x] nav_tree -> egui::Window (movable, closable via the compass flag, horizontal-only resize).
  - [x] Hover affordances in the video: the session tracks a synthetic pointer from its events, the painter's pointer seam serves it to the tools (route/resize/anim_target no longer read ctx.input), and the visible paint pass re-presents the hover.
- [x] V3: recorder mode (--record-tutorial): interaction/toolbar/editor taps, distillation with normalized durations, narration markers, full level .kdl emission, and on-the-spot replay validation.
- [x] Level authoring workflow (supersedes the script-debugger plan, kept as design record): hand-edit the level .kdl, preview with `entr -r blockworx --replay file` (animated player, parse errors on the console), read coordinates off the `--author` footer (pointer target in script spellings, grid cell, camera line). Debugger window / recorder / editor seams culled; the script engine stays at crate::script.
- [x] Level syntax v2: `initial "file.kdl"` references an exported document beside the level; the demo is a `script {}` of imperative commands including `camera` (view framing, mid-script re-framing allowed) and `instruct` (centered overlay text, replaces step key/en groups); targets accept logical ids (`b4`, `b4:p3`); solutions moved out of levels into `*.golden.kdl` regression goldens (regenerate with BLOCKWORX_UPDATE_GOLDENS=1); the `--author` footer emits id spellings.
- [x] `--replay` plays through the main UI: the script drives the app's own document and tools on the real canvas (tutorial::replay) — real toolbar highlight, selection overlays, in-place rename TextEdit; cues/instruction map through the view transform; the script camera re-asserts every frame; undoer paused during playback; errors print on the console (no transport UI — edit and re-run). The floating player stays for the in-app tutorial; Tutorial::single/fill_screen culled.
- [x] `--author` records: canvas interactions print on stdout as ready-to-paste script commands (debounced hover, click/double-click, drag with secs, type on rename commit, toolbar clicks); the footer and recorder name resize handles (`corner:<title>:<rb>`).
- [x] Differential drag destinations: `drag "b1" "+4,-3"` moves relative to the start (signed x marks the offset; only `drag` reads it that way); the cue cursor rides the dragged anchor; the recorder prints drags as displacements.
- [x] Pretty level-file errors: `Level::parse` returns a spanned `LevelError`; `--replay` renders it as a miette report with an arrow into the file (script step errors keep their spans too).
- [x] Cue polish: the mouse badge only shows while a button is held or flashing (glides/hovers show just the cursor); click presses lengthened to 0.45 s.
- [x] Self-contained level files: the starting document moved inline under `initial { … }` at the bottom of the level (parsed as a span-slice of the level source, so the document pipeline is unchanged); the `*.initial.kdl` sidecars and the resolve machinery are gone.
- [x] Scripted route-start: with no raw press stream, `route_start::widget` honors the event stream — a scripted click (or drag start) within the target radius starts a route instead of falling through to pin selection; live sessions are unaffected (the press stash breaks out first).
- [x] Replay/author merged into one read-only authoring mode: `--replay` implies `--author`, the script plays once through, then the session drops into interactive author mode on the end state (footer + recorder live); author mode forces `save_path = None`, so nothing is ever written back on exit.
- [x] `Interaction.press: Option<Press { origin }>` — the press-and-hold level the event model lacked, filled by `compute_interaction` live and by `Lowering` for scripts (held through click press frames and drag glides, released on the final frame). route_start and the new-pin markers rebuilt on it: no more raw-input reads or egui-memory press stashes in tools, and scripted sessions drive both affordances natively (a level can now demo add-pin and pull-a-wire).
- [x] Typed scalars: durations are `core::time::Duration` end to end (Step fields, SIM_DT, script clocks/sampling, player/replay carry, animation periods; `secs=` rejects negatives at parse); `Bounded<B>` (src/bounded.rs) is a NaN-silent, clamped, totally-ordered f32 wrapper generic over a `Bounds` marker, with saturating std ops and manual Copy/Clone; `Progress = Bounded<ProgressBounds>` (own TU) carries unit-interval animation/gesture progress; `Zoom = Bounded<ZoomBounds>` ([0.1, 10]) flows View → Painter → compute_interaction → overlays; `WorldPx`/`ScreenPx` wrap the hit/threshold and chrome-height consts.
- [x] Review theme 1: one canonical hit resolver (`Drawing::resolve_at_pos` + `HitTarget`, src/widget/hit_target.rs) encodes the canvas z-order once; select/select-pin/multi-pin/resize chains rewritten over it; resize_block's hand-rolled drag dispatch (which dropped type/port/pin-label/route-label branches — a live misclassification) now delegates to drag_to_move; pin-label hit-test quadruplet and move collision loops deduped.
- [x] Review themes 2,4-9 executed (opus agents, reviewed + committed per theme): SimDriver/SimClock unify the three script drivers; App::ui decomposed into phases with one Popup enum; Drawing split by concern with generation-stamped spatial invalidation (invalidate() deleted); WorldPx crosses the Renderer boundary and opacity became a Style swatch transform; SVG text lays out with epaint galleys (hand-rolled layout deleted); View::show split with one EditorFeedback struct; rot sweep (scene_rect gone, document_ng -> document, pub narrowing, one tracing channel with an always-on subscriber, Tool/ToolName drift tests).
- [x] Level-02 golden accepted: the expanded first-route demo (renamed p1 pins, click-click p2 route wrapping dst's east face, reverse p3 route) is the regression baseline.
- [x] Script `command` step: `command "<name>"` resolves against the frame's CommandSet (availability follows the demo's selection/lock state) and applies through `commands::apply_scripted` — the document-scoped dispatch arms extracted from App::dispatch_action and shared with the script driver, so a scripted session and a live one cannot drift. Unavailable or app-only commands (clipboard, undo, export, pickers) report on the console and change nothing; the golden replay flags the divergence. Deferred: cue highlight of the matching overlay button, recorder printing `command` lines for overlay/palette clicks, and app-only actions in scripts (undo/clipboard for tutorial levels 13–14).
- [ ] Script asset pre-selection: route the rfd file-dialog call sites (icon, new-image, import) through one picker seam; a scripted session answers the pending pick from `pick-file "asset:<name>"` — the stylized dialog card shows for the step's duration, then the embedded asset's bytes (include_bytes registry) resolve the request, so icon/image/import tutorials complete the real flow.
- [x] Camera glide: `camera ... secs=` eases the view from the previous framing instead of cutting (smoothstep on min/max; the glide holds the input timeline for its duration; a leading glide cuts — nothing to glide from). Omitting `secs` keeps the instantaneous cut, so existing levels are untouched. Unlocks the pan/zoom beats of tutorial 10.
- [ ] V4: step-seek via silent replay, more chrome, watched markers, translation table.

Persistent history (container format, autosave, timeline)

Goal: **do not lose work.** A browser refresh, a killed tab, a native crash, or a
power cut mid-write must all leave the user's drawing intact. Secondary goal: the
document's history is greppable, so a future agent can answer "when did this
route change?" without loading the app.

Where we are today: undo is an in-memory `egui::util::undoer::Undoer<EditorState>`
(debounced whole-document snapshots, gone on exit); the only disk write is
`on_exit` → `std::fs::write` (native only, truncate-then-write, so dying mid-write
destroys the file being edited); the web build persists nothing at all — a refresh
loses the session. Asset ids are assigned positionally on save
(`AssetIds::id_for`, `i{len+1}`), so deleting the first image renumbers every
later reference.

Decisions taken up front (the alternatives were weighed and rejected):
- **Undo stays session-scoped.** No editor persists an undo stack across restarts
  — VSCode, Figma and Photoshop all pair a session undo stack with a browsable
  version history, because Ctrl+Z after a reopen otherwise reverts edits the user
  has no visual context for. Crash safety comes from autosave; cross-session time
  travel is the timeline, where restoring an old state is itself a new undoable
  edit. This also lets autosave coalesce writes freely — the journal is advisory,
  not authoritative.
- **A folder is the working store; a zip is only for share/export.** A zip must be
  rewritten wholesale on every autosave (assets included); a folder appends one
  small write-once file and flips one pointer, which is the only shape that
  actually survives dying mid-write. It also stays greppable with ripgrep, which
  is the whole agentic-search story.
- **Always-saved, no Save command.** Autosave plus an atomic `root.kdl` flip means
  the container on disk is the truth at all times, so there is no dirty state and
  no recovery prompt to design.
- **Full deflated snapshots, no diffs.** Real documents are 13–31 KB, 4.7–6.5 KB
  gzipped; 1000 edits is ~5 MB, which is nothing. `bidiff` was proposed but a
  bsdiff-family patch is an opaque binary delta with no readable syntax, so it
  works against the "read the intermediate diffs" goal it was meant to serve.
  Only the autogen scale test (`block_100.kdl`, 4.2 MB / 217 KB gzipped) would
  justify deltas, and it is synthetic. Revisit with measurements, not up front.
- **Asset ids are content-derived: `<hash>.<ext>`, which is exactly the filename
  in `assets/`.** So a container's `root.kdl` carries **no `asset` nodes at all** —
  the placement `image "9f3a2c81d4e7b026.png"` already names the file, and the
  resolver reads it from `assets/`. A standalone `.kdl` keeps today's `asset`
  nodes with their inline `svg`/`png` payloads. One schema, one resolver over two
  sources (document nodes, else the container's `assets/`), and the "inline all
  assets" export transform is just "materialize an `asset` node per referenced
  id". This deletes the third payload variant the earlier draft of P2 wanted.
- **Reader permissive, writer canonical.** The writer always emits the content
  hash, which is where the invariant that matters ("never two files for the same
  content") is actually enforceable. The reader accepts any filename-safe token
  plus a known extension, because requiring a hand-author to compute a blake3
  hash before they can inline an SVG is hostile to a format this project keeps
  hand-editable. A load-time check warns when a hash-shaped id doesn't match its
  content; the editor re-canonicalizes on the next save.
- **No per-user history store for loose files.** A directory *is* the container;
  hand-authoring means making a directory and dropping the `.kdl` in as
  `root.kdl`. A bare `.kdl` argument keeps exactly today's single-file behavior
  (no assets dir, no history). A directory argument is opened tolerantly —
  missing `assets/`/`history/` are materialized on first write, a missing
  `root.kdl` opens empty.
- **The startup `.bak` is dropped.** An append-only content-addressed history is a
  strictly finer-grained backup than one copy per launch, and `.bak001`
  proliferation is the convention such stores replaced. What a startup copy
  genuinely guards is *our own* new-format bugs, which a `history/sessions.jsonl`
  marker plus a "revert to session start" verb covers at zero cost.

Container layout:

    doc.bwx/
      root.kdl              current document; the only mutated file (temp+rename)
                            opens `version 1` / `name "CT Scanner"`
      assets/
        9f3a2c81d4e7b026.png    name *is* the asset id; write-once, never
                                implicitly deleted
      history/
        000123.kdl.gz       deflated full snapshot
        000123.json         {ts, command, changed:["b3","b3:p2","r7"], parent}
        sessions.jsonl      one line per open: {ts, seq}
      lock                  holder pid / tab id, so two editors don't fight

The sidecar *is* the index — there is no separate index file to keep coherent,
and `rg '"r7"' doc.bwx/history/*.json` answers "when did route r7 change?"
directly. The payload is written first and the sidecar second, so a torn pair
degrades to "unlabeled but valid" rather than to data loss. History blobs are
sequentially named, not content-addressed: dedup would save a few KB on
undo/redo cycles and cost the sidecar pairing. Assets *are* content-addressed —
there the win (a 4 MB PNG stored once) is real, and it is what makes it safe to
never delete an asset a snapshot might still reference.

Sequential entry names assume a **single writer**, which the `lock` file enforces
and P7 makes visible. Two clients editing the same container offline would both
mint `000124`. That is the known cost of the payload/sidecar pairing, and it is
the one thing here that a future server-sync world would have to migrate
(ULIDs, or content-addressed entry names). Everything else about this layout is
sync-friendly by construction: assets are immutable and hash-named, so
upload-once and server-side dedup are free; history is append-only, so sync is
"send everything after seq N"; and `root.kdl` is derivable from the newest
entry, so the only mutable file need not sync at all.

**Export has two modes, and the default matters.** *Archive* writes the whole
container (history and every asset, referenced or not) — the backup, migration
and native↔web exchange path. *Document* writes `root.kdl` plus only the assets
it references, history stripped — the sharing path. Same code, one filter. A
container carries every earlier draft, everything deleted, and every asset we
deliberately never GC'd, so handing someone the archive leaks the entire edit
history; that is the Word-metadata failure mode, and it is far easier to prevent
by construction than to apologise for.

Open decisions, to settle inside the work rather than guess now:
- **Web fallback UX.** File System Access gives Chromium a real user-chosen
  directory; Firefox/Safari have no directory picker, so there the container
  lives in OPFS and getting it *out* is an explicit "Download .bwx.zip". That
  makes "always-saved" only half-true on those browsers — needs an honest
  affordance, not a silent asymmetry.

Phases:

- [x] P0 — crash safety now, no format change. `storage::write_atomically`
      (temp + fsync + rename + parent-dir fsync) replaces every `fs::write` of
      something a user would mind losing: the document save, export, `convert`,
      and the theme/font-size editor writes. The target is never opened for
      writing, so there is no window in which it holds a partial document —
      which the old path had, and which was a live way to lose the drawing with
      no container in sight. Symlinks are followed rather than replaced (a bare
      rename would silently detach a symlinked document from its target).
      `save_document` carries `save_serialize`/`save_write` spans, so `--trace`
      reports the split on any real session. The xtask and golden-regeneration
      writes stay plain: those outputs are regenerable, and xtask cannot depend
      on the app crate anyway.

      Measured (release; btrfs — /tmp is tmpfs here, where fsync is a no-op and
      the cost being measured vanishes):

          demo.kdl        13 KB   serialize  0.12 ms   write  11.08 ms
          test3.kdl       45 KB   serialize  0.33 ms   write  11.18 ms
          block_100.kdl  5463 KB  serialize 81.68 ms   write  18.82 ms

      **The write dominates, ~90x, and it is a fixed ~11 ms floor** — 13 KB and
      45 KB cost the same because this is fsync latency, not bandwidth. Only the
      synthetic 5.5 MB scale test inverts it. Two consequences for P4 below. (A
      debug-build measurement said the opposite by a wide margin; it is not a
      usable signal for this question.)

      Aside for P4/compaction: `block_100.kdl` re-encodes from 4081 KB on disk to
      5463 KB, a 34% growth. Probably float formatting in our encoder versus
      whatever wrote it. Worth a look before snapshot sizes are tuned.
- [x] P1 — one storage seam.

      Correction to the original wording: **a single sync trait cannot span both
      platforms.** OPFS is async on the main thread and synchronous only inside a
      worker, and eframe's loop is sync, so a sync trait can never be satisfied by
      OPFS on the UI thread while an async one infects native too. The seam is
      therefore two layers: a sync `Storage` (`std::fs` now, OPFS sync access
      handles inside the worker at P5) and, above it, a request/response channel
      the app talks to — the shape `crate::import` already uses for file dialogs.
      "Nothing above it is cfg'd" is delivered by that channel, which arrives with
      the writer thread at P4, not by the trait alone.

      Done: `storage::Storage` + `storage::fs::FsStorage` (paths relative to the
      container root, refusing any that would leave it; `write` creates
      intermediate directories, so `assets/`/`history/` appear when first
      written). `storage::container::Container` — tolerant open: no `assets/`, no
      `history/`, no `root.kdl`, not even the directory itself is required, and
      opening *creates* nothing, so a mistyped path leaves nothing behind. A
      malformed `root.kdl` is an error rather than a silently empty document,
      since saving over that is how a drawing gets lost. `App` swaps
      `save_path: Option<PathBuf>` for `Option<DocumentSource>` (a loose file or
      a container), `blockworx <dir>` works alongside `blockworx <file>`
      (a path that does not exist is a file when it carries a document extension
      and a container otherwise), `convert` takes either, and the window title
      comes from the document's `name` when it has one.

      The trait carries only `read`/`write`/`exists` — what a caller uses today.
      `list`/`remove` arrive with the history store that needs them. The whole
      module builds for wasm despite having no caller there yet: that is what
      proves it stayed portable instead of quietly growing a `std::fs` dependency
      nobody notices until P5.

      "Convert to container" (`demo.kdl` → `demo.bwx`) is a registry command,
      offered only for a loose file. It leaves the original alone — that file is
      the user's, and the container is a copy that gains a history — and refuses
      rather than merges when the target exists, since writing our document into
      someone else's container is a way to lose theirs. `convert` became
      symmetric while it was there: either side may be a container or a loose
      file, so it also converts a container back out to one file. The window
      title follows the document now (the `applied_*` idiom the theme and zoom
      already use), since it would otherwise still name the loose file after a
      conversion — and that carries a rename for free when there is one.

      Two new top-level nodes in `root.kdl`, not a
      `meta.kdl` sidecar — one file is what a hand-author wants, and a sidecar
      could only ever version *containers*, leaving every loose `.kdl` (the ones
      that get hand-edited and go stale) unversioned:
      - `version <n>` — cheap insurance for a format already breaking once at
        P2. Absent means the pre-versioning format, i.e. migrate. This is a
        *schema* concern and belongs in the schema crate, not in a container
        sidecar.
      - `name "…"` — optional display name. When absent, fall back to the
        directory (or file) name, so a hand-authored `ct-scanner.bwx/` holding a
        bare `root.kdl` still lists as "ct-scanner". Deliberately *not* the
        directory name itself: the directory is fixed at creation, so renaming is
        a write we already do rather than a directory move that would race the
        in-flight writer and make duplicate names a user's problem.
      Two consequences to accept: the picker (P5b) parses each `root.kdl` to
      list names, which is fine at these sizes and, if it ever isn't, wants a
      rebuildable name cache rather than a format change; and rename becomes a
      document edit, so it lands in history — which is a feature, since "when was
      this renamed?" is exactly the kind of question P3 exists to answer.
      Restoring an old snapshot keeps the *current* name; it restores the
      drawing, not the identity. `docs/kdl-format.md` grows both nodes (it is
      the stated read-before-editing reference, so an undocumented top-level node
      is a bug).
- [x] P2 — content-addressed asset ids. The id is `<hash>.<ext>` (blake3
      truncated to 16 hex chars — 8 would collide at ~77k assets, and a collision
      here silently swaps one image for another), which is also the name the asset
      takes in `assets/`.

      **Not breaking after all**, contrary to the note this item used to carry.
      The `version` node from P1 makes the upgrade free: the reader takes any
      filename-safe token, so version-1 documents and their `i<N>` ids still load;
      the writer always emits hashes at version 2; and a build that only knows
      version 1 refuses a version-2 file rather than misreading its ids. So there
      is no migration script and no scratch-file churn — documents upgrade on the
      next save. Verified end to end: `test3.kdl` (2 assets, 5 placements)
      upgrades in place and re-saves byte-identically.

      Done: `ASSET_ID_PREFIX`/`id_str` give way to a filename validator (rejecting
      `..`, `/`, and the empty string — inside a container the id *is* the file
      name); `AssetIds::id_for` hashes instead of counting, so equal images get
      equal ids in every document and no edit ever renames an asset;
      `docs/kdl-format.md` documents the id form, the version table and the
      permissive-reader rule. Regression tests cover the shape of an id, equal
      content getting one id across documents, a legacy document being
      re-canonicalized on save, and the property the change exists for —
      **removing one asset does not rename the others**, which positional ids
      could not offer.

      The container split is in: a container's `root.kdl` emits no `asset` nodes
      at all — the placement already names the file — and the payloads are
      written to `assets/<id>` as raw bytes, so `assets/ddae155d24b78ded.png` is
      a PNG a file manager will open. Assets are write-once (the name is a hash,
      so a file already there already holds the right bytes) and are **never
      deleted**, even once the document stops referencing them, since history may
      still point at them; GC only via an explicit "compact history". A hand-
      written container may still inline a payload, and the inline one wins. A
      *missing* payload is an error rather than a document that quietly loses an
      image.

      Worth carrying into P3/P4: moving the payloads out shrinks what a snapshot
      has to store by more than the earlier measurement assumed. `test3.kdl` as a
      loose file is 47 KB / 19.2 KB gzipped; the same document's container
      `root.kdl` is 24 KB / **3.4 KB** gzipped. History entries are snapshots of
      `root.kdl`, so they are ~5.7x smaller than the P0 numbers suggested, which
      makes "full snapshots, no diffs" a still easier call.

- [x] P3 — the change set. `document::change::changed(prev, next) -> ChangeSet`
      names what an edit touched, which is what fills a sidecar's `changed` list
      and labels a timeline row.

      Correction to this item's own example (`["b3","b3:p2","r7"]`): **`r7` is not
      a stable name.** Only blocks and pins carry ids on disk. Routes, texts,
      comments, images, waypoints and wire labels are all id-less — the schema
      mints or positions them on load — so a positional route id names a
      *different* route after a reload, which is precisely the instability P2
      removed from asset ids. So the comparison runs on session-local ids (cheap
      and exact, both documents being in memory) and the *reporting* uses names
      that survive a reload:

          document              the document's name, or which block is top
          b3                    a block, including its annotations
          b3:p2                 one pin
          b0:p1->b1:p1          a route, by its two endpoints

      Route endpoints are **absolute**, unlike the on-disk anchor they come from:
      there a route's own port is written bare (`p1`) because the `route` node
      sits inside its owner's `block` node, so the owner is implied. A change set
      has no enclosing context, and one pin must have one spelling — otherwise a
      search for `b0:p1` finds that pin's own edits but misses every route
      touching it. Once both ends are absolute the owner adds nothing to the
      name, since a route lives in the block containing both of them.

      A re-pointed route reports under both its old and new names, so a search
      for either finds the edit that moved it. `block_itself_differs` and
      `document_itself_differs` destructure rather than field-access, so a field
      added later is a compile error instead of a change that silently stops
      being recorded. Nine tests, including the one the endpoint naming exists
      for: a route's name is unchanged across a reload that shifts its id (with
      the id shift asserted, so it cannot pass vacuously).

      Known limits, both from the same root — annotations have no stable name:
      a route's aspect is not distinguishable (a label move, a waypoint drag and
      a recolor all report as the route), and two routes sharing both endpoints
      collide under one name. A facet suffix (`b0/p1->b1:p1#labels`) would fix
      the first and mostly mask the second; deferred until a consumer wants it.

      Deliberately dead until P4: wiring it now would mean a second settle
      detector beside the `Undoer`'s, which is the drift P4 warns against.

- [x] P4 — autosave. P0 settled the open question: **the writer thread is
      required**, and for the opposite reason to the one assumed. Serializing is
      free at realistic sizes (0.1-0.3 ms); the cost is an ~11 ms fsync floor,
      which is two thirds of a 60 fps frame — a synchronous autosave would jank
      visibly on every tick.

      Done: `storage::history` (deflated snapshot + JSON sidecar per entry, the
      sidecars *being* the index), `Durability` as a write argument rather than a
      policy background into the writer, `storage::writer::Writer` (a thread taking
      documents, with `drain` for the one moment saving must block — going away),
      and the settle wiring in `App`.

      **The settle is read from the undoer, not timed again.** `Undoer::add_undo`
      clears the flux, so `is_in_flux()` going false *is* the moment an undo
      point was recorded. That is what "one settle detection feeding both" means
      here — a second debounce beside it would drift, and the two would disagree
      about what an edit was.

      A correction the plan glossed: **assets are synced despite being
      write-once.** `root.kdl` is synced and *references* them, so a lost asset
      write leaves a document that will not load. Only history entries are
      relaxed — they are the ones nothing points at. Coalescing was skipped: the
      undoer's debounce already caps this at roughly one write per settled edit,
      which the writer keeps up with easily. Revisit with measurements.

      Only a container autosaves. A loose file has nowhere to keep a history, and
      rewriting the user's file as they type would be a surprise — it stays on
      save-on-exit.

      Entries carry the **registry's own spelling** of the command that caused
      them. Every button, chord and palette row reaches its `Action` through
      `CommandSet::take`, so recording the id there is the whole mechanism — no
      threading through the chrome, and no second name for an operation the
      palette and the script `command` step already spell. It is held rather
      than cleared per frame, since the settle it labels is about a second after
      the click, and is absent for direct canvas work.

      `sessions.jsonl` records each opening (`{ts, seq}`). Nothing reads it yet,
      but the record has to be made *at the time* — a timeline built later cannot
      work out when past sessions began — and it is what a "revert to where this
      session started" verb would stand on, which is the useful half of the
      `.bak`-on-startup convention the container replaces. It is the one
      read-modify-written file in the container, so it is synced: the whole file
      is replaced, and a torn write would lose every marker rather than the
      newest.

      Deferred to P5, where it belongs: flush-on-hide. The web has nothing to
      flush until it has a writer, so the hook would be a no-op today.

- [~] P5 — web storage.

      **Browser facts, checked rather than recalled** (and one of them corrects
      an earlier note in this plan):

          showDirectoryPicker    Chromium only — not Firefox, not Safari
          OPFS                   Chrome 86+, Edge 86+, Firefox 111+, Safari 15.2+
          createWritable (OPFS)  everywhere *except Safari*
          createSyncAccessHandle everywhere, dedicated workers only

      So writing **must** go through a dedicated worker using
      `createSyncAccessHandle` — that is the only path that works in all four
      browsers, not merely the fast one, and it lands exactly on the dumb
      `(path, bytes)` worker P4 already assumes. Reading is easier than feared:
      `getFile()` works on the main thread everywhere, so only writes need the
      worker.

      Done: `storage::archive` — a container as a single `.zip`, in the two
      scopes decided above. *Archive* carries the history and every asset;
      *Document* carries the current drawing and only the images it places. This
      is the piece every browser needs regardless of which APIs it has, and on
      Firefox and Safari it is the only way a document leaves the browser at
      all. `blockworx <in> -o <out>.zip` packs, `--share` picks the document
      scope, and either form opens again. An archive is untrusted input, so an
      entry naming a path outside the container is refused.

      Found by running it rather than by testing it: converting a container to a
      zip went through a `Document` and so carried no history, which made an
      archive and a shared export byte-identical. Container-to-container
      conversions now copy the container.

      Still open: OPFS behind the `Storage` trait, the worker, FSA for a real
      directory on Chromium, `navigator.storage.persist()`, flush on
      `visibilitychange → hidden`, and the P5b document manager. **None of that
      is verifiable in this environment** — there is no browser here, so
      `cargo clippy --target wasm32-unknown-unknown` proving it compiles is the
      only check available, unlike every phase so far.

- [ ] P5-old — web storage: OPFS as the working store, `navigator.storage.persist()`
      requested (OPFS is origin-private and evictable under storage pressure —
      losing it would violate the whole point). File System Access API where
      available so a Chromium user edits a real local directory, with the handle
      stashed in IndexedDB and permission re-requested on revisit; zip
      download/upload as the universal fallback. Spike first: whether to drive
      `FileSystemDirectoryHandle` through `web-sys` directly or via a wrapper
      crate, and whether the `zip`/`flate2` stack builds clean for wasm.
      OPFS root holds **several containers side by side**, exactly as a native
      folder does — the web is multi-document, not one resettable scratch
      drawing. Note FSA and OPFS hand back the *same* type
      (`FileSystemDirectoryHandle`), so there is one web implementation of the
      P1 trait, differing only in which root it was handed; and both write via a
      swap file committed on close, so P0's atomicity comes from the platform.
- [ ] P5b — the web document manager. OPFS is origin-private: the OPFS Explorer
      extension is a *developer* affordance and there is no user-facing path to
      those folders at all, so this is not an "open" dialog — it is the whole
      file manager the OS gives native for free. Open, create (as "Untitled",
      renamed in place; a name modal before the canvas appears is the paper cut
      that got the camera persistence reverted), rename, duplicate, **delete**
      (without it a user cannot reclaim the space at all), import by upload or
      drag-and-drop, and **storage-usage accounting** against
      `navigator.storage.estimate()`, since quota is finite and otherwise
      invisible. Native needs none of this, but does need something better than
      today's bare-launch default of `demo.json` — a recents list or an open
      dialog.
- [~] P6 — timeline tool. `tools::timeline` reads the sidecars alone, so opening
      it costs a few hundred bytes per entry rather than a snapshot each — which
      is what the sidecars were for. A snapshot is decompressed only when a row
      is actually restored. Rows are cached against the history length and
      rebuilt when it grows, not per frame.

      Session markers come from `sessions.jsonl` rather than from a
      `significance` field on entries, which this item used to propose. A
      separate record turned out to be better: a session that edited *nothing*
      still gets a row, and that is exactly the case where the marker is the only
      evidence it happened. No second storage tier, and no field on every entry.

      **Restoring is an ordinary edit**, which is what makes it safe. The undoer
      picks it up like any other, so it can be undone; autosave records it as a
      new entry rather than rewriting the past; and the document keeps its
      current name, since restoring a drawing must not also rename what is open.
      The block path resets, because the old document may not hold the block
      being viewed.

      **Compaction** (`storage::compact`, the `compact-history` command) is the
      other half of keeping a history: entries thin by age — everything within a
      day, then the last entry of each hour, since what an hour of work finished
      as beats what it started as — and assets are collected by reachability over
      `root.kdl` and every surviving snapshot. Two guards. The newest entry is
      never thinned, and an entry whose sidecar was lost has no age to judge by,
      so it stays. A snapshot that cannot be *read* aborts asset collection
      outright rather than counting as referencing nothing: keeping a file
      nothing needs costs disk, while deleting one something still names loses an
      image for good. It is a command, never a timer — deleting history
      automatically would mean the app silently discarding the thing the history
      exists to preserve — and it drains the writer first, since an entry still
      queued would be invisible to the decision. This is what `Storage::remove`
      was left out of P1 for.

      The toggle lives in the **history cluster**, beside undo and redo, where
      the rest of the document's history already is — the palette alone was not
      somewhere anyone would think to look. It is drawn even where no history is
      kept (a loose file, a zip, the web): a control that vanishes teaches nobody
      the feature exists, whereas a disabled one with hover text says why it is
      out of reach. Compaction stays palette-only; it is maintenance, not
      navigation.

      Deferred: **preview on hover**. Rendering an old document needs a second
      `Drawing` and painter pass, which is a real piece of work rather than a
      detail of this one. A cheaper version worth considering first is showing
      `changed(current, snapshot)` — what restoring *would* do, which reuses P3
      and is a more useful question than what the old state looked like.

- [~] P7 — the multi-writer hazard. Two editors on one container would not
      merely race: each holds a whole document in memory and rewrites `root.kdl`
      from it, so the second to save silently discards everything the first did,
      and the history records both sides of the fight as one person's edits.

      The exclusion is an **advisory lock on the `lock` file**, via `File::try_lock`
      (stable since Rust 1.89, so no dependency). That is better than the pid
      file this item originally described: the kernel drops the lock when the
      holding process exits, however it exits, so **a stale lock is impossible**
      and a crashed session cannot leave a container permanently unopenable. A
      pid file would need a liveness check and would get it wrong the first time
      a pid was reused. The file's *contents* are a separate concern — they name
      the holder, so a refused session can say who has it rather than only that
      someone does.

      A session that cannot claim its container opens read-only: no autosave, no
      save on exit, and the window title says so. Verified across real processes,
      not only in-process: a second process is refused and names the holder's
      pid, and the container frees when the first exits.

      Still open: `navigator.locks` for the web, which waits on P5 along with
      everything else there. Also unguarded: `convert` writing *into* a container
      an editor holds. It takes no claim, being a one-shot read in the normal
      case, so a concurrent editor would overwrite it on the next autosave.
      Worth a claim if conversion ever becomes something people run against live
      containers.

- [x] Red flashes over the chrome (2026-08-25) — the toolbar, the two corner
      clusters and the content path are child `Ui`s of the canvas `Ui`, and
      they took their ids from `UiBuilder::id_salt`, which mixes in the
      *parent's* auto-id counter. The inline editor renders into that same
      `Ui` one step earlier (`Canvas::show`'s `render_pending_edit`), so every
      time an editor opened or closed — placing a pin, naming a block, undoing
      either — every chrome widget kept its rect and changed its id, and egui
      0.35's `warn_if_rect_changes_id` (on by default in debug builds) outlined
      the whole toolbar in red for that frame. Chrome now names itself:
      `tools::chrome::Panel` is the id, handed out as an *explicit*
      `UiBuilder::id`, so a panel cannot be renumbered by a sibling. The
      per-panel measurement keys (toolbar rect, strip/nav widths, selection
      overlay rect) come off the same enum instead of loose strings.

Not in scope, deliberately: re-persisting the camera or the navigation path.
That was built and reverted (items 16-18 above) for paper cuts in use, and
autosave is not a reason to relitigate it.

Nomenclature review (2026-08-21) — the vocabulary grew a few private metaphors
that cost every new reader a decoding step. Renames agreed, to be executed as
mechanical sweeps (code, comments, playbook references), each with
`cargo xtask ci` green:

- [x] `suppose` → `preview` (2026-09-21) — `ToolTrait::preview`, `PreviewPhase`,
      `Previewed`, `preview_drag`/`preview_shapes`/`preview_resize`/
      `preview_routes`/`preview_pin_drag`, `Presentation::routes_previewed`,
      and the prose with them. The duplicate concept is gone: one word for one
      idea.
- [ ] rider → fixup: the umbrella term for consequence ops that travel in the
      same commit as the primary edit. `routing::solve_rider` becomes
      `reroute_fixup`; the growth/rename/payload/waypoint "riders" in
      `src/edit/` become fixups in comments and doc references
      (flag-day-playbook 12·3 included). The git `--fixup` association gives
      the right intuition for free: same commit, completes the change.
- [ ] `Gesture::author` → `Gesture::record`: reads as a noun at call sites;
      `record` pairs with the log-narration argument it already takes.
- [ ] `Promoted` / "promote" → `SolvedWaypoints` / "write back": the solver's
      corner lists that the gesture diffs and writes back as waypoints.
      "Promote" invites "from what to what?"; the plain phrase answers it.
- [ ] `OpCodes::narrate` → `describe`.
- [x] `Nonce` → `SubmissionId` (2026-08-23) — closed by deletion (Phase 1b,
      2026-08-28): there is no submission to correlate an answer with, so
      undo identity is the `Rev` the repo assigns. `Nonce`, `next_nonce`
      and `SessionError` are gone outright.

Reviewed and deliberately kept (established or guessable jargon): gesture,
seal, mint, arm/armed, flag day. *`materialize` left that list on 2026-09-20:
it is not a verb engineering uses much, and the pass's own doc comment had
already reached for the right one — wires are **reconstructed** from their
stored corners. `reconstruct_route(s)`, `reconstruct_corners_direct`,
`widget/reconstruct.rs`, and `present_document` for the document-level pass,
which was named for its mechanism rather than its purpose.*

Phase 7 re-planned (2026-08-26) — the author's call repealed the one-way
door: KDL stays alive both ways as the durable interchange format. The
demolition playbook is rewritten (13 steps, 5 stages): a new `raise`
projection (folded document → schema structs) feeds the existing KDL
encoder for "Export → KDL"; the reader, `lower`, and the socket converter
survive permanently; the JSON codec, legacy runtime model (`src/document/`),
`.bwx` container machinery, and `--output`/`--share`/`--no-write` still
die. D1 answered: serverless boot opens as normal (courtesy load stays);
import/export of KDL against the current log is the serverless
persistence story. `docs/kdl-format.md` stays. Next up: step 2, `raise`.

- [x] Phase 7 steps 2+3 (2026-08-26) — `src/schema/raise.rs`: the folded
      document back as schema structs, feeding the existing encoder. `raise`
      is the canonical form (blocks depth-first from top with siblings by
      position, pins by slot, content-derived asset ids), so the round-trip
      test is `raise(refold(encode(raise(doc)))) == raise(doc)` plus direct
      field assertions against `lower`'s RICH fixture — symmetry alone would
      hide a dropped field. Export gained the KDL format (whole document and
      selection-as-standalone, native + wasm), raising the optimistic
      document so the file holds what the screen shows. The inverse value
      maps (`schema_pin_side`, new `schema_label_side`/`schema_pin_dir`)
      left their `cfg(test)` gate.

- [x] Rev-addressed export + a rename (2026-08-26) — `raise` lived one day:
      the author asked what it really means, and the answer ("the document
      projected back into the on-disk vocabulary") was already the
      codebase's own word, so it is `schema::project` now, renamed while
      fresh instead of queued as debt. `examples/export.rs` exports the
      server's document at any rev from the console: the `Welcome` carries
      the whole log and rev N is the fold of its first N commits, so
      truncate-fold-project needs no protocol or server change; verified
      live against a scratch server (head/rev 1/rev 0/beyond-head).
      `schema` went `pub` for it (with `decode`/`encode`/`kdl`), which is
      honest now that it is the permanent interchange surface. Correction
      recorded: D7's `examples/convert.rs` was a decision, not a build — it
      still needs writing (stage 3). An in-editor rev picker stays open
      until a history-browsing story wants the client to retain the log.

- [x] Phase 7 step 4, the round-trip gate (2026-08-26) — `schema::roundtrip`:
      every repo-root `.kdl`/`.json` that parses as a document (21 of them,
      scale grids included) and every level embed round-trips to an equal
      projection, plus a byte-stability golden under the runner's
      `BLOCKWORX_UPDATE_GOLDENS` switch. The gate caught a real bug on
      arrival: pins sharing one slot ordered by minted id — random per
      lower — so `test3.kdl`'s four `w3` pins swapped names across the trip;
      the tiebreak is now document state (body rect, then name). Recorded
      non-documents: `carloni.json`/`foo.json` are an older per-block dump
      format; `connect_blocks.kdl`/`recorded_level.kdl` are scriptless level
      fragments. One named size-cap skip (untracked 16.7 MB
      `block_100.json`); every tracked document is in the gate. Stage 2 of
      the demolition playbook is complete. Executed by two Sonnet sub-agents
      (inventory + implementation) with corrections relayed mid-flight.

- [x] Phase 7 step 5, `.json` → `.kdl` (2026-08-26) — the six schema-JSON
      documents converted via the headless `blockworx <in> -o <out>` to
      `-json.kdl` twins (every bare stem already named a *different* `.kdl`
      document), each pair proved fold-and-project equivalent before its
      original was deleted (`git rm -f` for the three tracked, carrying
      their uncommitted edits into the conversions). `render_path_tests`
      re-pointed at `demo-json.kdl` with assertions passing unchanged; the
      CLI default follows `demo.json` → `demo.kdl`; the gate's must-parse
      list renamed. `block_100-json.kdl` (5.34 MB) sits over the gate's
      5 MiB cap like its original did — the named skip, with
      `block_100.kdl` as the covered twin. Left for the author:
      `carloni.json`/`foo.json`, the unconvertible old dump format — delete
      knowingly or stash outside the repo. Sonnet sub-agent executed;
      integration, the CLI-default fix, and the commit were mine.

- [x] Phase 7 step 6 (2026-08-26) — smaller than planned, on inspection:
      `unrouted_scale_scene` and `render_path_tests` were already off the
      legacy model (editor swap and step 5 respectively), and the level
      guard test's legacy oracle deliberately stays until stage 4 — its
      first draft contradicted D2, now corrected in the playbook. The real
      work was `closed_router_tests`' two cwd-relative `demo.kdl` reads
      becoming one `include_str!` const, with the missing-file if-let arm
      gone (a compiled-in fixture cannot be absent). Router suite passes
      unchanged. Haiku sub-agent executed; review trimmed a narrating
      comment and two pointless bindings.

- [x] Phase 7 step 7, the "nothing left" audit (2026-08-26) — stage 3
      closes. A Sonnet sub-agent swept `schema_convert`/`crate::document::`/
      `schema::json` (plus alternate spellings and bare `parse_json`) over
      src/xtask/crates/examples and classified all 48 hit files: 15 die
      with steps 8–10, 31 are survivor call sites on D3-re-homed
      vocabulary, and one real finding — `TOP_BLOCK_DEFAULT_{WIDTH,
      HEIGHT, RECT}`, live in `edit/create.rs`, had lost their D3
      disposition when the playbook revision condensed the original's list
      into an ellipsis. Repaired: they re-home to `edit/create.rs`, their
      one real consumer. One recorded grep false positive
      (`crates/doc/rev.rs` names the doc crate's own Document). Claims
      verified: JSON dies cleanly with step 8; the level guard test is the
      only surviving legacy-oracle test. Stage 4 is unblocked.

- [x] Phase 7 step 8 (2026-08-26) — import narrows to KDL + images:
      `embed.rs` deleted (230 lines, single consumer), the `.json` arm and
      `paste_from_embedded` gone, PNG/SVG unconditionally plain images,
      both dialog filters narrowed, stale doc comments in
      app.rs/tool.rs/widget::clipboard updated. A negative test pins the
      behavior change: a `.json` file is rejected by extension, bytes never
      inspected. Corrected on execution: `clipboard_from_document` does NOT
      die — the draft carried that from the one-way-door plan; it already
      is the lowered paste path the KDL arm keeps. Sonnet sub-agent
      executed; diff reviewed clean, no stranded deps (base64 has three
      other users).

- [x] Phase 7 step 9 (2026-08-26) — the container demolition, ~2,900 lines:
      `src/storage/{archive,compact,container,fs,history,lock,writer,mod}`
      deleted; `atomic.rs` re-homed to `src/atomic.rs` (its three surviving
      callers: export and the two dev-tree theme/font writers), simplified
      on the way (`Durability` had one reachable variant left) and cfg'd
      out of wasm — the browser has no filesystem, and the visibility drop
      from pub would otherwise trip dead-code lints there (caught by my CI
      run; the agent had verified native only). `DocumentSource` and the
      convert/zip machinery left app.rs; the courtesy load now parses the
      `.kdl` straight through `schema::model` + `lowered_session`,
      retiring app.rs's last two `schema_convert` uses a step early; the
      window title names the file a document was actually opened from.
      `--output`/`--share`/`--no-write` gone; `path` stays. The P7
      advisory lock died with the container: nothing writes implicitly,
      so there is nothing to exclude. Full suite 678 green (the deleted
      modules carried ~50 tests of their own).

- [x] Phase 7 step 10, in three green commits (2026-08-26) — the legacy
      model's end. 10a: the D3 re-homing sweep across 49 files (GridPos →
      GridPoint via the edit::lower conversion layer; LinearDistance →
      FracVal with route geometry keeping FracVal at every signature so
      the no-naked-float rule holds; Lock/TagVisibility → edit::naming's
      live twins; RouteEdge/RouteDirection into presentation;
      TOP_BLOCK_DEFAULT_* into edit::create per the audit rescue; store.rs
      split with the live trio in presentation::store). 10b: the canvas
      speaks block_model::Asset — registry keyed on AssetHash (content
      hash replacing content digest, same identity), ASSET_LIMIT refusal
      preserved as one shared predicate, image_data/asset conversion pair
      deleted. 10c: src/document/ (12 files, 2,683 lines), schema/json.rs,
      store.rs, and 53 legacy-model tests deleted; asset_to_bytes into
      schema::lower; level guard narrowed to the lowered oracle (D2);
      EdgeId inlined as a plain struct (define_id!'s last invocation —
      a macro serving one type lost to the type written out); lids off,
      two hidden stragglers deleted rather than re-allowed. All playbook
      verification greps empty. Three Sonnet sub-agents, one per part,
      each reviewed; one stalled mid-refactor and was resumed; one
      confessed and recovered a git-stash slip, verified clean after.
      internment/flate2/zip now user-less — step 11's prune list.

- [x] Phase 7 step 11 (2026-08-26) — stage 4 closes. Deps: flate2, zip,
      internment pruned with their comment blocks (base64/blake3 stay);
      the struct_field_names exception stays with its justification
      narrowed to KDL. Prose: docs/kdl-format.md drops its JSON and
      container sections and gains a "Round trip" section stating the D9
      contract (project is the canonical form; exporting normalizes a
      hand-written file without changing the diagram); schema::lower's
      "Not an importer, and never will be" header — false since the
      repeal — now describes the import direction it actually is; the
      toolbar's invented "timeline" comment fixed; ten present-tense
      container/JSON falsehoods reworded across schema/, with genuine
      historical records (version enumeration, past-tensed collab note)
      deliberately kept. Session memory updated the same way. Parked
      follow-up: schema::model/enums still derive Serialize/Deserialize
      with nothing left that serializes them — the hand-rolled KDL codec
      is the only path; removing the derives and their attrs is a small
      cleanup for stage 5's by-appetite pass.

- [x] Phase 7 step 12, P4 (2026-08-26) — `Scope { Root, Block }` and
      `Resolved { Root, Block, Absent }` in `src/path.rs`, promoting
      `edit::create`'s locally-discovered three-way answer; the wire keeps
      NULL and `from_wire`/`wire_id` are the editor's only two spellings of
      it. Core seams (path, Drawing, create's promotion) landed by hand;
      the 33-file chase by a Sonnet agent under eight conversion rules.
      UnlockedScope carries a Scope; PinAccents keys on (Scope, PinId);
      four root special-cases collapsed onto resolve(). The suite proved
      the invariant's worth immediately: present_document pushed the
      root's raw index key as a path segment — correct only because the
      sentinel round-tripped — and the honest conversion (root = empty
      path) landed with two briefly-failing tests as witnesses. Ten
      justified NULL spellings remain (conversions + wire bridge + two
      dummy-id tests). 626 tests, snapshots unmoved. Remaining before
      phase 8: step 13 by appetite (P3 Drag<S>, P6 re-evaluation, the
      serde-derive cleanup) — none of it blocking.

---

Phase 8 complete (2026-08-26): `collab-document-log` merged to `main` as
a pull request (the repo's rules route main through PRs). The
server-centric rewrite is the mainline. What remains, none of it blocking,
in rough priority order:

Nomenclature backlog (agreed 2026-08-21/23, mechanical sweeps, each with
`cargo xtask ci` green — see the rename section above for full rationale):
- [ ] `suppose`/`Supposing` → `preview`/`PreviewPhase` (also gives P3 the
      `Preview` name it wants to build on)
- [ ] rider → fixup (`solve_rider` → `reroute_fixup`, comments, playbook refs)
- [ ] `Gesture::author` → `Gesture::record`
- [ ] `Promoted` → `SolvedWaypoints`; "promote" → "write back"
- [ ] `OpCodes::narrate` → `describe`
- [x] `Nonce` → `SubmissionId` — closed by deletion (Phase 1b): undo
      identity is `Rev`
- [ ] marquee `move_group`/"Move Group"/`pin_group_tool` → selection
      spellings (was a Phase 5 prerequisite; a nicety since D6 chose
      `area`)

Deferred from phase 7:
- [-] `examples/convert.rs` — D7's bulk KDL → server-DB loader through the
      real ingress; decided and specified, never built (the export twin
      exists). Wanted before any bulk migration of documents into a server.
      Won't-do (2026-08-28): there is no server DB to load; conversion is
      the Phase 2 KDL → JSON pass (single-author playbook, D13).
- [ ] `carloni.json` / `foo.json` — unconvertible old dump format at the
      repo root; author decides: delete knowingly or stash outside the repo.
- [x] Idle refresh of `document.json` — done (2026-08-28): the marker
      proved to be exactly the nag predicted here, on the very first
      manual pass ("why the •? every change is in the log"). The
      projection now rewrites itself once the head sits still for 2s
      (`App::refresh_projection`, repaint-scheduled), and the `•` is
      gone — staleness never reaches the user. Unrecognized
      (hand-edited) files stay behind an explicit Save.
- [x] `schema::model`/`enums` serde derives — done by inversion (2026-08-28,
      phase 2c): D13 gave them their callers back. The derives *are* the
      document codec now; the `#[serde(...)]` attrs are the format's
      spelling and are pinned by `docs/json-format.md` and the goldens.
- [ ] P3 (`Drag<S>`) — one lifecycle for the fourteen tools' hand-rolled
      drag states; wants the `preview` rename first. By appetite.
- [ ] P6 (`Drawing<Reading>`/`Drawing<Writing>`) — re-evaluate now that the
      tree is smaller; ~151 signatures. By appetite.

Collab follow-ups (decisions on record, work not started):
- [-] R3 — resume on reconnect: a dropped socket keeps its queue and
      resumes (decided); today a disconnected editor stays disconnected.
      Won't-do (2026-08-28): there is no socket — F8 deletes concurrent
      editing outright (docs/single-author-playbook.md, Phase 1).
- [-] Web client — the exit criterion deferred it; ewebsock keeps the door
      open, and D1's serverless answer needs a browser story (no fs: import/
      export are up/downloads, but what a bare web session opens on).
      Won't-do (2026-08-28): ewebsock is gone; the browser story is the
      OPFS container and the share bundle (single-author playbook, Phase 8).
- [-] Local sqlite mode (`blockworx <file.db>`) — D1's escalation path if
      the server-beside-the-editor workflow chafes.
      Won't-do (2026-08-28): the durable store is the `.bwx` directory with
      a JSONL log (single-author playbook, D1/D3), not a database.
- [x] `blockworx log` dump (2026-08-28) — `blockworx log <container.bwx>`
      prints one line per record: rev, local wall time, author, kind
      (edit / undo of rN / redo of rN), op count, label, oldest first and
      column-aligned. It replays the file's own bytes through the store's
      verifier without taking the lock or repairing a truncated tail, so
      it is safe to run against a container the editor has open; a log
      that stops verifying prints the trail up to the break and then the
      break as a miette report with the offending line under an arrow,
      exiting non-zero. Formatting is `store::history::{rows, lines}` over
      `(&Repo, &[Entry])` — the same reader the in-editor panel uses, so
      the console and the panel cannot disagree about what a record says.
- [x] In-editor rev picker / history browsing (2026-08-28) — the time
      machine, Phase 4. See the Phase 4 entry below.

Paper cuts on record:
- [x] Dropping a block onto another block silently authors nothing (also
      single-client; found at the phase-7 demo). Diagnosed 2026-09-17: the
      refusal is *correct* — `move_blocked` rejects a destination
      overlapping any other routing shape, a shape the router routes around
      not being able to share a cell — but it lived inside the emitter,
      which returns without pushing ops, so the tool never learned and the
      drag undid itself on release in silence.
      Decided (user): show it during the drag, with a said refusal as the
      fallback where there is no drag to show. Redrawn 2026-09-18 after a
      design cycle (canvas: "Move Conflicts — Overlap Hatching"): the first
      drawing — a red frame over the landing — read as "this block is in
      error" rather than "these two cannot share space". **Option B chosen:
      hatch the overlap itself**, because a destination can conflict with
      several shapes at once and only the overlaps can say which (user).
      Two states, one vocabulary:
      - **refusing** (red hatch, outlined) while a drag is in the hand, over
        only the overlaps that *refuse* it — a group move is refused by a new
        overlap alone, so hatching one the selection arrived with would claim
        a refusal that is not happening;
      - **standing** (amber hatch, no outline) for an overlap the document
        already holds. A paste has no overlap check and is never refused, and
        the group rule exempts what a selection arrived with so it can be
        nudged away — so overlap is a legal, reachable state. It is a routing
        fault the router has to work around (user), so it is marked until it
        is cleared.
      Blessing the goldens turned up something: `store::fixture::edits(n)`
      gives every block the default rect, so the egui shell's chrome
      snapshots have had three blocks stacked at the origin all along, with
      their titles overprinted. The hatch is right; the fixture is not a
      scene. Left as it is — those snapshots go with the egui shell.
      The drag half is done:
      `edit::geometry::move_refused` is the one resolver the preview and the
      commit both consult, keyed on the *gesture* (`Moving { Alone,
      AsAGroup }`) rather than the shape count — a group of one is still a
      group, and exempt from the overlap it already had. `Drawing::landing`
      → `Landing { Free, Refused }`; both drag tools draw
      `render::draw_refused_landing` over the destination, in B08, the base
      the read-only dot already uses.
      - [-] The same rule refuses a **keyboard nudge**, which has no drag to
            tint. Skipped (user, 2026-09-17): the drag half carries the
            confusion — a shape that follows the cursor and snaps back — and
            a nudge that does not move, with the thing in the way plainly
            there, is not the same paper cut. No surface fits it anyway: the
            toast is reserved for what needs attention and kept rare to stay
            noticeable (`web/src/notices.rs`'s own policy), and the
            instruction line is per-tool state rather than an event channel.
            Were it wanted later, flashing the refused landing the way a drag
            draws it reuses the vocabulary already there and needs no new
            words.
- [ ] The undo stack is not parked with the repo on tutorial enter/exit
      (pre-existing; surfaced in the 1b review): entering a tutorial with
      a non-empty stack can trip record_history's frame-close
      debug_assert. Park/clear the stack in App::adopt or alongside
      Parked.
- [-] `cargo xtask demo` leaks its children on SIGTERM (documented in the
      demolition playbook; `server`/`client` subcommands are unaffected).
      Won't-do (2026-08-28): the three subcommands were deleted with the
      server (single-author playbook, Phase 1).

---

Single-author pivot (2026-08-28): user testing found the concurrent,
server-synchronous model too optimistic for MCAD-shaped documents. New
direction — single author, local text-based container, review comments
instead of co-editing, undo across restarts, the log as audit trail. The
plan is `docs/single-author-playbook.md` (branch `single-author`); it
supersedes the collab follow-up items above (R3, web client via ewebsock,
local sqlite mode → won't-do) and absorbs `blockworx log`, the rev picker,
and the `Nonce` rename (Nonce is deleted outright in its Phase 1).
Amended 2026-08-28 after design review: D10 (uuids stay; friendliness is
a rendering concern), D11 (write-only projection, sticky creation-order
naming), D12 (log hash chain + folded-state stamps), annotated log codec,
and git-merge hardening (.gitattributes in the container template).
Amended again 2026-08-28: D13/D14 — JSON everywhere. KDL hand-authoring
found not useful in testing; log is JSONL (serde backend, no hand-rolled
codec), projection is document.json, sidecars are JSONL, tutorial levels
and scripts convert to JSON, and the ~2k-line KDL parser/encoder is
deleted in Phase 2. Repeals the 2026-08-26 "KDL is the one interchange
format" decision; the caller-less serde-derive cleanup item above inverts
(the derives get their callers back).
Amended once more 2026-08-28: D9 revised + D15 added — identity is
account-shaped from day one (commit/comment records carry an author
field from Phase 2, locally populated until auth ships); licensing/web
authentication is a deferred parallel workstream with the privacy
contract stated (auth sees entitlement metadata, never document data);
Mode::{Author,Review} is the future entitlement seam.
Amended 2026-08-28 (later): D16/D17 — the choreographer. Any commit gets
a synthesized animated diff (ops -> depicted intent, one rule per
mutation-inventory row; L0 build-preview for the history browser, L1
tool pantomime for tutorials); narration is a `note` record kind in the
log; tutorials re-found as logs played through the choreographer, so the
recorded-input pipeline retires. D14 amended: no JSON script format —
the KDL parser is quarantined after Phase 2 (legacy levels only) and
dies in the new Phase 7; web moves to Phase 8.
Ratified 2026-08-28: D1 (document = directory container), the
append-on-commit save model (no quit-without-saving; persistent undo is
the escape hatch), and D6 resolved to `area` (Simulink's name for the
feature; no marquee-"group" collision, so the selection-spelling sweep
drops to a nicety). Fixture triage done the same day: the five rotted
root .kdl files, the .bak files, and stale demo.bwx/ deleted.
- [x] Phase 1 — de-collaboration. 1a (2026-08-28): server, wire,
      convergence suites, the transport half of `src/collab.rs`, and the
      xtask collab commands deleted. 1b (2026-08-28): `Host` +
      `ClientSession` + `Link`/`LocalHost` collapsed into
      `blockworx_doc::repo::Repo` (folded document + `Vec<Commit>` log +
      undo journal, one synchronous `submit`); `Nonce` deleted and undo
      re-keyed on `Rev`; `Document<R: RevKind>` collapsed to `Document`
      (`Confirmed`/`Provisional`/`RevKind`/`predict` gone, ~28 files swept);
      `src/collab.rs` and the CBOR codec (`crates/doc/src/encode.rs` +
      `goldens/`) deleted, `ciborium` dropped from the app crate (the doc
      crate keeps it for `content_hash`). The editor holds a `Repo`
      directly, and the undo stack learns a frame's edits from
      `Repo::revs_after(watermark)` instead of a drained submission list.
- [ ] Phase 2 — the `.bwx` v2 container and the JSON migration: JSONL
      commit-log codec, append store, open/save/recent, advisory lock,
      fixture hygiene + conversion, KDL codec deleted
  - [x] 2a — the durable store layer (`src/store/`, no UI). `record.rs`:
        `LogRecord` (rev, `kind` = edit/undo/redo, unix-millis `wall_time`,
        D9 `Identity`, label, ops each carrying D3's write-time `named`
        annotation, D12's `parent` chain link and `state` stamp), with
        canonical bytes (sorted keys, no whitespace) defined once and a
        byte-stable golden (`src/store/goldens/log.jsonl`). `replay.rs`:
        two passes — chain first, then fold — so a rewritten record is
        reported as a chain break at its successor and a stamp mismatch
        under an intact chain can honestly be named fold drift; three
        typed outcomes (dropped tail / break with line+column+prefix repo
        / drift). `container.rs`: the `.bwx` directory, append+fsync,
        tail truncation, the D1 `.gitattributes` template. `lock.rs`:
        pid+since advisory lock, released on drop, stale lock broken
        (Linux probes `/proc`; elsewhere a lock we cannot verify is left
        alone). `handle.rs`: `Store` — the only door, `&Repo` out and
        submit/undo/redo in. Doc crate gained `Repo::fold_one` (the
        per-record replay hook) and `Hash::bytes`; `Hash` now serializes
        as hex in human-readable formats so the log is greppable (CBOR
        bytes, and therefore `content_hash`, unchanged).
  - [x] 2b — the container wired into the app (native; the web keeps
        today's scratch session). `src/doc.rs`: `Doc::Scratch(Repo) |
        Doc::Attached(Store)`, the editor's one document handle — reads
        through `repo()`/`document()`, writes through
        `submit`/`undo`/`redo`, and no `&mut Repo` for an attached
        document, so "folded but not logged" is unrepresentable. The
        gesture bracket split (`gesture::seal` runs the rider and seals;
        `gesture::close` is the scratch-repo door the scripted driver
        keeps), so both homes end a gesture the same way. `Identity` is
        read once at construction and again from the restored
        preferences, which gained an `author_name` override (no UI —
        D15). File flow in a new toolbar File menu (cascading, beside
        Export/Import): New, Open container…, Open document…, Open recent
        ▸ (8, persisted via the eframe storage seam, a dead entry dropped
        on a failed open), Save as container… — which seeds a new
        container with the session's whole log through `Store::seeded`
        (new `Step::Seed`: logged like an edit, journalled like nothing).
        `blockworx <path.bwx>` attaches; `.kdl` keeps its courtesy load;
        `--author`/`--replay` leave a container closed rather than break
        their write-nothing promise. Read-only containers withhold every
        writing command through the registry (`CommandContext.writability`
        + `CommandId::writes_the_document`, gated once in `CommandSet`),
        with the reason in the title bar and a top-left notice. Toolbar
        snapshots regenerated for the File button.
  - [x] 2c — the write-only projection, and the document format goes JSON
        (D11/D13/D14). `document.json` is written by `schema::project`
        under a stamp (`rev` + `content_hash`) that is the file's *first*
        field, so load reads it with a one-field header struct and never
        models the body; `store/projection.rs` owns the stamp,
        `Found`/`Freshness`, and the atomic write. Refreshed on Save (a
        new registry command, gated on `Doc::saving()` — a writable
        attached container — rather than on writability, since it writes a
        file and not the log), on clean exit, and after Save As; staleness
        shows as a `•` in the title, and a stamp no fold of this log
        wrote warns in tracing and in the canvas notice (which grew from
        the read-only notice into `show_document_notices`). No idle-timer
        write — deliberately, and not planned. Sticky names: `b<N>`/`p<N>`
        and the order of every entity list now come from **first
        appearance in `Repo::log()`** (`project` takes a `&Repo`, not a
        `&Document`), so a moved block diffs as its own coordinates and
        nothing else; the history-independent canonical form is given up
        for it, `content_hash` being the equality oracle that replaced it.
        Documents are JSON: export/import speak it, `schema::model` grew
        `to_json`/`parse_json` (serde's derives are the codec, inverting
        the caller-less-derives item), the round-trip gate re-based on
        `fixtures/*.json` with `canonical.json` as its golden, and
        `xtask autogen scale` emits JSON. The KDL *encoder*
        (`src/schema/encode.rs`, 305 lines) and its golden are deleted;
        the parser is quarantined (D14) behind tutorial levels and the
        `.kdl` import/courtesy-open migration door — a deliberate
        deviation from the playbook's tutorial-only quarantine, since
        users have `.kdl` files and the importer is the converter. Twelve
        repo-root documents converted into `fixtures/` and deleted;
        `docs/json-format.md` written, `docs/kdl-format.md` bannered.
- [x] Phase 3 — persistent undo (F6): the journal is rebuilt by replay, so
      undo/redo depth survives save, quit and reopen. `JournalAs` (which
      already stated the whole undo/redo policy in one place) went public
      and grew the `of` rev on its two step arms; the paired *pop* moved
      into `Journal::push`, so `submit`, `undo`, `redo` and the new
      `Repo::replay_one(commit, JournalAs)` all run one implementation —
      a second copy of this policy is a silently corrupted undo stack.
      `replay_one` returns the folded document exactly as `fold_one` does,
      so a verified replay still folds each record once and D12's stamp
      check is unchanged; what it folds is the *record's* commit, never
      the inverse the journal would rebuild, because the stamp is a claim
      about the bytes on disk. `impl From<RecordKind> for JournalAs` is
      the boundary: the durable spellings stop in `src/store/record.rs`
      and `crates/doc` stays free of store types. `Repo::redo_revs()`
      joins `undo_revs()`; `Repo::step` lost its `unreachable!("an edit is
      not a history step")` to a two-variant `Direction`.
      Editor side: `UndoStack::reconstructed(&Repo, &BlockPath)` seeds the
      stack from a replayed journal, both ways, and `App` calls it wherever
      it takes a document (startup and `take_document`), so
      `record_history`'s frame-close pairing assertion holds from the first
      frame and `can_undo`/`can_redo` show the reconstructed depth. A
      reconstructed step has no view half — `Step::View` entries are
      session sugar and a selection names tools this process never held —
      so it lands in the scope the document opens at with nothing
      selected. Save As still journals nothing (seeded commits are the
      container's past, `Step::Seed` unchanged); reopening that container
      is where its depth comes back, since seeded records are edit records.
      Tests: the live/reconstructed equivalence property
      (`store::tests::persistent_undo`) tells one random history twice —
      straight through, and through restarts the generator picks —
      asserting equal journals at every reopen, identical log bytes, and
      an identical `content_hash` sequence from undoing all the way down
      and redoing all the way up; plus the reopen-mid-undo,
      fork-after-reopen and fork-before-restart cases, the doc-crate's own
      replay suite, and two app-level F6 tests driving the real dispatch
      across a drop and a reopen.
- [x] Phase 4 — the audit trail surfaced (F7, 2026-08-28). Two surfaces
      over one reader. `blockworx log <container>` (see the closed item
      above) and the **time machine**: a History panel hanging off a new
      clock toggle in the lower-left history cluster, listing commits
      newest-first with the present at the top. Picking a rev folds the
      log prefix (`Repo::folding` over `log()[..rev]`) and shows *that*
      document on the same canvas — the playbook's one read-only
      presentation, second consumer.
      Shape: `Viewing::{Head, Past(Rev)}` in `src/doc.rs` is the state and
      `App::time_machine: Option<TimeMachine { at, folded }>` holds the
      fold, so the cache is minted exactly when the selection moves and
      "viewing but nothing folded" is unrepresentable. Every canvas read
      routes through `viewed(&doc, time_machine)` — drawing, hit-test,
      nav tree, palette, content path, export — while every write still
      goes to the head. DocStamp-gated caches (`DocIndex`, `Presentation`,
      the spatial tree) needed no change: a fresh fold mints a fresh
      stamp, verified rather than assumed.
      Withholding: `CommandContext` gains `viewing` beside `writability`,
      and `CommandSet::allows` is the single gate — a read-only container
      drops every writing command, a past rev drops them too *except*
      `RestoreRev`, and undo/redo go with the rest (they act on a head
      nobody is looking at). `App::may_write()` folds both answers for the
      doors the registry does not guard: the write door itself
      (`App::submit` now refuses rather than logging a refusal from the
      handle), the undo walk, and the keyboard — where Copy is the one
      chord a read-only session keeps. The notice panel carries the
      reasons separately: "Viewing rev N of M — read-only" with a
      "Return to now" button beside it, above the container's own
      read-only line, and it now compiles on wasm too.
      Copy-from-history turned out to be *free*: `Action::Copy` reads
      `self.drawing()`, and routing `drawing()` at the viewed repo is the
      whole of it. Export likewise.
      Restore: `src/edit/restore.rs` is a pure emitter over two documents
      — entity-wise, since uuids span the whole log — reached through
      `Drawing::restore_to` so it rides the ordinary gesture bracket
      (labelled `Restore rev N`, journalled, undoable, solve rider and
      all). Register diffs come from a new generated
      `Entity::updates_toward`, so a register added tomorrow is restored
      by existing rather than by someone remembering this file. The
      equality check is the **projection**, not `content_hash`: the hash
      covers the rev and every register's write order, and a restore is a
      forward commit, so it can never be bit-equal however right it is.
      Also: `Store` keeps a `Vec<Entry>` (rev/kind/wall time/author)
      beside the repo's log, threaded out of `replay` — the audit columns
      the fold has no use for, without carrying every op twice;
      `BlockPath::is_alive_in` so a path that descends through blocks the
      viewed rev never held falls back instead of showing an empty scope;
      `jiff` joins the tree for local wall-time rendering (wasm-clean).
      Timings in TUNING.md, Finding 7: the fold-per-selection D4 accepts
      is 3–52 ms across a 300-commit, 2500-block container — no cache
      wanted — while the *same* measurement found D12's per-record state
      stamp costing 16.6 s of a cold open on that document, which is the
      evidence D12 reserved its every-Nth escalation for.
      Snapshot: `content_path.png` regenerated deliberately — the history
      cluster gained the clock toggle ahead of Undo/Redo, and nothing else
      in the frame moved.
- [x] D12's escalation, taken on Finding 7's evidence (2026-08-28).
      Load-time verification is now sampled; the *format* does not move —
      every record still carries `state`, `src/store/goldens/log.jsonl` is
      byte-identical, `record.rs` untouched. `Verify::{Sampled, Full}`
      (`src/store/replay.rs`) is the new input: the chain is checked on
      every record always, the state stamp on the head — the document the
      session opens on, and where any real fold drift lands — plus every
      `STAMP_SAMPLE`th (32) record. `Store::open` and `blockworx log`
      sample; the new **`blockworx verify <container.bwx>`** is the fsck,
      `Full`, exiting non-zero with the spanned report.
      Cost of the trade, made explicit rather than buried: a sampled
      mismatch cannot name the record a drift began at, so `Fault::Drift`
      grew a `Blame::{ThisRecord, AtOrBefore { verified }}` and the
      message says "at or before this record, and after rev N". Both
      halves are tests — a stamp drifting only between two samples is
      *asserted* to open clean and *asserted* to be named by `Full` — next
      to head-drift, on-sample and interior-sample cases and the two CLI
      paths.
      Numbers (TUNING.md, Finding 7, same 301-record/2501-block
      container, release): open 16.6 s → **0.84 s**; `blockworx verify`
      16.4 s. The residue is still stamps — ~54 ms each, 10 of them at a
      spacing of 32; with none it is 0.37 s. `STAMP_SAMPLE` is the dial
      and it is documented as such.
- [x] Phase 5 — comment → area rename (F10, 2026-08-28). The boundary box
      is an `Area` everywhere: 53 `.rs` files swept (~670 identifier
      occurrences), plus `theme.json`, two fixtures, the canonical golden
      and five living docs. Three `git mv`s: `src/shape/comment.rs` →
      `area.rs`, `src/tools/new_comment.rs` → `new_area.rs`,
      `icons/icon-comment.svg` → `icon-area.svg`. Per D6 no collision
      sweep was needed; the marquee `move_group`/`pin_group_tool`
      spellings stay in the nomenclature backlog untouched.
      **Format break, stated.** `OpCodes::Comment` → `OpCodes::Area`
      changes the serde tag inside `log.jsonl`, and `schema::Block`'s
      `comments` field → `areas` changes the projection's JSON key. Both
      durable spellings are the *derived* ones — no `#[serde(rename)]`
      pins a name the Rust no longer carries, which is the deliberate
      choice, not an oversight. A `.bwx` container written before this
      commit that holds an area op will not replay; we are undeployed, so
      that is accepted rather than migrated.
      Goldens: `src/schema/goldens/canonical.json` moved by exactly one
      line (`"comments"` → `"areas"`); `src/store/goldens/log.jsonl` is
      **byte-identical** — its seed builds only blocks, so the new op tag
      is not pinned by any golden. Worth knowing before someone trusts
      that file to cover the whole opcode set.
      Aliases kept, each with a test: the KDL decoder still accepts a
      `comment` node as an area (`src/schema/decode.rs`), and the script
      spellings `tool:comment` and `command "comment"` still name the area
      tool (`script::parse::parse_tool`, `CommandSet::take_by_name` via
      `legacy_command_name`) so tutorial scripts written against the old
      word keep running. `tool_kdl_name` emits `area` going forward, and
      `CommandId::Arm(NewArea).name()` is `"area"` with it. Tutorial level
      `.kdl` files were left alone (they die in Phase 7; the alias covers
      them).
      Judgment calls: `AreaKind::MNEMONIC` went `'C'` → `'A'`, a
      Display-only debug prefix that would otherwise have stayed the old
      word's initial. The icon is the same speech-bubble glyph under the
      new filename — Phase 6's real comments want that glyph, so the swap
      is theirs to make with a design eye, not a rename's to sneak in.
      Source-code prose about *code* comments was left alone in four
      places (`tools/tool.rs`, `xtask/src/main.rs`, and the KDL lexer's
      own comment-skipping in `schema/kdl.rs` + `kdl/imp.rs`).
- [x] Assets leave the log (2026-08-28, manual-testing bug). A real
      container demoted itself to read-only at open: record 37 ("Add
      Icon") carried a 1.5 MB SVG **inline**, and `Asset`'s serde impl
      serialized it with `serialize_bytes` (a JSON array of numbers) but
      deserialized with `deserialize_byte_buf`, which serde_json answers
      with `visit_seq` — "invalid type: sequence, expected a byte
      string". Two fixes, one for the symptom and one for the cause.
      *The codec* (`crates/doc/src/block_model.rs`, `arc_bytes`) is now
      format-aware: text formats write standard-alphabet padded base64,
      binary formats keep `serialize_bytes` unchanged — the latter is
      what `Document::content_hash` canonicalizes through, pinned by
      `a_folded_asset_stamps_to_the_bytes_it_always_did` so a text-side
      change can never move a state stamp. Reading accepts all three
      spellings: base64, byte string, and the legacy number sequence.
      `base64` joins `crates/doc`'s dependency list (no_std-friendly; the
      headless gate bans GUI and runtime crates, not this).
      *The architecture* (`src/store/assets.rs`, new): the container's
      `assets/` directory finally gets used. On append, a commit's
      payloads are written to `assets/<hash>.<svg|png>` — create-only,
      file then directory fsync'd *before* the record that names them
      reaches the log — and the record's op is hollowed to an explicit
      `{"asset": {"hash", "format"}}` in place of `{"op": …}`
      (`record::LoggedOp`, a flattened externally-tagged enum, so every
      non-asset op's spelling is byte-identical to before). On replay the
      reference is hydrated through an `AssetSource` seam (a trait, so
      Phase 8's browser reads the same names out of origin storage) and
      the bytes are checked against the hash that named them; a missing
      or swapped payload is a new typed `Fault::Artwork` and the ordinary
      read-only-at-the-verified-prefix outcome. `crates/doc` learns
      nothing about files: hollowing and hydration are both record-side
      transforms.
      Ordering, since three checks now nest: the chain covers the record
      **as the file holds it** (hollowed), so it is settled before any
      payload is read; hydration then rebuilds the ops and verifies each
      payload against its hash; the state stamp comes last, over the
      document that fold produced — an independent second witness that
      the bytes beside the log are the bytes that wrote it.
      **Policy, stated: new appends extract; legacy inline payloads are
      tolerated indefinitely and never rewritten.** A load must not edit
      history, and a read-only container has to load the way a writable
      one does — so there is no migration step and no lazy extraction
      during replay. Phase 7's sweep can revisit it.
      One D12 consequence, unavoidable: a legacy payload does not
      re-serialize to the bytes it was written as, so `parent` is now
      computed over the line's *own* canonicalization
      (`LogRecord::digest_as_written`) rather than over a round trip
      through the Rust types. Key sorting and whitespace stripping still
      apply — a reformatted line links exactly as before — and for every
      record this build writes the two are the same bytes.
      The projection needed nothing: `schema::ImageData` was already
      `Svg(String)` / `Png(<base64>)`, symmetric on both sides of the
      round trip. It stays self-contained by design and never points at
      `assets/`.
      Coverage that would have caught this: the store round-trip and
      persistent-undo property drivers gained `Act::Icon`, so a random
      script now places artwork; `src/store/goldens/log.jsonl` gained a
      fourth record pinning the reference spelling; and
      `store::tests::artwork` is eight new tests — extraction, dedup, a
      missing payload, a swapped payload, a legacy inline record
      replaying clean, a legacy container appended to, the read-only
      door, and a guard on the legacy-line helper itself.
      Verified on the real container (copied, original untouched): opens
      **writable**, replays all 39 records with record 37 taking the
      legacy `visit_seq` arm, `blockworx verify` passes 39 of 39 Full; a
      fresh asset append onto it extracts to `assets/` as a 388-byte line
      and re-verifies 40 of 40.
- [x] UI interlude (2026-08-29, complete: A, A2, B, C, C2, D, E, F) —
      the manual-testing punch list
      (`docs/ui-issues.md`), triaged into five workstreams run in order
      (A → B → C → D → E; C's title block is D's landing site). Phase 6
      stays parked until they land. Decisions taken with the triage,
      recorded in the playbook ledger: D18 (rev tags are log records,
      non-journaled), D19 (one flattened export with a provenance stamp —
      open = replace-import, import/paste = insert as a block), D20
      (startup: blank canvas, three-word container names, eager creation
      with pristine-on-exit cleanup). The comment tool stays Phase 6; the
      Area glyph swap stays Phase 6; choreographer-adjacent items stay
      Phase 7.
  - [x] A — read-only enforcement (2026-08-29). Three seams, one value:
        `doc::Authoring::{Offered, Withheld}` folds writability with
        `InterfaceLock` (the lock rule and the read-only rule were always
        the same rule; `RenderMode::Selected`'s naked `locked: bool` died
        for it); the `Gesture` carries the session's `Writability` — the
        write door declines in `author()` (backstop) and every tool reads
        the same answer back through `Drawing::authoring()`; and
        `ToolName::arming_writes_the_document()` is the tool-layer twin of
        `CommandId::writes_the_document()`, consumed by the registry (so
        toolbar buttons draw dead) and by `apply_scripted`'s SwitchTool
        arm (so chords, scripts and tool hand-offs can't reach what the
        button won't). The selection family answers false there on
        purpose — one tool state both selects and drags — so its drags
        are refused where they begin: the frame door strips `press` and
        Delete from the `Interaction`, and the four shared resolvers
        (`drag_to_move` → marquee, `editor_at_pos`, `edit_route
        ::start_drag`, `route_start`) plus the tool-local grab arms
        refuse the rest. Leaks found: the toolbar Import button dispatched
        `Action::Import` around the registry (item 5's actual culprit),
        and `GoUp` writes only at the root — gated in dispatch, since a
        command id cannot say "sometimes". The empty route selection
        overlay hides via a new single list `drawn_in_overlay(CommandId)`
        shared with `overlay_command`. Tests: `src/tools/
        read_only_tests.rs`, nine cases each stated both ways (writable
        proves the gesture works, read-only proves refusal + document
        stamp untouched + gesture seals empty), plus real-layout toolbar
        clicks and the time-machine variants. No existing test changed
        outcome; kittest snapshots byte-identical
        (`CommandSet::writable_toolbar()` keeps the presentational
        pictures writable-shaped).
  - [x] A2 — show disabled, don't hide (user feedback 2026-08-29; done
        same day, session lead directly). The registry now *marks*
        instead of *drops*: `offer` pushes every command with
        `Authoring::{Offered, Withheld}` (A's enum, reused) and every
        invoking door — `take`, `take_by_name`, `contains`/`get`, the
        palette's `iter()` — skips withheld entries, so nothing
        reachable changed; the private presentation enum renamed
        `Offered` → `Rendered` to free the word. The selection overlay
        renders `iter_drawn()` (withheld included) inside
        `add_enabled_ui`, so a read-only wire shows its full control bar
        grayed instead of vanishing; the hide-when-nothing check counts
        drawn controls, keeping the truly-empty case hidden. Canvas
        invitation affordances (pin dots, add prompts) stay hidden per
        the original punch list. Tests: the overlay test flipped to
        assert drawn-equal/invocable-zero both ways, and a registry test
        pins drawn-but-uninvocable as one entry (take/take_by_name/iter
        all refuse). Toolbar/palette behavior unchanged.
  - [x] B — history UX (2026-08-29). Labels: `edit::describe` is the one
        builder — `Gesture::open` takes a typed `Label::{Verb, Verbatim}`
        and the verb meets its object at `gesture::seal`, *before* the
        solve rider, composed `{verb} {object} in {scope}` with names read
        through `store::record::named` (the row and the record cannot
        disagree) and the scope in the content path's spelling;
        `ToolName::verb()` joins `label()`. Undo/Redo tooltips name the
        commit. Tags (D18 as amended): `RecordKind::Tag` targets via its
        `rev` field, `journals_as() -> Option<JournalAs>` makes the
        monotonicity exemption structural, `store::tags::Tags` is the
        replay-rebuilt projection, `Doc::Scratch` became a struct variant
        so scratch sessions tag too; `Fault::Mistagged` + chain coverage
        (a rewritten tag is caught by its successor). `LogRecord.scope:
        Option<ScopePath>` — advisory, skip-if-absent (golden line 1
        byte-identical proves it); threaded as `Attribution { author,
        scope }` with `From<&Identity>` so ~30 fixture call sites stood
        still. Panel: rows get a context menu + ⋮ (Visit / Restore /
        Tag… inline in a cascading submenu), the bare Restore button
        died, filter box over one `matches(row, query)` predicate (label,
        author, tag, date), humanized ages via `timeago` (no-default-
        features; absolute stamp on hover, CLI keeps absolutes), "Latest"
        not "head". The viewing notice + Return-to-now button died for a
        canvas watermark (`Viewing Rev 23 — "Initial Draft"`, new theme
        role `RevWatermark` on B07 at 0.09 alpha, behind content) and the
        history cluster in the past becomes Back one rev / Forward one
        rev / Return to current, policy in `Viewing::stepped` once
        (forward off the end lands `At::Current`). Restore drift, noted:
        reachable from any row's menu now, not only the selected row.
        Golden regenerated (scope on line 2, chain re-links, tag record
        line 5); `Act::Tag` joined the property driver and the
        persistent-undo property asserts reconstructed tags equal live at
        every restart. No existing test changed outcome; no snapshots.
  - [x] F — punch list 2 (`docs/ui-issues-2.md`, done 2026-08-29).
        Overlay: the A2 bug was 15 phantom slots — `add_enabled_ui`
        wrapped everything `iter_drawn()` yields, and an *empty* child Ui
        still advances the row one item-spacing; `overlay_controls()`
        (iter_drawn ∩ drawn_in_overlay) is now the one gate for
        emptiness, layout and width (118px vs 238px on a wire, test
        verified red first). Labels: `Said { verb-override, object }` —
        renames read old from the pre-image and new from the op
        (`Rename block "Old" to "New" in scope`; blank-old → `Name block
        "New"`; one match arm per renamable label), route create reads
        `Create route from Filter:out to c:in` out of RouteInit (Route's
        verb Draw → Create), text edits quote a 40-char flattened
        excerpt. Timeline: clock only toggles (past → ViewHead arm
        gone); tape icons |< >| >>| while past (3 new house-style SVGs)
        through the unchanged `Viewing::stepped`; panel opens at any rev,
        `Reveal` scrolls the viewed row into view on open/move only
        (pass-nr gap detection); seek-to-Latest button on the Latest row,
        dead at head; row context_menu deleted, `…` menu = View rev /
        Make current / Tag…; RestoreRev pushed unconditionally as "Make
        current" — `allows()` decides, offered wherever the session may
        write incl. head (`CommandContext.head: Rev` for the by-name
        target; restoring head seals empty, tested). Up: `Action::GoUp`
        lost its wrap arm, GoUp not offered at root (`current_scope()`
        made pub); `wrap_top` kept under expect(dead_code) until it gets
        a home. Tag badge: `Role::{TagBadge, TagBadgeText}` = B05/B00
        inverse video, the only base pair clearing WCAG AA in all six
        schemes × both luminances (tested); the old paint was
        strong_text_color, which the palette maps to B00 — background on
        background. Six tests changed outcome (wrap-on-GoUp pair →
        refusal pair, endpoint spelling, Make-current-at-head, two
        GoUp-at-root assertions relocated); no snapshots moved.
  - [x] F2 — overlay switch flash (user feedback 2026-08-29; session
        lead directly). Switching selection types flashed the new bar
        one frame at the old bar's place: placement used last frame's
        measured rect with no memory of *what* it measured. The stored
        measurement is now `(Vec<CommandId>, Rect)` — what was drawn,
        and how big — and a frame whose control list doesn't match runs
        as an egui `sizing_pass` child: laid out, measured, never
        painted, placed correctly the frame after. This also kills the
        first-ever-appearance provisional draw at the selection center.
        Test drives a wire→block→wire switch on one persistent context:
        hidden exactly on the switch frames, steady state never
        flickers, the wire's bar returns to the pixel; kill-tested by
        dropping `.sizing_pass()` (red on the first assertion).
        Second round (2026-08-30, user's RUST_LOG=overlay=debug capture):
        the "hidden" frame wasn't — egui's `sizing_pass` flag only
        tightens layout; only `.invisible()` silences the painter, and
        egui's own Area/Grid chain the pair. The measuring frame was
        painting the bar at the click point before the snap — precisely
        the reported flash — while the test asserted only the returned
        corner. Fixed with `.sizing_pass().invisible()`, and the test
        now counts leaf shapes in the frame output: a hidden frame
        paints zero (red-first: 10 shapes without the flag).
  - [x] F3 — no separate "Latest" entry (user feedback 2026-08-29;
        session lead directly). The synthetic Latest header row (and its
        seek-to-latest icon button, and its second Copy/Save menu) is
        gone: the head commit's own row is the top of the list, marked
        *Latest* (italic, weak — not the tag chip), selected when viewing
        Head, and clicking it dispatches ViewHead rather than a read-only
        visit of the same fold. One menu per row everywhere: on the head
        row "Make current" draws dead (restoring the current rev would
        seal empty) and "View rev" means return-to-current. Encoded as
        `Standing::{Latest, Past}` (clippy's zero-bools rule turned the
        flag into the enum it wanted to be) with `view()`/
        `on_the_canvas()` deriving click meaning, selection, and menu
        liveness from one place. The >>| tape control on the canvas
        remains the other way home. Four panel tests reworked to the new
        contract (incl. clicking dead Make-current fires nothing). (1) A2 regression: the selection overlay
        stopped hugging its buttons — contents are fixed now, so the size
        should be stable and fitted; find what add_enabled_ui did to the
        measurement. (2,3,9) richer labels in `edit::describe`: rename
        carries from→to ("Name block X" when the old name was
        empty/untitled), route creation names both endpoints
        ("Create route from <block>:<port> to <block>:<port> in scope
        <>"), text edits carry the new text truncated. (4,5) the clock
        icon only ever toggles the timeline popup (no click-through to
        Latest); the popup opens at any rev and highlights the rev on the
        canvas as selected; a seek-to-Latest button lives in the popup.
        (6) one popup only: the row context_menu goes, the "…" button
        stays — View rev / Make current / Tag… ("Make current" is the
        user's wording, replacing "Restore this rev"); single click still
        views. (7) while viewing a rev the history cluster shows tape
        controls: |< step back, >| step forward, >>| seek to Latest —
        icons, not words. (8) Up-at-root is disabled outright (the
        wrap-top demotion leaves the Up button entirely; its future home
        is TBD — probably the navigation popup — and it does NOT get
        built now). Known bug parked with it: wrap-at-top produced a
        "Top_1" block without visibly reparenting the contents —
        investigate when the feature finds its new home. (10) the tag
        badge is invisible — give it real contrast via a theme role.
  - [x] C — chrome (2026-08-29). Hamburger upper-left
        (`src/tools/main_menu.rs`, new `icons/icon-menu.svg`) with
        cascading File / Import/Export / Preferences / Help — the
        existing menu fns called in place, not copied; Import drawn dead
        read-only via the registry. Toolbar gains the nav group (compass,
        back, forward, enter, up) after a separator; the lower-right
        `nav_overlay` deleted (same widget bodies re-homed), `ToolbarFrame`
        carries the compass rect for the navigator popup. Title block
        lower-right (`src/tools/title_block.rs`): content path (small,
        selectable, empty at root) over `Row { caption, text }` lines —
        Author, Rev (the *viewed* rev, so the time machine stamps the
        past) — padlock only when read-only (an always-there open lock
        says nothing); D's provenance is one more Row. Notices
        (`src/tools/notices.rs`): `Notice::{Failure, Standing}` — the
        docs/ui-issues.md dichotomy — hung *below* the chrome band,
        wrapped at 40% width; failures carry Dismiss and are values now
        (`Loaded`/`Startup` enums; five formerly console-only error
        sites surface via `report_failure`). Snapshots: content_path/
        preferences_gear/help_menu deleted with their subjects;
        toolbar/main_menu/title_block added. Live-verified on a nested X
        server — three bugs found only there (full-height title block,
        tofu ✕ glyph → "Dismiss", unwrapped failure line). Three
        layout-asserting tests reworked, none else changed outcome.
  - [x] C2 — title block & hamburger fixes (user feedback 2026-08-29 on
        3aec19b; done same day, session lead directly). The runaway was
        two faults compounding: the child's max_rect overshot the margin
        to `viewport.right_bottom()` while `ui.separator()` stretched to
        the available width — so each frame's measured width read back
        one GRID_SIZE larger. Now the block is plain text on the canvas
        (no `Frame::popup`, no separator, every line hugs via
        `TextWrapMode::Extend`) inside a margin-bounded max_rect, so the
        measurement is intrinsic and converges after one frame. Proven
        by `the_block_settles_instead_of_growing` — kill-tested by
        re-injecting both faults (+15px/frame reproduced, test red) and
        found strict: either fault alone converges. Along the way the
        snapshot caught an egui trap: `top_down(Align::Max)` runs nested
        `horizontal` rows right-to-left, mirroring every line — noted at
        the layout, kept Align::Min. The hamburger lost its popup frame
        and is a bare ☰ menu button. title_block.png and main_menu.png
        regenerated (the box is gone; that is the point).
  - [x] D — provenance exports (D19, 2026-08-29). `Stamp` grew
        `provenance: Option<Provenance{document, rev, author, tag}>`
        (skip-if-absent — the container's `document.json` and every
        golden byte-identical, pinned by a projection-never-carries-it
        test); the only constructor is `Stamp::from(Source)` and `Source`
        has no rev field, so stamp and provenance cannot disagree.
        `projection::export_text(repo, source)` is the one door for
        every leaving artifact — Export ▸ JSON, history-row Copy, Save…
        — a rev export being that function over `folded_at(rev)`;
        selection export deliberately stays unstamped (an excerpt claims
        no rev). Freshness compares folds only (`names_the_fold_of`).
        Import/paste insert as ONE block: `block_from_document` retitles
        the parsed doc's top block and rides the existing
        `schema::lower` + `paste_snapshot` path (fresh ids, one sealed
        undoable commit, read-only-refused); label verbatim D19, `into
        scope` clause dropped at root per describe's precedent.
        Clipboard detection by shape (stamp + parses as document),
        disjointness with the shape clipboard tested both ways. Open
        shows the From row (title_block Row grew `hover`; author+tag
        ride it). Copy/Save deliberately NOT registry commands — they
        write nothing, no policy to own; row-menu-only for now. Save
        dialog defaults `<document>-r<N>.json`. Full Figma loop tested:
        copy a rev, paste at head, one titled block with the old
        content, undoable. Two tests updated to the new contract; no
        goldens or snapshots moved.
  - [x] E — startup (D20, 2026-08-29). `src/naming.rs`:
        `Documents::{Nowhere, In}` injected from `main` (tests pass a
        temp dir; nothing reads the environment mid-flow);
        `Documents::platform()` via new native-only dep `dirs` (documents
        → home → cwd); the generator is pure over a caller-supplied draw
        fed by `Uuid::new_v4()` bytes — `petname` rejected (it drags a
        second `rand` major for 144 words of data), three disjoint sorted
        48-word lists live in-repo; bounded collision retry (16). No-arg
        launch and File ▸ New are born attached (`<three-words>.bwx`);
        `--author`/`--replay` stay write-nothing scratch; any birth
        failure degrades to scratch with a dismissible notice. Rename:
        File ▸ Rename… (inline box, dead read-only) →
        `Action::RenameDocument` → `Container::rename` with
        `LockGuard::follow` (the fd follows the inode; proven by
        appending after the move and re-probing the lock; Windows sharing
        violations surface as a notice). Pristine cleanup:
        `App::unclaimed` tracks only session-created containers,
        claimed on first commit or rename (which is also when recents
        learn the name), swept on exit/switch via
        `container::discard_pristine` — removal only for a 0-byte log
        with only our own files; an *opened* empty container is never
        removed (D20 amended with the qualifier). 816 → 840 lib tests,
        nothing removed, snapshots unchanged.
- [x] Phase 6 — review via PDF export (D21, redirected 2026-08-29;
      landed 2026-08-30). `Export ▸ PDF` on krilla 0.8.2 + krilla-svg
      0.8.1 (+usvg 0.47 direct; lopdf dev-dep reads exports back in
      tests): one A4-landscape page per scope (root + every block that
      holds blocks — the navigator's disclosure set), fit-to-page
      aspect-preserved with a heading band; the outline mirrors the nav
      hierarchy; every scope-opening block carries a link annotation to
      its scope's page and the heading strip links back to the parent.
      Rendering consumes the existing SVG export path (`render_level`
      returns svg + frame + block rects from the Drawing that drew, so
      link rects cannot drift from the picture); krilla-svg chosen over
      a second Renderer backend — ~600 lines of primitive translation
      that would have to keep agreeing with `canvas/svg.rs` — at the
      cost of a second resvg/usvg copy until egui_extras moves off 0.45.
      Palette: the session's theme swapped to `Luminance::Light`, role
      edits surviving into print. Metadata carries the document name,
      rev, and D19 provenance. `ExportScope::{View, Selection}` replaces
      `ExportFormat::ALL` (PDF is view-only; an excerpt has no
      hierarchy); `ExportPayload.format` died for `ExportContent` (a
      format can no longer pair with the wrong bytes). Deterministic by
      omission (no clock, content-derived id) — byte-identity asserted,
      plus a *readable* structural golden (pages/outline/links). 13 new
      tests through lopdf incl. D21's exit criterion in CI form (the
      annotation rect equals the placed block rect, target page
      checked). Full wasm parity — browser gets Export ▸ PDF too.
      Decisions recorded in the playbook under Phase 6.
      Revised same day on user review: uniform *scale*, per-scope pages
      — fit-to-page onto one A4 blew small scopes up and shrank large
      ones down. `Sheet::for_frame`: 0.75 pt/world-px document-wide
      (CSS convention), sheet = scope bbox + margins + heading band,
      4×3in floor, 14,400pt Acrobat cap (the one place scale gives
      way). Mixed page sizes are ordinary PDF — per-page media boxes,
      fine in readers/annotators/printers. `Fit::of` → `Fit::at`
      (given scale, centred); the test reader stops assuming a page
      height and reads the MediaBox; golden gained per-page sizes.
      Also per user request: every page carries the title block in the
      lower-right — content path, document name, author · Rev N — in a
      reserved FOOTER band, right-aligned via epaint's own layout
      (`Fonts::with_pixels_per_point(1.0).layout_no_wrap`, ppp 1 so
      galley units are page points; long paths trim from the front),
      replacing the page-1-only provenance caption. Proven by lopdf
      `extract_text` on every page ("Rig/Power", "ada", "Rev 7").
- [x] egui 0.35 → 0.36.1 (2026-08-30, user-ordered; migrate-first rule).
      One breaking change touched this codebase and it was test-only:
      epaint 0.36 debug-asserts that a `TexturesDelta` is applied before
      drop, so all 105 headless-harness frames panicked at once — fixed
      with egui's own escape hatch (`drop_without_applying_deltas()` /
      `textures_delta.clear()`, 36 sites in 18 test files; `git diff -w`
      = 107/60 lines, the rest rustfmt). Everything else survived
      byte-identical: App::ui, sizing_pass+invisible, menus, Areas,
      kittest, the pdf.rs Fonts path. All 6 snapshots passed WITHOUT
      regeneration — including the shaped-text tessellation golden,
      despite harfrust 0.7→0.12 underneath. resvg does NOT dedupe:
      egui_extras 0.36.1 still pins 0.45.1 (krilla-svg wants 0.47), two
      copies remain. No deprecated egui surface in use. Upstream
      behavior notes: press-leaving-widget decides drag immediately
      (our drags run through the canvas hit tester — drag_abort suite
      unmoved); the OS titlebar now follows the egui theme. For G3:
      `epaint::Glyph` exposes `chr` but never the shaper's glyph id —
      svg.rs's identity-by-cmap / position-by-shaper split at
      canvas/svg.rs:170-175 is the seam the kerning bug lives on.
- [~] Interlude 3 (user feedback 2026-08-30; G1/G2 landed, G3 blocked
      on a user decision):
  - [x] G1 — rounded-rect outlines, both title blocks, one new
        `Role::TitleBlockBorder` (base B04 — `AreaStroke` deliberately
        NOT reused: an Area is a document object whose role a user
        edits; sheet furniture must not restyle with it). Editor:
        `rect_stroke` painted after layout from the measured rect,
        padding folded into the stored measurement so the settle test
        stands; gains the `Document <name>` row (App::document_name:
        container root → file stem → untitled) with a Rename hover —
        click-to-rename declined with reasons (E's seam is a submenu
        text field; inlining it is UI machinery, not a dispatch). PDF:
        `draw_title_block` unions the epaint-measured line rects and
        strokes a four-cubic rounded rect; baselines unchanged, all
        prior PDF tests untouched.
  - [x] G2 — "fit to extent misses routes": the exporter was innocent
        (probed first — SvgRenderer expands per primitive, wires
        included; guard tests added at the SVG and PDF layers). The
        real bug was `Drawing::content_bounds` behind Fit-view/Cmd+0/
        path navigation: it unioned blocks∪ports∪texts∪areas only —
        no routes, labels, waypoints, images, or block titles. Fix at
        the honest seam: `src/canvas/extent.rs` — `Bounds`, the one
        per-primitive extent policy (SvgRenderer's private twin
        deleted, arithmetic byte-identical, SVG golden unmoved) and
        `Extent<R: Renderer>`, a record-everything backend driven by
        the real `DrawingPasses`, so a shape kind added later joins
        the fit the moment it is drawn. Fit defers to the next canvas
        pass (`Refit::Owed` + request_repaint — same one-frame lag as
        before). Red-first: the old body fails
        `the_content_bounds_hold_the_wires_too`. Behavior change:
        fits now frame everything painted, incl. free images (was
        excluded by policy).
  - [ ] G3 — export kerning: DIAGNOSED, fix blocked on a decision.
        Root cause proven with a glyph table: Roboto's `liga` turns
        "fi" into one ligature glyph; epaint stores it as chr:'f' with
        the ligature's advance and atlas rect, then pads the cluster
        with a zero-advance, empty-uv continuation glyph for 'i'
        parked at 'g''s pen position (text_layout.rs); the canvas is
        right because the tessellator draws the ligature's atlas
        raster and skips empty-uv glyphs; svg.rs cmap-looks-up 'i'
        and outlines it there → i over g. Ligating fonts only
        (Sketchy clean). The specified fix is unshippable: epaint
        0.36.1 keeps the shaper's glyph id nowhere reachable from a
        Galley (Glyph has no id; GlyphInfo::id is pub(crate); the id
        dies in a private hash key), and svg.rs has no advance
        arithmetic left to delete — it already takes galley
        positions. Decision (user, 2026-08-30, after weighing
        strip-liga-from-fonts and rejecting it — ligatures stay):
        **option 1 — re-shape per row for identity only**, with
        harfrust+skrifa (epaint's exact shaper pair, so glyph choices
        match the canvas bit-for-bit), positions still the galley's;
        continuation (zero-advance) glyphs skipped, the ligature's own
        outline drawn once. ~60 lines accepted; pairs with an upstream
        epaint ask (expose glyph_id on Glyph) that would later delete
        them. On a shaped-count mismatch the exporter falls back to
        today's cmap path with a warn, never panics an export.
        Done 2026-08-30: `SvgRenderer` shapes per PARAGRAPH (epaint
        splits on '\n'; wrapping only partitions the glyph list) over
        the very bytes its ttf_parser Face wraps, mirroring epaint's
        calls exactly (skrifa::FontRef → ShaperData cached per face →
        default features; skrifa 0.44/harfrust 0.12 share read-fonts
        0.41 so the FontRef IS harfrust's). `Cluster::{Head,
        Continuation}` (advance > 0) + `Identity::{Shaped, ByChar}` —
        zip hands heads shaper ids, continuations draw nothing, count
        mismatch = ByChar fallback (unit-tested on the variant). Only
        the *lookup* changed; outlining still ttf_parser. No new
        crates (both deps were transitive). Red-first: "Config" = 6
        outlines with i parked at the g's pen (28.83/28.57), now 5,
        monotonic; "waffle офис fi fl ffi" = 11 outlines (ffi is
        3→1); per-paragraph test; end-to-end via render_svg. Finding:
        embedded Iosevka ships NO default-on code ligatures — its test
        stands as the nothing-substituted guard instead. All goldens/
        snapshots unmoved (Excalifont substitutes nothing in the SVG
        golden's strings). Upstream ask that deletes all of it,
        precisely located: epaint Glyph lacks `glyph_id` —
        GlyphInfo::id is pub(crate) (font.rs:44) and
        ShapingContext::glyph drops it (text_layout.rs:190) though
        layout_shaped_run holds it; continuation glyphs would carry
        None. File with egui upstream when convenient.
- [x] G4 — done 2026-08-30. `title_block::Grid { left, right, wide }`
      of `Cell { caption, value, emphasis, hover }` is the one
      definition; egui and krilla painters are thin consumers.
      `Document:|Rev:` / `Author:|Date:` over `Path:` (and `From:`)
      spanning; captions carry their colon once; `Value::Path` keeps
      segments so the sheet links EACH ancestor to its page (replacing
      the deleted heading band and its single parent link — strictly
      more reachable); text at `font_sizes().title` (15) nominal /
      ×SCALE = 11.25pt on paper, measured in the canvas family (the
      TTF krilla draws). Date = the rev's record day via
      `store::history::date` (jiff, ISO, local; scratch → row absent;
      byte-determinism preserved, tested with a pinned clock two days
      apart + time machine). Path overflow drops whole segments with
      …/ (a half-eaten name links to nothing). Seam test: one Grid,
      every cell asserted in the PDF's extract_text AND a real editor
      frame's paint. Goldens/snapshot regenerated and reviewed. A
      From: session also prints its provenance row — follows from one
      definition, kept. Declined: a caption Role on paper (ShapeType
      serves; add Role::TitleBlockCaption if wanted dimmer).
      Original spec (user, for the record): Why they
      differ today: no shared definition — the editor's came from punch
      list 1, the PDF's from D19, separately. Fix structurally: one
      definition (cells: captions, values, order, sizes; two-column
      ECAD arrangement as needed) in one module, consumed by the egui
      painter and the krilla painter, so drift is unrepresentable.
      Contents, both homes: Document title, Rev, Author, Date, Path
      (e.g. "Path: Left Engine/Inner") — WITH captions ("Document:",
      "Author:", …). Font size: captions and values all one size = the
      canvas block-title size (nominal in the app; × SCALE on the PDF
      page so it matches the drawing's own titles). Date = the wall
      time of the rev shown/exported (from the log — deterministic, no
      clock; scratch sessions have no wall times → row omitted). The
      PDF's upper-left heading band ("second title") DIES — the scope
      is named by the Path row's last segment; the parent back-link
      moves into the title block (path ancestor segments become link
      annotations — better than the strip: every ancestor reachable).
      Outline unchanged. PDF byte-identity/structural goldens will
      move (heading gone, links relocated) — regenerate as acceptance.
- [x] G5 — done 2026-08-30 (user-diagnosed G2 regression: one frame at
      the old camera). Measure-then-paint is now IN THE TYPE:
      `View::show` died for `View::begin(...) -> Canvas`, where
      `Canvas::fit_to(measure)` hands a NOTHING-clipped painter (a pass
      that draws by mistake cannot mark the frame) and applies the fit
      before `Canvas::paint(f)` consumes the handle — a fit cannot
      arrive after the frame it should have moved, and grid +
      interaction coordinates ride the new camera too. Measurement
      runs through the real egui painter/fonts at pre-fit zoom (as the
      old code measured). Every Refit consumer (document swap,
      navigate ×5, restore, settle, Cmd+0) reaches the one in-frame
      consumer; the request_repaint tail died. Red-first quoted: first
      frame painted ink at [[0,0]-[15,60]] vs the fit's
      [[385,222]-[415,342]].
- [x] CI gate 228s → ~25s warm (2026-08-30, user question "why so
      long / nextest / one slow test?" — all three instincts right).
      Measured: `cargo test --workspace` was 224 of 228s, and ONE test
      was 221 of those — `index_matches_linear_hit_tests`, the spatial
      oracle over scale_scene(6): cost ≈ scale⁴ in debug. The oracle's
      geometry policy is scale-free, so ci now runs it at scale 2
      (5.7s) with per-kind hit preconditions (shape/route/pin-stub must
      each answer at least once, so shrinking can't hollow it) and the
      scale-6 sweep stays as an `#[ignore]`d thoroughness run. xtask's
      test step prefers `cargo nextest` when installed (parallelism +
      per-test SLOW flags — the regression guard against the next
      quietly-growing test) with doctests run separately, falling back
      to `cargo test` so the gate needs nothing beyond cargo.
- [x] G6 — closed 2026-09-17, not reproducible and its precondition
      unreachable. Both halves of the 2026-08-30 diagnosis are gone:
      `Session::paste` goes through `commit_gesture` like every other
      edit, so there is no separate `Action::Paste` dispatch path, and
      `CachedIndex` is keyed on `(level, DocStamp)` and rebuilds on any
      new document value — guarded by
      `spatial::tests::a_commit_regenerates_the_index_with_no_explicit_invalidation`.
      What made the *symptom* unreachable is `show_landed`: a paste
      lands at the paste target, so a group wider than the room beside
      it lands partly outside the viewport, and the camera now follows
      it. The user's point, reproducing: a regression test for the old
      sequence would have to forbid the camera to move, which is a
      state the session no longer has — so it would pin nothing.
      `show_landed` was itself untested, and the kernel had no paste
      test at all; `a_paste_wider_than_the_viewport_is_brought_into_view`
      is both. Proven red with `show_landed` removed (the view sees
      800×600 while the pasted group sits at 405→2925), green with it.
      Original entry:
      during a paste, blocks AND wires
      that land off-screen do not appear — panning to them shows
      nothing — until the pasted group is dragged and released, when
      everything appears. The document is provably complete (34e9607's
      suite), so this is cache invalidation: something viewport-scoped
      and stamp-gated (the app's CACHED spatial index and/or
      presentation) is not refreshed by the paste's commit on the
      Action::Paste dispatch path, while a drag-release commit through
      the ordinary gesture bracket refreshes it. The earlier
      investigation built a FRESH index in its test — the app's cached
      one across the real paste dispatch is the untested seam.
      Reproduce through the real App frame: paste content extending
      beyond the viewport, pan, assert the off-screen blocks/wires
      draw WITHOUT any further commit; red-first, then fix the
      invalidation at one seam shared with the gesture bracket.
- [x] G6b — done 2026-08-30; the hypothesis verified exactly and the
      three halves (shapes, inner wire, tether wire) each proven red
      independently. Fix at the one home: `Drawing::visible_ids` unions
      the index's committed answer with this frame's `Supposed
      { shapes, routes }` — recorded by the preview funnel's renamed
      `suppose_drag/resize/pin_drag(s)` writers, and structurally
      unable to outlive its frame (a Drawing IS one frame — no
      invalidation, no lifecycle). O(dragged): idle frames pay two
      empty-vec loops; drag frames one rect test per dragged shape
      (+icon) and re-solved route. `shape_bounds_at` keeps one bounds
      rule for settled and previewed footprints. Side benefit: dragged
      annotations (areas/text/images/icons) now also record previews.
      Route-corner and pin drags proven out of scope (their hittables'
      committed bounds already reach the viewport). No test outcome
      moved; no goldens; 6/6 snapshots.
      Original entry:
      paste lands the group off-screen; DRAGGING the pasted selection
      so its entire extent is on canvas STILL draws nothing; only on
      release do blocks and routes appear. Mechanism: the per-frame
      paint set culls by the spatial index over COMMITTED rects ∩
      viewport; drag previews move where included shapes paint but
      never add shapes whose committed rect is off-screen — so their
      previews cannot draw. Release commits the move, the index
      refreshes, everything appears. Explains all three accounts and
      why the pan test was honestly green (panning moves the camera
      over the committed rects; dragging does not). Fix: the cull set
      must include shapes whose PREVIEWED bounds intersect the
      viewport — the preview machinery knows its overrides
      (GeometryOverrides / the drag delta); union them into the
      visibility question at its one home (DrawingPasses' visible
      set), never per tool. Red-first: committed-off-screen group,
      real drag frames bringing it in-view, assert blocks AND wires
      paint mid-drag before any release; the release-path assert stays
      as the control. Affects any drag of off-screen-committed content
      (not just paste), so it stands even with paste-into-view landed.
- [x] G7 — done 2026-08-30. `path::structure(indexed, id) ->
      Structure::{Leaf, Nested}` is the one predicate (one hash lookup
      + first-live-child, cheaper than the child_blocks both old
      spellings allocated through), consumed by the renderer
      (`BlockShape` carries `Structure` — a shape cannot be built
      without answering), the navigator's disclosure rows, and the
      PDF's page set; the three-way agreement is a test over one
      shared fixture. `draw_block_boundary` draws the inset at
      GRID_SIZE/4 (reads clear of pin names at /2 and rounding at /8),
      riding whatever stroke/fill the render mode chose — normal,
      selected, moving's snapped frame, resizing, locked, read-only —
      while ghost drag-trails stay single deliberately. Exports
      inherit free; every existing golden/snapshot unmoved; new
      structured_block.png for the veto. Drag-preview draws both
      lines (tested mid-drag).
      Original entry:
      request 2026-08-30: "thicker border or a double border... tells
      us that a block contains internal details"). Decision: DOUBLE
      border (inset second line) — a thicker stroke collides with
      role-colored borders and stops reading under zoom-out; the inset
      line is the classical contains-a-sheet notation and reads at any
      zoom. Lands on single-author (canvas render path → exports get
      it free; cad-shell inherits on rebase). Policy once: unify the
      predicate with the navigator's disclosure set and the PDF's
      page-per-scope set — one `opens_a_scope`-style answer consumed by
      all three, tested so they cannot disagree. Snapshot for user
      veto. Queued behind the root-routes fix holding the tree.
- [~] Phase 7 — SUBSTRATE COMPLETE (2026-08-30/31, thirteen steps of
      `docs/choreographer-playbook.md`, commits 3e03f29..[7·12]); the UI
      half waits on the cad-shell decision. Landed: EntityRef (7·0);
      the timeline model, caller-clocked playhead, C3
      losslessness oracle, and the choreography_stays_derived gate
      (7·1); ~50 choreography rules over every mutation-inventory row
      with reviewed text goldens — appear-from-top-left (user), wires
      drawing themselves along pure solves (user amended C1), staged
      path rewrites, crossfading names, stepping flags, outside-in
      delete cascades, cut-then-move as morphs (7·2–7·5); the total
      dispatch + whole-inventory guard + L1 ghost-cursor pantomime
      (7·6); note records with the stands_for projection (7·7 —
      disproved the plan's redo_revs rule by probe); the Tutorial
      reader as the substrate/UI contract + Store::reading (7·8); the
      three levels converted to containers, proven against the old
      replay goldens at the only moment both existed (7·9); and the
      demolition — recorded-input pipeline (~5,950 net out) and the
      KDL parser (~1,250 net out), D14 closed through both doors, the
      canonical golden byte-identical across the format switch
      (7·10–7·11). Tutorials are dark until the UI half, by user
      ratification. Docs re-cut at 7·12.
      SPLIT (2026-08-30, user decision), along the same chrome/substrate
      line as the cad-shell experiment: the substrate half — timeline
      model + playback clock, one choreography rule per
      mutation-inventory row with the final-frame-equals-fold property
      and golden timelines, note records (D17), tutorial levels
      converted to logs, recorded-input pipeline + quarantined KDL
      parser deleted (closes D14) — starts on `single-author` as soon
      as G6 lands, planned first as a sub-playbook per house
      convention. The UI half (history-browser animation surface,
      tutorial library/player per docs/cad-ui-spec.md §7) waits for
      the cad-shell decision so it is built once on the winning chrome.
- [ ] Phase 8 — web: OPFS/IndexedDB spike, container in origin storage,
      bundle import/export parity

## cad-shell branch — the CAD five-band frame

Spec: `docs/cad-ui-spec.md`. Blockworx mapping and resolutions:
`docs/cad-shell-playbook.md`. Chrome only — no document-model, store or
emitter change on this branch.

- [x] Phase A — the frame (2026-08-30). Bands are real `egui::Panel`s
      shown inside the app's root `Ui` (eframe 0.36 has no
      `update(&Context)` any more, and egui 0.36's unified `Panel`
      shows inside a `Ui` and reserves space from it, which is what
      invariant 1 actually needs). Built: band 1 document bar (File
      menu with Import/Export folded in, undo/redo with kind-naming
      tooltips, document name, history clock, ⌘K chip, help,
      preferences), band 2 docked tool band (authoring tools only,
      left-aligned, digits 1–7 beside the existing chords), band 5
      status strip (clickable breadcrumb, path arrows, selection
      count, cursor cell, zoom-to-fit, read-only padlock), band 4
      workspace (one icon rail + one collapsible resizable panel
      hosting the existing history and nav-tree bodies, remembered per
      document). Deleted: the hamburger, the floating toolbar, the
      lower-left history cluster, both popups, and the editor's
      painted title block (R2 — the PDF sheet keeps the `Grid`).
      Proved by `no_band_reaches_the_canvas` (invariant 1),
      `the_idle_frame_settles`, and six kittest shell snapshots.
- [x] R1 reversed the same day (user, OnShape precedent): redo stays
      visible and greys out, exactly like undo — disabled-not-hidden is
      uniform, with no documented exception.
- [x] R7 amended (user): the mockup's viewing band is the right
      treatment, so Phase C deletes the canvas watermark rather than
      keeping it as the third read-only signal.
- [x] R11 added (user, on the Phase A build): the tool band is
      icon-over-word, not icon-only — a 20px icon over one word in a
      56×46 cell, per the mockup's `.tool`. The words (Select, Block,
      Area, Port, Image, Text, Route) ride with the band's membership
      and order in `names::BAND_TOOLS`; the full label stays in the
      tooltip.
- [x] Phase B — the panels earn the spec (2026-08-30).
      - [x] History: day grouping ("Today"/"Yesterday"/ISO, off the
            records' own clock), the three facet chips over the rows'
            own authors/days/kinds beside the text box (R3 keeps both),
            §4.4 inverse revs muted and italic, §5.2's flag promoted
            from the row menu onto the row, and "Save…" renamed
            "Export rev…" (invariant 10).
      - [x] Hierarchy: §5.3.1 focus re-roots (a `»` on every branch row
            and a double-click, with the panel's own breadcrumb above),
            §5.3.2 the filter flattens to matches carrying their
            ancestor path truncated from the left and reads the whole
            document across the focus boundary, §5.3.3 reveal from
            canvas drops a focus that would hide the selection, leaf
            counts on closed branches, and the panel width remembered
            per view rather than per document (§5.3: a tree and a list
            want different widths). Instance counts skipped: blockworx
            has no instancing, so there is no group to collapse.
            Sticky ancestor headers stay unbuilt — the spec wants them
            for production, not v1.
- [x] Phase C — viewing and undo (2026-08-31).
      - [x] Band 3b: a bright strip under the document bar, drawn only
            while a past rev is on the canvas — the rev and its age (the
            history row's own words), the stepper on `Viewing::stepped`,
            Restore this state, Return to current. It claims Escape
            before the canvas pass, so a tool armed before the lens
            opened cannot answer that key first.
      - [x] The tape left band 1 with it: the document bar now holds
            undo and redo alone, both drawn dead under the lens because
            they act on a head nobody is looking at.
      - [x] The canvas watermark machinery is gone (R7) —
            `render::watermark`, `Viewing::watermark`, `Role::RevWatermark`
            and the seek-latest icon with them.
      - [x] Canvas desaturation is the third signal: `Saturation` is a
            palette transform, and the canvas chrome and drawing now
            resolve through one toned palette so the drain cannot reach
            one and miss the other. 0.35 remaining, the mockup's
            `filter: saturate(.35)`.
      - [x] The undo caret: `UndoSteps` carries the stack (the button's
            own label is its head), each row the commit's label with its
            kind beside it, and `Action::UndoThrough(rev)` walks back
            through the commit picked — named rather than counted,
            because the editor's stack interleaves view entries the list
            does not show.
      - [x] Proved by `the_read_only_lens_raises_all_three_signals`,
            `escape_returns_to_the_present_from_anywhere`,
            `the_viewing_band_takes_its_room_from_the_canvas`, the band's
            own click-scan, and a seventh shell snapshot.
- [x] Phase D — polish (2026-08-31). Closes the branch's planned phases.
      - [x] Band 3: a real panel drawn only while a tool is active,
            holding that tool's name and Cancel (the registry's own
            `Arm(Select)`, so it and band 2's leftmost button cannot
            mean different things). No Apply — a blockworx tool takes
            its arguments from the canvas gesture and commits on
            release, so no form stands between the user and the rev.
      - [x] Bands 3 and 3b are one slot, resolved once by
            `shell::Conditional` (§1) rather than each band deciding
            for itself. The lens wins; `settle_on_viewed` already put
            the tool down, so this is the second lock on that door.
            Proved by `the_conditional_slot_holds_one_band_at_a_time`
            and `the_parameter_bar_takes_its_room_from_the_canvas`.
      - [x] ⌘K results typed and grouped by source (§6): Go to,
            Commands, Blocks, History. The source is read off the row's
            own id, and grouping happens *after* the ranking cut so no
            source can crowd another out of the twelve. The log is a
            new source — `rev 2` or the words the history panel gives
            that commit — which is the third thing band 1's palette
            chip has promised since Phase A. Numeric search stays
            unbuilt (§6 open); tutorials stay out with §7.
      - [x] Band 5: the mockup's `.status .pill` on the breadcrumb and
            the zoom (no frame at rest, framed under the pointer — the
            arrows stay framed, since a disabled pill shows nothing),
            and the cursor readout names its unit (§1). Snap toggles
            are not built: blockworx always snaps, so the pill would be
            a switch with one position.
      - [x] §9 verified, not built: `--tap` is the WidgetSize
            preference, applied as egui's global zoom factor, so a
            tap-sized session has fewer *points* for the same bands.
            `the_tablet_widths_hold_at_the_largest_widget_size` walks
            the whole shell at 1024 and 834 with Large — bands clear of
            the canvas, every tool still printing its word. It fails at
            500px, so it is measuring something.
      - [x] The snapshot suite runs at 1280 *and* 1024 (nine pictures
            each), and the look every picture is dressed in is one
            function instead of a copy in each kittest module.
- [x] R55 (2026-09-02): a document undo/redo lands with its change
      visible — stored vantage first, the choreographer's own camera
      plan for the inverse commit where the stored one cannot serve
      (reopened sessions, palette edits); redo survives the framing;
      seven probes through real frames. One extra view entry when the
      camera had to move, ledgered.
- [ ] The iPad punch list (2026-08-31). The user ran the built shell on
      an iPad; the playbook's R13–R18 record the rulings.
      - [x] R13 The undo caret is dropped — *"Drop the caret on the
            undo. It's unnecessary."* Band 1 is Phase A's plain undo
            and redo again, each still naming the edit one press takes
            back. `Action::UndoThrough`, `App::undo_through`,
            `UndoEntry`, the `past` slice and `icon-caret-down.svg` go
            with it; the history panel is the surface for a commit
            several back. Chrome grew `hover_at` so the tooltip can be
            read back through a real frame.
      - [x] R14 Band 3 is removed — *"The parameter bar is annoying.
            Remove it. Each tool action causes the UI to shift around,
            which is disconcerting."* `shell::Conditional` is deleted
            rather than reduced: with band 3b alone in the slot,
            `Viewing::Past` *is* the draw condition and no resolver
            above it earns its keep.
            `only_the_lens_opens_the_slot_under_the_tool_band` replaces
            the two tests the removal invalidated, and still proves
            invariant 1 for the band that remains.
      - [x] R15 ledgered: §7's contextual walkthrough button needs a
            surface that is not band 3. Candidates named, none built.
      - [x] R16 The hierarchy rail icon is a tree, not a compass.
      - [x] R17 A block is born named "Block N", N the lowest free one —
            *"If the user declines to name a block, the fact that they
            are all named 'Untitled' is confusing."* Fixed at creation,
            not at display: the name is real, editable and journalled,
            so one policy in `edit::create::block` fixes the canvas,
            the breadcrumb, the hierarchy, the palette and the PDF
            outline together. The search reads titles rather than
            counting blocks, so it refills a gap a delete left instead
            of climbing past it, and `next_top_name` folds into the
            same `next_free_title`. Paste is untouched — a copy keeps
            its source's title, which is what a copy should do. Areas
            keep "Untitled": an area appears in no breadcrumb, no
            hierarchy row and no outline.
      - [x] R18 A CI step (`palette`) greps every UI module for a color
            it built itself — *"anything that is rendered goes through
            either a canvas role or an egui visual style."* Two
            violations found and routed: the two swatch borders that
            were a hardcoded grey now take egui's non-interactive
            outline (the theme editor) and the palette's own B03 (the
            role picker, whose active ring was already B07). Three
            measurement-only galleys that carried `WHITE` now carry
            egui's `PLACEHOLDER`, which is what they meant.
            `Color32::TRANSPARENT` and `PLACEHOLDER` are not colors and
            pass anywhere; one line opts out by saying why.
      - [x] R17/R18's siblings ledgered as deferred at the user's word:
            history-panel visual refinement, and colour/role tuning.
      - [x] The touch fixes (same day, from the iPad probe's root-cause
            report). `index.html` finally declares `touch-action` —
            `none` on the canvas, `manipulation` on the page — which is
            the only non-passive way to keep Safari's own recognizers
            (double-tap zoom, page pinch, long-press callout) from
            claiming a touch before egui sees it; eframe's own
            `preventDefault` on `touchmove` sits on `document`, where
            touch listeners are passive by default and ignored. The
            canvas reads `multi_touch()` at last: two fingers pan, a
            pinch zooms about the gesture's own center (not the moving
            primary finger), and a latch holds the canvas until the last
            finger lifts so the trailing finger never reaches a tool as
            a drag. The route-start press target widens from the 4.5 px
            bullseye to the 15 px ring on touch screens
            (`grab_radius`), since a fingertip has no hover phase to aim
            with. Upstream and waited on, not worked around: Scribble
            (eframe's 1×1 px hidden text agent, egui#4500) and the
            on-screen keyboard's first-tap failure (iOS gesture
            restriction, egui#4569). A settle probe now covers an open
            title editor, closing the repaint-loop hypothesis for the
            reported hang; the remaining candidate is a silent wasm
            panic, checkable from a tethered Mac's console.
      - [x] R24 (2026-08-31): the tool cluster went icon-only on the
            user's word — R11's icon-over-word belonged to the docked
            band; cells are 44px square, tooltips carry name, digit
            and chord.
      - [x] R25 (2026-08-31): the sheet keeps one width across its
            segments, on the user's word — per-view widths (Phase B,
            re-affirmed at F) read as the layout moving on its own.
      - [x] Iosevka ships subset (2026-08-31): 10.8 MB -> 222 KB; the
            wasm 23.7 -> 13.1 MB raw, 3.9 MB brotli. Regeneration
            command beside the declaration in src/font.rs.
      - [ ] Spec v2 adopted (2026-08-31): the unified floating layout
            replaces the banded shell — rulings R19–R23 and the four-
            phase plan (E frame, F sheet, G overlay contract, H view
            undo) live in docs/cad-shell-playbook.md. Wanted model
            feature ledgered from §10: interface-as-contract (a sheet
            entry that mismatches its child's ports is a surfaced
            error with a reconcile action). Instancing declined.
      - [x] Phase E — the floating frame (2026-08-31). The five docked
            bands are gone; the canvas runs edge to edge under seven
            floating pieces: document chip, action cluster, tool cluster,
            viewing pill, path pill, status chip, and a transitional
            sheet. One `Berth` table says where each piece hangs and
            which edge it takes room from, so the §2.1 safe-area
            resolver and the anchor that places it cannot disagree;
            fit-to-view frames inside the measured region rather than
            the raw viewport, and the same resolver's `clamp` is what
            Phase G's overlay will ask. Glass is translucent fill plus
            elevation, no blur (R23); 44 px targets, radii 13-20, no
            hairline rules — Select is set apart from the creators by
            air. The sheet is deliberately minimal: Phase F owns its
            three segments, its dock threshold, its restyled filter and
            its resize.
      - [x] Phase F — the sheet (2026-08-31). One component, three
            segments (History, Hierarchy, a Learn placeholder) and three
            ways of hanging: floating over the right edge, flush with it
            at >=1400 px, up from the bottom in portrait. The `Berth`
            table carries all three, so the safe area follows the mode
            without the sheet placing itself — seven pieces, nine
            berths. The middle segment keeps the name Hierarchy:
            blockworx has no instancing and no parts list, and what its
            tree shows is containment. History is restyled to the mockup
            (R20) — rows with a lead glyph, a title over the rev and its
            author, a flag chip, the whole row a target — with the
            `Current` row at the top and inverse revs titled
            `Undo — <original>`. §8.2's resize is wired at last, as the
            mockup's grab handle at the sheet's head, since an
            `egui::Area` takes no interaction outside its own box. The
            sheet is opaque where the rest of the chrome is glass. Open,
            for the lead's eyes: the portrait sheet covers the tool
            cluster, as the mockup's does, and §12.6's portrait cluster
            stays an open decision.
      - [x] Phase G — the selection overlay contract (2026-08-31). The
            file that held the floating toolbar has carried the overlay
            since Phase E deleted the band; it is `tools::overlay` now,
            and it draws §3 whole. Placement reads the frame's own
            `SafeArea` instead of a hard-coded 96 px top gap and a list
            of obstacles empty since Phase E: centred above the
            selection with 14 px of air, flipped below under the top
            chrome, slid sideways into the room the chrome left. The
            clamp is *horizontal only*, which is what makes invariant 5
            hold structurally — both bands are clear of the selection in
            y and nothing downstream touches y. Two behaviour changes
            fall out: the gap is flat pixels where it was grid cells
            scaled by zoom, and the bottom-of-viewport fallback is gone,
            since what it did for a selection too big for the room
            around it was park the bar on top of the selection. §3.4 —
            `PanGesture` is `canvas::Camera` now, one answer with two
            readers, and the bar stands down while the camera is worked.
            §3.5 — beyond five commands the tail goes behind one
            ellipsis; `Bar` is where the one list is cut, generic over
            what is cut so a test's ids and a frame's commands cannot be
            divided differently. §3.6 — right-click opens a menu built
            from the same `Bar`, the editor's first, and a right *drag*
            still pans. §3.2 — the count prefix and the empty-intersection
            sentence are built; the intersection itself is degenerate,
            every multi-shape command being a verb over the whole set,
            and the module says so rather than pretending. The bar is
            `glass::shell` and every control in it is a 44 px target.
            Open, for the lead's eyes: §12.4 is live (a block draws
            twelve commands, past the spec's own ~8 line, so Delete sits
            behind the ellipsis), and the 14 px is measured from a box
            that excludes the title a block draws above itself.
      - [x] Phase H — view-kind undo entries (2026-08-31). R9 closed,
            and with it the spec-v2 phases. §7's two kinds are one
            stack, and the stack is **egui's own** `Undoer<State>` on
            the user's ruling — *"Should we just reuse the Undo/Redo
            stack from egui? Why are we writing our own?"* — fed the
            editor's state every frame. A state is the camera, the
            scope and which of the repo journal's steps the document
            stands on; the Undoer's settle time is §7.2's 1.5 s, so
            coalescing, gesture-end entries and one-entry-per-pinch all
            fall out of it rather than being written. `Stood` is the
            journal's *depth*, never the head and never the top entry's
            rev: undo authors forward, and an entry's rev changes as it
            crosses the journal, so either would make a round trip look
            like a new state. Undo restores the state and walks the
            journal only as far as the two `Stood`s differ — a camera
            entry crosses nothing and appends nothing. The tooltip
            names target and kind (`Undo zoom to fit — view only, no
            rev` / `Undo <label> — authors a rev`), the registry asks
            the kind rather than the command's identity, so under the
            lens and on a read-only container undo still serves the
            view entries. `history::UndoStack`'s hand-rolled
            `Step`/`ViewState` are gone. Two calls listed for the lead:
            the path pill keeps its arrows (R21 asked for them) now
            that a scope change is an entry in the one stack — they are
            navigation, and undo takes navigation back; and a camera
            move drops the forward half, which is one-stack semantics
            but also costs a reopened container its F6 redo the moment
            the view moves.
      - [x] The wasm weighed 40 MB and 11.8 MB of it was the same four
            fonts twice: `const` slices are inlined per codegen unit, so
            the fonts' only out-of-module reader duplicated them all.
            `static` now; plus a `wasm-release` profile (opt-level s,
            fat LTO, one codegen unit, panic=abort — no `strip`, which
            deletes the section wasm-opt needs to accept bulk memory)
            and wasm-opt at `z`. 40.1 -> 23.7 MB raw. The bigger lever
            is serving: trunk's dev server compresses nothing; a
            brotli-serving static server ships ~5.7 MB. Deeper cuts
            ledgered, not taken: font subsetting (−11.5 MB raw — Iosevka
            alone is 10.8 MB of full-Unicode glyphs), eframe compiling
            wgpu into a web build that renders on glow (−1.1 MB), the
            two resvg stacks (−0.4–0.7 MB, blocked on upstream
            alignment).

- [x] The floating-shell polish punch list (2026-08-31/09-01). The user
      ran the spec-v2 shell and returned 28 items;
      `docs/cad-shell-playbook.md` records the rulings as R26 onward.
      The numbering is the user's and does not change.
      - [x] 1 Top two pills 52px tall; toolbar 64px wide
      - [x] 2 Separator between undo/redo and the view controls
      - [x] 3 Document name vertically centred in the chip
      - [x] 4 Lower-right pill 40px
      - [x] 5 Sheet fixed size (~350px, fixed height); resize grab dies
      - [x] 6 Sheet animates in from the right with spring overshoot
      - [x] 7 Drop shadows smaller and tighter to the chrome
      - [x] 8 Full-width equal-spaced History/Hierarchy/Learn segments
      - [x] 9 Head rev row hidden; the Current row carries its controls
      - [x] 10 Click-away dismisses the sheet; chevron gone; click eaten
      - [x] 11 History scroll bar hidden
      - [x] 12 Hierarchy tree guides: a dot per row plus ancestry lines
      - [x] 13 Drop the word "cells" from the status readout
      - [x] 14 Path pill merges into the bottom-right pill
      - [x] 15 Back/forward navigation buttons die
      - [x] 16 Double-click renames the document in the chip
      - [x] 17 Hamburger icon becomes an ellipsis
      - [x] 18 "Refresh document.json" menu entry dies
      - [x] 19 Add-pin hit radius maximised, derived from its neighbours
      - [x] 20 Selection overlay pill 52px like the top pills
      - [x] 21 Tool cells: springy press, shrink then pop to the accent
      - [x] 22 Overlay split categorical: seven primaries inline
      - [x] 23 The mockup's type scale, taken whole
      - [x] 24 Go Up joins the action cluster, dead at the root
      - [x] 25 BUG: the area selection overlay is missing
      - [x] 26 Eye icons for the tag toggle, |<- / ->| for I/O
      - [x] 27 Port overlay unified through the same split
      - [x] 28 Horizontal separator under Select in the tool cluster
      - [x] The selection bar: a categorical split, the missing area
            overlay, and two glyphs (22, 25, 26, 27). R35 replaces
            §3.5's split-at-five with a `Placement` the command carries,
            set once from one table, so a verb's place in the row is a
            property of the verb rather than of how many neighbours it
            turned out to have. `Bar` keeps the registry's order inside
            each half and the whole list beside them, which is what the
            right-click menu reads (§3.6 parity). The area bug was not
            in the registry — an area's commands were there all along —
            but in `place`, which had only two candidate bands, both
            *outside* the selection: an area is the one selection
            routinely bigger than the room around it, so both fell out
            of the region and the bar hid itself. There is a third band
            now, just inside the selection's own leading edge, and the
            empty case says so out loud rather than drawing nothing.
            The tag toggle wears an eye showing the state, the way the
            padlock beside it does; the I/O control wears the user's own
            `|<-` — an arrow arriving at a wall, mirrored for output and
            doubled for both — read off the pins' current direction.
      - [x] The add-pin marker grabs from as far out as it can (19).
            R34: the radius is not written down — it is the smallest
            clearance the neighbouring affordances leave, computed from
            their own constants, so moving one closer shrinks this to
            suit instead of starting a silent overlap. The binding
            neighbour is the block body, which takes a press with no
            padding and sits exactly one grid cell in, so the answer is
            one grid unit — the user's own guess — where the commit test
            had been `PORT_RADIUS`, the size the ring *draws* at. Both
            tools that offer the marker now grab at the same radius.
      - [x] The chip renames in place, wears an ellipsis, and stops
            offering to do what already happens (16, 17, 18). R32 puts
            the rename box on the name itself and takes the menu's own
            Rename with it — two doors to one box is the duplication
            that drifts — and `Document` moves to the chip, which is
            what owns renaming now. R33 swaps the hamburger for the
            mockup's vertical ellipsis, leaving the selection bar's
            horizontal one to mean overflow. R31 deletes the
            projection-refresh menu entry: the projection has kept
            itself fresh on a two-second settle since 2026-08-28, so
            the entry offered to do by hand a thing that had already
            happened. The command stays in the registry for the one
            case the timer will not touch — a hand-edited projection,
            which must be overwritten deliberately or not at all — and
            `docs/json-format.md`'s write-moments row, stale since the
            timer landed, now says so.
      - [x] The bottom two pills become one (13, 14, 15). R30 folds
            R21's path pill into the status chip, so the corner reads
            `<breadcrumbs> <zoom> <pos>` and the other corner is empty.
            The clickable segments come with it; the back/forward arrows
            do not, and with them goes the whole `path_history` /
            `path_cursor` stack and `Action::PathBack`/`PathForward` —
            since Phase H a scope change is an entry in the one undo
            stack, so that machinery had become a second history nothing
            could reach. The unit word goes too: the editor measures in
            nothing else, so "cells" said the same thing on every
            document there has ever been, and the hover still spells it
            out. Seven pieces and nine berths become six and eight.
      - [x] The head has no row, the bar is gone, and the tree has
            guides (9, 11, 12). Hiding the head's row (R29) leaves the
            Current row standing for it, so the flag and the "…" it
            carried hang there — the row was inert by construction at
            head, which is what made it a row that said nothing. The
            history list's scroll bar is hidden and its wheel is not.
            The hierarchy's rows each wear a dot in the block's own
            accent, and the guides above them stop at a parent's last
            child rather than running past the family, which is the
            difference between a directory tree and a grid.
      - [x] The sheet is one size, comes in on a spring, and goes away
            when you click past it (5, 6, 8, 10, 28). It is the mockup's
            348 wide and as tall as the chrome beside it left, both
            fixed — the body is pinned into the room the header leaves,
            since egui grows a `Ui` to whatever is put in it and a long
            list would otherwise push the sheet's own box open. §8.2's
            grab and R25's remembered width go with the fixed size, and
            `Workspace` is two fields. It travels on §2.2's one curve,
            evaluated in `glass` because egui interpolates linearly; the
            overshoot falls out of the curve leaving the unit interval,
            and the safe area insets by where the sheet *settles* so
            fit-to-view does not chase it in. The dismiss chevron is
            gone: a press anywhere but the sheet and the cluster that
            toggles it puts it away, and that press is spent — not by a
            pane over the canvas, which would have taken the hover with
            it, but at the canvas seam, where `Interaction::spent` keeps
            the hover and drops the press for the rest of the gesture.
            The segments are the mockup's track with three cells of
            exactly equal width, placed rather than laid out.
      - [x] Chrome metrics, elevation and type, off the mockup. The pill
            tier is 52 tall and the readout 40 — both the user's own
            measurements — with the radius read off the height (both are
            capsules) and the vertical air read off the tap target, so a
            shell can never be shorter than the buttons in it. The tool
            cell is the mockup's 52 square, which with the shell's pad
            is the 64px column the user measured. `glass::Shape` carries
            height, corner, air and type scale in one table beside
            `Berth`, and `Chrome` opens the row itself — which is what
            centres the document's name against the taller menu button
            beside it. The elevation is the mockup's `--shadow`: straight
            down, soft, at 15% of the theme's own shadow ink, where
            egui's default fell sideways and landed hard. R26 puts the
            mockup's `.divider` back between undo/redo and the view
            controls, the one hairline in the shell. The action cluster
            gained Go Up, gated by the registry so the button and the
            registry cannot disagree about whether there is a scope to
            pop. Tool cells press with the mockup's spring — the plate
            ducks to .9 and springs back past its own size, the curve
            (`cubic-bezier(.32,1.5,.5,1)`) evaluated once in `glass` and
            reaching an exact fixed point so idle frames still settle.
            The type scale is the mockup's whole ladder rather than two
            patches: egui's stock 12.5/9 had the chrome at one size.

- [ ] The time machine grows a way out (2026-09-01). Three things the
      user asked for after the punch list: a pill that arrives rather
      than appears, a Save-as that writes the document *as shown*
      instead of a Restore that rewrites the head, and one place for a
      file operation to say what it did.
      `docs/cad-shell-playbook.md` records the rulings as R36 onward.
      - [x] 1 The viewing pill drops in on the spring and says less
      - [x] 2 Restore dies; Save-as writes the log through the rev shown
      - [x] 3 One toast, for what the file paths did
      - [x] The pill arrives, and carries three things (1). *"Drop in
            from the top with the spring loaded overshoot action"* and
            *"include only the 'Rev # · time', then some up and down
            arrows and a big 'Return' button"* (R36). The motion is the
            sheet's, not a second one: `glass::TRAVEL` is now the
            mockup's `.3s` for both pieces, `glass::off_stage` is how
            far either has to go to take its shadow with it, and
            `Chrome::shaped` carries the slide for every piece, so the
            safe area insets by where a travelling piece *settles* in
            one place rather than two. The pill is drawn every frame
            now, like the sheet, and keeps what it was saying in egui's
            memory for the length of its exit — a pill that blanked
            itself on the way out would be a second change to watch —
            and answers no clicks once it is leaving. Return is the
            shell's one filled control, in the pill's own two roles the
            other way round. **Fixed on the way through:** `egui::Area`
            holds an area inside the screen by default, so *neither*
            travelling piece could actually leave — the sheet's spring
            has been clamped since it landed. Every berth is anchored
            and none is movable, so the constraint is off.
      - [x] Restore dies, everywhere (2). *"Let's drop the whole notion
            of 'make this rev current'."* (R37, superseding spec §6.3.)
            The pill's button went with R36; the history row's "Make
            current", `CommandId::RestoreRev`, `RESTORE_HINT`,
            `Action::RestoreRev` and its dispatch, `edit::restore` and
            `Drawing::restore_to` go here. Two things fell out with
            them: `CommandContext.head`, which existed so a restore
            invoked by name could target the rev on the canvas and which
            nothing else ever read, and the `&mut CommandSet` the
            history panel took — the restore was the one thing it asked
            the registry about, so the panel now asks it nothing. The
            read-only gate is one question instead of two: nothing
            writes the document through the lens.
      - [x] Save-as writes the document as shown (2). One entry, whose
            meaning is the rev on the canvas: under the lens it writes a
            container whose log is the prefix through that rev and lands
            the session in it, writable, at that rev; at the head it is
            the Save-as it always was. The scope is settled when the user
            asks, not when the dialog returns — a picker can sit open for
            minutes and what lands must be what the hover promised. The
            lines are copied verbatim (`src/store/prefix.rs`): the log
            *is* the document, so re-serializing it would be a save that
            could change it, and a contiguous prefix keeps D12's chain
            whole by construction. Written rather than copied: the
            projection and its stamp for the new head, the artwork the
            lines reference (same content-addressed names), and the tag
            records for revs inside the cut that were named after it —
            those cannot be copied, so they are re-stated by whoever
            saved, which a claim *about* history may be and an edit may
            not. **`Store::seeded` flattened all of that** — commits are
            all it takes, so a container saved through it came out as a
            run of fresh `edit` records by the saving author with the
            tags gone. It now serves only the scratch session it was
            written for, and the ledger item closes.
      - [x] One toast, for what the file paths did (3). *"Fold the toast
            into that dispatch"* (R38): Save-as and export, each way —
            and the failure half is the load-bearing one, since an export
            that could not be written said so in the console and nowhere
            else. Not per edit and not per tool: the document says what
            an edit did. Geometry, tone and motion are the mockup's
            `.toast` (bottom centre 22 off the edge, capped, one 13px
            line, twenty pixels of rise on the one curve over .25s, 2.4s
            of hold), except the colours, which are the palette's
            inverse-video pairing rather than the mockup's near-black
            glass — the only pairing that clears AA in both schemes. It
            is not a berth: transient, unpressable, and it takes no room,
            so it draws beside the chrome rather than through `Chrome`.
            `toast::say(ctx, …)` is the one enqueue and works off the UI
            thread, which is where a dialog finishes, so the export path
            says what it did with no channel and no poll. A holding toast
            asks for the one frame it will leave on rather than a stream
            of them, so an idle frame with a toast up still settles.
            Pictures: `shell_toast` at both widths.

- [x] Arming and dragging out are two gestures (2026-09-01). The user, on
      the odd one out: *"The 'add port' tool behaves differently than the
      rest of the tools. It should require a drag to create (like the
      other tools) if the tool is just clicked. If the user drags the
      tool off the toolbar, then use the current 'stamp' behavior. For
      the other tools, like the block, area, and text, use the same
      semantics. Dragging the tool onto the canvas creates a default
      'thing' with basic initial dimensions/contents. For the image tool,
      a drag to the canvas should just open the import image box, and
      import the image with some default size. For the route tool,
      dragging it onto the canvas shouldn't really do anything. A route
      cannot exist without it's endpoints."*
      `docs/cad-shell-playbook.md` records this as R39–R40.
      - [x] 1 The drag-out path: a cell carried onto the canvas stamps
            the tool's default thing where it lands. The cells sense a
            drag as well as a click (egui tells the two apart by how far
            the pointer travelled), the cluster reports the drop and
            nothing more, and the app turns it into
            `Action::StampTool { tool, at }` — dispatched like every
            other creation, so the write goes through `Drawing` and the
            stamp is one commit labelled by its tool's own verb. The
            defaults live beside their emitters in `edit/create.rs`
            (`stamped_block` reuses the rect a top block is born with,
            `stamped_area`, `stamped_port` — which the add-port drag now
            falls back to as well, deleting its private copy of the same
            two numbers). Route and Select stamp nothing; the image drop
            is handed back to the app, which opens the picker and lands
            the file at the drop point. A drop on the glass, on the
            sheet, or off the window is no drop at all
            (`shell::over_the_chrome`). The carried cell ghosts its own
            icon under the pointer while it travels.
      - [x] 2 The armed path: a bare click creates nothing, anywhere. The
            add-port tool loses its click arm — the drag it already had
            (press for the body's top-left, slide for its width, release
            to commit) is the whole gesture, and it is the tool's own
            semantics rather than a new one. The text tool grows the same
            drag: a box has no stored extent, so the drag names the
            corner it begins at rather than a size. The image tool loses
            its click arm too, since opening the picker and placing at
            the cursor *is* the stamp, and stamping now lives on the
            drag-out path alone. New Block keeps its two-click placement
            — the first click still creates nothing, and the tutorial
            teaches that gesture by name. The add-port tool's private
            copy of the emitter's default width and fixed height goes
            with the click arm.
      - [x] 3 The playbook's rulings and the landed note. R39 is the
            two gestures and everything the drop has to ask before it
            counts; R40 is the session lead's generalization — a bare
            click with a creator armed creates nothing — with the three
            judgment calls it forced written down beside it (the
            add-port drag needed no invention, New Block keeps its
            two-click placement, and the image's pending dialog is the
            one place a drag-out touches the armed tool).

- [x] Spec v3 Phase I — the top bar (2026-09-01). The floating pills
      converge on `docs/cad-ui-spec.md`'s three persistent regions and the
      status line beside them; `docs/cad-shell-playbook.md` records the
      rulings as R41–R46.
      - [x] 1 The elevation rule, once. `glass::Elevation` is §2's
            *docked chrome is flat, square and unshadowed; floating chrome
            is rounded and lifted*, read off the berth — so a piece cannot
            be given a place without being given the treatment that place
            implies, and `shell()` derives both from the one answer. A
            third case, `Bare`, is the status line: no surface at all, and
            no pointer either. Four berths where there were eight: TopBar,
            ToolCluster, Navigator, StatusLine. `SafeArea` now stores each
            edge's bare depth and adds the clearance in `region()`, so the
            navigator can hang from where the bar *ends* rather than from a
            gap below it.
      - [x] 2 The top bar (§2.0). 54px, docked, translucent, spanning:
            document menu, liveness dot, breadcrumb left; mode centre;
            undo, redo, the rule, Go Up, fit and Browse right. It replaces
            four modules — the document chip, the action cluster, the
            viewing pill and the status chip — whose berths are deleted
            rather than reduced. The breadcrumb is rooted at the document
            name (which still renames on a double click), collapses from
            the middle past four segments with the hidden run behind one
            ellipsis that lists them, and caps each segment at the
            mockup's 200px. The dot is liveness, not a save state (R42):
            green recording, amber under the lens.
      - [x] 3 Viewing is a state of the bar, not a second object. The pill
            is gone; the bar tints amber (`Role::ViewingTint` washed over
            its own glass, so the bar changes state rather than becoming
            another object) and its centre fills with the rev, the stepper
            and Return. The mockup transitions the background and swaps the
            centre outright, so that is what happens here. The centre is
            measured on a sizing pass and placed in the middle of the room
            the two runs left — adding up the widths of what it holds would
            be the same layout written twice. Undo under the lens carries
            the mockup's own words for why it is dead: *return to current
            first*.
      - [x] 4 The status line (§2.0.1). Plain muted text, bottom left, no
            container, no pointer. Four states in one enum whose variant
            order *is* the priority — confirmation, tool instruction,
            selection path, then zoom and pointer (R46) — resolved once in
            `Says::of`. A confirmation holds two seconds and asks for
            exactly the frame it will leave on, so an idle editor still
            settles. The tool instructions live beside the cluster's own
            membership list, one per cell.
      - [x] 5 The Navigator (§8). One form at every size: flush right,
            full height, translucent, docked treatment, no scrim, no close
            control, no dock breakpoint and no portrait variant — a narrow
            window narrows it to 320 and changes nothing else. Every pick
            hands off and leaves it open (a segment no longer toggles it
            shut). Only working dismisses it: a canvas click, which now
            **passes through and performs its selection in the same
            gesture** (reversing R28's spent press), a tool pick, or
            Escape — which the navigator claims *before* the lens, so two
            presses do two things in the spec's order. Dismissing clears
            the Hierarchy filter (§9's "survives Escape" superseded).
      - [x] 6 R43's re-wiring. "Saved as…" and "Exported…" are status-line
            confirmations now, and so is every edit that moves the head —
            asked of the whole frame rather than of the dispatch, since
            most edits are a tool's gesture sealing on the canvas rather
            than an action anybody named, and read off the commit's own
            label, so the line and the history row word one edit the same
            way. The toast keeps failures and
            stays capable of the attention-with-action events §2.0.2
            reserves it for.
      - [x] 7 The pictures. `shell_frame` is the frame at rest now (the
            navigator closed), `shell_navigator` and
            `shell_navigator_hierarchy` are the panel open at both widths —
            1024 being the narrow variant — and `shell_frame_viewing` is
            the amber bar. `shell_sheet_docked` and `shell_sheet_portrait`
            are deleted with the two modes they photographed.

### The spec-v3 top-bar punch list (user, 2026-09-01)

The user's fifteen items on the landed Phase I chrome, in their own
numbering (9 and 13 are one line here — they asked for one thing twice).
Rulings are R47 onward in `docs/cad-shell-playbook.md`.

- [x] 1 The breadcrumb's last segment is a label, not a button.
- [x] 2 Breadcrumb hover changes nothing but colour; the rule under
      Select is centred in the rail.
- [x] 3 The Zoom preference is deleted, `WidgetSize` with it (R47).
- [x] 4 The Navigator's segmented control shows its gutter.
- [x] 5 Its segments are tighter on their words, with air between them.
- [x] 6 The chrome is opaque — translucency without blur is grid noise
      under text (R48).
- [x] 7 Go Up and Enter wear arrow-square-out / arrow-square-in.
- [x] 8 One click on the document rises to the root, two rename it and
      the rise is never taken (R49).
- [x] 9/13 The status line is a two-line title block: state, then
      author · rev · when.
- [x] 10 The document menu takes the mockup's own glyph.
- [x] 11 The dot's four states, each with words (R50).
- [x] 12 The rev stepper takes the mockup's up/down arrows.
- [x] 14 The I/O control's icon is fixed, and its choices are icons.
- [x] 15 The chrome's one word for the thing is *diagram* (R52).
- [x] 16 One Open, and it asks for a `.bwx` diagram; the projection is
      not an openable thing (R53). The command line keeps its own door.
- [x] Review catch: the document name was painted in
      `strong_text_color`, which this palette maps to its darkest base.
      `glass::full_ink` is the one answer, the bar's tests wear the app's
      own colours, and the harness reports the ink a word was actually
      painted in.

### Share bundle (user, 2026-09-01)

*"I suspect that the application should allow for opening a .zip file of a
.bwx, since that is something that can be sent via e-mail or dropped on a
thumb drive. Directories don't cross OS boundaries all that well."* F9's
share bundle, brought forward from the web phase. Ruling R54 in
`docs/cad-shell-playbook.md`.

- [x] `store::bundle` — `pack` writes a container's own files into one
      archive under its own name (sorted, one timestamp, no lock);
      `unpack` lays a bundle back out as a directory, refusing a zip with
      no diagram in it, one with two, and an entry reaching outside.
- [x] The chrome: `Share…` in the document menu, `Open shared diagram…`
      beside `Open diagram…`, and unpack-then-open with the collision
      going to the save dialog rather than over anything. Two Open
      entries rather than one because rfd has two dialog modes and no
      third: the folder flag is exclusive on every backend (the XDG
      portal's `directory`, Windows' `FOS_PICKFOLDERS`, NSOpenPanel's
      `canChooseDirectories`), so one dialog cannot offer a `.bwx`
      folder and a `.bwx.zip` beside it.
- [x] The playbook ruling (R54) and the F9 row's pointer. Deferred and
      said so: the without-history bundle (Save-as already answers "at
      which rev") and the web build's share form (Phase 8).

## The cad-shell merge — one line again (2026-09-02)

Branch `merge-cad-shell`, a real merge of `cad-shell` into the
`single-author` line. The two halves of the Phase 7 split come back
together: the substrate (choreography, notes, the tutorial reader) and the
chrome (the spec-v3 shell). Resolution policy, per subsystem:

- [x] **The demolition stands.** Nothing from `src/script/`, the old
      `src/tutorial/` modules or the KDL schema returns. cad-shell's edits
      to those files died with them: `tutorial/player.rs`'s input-blocker
      change, `tutorial/cues.rs`'s `full_ink` call (the finding it records
      already lives in `shell::glass::full_ink`), and `script/driver.rs`'s
      `History`/`StampTool` adaptation.
- [x] **Chrome, tools, canvas: cad-shell.** `src/shell/*`, `tools/overlay.rs`
      (ex-`toolbar.rs`), `tools/stamp.rs`, `history.rs`'s `Undoer` rewrite,
      the icon set, `index.html`, the `wasm-release` profile, the subset
      Iosevka, "Block N" naming, and the twelve shell pictures.
- [x] **The store: a true merge.** single-author's note records, the notes
      projection and `Store::reading` sit beside cad-shell's `prefix.rs`
      Save-as and `bundle.rs` share bundle. `Store::replayed` became
      cad-shell's `Store::over`, which `Store::reading` now shares;
      `store::history::Kind` grew a `Note` arm so a note's row says what it
      is. No record kind was lost either way.
- [x] **app.rs: cad-shell's, minus the demolished.** `Mode` and `Parked`,
      the tutorial enter/load/exit, `advance_replay`, `show_replay_cues`,
      `show_author_footer`, the script-spelling `PointerObject` family and
      the `--author` recorder are gone; R40's image-stamp arm, the drag-out
      dispatch and the whole shell frame stay. `script::driver::action_name`
      re-points onto `commands::action_name`; `script::parse::tool_kdl_name`
      onto `ToolName::command_name` (the recorder that called it is gone, so
      the call went with it).
- [x] **Phase 7 UI seams**, marked `// Phase 7 UI` where they are deliberate:
      `tool_cluster_video` deleted (with `Panel::VideoToolbar` and the unused
      `Panel::AuthorFooter`), the Learn segment left as its placeholder, and
      the Help menu left without §7's walkthrough entry (R15).
- [x] Judgment calls: `src/edit/restore.rs` came back `#[cfg(test)]`-gated —
      R37 killed the Restore *command*, but the choreography oracle still
      proves its `Crud::Restore` rules against that emitter, and `Crud::Restore`
      is what every paste travels out of. Two choreography goldens
      regenerated (`pantomime`, `rename_title`): blocks are born named now
      (R17), so "Untitled" reads "Block 1" — the only diff.
- [x] Proof: `cargo xtask ci` green, both sides' gates in it
      (`choreography_stays_derived`, headless, waist, palette, the shell
      snapshots, wasm). New: `every_shipped_tutorial_plays` — the three
      `fixtures/tutorials/*.bwx` open through `Store::reading`, report their
      chapters and depict every commit they hold, which nothing had asserted
      since the converter was deleted at 7·11.

## Phase 7's UI half — the tutorial library and the player (2026-09-02)

Branch `phase7-ui`. The chrome decision has landed (spec v3), so the half
that waited for it is built: `docs/cad-ui-spec.md` §8.3's Learn segment and
the floating player, against the frozen surface
`docs/choreographer-playbook.md` left (`tutorial::reader::Tutorial`,
`Timeline::at`, `Frame`, `Pantomime`, `Notes::shown_at`).

- [x] **The format ruling, taken whole.** *"Include any meta information
      about the tutorial in the log of the tutorial."* What a tutorial
      teaches is a `teaches/<tool>` tag (D18) on the rev whose commit that
      tool made; chapters and narration were already notes; the display
      title is the diagram's own name. No sidecar, no filename convention,
      no new record kind. `docs/tutorial-levels.md` says how to author one
      and how the three shipped containers were given theirs
      (`BLOCKWORX_TAG_TUTORIALS=1`, idempotent, `document.json` untouched).
- [x] **The Learn segment** (`shell/learn.rs`, `tutorial/library.rs`): the
      shelf, with each walkthrough's title, chapter count and total length;
      the filter matches CHAPTER titles (chapters are the addressable unit);
      watched state and a resume offer, persisted in eframe storage under its
      own key. A pick opens the player and leaves the panel open, per the
      Navigator's hand-off contract. Native only; the wasm segment says
      walkthroughs arrive with the web build.
- [x] **The player** (`tutorial/player.rs`): a floating panel in a corner of
      the room the chrome measured, corner-sized or large. It draws the
      tutorial's own document through the ordinary `display` pass and lays the
      synthesized `Frame` over it in one guide role — the subjects a track
      depicts are held back from the document pass, so nothing is drawn twice.
      Chapters are the navigation (a menu off the title, and a prev/next
      stepper); narration is a caption that stands until something else is
      said. The user's document stays live behind it: it is a panel, not a
      mode, and no `Mode` enum returns.
- [x] `Tutorial::span` — the one dwell resolver. A note is an empty commit
      and depicts nothing, so without it a walkthrough's narration would flash
      past in a frame. It lives in the substrate so a chapter's advertised
      length and the player's clock cannot disagree (7·8's rule kept).
- [x] **The ring** (R15's sibling): while a walkthrough plays, the tools its
      `teaches/` tags name wear a ring in the *live* tool cluster — the button
      the viewer will actually press, not a picture of one. A theme role
      (`WalkthroughRing`), not accent arithmetic.
- [x] **The palette source** (§9, invariant 13): a `Learn` group, typed like
      the other sources — one row per walkthrough and one per chapter, since
      chapters are the addressable unit in the library and a palette that
      offered only the tutorials would reach a fifth of what Learn does. The
      invariant is a test that walks the library and demands a row for every
      one of them.
- [x] **Contextual entry (R15)**, on both surfaces the ruling named. The
      selection overlay's OVERFLOW menu carries *Walkthrough: <title>
      (m:ss)* for every walkthrough whose taught tools the selection's command
      set holds; the right-click menu does not, because a walkthrough is an
      offer *about* the selection rather than a verb on it and §3.6 carries
      exactly the overlay's commands. Help ▸ Tutorials opens the navigator
      onto Learn — the one walkthrough that applies here, and the library.
- [x] Proof: every fixture plays end to end through the real player surface
      (real egui frames, a test-supplied clock); the caption appears at the
      rev its note was written at and stands until something else is said;
      chapter jumps take, forwards and back; a paused player settles; the
      depiction reaches a real `Renderer` in the guide role; the ring appears
      for taught tools and only those; the R15 overflow entry appears exactly
      when the selection's commands intersect a taught tool, and the
      right-click menu never grows one; invariant 13 over the whole library;
      watched state survives the eframe storage round trip.
- [x] Pictures: `shell_learn`, `shell_player`, `shell_player_expanded`, each
      at 1280 and 1024. `shell_palette` re-cut — the Learn group is in it now,
      which is invariant 13 visible.
- [x] Docs: `docs/choreographer-playbook.md` (the UI half's landed note and
      what the frozen surface grew), `docs/single-author-playbook.md` (Phase 7
      closes), `docs/tutorial-levels.md` (the teaches tag and how the fixtures
      were re-tagged), `docs/cad-shell-playbook.md` (R15 answered on two
      surfaces; invariant 13 closed; the player is not a berth).

**Resolution candidates for the lead.**

- **The shipped walkthroughs are titled by their container names** —
  `first-block`, where the chapter under it reads "Your first block". The
  format ruling ties the display title to the diagram's own name and D20 says
  that is the container's, so this is honest rather than a bug; renaming the
  three directories is the one-line fix if prose titles are wanted.
- **R15's match is against the selection's whole command set**, not the verbs
  the bar draws, because no shipped walkthrough teaches an overlay verb. With
  §8.3's eventual ~20 walkthroughs that intersection wants narrowing.
- **`Library::shipped()` looks beside the executable and falls back to the
  source tree.** Installed layout is Phase 8's question; today the fallback is
  what answers.
- **`Tutorial::span`/`DWELL` is a substrate constant the UI half asked for.**
  It keeps 7·8's rule true rather than breaking it, but it is a number the
  choreographer did not previously have.
- **The player takes no room from the canvas** (the toast's bargain, not the
  navigator's), so a fit taken while one is up can land the model under it.
- **The pantomime's affordance is read but not drawn.** The hand travels and
  the tool is ringed; nothing yet rings the affordance the gesture took hold
  of.
- **The history browser's animation is not built** — the other half of Phase
  7's UI exit criterion, and now a small piece on top of what landed.

## R56 — the tutorial subsystem is struck (2026-09-03)

The lead: *"I have an alternate solution... it looks like the existing
approach will take significantly more effort to reach a professional level
of polish, and I need something simpler."* Everything above in Phase 7's UI
half is deleted, along with the substrate it stood on. The one thing that
had to survive is R55's camera, and it did not need a choreographer.

- [x] Deleted whole: `src/choreography/` (2,551 + 4,021 lines and 55 golden
      timelines), `src/tutorial/` (reader, library, player), the Learn
      segment `src/shell/learn.rs`, `src/tools/help_menu.rs`,
      `fixtures/tutorials/` (three `.bwx` walkthroughs), the `shell_learn`
      and `shell_player*` pictures, and `xtask`'s
      `choreography_stays_derived` headlessness guard.
- [x] Struck from the surfaces that reached them: `PanelView::Learn` and its
      navigator segment, R15's overflow offer (`Offer`/`offers`, and `Picked`
      with it — the overlay's click is a `CommandId` again), the palette's
      `Learn` source and walkthrough rows, Help ▸ Tutorials (the submenu
      keeps GitHub, inlined — a module for one entry was overweight), the
      tool cluster's `Taught`/`Ring` and its `theme` handle,
      `Action::Walkthrough` and the `Walkthrough` struct,
      `names::from_command_name` (the `teaches/<name>` resolver),
      `edit::naming::LabelVisibility`, the persisted `watched` list and its
      storage key, and the three `Walkthrough*` theme roles.
- [x] New: `src/spotlight.rs`. `worked(doc, routes, commit)` answers R55's
      question off the commit itself — the scope its ops worked in, and the
      union of its subjects' footprints in that scope. Tombstones mean a
      delete still frames what it took away; a block the pre-image never held
      is framed from the commit's own create; a wire uses the solved polyline
      where the presentation has one and its endpoints plus authored
      waypoints where it does not. The ring lives here too: `light` raises
      it, `ring` draws it wherever its fade has got to, `Role::ChangeRing` on
      the brightest base, one second held and half a second going.
- [x] ~~`Reach` decides how far a framing may go.~~ **Reversed by R57 the
      same day** — see below. A framing always opens the owning scope.
- [x] Proof: eight tests in `src/spotlight/tests.rs` over the derivation (the
      plain move, the union, the nested scope, the delete's tombstone, the
      commit's own create, the op one scope out that must not widen the
      region, the commit that frames nothing, and the fade curve), plus
      `a_step_rings_what_it_took_back` through the **real canvas pass** — the
      run of outlines the frame paints gains exactly one, round the
      pre-image footprint, and a frame with no step behind it paints none.
      R55's eight existing probes are untouched and still pass.
- [x] Fixed on the way: the R55 harness held two egui contexts — dispatch on
      one, paint on the other — so nothing raised in a dispatch could be seen
      in a frame. One context now, which is what the ring test needed.
- [x] Pictures re-cut: `shell_navigator`, `shell_navigator_hierarchy`,
      `shell_selection_overlay`, `shell_palette`. Each diff is a removal —
      the third segment, the walkthrough row, the Learn group.
- [x] Docs: R56 in `docs/cad-shell-playbook.md`; Phase 7's UI-half section
      marked struck; `docs/cad-ui-spec.md` §8.3 and invariant 13 marked void;
      `docs/choreographer-playbook.md`, `docs/tutorials.md` and
      `docs/tutorial-levels.md` kept as the record of what was tried, each
      headed with a retirement banner (the `kdl-format.md` precedent).

**Resolution candidates for the lead.**

- **A wire with no solved geometry is framed by its corridor, not its path.**
  The router can detour outside the box its endpoints and waypoints make, so
  such a ring can sit inside the wire it names. It only arises when the
  presentation has not materialized that route; passing the live
  `RouteGeometries` covers the case the editor actually hits.
- **The ring is stored with its scope and drawn only on that level.** With
  R57 every framing opens the owning scope, so the skip is now a guard
  against a viewer navigating away mid-fade rather than a policy.

## R57 — a visualized step brings its own scope (2026-09-03)

The lead: *"Reverting to an older view does not change the scope that is
visible. When visualizing a step, the owning scope for that step needs to be
on the canvas. This should be computable from the opcode."*

R56 gave the rev pick a narrower reach than undo/redo, on the argument that
browsing a log should not move a reader between levels. Wrong, and for R55's
own reason: a lens showing a level the picked rev never touched is a drawing
that did not change, which *is* the "edits are happening that you cannot see"
complaint.

- [x] `Reach` and both its variants are deleted. `show_what_changed` opens
      the owning scope for every caller — undo, redo, and the rev pick alike.
      All three rev-pick surfaces (history panel row, its stepper, the top
      bar's lens control) route through `Action::ViewRev`, so one change
      covers them.
- [x] No new machinery: `spotlight::worked` already derived the scope from
      the ops — a shape's owner, a block's parent, a wire label's wire's
      owner, and the pin exception (a pin shows at its slot anchor on its
      owner's *outside*, so it belongs to the scope the owner is a child
      of). The pick was refusing to use what it was handed.
- [x] Proof: `a_rev_pick_opens_the_scope_that_rev_worked_in` — an edit two
      scopes in, the reader browses back out, picks that rev, and lands on
      the level it happened on with the change in sight. R55's eight probes
      are untouched; nine now in the module.
- [x] Cost, named: `copy_out_of_the_past_pastes_into_the_present` walks back
      into the level it copies from, because that fixture's one seeding
      commit works at the root while the block it copies lives a level in.
      The honest shape of the test — a hand would do the same.
- [x] Docs: R57 in `docs/cad-shell-playbook.md`, and R56's paragraph amended
      in place rather than rewritten.

**Resolution candidates for the lead.**

- **A commit spanning two scopes is framed on its first op's scope.** One
  gesture works in one scope, so this only shows up on synthetic
  multi-level commits (the test fixtures' seeding commit is the one in the
  tree). If hand-authored commits ever span levels, the rule to want is
  probably the scope holding the most subjects, not the first.

## P0 — ids are counters (2026-09-04)

The first phase of `docs/log-vs-snapshot.md`'s plan, and the one it says is
worth taking on its own: D10 reversed (§5.1). `Id<K>` was a random v4 uuid;
it is now a per-kind, document-global `u32` counting from 1, minted by an
allocator the document owns and never stores — derived at load as one past
the highest of that kind the document has ever held. Details taken as given
from §14.3: counters start at 1 so `Id::NULL` stays the root, mnemonics go
lower-case so `Display` is `b7`, asset hashes are untouched.

- [x] `crates/doc/src/id.rs` rewritten. The eight hand-written kind blocks
      become one `id_kinds!` table that also generates `EntityRef`, its
      narration `Display`, and `Allocator`'s per-kind marks — so a kind
      cannot exist without a mark or a way to name it. `Id<K>` serializes
      as its `Display` form through `FromStr`, which is both the greppable
      spelling and a legal JSON map key (§8's one real piece of work, paid
      for here). `LabelKind`/`LabelId` were referenced by nothing and are
      deleted.
- [x] The mark rises, never falls: `Document::try_apply` observes every
      op's `EntityRef` before folding it, so loading a log leaves the marks
      past everything the document has held — tombstones included, which is
      why a deleted id cannot come back on something else.
- [x] Minting flows through the write waist the tools already hold.
      `Gesture` carries an `Allocator`; `Drawing::mint` raises it to the
      gesture's own prediction and hands out the next id, and
      `Drawing::ids` hands paste a snapshot for its data-dependent count. A
      cancelled gesture reuses its numbers, which costs nothing because it
      created nothing. `FreshIds` and its test-only `Sequential` variant
      are gone; the tests use the real allocator.
- [x] `schema/project.rs`'s `Names`, `Rank`, `CreationOrder` and `minted`
      are deleted (§14.3.6) and `project` now takes `&Document` rather than
      `&Repo` — the names were the log's to give only because they had to
      be synthesized; the id *is* the name. Every list sorts by id through
      one `owned_in_id_order`, so the four scope-owned kinds cannot
      disagree about order.
- [x] Pin ids are document-global, so a route endpoint has one spelling
      everywhere (`"from": "p47"`), at the root and inside a block alike.
      `anchor_key`, `LoweredScope::anchor_key` and the qualified `"b0:p1"`
      form are retired, and `SourceIds` — whose one caller already threw it
      away — with them.
- [x] Proof: `the_next_id_is_one_past_the_highest_the_fold_saw` and
      `a_deleted_entity_does_not_free_its_id` in `crates/doc`, the id
      module's own six, and — through the real editor path —
      `a_tool_mints_one_past_the_highest_id_the_document_holds`, which
      loads a document up to `b7`, mints `b8` and a `t1` beside it, deletes
      `b8` and gets `b9`.
- [x] Goldens regenerated and read: `src/store/goldens/log.jsonl` (record
      ids and every chained hash), `src/schema/goldens/canonical.json`
      (block names 1-based, the endpoint unqualified). §13 guessed the
      canonical golden would not move; it does, for exactly those two
      reasons.
- [x] Cost, named: **`fixtures/*.json` had to convert now, not at P2.**
      Three suites read them — the round-trip gate and its root-scope
      counts (`schema/roundtrip.rs`), `widget/closed_router_tests.rs`
      (`demo.json`) and `widget/render_path_tests.rs` (`demo-2.json`) — and
      a per-owner `"p1"` cannot be read under a document-global rule
      without keeping the whole two-spelling resolution the phase exists to
      retire. The conversion is surgical: only `id`, `top`, `children`,
      `from` and `to` values changed, every other byte is the file's own.
      `xtask autogen scale` mints under the new scheme, and
      `fixtures/block50.json` was renumbered in place rather than
      regenerated so it stays the 4.8 MB measurement §5.5 cites.
- [x] Cost, named: a file spelling one pin id twice used to be legal (the
      key was `"block:pin"`) and now names one pin. The lowering keeps the
      first and drops the second with a warning rather than minting one id
      for two pins, which the fold would refuse — taking the whole document
      down over a line a hand could have written.
      (`a_pin_id_spelled_twice_keeps_the_first_pin`.) Duplicate *block* ids
      still fail the fold, as they did before.
- [x] Three pre-existing failures fixed on the way past, none of them P0's:
      the atomic-write test compared a canonicalized path against an
      uncanonicalized one (macOS `/var` is a symlink), the tool-cluster
      hover test read fonts off a context that had never run a frame, and
      clippy had two new lints (`as_chunks`, `f32::midpoint`).
- [x] Docs: `docs/json-format.md`'s naming and anchor sections rewritten;
      `docs/single-author-playbook.md` records the D10 reversal in the
      ledger's own style with the original decision kept beneath it as the
      record, and D11 annotated where stickiness became structural.

**Resolution candidates for the lead.**

- **`uuid` stays a dependency of the app, not of `crates/doc`.** The
  document crate no longer needs it at all. The app's one remaining use is
  `naming::entropy` — 32 bits for the three-word name a new diagram is born
  under — and swapping it for `getrandom` would need a native dependency
  and a fallback path for a `Result` that cannot usefully fail here.
- **`chronological()` still sorts by `(max_order, id)`.** §14.1 rules that
  it becomes a sort over ids alone, but that is P2's, with `Register`; ids
  being monotonic is the precondition, and it now holds.

## P1 — tombstones out (2026-09-04)

The second phase of `docs/log-vs-snapshot.md`'s plan: S5, "tombstones are
removed — delete is removal" (§10's phase table). `Live<T>` collapses to `T`,
`Liveness` and `presence` go with it, `Crud::Restore` leaves the opcode
vocabulary, and `DocIndex::suppressed` — the concurrency net for a route whose
endpoint died in a commit this client never saw — goes with them. "Is it
alive" and "is it there" were two questions with one answer; now they are one
question.

- [x] `crates/doc`: every entity table is `HashMap<Id, Arc<T>>`, every
      accessor hands back the entity itself, and `apply_crud_to_entity`'s
      `Delete` arm is `map.remove(&id)`. `validate()` learns that a commit
      which removed its target leaves nothing to check — it runs against the
      *folded* document, so an op's position inside its commit never
      mattered and still does not.
- [x] Undo of a delete is a **create under the same id**. `invert_crud` now
      takes the pre-image *and* the folded document and asks whichever one
      holds the entity: a create is undone by deleting what the commit left
      standing, a delete by re-creating what stood before it. The pair
      created-and-deleted within one commit therefore inverts to *nothing*,
      which is what it means. Safe because the allocator's marks only rise
      and the fold observes every id the log names — stated in a comment at
      `Document::ids`, because P5 (log deletion) has to replace that
      reliance with stored marks.
- [x] Cut-and-paste stays a move, on the same footing: `move_back`'s seven
      `Crud::Restore` pushes and its follow-up register writes become
      `Crud::Create` of the snapshot's own inits, under the original ids,
      with the landing's owner and shift applied to the init rather than
      after it. `move_back` and `duplicate` then differed only in *how they
      name what they insert*, so they are one emitter (`insert`) over a
      `Naming` enum — `Keep` or `Mint(&mut Allocator)` — which also decides
      how a wire resolves an endpoint and where a pin takes its slot. ~100
      lines of parallel implementation deleted.
- [x] **The spotlight reads both documents** (§12.7). `worked` takes a
      `Step { before, after }` and reads each subject from `before`, falling
      back to `after` — a step frames where the thing *was*, and only what
      the commit itself brought into being is read from the arrival. That
      retires `minted()` and the three "read it from the create's init"
      special cases in `container`, `block_rect` and `parent_of`: every
      subject a commit can name stands in one of the two real documents.
      `view_rev` folds the prefix to rev−1 **once**, clones that document as
      `before` (cheap: `Arc` tables), and `fold_one`s the commit to get both
      the arrival and the `Repo` the time machine keeps.
- [x] `src/edit/restore.rs` **deleted** rather than rewritten (§14.3.8 puts
      it at P2). It was `#[cfg(test)]`-only, its command died with R37, and
      `src/edit/mod.rs` said in as many words that it survived "because
      `Crud::Restore` is still what a paste travels out of" — which stopped
      being true here. Leaving it until the file is deleted around it is how
      dead code survives a refactor (§14.3.6's reasoning, applied one phase
      early).
- [x] Proof: `deleting_removes_an_entity_and_a_second_delete_refuses`,
      `an_edit_after_a_delete_refuses_the_commit`,
      `a_deleted_id_is_never_reissued_but_can_be_re_created`,
      `deleting_an_endpoint_without_its_route_refuses_the_commit`,
      `a_create_and_delete_within_one_commit_inverts_to_nothing` in
      `crates/doc`; the clipboard's move/duplicate/partial-state trio
      rewritten to assert the new op shape; and — through the real editor
      path — `viewing_a_delete_rings_where_the_block_stood`, which deletes a
      block, picks that rev in the time machine and finds the ring drawn on
      the footprint the block no longer has.
- [x] Cost, named: **an op aimed at something already gone now refuses the
      whole commit.** The retained inner used to absorb an update to a
      tombstone and surface it on restore; there is no inner to absorb it.
      The emitters already "push nothing at absent targets", so this is the
      contract becoming checked rather than a new restriction.
- [x] Cost, named: **a fold-only invariant was added, not removed.** A
      commit that takes a pin out from under a wire it does not also delete
      is refused (`dangling_endpoints`), because a route's endpoints are the
      one reference the index cannot quietly drop — an orphaned wire is
      still indexed and still drawn, where a child of a departed owner
      leaves the index with its scope. `edit/delete.rs`'s cascade already
      emits every dependent delete, so the refusal is unreachable from the
      editor. §12.9 wants this list empty before P4; it is now one long.
- [x] Cost, named and then **paid** (follow-up commit, same day): presence
      was the only thing telling "the cut this paste completes" from "a
      payload from elsewhere", since tombstoned-vs-absent *was* that
      distinction. For one commit a foreign snapshot moved rather than
      duplicating; the payload now says which document it came out of
      instead — `Origin::{Copy, Cut { from: DocumentNonce }}` on
      `Clipboard`, against `Doc::session()`, the nonce a handle mints when
      a document is created or opened. `docs/collab-architecture.md` §9's
      rule is restored exactly (cut + first paste = move; copy-paste and
      later pastes = duplicates), and §5.1's premise that every
      cross-document flow re-mints holds again.
- [x] Golden regenerated and read: `src/store/goldens/log.jsonl`. Only
      `state` and `parent` hashes move — the ops are byte-identical — which
      is exactly the folded-state stamp losing the presence register from
      its canonical bytes.
- [x] Docs: `docs/single-author-playbook.md` gains **D22** in the ledger's
      style, and D10's derivation clause is reworded now that "tombstones
      included" names nothing. `docs/json-format.md` needed no change: it
      never mentioned restores or tombstones. The historical playbooks
      (`op-emitter-playbook.md` 10f, `collab-architecture.md`) are records
      and stay as written; the *live* doc comments they seeded were fixed
      where they now say something false.
- [x] `cargo xtask ci` passes except the two GPU snapshots that already
      failed on the parent commit (`shell_selection_overlay`,
      `shell_palette` — driver drift, same failing-pixel shape);
      `cargo xtask ci --no-snapshots` is green.

### P1a — the paste asks the payload where it came from (2026-09-04)

A follow-up commit on P1, closing the one behavior change it left open.

- [x] `Clipboard` carries `Origin::{Copy, Cut { from: DocumentNonce }}`
      beside its snapshot, in both wire variants. `copy()` stamps `Copy`,
      `cut()` takes the session's nonce and stamps `Cut`.
- [x] `DocumentNonce` lives in `src/doc.rs` beside the handle that owns
      it: 64 drawn bits (`naming::entropy` twice), minted in `Doc::scratch`
      and `Doc::attached` — the only two ways a `Doc` exists — so a handle
      without a document identity is unrepresentable rather than merely
      unlikely. `Doc::Attached` becomes a struct variant to carry it. It is
      session state: never logged, never projected.
- [x] `mode()` asks the payload first and the document second: a `Copy`
      always mints; a `Cut` keeps its ids only where its nonce is this
      session's *and* every source it names is gone from the document. The
      export path (`selection_repo`) pastes into a `DocumentNonce::mint()`
      of its own, which says in one line why an export always mints.
- [x] Proof: `a_cut_pasted_into_another_document_duplicates` (a target that
      has held and deleted `b1`–`b5`, so nothing collides and only the
      origin can refuse) and `a_copy_whose_sources_are_gone_still_duplicates`,
      beside P1's move/second-paste/partial-state trio, all kept.

**Resolution candidates for the lead.**

- **The paste is no longer derived from the document alone.** `mode()` now
  reads the payload's `Origin` as well as the document, which ends the
  doctrine in `docs/collab-architecture.md` §9 that provenance is never
  clipboard state. That doctrine was a concurrency argument — another
  client could have deleted or restored the sources between the cut and
  the paste — and it is spent. The *rule* §9 states is unchanged; only
  where the answer is read from is.
- **`chronological()` still sorts by `(max_order, id)`**, and now reads it
  off the entity rather than through the wrapper. §14.1's collapse to ids
  alone is still P2's, with `Register`.

## P2 — the model collapse, and the format moves once (2026-09-04)

`docs/log-vs-snapshot.md` §8's ruling executed: the `crates/doc` values
*are* the JSON. Decision B's residue and the second durable model go in
one commit, so the durable format moves exactly once.

- [x] `Register<T>`, `WriteOrder`, `Applied` deleted. `entity!` generates
      one struct per kind with plain fields and both serde derives, every
      field `skip_serializing_if` at its zero through one generic
      `is_default`; `$init` is gone and `Crud::Create` carries the entity
      itself. The `Entity` trait is `Id`, `Update`, `apply`, `invert` —
      `from_init`/`to_init` (identity now), `max_order` and
      `updates_toward` leave with their consumers. Every write wins.
- [x] `Document` is `rev`, `stamp`, allocator marks (all session-only,
      `#[serde(skip)]` by construction — serde sees only `Content`) over a
      `Content` of `version: FormatVersion` (3; a newer file is refused by
      the type's `TryFrom`, §14.3.1), the flattened title block (`name`,
      `top`), seven `BTreeMap<Id, Arc<_>>` tables and `assets`. `BTreeMap`
      is the type-level answer to id-ordered output; `Arc` stays so the
      spotlight's and `view_rev`'s document clones stay cheap.
- [x] `src/schema/` deleted whole — `model`, `lower`, `project`,
      `roundtrip`, `enums`, `loc`, `error`, `tests`, the canonical golden:
      3,085 lines against §11's 2,255 for the files it named (the rest is
      the six it did not). `edit/lower.rs` keeps the world-pixel ↔ grid
      table and loses its value bridges (−74). Code outside fixtures and
      goldens: 81 files, +1,724 / −5,416.
- [x] What replaces the bridge: `Document::creating_commit` — the parsed
      file as one commit of creates in dependency order, id-preserving;
      `src/document_file.rs` — `parse` with a miette span against the
      source, `to_json` pretty-printed. Open, D19 import, the OS-clipboard
      document paste and the router/render test fixtures all go through them; the
      paste re-mints (P1a's `Origin::Copy`), a loader never does.
- [x] Assets in human-readable form are `{"svg": "<verbatim>"}` /
      `{"png": "<base64>"}` — lower-case tags, SVG as its own text (the
      house preference for opaque content), the number-array reader kept.
      ciborium (the content hash) still sees bytes.
- [x] **Z-order is id order** (§14.1). `chronological()` sorts ids alone;
      `path.rs`, `auto_route.rs`, `drawing.rs` ×2, `presentation` unchanged
      in shape. Behavior change: editing a block no longer raises it. No
      snapshot moved for it.
- [x] Fixtures (13) converted to the flat v3 spelling through the old
      bridge before it was deleted; `xtask autogen scale` builds a real
      `Document` (xtask now depends on `blockworx-doc`) so `block50.json`
      is regenerated, not hand-renumbered. SVG/PDF goldens byte-identical.
- [x] Proof: `document_file::every_fixture_survives_a_write_and_a_read`
      (P2's gate, `parse(serialize(doc)) == doc`, stronger than the
      projection comparison it replaces) and
      `the_document_format_is_byte_stable` over `src/goldens/document.json`;
      `FormatVersion`'s newer-build refusal; the log golden regenerated —
      ops now spell a `Create` with defaults omitted, and every hash moves.
      `cargo xtask ci --no-snapshots` green (1,079 tests); the snapshot
      step fails on exactly the two pre-existing driver-drift images.
- [x] Docs: `docs/json-format.md` rewritten for the flat format with the
      golden as its example; D23 in `docs/single-author-playbook.md`.

**Resolution candidates for the lead.**

- **Screen coordinates are raw fixed point in the file.** `FracVal`
  serializes as its `i64` (value × 2²⁴), so an image rect reads
  `"x": 3355443200` for 200 px. The type owns the format, and this is what
  the type says; a decimal human-readable form (`Serialize` branching on
  `is_human_readable`, as `AssetHash` already does) is a small type-level
  change that re-converts the fixtures. Not taken here to keep P2 to the
  ruling.
- **`block50.json` grew from 4.8 MB to 6.4 MB** in the pretty-printed
  nested spelling. §5.5's measurements were on the compact form and are
  about the compressed size, which is unaffected in kind; P3 writes revs
  compact and compressed.
- **The log now carries the file's spelling.** A `Create` op omits
  defaults exactly as `document.json` does, so the log format moved with
  the document format. Acceptable while undeployed and moot at P5, but it
  is a second break in one commit and is named as such.
- **For P5:** the allocator marks are still derived by folding the log
  (`Document::ids` observes every op target). Once `Document` is what is
  loaded, a document whose highest id was deleted would re-mint it; the
  marks have to be stored with the snapshot or the rule accepted.

## P3 — revs beside the log (2026-09-05)

`docs/log-vs-snapshot.md` S4, the dual-write. Purely additive: `log.jsonl`
is still the document and `revs/` is derived from it, which is what makes
the phase's own gate free — the fold and the files are two independent
computations of one value, so they can be asserted equal at every rev.

- [x] `src/store/revs.rs` owns the name and the encoding:
      `revs/{rev:06}.json.zst`, zstd −1 (§14.2's default, `zstd` added
      native-only) over the *compact* `serde_json` bytes of the `Document`
      at that rev — a plain serde call since P2. Rev 0 is the empty
      document and has no file. K = 1: every rev is whole, no diffs (§14.2).
      `write`/`read`/`path`/`through` are the only spellings; `Container`
      exposes `write_rev`/`read_rev` over them, so the store, the fsck,
      Save-as and the bundle cannot disagree about a rev's name or bytes.
- [x] Write path: in `Store::write` the rev file lands, atomically and
      fsync'd (file *and* directory, via `atomic::write_atomically`),
      **before** the log line that names it — `extract_assets`'s
      before-the-fact discipline. All three ways a write can fail to land
      now share `Store::parted_from`, which demotes the container to
      `WriteFailed` and returns `Refusal::Append`.
- [x] The container gains `revs/` at `create_holding`, `revs/** binary` in
      the `.gitattributes` template, and `LAID_DOWN_DIRS` beside
      `LAID_DOWN` so `discard_pristine` still recognizes a pristine
      container (the `assets/` special case is generalized rather than
      duplicated).
- [x] Backfill: a container written before this commit, or one a crash
      caught between a rev file and its log line, has revs without files.
      `revs::backfill` writes them on the first **writable** open, folding
      the log forward *once* (`Repo::default` + `fold_one`) rather than
      `Repo::folding` per rev — O(n), not O(n²). A read-only open repairs
      nothing.
- [x] The gate. `revs::disagreement` is the one comparison — the same
      forward fold, reading each rev file and comparing `Document`s (P2's
      `PartialEq` is over content, so rev and stamp do not confuse it).
      `blockworx verify` reports the first mismatch by rev and exits
      non-zero; a **debug** build runs it inside `Store::over` on every
      open and panics naming the rev. `Absent::{IsAFault, IsExpected}` is
      the one honest difference between the callers: after a backfill a
      hole is a fault, and on a read-only container it cannot be.
- [x] `bundle::pack`/`unpack` carry `revs/` (the walk was already
      recursive; `lay_out` now lays the directory down like `assets/`), and
      `prefix::save_through` copies the rev files at or below the cut as
      files, like `carry_assets`, so a saved container needs no backfill.
- [x] Proof: every accepted step — undo and redo included — leaves a file
      that decodes to `Repo::folding(prefix)`'s document at that rev; a
      reopen finds them; a container with `revs/` deleted opens, backfills
      every rev and passes `verify`; a rev file rewritten with a different
      document is named as "rev 2" by `verify` while the log still verifies
      3 of 3; the bundle's revs are byte-equal across the round trip
      (checked *before* the unpacked container is opened, since opening
      would backfill); a save-as at rev 2 carries revs 1–2 and not 3; and a
      read-only `revs/` directory demotes the container with nothing
      appended. The debug gate also runs on every open in every store test.
      `cargo xtask ci --no-snapshots` green (1,086 tests, wasm clippy
      included — `zstd` is native-only); the snapshot step fails on exactly
      the two pre-existing driver-drift images, at the parent's counts.
- [x] Measurement (`TUNING.md` Finding 8): one rev of `fixtures/block50.json`
      (2,501 blocks) is **16.7 ms and 121 KB**, release — §3 projected
      ~10 ms and called it "under the fsync it already needs". Reproducer:
      the `#[ignore]`d `writing_the_biggest_fixture_is_timed`.
- [x] Docs: `docs/json-format.md`'s container section gains `revs/` (and
      says that "nothing outside `log.jsonl` is authoritative" is still
      true at P3); D24 in `docs/single-author-playbook.md`.

**Costs, named.**

- Every accepted step now writes and fsyncs one more file. On a realistic
  diagram this is sub-millisecond; on the largest document in the tree it
  is 17 ms.
- A **debug** open re-folds the whole log a second time for the gate:
  `store::tests::persistent_undo` goes 35 s → 62 s. Release opens are
  unaffected, and the cost dies with the log at P5.
- A container is now bigger by the compressed sum of its history. §3's
  figure stands: ~30 MB of revs against 7.4 MB of log for the 300-commit
  2,500-block synthetic, the same order of magnitude.

**Resolution candidates for the lead.**

- **`document.json` is untouched.** §14.2 makes `latest.json` a P5
  question, and `document.json` is already a full uncompressed stamped
  copy of the head written on Save. Whether the two become one file — and
  whether the head snapshot stops being written on Save and starts being
  written on every commit — is P5's to answer.
- **An orphan rev file is tolerated, not reported.** A crash between a rev
  file and its log line leaves a file for a rev the log does not hold; the
  next edit mints that rev again and overwrites it. `verify` checks the
  revs the log *names* and says nothing about extras. Cheap to tighten if
  the lead wants the fsck to be exhaustive.
- **The gate is `debug_assertions`, not a flag.** §10 says "behind a debug
  flag"; a debug build is the flag, which means every store test exercises
  it on every open with nothing to remember to switch on. If a release
  build should be able to run it too, `blockworx verify` already does.
- **For P4:** undo loads rev N−1 and writes it as a new rev — the files
  are there as of this commit. §12.9 is still open and blocks it:
  `src/edit/mod.rs` says emitters "never pre-validate what the fold
  refuses", so `document.rs`'s `validate()` — `dangling_endpoints` in
  particular — must move to edit time or be accepted before undo stops
  going through the fold. `Stood` re-keys from journal depth to rev in the
  same phase.
- **For P5:** the gate, `revs::disagreement` and `Absent` all exist only
  because the log and the revs coexist. They are deleted with
  `store/replay.rs`, and P2's note about the allocator marks still stands:
  a document whose highest id was deleted would re-mint it once the log is
  no longer what derives the marks.

## P4 — undo is a rev copy (2026-09-05)

`docs/log-vs-snapshot.md` S6. A step no longer folds its way back: it reads
the document P3 wrote for the rev the session stood on, adopts it, and
writes it again as a new rev. Undo stays *in* the trail — a forward record
with its own rev, kind and `of` — so F7's audit trail, the history panel
and Save-as through a rev are untouched.

- [x] `crates/doc/src/trail.rs` is the new home of the undo policy:
      `Trail { undo, redo, standing }` over `Entry { rev, restores }`,
      holding *positions* rather than commits. `JournalAs::{Edit,
      Undo{of}, Redo{of}}` stays the vocabulary, `retire` stays the
      policy, and `Trail::record` is the one place either runs — a live
      step and a replayed record feed the same function.
- [x] The rule that makes every entry uniform: **`restores` is the rev the
      session stood on when the record was written**, not `rev − 1`. The
      plan's `rev − 1` spelling is wrong for a trail with an undo in the
      middle of it: after `edit 1,2,3 / undo / edit`, the top entry's
      `rev − 1` names the undo's own rev, so a walk would leave the
      session standing on a position it had never stood on and
      `walk_document` would not terminate. Standing-at-write-time makes an
      undo and its redo return to the same `Stood`, and makes the trail's
      positions ordered — which is the property the walk needs. The trail
      has a test for each half.
- [x] `Repo` is `{ document, log }`. `submit` takes the `&mut Trail` it
      records on, so an edit cannot be logged without standing on one;
      `fold_one` takes none, which is what a document's *past* comes
      through (seeding, `Repo::folding`, the time machine, replay).
      `replay_one` is gone — `store/replay.rs` folds and feeds the trail
      itself, from the same `RecordKind::journals_as` it always did.
- [x] `Repo::restore` is the step: build the record's ops (still
      `inverse_of`'s, see below), take `rev = head + 1`, and **adopt** the
      target document. Under `cfg(debug_assertions)` it also folds those
      ops onto the head and asserts the result equals what it adopted —
      P3's gate taken one step at a time, and the thing that caught both
      bugs below.
- [x] The document-at-a-rev abstraction is one closure argument, and the
      only thing the two session arms disagree about: `Store` reads
      `Container::read_rev` (P3's door, unused until now), `Doc::Scratch`
      folds `Repo::folded_to` — O(rev) per step, deliberately *not* cached
      (the arm exists for the browser and the tests). `crate::doc::stepped`
      holds the shared half so the arms cannot drift.
- [x] `Stood` re-keys from journal depth to the rev the head's document
      stands on (`Trail::standing`). `UndoStack::reconstructed` takes the
      trail and its `standings()` line; `walk_document` is unchanged
      except for reading the trail. `Trail::seeded` moves the standing
      without leaving a step to take back — a repo handed to
      `Doc::scratch` has already folded its past, and `Step::Seed` writes
      a past nobody may take back.
- [x] §12.9, accepted, with the reason in `src/edit/mod.rs`'s doctrine:
      the fold validates *edits*, and the trail adopts documents the fold
      already accepted. The one fold-only invariant, `dangling_endpoints`,
      is unreachable from the editor because `edit/delete.rs`'s cascade
      emits every dependent route delete itself — including a wire owned
      *outside* the deleted subtree, which
      `deleting_a_block_takes_its_subtree_and_leaves_the_sibling_standing`
      proves by folding exactly that commit.
- [x] Two things a session accumulates rather than holds are carried
      across a step, because the fold carries them too and the adopted
      document has to *equal* the fold: the allocator marks
      (`Allocator::raise_to`) and the artwork payloads. The second was
      found by the debug assertion, not by reasoning: `OpCodes::Asset` has
      no inverse — a payload outlives the reference that brought it in —
      so folding an undo of an icon keeps the bytes while the rev copy
      from before the import does not. `Document::restored_at` is where
      both live, named as one doctrine.
- [x] Proof: an undo adopts the earlier rev's file, byte for byte, and
      writes it as its own rev; a redo comes back to the document the edit
      made; a reopened container's trail is the one its session closed
      with, standing included, and the rev it stands on decodes to the
      document it holds; a scratch session reaches the same documents
      through prefix folds; a mark never falls (delete the highest block,
      undo, mint — the new id is past the restored one); an undo of the
      first edit adopts the empty document (rev 0 has no file); and the
      editor's walk lands on the `Stood` it was aimed at over a trail with
      an edit, an undo and a redo in it. `store::tests::persistent_undo`'s
      property — the Figma round trip — still holds, at 59 s against P3's
      62 s.

- One snapshot fixture was wrong and only now shows it. `picture_at`
      adopted a document without rebuilding the undo stack over it, which
      every real path does; with `Stood` a depth, the stale stack still
      read `Stood(0)` for both documents and nothing showed. With `Stood` a
      rev it reads the mismatch as a *document* step and the lens draws the
      undo arrow dead. The fixture now rebuilds the stack, as the app does,
      and `shell_frame_viewing` is byte-identical to its golden again.

**Costs, named.**

- A container step now reads and decompresses one rev file where it used
  to fold nothing at all (the inverse was built at edit time). It is one
  `zstd` decode of the head-sized document — the same order as the write
  P3 already added, and paid only on undo.
- A **scratch** step folds its whole log prefix, O(rev). That arm has no
  files; it is what the time machine already pays, and the plan is
  explicit that it must not grow a per-rev cache.
- `inverse_of`, `invert_op`, `invert_crud` and `Entity::invert` all
  survive. While the log is authoritative a step's *record* must carry ops
  that fold to what it adopted, or P3's gate fails on the next open. They
  are now how a record is written, not how undo works, and they leave with
  the log at P5.
- The debug assertion folds once per step, so a debug-build undo does the
  work twice. Release does not.

**Resolution candidates for the lead.**

- **`invert` survives to P5, and that is the whole of what P4 leaves
  behind.** The plan's §4.4 says undo "deletes `Entity::invert`"; it
  cannot, while `read(revs/N) == fold_at(N)` is still asserted on every
  debug open. The deletion is P5's, in the same commit that deletes the
  ops from the record.
- **A redo record is now labelled `Redo <what it moved>`.** It used to
  read `Undo Undo <original>`, because the redo submitted the inverse of
  the inverse. The history panel reads the *record's* `of` for its title
  and shows the kind column separately, so nothing visible depends on the
  old spelling; an undo record's label is unchanged (`Undo <original>`,
  as the golden holds it).
- **`store/notes.rs` stayed.** It was not in the undo path's way —
  `Doc::undo`/`redo` still hand it `(at, of)` and it still follows a note
  through its stand-ins — and deleting it touches the record kind, the
  replay projection and the display path. §14.3.10 says it can go at any
  time; P5, with the log, is the cheap moment.
- **`view_rev` still folds a prefix.** Reading the rev copy for an
  attached session needs a `Repo` built from a document (`TimeMachine`
  holds one) *and* the commit either side for the spotlight. It is a real
  simplification and it is P5's, once `latest.json`/`revs` are the only
  document there is.
- **Standing is not derivable from the stacks alone**, which is why
  `Trail` stores it. If P5's `manifest.jsonl` row carries the kind and the
  `of` — as the log record does today — the trail rebuilds exactly as it
  does now, and nothing else about it needs to be durable.

## P5 — the log is gone (2026-09-05)

`docs/log-vs-snapshot.md` S3 and S9: nothing reads the log, so it is
deleted. A container is `revs/{head}.json.zst` plus `manifest.jsonl`, one
appended row per rev carrying names and circumstances and never a
document value.

- [x] `src/store/manifest.rs` (new): `Row { rev, kind, wall_time, author,
      label, scope, camera { x, y, zoom }, touched, truncated, hash, parent }`,
      canonical bytes and the D12 chain (`parent` over the previous row's
      canonical bytes, `hash` the blake3 of the rev file's own bytes), the
      scan with a located `BreakReport`, the dropped-tail rule the log had,
      and `history()` — rows replayed through `Trail::record` to rebuild
      entries, tags and the trail with its standing. A row whose `of` the
      trail does not hold is a fault at scan, not a silent mis-standing.
- [x] `Store::over` reads the manifest, checks the chain, reads `revs/{head}`
      (hash witnessed), re-attaches the payloads the document references,
      derives the marks. `Store::write` writes the rev file, then the row.
      `revs::Backing` is a directory or a `BTreeMap`, so `Doc::Scratch`
      steps through the same encoding in memory and the time machine reads a
      copy instead of folding a prefix.
- [x] Deleted whole: `store/replay.rs` (566), `store/notes.rs` (248) and the
      `Note` record kind, the log golden. Gone with them: `LogRecord`,
      `OpRecord`, `LoggedOp` and the log-only artwork hydration;
      `Document::content_hash` and `ciborium`; `Entity::invert`,
      `inverse_of`, `Repo::{commit_at, folded_to}`; `revs::{disagreement,
      assert_agrees, Absent}` and backfill; `Verify::{Sampled, Full}` and
      `STAMP_SAMPLE`. `Entity` is `Id`, `Update`, `apply`.
- [x] Every history derivation reads the row: the panel, `blockworx log`,
      the rev pick (camera and scope recorded at seal — §12.2), live
      undo/redo framing (`touched`, parsed back through `EntityRef`'s new
      `FromStr`), the trail. `Repo` keeps the session's own `Vec<Commit>` for
      the waist and `describe.rs`; a reopened container's is empty, and a
      step's commit is its label alone.
- [x] Proof: the store suite rewritten around rows and rev files (+722 /
      −1,296 in `store/tests.rs`; `noting` gone) — round trip through reopen
      (document, trail with standing, tags, rows); a rewritten row, a wrong
      `of`, and a tampered head rev open read-only at the last good prefix
      with a located report; a tampered older rev is `verify`'s finding and
      refuses the undo to it; rev files hold no payloads and `read` reattaches
      exactly the referenced ones; the projection stamp is the head rev's
      digest; save-as carries rows ≤ N, revs ≤ N, referenced assets, and
      re-states later tags; a rev pick after reopen frames the recorded
      camera; `touched` truncates at 64; `EntityRef` round-trips; the scratch
      arm steps through in-memory revs; the persistent-undo property holds.
      New golden `src/store/goldens/manifest.jsonl`. `cargo xtask ci
      --no-snapshots` green; the snapshot step fails on the two pre-existing
      driver-drift images only.
- [x] Measured (`TUNING.md` Finding 9, release, block50 + 300 moves): open
      **22.7 ms** over 301 rows against the log's 0.84 s sampled / 16.6 s
      verified; a rev write 17.2 ms / 124 KB. Findings 7 and 8 retired.
- [x] Docs: `docs/json-format.md` rewritten (container, manifest row with a
      golden row, save-as, `document.json`); D26 in the playbook with the
      seven resolutions; `CLAUDE.md`'s format note; a landed line at the top
      of `docs/log-vs-snapshot.md` Part II.
- [x] Ledger, this phase: 38 files, +4,241 / −5,100. The whole plan
      (`544ee83` → here, fixtures and goldens excluded): 119 files, +9,890 /
      −12,486; Rust +8,688 / −12,277, the `.rs` tree 93,135 → 89,546 lines
      (−3,589, 3.9%) against §11's ~4,750 estimate — the shortfall is the
      manifest module and the tests that moved rather than died.

**Resolution candidates for the lead** — every decision below was made
while executing because the plan did not cover it; each is reversible.

- **Rev files carry no payloads** (D26 §1). Written stripped, read with the
  referenced payloads reattached from `assets/`. The plan's "assets
  unchanged" claim (§6) needed this to stay true.
- **The one state digest is the rev file's blake3** (D26 §2).
  `content_hash`/ciborium are gone; the projection stamp is that digest.
- **`document.json` keeps its name and Save-time refresh** (D26 §3). §7's
  `latest.json` is not adopted; opening never parses `document.json`.
  Rename if the §7 name is wanted — a constant and the docs.
- **Marks derive from the head at open** (D26 §4, §5.1's rule): after a
  restart the highest deleted id can be re-minted; the `touched` hint may
  then be ambiguous across revs. Store the marks in a manifest header if
  that ambiguity matters.
- **Only the head rev is hashed at open**; older revs are `verify`'s. An
  orphan rev file (crash between rev file and row) is tolerated, not
  reported (P3's open item stands).
- **The browser's in-memory revs are raw JSON bytes** (no zstd on wasm).
  Memory grows with revs × document size there; the OPFS container of
  Phase 8 is the durable answer.
- **Redo labels read `Redo <what it moved>`** (P4); undo labels unchanged.
- **Screen coordinates are raw fixed point in the file** (P2) — still open.

**Spotlight and label audit** (2026-09-08, uncommitted work landed as one
commit so the session can move machines).

- [x] `docs/spotlight-audit.md` — one row per user action (create, update,
      name, delete, history), each carrying the label the history panel
      actually shows and the test that pins it, with empty notes columns
      for hand testing the label wording and the spotlight ring.
- [x] `src/tools/label_audit_tests.rs` — 57 `expect!` goldens, one per
      row, sealed through `Scene::sealed_label` (the same `gesture::seal`
      the app uses). Regenerate after a wording fix with
      `UPDATE_EXPECT=1 cargo test --lib label_audit`; the diff is the
      review. Not covered: wrap-top (emitter unhooked), import (verbatim
      label), the route-drag session, document rename (no Drawing setter).
- [ ] Hand-test the rows and record notes; then fix the wording the goldens
      already expose — counted cascades (`Delete 6 shapes`), tool verbs for
      the act (`Resize pin`, `Edit 4 shapes`), palette slugs (`Flip-lr`,
      `Io`), block-field edits naming the block (`Add block “A”` for an
      icon), three spellings of unnamed new items.

## §8.1 rewritten — the history view (2026-09-08)

The spec's History section was replaced wholesale (`docs/cad-ui-spec.md`
§8.1, mockup `docs/cad-unified-topbar.html`) and the panel rebuilt to it.
§2.2 Color arrived in the same edit; Metrics renumbered to §2.3 and the
~20 code references swept.

- [x] **Rows, not cards.** A row is now: author initials on a hashed
      accent disc, the description, the scope under it elided **from the
      left** so the leaf survives, tag chips, and the time over `#rev`
      trailing. The rev number is the smallest thing on it — §8.1 calls it
      an identifier rather than something anybody scans for.
- [x] **The viewed rev expands in place into a card**: author, full
      timestamp, `#rev`, the description at 15px, the whole scope path,
      and the tag editor. D19's Copy and Export rev moved here from the
      per-row `…`, which is gone — a control on every row that said
      nothing about the row it sat on.
- [x] **Facets replaced by one search field** with §8.1's prefixes
      (`tag:`, `by:`, `in:`, `#`), the count reading "N of M", and an empty
      result that names the prefixes. `Query` lives beside `Row` in
      `store/history.rs`, so the box and the list cannot disagree.
      Clicking any tag chip sets `tag:<name>`. The `Current` row stands
      down while a query narrows: it is the head's representation, not a
      match.
- [x] **Tags went plural** (D18 → §8.1's "zero or more"). `Tags` is a
      sorted set per rev with a document-wide `vocabulary()` offered back
      as suggestions; `RowKind::Untag` is a new durable kind naming *which*
      name comes off, where the old encoding — a `tag` row with a blank
      label — could only mean "all of them". `Action::TagRev` carries a
      `Tagging`. Names are stored as typed: folding them to lowercase, as
      the mockup does, would overrule the author about what their own rev
      is called.
- [x] **`scope_names` recorded at seal** beside the ids, so a row reads its
      own path after the blocks along it are renamed or deleted. Ids and
      names are bundled into `record::Standing`, which cannot hold names of
      a different length than the ids they spell. `describe.rs` **stopped
      appending `in <scope>`** — the row has a line for it now, and the
      label would have said it twice, worse (its suffix truncates
      leaf-first).
- [x] Proof: 18 panel tests through real egui layout and hit-testing (row
      anatomy, left elision, the card, the tag editor's add and remove, the
      prefixes, the chip, the count, the empty state), plus `Query` tests
      beside `Row`. `cargo xtask ci` green, snapshots included.
- [x] Goldens regenerated as acceptance steps, each diff reviewed:
      `manifest.jsonl` (one `scope_names` field, chain re-linked below it),
      6 of the 57 label-audit expectations (the scope suffix), and
      `shell_navigator`/`shell_selection_overlay`.

**Corrected in passing.** The worklog's claim that the snapshot step fails
on two pre-existing driver-drift images is **stale** — the pre-change tree
runs all 9 green, checked by stashing. The two that failed were this
change's own, and are regenerated.

**Still open, for the operation pass.**

- `store/handle.rs:568` labels a step `"{verb} {label of the row it
  moved}"`, so a redo of an undo reads `Redo Undo Added a block` (pinned at
  `store/tests.rs:355`). P4's note claims it reads `Redo <what it moved>`;
  it does not. `Store::framing` already walks `of` to the originating act
  and is what `step()` should read. Same string feeds the top bar.
- `is_inverse()` is `kind_of() == Undo`, so a **redo** row is not inverse
  and renders unmuted while an undo is italic — two step kinds, two
  unrelated presentations.
- `view_rev` returns before setting `time_machine` if either document read
  fails, so a click can be a silent no-op; and camera/scope only move
  inside `if let Some(spotlight)`, so a row whose subjects fall outside the
  recorded scope moves nothing and says nothing.
- Leftover machinery: `Trail::{undo_revs, redo_revs, can_undo, can_redo}`
  have no production caller; `is_inverse`/`undone` still sniff an `Undo `
  prefix off the label; `Row::touched` is a lossy count kept only for the
  dump.

- [x] `cargo xtask web serve` takes `--address`/`--port` (2026-09-08) — the
      default is still loopback with `--open`; binding elsewhere (`-a 0.0.0.0`,
      or a specific interface address) reaches the app from another machine
      over the LAN or Tailscale and skips opening a local browser.

## Exit egui — branch `exit-egui` (2026-09-10)

The UI toolkit is leaving; the crate graph is what makes the port small.
Design, phases and the worklog live in `docs/exit-egui-playbook.md` — the
single source of truth for the phase checklist; this section only records
what landed.

- [x] Phase 0 — branch cut, playbook committed. Baseline: `cargo xtask ci`
      green in 87 s (1139 tests), snapshots 9/9.
- [x] Phase 1 — `blockworx-geom` (Pos2/Vec2/Rect/Rangef/Align/Align2, lerp/remap,
      `Bounded`, `WorldPx`, the `grid` metric) and `blockworx-paint` (`Color`,
      `Font`/`FontFamily`, `CANVAS_FAMILY`), both with no egui-family crate in
      their tree — normal, build *or* dev, which the generalised `headless` gate
      now checks for all three core crates. The API was built by inventory of
      what the app actually calls on emath's and ecolor's types — 112 methods
      and free functions, 24 consts, 48 operator/`From`/`Debug` impls — copied
      from those crates where the semantics carry (`Rect` union/intersection
      against `NOTHING`/`EVERYTHING`, `Align2::anchor_size`, lerp/remap, the
      premultiplied-alpha arithmetic), under their MIT/Apache attribution. The
      conversions live in `canvas::egui_compat` as `.egui()`/`.geom()`
      extension traits, with proptests there proving every method agrees with
      emath/ecolor bit for bit. `Renderer`, `Style`, `EditText`, `Interaction`,
      `Palette`, `Theme` and the `Chrome`/headless test drivers now speak geom.
      Untangled beyond the plan: `Zoom` and `Progress` became newtypes over
      `Bounded` (an inherent impl on a type alias is illegal once the alias
      crosses a crate), `Theme::canvas_font*` collapsed into `Font::canvas`, and
      the `palette` gate learned the new spelling. No behavior change; 1156
      tests (1139 + 17 new), `cargo xtask ci --no-snapshots` green in 57 s.
- [x] Phase 2 — `blockworx-store`: the whole of `src/store/*`, plus `doc.rs`
      (the write door), `document_file.rs`, `naming.rs`, `atomic.rs`, the pure
      half of `file.rs` and `spotlight::Worked`, with no egui-family crate in
      its tree. `Worked.scope` is now the wire `BlockId` the manifest row
      already holds, so `Scope` stays in the editor; `Step` and the
      subject→scope walk went down with `Worked` rather than being duplicated.
      `Viewing::saturation` became `app::saturation`, and `Authoring` moved to
      `edit/naming.rs` beside the `InterfaceLock` it reads. `fixture`/`temp`
      are behind a `test-support` feature the app dev-depends on. No behavior
      change, no golden regenerated: 1156 tests, `cargo xtask ci
      --no-snapshots` green in 48 s.
- [x] Phase 3 — `blockworx-router`: `src/router/*` becomes the crate, owning
      `petgraph`/`pathfinding` (nothing else in the app called them) and naming
      only geom and `blockworx_doc::geometry::GridPoint` besides. The grid↔world
      bridge — `grid_point`/`px_point`/`grid_size_ceil`/`grid_vec`/`px_vec`/
      `px_rect`/`screen_rect`/`artwork_rect`/`grid_rect` — left `edit/lower.rs`
      for `blockworx_geom::grid`, so geom is now the world coordinate system
      *and* its bridge to the document grid (its one dependency is
      `blockworx-doc`); `lower.rs` keeps the editor's own rules (`block_rect`,
      `slot_capacity`, the accent↔role map, the label/asset reads). The lattice
      and the bridge round in one place: `CoordX`/`CoordY`'s naked-`f32` `From`
      is gone and `Point ↔ Pos2` goes through `grid::grid_i32`/`grid::px`.
      Untangled: geom's world→world snap, which shared the name `grid_rect` with
      the bridge's, is `snap_rect`; the sweep's `ci_stats` counters moved behind
      a `test-support` feature the app dev-depends on, since `cfg(test)` no
      longer reaches across the crate line. No behavior change, no golden
      regenerated: 1156 tests, `cargo xtask ci --no-snapshots` green in 66 s.
- [x] Phase 4 — `blockworx-paint` grown out: the `Renderer` trait, the palette,
      the theme and its `Style<R>` adapter, `Zoom`/`Extent`, the interaction
      types, `EditText`, `ImageHandle`, and `preferences`' three pure enums
      (`Theme` → `Scheme`, since paint already has a `Theme`) with the four
      embedded `.ttf`s behind them — no egui-family crate in its tree, normal,
      build or dev. New there: `Cursor`, `PointerKind`, `EditId`, `AnimKey`,
      `Waker`, and the `Animator`/`Canvas` traits a *live* backend adds on top
      of `Renderer`. **The tools no longer name egui**: `ToolTrait` and every
      tool body are generic over `C: Canvas` (enum_dispatch forwards the generic
      methods, so no hand-written dispatch and no `dyn`), and cursors, animation
      keys, repaint requests, touch sniffing and the image dialog's waker all
      go through the trait. `Palette::egui_visuals` became
      `canvas::egui_compat::visuals`; `role_picker` moved to the shell. Beyond
      the plan: `Canvas::request_repaint_after` (two tools poll a dialog on a
      timer) and `Canvas::now` (the drag-to-route hint is a sawtooth off the
      frame clock, not an easing). No behavior change, no golden regenerated:
      1156 tests, `cargo xtask ci --no-snapshots` green in 50 s.
- [x] Phase 5 — `blockworx-egui`, the only backend: `Painter` (the
      `Renderer`/`Animator`/`Canvas` impl), the canvas `View` with its grid,
      pan/zoom/touch and in-place `TextEdit`, `compute_interaction`, the
      `ImageRegistry` and its intrinsic-size parsers, `Icons`, `build_fonts`,
      `visuals`, and `egui_compat` — now `convert`, with its emath/ecolor
      equivalence proptests. Its normal tree is doc, geom and paint and nothing
      else, which `cargo xtask ci`'s new `backend` gate asserts. The
      `Vantage ↔ record::Camera` pair moved *up* to `src/camera.rs`: the backend
      knows where a camera stands, not that anything writes one down.
      `tools::settle` and `Painter::headless` went behind a `test-support`
      feature the app dev-depends on. `src/canvas/` is gone — `crate::canvas` is
      an alias for the crate, `svg.rs` joined `export/`. No behavior change, no
      golden regenerated: 1156 tests, `cargo xtask ci --no-snapshots` green in
      66 s.
- [x] Phase 6 — `blockworx-editor`: grid-aware document editing — `path`,
      `state`, `gesture`, `presentation`, `edit` (the op emitters), `shape`,
      `render`, `widget` (the `Drawing` waist and its impls), `title_block`,
      `content_path`, `names`, and the parsing half of `import` — with no
      egui-family crate in its *normal* tree; its dev-dependencies drive the
      real backend for text metrics (D3), which is why the `headless` gate is
      now a `(crate, edges)` list checking the editor over `normal,build`
      alone. Stayed in the shell: the import dialog, `io_pin_picker`, the SVG
      level entry (`src/export/level.rs`) with the render-path SVG suite, the
      egui tessellation bench, and `lazy_edge_drag`, which drives a tool.
      Untangled: `Supposing` moved to `widget` beside the preview writers it
      gates; `Deletable`/`RoleTarget` to `shape` (a selection is shapes, and
      `Drawing` takes them), re-exported from `tools::tool`; the handle
      affordance constants to `render/selection.rs`, which draws the resize
      handles from them; `nav_tree::tree_root` to `path`. `Gesture::author` is
      `pub(crate)` — a gesture is authored into through a `Drawing` method, not
      by the tool holding it. `blockworx-egui` grew `measure::Measured`
      (`test-support`), one headless font-loaded painter for every suite that
      had been hand-rolling an `egui::Context`. `rstar`/`indexmap` left the
      root for the editor; unused `derive_more` left altogether. No behavior
      change, no golden regenerated: 1156 tests, `cargo xtask ci
      --no-snapshots` green in 43 s.
- [x] Phase 7 — `blockworx-tools`: the tools, `ToolTrait`/`Tool`, the command
      registry and its bindings, the undo stack and the spotlight, with no
      egui-family crate in its normal tree (dev-deps drive the backend for
      text metrics, D3). The six widget panels — `overlay`, `palette`,
      `nav_tree`, `history_panel`, `notices`, `chrome`, plus `file_menu` and
      the `painted` test chrome — moved to `src/panels/`, so what is left in
      the tools directory is tools. Three egui habits left the core: the
      chords are `blockworx_paint::{Key, Modifiers, Chord}` and the shell's
      `src/keys.rs` consumes them through `IntoEgui`; the undo stack is an
      in-crate `Undoer` over `Duration` (egui's semantics, copied under its
      MIT/Apache notice); the spotlight ring is a `Spotlighter` the app owns
      rather than a slot in egui's frame data. The easing table came forward
      into `blockworx-paint` as `Easing`/`Tick`, owned by the `View` and read
      by `Painter::animate`, so every backend animates identically. The
      `waist` gate is retired (D4): the crate boundary now says what it
      grepped for. No behavior change, no golden regenerated: 1156 tests,
      `cargo xtask ci --no-snapshots` green in 69 s.
- [x] Phase 8 — `blockworx-export`: the SVG exporter, the PNG rasterization,
      the JSON projection and the PDF, with no egui-family crate in its normal
      tree. Text layout became `blockworx_paint::TextLayout` (D2) — a trait the
      backend implements as `EpaintLayout`, which also says *which* typeface it
      lays out in, so the outlines the SVG traces cannot come from a different
      face than the one that placed them. `SvgRenderer<L: TextLayout>` is built
      from `(Palette, layout)`; `render_svg`/`render_level` and `pdf::Scene`
      carry a `&dyn TextLayout` where they carried a `FontChoice`. The shell
      keeps `src/export.rs`: `ExportPayload`, both `spawn_export` bodies, and
      the `ExportContent → ExportFormat` mapping (the formats stay command
      vocabulary in `blockworx-tools`; the export crate names what it
      rendered). The `Asset` intrinsic-size parsers went down to
      `blockworx_paint::image` — they are about an image, not a toolkit — and
      the `kittest` tessellation snapshots left the render-path suite for the
      shell's `src/tessellation_snapshots.rs`. `krilla`, `resvg`, `usvg`,
      `svg`, `ttf-parser`, `harfrust`, `skrifa`, `base64` and dev `lopdf` left
      the root. 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.

- [x] Phase 9a — the kernel spike: `crates/kernel` (`blockworx-kernel`), a
      `Session` holding everything the editor is apart from the surface it is
      shown on, and `kernel(&mut Session, events, &impl TextLayout, tick,
      viewport) -> View` driving it with no toolkit in the call. The frame
      logic was *extracted*, not forked: `App` owns a `Session` field and the
      canvas pass is `Session::canvas_frame` over an egui `Painter`, the same
      method the kernel runs over a `blockworx_paint::record::Recording` — the
      second `Canvas`, which appends a `Vec<Paint>` display list, measures
      through the host's `TextLayout`, and answers the cursor, the in-place
      editor, the keyed easings and the repaint requests back to its caller.
      The world→screen transform and the framing math moved to
      `Vantage`/`Zoom` in paint, so the painter and the recorder cannot place
      a mark differently. The camera stays the shell's: the session is told
      where it stands (`Sighting`) and asks for moves (`Framing`), which the
      shell hands to its `View` and the kernel applies to its own vantage.
      Four kernel tests drive the existing scenes: a `NewBlock` arm-and-drag
      that writes a block, paints it and leaves an undo step; an `Undo` event
      that restores the document; a hover that answers with a cursor; and a
      resize handle that grows across two ticks of the same hover, which is
      the easing being the core's. `docs/exit-egui-playbook.md` lists what the
      shell still owns. No behavior change, no golden regenerated: 1161 tests
      (1157 + 4), `cargo xtask ci --no-snapshots` green in 68 s.
- [x] Phase 9b — the closing sweep: the gates made honest, the build tool out
      of a library crate, and the branch closed out. The `palette` gate's
      per-file scan stopped at the first `#[cfg(test`, so anything below a
      mid-file test module had never been read — the finding that struck in
      `painter.rs` (Phase 5) and again in `pdf.rs` (Phase 8). It now skips the
      *item* the attribute gates, tracking brace depth from the line that
      opens it, and covers 1,519 more lines over 81 files; it finds nothing
      new, and the two exemptions (egui's white image tint, krilla's
      `rgb::Color`) are still the only two. A third dependency gate,
      `shell`, reads `cargo tree -p blockworx -e normal --invert egui` and
      fails on any `blockworx-` crate but `blockworx` and `blockworx-egui`, so
      the property is checked from the toolkit's end as well as from each
      crate's; the `headless` step now names the nine crates and their edges
      in its log line. `crates/editor` no longer depends on `xtask`: the
      scale-scene generator 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 byte-identical output (checked at N=1 and N=4), and `clap`/`duct`
      leave the editor's and the app's test trees. The comment sweep: 55 edits
      across 26 files, taking out history narration ("used to carry Rename",
      "superseded by", "reversing R28", "since P2", "Phase G hid the bar",
      "CP3/CP5 regression") and references to things that no longer exist (the
      `legacy` model behind `port_orientation`, the `legacy` `top_id`, "the
      editor-swap addition", "a serverless boot", "Phase 8 owns web
      persistence"); decision pointers that still govern the code *and* say
      what they do to it stayed. `CLAUDE.md`'s project section is now the
      crate graph, the four CI gates and what each proves, where the kernel's
      entry point is, and the `test-support` convention. The playbook gains a
      "State at the end of the branch" section: the graph as landed, the 981
      lines of egui left in the shell by file group, the follow-ups, and the
      answer to question 10. No behavior change, no golden regenerated: 1161
      tests, unchanged in outcome, `cargo xtask ci --no-snapshots` green in
      42 s.
- [x] Phase 9c — the image dialog as two events. A drawing trait carried an
      execution model: `Canvas::waker()` handed out a `Waker(Arc<dyn Fn() +
      Send + Sync>)`, and the image and icon tools used it to spawn `rfd` on a
      thread, hold the `Receiver` in a `Pending` state and poll it every frame
      behind a 100 ms repaint — which is why `blockworx-tools` carried `rfd`
      and `wasm-bindgen-futures` as normal dependencies. A request is an action
      and a result is an action, the shape the File flow and `Import` already
      had: `Action::PickImage(ImageTarget)` leaves the session, the shell runs
      the dialog (`spawn_image_dialog` beside `spawn_import_dialog` in
      `src/import.rs`) and dispatches `Action::ImagePicked { target, asset }`
      back in, where `Session::dispatch` places the image and settles on the
      resize selection, sets the block's icon, or — on a cancel, an unreadable
      file, or one over the asset limit — lands where a cancel always landed.
      The drop path folded in: the kernel turns an image `StampTool` into
      `PickImage(Place(Centered(at)))`, so the shell has one image arm instead
      of two. Deleted: `Waker` (paint's struct, the re-export, `Style::waker`,
      `Recording::waker` and `Recorded::woken`), `Canvas::waker`,
      `Painter::waker`, `convert::waker`, both `spawn_image_dialog` bodies and
      `read_image` in the tools crate, and the two `Pending` states;
      `Canvas::request_repaint_after` stays for the spotlight's ring. The one
      place on the branch where tests change shape: the three that owned the
      other end of a tool's channel are gone
      (`a_picked_image_becomes_an_image_in_the_view`,
      `a_picked_icon_is_attached_to_its_block`,
      `a_cancelled_pick_writes_nothing`), replaced by two that assert the
      *request* (`the_image_tool_asks_for_an_image_fitted_to_the_box_it_drew`,
      `the_icon_tool_asks_for_an_image_for_the_block_clicked`, through a new
      `headless::Canvas::asked`) and three in the kernel that assert what the
      *answer* writes (`a_picked_image_lands_in_the_box_that_asked_for_it`,
      `a_picked_icon_lands_on_the_block_that_asked_for_it`,
      `a_cancelled_pick_lands_nothing`). No golden regenerated: 1163 tests,
      `cargo xtask ci --no-snapshots` green in 85 s from a touched paint
      crate, 14 s fully warm.
- [x] Phase 9d — the shell decomposed. `src/app.rs` was 8,514 lines around one
      `App` struct with 27 fields and ~90 methods, mixing six concerns with no
      boundary between them — 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 a composition of five parts, each a module with one struct
      that owns its state, its per-frame work and the actions it answers:
      `surface` (`src/surface.rs`, 10 fields — the canvas, the bands, the
      popups, the palette, the navigator, the safe area; answers
      `OpenRolePicker`, `OpenPinTypePicker`, `Camera`), `exchange`
      (`src/exchange.rs`, 2 fields — the export content and the two pending
      dialogs; answers `Export`, `ExportRev`, `Import`, `PickImage`),
      `library` (`src/library.rs`, 9 fields — the startup open, every
      container door, the projection refresh, the recent list, the notices;
      answers `NewDocument`, `RenameDocument`, `PickFile`, `OpenRecent`,
      `SaveProjection`), `appearance` (`src/appearance.rs`, 5 fields — the
      push to the toolkit, the window title, the two dev editors) and the
      kernel `Session`, unchanged. A part never holds a reference to another:
      what it needs comes in as a parameter, and the steps that read two parts
      at once — `document_name`, `window_title`, `title_block`, `sheet`,
      `notices` — stay on `App`. `shell_frame` reads as its phases (`sync`,
      `ahead_of_the_canvas`, `gather`, dispatch, `settle`, `poll`) and
      `dispatch_action` as a chain (session, surface, exchange, library) that
      ends in the same `unreachable!`. `failures` went to the `Library` as a
      `Notices` newtype, because every failure the shell reports is a file
      door's and the standing half of the list is the container's. The tests
      split by their own submodules into `src/app/tests/*.rs` with the
      fixtures in `tests/mod.rs`; no name or body changed apart from the paths
      they reach. No behavior change, no golden regenerated: 1163 tests,
      `cargo xtask ci --no-snapshots` green in 45 s.
- [x] Phase 9e — the comments stop citing documents nobody has. 725 comment
      lines across `src/`, `crates/*/src/`, `xtask/src/` and the manifests
      carried tags — `(R19)`, `R32's rule`, `D9:`, `(D20)`, `F6`, `P5`, `S6`,
      `§10.1`, `spec §2.0.2, playbook R38, R43`, `invariant 11` — pointing at
      the single-author playbook, the CAD shell playbook's review rounds,
      `docs/cad-ui-spec.md` sections and `docs/log-vs-snapshot.md`. A reader of
      the code cannot resolve any of them, and the sentence around them already
      carried the reason. The rule applied: strip the tag, keep the sentence,
      and make it stand on its own — reworded to a plain statement of the rule
      where the tag left a fragment (`§2.3 forbids them categorically` →
      `Hairlines are forbidden categorically`), deleted outright where the tag
      was all the line carried (`// (R31)`, `/// D19.`). A documentation
      pointer survives only when it names a document by path that still
      exists, and keeps its section number only where that heading really
      exists in that file; the `S6`/`P5`/`D10`/`§12.7` suffixes on those same
      paths went. The last history line (`src/shell/top_bar.rs`: "The menu that
      used to carry Rename does not any more") collapsed to the rule it was
      narrating. Census 725 → 37: ten document pointers, one `RFC 4648 §4`,
      two `// Phase 1:`/`// Phase 2:` algorithm step labels, and twenty-four
      present-tense "used to"/"no longer". No code changed — the diff is
      comment lines only — and `cargo xtask ci --no-snapshots` is green with
      1163 tests, unchanged in outcome.
- [x] `docs/react-frontend-interface.md` (2026-09-10) — the boundary a
      React-style front end would see: `view = kernel(session, events,
      layout, tick, viewport)` over a wasm facade; the event families
      (pointer in screen space, commands, actions, tick, camera, text
      edits, answers), the `View` with the chrome model folded in, one-shot
      `Request`s in place of shell-answered actions, text shaped in the
      core with glyph-positioned paints, and the ordered list of core
      changes (a–h) that precede it.
- [x] Phase 9f — commands in, delegations out (2026-09-10). The editor reacts
      to commands: `Action::ImagePicked { target, asset: Option<Asset> }` — an
      answer to a question the core had asked, carrying a cancel the core then
      interpreted — is replaced by `Action::SetIcon { block, asset }` and
      `Action::PlaceImage { placement, asset }`, executed and never handed
      back, and `Action::PickImage` is renamed `Action::ImageWanted`, a
      fire-and-forget delegation the core keeps no state about. The shell owns
      every substate of the picker, cancel included: `Exchange::picked` sends
      the tool switch a cancel lands on and nothing else, so the screen is
      unchanged. `CommandId::AddIcon` raises the delegation directly;
      `IconTool::Armed` is gone and `IconTool` is a unit struct.
      `Session::dispatch`'s hand-back arm is now exactly the delegations and
      says so. `docs/react-frontend-interface.md` §5 is rewritten as
      "Delegations out, commands in, queries" — no `Request` enum, no ids, no
      answers, downloads made from the facade's synchronous reads. Tests:
      three kernel answer-tests become three command-tests
      (`a_place_image_command_lands_the_image`,
      `a_set_icon_command_attaches_the_icon`, `an_oversized_asset_is_refused`),
      the cancel moves to the shell
      (`a_cancelled_image_pick_leaves_the_tool_where_it_was`), and the registry
      gains `add_icon_asks_for_an_image_for_the_block_it_was_offered_for`.
      1165 tests, no golden regenerated, `cargo xtask ci --no-snapshots` green
      in 289 s.
- [x] Phase 9g — the icon tool retired, the image drop the shell's
      (2026-09-11). Nothing armed `IconTool` after 9f — `CommandId::AddIcon`
      raises `ImageWanted(Icon(block))` itself — so `crates/tools/src/icon.rs`,
      the `Tool::Icon` variant, its `enum_dispatch` arm and the tool test
      `the_icon_tool_asks_for_an_image_for_the_block_clicked` are deleted.
      `ToolName::Icon` stays as a name with no tool behind it: it is the verb
      `Action::SetIcon` commits under, and the label audit's `c5_add_icon`
      golden pins the label it produces. Nothing user-visible listed the tool
      (not in `BAND_TOOLS`, no `command_name`, never offered as `Arm(Icon)`).
      The image cell's drag-out is the shell's gesture, so the shell opens the
      pick: `Surface::dropped` answers a `NewImage` drop with
      `ImageWanted(Place(Centered(at)))`, and the kernel arm that made that
      conversion is gone along with the `NeedsApp` hand-back in
      `apply_scripted` that fed it. Tests:
      `an_image_drop_is_handed_back_carrying_its_drop_point` becomes
      `dropping_the_image_tool_asks_for_an_image_centred_on_the_drop`
      (`src/app/tests/drag_out.rs`). `docs/react-frontend-interface.md` §5b
      names the two remaining raisers of `ImageWanted`. 1164 tests, no golden
      regenerated, `cargo xtask ci --no-snapshots` green in 140 s.
- [x] Phase 9h — the registry is permission, the shell performs its own
      commands (2026-09-11). `Command.action: Action` becomes
      `Command.act: Act`, which is `Edit(Action)` — what the core executes —
      or `Errand(Errand)` — what the shell performs: the two pickers, the two
      image picks, the exports, the import, the projection refresh, the camera
      and the four document doors. The registry answers availability and
      target and nothing about flow; `CommandSet::{take, take_by_name}` hand
      back an `Act`, and `apply_scripted` reports an errand as
      `NotApplicable`. The shell's own raisers emit `Act` too (file menu, top
      bar, history panel, palette, the cluster's drop), so `App::act` has two
      arms — `dispatch_action`, and `perform`, which calls the part that owns
      the flow — instead of a four-part chain. `Action` is now exactly what
      the core executes and `Session::dispatch` returns `()`. **Behavior
      change:** the image tool is retired (`crates/tools/src/new_image.rs`,
      `ImageTarget`, `Action::ImageWanted`) and artwork is picked and then
      placed — the rail's image cell is `CommandId::AddImage`, pressing it
      opens the picker, and `Action::PlaceImage { asset }` lands the image
      centred where a paste lands at the file's own aspect, selected for
      resizing. With it goes the last core→shell notice: the `View` has no
      channel out at all. New `src/dialogs.rs` seam
      (`Dialogs::{Native, Scripted}`) so no test opens a real `rfd` window —
      the default is `Scripted` under `cfg!(test)`. 1167 tests, no golden
      regenerated, `cargo xtask ci --no-snapshots` green in 155 s (47 s fully
      warm).
- [x] Phase 9i — a command in, a result in the slot (2026-09-11). The shell
      never queries the editor: `Action::Export { format, selection }` and
      `Action::ExportRev { at, to }` are core actions again (raised as
      `Act::Edit` by the registry, the top bar's Export menu and the history
      row's Copy / Export rev…), `Errand` loses its two export variants, and
      `exchange.rs`'s `export_content`/`export_pdf`/`export_source`/
      `selection_repo` move into `crates/kernel/src/export.rs` — the kernel
      gains `blockworx-export` and the headless gate stays green.
      `Session::dispatch` takes `layout: &dyn TextLayout`. The result comes
      back through one pass-back slot: `Session.handoff: Option<Handoff>`
      (`Export { content, name }` | `Clipboard(String)`) replaces
      `clipboard: Option<String>`, `take_clipboard`/`put_on_clipboard` give way
      to `take_handoff`/`hands_back`, and `View` carries `handoff`. `App::poll`
      ends with `hand_off`, which sends an export to `Dialogs::export` and
      clipboard text to `ctx.copy_text` — polled every frame, so a kernel that
      later computes on a thread needs no change here. The PDF's sheet inputs
      travel in session state rather than in the action (the registry builds
      the command and knows nothing about the container's name or the
      preferences): `Sheet { name, block, scheme }` is a field on `Session`,
      stated by the shell before every dispatch; `Sheet::font` is gone because
      the typeface is `TextLayout::typeface`, and `Appearance::layout()` keeps
      the shell's one `EpaintLayout`. New kernel tests
      `an_export_command_hands_back_the_svg` and
      `an_export_rev_command_hands_back_that_rev`; new shell test
      `an_export_reaches_the_save_dialog_through_the_hand_off`, split out of
      `each_shell_command_opens_the_one_dialog_it_is_about` because an export is
      no longer one of the shell's own commands. 1170 tests, no golden
      regenerated, `cargo xtask ci --no-snapshots` green in 45 s (warm).
- [x] Phase 9j — time and the viewport are events (2026-09-11). Everything the
      front end says is an event, time and the viewport included:
      `Event::Tick(Tick)` carries the clock, `Event::Viewport(Rect)` a resize,
      and `kernel(&mut Session, events, &impl TextLayout) -> View` loses its
      `tick` and `viewport` parameters. A batch is one frame — the clock, the
      viewport and the pointer are taken off it in order, so a `Tick` ahead of a
      `Pointer` dates that pointer; a batch with no tick is a batch time did not
      pass during, and one with no viewport runs at the last size stated, since
      `Session::ticks`/`sees` already keep both as the internal setters the
      events land in. `Tick` keeps `{ now, predicted_dt }` with the prediction
      optional behind `now()`/`predicted_dt()`: a host with its own frame rate
      says `Tick::predicting(..)` (what `blockworx-egui`'s `Animator` reads off
      egui, so the easing table is bit-identical under the shell), and a front
      end that knows only the time says `Tick::at(now)`, which `Session::ticks`
      resolves against the last tick. Deviation: the egui shell has no per-frame
      batch — it drives `Session` directly and `kernel()`'s only callers are its
      tests — so `App`/`Surface` are untouched; finding: the shell has never
      called `Session::ticks`, so `Session::now()` is zero under it and the
      spotlight ring's fade is dated from zero, which is the next branch's to
      fix. New tests `time_does_not_advance_without_a_tick` and
      `a_tick_before_a_pointer_dates_it`; the rest updated mechanically onto a
      `batch(millis, events)` helper. 1172 tests, no golden regenerated,
      `cargo xtask ci --no-snapshots` green in 178 s.

## Shell on kernel — branch `shell-on-kernel` (2026-09-11)

The egui shell becomes a consumer of `View`. Phases and the worklog live in
`docs/shell-on-kernel-playbook.md`; the target is `docs/react-frontend-interface.md`.

- [x] Phase 0 — branch cut from `exit-egui` at `893f547`; playbook committed.
- [x] Phase 1 — the replayer (2026-09-11). `blockworx_egui::replay(&[Paint],
      &egui::Painter, &mut ImageRegistry)` draws a display list; the egui
      `Canvas::paint`/`fit_to` take a `TextLayout`, hand the closure a
      `Recording`, and replay what it recorded. `Painter` is deleted rather than
      kept (D4 revised): the headless drivers, the bench and the tessellation
      snapshots record and replay too, through `measure::headless`, so there is
      one path from a `Paint` to a shape. `ContextLayout` (D5) is the context's
      fonts behind `TextLayout`, at the display's resolution, so the recorder
      measures what the replay draws; `EpaintLayout` stays the exporters'.
      `Recording::take_edit_text`; text marks carry the font at screen size;
      `Framed.cursor` gone, `Painted { value, cursor }` in its place. Behavior
      change: the pan cursor reaches the pointer again (dead since `7bb93d7`).
      1172 tests, 7 snapshots unchanged, no golden regenerated, `cargo xtask ci`
      green in 77 s.
- [x] Phase 2 — the chrome model (2026-09-11). `blockworx_kernel::chrome`: owned
      `TopBar`, `Reading`, `NavTree`, `Overlay`, `Notice`, built by `Session`
      (`top_bar`, `reading`, `history_rows`, `nav_tree`, `overlay`, `notices`,
      `displayed_tool`) and put on `View` by `kernel()`. The store's history
      `Row` is owned now (one type for the CLI, the panel and the palette); the
      library's failures live on `Session.failures` and an acknowledgement is
      `Action::AcknowledgeFailure`; the sheet is stated every frame. Widgets take
      the model beside their own chrome state (D7); `serde` waits for §7g (D6).
      Behavior change: `Sighting.pointer` is reported only over the canvas, so a
      keyboard zoom over the navigator steps about the centre. 1173 tests, 7
      snapshots unchanged, no golden regenerated, `cargo xtask ci` green in 74 s.
- [x] Phase 3 — pointer resolution (2026-09-11). `blockworx_paint::{Raw, Button,
      Keys, Input}` are what a host says about the pointer, in screen space;
      `blockworx_kernel::pointer::Resolver` makes the click, the drag, the
      double-click and the hover of them with egui's thresholds (D8);
      `Session::resolve(&Input)` runs it against the sighted camera and the
      session's clock. `Event::Pointer(Raw)` + `Event::Keys`; the egui
      `Canvas::input()` reads raw events off the response and reports a pan as
      `Raw::Panning`; `compute_interaction` is deleted. The shell ticks the
      session every frame (`App::sync`), which also dates the spotlight ring's
      fade properly. 1180 tests (8 new), 7 snapshots unchanged, no
      golden regenerated, `cargo xtask ci` green in 74 s.
- [x] Phase 5 — in-place editing (2026-09-11). `Session.editing` is the one draft;
      `TextEvent::{Changed, Committed, Cancelled, TabPressed, CaretAt}` are what a
      host's field says (`Event::Text`); tools ask through `EditText` (no shared
      buffer) and read back through `Canvas::draft`; the recorder paints the
      field, selection, draft/hint and caret from the core's `Layout` (D11); the
      egui `TextEdit` is an invisible capture, `Canvas::capture` its events (D12);
      `View.edit_text` is an `EditField`. Five editor theme roles. The editor's
      look is the core's now; no snapshot pins one. 1190 tests (7 new), 7 snapshots
      unchanged, no golden regenerated, `cargo xtask ci` green in 44 s.
- [x] Phase 6 — `App::update` (2026-09-12). `App::shell_frame` is one batch,
      one `kernel` call, one replay: the docked chrome draws from the last
      call's `View` and acts into the batch, the glass over the picture draws
      from this call's `View` and its acts are queued for the next (D13); the
      shell's one act a frame goes into the batch, the tool's is the call's
      (D14); the call's engine is the screen's `ContextLayout` (D15); the theme
      is the appearance's, told to the session (D16). `View` gains `viewport`,
      `ground`, `writable`, `selected`, `landed`; `NavTree` resolves content
      paths (the editor's document parser is gone). `Session` is named only by
      the call, the doors and the host facts told ahead of it — grep proof in
      the playbook. The branch is complete. 1191 tests, 7 snapshots unchanged,
      no golden regenerated, `cargo xtask ci` green in 54 s.
- [x] Phase 4 — the camera (2026-09-11). `Session.camera` owns the vantage, the
      viewport, the safe region and the framing in flight; the framing math and
      its tests moved from the egui view; `Sighting`/`Framing`/`apply_framing`
      are gone. `blockworx_paint::{Move, Factor}`; `Event::Move`/`Safe`;
      `Action::FrameRect` replaces `Errand::Camera`; `View.vantage` out. The
      egui `View` reads gestures and reports `Move`s (a pinch is a pan and a
      zoom, D9); the ease runs in `Session::ticks` (D10). 1183 tests (7 moved
      from the egui view, 2 new), 7 snapshots unchanged, no golden regenerated,
      `cargo xtask ci` green in 74 s.
- [x] Two bugs found in use after phase 6 (2026-09-12). A click on the glass
      drawn in the canvas's own layer — the selection bar's enter-scope or
      add-icon button, the notices' Dismiss — left the select tool armed: egui
      gives the click to the control but counts the canvas beneath as hovered
      on the press and not on the release, so the canvas heard the press and
      never the release, and the resolver held it. Two fixes: the resolver ends
      a press when the pointer goes (a drag stops where it stood, a held press
      comes to nothing), and the shell names its same-layer glass (`shell::glass`,
      from the bar's and the strip's rects stamped with the pass they were drawn
      on) so `Canvas::input` takes no press on it. Found on the way: the notices
      strip hung under the docked top bar; it hangs below its berth now. The
      pin-name editor (and every editor) drew its draft left-aligned in a field
      built around a right- or centre-aligned label: `EditText`/`EditField` carry
      the label's `align`, the recorder anchors the draft by it and the hidden
      field aligns the same way. Tests: the resolver's press-goes case and an
      app test that clicks Dismiss through `shell_frame` and carries the pointer
      on. 1193 tests, 7 snapshots unchanged, `cargo xtask ci` green.


## After shell-on-kernel — UI nits and the design split (2026-09-13)

The app works in everyday use. What the user found, analysed against the
current design; none of it needs a new seam.

Branch `ui-polish` (2026-09-13); steps P1–P5 in `docs/ui-polish-playbook.md`.

- [x] Rename `Paint` → `DrawOp`, `Vec<Paint>`/`View.paints` → `DrawList`.
      "Paint" is a verb; the display list is a list of draw ops. Mechanical:
      `blockworx_paint::record`, the recorder, `replay`, the kernel's `View`,
      the interface doc §4.1 (done on `ui-polish`, P1: `DrawList` is an alias
      for `Vec<DrawOp>`; the field is `draw_list` on `View`, `Recorded` and
      `Picture`, and `replay(draw_list, …)`).
- [x] Text tool: a click stamps a box. Today the tool wants a rectangle
      dragged out; the icon promises click-and-type. A click on the canvas
      should do what a drop from the cluster does (`Action::StampTool` for
      `ToolName::NewText` already places a default-size box) and open its
      editor. The box grows with its text, capped at 132 characters wide and
      512 lines high, with the rest elided — so a paste too big to show
      cannot blow the layout up. The measurement is the recorder's own
      (`TextExtents` off the same layout the box commits with).
      (done on `ui-polish`, P2: a click is `Action::StampTool` through
      `stamp::stamp`; text wraps at 132 columns of the title font and rows
      past 512 are elided with `…`; the box fits its text on commit.)
- [x] Text box resize. A selected text box shows no handles; the resize
      tool (`ResizeBlock::Selected { shape: ShapeId::Text(_) }`) should
      offer the same corner handles a block has.
      (done on `ui-polish`, P3: the four corners drag the width only; it is
      stored as `Text.width` in grid cells, the text rewraps at it, the
      height follows the rows, and a resize stops at the column cap.)
- [x] Caret blink (retired by E3: the blink is the editor's). Phase 5 (D12)
      painted a steady caret so an open editor
      settles. Blink it from the session's tick — a repaint owed at the blink
      period while an editor is open — and let the settle probe treat that
      wake the way it treated egui's blink (the old test disabled it for the
      shape count).
- [x] Selection overlay: no ellipsis under five items. Show up to five
      controls in the row; the sixth and later go behind `…`. `Bar::of` in
      `panels/overlay.rs` cuts the list by placement today; the cut becomes
      "first five, then the rest" (done on `ui-polish`, P5: `Placement`
      became `Precedence { Own, Clerical }`, which orders the list;
      `INLINE_CONTROLS = 5` cuts it).
- [x] Bigger resize handles, bigger hit regions, on every resizable shape
      (blocks, areas, ports, text boxes). `resize_block.rs` draws them and
      `min_resize_size`/the hit tests size them; both grow together.
      (done on `ui-polish`, P3: `HANDLE_RADIUS` and `HANDLE_GRAB` in
      `render::selection`, in world units, both half again as big;
      `resize_handles`/`resize_handle_at` are the one corner list the
      drawing, the resize tool and the debug overlay read.)
- [x] Add-port tool acts like a stamp. Drop the drag behaviour: a click on
      the canvas and a drop from the cluster both place a port of a default
      size twice today's; the user resizes it afterwards.
      (done on `ui-polish`, P2: the stamped width is 8 cells, twice 4; only
      the width doubles, because `create::port` fixes a port's height at
      `PORT_HEIGHT`. A drag with the tool does nothing.)
- [x] The new-pin tool returns to the toolbar. While it is armed every
      unlocked block in scope shows its pin-add points and a click on one
      adds the pin (the "(+)" path, `route_start`'s marker without the
      route). It takes the `|-o` icon the new-port tool wears today; the
      new-port tool gets a pentagon in a port's own shape.
      (done on `ui-polish`, P4: `ToolName::AddPin`, cell 5 and ⌘N; the
      markers, their hit test and their drawing are `new_pin`'s, shared by
      the tool, the "(+)" and the route tool; a click is a stamp; the tool
      settles on the pin's name editor like the port and text tools.)
- [x] Retire the route-and-create-a-port-in-one-motion tool and its
      drag-to-start-routing animation (`route_start`'s grow-out and the
      "drag hint"): naming the port needs the keyboard, and routing in the
      same motion competes with it. A pin made from the "(+)" beside a
      selected block opens its rename editor and nothing else.
      (done on `ui-polish`, P4: the pull off the "(+)",
      `RouteTool::routing_from_new_pin`, `edit_start_on_commit`,
      `pending_name_edit`, the drag hint and its constants, and the
      held-press pull in `route_start` are gone; a drag from the "(+)" does
      nothing; a drag from an existing pin's green target still routes, on
      the ordinary drag.)
- [x] Keep "a new pin as a route target" (a route dragged onto a block's
      edge mints the pin it lands on) — that flow has no competing focus.
      (done on `ui-polish`, P4: guarded by the kernel test
      `a_route_dragged_onto_a_blocks_edge_mints_the_pin_it_ends_on`.)
- [x] Remove "Open shared diagram" (the `.bwx.zip` bundle door: `open_bundle`,
      `unpack_and_open`, `share_bundle`, `FilePick::NewBundle`) and the JSON
      export and import commands. The projection is not a self-contained
      document now that assets live beside it, so none of the three says
      anything true. Importing another diagram into this one as a block is
      wanted, and is its own design: a placement action and a dialog flow,
      not a file door (done on `ui-polish`, P5: the bundle door and
      `store::bundle` are gone and `zip` with them; JSON export, the rev
      export's Copy and Export rev…, JSON import and the paste of an export
      are gone; Import keeps PNG and SVG).

### The UI/kernel split — branch `ui-ux-split-completion` (2026-09-13)

The egui shell is a consumer of `View`; the seams below are what a second
front end still needs. Decisions E1–E8 in
`docs/ui-kernel-split-completion-playbook.md`; the target front end is a Rust
one (dioxus or similar), so the wasm and JSON edges are deferred.

- [x] E5 — `Errand` becomes `Effect` (`Act::Effect(Effect)`, `Effect::named`),
      the standard name for what a pure update hands the host to perform
      (done 2026-09-13: type, variant, `named`, the shell's perform path,
      the docs; E4/E6/E7 noted in the playbooks).
- [x] E3 — the text editor is the front end's (done 2026-09-13: `EditField`
      asks with rect, `Angle`, font, wrap, text, limits, hint and colours;
      `TextEvent` is `Committed`/`Cancelled`/`TabPressed` with the text, and
      tools read it as `Interaction.text: Option<TextOutcome>`; `Editing`, the
      recorder's draft painting, `Layout::caret/selection/hit` and the three
      key flags are gone; egui runs a visible `TextEdit`, upright). The kernel asks for an editor
      (field id, screen rect, angle, font, alignment, current text,
      multiline, char limit, Tab-cycles, hint) and hears back only the
      edited text on commit, a cancel, or Tab with the text. Retires the
      kernel-painted draft/caret/selection (D12), `Editing`, `TextEvent::
      Changed`/`CaretAt`, `Layout::caret/selection/hit`, and the caret-blink
      item above (the blink is the editor's). Layout may differ between the
      editor and the committed text — accepted. §6 (`CoreLayout`,
      `Paint::Glyphs`) is retired with it; D15 stands.
- [x] E2 — assets across the seam. `Paint::Image` names an `AssetHash`; the
      first frame that draws a hash the session has not sent carries the
      `Asset` (the enum is the format, PNG or SVG, which both sides must
      know) in the hand-off, which becomes a list so one frame can carry
      several. The egui `ImageRegistry` is the cache, keyed by hash, fed
      from the hand-off. `ImageSource::Registered` (the icons) folds into
      the same table (done 2026-09-13: `Paint::Image { rect, hash }`,
      `Recorded.assets`, the session's sent-set and `hand_out_assets`,
      `View.handoffs: Vec<Handoff>`, the registry keyed by hash and fed only
      from the hand-off; `ImageSource`, `ImageHandle`, `Canvas::image` and
      the egui `Icons` pipeline retired — the shell's chrome icons never
      went through the display list, and the toolbar conversion they were
      kept for was done with `egui::include_image!`).
- [x] E1/E8 — `serde` on everything that crosses the call. No `Tool`
      crosses: the three `CommandSet` entries that shipped a seeded tool
      (band arm, add-route-label, the editor commands) become
      `Arm(ToolName)`, a `RouteId`, and a re-derivation from the selection
      at dispatch; tool-to-tool chaining (`Action::SwitchTool(Tool)` from a
      tool's `widget`, installed inside the same call) is untouched. Then
      derive on `Event`, `View` and every type they carry, down to `Rect`,
      `Pos2`, `Vec2`, `GridCell`, `Zoom`, `Vantage`, `Color`, `Font`,
      `ToolName`, `BlockPath`, `Consequence`; round-trip tests.
      (E1 landed: `Action` holds no `Tool` — `Arm(ToolName)`,
      `ArmAddRouteLabel(RouteId)`, `OpenEditor(EditTarget)`; a tool's frame
      returns `Transition { SwitchTool(Tool), Action(Action) }`. E8 landed:
      `Event`, `View` and all they carry derive `Serialize`/`Deserialize`;
      `Bounded` (so `Zoom`, `Progress`, `WorldPx`) is refused out of bounds on
      the way in; `Command.label`, `EditField.hint` and `Reading.tool` are
      `Cow<'static, str>`; round-trip tests over a real session.)
- [x] Hand-offs delivered before their replay. The egui shell delivered a
      call's hand-offs in `settle`, after `canvas_close` had already replayed
      the display list, so the first frame to paint a new image missed it
      (and warned); and the call that primes the first frame never delivered
      its hand-offs at all, so an image in a freshly opened document was
      marked sent and never registered. Both now deliver right after the
      `kernel()` call (done 2026-09-13: `canvas_close`, `take_shown`; two
      app tests in `src/app/tests/assets.rs` count the replay's misses).
- [x] E4 — the wasm facade: retired. Web support stays in egui until the
      split is done; a Rust front end needs no `wasm-bindgen` edge.
- [x] E6 — the theme across the seam (done 2026-09-13: the UI holds the
      theme and tells the engine its palette with `Action::SetPalette`,
      queued like any action; no rev, no undo, read-only takes it; the dev
      editors' role table and font sizes go through `Session::retune`).
- [x] E7 — the glass lag (D13): retired as a non-issue.
- [x] In-place editors drawn under the picture. Since E3 the visible egui
      field was placed while the canvas opened and the ground, grid and
      display list were painted over it when it closed, so every in-place
      editor was invisible: Add label on a route seemed to drop its label
      (suppressed while edited) and the next click committed an empty name,
      deleting it. The canvas now reserves its paint slot as it is laid out
      and fills it in `show`. The field's focus filter is set after the field
      sets its own, so Escape reports a cancel rather than dropping focus and
      committing; a cancelled rename of a fresh route label withdraws it
      (done 2026-09-13: three app tests in `src/app/tests/editors.rs` judge
      the editor by what is on screen).
- [x] In-place editors stand out from the drawing. The front end's field
      was placed with `Frame::NONE`, under which egui ignores
      `background_color` and the margin, so an editor drew its draft
      straight over the committed label, unframed and unfilled. The field
      is now an opaque plate in its fill, ringed in a new `EditorBorder`
      role (B07), rounded and padded by `EDITOR_ROUNDING`/`EDITOR_PAD`, built
      once in `blockworx-egui`'s `editor_frame`. `EditText::fitted` sizes
      every tool's field in one place (the recorder's `set_edit_text`): room
      for the wider of the text and the hint as laid out, about the label's
      anchor, then pad and ring round it — so a fresh route label's "Add
      route label" is no longer cut to "Add rout…". The text box's field is
      anchored at `text_origin`, where the box draws its text. The kernel drops text runs the field
      wholly covers from the display list (`EditField::covers`), so no
      editor paints its label beneath itself (done 2026-09-13: four app
      tests in `src/app/tests/editors.rs`, one per editor kind, check the
      ring's role, the opaque plate over the label and that the picture no
      longer paints it; the add-label test checks the hint is whole). The
      first commit was pushed with the `doc` step failing on an intra-doc
      link the import cleanup broke; the follow-up restores it.

Dioxus web shell — branch `dioxus-web-shell`. Playbook and phase status:
`docs/dioxus-web-shell-playbook.md`; the phase checklist lives there.
- [x] Phase 0 — branch cut, playbook committed with every review decision
      taken (2026-09-14): Canvas2D picture, Tailwind chrome with a light/dark
      selector, base16 for the picture only, gzip revs everywhere, text
      shaped in Rust and drawn as outlines through `blockworx-text`, one
      `Store` over a `Storage` trait, one document per tab, primitives for
      menus, preferences in localStorage, touch last.
- [x] Phase 1a — `blockworx-text`, the text engine the exporters and the
      Canvas2D backend share (2026-09-14): `Shaper` (a `TextLayout` over
      `harfrust` with epaint's line-breaking rules and a layout cache),
      `Outlines` (glyph id → contours in font units) and the `Metrics` both
      are stated in, lifted out of `crates/export`. `Glyph` grew
      `id: Option<GlyphId>`, so `SvgRenderer` skips its identity re-shape
      when the layout carries one and keeps it for epaint's, which leaves
      every SVG and PDF golden untouched. The kernel's 62 tests lay out
      through the `Shaper` now and `crates/kernel` dropped its
      `blockworx-egui` dev-dependency, so the headless gate — ten crates
      since this one — checks the kernel over `dev` too.
- [x] Phase 1b — `blockworx-canvas2d`, the web backend (2026-09-14): `replay`
      of a display list onto a `CanvasRenderingContext2d`, text as cached
      `Path2D` glyph fills through `Glyphs` (the `Shaper` the kernel is called
      with *and* the `Outlines` its ink is traced from, one value so the two
      cannot disagree), the image registry over `Blob` URLs with a typed
      `Repaint::{Owed, Settled}`, the ground, `fit` for HiDPI, `css_cursor`,
      the download and clipboard hand-offs, and the DOM input path as a pure
      `Reader` over plain samples with the egui `View`'s latches ported test
      for test. The grid rule moved to `blockworx_paint::ground` and the wheel
      zoom rate to `Factor::of_scroll`, so both backends read one of each;
      `Camera` and `Ground` moved to `blockworx-paint` beside them. Seven
      `wasm-bindgen-test`s run in headless Chrome from `cargo xtask ci`'s new
      `browser` step; the `backend` and `shell` gates now cover both backends
      and the browser family.
- [x] Phase 4a — `Storage`: one store over a trait (2026-09-14). The
      container's layout — which entry, in what order, durable before what,
      the lock, the projection, save-as, birth names, the fsck — is written
      once over `storage::Storage` (async below, synchronous above: a
      storage that promises its futures are ready is resolved with one poll
      by `ready_now`). `Native` (the `std::fs` container, moved not
      rewritten) and `Memory` (a map) land with it; `Any` boxes one so the
      document handle holds a container without naming where its bytes are.
      `Doc::Attached`, `handle`, `container`, `projection`, `prefix`,
      `naming` and `dump` lose their `cfg(not(wasm32))`: whether there is a
      container is a fact of the `Doc`. `Residency::{Lazy, Resident}` says
      whether a container is read as it is asked for or read whole at open.
      `history::now()` is the page's clock in a browser, and `jiff` its
      zone.
- [x] Phase 4b — gzip revs everywhere, and `migrate` (2026-09-14). `flate2`
      on its pure-Rust `miniz_oxide` backend replaces `zstd`: one
      `pack`/`unpack` pair with no `cfg`, gzip framing, and
      `revs/{rev:06}.json.gz`. Level 1 — 3.7 ms for the 2.8 MB of JSON the
      biggest fixture folds to, against zstd −1's 1.9 ms; level 2 saves a
      seventh of the bytes for twice the time, and the slowest machine this
      runs on is a tablet. A row stamps the bytes its rev file holds, so
      the manifest golden moved (only `hash` and `parent`) and
      `blockworx migrate <container>` re-stamps and re-chains an existing
      container's rows as it rewrites its revs, refreshing the projection
      after. `docs/json-format.md` is rewritten for the new encoding and
      carries the migration.
- [x] Phase 4c — the wrong cfg axis, retired (2026-09-14).
      `Effect::{NewDocument, RenameDocument, PickFile, OpenRecent}` and
      `blockworx_tools::file` lose their `cfg(not(wasm32))`: the registry
      is one list on every target, and `OpenRecent` carries a
      `storage::DocumentRef` — a reference the shell that minted it
      resolves against its own storage — instead of a `PathBuf`. The
      kernel's `title`/`window_title`/`document_name`, the top bar's
      `renaming` and the file notices lose their wasm branches, so one
      body serves both targets. The egui shell performs the library doors
      through one `perform_in_library`, which its web build logs and
      ignores as `kernel()` does an effect no surface can perform; the
      web egui build stays scratch-only.
- [x] Phase 2 — the shell skeleton, `blockworx-web` (2026-09-14). A
      Dioxus package under `web/`, built by `dx`: `Shell` (session, glyphs,
      images, reader, the mounted canvas, the bands) behind one `use_hook`,
      `Chrome` (the `View` minus its picture, compared before the signal
      is written) behind one signal, `pacing` as a browser-free state
      machine over the frame booking, the canvas element's DOM handlers as
      pure sample conversion, the in-place editor as a rotated field in
      the picture's face, and a placeholder bar (tool cells, undo/redo,
      fit, export SVG). The exit walk — two blocks titled, a pin on each
      named, wired, undone by chord and by button, SVG exported — passes
      in headless Chrome and Firefox over the WebDriver wire; it found the
      keyboard staying on the body after a field closed (fixed: `ends`
      hands it back) and the undo chords living outside `BINDINGS` on the
      desktop (mirrored as `history_chord`, flagged for review). CI's
      `wasm-web` step clippies the shell for wasm; the `shell` gate names
      it through one `BROWSER_CRATES` list. `ExportContent::{mime,
      extension}` for the download.
- [x] Undo and redo in `BINDINGS` (2026-09-14). ⌘Z → Undo, ⌘⇧Z and ⌘Y →
      Redo are rows in the one chord table; `Key::{Y, Z}` and
      `Modifiers::CommandShift` name them. The desktop's hand-rolled undo
      shortcuts and the web's `history_chord` are gone, both shells reading
      the table; `handle_keyboard` keeps paste, copy and the nudge. The
      canvas2d reader takes Shift as a modifier of a letter only, so `+`
      stays its own key.
- [x] Phase 4d — the journal and the resident open, in the store
      (2026-09-14). Every container and store door grew an awaited twin and
      the synchronous one is `ready_now` of it, so the layout, the ordering
      and the verification are written once and `Native` is unchanged. A
      resident container's writes reach memory now and the storage later:
      `(Entry, Op)` in the order the store made them, `drain()` performing
      them one at a time, `pending()` the depth a status line reads
      "writing…" off, a write that does not land demoting the container
      with `ReadOnlyReason::WriteFailed` and dropping what it had not
      written. `claim`/`release` became `Storage` hooks so a browser can
      keep its lock in the origin's lock manager; `Claim::Held` and
      `ReadOnlyReason::Locked` carry `lock::Holding`, since not every lock
      names a process. `Memory` is a handle on a map with the browser's
      facts as knobs (`deferred`, `refusing`, `reads()`, `wrote()`), and
      `fixture::block_on` drives them.
- [x] Phase 4d — `blockworx-opfs`: the container in the origin
      (2026-09-14). `Storage` over a `FileSystemDirectoryHandle` and
      nothing more — the `.bwx` layout stays in the store, which is what
      keeps the `headless` gate true. `Root` over
      `navigator.storage.getDirectory()` (`open`, `container`,
      `containers`, `holds`, `remove`) is what the web library will stand
      on; `containers()` counts a directory holding a manifest and answers
      in name order. Every write is a fresh writable closed (whole file or
      nothing); `append` keeps what is there and seeks to the end, which is
      how the manifest grows; rename is copy-then-remove, since `move()` is
      Chromium's alone, and the lock — the origin's lock manager, held as
      an unresolved promise so it releases in one synchronous call —
      travels with it, taken under the new name before any bytes move so a
      rename onto a name another view holds moves nothing. `residency()` is
      `Resident`. Six `wasm-bindgen-test`s in headless Chrome cover the
      `Store` round trip through the origin, the manifest's
      append/truncate/write, a read of nothing answering `None`, the rename
      and its refusal, and one container held by one view at a time; CI's
      `browser` step runs them beside the backend's
      and `wasm-web` clippies the crate. Safari is out until the worker
      path: it has no `createWritable` outside one. `blockworx-store`'s
      `test-support` now turns on `blockworx-doc/fixtures` itself, rather
      than leaning on whoever depends on both.
- [x] Phase 3a of the web shell: the chrome (2026-09-14). The docked strip
      (document menu, liveness dot, breadcrumb with an in-place rename, the
      lens's rev/stepper/Return, undo/redo naming what they cost, up, fit,
      preferences), the floating rail of eight tools with the armed cell
      filled and the withheld ones dead, the status line to §2.0.1's four
      states plus a typed `Owed` slot for the journal, the notices strip
      and a toast for what the shell itself could not do, the `.viewing`
      state as one `data-viewing` on the root that five regions vary on,
      and preferences (mode, base16 scheme, font, profile) in
      `localStorage` with `Mode::System` following a media-query listener.
      One reading of the axis drives both the `dark` class and
      `Action::SetPalette`. `dioxus-primitives` for the menu and the
      popover; `title` for tooltips, since a withheld control must still
      say why. Three policies moved below the shell so the two front ends
      cannot drift: `history::consequence` (invariant 8's sentence),
      `ScopePath::{collapsed, up_to}` (the breadcrumb's collapse) and the
      icon set, which the web now `include_str!`s. Seven `dioxus-ssr` +
      `expect-test` snapshots over fixture `Chrome`s.
- [x] Phase 4d of the web shell: the library, the journal and `.bwx.zip`
      (2026-09-14). `web/src/library.rs` over `blockworx_opfs::Root`: a tab
      opens on the document it last had (the recent list in `localStorage`
      beside the preferences), falls back to one born under a three-word
      name, and falls back again to a scratch session with a standing
      notice where the page has no private storage. The File menu is the
      library's — New, the containers the origin holds, Import/Export
      `.bwx.zip`, Rename, Delete — above the formats a drawing leaves in.
      The journal: after each frame, what the container owes the origin is
      written, one door at a time, with the depth on the status line's
      `Owed` slot. Because a real `await` cannot hold the shell's one
      borrow while a frame wants it, a door takes the document *out* of the
      session for its length and the frame stands off — the cost is the
      frame after a commit lands. A drain that fails has already demoted
      the container; the reason shows as the kernel's read-only notice.
      Moved below the shells so the two front ends cannot disagree:
      `naming::candidates` (the born name for a storage asked
      asynchronously), `recent::{remember, forget}`, `projection::{SETTLE,
      refreshed}` (the stale projection's settle, which the desktop now
      reads from there too) and `Session::opens` (adopt, then stand the
      editor back up). `transfer::{pack, unpack}` is the `.bwx.zip` over
      `Storage` — entries stored, the lock left behind, an archive that is
      not a container refused — documented in `docs/json-format.md`; `zip`
      8.6 on `deflate-flate2` is the one new dependency. `naming::entropy`
      draws through `getrandom`, since `RandomState`'s seed in a browser is
      a constant and every tab drew the same first name. Verified in
      headless Chrome 149 and Firefox 143: born, edit, drain, reload, the
      document still there; a second tab of one browser refused the Web
      Lock and read-only.
- [x] Phase 3b of the web shell: the navigator, the selection overlay, the
      pickers and the palette (2026-09-14). The `.sheet` navigator with its
      History and Parts segments — the block tree with a twisty, an accent
      dot, leaf counts, a `»` that re-roots and a filter that flattens the
      whole document; the log newest-first under day headings, with avatars,
      tag chips and the viewed rev expanded into its tag editor — dismissed
      only by working. The selection bar over `selection_bounds`, verbs in
      precedence order with five inline and the rest behind an ellipsis,
      withheld ones dead with their reason. The accent and I/O pickers as
      popovers over the bar, clamped into the safe region. The ⌘K palette
      over the kernel's own fuzzy ranking. One hidden file input behind
      `AddIcon`/`AddImage`/`Import`. Six more `dioxus-ssr` snapshots.
      `kernel()` now hands back the `Effect`s a named command resolves to
      (`View::effects`) — it used to log and drop them, so no control that
      raised `Event::Command` could ever open a picker. ⌘K joined `BINDINGS`
      as `CommandId::Search`/`Effect::Search`; on the desktop it now opens
      the palette rather than toggling it, Escape closing it as before. Six
      policies moved below the shells: the palette's rows
      (`kernel::palette`), the tree's flattening (`kernel::nav`), the bar's
      order and placement (`kernel::bar`), `in_overlay`, the pickers'
      `ACCENTS`/`PIN_DIRS`, and `said`/`undone`/`avatar_role`.
- [x] Phase 5 of the web shell: touch, the release bundle, and closing out
      (2026-09-14). Two fingers pan by the centre they carry and pinch by the
      ratio of their separation about it — `Span`/`Span::since` in the
      canvas2d `Reader`, the logic the egui backend reads off egui's
      `multi_touch()` written once as a pure function over samples; a second
      finger landing now raises `Raw::Cancelled` rather than leaving a tool
      holding the gesture. `cargo xtask web` drives `dx` against
      `blockworx-web` and reports the bundle's weight — which caught `dx`
      leaving an unoptimized module behind when its wasm-opt aborts on the
      DWARF it asked rustc for; `strip = "debuginfo"` on `wasm-release` is the
      fix and the release wasm is 7.94 MB, 3.14 MB gzipped (8.04/3.16 for the
      bundle). The trunk-driven egui web build is retired: one front end per
      host. `discard_unclaimed` sweeps a newborn nobody kept at startup, since
      a closed tab cannot run the exit sweep the desktop does, with the
      origin's lock standing in for "this session made it". The walk-throughs
      move into `web/walk/` behind `cargo xtask web walk` — Chrome 48 checks,
      Firefox 9, all passing. Not built: Safari, the tree's keyboard, the
      overlay's context menu.
- Web shell issues reported 2026-09-14:
  - [x] 1. Flicker, worst on resize — `fit` no longer resizes (and so
        clears) a backing store already the right size, and the shell keeps
        the last call's picture to repaint over a resize until the next call
        answers.
  - [x] 2. Icons thick and cartoonish — the `[&>svg]` size and stroke
        selectors missed icons nested a level down; `[&_svg]` reaches them.
  - [x] 3. Main menu unreadable — the same selectors, its icons now sized.
  - [x] 4. A still click drew nothing until the pointer moved — `kernel()`
        asks for an immediate repaint after any dispatch, transition or rev.
  - [x] 5. Add-text input too small — a multi-line field opens at no less
        than 40 × 4 of its font's columns and lines, and grows with its draft.
  - [ ] 6. An empty document takes 3–4 s to "write" in Chrome. Mostly the
        2 s settle before `document.json` is rewritten, with commits already
        safe: whether the dot should stop saying "Writing…" then is a
        decision, and it changes the desktop's dot too.
  - [x] 7. Esc didn't disarm the tool — keys are read at the page root
        unless a field is being typed into, so a focused rail button no
        longer swallows them.
  - [x] 8. Dragging route label #2 hid label #1 — the drag and the rename
        leave out only their own label (`LabelPass::Omit`).
- [x] OOM kills during `ci` (2026-09-14) — a debug `dx` build's wasm was
      363 MB, 320 MB of it DWARF, and wasm-bindgen held 7.2 GB reading it
      beside a parallel cargo. `[profile.wasm-dev]` keeps line tables only:
      102 MB, and the dev build peaks at 2.8 GB.
- Web shell issues reported 2026-09-14, second round:
  - [ ] 1. Saves still take seconds, even for a trivially small diagram
        (carries round one's #6). Measured in Chrome on a small document:
        `document.json` is rebuilt 2.0 s after the last edit (the `SETTLE`
        debounce) in under 1 ms, and each journal drain to the origin takes
        4–17 ms — the seconds are the debounce, during which the dot says
        "Writing…". The dot's wording is a decision to bring back; the large
        document's numbers are in the performance block below.
  - [x] 2. Keyboard handling still wrong — e.g. the arrow-key nudge no
        longer works. The arrows are `CommandId::Nudge(Heading)` in
        `BINDINGS`, offered by name for a selection and withheld read-only,
        so both shells read them from the one table (the desktop's own
        `arrow_nudge` is gone); copy and cut are read at the web page's root
        beside paste, and the desktop reads Cut beside Copy. Nudge verified
        in Chrome.
  - [x] 3. Toolbar cells are `<button>`s and take a focus ring, which means
        nothing there; use a styled `<div>` (or another component) and match
        the mockup's behaviour. `Press`, Browse, the time-machine steps and
        the picker cells are a `Pressable` `div` with `data-disabled`, styled
        through `live:`/`dead:` variants; a rail press leaves the focus on the
        canvas and Escape disarms, verified in Chrome.
  - [x] 4. Renaming the document panics: `top_bar.rs:327` `AlreadyBorrowed`
        (borrowed at `:344`), then dioxus `RefCell already borrowed` on every
        event; also a `CopyValue` created in `Canvas` (`canvas.rs:27`) used
        from `DocumentName`. The panic is fixed (a `peek()` guard held across
        the callback that wrote the signal) and verified in Chrome. Found
        with it: the box reopened whenever the crumb remounted (an import),
        and a box opened from the menu lost the focus to the menu's trigger —
        both fixed and verified in Chrome.
  - [x] 5. Log how long saving the current document took — `info!` lines
        for the `document.json` rebuild (time since the last edit, and its
        own cost) and for each journal drain (its cost and write count).
  - [x] 6. A synthetic 50×50-block document from `cargo xtask`, importable
        into the browser for heavier performance testing —
        `cargo xtask autogen scale 50 target/autogen/scale-50.bwx.zip` (a
        `.zip` destination packs a seeded container; 2501 blocks with the
        sheet).
  - [→] 7. Long script times in Chrome's performance profiler — deferred;
        see the performance block below.
  - [x] 8. The scale generator stores its wires' corners, as a saved
        document does, so it opens and edits like one. The editor's
        `settle_corners` runs the solve rider over every wired level until
        it promotes nothing new (one solve is not a fixed point: corners
        promoted from a bare wire re-solve differently); the settled 50×50
        opens in 1.23 s and nudges in 0.79 s in Chrome, as `block50` does.
  - [x] 9. Escape still does not cancel the active tool. The keyboard and
        clipboard were read on the app's root `div`, so a key pressed with
        nothing focused — aimed at `<body>` — never arrived; they are read at
        the document now. And a menu trigger marks Escape handled even while
        closed, so a handled Escape is the control's only from inside
        something open. Verified in Chrome; with the navigator or palette
        open the first Escape closes it, as designed.
  - [→] 11. A nudge on the 50×50 froze the `dx serve` build for ~2 minutes —
        deferred; see the performance block below.
  - [x] 12. Better web console logging: every line timestamped, so the gap a
        freeze leaves is readable from the log alone. No logger of our own:
        `tracing-subscriber`'s fmt layer (UTC RFC 3339 stamps through
        `time`'s `wasm-bindgen` clock) writes through `tracing-web`'s console
        writer, and `tracing-web`'s performance layer puts every span in the
        Performance panel; debug and up in a debug build, info and up in
        release. Verified in Chrome on a release bundle.
  - [x] 10. The egui desktop app opens a `.bwx.zip` named on its command line.
        `blockworx engine.bwx.zip` lays the archive down beside itself as
        `engine.bwx` (the store's `open_archive`, over `transfer::unpack`)
        and opens it attached; a diagram already standing there is refused
        with a notice rather than overwritten, and a container that will not
        open is removed. `Name::of_archive` is the one rule for what an
        archive carries, shared by the web import and `cargo xtask autogen`.
- Web shell UI nits, 2026-09-15:
  - [x] 1. The page's background is the diagram's ground, so a resize shows
        no white flash. The shell states `--bw-ground` on `<html>` whenever
        the ground changes; html, body and the canvas wear it.
  - [x] 2. A filename in the "Open …" menu shows a text cursor on hover.
        Menu rows are `cursor-default select-none`.
  - [x] 3. The web app opens on a new document, so a document that breaks
        the app cannot trap it. `Library::stands_on` always births; the
        storage walk now reopens the earlier document from the menu.
  - [x] 4. Clicking the gear icon makes it wiggle. The overshooting
        `ease-spring` token is gone; controls ease out.
  - [x] 5. A right-button drag on the canvas pans, as on the desktop, rather
        than raising the browser's image menu. The reader gives the
        secondary button to the camera; the canvas cancels `contextmenu`.
  - [x] 6. Design the Preferences (gear) popover — static hi-fi mockups
        first, for review before building. Design approved 2026-09-15
        (https://claude.ai/artifact/LoHxGQ9zXmHZKdRnab97Ey): Appearance as
        a segmented control, scheme cards drawing a block and wire in their
        own colours, lettering cards in their own face, "Your name" with
        when it takes effect. Built into today's popover and verified in
        Chrome; under S1 (#14) it moved into the sidebar's Settings
        section, which is #14's P4.
  - [x] 7. "Picture" is the wrong name for what the canvas draws: it is the
        diagram. 207 uses across 48 files — identifiers (`blockworx_egui::
        Diagram`, the web shell's `Diagram`, `Canvas.diagram`), comments,
        test names, check labels, the current design docs and CLAUDE.md's
        frame note — while "picture" stays where it means an image file, an
        exported raster or a kittest snapshot.
  - [x] 8. Renaming the document has no undo — expected? It is not an op, but
        not being able to take it back is strange. Decided: make it
        undoable (a rename step on the session's undo stack, undone by
        renaming back). Done in the kernel and tools: `State` records the
        container name, a changed name lands as its own `Kind::Rename` entry
        at the start of the next `kernel()` call, and stepping over one
        raises `Effect::RenameDocument(<that name>)` through `View.effects`,
        which both shells already perform — withheld read-only and under
        the lens like any writing step. Rename steps do not survive a
        reload (the name is not in the log).
  - [x] 9. A proper list of the documents the browser holds — static hi-fi
        mockups first, for review before building. Round 1 not settled:
        A (the chip's picker) grows without limit as documents accumulate;
        B (a navigator segment) splits the document doors between the
        hamburger and the panel. Round 2 explores C (a capped picker plus a
        Diagrams library dialog, with the hamburger pointing at it) and D
        (the hamburger opens a scrolling documents drawer). Settled by #14:
        neither — the sidebar's Diagrams section is the list, built as P5.
  - [x] 15. A door asked for while another was out (say, Open from the menu
        while the journal drained) was silently dropped by
        `Shell::opens_a_door`. It now waits (`Shell::waiting`, the latest
        ask kept) and opens when the door that was out comes back — after a
        drain first if the document still owes the origin writes. A guard
        against a real but unobserved drop: the storage walk failure first
        blamed on it was the walk itself, pressing the Document menu's
        button a second time and shutting the menu over the row it meant to
        pick.
  - [ ] 14. Reconsider the web shell's overall structure (2026-09-15): the
        gear's popover, the navigator drawer and the hamburger menu are too
        many places to look — and documents would add another. Prototype
        stage, so a whole-screen redesign is on the table. Round 3 of the
        design canvas sketches whole-screen directions to pick from; #6's
        placement and #9 wait on the choice (#6's content carries over).
        Decided 2026-09-15: **S1, one sidebar** — "the most familiar, and
        probably a simpler learning curve". A thin activity bar on the left
        opens one panel with Diagrams, Parts, History and Settings; the tools
        move to a floating bar at the bottom; the hamburger, the gear
        popover, Browse and the right-hand navigator go. #6's approved
        Preferences become the Settings section, and #9 becomes the Diagrams
        section. Refinements the same day: the sidebar's panels **float over
        the diagram** rather than pushing it aside, so the bottom toolbar
        never moves; the toolbar keeps the S1 sketch's icons; tools run from
        most to least used, left to right — Select, New block, Add block pin,
        Route, Add icon, Add sheet port, Add text, Comment (Add text beside
        Comment, both being annotations). The web shell moves to S1 first;
        the egui desktop shell follows in a later pass, at rough parity
        meanwhile. The work happens on branch `web-shell-sidebar`, following
        `docs/web-sidebar-playbook.md` (phases P0–P6; the phase checklist
        lives there). The Diagrams section's mockup was approved the same day
        (status line bottom right, the panel translucent glass); build it on
        the web shell only.
        - [x] Toolbar sections (user, 2026-09-15): three logical groups with a
              rule between each — Select | block tools (New block, Add block
              pin, Route) | sheet tools (Add sheet port, Add text, Comment,
              Add image) — and possibly the sheet tools folded into an
              overflow, as Figma does. Mockup round 5 on the design canvas
              first, then build per the playbook. **Decided: B** — the sheet
              tools fold into one cell wearing the one used last, its caret
              opening all four with their keys; digits run 1 Select, 2 New
              block, 3 Add block pin, 4 Route, 5 Add sheet port, 6 Add text,
              7 Comment, 8 Add image. Built in P1 (the order and the groups,
              in `BAND_TOOLS`) and P2 (the rule between groups, the folded
              cell and its caret, in `web/src/tool_cluster.rs`).
        - [ ] Move `BAND_TOOLS` (membership, order, group, instruction) and
              `displayed_tool` out of `blockworx-editor::names` into
              `blockworx-tools`, beside `BINDINGS`: nothing in the editor reads
              them — the registry, the digit bindings, the kernel's chrome and
              both shells do (user question, 2026-09-15). Behaviour-preserving.
        - [x] P1 tool order, groups and faces (shared data). `BandTool`
              carries a `ToolGroup`; digits follow the new order; the eight
              faces are the S1 sketch's; the desktop rules every group
              boundary (its band reorders and changes faces too).
        - [x] P2 bottom toolbar with the folded sheet cell; status line bottom
              right, notices top right, toast above the toolbar
        - [x] P3 activity bar and the floating panel (Parts, History).
              `sidebar.rs`; the shell's `browsing` flag became
              `section: Option<Section>`; the right-hand navigator and Browse
              are gone.
        - [x] P4 Settings section: the Preferences popover's content, as a
              section; the gear left the strip for the bar's foot.
        - [x] P5 Diagrams section (`diagrams.rs`): this diagram's card
              (rename, delete, SVG/PNG/PDF/.bwx.zip), search, New, Import,
              and every container newest first with "Edited … · rev N" from
              `Container::glance` (read without the lock). A row's menu
              exports or deletes any diagram; another tab's is refused
              (`Root::remove_unheld`). Renaming a diagram this tab does not
              have open is not offered yet: the rename box is the
              breadcrumb's, and a row would need a box of its own (the
              storage can rename a closed container).
        - [x] Settings: toggling light/dark repainted the canvas but left the
              scheme cards' previews at the old end of the axis (user,
              2026-09-15). They read `shell.theme()`, which is not a signal,
              so nothing re-rendered them; they now take the luminance from
              the preferences and the browser's own, as the page does, and a
              snapshot test pins the cards' grounds to the mode.
        - [x] Every control's label was selectable and took the text cursor
              (user, 2026-09-15): a browser's default for text in an element,
              not an accessibility affordance — a native button behaves the
              other way. One base rule in `web/tailwind.css` now covers
              buttons, pressables, menu items and tabs, and the class a menu
              row carried for it is gone.
        - [x] History tags (user, 2026-09-15): (a) a rev's chips overlapped
              the author's initials disc — absolutely placed from the row's
              left edge, under the avatar; they now sit under their row,
              indented past the disc. (b) The tag that looked as though it
              were on other revs was (a) reading as the next row's; checked
              in Chrome, a tag lands on its own rev and on no other.
        - [x] The add-tag field offers the document's own vocabulary as a
              prediction list the moment it is clicked into, as the prototype
              did (user, 2026-09-15) — shortening the typing and keeping one
              diagram tagged in one spelling. `suggestions()` already computed
              it; it was drawn as small inline links after the field, which is
              not a list and may never have been seen.
        - [x] The expanded history card's design pass (user, 2026-09-15):
              keep the author's initials disc — losing it where every other
              row has one is jarring — put the timestamp under the author's
              name, draw the scope path smaller and quieter, and balance the
              whole card. Mockup approved the same day (design canvas, page
              "History card"); built from it, with #rev quiet at the trailing
              edge and the description in the weight the eye lands on.
        - [x] Dropping a file on the canvas imports it, exactly as the
              matching door does (user, 2026-09-15). Built on branch
              `web-drop-import`: `Dropped::of` reads the name — `.zip`/`.bwx`
              through the library's archive import, anything else as artwork
              (which is all `handle_imported` accepts) — and the picker and
              the drop share one `FileReader` path, so a drop and a pick
              cannot disagree. `dragover` is refused so the browser does not
              open the file in place of the page; one file at a time, since a
              library door holds the document while it is open. Walked in
              Chrome as `drop_import` (10 checks): the file is built in the
              page and the two events are raised on the canvas, WebDriver
              having no drag from outside it. Desktop parity is its own pass,
              below.
        - [x] Two drop zones, not one (user, 2026-09-16). A dropped document
              means different things in different places, and one canvas
              door conflated them:
              - **the Diagrams panel** — file it: the container is laid down
                in the origin, as `imports_archive` already does. This is
                where today's canvas behaviour belongs.
              - **the canvas** — embed it: the document becomes a *block* in
                the current scope. Decided (user, 2026-09-16): a copy grafted
                in, not a live reference — nothing records where the block
                came from, and editing the source later does not touch it.
                A canvas drop files nothing in the origin; the two zones do
                not overlap.
              The model already has the shape: a block's pins *are* its
              interior's sheet ports (`Pin::rect` is the body inside the
              block's own view, `Pin::slot` places it on the block-as-child),
              and a root-level port is a pin owned by `BlockId::NULL`. So
              grafting a document's root scope under a fresh block turns its
              sheet ports into that block's pins for nothing. Steps:
              - [x] `store`: `document_in(archive)` — the archive laid down
                    in a `Memory` and opened exactly as the library opens
                    one, so what an archive *reads* as and what it *opens*
                    as cannot come apart. It first read `document.json`
                    instead, which is one file rather than the whole
                    container but is a **view**: `Freshness::Stale` is an
                    ordinary state, and an archive packed between an edit
                    and the save after it carried a projection behind its
                    own head — so a canvas drop embedded a drawing missing
                    its most recent edits while the same file on Diagrams
                    opened current (caught by the user, 2026-09-16;
                    `what_an_archive_reads_as_is_what_it_opens_as`). The
                    real path also re-attaches the artwork, which lives once
                    in `assets/<hash>` with everything else keeping only the
                    hash, and reads a container that has never saved a
                    projection at all.
              - [x] `editor`: `edit::embed` — mint the block, create it in the
                    current scope, graft the source's root scope into it with
                    `clipboard::copy` + the paste insert, re-minting as every
                    cross-document flow must.
                    `Boundary::of` reads the target scope's block out of the
                    index, so a block minted in the *same* commit has no
                    boundary and `insert` drops every root port on the floor
                    (`take()` → `None` → `continue`). Hoist the `Boundary` out
                    of `insert` to its callers, so a paste builds one from the
                    index and an embed seeds one from the block it is
                    creating. Not a second copy of `insert`.
              - [x] `kernel`: `Session::handle_embedded`, one commit, the new
                    block selected — as `handle_imported` is for artwork.
              - [x] `web`: the canvas takes a document as an embed and artwork
                    as artwork; the Diagrams panel grows a drop zone of its
                    own for the filing half. The drop point becomes the paste
                    target (raise the pointer at it before the read, so
                    `paste_target()` answers with it rather than the last
                    click).
              - [x] the walk splits in two, one check per zone (14 checks,
                    Chrome). The `Boundary` hoist turned out to also drop the
                    nonce `insert` never read, so `paste_into_fresh` takes a
                    `FreshScope` and an embed names no document to land in.
                    The drop point reaches `paste_target()` through the
                    existing policy rather than around it: `dragover` is
                    sampled as a pointer motion (`dom::dragged`), a browser
                    raising none of its own during a drag it is running.
        - [ ] Desktop parity for drop-import: the egui shell takes a dropped
              file through the same two zones, off the same reading of the
              name — a document on the canvas embeds, one on the library
              side files.
        - [ ] Embedding is a copy, so a block records nothing about where it
              came from (decided 2026-09-16). If a part *library* is ever
              wanted — edit the source, every instance follows — that is a
              document-model change: a register on `Block` naming the
              source, resolution at fold or render, and answers for a source
              that changed shape, went missing, or is being descended into.
              Not now, but the place it would go is `edit::embed`.
        - [ ] A dropped file the document cannot read says nothing — the
              console gets `Unsupported or unreadable import`, the page gets
              no notice (walked 2026-09-16). The Import door has always been
              silent this way, so the drop matches it; but a drop can carry
              anything, where a picker has an `accept` filter to narrow it,
              so the drop is where it will be met. `handle_imported` should
              report the refusal as `delivers` already does for an icon
              ("… is not a PNG or an SVG").
        - [ ] Desktop parity for the tag field: its suggestions are drawn
              inline and capped at four, as the web's were, and the same
              prediction list would suit it (its chips sit in the row's flow,
              so it has never had the overlap).
        - [ ] A row's own rename box for diagrams this tab does not have open
              (`Opfs::rename` on a second handle, refused where another tab
              holds it).
        - [x] P6 top bar: breadcrumb, undo, redo, go up, fit. The document
              menu and the gear left it; at the top level the root segment
              opens Diagrams. Go up was taken off with them and put back the
              same day, at the user's ask. Walks re-aimed at the sidebar; digits in the walks
              follow the new order.
  - [x] 10. Generated PDFs look crowded: more whitespace between the diagram
        and the document block. Half an inch of `CLEARANCE` between the
        drawing band and the title block; the structure golden grew each
        page by 36 pt and moved the title block's links with it (its
        regeneration path, stale since the crate split, fixed too).
  - [x] 11. A generated PDF always shows the path — "Path: Root" at the root.
        The title block's one grid always carries the Path row, spelled
        `ROOT_PATH` at the root, so the canvas's block says it too; a lone
        "Root" segment is the page's own and gets no link.
  - [x] 12. The side panel's spring overshoots; use a plain ease-out/ease-in.
        It eases out as it opens and in as it closes.
  - [x] 13. The history list's scrollbar is unstyled and ignores light/dark.
        The root states `color-scheme` for its end of the axis, which native
        scrollbars follow.
  Verified 2026-09-15 in Chrome: the walk-throughs (editor and chrome 26,
  storage 17, touch 6; Firefox 9) and a probe of nits 1, 2, 4, 5, 6, 8, 12
  and 13 (14 checks), all passing.
Retiring the egui shell — branch `retire-egui`, playbook
`docs/retire-egui-playbook.md` (P0–P5; the phase list lives there).
- [x] The web shell has been the real front end since S1 (#55) and the egui
      one has been kept at rough parity behind it, so every nit costs twice
      (user, 2026-09-18). The web shell becomes the only front end.
      Scoping found the weight is not where the grep points:
      - `blockworx-egui` is a **dev-dependency of editor, tools and export** —
        their suites measure real text, a fake font changing which hit tests
        pass. It is small anyway: `Headless = Recording<ContextLayout>`,
        `Recording<L: TextLayout>` lives in `paint`, and
        `blockworx_text::Shaper` is already a `TextLayout` — so the swap is a
        constructor, hosted in `blockworx-text` behind `test-support`.
      - `src/` is the **CLI** as well as the shell: `log`, `verify` and
        `migrate` touch no toolkit and stay. The binary keeps its name and
        loses its GUI.
      - Four `ci` steps name egui and two exist only because there are two
        backends; they narrow rather than disappear.
      Deleted rather than scheduled: both desktop-parity items, and the
      158-occurrence `wasm32` cfg sweep in `src/`.
      **Stated plainly: there is no desktop app after this.** A desktop
      blockworx later means wrapping the web shell, not reviving this one.
      Landed 2026-09-18 as #59 (squashed to a12d6b2): 24,240 lines out,
      257 crates out of the lockfile (844 → 587), `ci` from ~450 s to ~22 s.
      `--trace` was reversed mid-branch and dropped — every span it could
      close was on the frame path, which the CLI does not run.
      P1 (the harness) is the only phase that can be wrong quietly, so it
      went first and alone, while the egui shell still builds to diff
      against; everything after it is deletion, which fails loudly.
      Decided (user, 2026-09-18): **`--trace` stays** and rides the CLI — it
      is what TUNING.md's findings were measured with, and the web's spans
      in a CPU profile are a different instrument; **the font and theme
      editors go**, a theme being a file and a re-run already
      (`theme.json`, `font_sizes.json`), which is the project's own
      practice for dev tooling; **`render_bench` and
      `tessellation_snapshots` go** with the tessellator they measure.
- [x] P1 done (2026-09-18) — the harness is off egui. `blockworx-text` grows
      `measure` behind `test-support`: `Measured` (the shaper, the palette,
      the easing table and a clock that ticks once per frame) hands out
      `Headless<'_> = Recording<&Shaper>` per `Scripted` frame, and the
      viewport, pointer kind and tick a window used to supply are stated.
      The twenty uses in editor, tools and export moved onto it and the
      three `blockworx-egui` dev-dependencies are gone, along with tools'
      direct `egui` one — `route_start`'s touch case scripts
      `PointerKind::Touch` instead of feeding egui a touch event, and the
      frame drivers no longer run inside `ctx.run_ui`.
      Every test that passed before passes now; three things changed with
      the engine, all sub-pixel: the SVG and PDF goldens moved (≤0.3 pt on
      the page, ≤0.5 pt on a link rect) and are regenerated, and
      `a_ports_name_editor_opens_over_the_ports_own_label` compares the two
      centres within a tolerance rather than bit-for-bit — a
      `Rect::from_center_size` round trip now loses 5e-7 px.
      `export/src/engine_tests.rs` is deleted: it asserted epaint and the
      shaper break a run into the same rows, which cannot be asserted
      without epaint, and every other claim it made is already covered by
      `crates/text/src/shaper.rs`'s own tests.
- [x] P2 done (2026-09-18) — the CLI is `src/cli.rs` and names no toolkit:
      the `Cli` parser, `log`/`verify`/`migrate` behind a `Command::run`, the
      three reporters with their exit codes, and the tracing `--trace`
      selects. `main.rs` keeps the egui half — `configure`, `open_window`
      and the wasm entry — as the call P3 deletes, and the clap test moved
      with the parser. `--trace` still only names spans the GUI opens: every
      instrumented span is on the frame path, so over `log|verify|migrate` it
      raises the filter to `blockworx=info` and finds nothing to time. What
      it is for is measured through the app, and P5 is where that lands.

- [x] P3 done (2026-09-18) — the egui shell is deleted: `src/`'s 52 GUI files
      (`app/`, `shell/`, `panels/`, the surface, the library, the exchange,
      the appearance, the dialogs and the pickers, the file and key doors,
      the preferences, and the font/theme editors, `render_bench` and
      `tessellation_snapshots` by decision), `crates/egui/` and its member
      entry, `tests/snapshots/`'s 17 PNGs, the `eframe`/`egui`/`egui_extras`/
      `egui_kittest`/`rfd`/`image` dependencies with the whole wasm32 block
      the egui web build needed, and the `kittest` and `ui_debug` features.
      ~24.2k lines, and 257 crates out of the lockfile. What is left of the
      root package is `main.rs` — parse, install the subscriber, run the
      command — over `cli.rs`.
      `Cli::path` goes with the window it opened: the binary has nothing to
      open a container *in*, so the subcommand is required and a bare path
      is an error rather than a silent no-op. The clap test's claim changes
      with it — the shared first slot is gone, so it now proves each
      subcommand takes its container and that a bare path is refused.
      Four ci steps are stale until P4 narrows them properly, and were cut
      to the minimum that goes green: `lint` drops `--features ui_debug`,
      the `snapshot`/`snapshot-check` steps and `--no-snapshots` go with the
      feature, `backend` and `shell` lose their `blockworx-egui`/`egui`
      entries, `palette` stops walking `crates/egui/src`, and the root
      package's `wasm` clippy step goes — it built a `--lib` this package no
      longer has, for a browser this binary never runs in. `headless`'s
      forbidden list, `BROWSER_CRATES` and the palette gate's dead file
      names still say egui; that, and the docs, are P4's.

- [x] P4 done (2026-09-18) — the gates and the prose.
      **`headless` tightened**: all ten core crates now walk
      `normal,build,dev`, where `editor`, `tools` and `export` were exempt
      from `dev` for exactly the egui dev-dependency P1 removed. It caught
      nothing — no core crate carries a host on any edge — which is the
      strongest claim the gate has made. Its forbidden list stops being an
      egui list: the egui family stays as the tripwire against
      re-introduction, `dioxus` joins it, and the point is now "no host of
      any kind", so the error says so.
      **`shell` is down to three crates** — `canvas2d`, `opfs`, `web`. The
      root package leaves `BROWSER_CRATES`: the command line reaches
      `web-sys` on no target and names it in no manifest, so it is now
      checked like any other crate rather than excused. The one-element
      `TOOLKITS` loop goes with the second toolkit.
      **`palette`** loses `CONVERSION` and `TEST_ONLY` outright rather than
      re-pointing them — nothing in the scan is a whole-file test module that
      names a color, and the conversion file is deleted. `names_a_raw_color`
      drops `Color32::`, `Rgba::from_`, `hex_color!` and `Hsva::`: none of
      those types exists in the tree any more (no egui-family crate is in the
      lockfile), so `Color::` is the whole of what a module can name. The
      scan stops walking `src/`, which is the command line and not a render
      path.
      *Known gap, not closed here*: the palette scan never walked a backend
      or a shell, so with `src/` gone it covers the render path below the
      front end only. `web/src/snapshots.rs` names a raw color today; the
      shell's own colors are CSS, which this grep could not read anyway.
      **Docs.** CLAUDE.md: the crate table loses `blockworx-egui`, the
      `blockworx` row is the CLI, the `App`/`shell_frame` paragraph becomes
      `Shell`'s frame, the ci convention states the four gates as they now
      are, `--trace` is replaced by the browser profile, and "prefer egui
      cascading submenus over dialogs" is restated for the web shell as
      "prefer a menu that opens in place over a modal dialog" — the reason
      for it was never egui's.
      TUNING.md keeps every finding and marks the desktop recipes history
      with the dated-blockquote convention Findings 7 and 8 already use,
      naming the instrument that replaced them. The `--trace` mentions in
      `doc-ng-design-notes`, `doc-ng-port-playbook` and
      `single-author-playbook` were live conditionals, so the instrument word
      goes; `live-demo-plan` and `tutorials` already carry superseded banners
      and are left as history. `exit-egui-playbook`'s "what still lives in
      the shell" gets a dated note: the division carried over, the egui
      answers did not.

- [ ] The palette gate has a gap the egui retirement made visible: it never
      walked a backend or a shell, so with `src/` gone it covers the render
      path *below* the front end only. `web/src/snapshots.rs` names
      `blockworx_paint::Color::from_rgb(0x7a, 0xa2, 0xf7)` today and would
      trip the gate if `web/src` were added. The shell's own colours are CSS,
      which the grep cannot read at all — so closing this is a decision about
      what the gate is for, not a const edit (found while retiring egui,
      2026-09-18).

Editor nits (user, 2026-09-18).
- [ ] A route label whose arc length outruns its wire is clamped onto the
      wire's **end**, which is the pin — so the text runs into the pin and
      the block behind it (user, 2026-09-18). Geometry changes under a
      label all the time (a block moves, a leg re-solves shorter), so this
      is not an edge case.
      Where: `presentation/route.rs:154`, `map_linear_distance_to_position`.
      The loop walks the edges subtracting lengths; a distance past the last
      one falls out of the loop into a fallback that returns `end_pos`
      verbatim. Two things are wrong with those three lines:
      - the position is the wire's endpoint, with no room left for text;
      - the fallback also returns `RouteDirection::Horizontal`
        unconditionally, so a label clamped onto a vertical approach is laid
        out along the wrong axis as well.
      Two fixes, and they are not the same size:
      - **Clamp short of the end.** Stop at `length - gutter`, the gutter
        wide enough to clear the pin stub. Local and cheap, but the
        geometry primitive does not know how wide the label's *text* is —
        that measurement lives at the render layer — so the gutter is a
        constant rather than an answer, and a long label still overhangs.
        `label_anchors` (`auto_route.rs:248`) is the door that could know
        the width, if it were given the layout.
      - **Store the relative position instead.** A label holds an absolute
        arc length, so every wire that shortens pushes its labels off the
        end; a fraction of the length would degrade on its own and need no
        clamp. Better semantics, but it changes what `RouteLabel` stores
        and what `reanchored` writes — a format change, and the arc length
        is what makes a label stay put when a *different* part of the wire
        moves. Worth deciding before the cheap fix hardens.

- [x] The block icon is too easy to grab by accident when the block is what
      you meant to grab — and gating on the *block* being selected is not
      enough, since it is usually unintentional even then (user,
      2026-09-18). A design pass drew five ways to ask for an icon drag
      (canvas "Grabbing a Block's Icon"): a handle, a mode, a modifier, a
      deeper press, or slots instead of dragging.
      Settled without building any of them: **the app already has the
      deeper press.** Clicking an icon selects it — that is what raises its
      resize handles — so repositioning is now allowed only in that state.
      `drag_to_move` takes an `IconGrab { NotArmed, Armed(BlockId) }`, and
      an unarmed icon hands the drag to the block it sits on; the five
      callers that can never hold an icon say so, and
      `ResizeBlock::Selected` — the one selection that can be an icon —
      arms it. The hit test is untouched: the icon stays hittable, so the
      click that arms it still lands.
      Not done in `hit_target.rs` after all. Excluding the icon there would
      have needed the selection carried on the per-frame `Drawing`; gating
      the *drag* needs only what the tool already knows.

Web shell nits (user, 2026-09-17).
- [x] Clicking the scroll wheel pastes. The middle button drives the camera
      (`Drives::Camera`), and X11 binds the same button to pasting the
      primary selection — with a clipboard manager syncing PRIMARY and
      CLIPBOARD, every middle-drag pan pasted the diagram's own clipboard
      back in, as its own commit (rev 23→24→25 in the user's console).
      The browser raises a real `paste` on the canvas, so nothing was
      replaying: the app was doing what the platform told it. The
      secondary button already had this fix — `oncontextmenu` refuses the
      menu that would "take the gesture from under it" — and the middle
      button's own binding was simply missed. `prevent_default` on
      `pointerdown` does not reach it (Blink raises the paste when the
      button comes back *up*), so the shell refuses it itself: `Pasting
      { Asked, Provoked }`, set by a middle press on the diagram and
      cleared by any keydown, so one middle click refuses exactly one
      paste and ⌘V after a pan still works. No timing window.
- [ ] A document opens at the root, not where it was last left (user,
      2026-09-17) — confusing. `BlockPath::opening` takes the designated
      top block, or the root when there is none, and the camera starts
      wherever a fresh one starts. It should open on the scope and camera
      last seen.
      The manifest already holds both, so this may need no new metadata:
      every `Row` carries `scope: ScopePath`, `scope_names` and `camera:
      Camera` — *"where the author was standing: every scope is a
      coordinate space of its own, so a camera without one means
      nothing"*. The head row is the last place anybody stood. Decide
      between that and a stated default in the document's own metadata:
      the row is free and always true, a stated default survives someone
      else's last edit and can be set deliberately.
- [ ] A group selection's bounding box leaves out the routes in it, and
      the count does not tally with what is on screen (user, 2026-09-17).
      Two halves:
      - The box cannot include them: `Deletable::Shapes(Vec<ShapeId>)` is
        what a group selection is, and `ShapeId` has no route variant
        (`Rect | Port | Text | Area | Image | Icon`). A route is only ever
        `Deletable::Route(RouteId)`, singular. So a wire between two
        selected blocks is not *in* the selection at all — it travels with
        them at delete and copy time through the closure, which is why it
        looks included and is not boxed. Either routes join the group
        selection model, or they stop being drawn as though they had.
      - `chrome.rs:663` says `{count} selected`, which the user cannot act
        on and cannot check against the canvas — is a text box an item?
        Either break it down ("N blocks, M routes, K other items") or drop
        the count; a count nobody can use is worse than nothing. Wants the
        first half settled before the wording, since the number depends on
        what the selection is agreed to hold.

Retiring `document.json` — branch `retire-projection`, playbook
`docs/retire-projection-playbook.md` (P0–P4; the step list lives there).
- [x] The projection was a git-diff platform: revs are gzipped, so a diff of
      one is noise, while `document.json` was pretty-printed a field per line
      so a diff of two folds read as the edits between them. The manifest is
      the changelog now, and the History section reads it — so the purpose is
      spent (user, 2026-09-16). Nothing ever read the projection's body: the
      one programmatic reader takes the stamp off the front to ask whether the
      file has kept up with itself. What is left is a second representation of
      the document, written after every edit, that can silently disagree with
      the first — and did, in #56. `Stamp`/`Provenance`/`export_text` stay:
      that is a *stamped export*, a different idea under the same module name.
      `Liveness::Writing` went with it, the web's journal depth already being
      what the status line reports — so the durability signal users read is
      untouched, and the desktop's amber dot is gone, a rev there being
      written as it is committed. `Liveness::of` now takes a
      `doc::Attachment` where it took an `Option<Freshness>` that doubled as
      "is there a container". Containers already on disk keep an inert
      `document.json` that nothing writes and nothing reads; it is not swept.
      Breaking, and the project is undeployed.

- [ ] Performance — deferred to a later pass (2026-09-15). The web and native
      builds are at rough parity, which is enough for now. Everything measured
      and suggested so far, on the settled 50×50 in release unless noted:
      - **Every commit pays two whole-sheet routing passes, whatever it
        changed.** The solve rider (`gesture::seal` → `route_update_closed`,
        the closed router build alone ~150 ms in wasm) and, on the next
        `Session::drawing()`, `refresh_routes` re-materializing every wire
        (~200 ms) after throwing away the geometry the rider just solved.
        Blocking tasks: nudge ~540 ms, a title commit (no geometry change)
        417 ms, a new block 577 ms. Native nudge 468 ms
        (`a_nudge_on_the_settled_grid_is_timed`, ignored).
        - [x] Two constant-factor fixes off the profile (TUNING Finding 8),
          native nudge **468 → 200 ms**: the router's `node_to_index` is an
          `FxHashMap` rather than a `BTreeMap` (372k lookups per build;
          `rebuild_graph` 22.6 → 5.5 ms), and `Obstacles::hugs` queries the
          row/column index a gutter wide either side instead of scanning every
          rect (`straighten` 238 → 13.7 ms, 17×). `Graph::with_capacity` was
          tried and does not help.
        - Adopt the rider's solved geometry as the materialized presentation
          for the stamp it commits, instead of re-deriving it. **This is what
          is left**: after the two fixes above, both halves of the nudge (rider
          92 ms, `present_document` 76 ms) are dominated by building the
          same router twice on identical input — 356/255 rows, 90,536
          crossings, 101,761 nodes, reported identically by both.
        - Scope the solve to the edit — wires whose endpoints moved or whose
          path a new or moved shape crosses, none for a rename — and keep the
          closed router across commits rather than rebuilding it.
      - [x] **~100 wires never satisfied the straighten test, and were why the
        lattice was built twice** (fixed 2026-09-18). Every pass after the settle
        deferred exactly the 100 `in` wires, and `reconstruct_routes`' phase 2 is
        gated on `!deferred.is_empty()` — so those wires were the only reason
        that pass built a router at all. **The cause was the fixture**: a scope
        draws its own boundary ports as free-standing shapes placed by
        `Pin::rect` ("a different scope from `slot`", as the entity says), and
        `fixtures/scale.rs` set only `slot` — so all 100 port bodies sat at
        `GridRect::default()`, stacked at the origin under `block_0_0`, and every
        `in` wire started from an inaccessible anchor at `(1,1)`. Placed in the
        sheet's margins: deferrals after the settle are **zero**,
        `present_document` 76 → **27 ms** (no router), the nudge 197 →
        **~140 ms**, and the "same lattice built twice per commit" finding is
        retired — it is built once, in the rider.
        - Latent, now unexercised: `reconstruct_route` drops inaccessible corners
          only when routing (`if router.is_some() && !obstacles.accessible(pos)`),
          so a corner inside a block would straighten as `blocked` forever while
          the router silently deleted it. Worth closing when something else
          touches that walk.
        - Unconfirmed: `seed_horiz_channel` expands each blocking rect by one
          before clipping while `seed_vert_channel` does not, so the router may
          open a channel where `hugs_wire` refuses a leg.
        - `fixtures/scale50.bwx.zip` is the settled container to open this in
          (File ▸ Import).
      - **Canvas replay at fit zoom**: ~150 ms frames, nearly all Canvas 2D
        calls; `replay::ink` makes five calls per glyph.
        - One `Path2D` per run (`addPath` with the glyph matrix), one fill.
        - Skip text below a legible pixel size (TUNING Finding 6, direction 2).
      - **Saves on large documents**: a journal drain is 22–33 ms and the
        `document.json` rebuild 22–25 ms; the wait users see is the 2 s
        debounce plus whatever routing holds the main thread.
      - **The `dx serve` (wasm-dev) freeze**: one nudge froze the page ~2 min;
        its solve rewrote all 7,550 routes on the first nudge and still 102
        (r1–r100, the sheet's edge wires) on the second, for a one-block move.
        Retest in release first; the sheet-edge churn is worth a look either
        way.
      - **Performance-panel timings accumulate** for the page's life (~10 per
        redraw of blockworx spans).
      - Tools for the pass: `cargo xtask autogen scale 50 <out>.bwx.zip`
        (settled), `cargo xtask autogen pack fixtures/block50.json <out>`,
        the ignored kernel timing test, and the tracing-web Performance spans
        (a release build with `dx build --release --keep-names` names the
        wasm functions in a CPU profile).
      - Regional router P8 — the preview arm (`docs/regional-router-playbook.md`).
        A drag frame is ~133 ms.
        - [x] A preview marks the wires it drew, not the document:
          `Presentation::routes_previewed` names routes, and the next borrow
          takes back exactly those (`Reconstructing::These`) instead of
          re-deriving all 7,550. Drag frame 133 → ~110 ms.
        - [x] The preview walks the foreground, not every wire.
        - [x] The preview's lattice is bounded to the region its wires span.
          Together: drag frame 110 → ~35 ms, the drop 186 → 115 ms.
        - Fixed by P4b — latent since P4a: a scoped reconstruction recomputed crossings among
          its subset only, so hops against wires outside it are lost and the
          others keep hops against stale geometry. P4b (crossings computed at
          draw time) retires it.
      - [x] Regional router P4b — crossings leave the presentation: computed in
        the draw pass for the routes drawn; the stored `crossings` field and
        every `recompute_route_crossings` go. Retires the stale-hops bug above.
        Nudge 45 → 37 ms, drag frame 35 → 29 ms, drop 115 → 100 ms.
      - [x] Regional router P10 — the unresolved list: a wire that could not be
        routed (region too small, or genuinely unroutable) is grown-and-retried,
        then drawn straight, recorded, and retried on each update until it
        resolves or the user fixes it. Also fixed: the editor never passed
        `Bounds` to the router, so region lattices leaked lanes past their edge
        and routed through blocks they had dropped.
      - [x] P10 rework (user, 2026-09-21): settle on the L. A wire no lattice
        routes commits its fallback L; "illegal" is a fact of the document (a
        straight leg crossing a block), not a presentation record. The
        reconstruction draws such a leg verbatim (no lattice); only the rider
        re-routes it, when the foreground raises it. No retry on every commit.
        Illegal wires are marked by the standing-conflict hatch where they
        cross a block — not amber, which an accent can also be.
        - [x] Exports carry the fault hatch (user, 2026-09-21): an export that
          looks different from what is being edited would surprise.
        - [x] Criterion is the microbenchmark tool (user, 2026-09-21):
          `crates/bench` (scenarios shared by the benches and the `spans`
          breakdown example); the kernel's two ignored timing tests are gone.
          First numbers in TUNING Finding 10.
          - [ ] The editor's older ignored timing tests
            (`closed_router_tests`: `drag_preview_frame_time_n25`,
            `route_tool_*_n25`, `recompute_crossings_time`,
            `collect_intersections_stats_n25`; `routing`:
            `a_region_lattice_costs_what_the_region_holds`) print single
            samples — move what is still wanted into scenarios, delete the rest.
          - [x] Instrument the untraced time (Finding 10): every scenario is
            traced to within 0.6 ms. It was index rebuilds per authored op
            (the drop's trims: 5 × 3.2 ms), the rider's foreground and
            scratch clone, the scene around its passes, the navigator, the
            index view after a commit.
          - [ ] `Gesture::author` rebuilds `DocIndex` per authored op (~3.2 ms
            each). Still paid for the move, the rider and the view after the commit.
            - [x] The drop's trims (user, 2026-09-21): one authoring through
              `push_route_riders` instead of one per wire, and the Drawing's
              parallel `straddling_routes` scan is gone. Drop 92.6 → 71.1 ms.
          - [ ] The scene pays for the whole sheet at any zoom (~1.1 ms per
            block pass at the opening zoom) — confirm culling happens before
            shapes are built.
          - [ ] `scope_route_ids` re-sorts 7,550 ids several times per call.
          - [ ] The spatial index is rebuilt from scratch per commit (~6 ms).
          - [ ] The move that starts a drag carries no travel of its own
            (`pointer.rs`): a drag's first delta is dropped, so the block can
            lag the pointer by that move — confirm and decide.
      - [x] Diagnostics overlay (user, 2026-09-21): replace the cycle (full-sheet
        lattice / foreground) with one toggle showing the selection's
        foreground, the healing bound the regional router uses for it, and the
        lattice inside that bound. The old lattice mode showed the unbounded
        scratch lattice, which the regional router never builds.
      - [x] Debug FPS display (user, 2026-09-21), toggled from the ⌘K palette
        ("Diagnostics: frame rate"): frames in the last second, the last
        frame's kernel and canvas time, the second's slowest. The kernel keeps
        the switch; the shell meters (`web/src/meter.rs`) and draws the badge.
      - [x] Wires and block titles drew green (user, 2026-09-21): `7f79dc1`
        removed the `DebugTextBbox` role but left it in `theme.json`, so the
        embedded theme failed to parse and every override fell back to the
        built-in defaults (RouteNormal/ShapeTitle B0B). Key removed; the suite
        now holds `theme.json` and `font_sizes.json` to parsing.
      - [ ] Hover at far zoom (user, 2026-09-21): every pointer move is a full
        frame — hit test, then the whole display list re-recorded (~19,500 ops
        at fit) and replayed. Browser at 10%: kernel ~74 ms, canvas ~230 ms.
        Native `hover_at_fit` 20.2 ms against `frame_at_fit` 13.4 ms:
        `hit_test_hover` ~5 ms (mostly untraced; `scope_route_ids` ×2.5),
        `route_start` ~1.3 ms. Directions: level-of-detail below legible size
        (both halves), skip the re-record when a move changes nothing drawn,
        the hover hit test's cost at low zoom.
      - [x] wasm built for speed (user, 2026-09-21): `wasm-release`
        `opt-level = 3` (was `"s"`), `Dioxus.toml` pins wasm-opt at level 3.
        Measured with the bench scenarios compiled to wasm in V8 (Node): 15–25%
        faster, a whole-sheet frame 17.7 → 14.1 ms (native 13.4). SIMD and a
        further `-O3` no gain; `-Oz` 5–20% slower. Bundle 3.22 → 3.88 MB gzip.
        - [ ] The browser's hover at 10% read kernel ~74 ms where V8 in Node
          runs the same frame in ~22–28 ms: unexplained. Check with DevTools
          closed (an open debugger can hold wasm at the baseline tier) and
          whether one frame carries several pointer moves.
      - [ ] Far-zoom rendering (user, 2026-09-21): scoped in
        `docs/regional-router-playbook.md` ("Next: rendering at far zoom") — a
        retained, world-space GPU scene instead of LOD and caches over Canvas2D.
        R0 first: where the 230 ms canvas replay goes, and what a world-space
        tessellation of the 50×50 costs. (in progress 2026-09-21)
        - [x] Test 1: canvas time is text drawn as vector glyphs; native
          `fillText` + sub-3 px drop + stroke batching keep the canvas ≲22 ms at
          10/24/48% (headless, software raster). On Safari (MBP, GPU) the
          best variants are 11.8 / 11.2 / 3.2 ms; the combination
          (`native_lod3`) is 21.6 / 13.2 / 5.5 headless, ~half on Safari.
          At Retina (`dpr=2`) on the MBP: `native_lod3` 15.3 ms at 10%. Canvas2D
          route first (C1 replay, C2 kernel), GPU scene as fallback.
          Superseded: at 24% on Retina our own glyphs cost 22–27 ms of canvas
          (text alone 24); only the browser's text (a second layouter) is
          fast. Next: R0 tests 2 and 8, then R1, the GPU spike.
      - [x] R0 test 2 (`tools/tessellation-bench`): shapes as world meshes
            are cheap (whole sheet 4–47 ms cold, 9–18 MB); glyphs as meshes
            are out (3.4 M triangles) — text goes to an atlas of our
            outlines. Next: test 8, then R1.
      - [x] R0 test 2b: epaint doing all of it, text included, re-tessellates
            the 10% frame in 9.6 ms (24%: 1.9 ms) native — immediate mode is
            affordable; the catch is epaint's text is its own layouter.
            epaint's `Fonts` as *the* layouter joins test 8's candidates.
      - [x] R0t (user, 2026-09-21): epaint as the one layouter, exports as
            text (selectable PDF, greppable SVG); `blockworx-text` to go.
            Checks T1 zoom, T2 epaint-vs-usvg drift, T3 PDF text via
            pdftotext, T4 viewers — spike in `tools/text-export-spike`.
            Done: rows must be fixed in world units (re-wrap at screen scale
            moves breaks in half the wrapped labels); epaint vs usvg 0.05 px
            mean at density 8; PDF text extracts exactly; viewers agree bar
            font fallback. Next: R1 with epaint, parley + vello_hybrid beside.
