# The Dioxus web shell: a second front end over the same kernel

Status: **done (2026-09-14).** Every phase below is built and on the
branch `dioxus-web-shell`: the text crate, the Canvas2D backend, the
Dioxus shell and all six regions of its chrome, storage in the origin
with the journal and the `.bwx.zip` doors, and touch. `cargo xtask web
build` bundles it and `cargo xtask web walk` drives a real browser
through it — the walk-throughs, their README and what each one covers
are in `web/walk/`, and are how every phase was signed off.

What is **not** built: Safari, which has no `createWritable` outside a
worker and so no origin-private storage until the kernel runs in one
(§12); the navigator tree's keyboard, which is the desktop's alone
because the web's rows are buttons the browser already tabs through; and
the selection bar's right-click menu, which §3.6 of the UI spec makes a
duplicate of the bar. The egui shell keeps the desktop, and its own web
build — which was always scratch — is retired: `cargo xtask web` is the
Dioxus shell's. Its `cfg(target_arch = "wasm32")` arms are all that still
compile for a browser, and taking them out is the next sweep.

## 0. The ask, and the shape of the answer

A web-only Blockworx whose chrome is HTML rendered by Dioxus, whose diagram
is drawn with the Canvas2D API, and whose engine boundary is exactly the one
the egui shell already stands on:

```text
kernel(&mut Session, Vec<Event>, &impl TextLayout) -> View
```

The egui shell stays. Both shells run, from one workspace, off one kernel.
Nothing below the shell learns the word "Dioxus", and nothing below the
backend learns the word "canvas".

Three facts about the code as it stands make this tractable, and the plan
leans on all three:

1. **Everything below the shell already compiles for `wasm32`.** The egui
   shell ships as a `trunk` bundle today (`cargo xtask ci`'s wasm step), so
   `doc`, `geom`, `paint`, `store`, `router`, `editor`, `tools`, `kernel`
   and `export` are proven on the target. The new work is a backend and a
   shell, not a port.
2. **The kernel is retained-mode ready.** `View` is a display list plus a
   chrome *model* (`TopBar`, `Reading`, `NavTree`, `Overlay`, notices,
   commands, cursor, edit field, title). Every draw op is in screen space
   with colours resolved. Time is an `Event::Tick`, a repaint request comes
   back on the `View`. This is precisely what a DOM front end wants: paint
   the list, bind the model.
3. **The store already has the seam.** `revs::Backing` (`put`/`get` by rev)
   with `Memory` and native `Dir` implementations, `AssetSource`/`AssetSink`
   with a `Held` in-memory store, and `Doc::Scratch` — a session that keeps
   every rev in the container's own encoding over a map — is the shape the
   web store extends. The `#[cfg(not(target_arch = "wasm32"))]` on
   `Doc::Attached` and on the `container`/`lock`/`atomic`/`handle` modules
   is the wrong axis (it encodes *"wasm means no files"*), and retiring it
   is the storage work.

## 1. Crate layout

Three new workspace members: a text crate the exporters and the web
backend share, and a backend/shell pair mirroring the existing one
(`blockworx-egui` backend, `blockworx` shell):

| Crate | Path | Owns | Depends on |
|---|---|---|---|
| `blockworx-text` | `crates/text` | the `Shaper` (a `TextLayout` over `harfrust` with epaint's line-breaking rules and a layout cache) and the glyph `Outlines`; the face parsing, shaping and outline tracing that live in `export` today | paint, `ttf-parser`, `harfrust`, `skrifa` |
| `blockworx-canvas2d` | `crates/canvas2d` | the web backend: `replay` of a `DrawList` onto a `CanvasRenderingContext2d`, glyphs filled as cached `Path2D`s from the `Outlines`, the image registry (asset hash → decoded `HtmlImageElement`), the pointer/wheel/key reader that turns DOM events into `Event`s, the download helper | doc, geom, paint, text, `web-sys`, `wasm-bindgen`. **Not** Dioxus. |
| `blockworx-web` | `web/` (a binary crate with its own `Dioxus.toml`, `tailwind.css`, `assets/`) | the shell: the Dioxus app, the frame loop, the chrome components, the web library (documents in OPFS), preferences, the effects | everything, plus `dioxus`, `dioxus-primitives` |

Why a backend crate with no Dioxus in it: the backend is DOM-level code
(`web-sys` calls over a canvas element and a batch of events) and is
testable under `wasm-bindgen-test` in headless Chrome without a component
tree. It is `blockworx-egui` rewritten for a different toolkit, which is
what that crate's own doc comment says the next backend would be. The
Dioxus shell then stays thin: components, signals, effects.

The two gates in `cargo xtask ci` extend by symmetry:

- **backend**: `blockworx-canvas2d` knows nothing above `paint` and
  `text` (as `blockworx-egui` knows nothing above `paint` today).
- **shell**: `web-sys`/`js-sys`/`wasm-bindgen` are reached only through
  the two backends and the two shells; `dioxus` only through
  `blockworx-web`; egui still only through `blockworx` and `blockworx-egui`.
- **headless**: the nine core crates carry no egui-family *and* no
  dioxus-family crate.

The web shell is a separate binary, so the root package's `Cargo.toml`
is untouched. The two web builds coexisted while this branch was in
flight — `trunk` for egui, `dx` for Dioxus — and the egui one is retired
now that this one works: one front end per host, and `cargo xtask web`
means this one.

## 2. The diagram: Canvas2D replay

`DrawOp` has nine variants — `Rect`, `LineSegment`, `Line`, `Circle`,
`ConvexPolygon`, `Text`, `TextWrapped`, `RotatedText`, `Image` — and each
is one or two Canvas2D calls. `replay(&[DrawOp], &Context2d, &Images)` is a
`match` the length of `blockworx_egui::replay`, minus the baseline-snap
corrections egui needs (Canvas2D places text at continuous coordinates).

- **HiDPI**: the canvas's backing store is sized by `devicePixelRatio`
  and the context is scaled once per frame. The kernel and the layout work
  in CSS pixels, which are the "screen space" the egui shell's points are.
- **The ground and the grid**: `View.ground` gives the colours; the grid
  is drawn by the backend from `vantage` and `viewport` before the list, as
  `blockworx_egui::view::grid` does today (major/minor lines, minor dropped
  under 5 px spacing). That function's logic moves to `paint` so both
  backends read one grid rule rather than two.
- **Images**: `Handoff::Asset { hash, asset }` arrives once per asset. The
  registry turns the bytes into a `Blob` URL and an `HtmlImageElement`;
  SVG and PNG both decode natively and `drawImage` rasterises an SVG at the
  drawn size, so no cap on raster size is needed. Until `onload` fires the
  op draws nothing and the backend asks for a repaint, as the egui backend
  does while its loader is `Pending`.
- **Cursor**: `View.cursor` maps to the canvas element's CSS `cursor`.
- **No GPU path.** At this app's scale (hundreds of blocks, a few thousand
  ops a frame) Canvas2D is well inside budget, and frames run only when
  something changed (see §4), so the idle cost is zero.

## 3. Text: shaped in Rust, drawn as outlines

`TextLayout` is the one thing the kernel needs from a host: rows, glyph
positions, sizes. The contract says it is the *host's* engine, so an
export traced through it breaks lines where the screen did.

What the exporters already do is the model. The SVG writer takes row
breaks and pen positions from the host's `TextLayout`, re-shapes each
paragraph with `harfrust` over the same font bytes — only to recover the
glyph *identity* the layout dropped, since epaint reports characters, not
glyphs — and traces each glyph's outline with `ttf-parser` into a filled
path. The PDF writer measures through the layout and lets `krilla` set the
embedded face. Nothing in that path needs a toolkit or a browser; on the
desktop the layout happens to come from epaint because that is what the
screen draws with.

For the web the same machinery is the whole answer, and it removes the
wart rather than working around it:

**`blockworx-text`** (`crates/text`, depends on `paint`, `ttf-parser`,
`harfrust`, `skrifa` — moved out of `export`) owns two things:

- **`Shaper`, a `TextLayout`.** One parsed face per `FontChoice`; each
  paragraph shaped by `harfrust` (glyph ids, advances, clusters, kerning
  as the font states it); rows broken at whitespace and hard newlines
  with a per-character fallback for a word wider than the wrap — epaint's
  rules, written once; a layout cache keyed by `(text, size, wrap)`, since
  the recorder lays every label out every frame. Its `Layout` carries the
  glyph id beside the character (`Glyph` gains `id: Option<GlyphId>`), so
  a consumer that has one never re-shapes.
- **`Outlines`**: glyph id → outline in font units, cached per face, as a
  small path vocabulary (`MoveTo`/`LineTo`/`QuadTo`/`CurveTo`/`Close`).
  The SVG writer renders it to a `d` attribute — its `GlyphPath` builder,
  moved — and the Canvas2D backend to a `Path2D`.

**On the web** the kernel is called with the `Shaper`, and the backend
draws each `Text`/`TextWrapped`/`RotatedText` op by filling the cached
`Path2D` of each glyph under one `setTransform` (scale by size over
units-per-em, translate to the pen). No `FontFace` registration, no
`document.fonts.ready`, no `measureText`, no kerning mismatch between what
was measured and what is drawn: the diagram is font-engine-free, and the
glyphs are the export's glyphs by construction. The in-place editor is
the one place a web font is needed — the `<textarea>` gets the same TTF
bytes through `FontFace` — and it is off the diagram's path.

**The exporters simplify.** `SvgRenderer` drops its own face, shaper and
`identify`/`Cluster`/`ByChar` fallback when the layout carries ids, and
keeps the re-shape only for a layout that does not (epaint's, on the
desktop). The kernel's own tests, which today construct
`blockworx_egui::EpaintLayout` for want of any other `TextLayout`, take
the `Shaper` and drop their egui dev-dependency, which makes the headless
gate stronger.

**The desktop is unchanged.** Its screen lays out through epaint and its
exports through `EpaintLayout`, so a desktop export still breaks lines
where the desktop screen did. Web and desktop exports of one document may
differ by a kern or a break; that is the per-host disparity the trait
already states. Moving the desktop exports onto the `Shaper` too would
make every export identical across hosts and is a small follow-up (the
export goldens would be regenerated and reviewed), not part of this
series.

**Costs to state.** Outline fill is unhinted, so small labels render a
touch softer than the browser's own text rasteriser — the same trade
epaint makes on the desktop. Glyph count per frame is the load: a
diagram of a few hundred labels is a few thousand `fill(Path2D)` calls,
inside Canvas2D's budget; if a dense sheet stalls, the next step is a
per-row bitmap cache keyed by the row's text and size, and the display
list already gives each row's rect. Phase 1 measures both before anything
above the backend is built.

## 4. The frame loop, in retained mode

The egui shell runs `shell_frame` every egui frame: one batch, one
`kernel` call, one replay, chrome drawn from the *previous* call's `View`.
The web shell keeps the discipline and drops the immediate-mode habit:

```text
DOM event ──► batch.push(Event) ──► schedule_frame()      (one rAF pending at a time)
                                          │
rAF ─────────────────────────────────────►│ view = kernel(session, take(batch) + Tick, &layout)
                                          │ replay(view.draw_list) onto the canvas
                                          │ chrome.set(Chrome::of(&view))        ── the Dioxus side
                                          │ deliver(view.handoffs)
                                          │ if let Some(after) = view.repaint { schedule_frame_in(after) }
```

- A frame runs when an input arrived, when the kernel asked for one
  (`repaint`), when the canvas resized (`ResizeObserver` → `Event::Viewport`),
  or when the chrome's insets changed (`Event::Safe`). Otherwise nothing
  runs. This is the same contract as the egui shell's `repaint` handling,
  minus the idle frames egui runs anyway.
- **The display list never enters the Dioxus VDOM.** The canvas is painted
  imperatively by the backend. What the components bind is `Chrome`, a
  struct of the `View`'s model fields (`top_bar`, `status`, `nav_tree`,
  `overlay`, `notices`, `commands`, `tool`, `title`, `edit_text`, `cursor`,
  `selection_bounds`, `writable`, `selected`, `landed`). `View` is
  `PartialEq`, so a `Signal<Chrome>` write is skipped when nothing changed
  and a frame that only panned the diagram re-renders no HTML.
- **Ownership**: the `Session` and the shell's parts live in one `Shell`
  value held by `use_hook` behind `Rc<RefCell<_>>`; the pending batch is a
  separate cell so an event handler firing while a frame is running (it
  cannot, on one thread, but the borrow checker does not know that) never
  re-enters the session. Handlers do one thing: `shell.say(Event)`.
- **Chrome to kernel**: a component that raises something pushes an `Act`
  onto the same batch — `Action`s and `Command`s as `Event`s, `Effect`s
  performed by the part that owns them (§6) — exactly `App::queued` today.

## 5. Input

`blockworx-canvas2d::input` turns DOM events into `Event`s. The mapping is
a pure function over plain structs (`PointerSample { pos, button, kind }`,
`WheelSample`, `KeySample`) so it is unit-tested natively; the `web-sys`
event → sample conversion is the only wasm-bound layer.

| DOM | Kernel |
|---|---|
| `pointermove` / `pointerdown` / `pointerup` on the canvas, with `setPointerCapture` on down | `Raw::Moved` / `Down` / `Up` (primary, secondary, middle) |
| `pointerleave`, `pointercancel` | `Raw::Gone`, `Raw::Cancelled` |
| middle-drag, or space held + primary drag | `Move::Pan(delta)` and `Raw::Panning` (the latch semantics of `blockworx_egui::View::handle_pan_zoom`: a pan that began stays a pan until the button lifts) |
| `wheel` (bare) | `Move::Zoom { factor: exp(-Δy·rate), anchor: cursor }` — a bare wheel zooms, as on the desktop; `ctrl`+wheel is what trackpad pinch arrives as and zooms too |
| two active pointers (touch) | `Move::Pan` + `Move::Zoom` about the gesture centre — computed from the two pointer positions, the `multi_touch` logic the egui backend reads off egui, written once here as `Span::since` |
| `keydown` Escape / Delete / Backspace / Shift, when `document.activeElement` is the canvas | `Keys { escape, delete, shift }` |
| `keydown` matching a `Chord` from the tools crate's binding table | `Event::Command(id)` |
| `paste` on the window while the canvas holds focus | the object-paste path (`Action::Paste`), ahead of any text field, as `Ahead::pasted` does |
| `ResizeObserver` on the canvas container | `Event::Viewport(rect)` |

The chrome's rects are told to the kernel as `Event::Safe` so a framing
centres in the visible part of the canvas; the docked bands report their
own sizes from `onmounted`/`ResizeObserver`.

## 6. Chrome: components over the `View` model

The mockup is `docs/cad-unified-topbar.html`; the behaviours are
`docs/cad-ui-spec.md`, which wins where they disagree. What the mockup
settles for us:

- **The Web variant is the null variant.** Platform is a `data-p`
  attribute and only four CSS rules key off it (the macOS menu strip and
  traffic lights, the Windows/Linux caption buttons); `web` has none of
  them and uses Ctrl as the modifier. We build the base case and no
  platform branching.
- **The regions map one-to-one onto `View`**: `.topbar` → `top_bar`,
  `.tools` → `tool` + `commands`, `.overlay` → `overlay`, `.status` →
  `status` + `landed`, `.toast` → `notices`, the `.sheet` navigator's
  History and Parts segments → `history` and `nav_tree`, `.canvas` →
  `draw_list` + `ground` + `cursor`. The mockup lacks the ⌘K palette (spec
  §9; the egui shell has one) and still shows a Learn segment the spec
  struck; we build the palette and not Learn.
- **One state class, `.viewing`**, restyles five regions at once (amber
  bar, desaturated canvas, inert rail, the centre mode slot). It is one
  attribute on the root, derived from `top_bar.lens` and `Locked`.
- **Insets are measured, not cached** (spec §2.1): the rail's and the
  sheet's live bounding rects, re-read when the sheet opens. That is our
  `Event::Safe`.
- **What is the shell's, not the kernel's** (shell-on-kernel D7): which
  navigator segment is open, the tree's expand set and filter, the rename
  draft, the recent list, which picker is up, the wall clock an age is
  measured against. These are the components' local signals; nothing else
  is.
- The egui decision that does **not** carry over: D13, chrome drawn from
  the last call's answer and glass from this one's. A retained tree
  renders all of it from one `View`, with no one-frame lag.

Each region becomes one Dioxus component that reads `Signal<Chrome>` and
raises `Act`s.

**Styling: Tailwind for the chrome, base16 for the diagram.** Two colour
systems, deliberately kept apart:

- **The chrome is Tailwind** (v4; `dx` runs it when `tailwind.css` sits at
  the crate root). Components are written as utilities in `rsx!` —
  `bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 rounded-2xl
  shadow-lg` — using Tailwind's own theme and its `dark:` variant. The
  mockup's ~310 lines of hand-written CSS and its base16-derived token
  layer are **not** carried over; what is carried is the layout, the
  metrics (44 px targets, the 54 px bar, radii 13–20 px, one spring curve)
  and the `prefers-reduced-motion` rule, which become a few `@theme`
  entries in `tailwind.css`. The `.viewing` state is a class on the root
  that the affected regions vary on with Tailwind's `group-*` variants.
- **The diagram is the app's palette**, exactly as on the desktop: the
  shell tells the kernel a `Palette` by `Action::SetPalette`, the backend
  replays colours the kernel already resolved, and the **palette gate**
  (a grep over Rust for colours made outside `palette.rs`) is unchanged
  in scope — it governs what is painted, and Tailwind class strings are
  not colours to it.

**Light and dark.** One selector, the `Mode` the preferences already hold
(`Light`, `Dark`, `System`). It sets the `dark` class on the root for the
chrome and picks which variant of the scheme the diagram uses, which is
what `Preferences::palette` does today. The base16 *scheme* (Catppuccin,
Gruvbox, …) remains a separate picker in preferences and affects only
the diagram; `System` follows `prefers-color-scheme` through a media
query listener. Tailwind's `dark` variant is configured to key off the
class, not the media query, so the two stay in step.

| Region | Reads | Raises |
|---|---|---|
| Top bar (name, scope breadcrumb, undo/redo, lens/time machine, liveness, rename) | `top_bar` | `Command`s, `Action::Rename…`, nav actions |
| Tool cluster | `tool`, `commands` | `Command(select-tool…)` |
| Status line | `status`, `landed` | — |
| Navigator (the block tree) | `nav_tree` | `Action::NavSelect`, scope changes |
| Selection overlay (the bar anchored to `selection_bounds`) | `overlay`, `commands` | `Command`s; `Effect::Accent`, `Effect::PinType` open the pickers |
| Pickers (role, pin type, I/O pin) | the effect's payload | `Action`s |
| Notices strip, toasts | `notices`, `landed`, shell failures | `Action::AcknowledgeFailure` |
| History panel | `history` | `Action::ViewRev`, tags |
| Command palette | `commands`, `nav_tree`, `history` (`nucleo-matcher` for the fuzzy match, as today) | `Command`, nav |
| In-place editor | `edit_text: Option<EditField>` | `Event::Text(TextEvent)` |
| File menu | the web library (§7) | library effects, export commands |
| Preferences (light/dark mode, diagram scheme, font, identity) | shell state, persisted in `localStorage` | `Action::SetPalette`, font change; the `dark` class on the root |

`dioxus-primitives` (the official unstyled, WAI-ARIA component set,
installed per component with `dx components add`) is used where keyboard
and focus handling is the maintenance burden — dropdown menus, popovers,
the palette's dialog and listbox, tooltips, toasts — and plain `rsx!`
everywhere else. The primitives are unstyled by design, so they take the
same Tailwind classes as the rest of the chrome and pull in no second
design system.

The in-place editor is a positioned `<textarea>` (or `<input>` when
`multiline` is false) over the canvas, sized from `EditField.rect`,
rotated by `EditField.angle` with a CSS transform (the web *can* rotate a
field, so the egui backend's accepted disparity does not apply),
committing on Enter/blur, cancelling on Escape, and stepping the cycle on
Tab when `tab_cycle` is set — the `TextEvent` contract, nothing more.

Snapshot tests: `dioxus-ssr` renders a component from a fixture `Chrome`
to a string, checked with `expect-test` as the repo already does for
goldens. These are cheap, run natively, and catch a region that stopped
reading a field.

## 7. Storage: the container behind a `Storage`

### What is there today

`blockworx-store` is written for one property — what the editor holds and
what the files hold agree at every point a call can return — and it does
this synchronously: a rev lands and is fsync'd before the manifest row that
names it; the container takes a lock; atomic writes go through a temp file
and a rename. On the web all of this is compiled out, and `Doc` has one
arm, `Scratch`: revs in `revs::Memory`, payloads in `assets::Held`, nothing
durable, every commit stamped at the Unix epoch (`history::now()` is a
constant on wasm) and attributed to nobody (`Identity::from_environment`
reads `$USER`).

The survey of the crate (module by module) says where the storage is
touched, and it is narrower than the module list suggests:

- **Already pure and reusable as-is**: `manifest.rs` (scan, chain
  verification, canonical rows — all over `&str`), `record.rs`, `tags.rs`,
  `worked.rs`, `document_file.rs`, the top half of `projection.rs`, the
  rev encoding in `revs.rs`.
- **Already a trait**: `revs::Backing` (`put`/`get` by rev; `Memory` and
  native `Dir`) and `assets::{AssetSource, AssetSink}` (`Held` and native
  `Dir`). One leak to fix: `AssetSource::names` answers a `PathBuf`.
- **Concrete `std::fs` over `&Path`**, the part to lift: `Container`
  (holds an append-mode `File` on the manifest; `sync_data` per row;
  truncate on a torn tail; create; rename; discard), `lock.rs` (a lock
  file plus `/proc` liveness), `projection::{found_at, write_at}`,
  `naming::Documents` (where a document is born), `prefix.rs` (save-as),
  `handle.rs` (`Store`, hard-typed to `Container`, hard-typed to
  `PathBuf`).

What the kernel needs of a `Doc` is exactly: `submit`, `undo`, `redo`,
`tag` (the writes), `document_at`, `worked_at`, `camera_at`, `label_at`,
`stamp_at`, `journal`, `tags`, `trail`, `repo` (the reads), the
`writability`/`saving`/`renaming`/`read_only_reason` facts, and the nonce.
The shell adds `rename`, `save_projection`, `container_root` and the
container doors. Nothing in that list is a path except `container_root`,
which feeds the title.

Reads are **lazy** on native: open reads the whole manifest and only the
head rev; earlier revs are read on a time-machine pick or an undo, assets
on reference. The design below keeps that.

### The web's constraints

- OPFS from the main thread is **async** (`getFileHandle`, `createWritable`,
  `write`, `close` are promises). Synchronous access
  (`FileSystemSyncAccessHandle`) exists only in a Web Worker.
- The kernel is synchronous and stays so: a `Session` that awaited its
  store would be a second editor.
- Tabs of one origin share OPFS; the Web Locks API is the browser's
  counterpart of the container's lock file.
- `zstd` does not build for `wasm32-unknown-unknown` (the reason the wasm
  build writes a rev as its own JSON today); the compressor has to be one
  that runs everywhere.

### The design

One `Store`, generic over where its bytes live, with the store's own logic
— layout, ordering, verification, the projection, save-as, birth names —
written once and compiled on every target.

**The trait.** Path-level rather than container-level, so the `.bwx`
layout (which file, in what order, fsync'd before what) is encoded once in
the store and not once per storage:

```rust
/// Where one container's bytes live. Paths are container-relative names
/// (`manifest.jsonl`, `revs/000012.json.zst`), never platform paths.
pub trait Storage {
    fn read(&self, at: &Entry) -> impl Future<Output = io::Result<Option<Vec<u8>>>>;
    /// Whole file or nothing: temp-and-rename on disk, a fresh writable on OPFS.
    fn write(&self, at: &Entry, bytes: &[u8]) -> impl Future<Output = io::Result<()>>;
    fn append(&self, at: &Entry, bytes: &[u8]) -> impl Future<Output = io::Result<()>>;
    fn truncate(&self, at: &Entry, len: u64) -> impl Future<Output = io::Result<()>>;
    fn list(&self, dir: &Entry) -> impl Future<Output = io::Result<Vec<String>>>;
    fn remove(&self, at: &Entry) -> impl Future<Output = io::Result<()>>;
}
```

Three implementations: `Native` (`std::fs`; what `atomic.rs` and
`container.rs` do today, moved), `Opfs` (`web-sys` over
`FileSystemDirectoryHandle`, ~200 lines), `Memory` (a map, for tests on
both targets). The `opfs` crate (0.2.0) offers roughly this shape with a
tokio native fallback, but it is thinly documented, would put tokio in the
native tree, and expresses none of the append/truncate/atomic semantics
the manifest needs; the surface we need is small enough that owning it is
less to maintain than adapting to it.

**Sync above, async below.** The kernel's read path stays synchronous
(`document_at`, an undo's read-back). On native every `Native` future is
ready the moment it is made, so the store polls it once and behaves as it
does today, lazily and with the same fsync ordering — **no behaviour
change**, and the store's 1,500 lines of tests prove it. On the web the
futures are real, so the store is opened **resident**: the open reads the
manifest, every rev and every referenced asset into the existing
`revs::Memory` and `assets::Held`, and every later read is a memory hit.
Residency is one enum on the `Store` (`Lazy` on native, `Resident` on
the web), not a second store.

**Writes are journaled.** `submit`/`undo`/`redo`/`tag` encode the rev, the
asset and the row synchronously, into memory when resident, and push the
`(entry, bytes)` pairs — in the store's order — onto a `Journal`. Native
drains it inline (ready futures), so "the rev lands before the row that
names it" holds as now. The web drains it through `spawn_local`, in order,
one writer at a time. The store reports the journal's depth; the status
line's liveness reading shows "writing…" while it is non-empty. **Loss
window on the web**: a tab killed between a commit and its OPFS write
completing loses that commit. Stated here rather than hidden; the
mitigation, if wanted later, is the kernel in a Web Worker with
`FileSystemSyncAccessHandle`, which `View`/`Event` being `serde` already
leaves open.

**Open is async on every target**; the shells await it before the first
frame (native awaits a ready future). **The lock** is a `Storage` concern:
the lock file with `/proc` liveness on native, `navigator.locks.request`
on the web, held for the session; a second tab opening the same document
is refused, as a second process is.

**Time and identity** on the web: `history::now()` reads
`js_sys::Date::now()` instead of answering the epoch; `jiff` gains its
`js` feature so the local zone is the browser's; `Identity` comes from
preferences (the profile name the egui shell already stores) with the
same "unattributed author" fallback.

**Resident memory** is the cost to state: at `TUNING.md` Finding 8's
121 KB per rev at 2,500 blocks, a thousand-rev history of a large diagram
is ~120 MB of JSON in the tab. Typical documents are far below that, and
the revs are held decoded (the encoding is one compressor everywhere,
see below), so the ceiling is memory, not decode time. If it binds, residency becomes a
window around the head with the time machine prefetching on hover — an
extension of the same enum, not a redesign.

### The web library

The egui shell's `Library` (documents on disk, recent list, born-under-
three-words containers, rename, projection refresh) becomes target-
independent over the same `Storage`, with the documents directory being the
OPFS root. `File ▸ New / Open / Rename / Delete` list and pick from OPFS;
`File ▸ Import / Export .bwx.zip` move a container in and out of the origin
(the transfer form the format doc already names). The `Effect` variants
that are `#[cfg(not(wasm32))]` today (`NewDocument`, `RenameDocument`,
`PickFile`, `OpenRecent`) lose their cfg and carry a `DocumentRef` (a
name, not a `PathBuf`) that each shell resolves against its own `Storage`.
The kernel's `title`/`window_title`/`document_name` wasm branches go with
them: whether there is a container is a fact of the `Doc`, not the target.

### The rev encoding: one compressor everywhere

Decided: revs are compressed with **`flate2` on its pure-Rust
`miniz_oxide` backend** on every target, replacing zstd. One `pack`/
`unpack` pair, no `cfg`, the same bytes in a container whether the web or
the desktop wrote it, so a `.bwx.zip` moves between them with nothing to
translate. The file becomes `revs/{rev:06}.json.gz` (gzip framing, so a
rev is still inspectable from a shell with `gzip -dc … | jq`).

This changes the native format. The project is undeployed, so the change
is a clean break: the store reads `.json.gz` only, `docs/json-format.md`
§14.2 and the table in §1 are rewritten for it, and a one-shot
`blockworx migrate <container>` subcommand (native, beside `log` and
`verify`) rewrites any existing container's `revs/` from zstd to gzip so
the developer's own documents survive. `zstd` leaves the tree; the
migration subcommand is the last thing that names it and goes with the
next release.

Cost to state: zstd −1 was chosen at 30 ms for 13.3 MB (`revs.rs`);
deflate at a comparable level is slower per byte (roughly two to four
times) and compresses a little less. At the sizes a rev actually has
(121 KB at 2,500 blocks) that is single-digit milliseconds per commit,
off the critical path on the web (the journal) and measured on native in
Phase 4 before the swap lands.

## 8. Effects the web performs itself

The `Effect` enum names what the shell does that the kernel cannot. On the
web:

| Effect | Performed by |
|---|---|
| `Accent`, `PinType` | popover pickers anchored to the overlay |
| `AddIcon`, `AddImage`, `Import` | a hidden `<input type="file" accept=".svg,.png">`; the bytes come back as `Action`s, as `Exchange::picked` does |
| `SaveProjection` | rewrite `document.json` in OPFS (real once the container is real) |
| `NewDocument`, `RenameDocument`, `PickFile`, `OpenRecent` | the web library (§7) |
| `Handoff::Export` | the Blob + `<a download>` helper the egui web build already has, moved into the backend |
| `Handoff::Clipboard` | `navigator.clipboard.writeText` |

## 9. What the egui shell keeps that the web does not

Native-only by nature and left alone: the theme and font *editor windows*
(`--theme-editor`, `--font-editor`), the `--trace` subscriber, the `log`
and `verify` subcommands, `rfd` dialogs. Nothing in the kernel or the
core crates is touched for them.

## 10. Phases

Each phase is a reviewable PR onto the branch; `cargo xtask ci` passes at
every step, with the new web steps added in Phase 1.

**Phase 1 — text and the backend.** First `blockworx-text`: the face
parsing, shaping and outline tracing lifted out of `export`, the `Shaper`
with its line breaker and cache, `Glyph.id`, the exporters and the kernel
tests moved onto it with **no golden change** (the desktop exports keep
`EpaintLayout`; only the tracer's home moves). Then `blockworx-canvas2d`:
`replay`, the grid, glyph fills from the `Outlines`, the image registry,
the input mapping as pure functions, the download helper.
`wasm-bindgen-test` in headless Chrome: a fixture `View` (the kernel's
own tests build these) replays without error; the `Shaper` is tested
natively against the row counts the egui backend's `measure` tests
assert. xtask: `cargo check` and clippy for both crates on
`wasm32-unknown-unknown`; the backend gate extended. *Exit: a recorded
frame draws on a canvas in a test page, text included, on all four faces;
the rendering at 100% and 50% zoom has been looked at (§12).*

**Phase 2 — the shell skeleton (`blockworx-web`).** The Dioxus app, the
canvas component, the frame loop of §4, the input wiring of §5, the
in-place editor, the cursor, export downloads, a placeholder chrome (tool
buttons and a status line) so the editor is usable. Scratch document only,
as the egui web build is today. *Exit: draw blocks, wire them, rename a
pin, undo, export SVG, in Chrome, Firefox and Safari.*

**Phase 3 — the chrome.** The regions of §6 built to the mockup, region
by region, `dioxus-primitives` where §6 says so, SSR snapshot tests,
preferences in `localStorage`, the light/dark selector driving both the
chrome's `dark` class and the diagram's palette.
*Exit: everything the egui shell offers over a scratch document, the web
offers.*

**Phase 4 — storage.** In order: `Storage` and the `Native` storage with the
store ported onto it and **no behaviour change** (the store's 1,500 lines
of tests prove it); the un-cfg of `Doc::Attached`, the effects and the
kernel's title code; the `Opfs` storage and the journal; the web library
and the `.bwx.zip` doors; the gzip rev encoding, the `migrate`
subcommand and the `json-format.md` rewrite of §7. *Exit: reload the
tab and the document is there; a container round-trips web → native →
web; multi-tab opens of one document are refused by the lock.*

**Phase 5 — touch, and closing out.** Two-pointer pan/pinch; `dx build
--release` in xtask, size measured; docs (the crate table in `CLAUDE.md`, this playbook marked done);
`todo.md`.

Phase 4 can run ahead of or alongside Phase 3 — it touches no shell code
above the library — and the ordering above only reflects that a usable
editor is the earlier proof.

## 11. Decisions taken at review

Settled on 2026-09-14, in the order they came up:

1. **Rendering** is Canvas2D; no GPU path at this scale.
2. **Chrome** is Tailwind with a simple light/dark selector; the base16
   palette is the diagram's only.
3. **Rev encoding** is one universal compressor everywhere (`flate2` on
   `miniz_oxide`, gzip framing), replacing zstd; the native format
   changes and a `migrate` subcommand carries existing containers over.
4. **Text** is shaped in Rust and drawn as glyph outlines through the new
   `blockworx-text` crate; no browser text engine on the diagram.
5. **The storage trait is `Storage`** (not "medium").
6. **Touch** is Phase 5.
7. **One store**: the native store is ported onto `Storage` in this
   series; no parallel web store.
8. **One document per tab**, the Web Lock refusing a second open, so two
   tabs can never edit one document concurrently.
9. **`dioxus-primitives`** for menus, popovers and dialogs.
10. **Preferences in `localStorage`**.

## 12. Risks

- **Outline text at small sizes**: unhinted fills read softer than
  native browser text. Phase 1 renders the fixture sheet at 100% and 50%
  zoom and we look at it before committing to the backend; the fallback
  is `fillText` with a registered `FontFace`, which keeps the `Shaper`
  for layout and changes only the ink.
- **OPFS availability**: secure context only (https or localhost);
  Safari's OPFS support is recent and its `FileSystemFileHandle.move()`
  is missing, so "rename" is copy-then-remove behind the `Opfs` storage.
- **Glyph fills per frame**: text is the heaviest op on a Canvas2D. If
  a dense diagram stalls, per-row bitmap caching is the next step; the
  display list already gives each row's rect, so the cache key is there.
- **The Dioxus learning curve** is real for both of us: the design keeps
  the surface small (one signal for the chrome, one cell for the batch,
  components that only read and raise) so that what is Dioxus-specific
  fits on a page.

## Worklog

### 2026-09-14 — Phase 1a: `blockworx-text`

`crates/text` is a new workspace member and the first half of Phase 1.
It owns the whole of §3's first bullet:

- **`Shaper`**, a `TextLayout` over `harfrust`: one parsed face per
  `FontChoice`, each paragraph shaped with the flags and options the SVG
  export shaped with, rows broken by epaint's own rules (the last whitespace
  that fits, then a dash, a punctuation mark, a CJK boundary, and finally any
  character at all), and a layout cache keyed by the run, the font size and
  the wrap width, cleared whole past four thousand entries.
- **`Outlines`**, glyph id → contours in font units as `PathCommand`s, traced
  once per glyph and kept.
- **`Metrics`**, the vertical metrics both are stated in — read through the
  shaper's own font reader, which follows the same rule epaint follows, so a
  row is the same height on both engines.

`blockworx_paint::text::Glyph` gained `id: Option<GlyphId>`: the `Shaper`
fills it, `EpaintLayout`/`ContextLayout` leave it `None`. `SvgRenderer` drops
its face, its font reference and its shaper data for the two types above, and
skips its re-shape when the layout carries identity — the epaint path keeps
it, so **no golden changed**. `ttf-parser`, `harfrust` and `skrifa` moved out
of `crates/export`.

The kernel's tests moved onto the `Shaper` whole: all 62 pass on its metrics,
and `crates/kernel` no longer dev-depends on `blockworx-egui`, so the
headless gate now checks it over `dev` too.

Two things §3 did not settle, decided here:

- **No pixel snapping.** epaint rounds a glyph's x, a row's height and a
  baseline to whole pixels (at one pixel per point, whole points). The
  `Shaper` keeps them continuous: Canvas2D places text at continuous
  coordinates and the CSS pixel the layout works in is not the device pixel
  the ink lands on. The two engines therefore differ by a fraction of a pixel
  per glyph, which is the per-host disparity the trait already states — and
  `crates/export/src/engine_tests.rs` pins the part that must *not* differ:
  both engines break the same runs into the same rows, in all four faces.
- **No font fallback.** epaint resolves a character its face cannot map
  against the rest of its family list; the `Shaper` has one face and lets the
  shaper's `.notdef` stand. The bundled faces cover the labels, and a second
  face would be a second set of metrics to keep in step.

### 2026-09-14 — Phase 1b: `blockworx-canvas2d`

`crates/canvas2d` is the second half of Phase 1 and the counterpart of
`blockworx-egui`: the only crate that knows both the paint vocabulary and the
DOM, and no more Dioxus than the egui backend knows eframe.

- **`replay(&[DrawOp], &CanvasRenderingContext2d, &Images, &Glyphs) -> Repaint`**
  — every variant of the display list, in CSS pixels, with a per-frame cache of
  the CSS strings its swatches are written as. A rounded rect goes through
  `roundRect` and falls back to four `arcTo`s; a stroke with no width or no
  colour, and a fill with no colour, are the same two nothings the egui
  backend's strokes collapse to.
- **Text is glyph outlines.** `Glyphs` is one value holding the `Shaper` the
  kernel is called with *and* the `Outlines` its ink is traced from, both built
  from one `FontChoice` — so §3's contract (the diagram draws the layout the
  recorder measured) is kept by construction rather than by two arguments
  agreeing. Each glyph is filled from a cached `Path2D` under a `translate` +
  `transform` of `[cos·s, sin·s, sin·s, −cos·s]`, the font's Y-up axis flipped
  and turned by the mark's angle. `RotatedText` reproduces epaint's anchored
  placement exactly — the run's top left at `pos`, turned about the run's own
  anchor point — so a vertical pin label lands where the desktop puts it.
- **Images** are `Blob` URLs decoded by the browser into an `HtmlImageElement`,
  with `onload`/`onerror` moving a shared cell through `Decoding → Ready |
  Failed`. A mark drawn while its ink is decoding draws nothing and answers
  `Repaint::Owed`.
- **The ground and the grid** moved to `blockworx_paint::ground`:
  `Ground`, `GridLine` and `verticals`/`horizontals`, an iterator of
  `(screen position, weight)` per axis over a `Vantage` and a viewport. Both
  backends read it; `blockworx_egui::CanvasChrome` is now a re-export of
  `Ground`, so the shell was untouched. The wheel's zoom rate went the same
  way, as `Factor::of_scroll(ScrollPx)` — each host states the sign of its own
  wheel and none of them states the rate — and `Camera` with it.
- **Input** is plain samples (`PointerSample`, `WheelSample`, `KeySample`,
  `Held`, `Shift`) and a `Reader` with no `web-sys` in it, tested natively: the
  four gesture tests at the bottom of `crates/egui/src/view.rs` are ported
  test for test, plus the middle-drag pan, the wheel zoom, the focus scoping
  and a press whose release was delivered elsewhere. `input::dom` is the one
  wasm-bound layer and `input::chord_of` maps a `KeyboardEvent` to a `Chord`.

Three things §2 and §5 did not settle, decided here:

- **A hash the registry does not hold owes no repaint.** A mark whose ink is
  still decoding does; one the front end never registered draws nothing and
  settles, because a dropped hand-off is a bug rather than a frame to wait for,
  and owing a frame for it would spin the loop of §4 forever.
- **The web pans on middle-drag and space+primary-drag only.** The egui
  backend also pans on a secondary drag; on the web that is the context menu,
  and §5's table lists the two gestures above. Right-drag stays a tool's.
- **Two pointers are a typed hole, not an implementation.** `Fingers::Several`
  latches from the second pointer down until the last lifts, and while it is
  latched the gesture is the camera's — no `Move` is emitted yet, but no tool
  reads the trailing finger as a drag either. Phase 5 fills in the pan and the
  pinch behind that latch.

The browser suite runs under `wasm-pack test --headless --chrome
crates/canvas2d`, wired into `cargo xtask ci`'s wasm step as `browser` and
skipped with a message when `wasm-pack` is absent. `wasm-pack` fetches a
`chromedriver` of its own and regularly picks the wrong one for the installed
Chrome; it prefers one on the `$PATH`, and the step passes `--chromedriver`
when `CHROMEDRIVER` is set.
### 2026-09-14 — Phase 4a: `Storage`, and one store over it

`crates/store/src/storage/` holds the trait of §7 and its first two
implementations: `Native` (what `atomic.rs`, `container.rs` and `lock.rs`
did with `std::fs`, moved) and `Memory` (a map, for tests on every
target), with `Any` boxing either so `Doc::Attached` holds one container
without its storage's kind reaching the types above it. The trait grew
four facts (`name`, `names`, `residency`, `disk_path`) and four
operations §7 did not name but the container has always performed
(`exists`, `create_dir`, `rename`, `discard`); `exists` is the one that
matters — a payload is filed under a content hash, so reading a megabyte
to learn it is already there would make every commit cost the artwork on
the page. Every method is `fn f<'a>(&'a self, …: &'a _) -> impl Future +
'a`, one lifetime rather than the elided two, so the boxed form is
expressible. Residency is one enum on the container: `Lazy` reads through
the storage as today, `Resident` reads the container in at open and keeps
that memory in step with every write — the shape the browser's
synchronous read path will stand on. The journal of §7 is **not** here:
`Native` drains inline because its futures are ready, and the queue only
earns its keep with a storage whose writes are not. `Doc::container_root`
became `container_name` (a `Name` the storage resolves) beside
`container_path` (the `Native`-only fact the window title and save-as
need); `AssetSource::names` answers a `String`.

### 2026-09-14 — Phase 4b: gzip revs, and `migrate`

`flate2`
on `miniz_oxide`, gzip framing, one `pack`/`unpack` pair with no `cfg`,
`revs/{rev:06}.json.gz`. **Level 1**, not the "comparable level" §7
guessed at: on the biggest fixture in the tree (2.8 MB of JSON) gzip −1
takes 3.7 ms to 292 KB where zstd −1 takes 1.9 ms to 124 KB, and gzip −2
takes 8.0 ms to 248 KB. Two to four times slower per byte was the
estimate and two is what it is; the seventh of the bytes level 2 buys is
not worth twice the encode on the tablet the wasm profile is already
tuned for.

What §7 did not foresee: **the rows stamp the compressed bytes**, not the
JSON. `Digest::of` is taken over what `revs::write` put on the shelf, so
re-compressing a rev changes its row's `hash`, and every row after it
links to that row. `migrate` therefore rewrites the manifest as well as
the revs — each row keeping every column it carried and getting the
`hash` its rev now has and the `parent` that follows, which is the chain
the same rows would have had if this build had written them — and then
refreshes `document.json` so its stamp names the head as it now stands.
The alternative, stamping the *JSON* so the encoding is not covered,
would have made the migration transparent to the rows only for the
*next* encoding change, not this one: existing containers stamp zstd
bytes either way. The manifest golden moved in `hash` and `parent` and
nothing else.

### 2026-09-14 — Phase 4c: the wrong cfg axis, retired

The
`Effect` variants the File menu raises, `blockworx_tools::file`, the
kernel's `title`/`window_title`/`document_name`, `TopBar::renaming` and
`Session::file_notices` are one body on every target. `Effect::OpenRecent`
carries a `storage::DocumentRef`: an opaque string a shell mints and
resolves against its own storage — a path on a desktop, a name in the
library in a browser — so "where the documents are" never becomes a fact
of the crates underneath. The egui shell's web build has no library yet,
so its `App::perform` routes all five library doors through one
`perform_in_library`, whose wasm arm logs and ignores; the native arm is
the same five calls it always made. Nothing else in `src/` moved.

### 2026-09-14 — Phase 2: the shell skeleton (`blockworx-web`)

`web/` is a package of its own — the root package *is* the egui shell —
built by `dx`, which compiles `tailwind.css` itself. The Dioxus-specific
surface is what §4 asked for and no more: one `use_hook` holding the
`Shell` (the `Session`, the `Glyphs`, the `Images`, the input `Reader`,
the canvas once it is mounted, and the bands the chrome covers), one
`Signal<Chrome>` the components bind, and components that only read it
and `say` an `Event`. The batch is a cell of its own, so a handler
firing mid-frame adds to the next one; `pacing` is the frame loop's
booking as a state machine over plain values (one animation frame or
one timer standing at a time, a timer overtaken by anything sooner),
tested natively with no browser in it. The chrome model is `View` minus
its diagram, compared before the signal is written, so a frame that only
panned re-renders no HTML. The canvas element's handlers turn DOM events
into the backend's samples and nothing else; the in-place editor is a
positioned `<input>`/`<textarea>` over the diagram, rotated by the
field's angle, in the same face as what it covers (the bundled faces are
registered with the browser for the field alone — the diagram never uses
them). The safe region is what the status band measures of itself, taken
off the edge it hugs; glass in the middle takes nothing.

Two things the exit walk found. **The keyboard did not come back**: a
field that closes leaves the focus on the body, where the canvas's
listeners hear nothing, so the next digit armed no tool — `ends` now
hands the keyboard back to the canvas. **Undo has no chord in the
table**: the desktop reads ⌘Z / ⌘⇧Z / ⌘Y as bare shortcuts in
`surface::handle_keyboard`, dispatching the step whatever the
writability and letting the history refuse what would author, with a
test asserting ⌘Z is *not* a binding. The web canvas reads the same three
the same way (`history_chord`). Two shells reading one policy in two
places is the drift §0 warns of; folding the three into `BINDINGS` would
change what the desktop's test asserts, so it is left as a decision for
review rather than taken here.

The exit criterion was walked in a real browser by a script over the
WebDriver wire (Chrome through `chromedriver`, Firefox through
Marionette): draw two blocks and title them, add a pin to each and name
it, wire them, take the route back with ⌘Z and the name with the bar's
Undo, export an SVG and read the download. Twelve checks in Chrome, nine
in Firefox; Safari is not on this machine. The placeholder bar gained an
"Export SVG" control so the export path has something to raise it —
`ExportContent` now names its own media type and extension, since PNG
content is SVG source until it is written and only the exporter knows
what the bytes will be. `cargo xtask ci`'s `wasm-web` step (the old
`wasm-backend`) clippies the shell for `wasm32` beside the backend, and
the `shell` gate's two allow-lists are one `BROWSER_CRATES` that names
it.

### 2026-09-14 — Undo and redo join the binding table

The question Phase 2 left for review was a distinction with no observable
difference: a bare `Action::Undo` and a `CommandId::Undo` taken through
the registry both end in `Session::step_history`, and the registry's
"withheld when it would author under a read-only lens" is the same rule
the session re-checks itself. The desktop kept its three shortcuts out
of `BINDINGS` because they predate the table, not by design. So ⌘Z,
⌘⇧Z and ⌘Y are three rows in `BINDINGS` now — `Key` gained `Z` and `Y`,
`Modifiers` a `CommandShift` — and both shells read them the way they
read every other chord: the desktop's `consume_binding` and the web
canvas's `commands.bound`. `surface::handle_keyboard` keeps only what
egui reports as events rather than keystrokes (paste, copy, the nudge);
the web's `history_chord` is gone. The palette and the bar's tooltips
now spell the undo chords, since they read chords off the table. On the
web, Shift counts as a modifier of a letter only — a shifted `=` arrives
as `+`, its own key, so the zoom bindings still read as they did.

### 2026-09-14 — Phase 4d: the journal and the resident open

Phase 4a landed `Storage` and deferred the two things a storage whose
futures are *not* ready needs. Both are here, target-independently —
nothing in the store names a browser. Every door the container has grew
its awaited twin and the synchronous one is now `ready_now` of it
(`Container::{creating, creating_holding, opening, opening_to_read,
renaming_to}`, `Store::{creating, seeding, opening, opening_to_read,
renaming_to}`), so the layout, the ordering and the verification are
written once and awaited once, and `Native` resolves the whole body in one
poll exactly as it did.

For a resident container a write reaches memory synchronously and the
storage later: `write`/`append`/`truncate` record `(Entry, Op)` in the
order the store makes them — the payload, the rev, the row that names it,
the projection, a torn tail's cut back — and `drain()` performs them one
at a time in that order. `pending()` is the depth a status line reads
"writing…" off. A write that does not land demotes the container with
`ReadOnlyReason::WriteFailed` and drops what it had not written, as a
failed native append parts a session from its files today.

The lock became a `Storage` hook — `claim` (awaited, defaulting to
today's lock entry plus the `/proc` probe) and `release` (synchronous,
because a container gives up its lock as it is dropped and a `Drop` cannot
await) — so a storage whose futures are not ready must keep its lock in
its host's lock manager rather than in an entry it could never remove.
`Claim::Held` and `ReadOnlyReason::Locked` therefore carry `lock::Holding`
rather than a `LockHolder`: not every lock names a process, and a
browser's names none. `Memory` became a handle on a map (`Clone`; two
handles are one container's bytes) and gained the browser's facts as knobs
— `deferred`, `refusing` — plus `reads()` and `wrote()`, so the journal is
driven natively; `fixture::block_on` is the bounded spin that drives it.

### 2026-09-14 — Phase 4d: `blockworx-opfs`, the container in the origin

`crates/opfs/` is the `Storage` of §7 over a `FileSystemDirectoryHandle`,
and nothing more: the `.bwx` layout stays in the store, which is what
keeps the `headless` gate true. Two values — `Root` over
`navigator.storage.getDirectory()` (`open`, `container(&Name, Missing)`,
`containers`, `holds`, `remove`) and `Opfs`, one container's directory.
`Root::containers` counts a directory holding a `manifest.jsonl` as a
container, so a folder something else in the origin made is never offered
as a document; it answers in name order, since the origin's own is
unspecified.

Four things the web made us choose:

- **Every write is a fresh writable, closed.** The browser puts a
  writable's swap file in place only at `close()`, which is what gives
  `write` its whole-file-or-nothing. `append` is the same stream opened
  with `keepExistingData`, seeked to the file's current size — which is
  how the manifest grows — and `truncate` is that stream's own `truncate`.
- **Rename is copy-then-remove.** `FileSystemHandle.move()` is Chromium's
  alone, so the bytes travel (a work queue over the handles, no depth
  assumed) and the old name is removed behind them. A name something
  already stands under is refused with `AlreadyExists`, as `Native` does.
- **The lock is the origin's lock manager**, held as a promise nobody has
  resolved, released by resolving it in one synchronous call — which is
  what `Storage::release` requires. A refusal is `Holding::Elsewhere`:
  the manager says a lock is held and never who holds it. The lock is
  named after the container, so a rename takes the new name's lock as
  well as the new name, both before any bytes move, and drops the old
  lock last. A view that has claimed a name and not yet laid its
  directory down holds that lock and nothing else — the whole of what a
  new document is until it is saved — so a rename onto it is refused with
  `AlreadyExists` and the source is left where it was, still held.
- **`residency()` is `Resident`.** Every future here is real, so the
  store reads the container in at open and owes its writes back.

**How the shell drives the journal.** After each frame: if
`Doc::pending() > 0` and no drain is in flight, `spawn_local` one
`store.drain()` and mark one in flight; clear the mark when it answers.
One at a time and never two, because `drain` performs what is owed in the
order it was owed and two drains would interleave a rev with the row that
names it. A drain that fails has already demoted the container
(`ReadOnlyReason::WriteFailed`) and dropped what it had not written, so
the shell has nothing to retry — it shows the reason on the status line,
which it reads off `read_only_reason()` as it does every other one.
`pending()` is "writing…" while it is non-zero. The loss window §7 names
is real and unchanged: a tab killed between a commit and its drain loses
that commit.

**Browsers.** Verified in headless Chrome — the six-test suite in
`crates/opfs/tests/browser.rs` is a CI step (`wasm-pack test --headless
--chrome`, beside the backend's), covering a container created through
`Store::creating`, drained, dropped and reopened through `Store::opening`
with its document and rows intact; the manifest's append, truncate,
whole-file write and nested-entry semantics through the storage; a read of
an entry nothing wrote answering `None`; rename carrying a nested entry
and its lock; a rename onto a name another view holds moving nothing; and
one container held by one view at a time, granted again once the first
view is dropped. Firefox has OPFS, `createWritable` and Web
Locks and is expected to work, but was not run here. **Safari does not**:
it has OPFS, but `createWritable` exists only inside a Worker
(`createSyncAccessHandle`), and every write here is made of one. The way
in for Safari is the kernel-in-a-worker of §7, not a second storage.

**What this does not build**, and what it hands whoever does: the web
library stands on `Root` — `containers()` is `File ▸ Open`'s list,
`holds()` is what a new name and a rename box ask, `container(&name,
Missing::IsMade)` then `Store::creating` is `File ▸ New`,
`container(&name, Missing::IsNothing)` then `Store::opening` is the open,
and `Root::remove` is `File ▸ Delete` (`Opfs::discard` is the store's own
and takes only an emptied container). The `.bwx.zip` doors, the File menu,
identity from preferences and the drain loop itself are still ahead.
### 2026-09-14 — Phase 3a: the chrome

The regions of §6, built to the mockup's metrics and the spec's behaviours,
over the same `Signal<Chrome>` Phase 2 left. What landed:

- **The strip** (`top_bar.rs`) is §2.0's grammar and nothing else: left is
  the document menu, the liveness dot and the breadcrumb; the centre is
  empty unless a mode is active; the right is undo, redo, up, fit and
  preferences. The dot's colour and its words come from one resolver, so
  they cannot disagree. The breadcrumb's root segment is the document's
  name, renamed where it stands — a shell-local draft opened by a double
  click, committed on Enter or on losing focus, cancelled by Escape, raised
  as `Effect::RenameDocument`. Both gestures live on one word, so a single
  click arms the rise and a second one cancels it, which is the desktop's
  rule written for a browser's clock.
- **The rail** (`tool_cluster.rs`) is the eight band tools in the band's own
  order, Select behind a rule at the top, the armed cell filled, a withheld
  cell drawn dead. Tapping the armed cell returns to Select — so a cell is
  *labelled* what it stands for and *raises* what pressing it means, which
  is what `Press`'s `raises` is for.
- **The status line** (`status_line.rs`) is §2.0.1's four states in one
  priority (`Says`), the title block under it, and a liveness slot: `Owed`,
  a typed hole the journal will knock on, beside `Liveness::Writing`.
- **The notices strip and the toast** (`notices.rs`): standing facts stand,
  failures carry a Dismiss that raises `Action::AcknowledgeFailure` by
  position *among failures*, and what the shell itself could not do — an
  export the browser refused, a clipboard that would not take a copy —
  toasts for 2.4 s rather than joining the document's notices.
- **`.viewing`** is one `data-viewing` attribute on the root that the strip,
  the rail and the canvas vary on through Tailwind's `group-data-*`
  variants: amber wash, inert rail, desaturated diagram, the centre slot
  filled. One attribute, five regions, no second opinion.
- **Preferences** (`prefs.rs`, `settings.rs`) are one JSON blob in
  `localStorage` behind a `Store` that answers the defaults where the
  browser refuses one. Mode, the base16 scheme (the diagram's only), the
  face and the profile name; `Mode::System` follows `prefers-color-scheme`
  through a listener, not a reading taken once at startup. One reading of
  the axis sets the `dark` class *and* the `Action::SetPalette`, so the
  chrome and the diagram can never disagree, and the profile reaches
  `Session::identity` live — the identity is stamped on the next commit, so
  nothing needs restarting.

Six things §6 did not settle, decided here:

- **Tooltips are the browser's `title`, not a primitive.** §6 lists tooltips
  among what `dioxus-primitives` should carry, but a withheld control must
  still say *why* it is withheld (invariant 8, and the desktop's
  `on_disabled_hover_text`), and a disabled button fires no pointer events
  for a JavaScript tooltip to hear. The browser's own shows there. The
  primitives are used where the keyboard and focus work is real: the
  document menu (`DropdownMenu`) and the preferences sheet (`Popover`).
- **The vendored wrappers `dx components add` writes are not kept.** It
  installs a CSS-module layer and a `dx-components-theme.css` — a second
  design system, which §6 says the primitives must not bring. The
  dependency it adds is kept and the unstyled primitives are consumed
  directly, wearing the same Tailwind classes as the rest.
- **Three policies moved below the shell**, because two front ends printing
  one rule is the drift §0 warns of: `blockworx_tools::history::consequence`
  (the sentence invariant 8 asks an undo button to carry, with `Direction`
  and `Offered` beside it), `ScopePath::{here, collapsed, up_to}` with
  `Crumb` (the breadcrumb's middle collapse and what a segment's click
  asks for), and the icon set — `web/src/icons.rs` `include_str!`s the very
  files the desktop's buttons are drawn from, re-inking the bundled white
  stroke to `currentColor`. The egui shell's own tests for the two moved
  functions moved with them; no string changed.
- **The strip is a docked sibling of the diagram, not glass over it.** The
  mockup absolutely-positions it; a flex row above the canvas is the same
  inset with no arithmetic, and it keeps the docked elevation rule (flat,
  square, no shadow) honest. Only the rail, the status line and the notices
  measure themselves — `Shell::covers(Band, Rect)` now takes one reading per
  band, and the rail reports its box *grown to the canvas's left edge*,
  since the gutter beside it is not room a framing may use.
- **The web states its own sheet.** Phase 2 never told the session a
  `Sheet`, so the document had no name: the breadcrumb's root was blank and
  an export was called `svg.svg`. `Editor::states_the_sheet` runs before
  every call, as `App::state_the_sheet` does on the desktop, and the
  document is `untitled` until a library names it.
- **`Preferences` is the web's own struct.** The shell gate keeps the two
  shells out of each other's trees, so the four-field container and its
  storage cannot be shared; what *is* shared is everything inside it
  (`Mode`, `Scheme`, `FontChoice`, `Identity`). If a third consumer appears,
  the container belongs in `paint` beside the types it holds.

Two things found on the way. `expect-test` resolves a snapshot's source file
by walking up to the **outermost** directory holding a `Cargo.toml`, which
leaves the tree entirely when the workspace is checked out inside another one
— it would have written into a sibling checkout. `.cargo/config.toml` now
names `CARGO_WORKSPACE_DIR` outright, which is the documented way round it.
And every `web_sys::window()` call went behind one `shell::window()` that is
`None` off wasm: a JS import called natively panics rather than answering, and
the SSR snapshots render the chrome with no browser under it at all.

The Phase 2 walk-through still passes: twelve checks in Chrome, nine in
Firefox. Two selectors in the driving scripts moved with the chrome — the
bar's Undo is found by `button[data-cmd=undo]` (its `title` is now the
consequence sentence) and Export SVG lives in the document menu, so the
script opens that first and clicks `[data-cmd=export-svg]`. Every control
carries `data-cmd` with the command's stable name, which is the handle a
script or a snapshot should use.

Still the next agent's, and named here so nothing is assumed built: the
navigator (the block tree and the history panel), the selection overlay and
its pickers, the command palette, the file menu and the web library, the
file inputs for images, and touch.

### 2026-09-14 — Phase 4d: the web library, and the journal the shell drives

The origin's storage, reached by a shell. `web/src/library.rs` is §7's web
library over `Root`: the born name, the recent list, open, rename, delete,
and the two `.bwx.zip` doors. The File menu the Phase 3a strip left is now
the library's — New, the containers the origin holds, Import .bwx.zip,
Export .bwx.zip, Rename, Delete — above the formats a drawing leaves in.
A tab opens on the document it last had, falls back to a newborn, and falls
back again to a scratch session with a standing notice where the origin
refuses its storage (every page that is not a secure context).

**What the borrow made us design.** §7 said the shell would `spawn_local` a
`store.drain()` after each frame. It cannot: `drain` wants the document
exclusively across a real `await`, and the frame that shares the shell's one
`RefCell` would find it borrowed — a panic, not a stall, since a pointer
move fires during every OPFS write. So a door **takes the document out of
the session** for as long as it awaits (`Shell::opens_a_door`), a frame
while one is open stands off and re-books rather than reading a document
that is not there, and the door hands it back and books the frame that
reads it. The journal is one of these doors, so §7's "one at a time and
never two" is the same `away` flag every other door takes, rather than a
rule about drains alone. What it costs is the frame after a commit lands:
a burst is two or three origin writes, single-digit milliseconds. The
alternative — making `drain` take `&self` — does not help, because the
frame wants `&mut` and a `RefCell` refuses the pair either way; the
alternative that would is a second handle on the container, which is the
journal written twice.

**Three things moved below the shells**, because the desktop and the browser
must not disagree about them:

- `naming::candidates` — the same three words, the same order, the same
  bound, for a host that has to *ask* its storage whether a name is taken
  rather than look. `Documents::create` walks it too.
- `recent::{remember, forget}` — newest first, mentioned once, bounded at
  eight. Two functions rather than a list type: the two shells hold the
  list in the two spellings their hosts remember a document by.
- `projection::{SETTLE, refreshed}` — the stale projection's settle, which
  the web needed the moment it had a container: the liveness dot reads
  `Freshness` and would otherwise say "writing…" for the life of the tab.
  `Session::opens` joined them — the desktop's "adopt and stand the editor
  back up" (scope, derived state, gesture, tool, step stack, notices, fit),
  which `src/library.rs` now calls instead of spelling out.

`transfer::{pack, unpack}` is the `.bwx.zip` of §7, over `Storage` and so
target-independent: entries stored rather than deflated (a rev is already
gzip), the lock left behind, and an archive that holds no manifest or names
an entry outside the layout refused rather than unpacked. `docs/json-format.md`
gained the form; it is *not* the share bundle P5 removed, which carried a
projection alone. The dependency is `zip` 8.6 with `deflate-flate2` and
nothing else — pure Rust on the `miniz_oxide` the store already compresses
revs with, and it builds for `wasm32-unknown-unknown` unchanged.

`naming::entropy` now draws through `getrandom` rather than `RandomState`,
whose seed on `wasm32-unknown-unknown` is a **constant**: every tab in every
browser was drawing `keen-blossom-badger` and then walking the same list
behind it.

**Browsers.** Chrome 149 and Firefox 143, headless, over WebDriver. The
storage walk (`drive_storage.py`): born under a three-word name, a block
drawn and titled, the journal drained, the tab reloaded and the document and
its rev still there, the File menu offering what the origin holds, and a
`.bwx.zip` downloaded whose entries are the manifest and the revs and not the
lock — 12 checks, all passing. A **second tab of the same browser** opens the
same document, is refused the Web Lock, and lands read-only saying so: 5
checks in Chrome, and the same walk in Firefox (`ff_storage.py`) passes its
5. The Phase 2 walk-through still passes twelve checks in Chrome. In Firefox
it passes eight of nine: the SVG download does not land, and neither does a
plain `<a download>` blob click made from the same page — a download setting
of this machine's headless Firefox rather than anything the app does.
`crates/opfs/tests/browser.rs` gained a seventh test, the `.bwx.zip` round
trip through real origin-private storage, and the second-view test now
asserts the reason is `Locked` rather than merely that there is one.

**What this does not build**: Safari (no `createWritable` outside a worker,
as Phase 4d's storage entry says), the sweep of unclaimed born containers the
desktop does on exit — a tab that is closed leaves its newborn behind — and
the `Effect`s the pickers and the image inputs own.
### 2026-09-14 — Phase 3b: the navigator, the overlay, the palette

The four regions of §6 that are *summoned* rather than persistent. Each one
is a policy the egui shell already encodes, so each moved below the shells
first and both front ends now read the one answer (the moves are listed at
the end).

- **The navigator** (`sheet.rs`) is §8's one component with two segments,
  overlaying from the right edge and never docking. **Parts** is the block
  tree: a twisty, an accent dot, a leaf count on a closed branch, a `»` that
  re-roots, and a filter that flattens the whole document to its matches with
  each one's ancestry beneath it. **History** is the log newest-first under a
  day heading each, an initials avatar per author, tag chips that narrow the
  search, and the viewed rev expanded in place into the card that holds the
  tag editor. Every pick completes a hand-off and leaves the panel open;
  only *working* — a press on the diagram, a tool pick, or Escape — dismisses
  it. It is the first band to report a rect that is sometimes empty: a shut
  panel covers nothing, so the diagram gets its right edge back the moment it
  closes.
- **The selection overlay** (`overlay.rs`) is the bar the kernel now places:
  the verbs in precedence order, five in the row and the rest behind one
  ellipsis, the count for a multi-selection, and the sentence a selection
  with nothing to do says out loud. The accent cell wears the selection's own
  colour as a bar under its glyph and the I/O cell carries the pins'
  direction in its words, both read off `Overlay` rather than re-derived.
  A withheld verb keeps its place, drawn dead, with the reason in its
  `title`.
- **The pickers** are popovers over the bar's top right, built from the
  registry's own `ACCENTS` and `PIN_DIRS`, so a swatch sets exactly what a
  script naming `accent-3` sets. Each has a backdrop, which is what makes a
  click away a cancel with no frame's delay — the press that opened it is
  over by the time the backdrop exists. Unlike the desktop's, the sheet is
  clamped into the safe region, so a bar near the top of the diagram does not
  push its picker off the page.
- **The palette** (`palette.rs`) is §9's ⌘K dialog over
  `blockworx_kernel::palette`: the same `nucleo-matcher` ranking, the same
  twelve-row cut, the same grouping by source. Rows carry `data-row` with a
  handle that is the row's identity rather than its wording.
- **The file inputs** (`exchange.rs`) are one hidden
  `<input type="file" accept=".svg,.png">` that three effects click, with
  what the pick was *for* held beside it — so a pick that took a while still
  lands where the press that asked for it meant it to. What the bytes mean is
  `blockworx_editor::import`'s and the size a document accepts is the
  kernel's; neither is decided in the shell.

Five things the plan did not settle, decided here:

- **`kernel()` hands effects back.** A control that raises `Event::Command`
  said everything a control has to say — the command's name — but the kernel
  *swallowed* the `Act::Effect` it resolved to, logging "no surface to
  perform" and going on. The desktop never noticed because it takes commands
  out of the `CommandSet` itself and performs them there. `View` grew
  `effects: Vec<Effect>`, filled from that same loop, and the web shell
  performs them after the frame's borrow is given up. Without it the accent
  and I/O pickers could never have opened at all.
- **The chrome model carries the whole registry**, not only what draws a
  control. `Face` gained `rendered`, and `Commands::of` reads `iter_all()`.
  A chord bound to a by-name-only command — which ⌘K now is — has to resolve
  on a front end that holds the registry as a model, and the palette lists
  only what a press would actually reach.
- **⌘K is a row of the binding table**, as undo and redo became. It needed
  `CommandId::Search` and `Effect::Search`, since *whether the palette is up*
  is the shell's and not the session's; both shells toggle on the effect.
  **One behaviour change**: on the desktop the chord used to be consumed in
  place and toggled whatever had the keyboard, so ⌘K shut an open palette.
  It now goes through the binding table, which is gated on no text field
  wanting the keyboard — so ⌘K opens the palette and Escape or a click away
  closes it. §9 calls the palette transient and dismissed on selection, so
  the one door out is the one it already had.
- **The withheld reason is resolved in the chrome**, not asked of the
  registry. `Command::withheld()` is a bare fact with no payload, and the two
  causes — a read-only container, an earlier rev on the canvas — are facts
  the model already carries. `Withholding::of(viewing, writable)` is the one
  resolver, so a dead control and the liveness dot cannot give two accounts
  of the same session.
- **Hooks run before any return.** Both new components first returned early —
  no selection, palette shut — and took their hooks afterwards. Dioxus
  matches hooks by position, so the render where the region appeared handed
  each hook the previous render's neighbour's state: the palette drew, took
  a query, and dispatched nothing. The rule is the framework's and the fix is
  mechanical, but it fails silently and is worth naming.

**What moved below the shells**, with the egui tests that covered it:

| What | To | Why |
|---|---|---|
| the palette's rows, scoring, cut and grouping | `blockworx_kernel::palette` | 8 tests; `nucleo-matcher` left the egui shell's tree |
| the tree's flattening, filter, reveal and focus | `blockworx_kernel::nav` | 11 tests; all three of §8.2's mechanisms change *which rows exist* |
| the bar's order, its row/overflow cut, `place`, `clamp` | `blockworx_kernel::bar` | 8 tests, including the sweep proving the bar never covers the selection |
| which verbs are on the bar | `blockworx_tools::commands::in_overlay` | it was defined as "does an egui icon exist for it" |
| the accent and I/O cell lists | `ACCENTS`/`PIN_DIRS`, made public | three copies of the nine accents, two of the three directions |
| `said`/`undone` (a rev's wording), `avatar_role` | `store::history`, `paint::theme` | the two shells name one commit one way |

Costs to state. `PIN_DIRS` ran Input, Output, In/out and the picker drew
Input, Input Output, Output; the picker's order and words won, which changes
the label `io-in-out` carries in a script's error message and nothing a
reader sees. The tree's keyboard (arrows, Home/End, Space, Enter) is the
desktop's alone — the web rows are buttons the browser already tabs through,
and a second key handler over a scrolling list is worth having a reason for.
The overlay's right-click menu is likewise not built: the mockup's context
menu is a pointer-platform duplicate of the bar, and §3.6 says it may never
extend the set, so it adds no reach.

**Browsers.** The Phase 2 walk-through still passes in Chrome — twelve checks
— and the script gained fourteen more for these regions: the panel opens and
lists the document's blocks, a tree row selects on the canvas and leaves the
panel open, the history lists Current above the revs behind it, a press on
the diagram dismisses the panel, the bar's accent control opens a picker of
the registry's nine cells, a swatch recolours the block and shuts the picker,
and ⌘K opens the palette, ranks `fit` first for `fit`, and runs the
highlighted row. Twenty-six in all. Firefox runs eight of its nine: the ninth
is the export landing on disk, which this machine's Firefox drops with no
refusal reported — the export path is byte-identical to Phase 3a, so it is
the profile's download handling rather than the shell's.

Still the next agent's: the file menu and the web library (`File ▸ New /
Open / Rename / Delete / Import / Export .bwx.zip`, the OPFS open at startup
and the journal drain loop), and touch.

### 2026-09-14 — Phase 5: touch, the release bundle, and closing out

The last of it: the gesture a touch screen navigates with, the bundle
measured, the cleanup a browser cannot run on the way out, and the
walk-throughs brought into the repo.

- **Touch** is `Span` in the backend's `Reader`: the two fingers as one
  value — the point between them and how far apart they are — and
  `Span::since`, which is the whole rule. The pan is the centre's motion,
  the zoom the ratio of the separation, anchored where the fingers now
  are; that is what egui's `multi_touch()` hands the desktop, written
  once here as a pure function over samples. Five tests state it in
  arithmetic: fingers carrying a step pan by exactly it and do not zoom,
  and fingers drawn from 200px to 400px apart double the zoom about the
  point between them. **One behaviour change**: a second finger landing
  now raises `Raw::Cancelled`, so the tool holding the first one's
  gesture is told it is over rather than left to find out. One finger is
  the mouse, unchanged.
- **The release bundle** is `cargo xtask web build`, which reports what
  the bundle weighs file by file and gzipped — and that report is what
  caught the one thing `dx` gets wrong here. It asks rustc for DWARF and
  then its own wasm-opt aborts parsing it ("compile unit size was
  incorrect"), reports the build a success, and leaves an unoptimized
  module behind. `strip = "debuginfo"` on the `wasm-release` profile is
  the fix — symbols stay, so wasm-opt still finds the `target_features`
  section that tells it the module uses bulk memory. **9.80 MB → 7.94 MB
  (3.14 MB gzipped)**; the whole bundle is 8.04 MB, 3.16 MB gzipped,
  which is the wasm plus 62 KB of wasm-bindgen glue and 37 KB of
  Tailwind.
- **The egui web build is retired from xtask.** The Dioxus shell is the
  web front end, the egui web bundle was only ever scratch, and two ways
  to build one app for one host is the fork that drifts. `web
  setup/build/serve` drive `dx` against `blockworx-web`; the root
  `index.html` that configured trunk is gone. The `wasm` CI step stays,
  because the desktop shell still carries a `cfg(target_arch =
  "wasm32")` arm at every door a browser has not got and nothing else
  compiles them — removing those arms is a sweep of its own.
- **The unclaimed-newborn sweep** the desktop runs on the way out cannot
  run in a tab that is closing, so the web asks the question the next
  time one opens, before the library lists anything. What the desktop
  narrows by — "this session made it" — the origin's lock stands in for:
  a container another view holds is one that tab still has.
  `discard_pristine` went `async` (the desktop resolves it with
  `ready_now`, as it resolves every other read of a `Native`) and
  `discard_unclaimed` is it with the lock in front, written once in the
  store and consumed by the web library. Tested over `Memory::deferred`
  — a lock of its own, futures that answer `Pending` first, which is the
  browser's facts without a browser — and in the origin itself in the
  `blockworx-opfs` browser suite. The sweep runs *after* the tab takes
  the document it stands on, because the lock it takes on the way in is
  what keeps the sweep off it.
- **The walk-throughs live in `web/walk/`** with a README saying what
  they need. One WebDriver harness (`driver.py`, urllib and nothing
  else), one Chrome script of three walks — the editor and its chrome,
  storage, touch — each in a session of its own so each starts on an
  origin of its own, and one Firefox script over Marionette's raw
  socket. `cargo xtask web walk` builds the bundle, serves it on a free
  port, starts chromedriver, runs both and prints their lines; it is not
  part of `cargo xtask ci`, because it drives real browsers over real
  timings, and a machine with no browser or driver is told so and
  passes.

Two things the merge of the scratch scripts taught. The status line says
**one** thing at a time — a confirmation for two seconds, then the armed
tool's instruction, then the selection, and only then the zoom — so a
walk that wants to read the zoom has to get the line to fall all the way
back first; `Chrome.zoom()` waits for it. And every check that reads the
DOM after pressing something in the chrome has to let the frame it asked
for land: three checks that had passed in the scratch script failed in
the merge until every chrome press went through one `press()` that
waits.

**Browsers.** Chrome: 48 checks, all passing — 26 for the editor and its
chrome, 16 for storage, 6 for touch, of which the two that state the
arithmetic land exactly (the selection bar moves 143 → 263 for a 120px
two-finger drag, and the zoom reads 100% → 200% for fingers drawn from
200px to 400px apart). Firefox: 9 of 9, the export landing on disk this
time, which is the one the Phase 3b run lost to the profile's download
handling.
