From bb5a7aff22be6cf47f808f3d65e597869ba30486 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 27 Sep 2026 11:13:08 -0700 Subject: [PATCH] docs: AGENTS.md and arc/ architecture essays A short AGENTS.md maps the crates, the rules that matter (edits are ops that append one revision, write what the spec and OneNote write, OneNote 2010 is the reference) and the sync basics, and links into arc/: the file format, sync and SMB locking, the canvas editor, the UI kit, platforms, and why the tests are trusted. The repo gitignore keeps AGENTS.md despite the global ignore and skips nested target/ directories. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- .gitignore | 5 ++ AGENTS.md | 92 +++++++++++++++++++++ arc/canvas.md | 115 ++++++++++++++++++++++++++ arc/file-format.md | 183 ++++++++++++++++++++++++++++++++++++++++ arc/platforms.md | 98 ++++++++++++++++++++++ arc/sync.md | 184 +++++++++++++++++++++++++++++++++++++++++ arc/testing.md | 147 ++++++++++++++++++++++++++++++++ arc/ui.md | 127 ++++++++++++++++++++++++++++ tools/canvas/README.md | 2 +- 9 files changed, 952 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 arc/canvas.md create mode 100644 arc/file-format.md create mode 100644 arc/platforms.md create mode 100644 arc/sync.md create mode 100644 arc/testing.md create mode 100644 arc/ui.md diff --git a/.gitignore b/.gitignore index 4e56d3d96531f5887599137d3cee8b604d37dabb..19a307f96fbafcca1a913a7d9fb1c406245f7467 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ __pycache__/ /tools/w7/payload/vendor/ /resources/ /.env + +# The global ignore excludes AGENTS.md; this repo keeps its own. +!AGENTS.md +# Build output of nested crates outside the workspace. +**/target/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..cf23b190baa7f976ebe28de8ad2bda5cac609719 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Snowbound + +Snowbound is a modern remake of OneNote 2010 that stays fully interoperable +with it. Notes are rich text on a free canvas, stored in OneNote's own `.one` +files, so Snowbound and OneNote 2010 can open the same notebook side by side. +A notebook is just a folder. Put it on an SMB share and several people can +edit it together from either app, with no cloud service involved. Snowbound +runs on macOS and Linux, and has an iOS app built around the same core. + +## Crates + +| Path | Owns | Depends on | +| --- | --- | --- | +| `crates/onestore` | The file format: revision stores (`.one`, `.onetoc2`), the page model, ops, the commit protocol. No network, no SQLite, no `unsafe`. | none | +| `crates/notebook` | From editor to disk or share: discovery, notebook structure, sessions, the SQLite replica, sync and merging, conflict pages, the embedded SMB client (feature `smb`). | onestore | +| `crates/draw` | The wgpu renderer that page and chrome both paint through, and the text-editing core (keys, chords, carets) they share. | none | +| `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw | +| `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw | +| `crates/snowbound` | The desktop app: winit window, platform glue, sidebar, menus, templates. | all of the above | +| `crates/mobile`, `apps/ios` | A C surface over canvas and notebook, and the UIKit app built on it. | canvas, notebook, and below | +| `tools/`, `corpus/`, `fuzz/` | Lab harnesses (Python, PowerShell, AutoHotkey), the corpus of OneNote-verified files, and a separate cargo-fuzz workspace. | | + +Dependencies point from the apps toward the format, never back. The format +crate never learns about storage engines, networks or pixels. `canvas` emits +ops but never touches storage. `ui` never sees a notebook. The hosts are where +these pieces meet. + +## Rules that matter most + +- **Edits are ops, and each edit appends one revision.** `onestore` and + `notebook` never recreate a whole page or section from a model. Only creating + a section or page, and opening a file, handle whole images. A design that + diffs whole pages to save them is the wrong design. +- **Write what the spec requires and what OneNote writes; read tolerantly.** + Emit every property MS-ONESTORE and MS-ONE require (even where OneNote + tolerates an omission), plus what OneNote itself stores. Readers accept + whatever is out there. +- **OneNote 2010 is the reference.** When behaviour is in question, observe it + in the Windows 7 lab (`tools/w7`) before deciding. A storage feature is done + when a file Snowbound wrote cold-opens in a fresh OneNote and reads back as + intended ([testing](arc/testing.md)). +- **Jujutsu, not git.** The repository is managed with `jj`. Don't run `git`. +- **Disposable copies only.** Never point the app, a replay script or a lab + harness at an original notebook. + +## Sync in one breath + +A `.one` file is a revision store. Every client, OneNote included, commits by +appending a revision and then rewriting the 1024-byte header, which makes the +header plus the file length a cheap *stamp* of the committed state. Snowbound +queues edits as ops in a local SQLite replica. A background thread polls the +stamp. If the stamp is unchanged, it publishes the queued batch as one +appended revision. If it has changed, it reads the file once and replays the +queue on top. Where an op can't merge, the result is what OneNote 2010 makes: +a read-only conflict page under the page. OneNote coordinates writers through +share modes and one-byte locks on the section file itself, and macOS's +`smbfs` can't reproduce those, so Snowbound carries its own SMB client that +takes exactly OneNote's locks. Offline is just a sync step that fails: the +queue waits and publishes later. [More in the sync essay.](arc/sync.md) + +## Architecture notes + +- [The file format and `onestore`](arc/file-format.md): revision stores, + object spaces, transactions, and what "append one revision" means. +- [The data layer and sync](arc/sync.md): ops, sessions, the replica, + publishing, merging, conflict pages, SMB and locking, offline. +- [The page: editor and canvas](arc/canvas.md): layout, OneNote-faithful + geometry, the page view, accessibility. +- [The interface kit and the renderer](arc/ui.md): immediate mode, + custom drawing, the OneNote shell and its motion. +- [Platforms](arc/platforms.md): macOS, Linux, and iOS with UIKit around the + canvas. +- [Testing, and why the code can be trusted](arc/testing.md): the native + lab, oracles, failure injection, fuzzing, and the two test tiers. + +The crate READMEs ([onestore](crates/onestore/README.md), +[notebook](crates/notebook/README.md)) are the API references. +[tools/TESTING.md](tools/TESTING.md) lists the test lanes. + +## Working here + +```sh +cargo test --workspace --all-features +cargo clippy --workspace --all-targets --all-features -- -D warnings +python3 -m unittest discover -s tools -p 'test_*.py' # VM-free corpus gates (needs Pillow) +python3 tools/check_public.py /absolute/new/results # everything above, from a clean checkout +python3 tools/canvas/build_macos.py && target/Snowbound.app/Contents/MacOS/Snowbound --notebook COPY +``` + +The MS-ONESTORE and MS-ONE specifications are Microsoft Open Specifications. +Local copies, design history and raw lab evidence are kept out of version +control. diff --git a/arc/canvas.md b/arc/canvas.md new file mode 100644 index 0000000000000000000000000000000000000000..9de1c45ed7206fa5502f03da903cc477c666d908 --- /dev/null +++ b/arc/canvas.md @@ -0,0 +1,115 @@ +# The page: editor and canvas + +A OneNote page is a free canvas, but it doesn't feel like a drawing app. You +click anywhere and type, and a text box (an *outline*) appears and grows as you +write. Outlines wrap, lists indent, tags hang in the margin. `canvas` is the +crate that makes a page behave that way and look the way OneNote 2010 draws +it. It depends on `onestore`'s page model and on `draw`. It knows nothing +about storage, windows or the interface kit, which is why the same page runs +inside the desktop app and inside a UIKit view on iOS. + +## Layers + +```text +onestore::page::Page stored identities, formatting, unknown content kept aside + │ +canvas::document text outlines as paragraphs of rich text, UTF-16 positions +canvas::editor CanvasEditor: edits, selection, composition, undo, the ops each edit lowers to +canvas::layout, outline shaping (parley), Windows line metrics, outline and table geometry + │ +canvas::gpu::page PageScene: the page as draw primitives; pictures decoded off-thread +canvas::interaction PageView: hit layers, drags, grid, handles, key routing, scroll, zoom, AccessKit + │ +host translates platform events in; carries out the Requests that come back +``` + +A host feeds `PageView` pointer, key and text-input events. It gets back a +`Response` saying whether the page, the selection or only the view changed, +plus the occasional `Request` the page can't do itself: show a date picker, +read the clipboard, open the character palette. Everything platform-specific +stays on the host's side of that line. + +## Editing emits ops + +`CanvasEditor` holds the working page. When an edit changes what's stored, the +editor records the `onestore::op`s the change lowers to at that moment. The +host collects them and hands them to the section as one `Edit`. No page is +ever rebuilt or diffed to be saved, so the work of saving a keystroke follows +the size of the edit, not the size of the page. + +Undo lives in the editor, not in storage. Each history entry keeps the inverse +of the change it made. Undoing applies that inverse and emits its ops as a new +edit, so the file only ever moves forward, like OneNote's. Undoing a deletion +brings back the original identities, so internal links to those paragraphs +survive an undo. + +Input-method composition (marked text) stays inside the editor until it +commits, and only then becomes ops. When another client changes the open page, +the editor compares the stored page with what it last read plus the ops it has +handed out, and reloads in place. + +## OneNote-faithful geometry + +The goal is that a page looks the same in Snowbound as in OneNote: same wraps, +same line heights, same places. That took measuring, not guessing. + +- **Stored geometry is not laid-out geometry.** An outline's stored width and + height are constraints and hints. The displayed box comes from layout, so the + canvas lays out as OneNote does and never treats a stored size as a clip. +- **Line boxes use Windows metrics.** OneNote measures lines with a font's + Windows ascent and descent, not the typographic or horizontal-header values + most text stacks pick. The difference is small per line and large per page. + Drawing and hit-testing share these line boxes. +- **Substitutes match metrics.** Where the system lacks Calibri, Arial, Times + New Roman or Courier New, bundled metric-compatible substitutes (Carlito, + Arimo, Tinos, Cousine) stand in, under the stored font name. +- **OneNote's constants are OneNote's.** New outlines take OneNote's default + width. Dragging snaps to its grid, anchored at the page's margin origin. Tags + sit in a column to the left of the text with OneNote's spacing, and a tag's + colour paints the whole paragraph as it does there. + +These rules were established against OneNote's own output: its XML export +gives outline sizes, its PDF export gives exact line breaks, and screenshots +give placement. The comparators in `tools/canvas` (with the probes +`layout-probe` and `page-probe`) keep checking them. + +Equations draw in two dimensions from the tree `onestore::page::Math` parses. +The linear text stays the editable source. Ink draws stroke by stroke in page +coordinates. Page templates' background art is recreated as vector art and +recognised by the stored picture's hash. OneNote's bitmaps aren't shipped. + +## Content the editor doesn't understand + +Nothing is lost for being unfamiliar. A paragraph the canvas can't draw +becomes a labelled placeholder that keeps its place in the flow, and the rest +of its outline stays editable. An object the editor can't hold draws as stored +and stays read-only. Its bytes are never touched either way. Wherever a +remaining "can't edit this" state exists, the aim is to remove it by teaching +the editor the structure, not by flattening the content. + +## Pictures and memory + +The page scene reads only picture headers when it's built. Pictures in and +near the view decode on a background thread at the size they're shown, within +a fixed memory budget, and pictures long out of view are let go. No number or +size of pictures can make a page fail to open. Opening a page happens on its +own thread too. The current page stays live until the new one is laid out and +the pictures it shows first are ready. + +## Accessibility + +The page builds an AccessKit tree. Each text outline appears as its own +editable text area, whose runs carry the canvas's real line boxes and +character positions, so a screen reader's caret and selection land where the +eye does. Native selection and replacement actions go through the editor's +history like any other edit. The tree updates only while an assistive client +is listening, and never for a caret blink. The interface kit around the page +doesn't have a tree yet. + +## Search, dates, conflicts + +Smaller modules follow the same pattern of reproducing OneNote's behaviour +precisely. `search` matches the way OneNote 2010 searches: word prefixes, +ignoring case and diacritics, title matches first. `date` edits the title's +date and time fields the way OneNote stores a changed page date. `conflict` +shows conflict pages with OneNote's highlight. diff --git a/arc/file-format.md b/arc/file-format.md new file mode 100644 index 0000000000000000000000000000000000000000..9e7b87162cfc13ccc4230e2b3c2ee4b35ef99a65 --- /dev/null +++ b/arc/file-format.md @@ -0,0 +1,183 @@ +# The file format and `onestore` + +Everything Snowbound promises (opening the notebooks you already have, editing +them beside OneNote, collaborating without a server) comes down to one thing: +reading and writing OneNote 2010's files exactly as OneNote does. `onestore` is +the crate that owns that. It knows nothing about SQLite, networks or pixels. It +parses files, turns them into a model an editor can work with, and turns edits +back into bytes OneNote will accept. + +## A revision store + +A notebook is a folder. Each section is a `.one` file. The folder's +`Open Notebook.onetoc2` lists the sections and section groups in order, and a +section group is a subfolder with its own `.onetoc2`. Both kinds of file share +one container format, the *revision store* described in Microsoft's +MS-ONESTORE specification. The content inside them follows MS-ONE. + +A revision store is closer to a small database than to a document. Logically +it looks like this (physically, list fragments and revision data interleave as +the file grows): + +```text +┌──────────────────────┐ +│ header (1024 bytes) │ commit point: transaction count, file version GUID, list roots +├──────────────────────┤ +│ file node lists │ chains of fragments; each fragment ends by pointing at the next +│ ├ root list │ object spaces in the file +│ ├ per object space │ its revisions, in order +│ ├ transaction log │ how many list entries each committed transaction added +│ └ file data store │ embedded pictures and attachments +├──────────────────────┤ +│ revision 1 │ objects the revision declares, in object groups +│ revision 2 ─dep─► 1 │ only what changed, plus the ancestors that point at it +│ revision 3 ─dep─► 2 │ +│ … (appended) │ +└──────────────────────┘ +``` + +- **Object spaces** partition the content. A section has a root space for + section-wide metadata and the page series, plus one space per page. A + conflict page, when there is one, gets a space of its own too. +- **Revisions** are per space. A revision declares the objects it adds or + changes and depends on the revision before it. OneNote itself typically + appends a small dependent revision for each edit: the changed objects plus + the chain of containers above them, whose modification times moved. +- **Objects** have a type (a JCID) and a property set. They reference each + other by *ExtendedGUID*: a GUID plus a small integer. Inside a revision these + are compressed to compact IDs through a global ID table. +- **The header** is the commit point. Bytes appended past the old end of the + file mean nothing until the header's transaction count says a transaction + holding them has committed. + +External payloads (large pictures, attachments, recordings) can live beside +the section as `.onebin` files in a `_onefiles` folder. The section refers to +them by name. + +## What "append one revision" means + +Every edit Snowbound makes ends as a `Transaction`: bytes appended at the old +end of the file, a few small patches inside it (the tail of each list gains a +link to its new fragment, and the transaction log gains an entry), and a new +header. A transaction is written for a particular base, named by its `Stamp`: +the base's header and its length. + +```text +exclusive lock ─► stamp still matches? no ─► NotCommitted, reread and rebase + │ yes + ▼ + append new fragments, patch list tails ─► flush + header fields that describe the append ─► flush + transaction count (the commit) ─► flush + file version GUID (wakes cached readers)─► flush ─► unlock +``` + +The stamp works because every committed transaction rewrites the header, +including the file version GUID. So equal stamps mean the same committed image, +and a commit never has to compare the file's body. The flushes are what make a +torn write recoverable. At any cut point, the file is either the old image with +some ignored bytes past its end, or the new image. The version GUID goes last +because OneNote's cached readers watch it, not the transaction count. +Publishing it any earlier was once a real race with native readers. + +A commit ends in one of three states. Callers must honour them: + +| State | Meaning | +| --- | --- | +| `NotCommitted` | Nothing was published. Reread before retrying. | +| `Unknown` | The reply was lost after the point of no return, so it may have landed. Reread and find out before doing anything else. | +| `Committed` | Durable. Only cleanup failed. Never replay it. | + +The only operations that handle a whole image are creating a section or page, +and opening a file. Everything else is an appended revision. The project holds +this as a rule rather than an optimisation. A writer that regenerates a page +from a model does page-sized work (parsing, diffing, rereading the file) for +every keystroke. On a share, that work happens inside the writer lock every +other client is waiting on. + +## Kept open: `Section` + +`Section` is a section file parsed once and kept in memory across edits. Ops +apply to its spaces in memory. `seal` then turns everything that changed into +one `Transaction` that appends one revision per changed space. A seal checks +only what it appends: every fragment is linked from its list's old tail, every +declared object parses and resolves, reference counts are the incremental +counts, and the log entries have the CRC the state predicts. The full-file +validator runs when a file opens and throughout the tests. The section borrows +its bytes from an arena that lives beside it, so there is no self-reference and +no `unsafe` (the crate forbids it). + +The writer caps revision dependency chains with a checkpoint revision, because +native cold opens fail on very long chains. It also keeps a small reservation +after a transaction-log fragment that ends the file, because OneNote does. + +## The page model + +`onestore::page` is the editable view of a page: title, outlines, paragraphs +of text with formatting spans, lists, tags, tables, pictures, attachments, ink, +and equations. Every node carries its stored identity. Content outside the +model isn't dropped. It becomes `Unsupported`, which keeps its identity, type +and layout, and its bytes stay untouched in the file. The canvas edits this +model and the notebook crate stores it. Neither needs to know how it is encoded. + +## Ops + +`onestore::op` is how an edit is expressed: what the editor emits, what the +queue stores and what `Section::apply` writes. An `Edit` is one user action (a +list of ops and a timestamp), and it applies entirely or not at all. The ops are +object-level, for example "replace this UTF-16 range of this text object", +"split this paragraph here, naming the new paragraph", "move this subtree +before that sibling" or "add these table rows". A refusal names the target, +identity or structure at fault. + +Three properties make ops work as a sync currency: + +- **The emitter chooses new identities.** Replaying the same op on another copy + of the section creates the same objects, so an edit keeps its meaning across + retries, rebases and devices. The identity scheme copies OneNote's own + (`{page guid},n`, counting from 1). An earlier scheme that used 0 was accepted + by OneNote's integrity check, but OneNote then silently dropped elements in + roughly one build in five. Only a large native gate caught that. +- **Ranges are UTF-16 code units.** That is how the file stores text, and it is + what UIKit's text input speaks. Ranges that would split a surrogate pair or a + hidden field are refused. +- **Ops are plain data.** They serialize, they carry no UI types, and payload + bytes travel beside them by hash. + +`op::model` interprets ops on the page model without touching bytes. The +lowering code (`op::lower`, which turns a model change into ops) uses it to +predict what each op leaves, and the tests use it as an oracle. + +## Documented, observed, and reverse-engineered + +MS-ONESTORE and MS-ONE are good, but they are not the whole truth: + +- Some things OneNote writes aren't in the spec, such as the author initials it + stores beside every author name. Snowbound writes them too. +- Some things the spec requires aren't needed by OneNote to open a file. + Snowbound writes them anyway, because other readers exist. +- Ink, equations and media recordings are absent from MS-ONE entirely. Ink and + math were reverse-engineered, each against an independent oracle. For ink, + the stroke extents from OneNote's own export. For math, the MathML it exports, + matched byte for byte. Recordings and embedded objects are read and kept, but + never authored. + +Readers stay tolerant (files in the wild are older, odder, or written by other +tools), while the writer stays strict. + +## Protected sections + +Password-protected sections keep their encrypted structure. With the +`protected` feature, `onestore` opens OneNote 2010's AES wrapper for a supplied +password. Edits are sealed back under the section's key, with a fresh IV for +each object. Plaintext never reaches the replica's cache. A protected section is +edited directly in its file and never queued. + +## Looking inside + +The crate's examples are the fastest way to get a feel for a file. +`inventory`, `inspect` and `document` dump the lists, revisions and document +model. `tools/notebook_report.py` renders a readable report of a whole +notebook, and the `onestore-diagnostic` binary in `notebook` is a small HTML +editor for poking at one. The [crate README](../crates/onestore/README.md) is +the reference for the public surface. diff --git a/arc/platforms.md b/arc/platforms.md new file mode 100644 index 0000000000000000000000000000000000000000..bba663bf2f57731912a1796149de4c3309d4ed9b --- /dev/null +++ b/arc/platforms.md @@ -0,0 +1,98 @@ +# Platforms + +Snowbound is one core with thin hosts around it. The file format, sync, the +page editor and the renderer are identical everywhere. What changes per +platform is the window, the input plumbing, and how much of the interface is +drawn by Snowbound versus the operating system. + +```text + macOS Linux iOS +shell snowbound + ui snowbound + ui UIKit (apps/ios, Swift) +glue snowbound/src/macos snowbound/src/linux crates/mobile (C ABI, staticlib) +page canvas ─────────────────────────────────────────────────────► +paint draw (wgpu: Metal) draw (Vulkan or GL) draw (Metal, CAMetalLayer) +data notebook::session + embedded SMB client ────────────────────► +format onestore ───────────────────────────────────────────────────► +``` + +## Desktop: `snowbound` + +The desktop app is a winit window with the `ui` kit's chrome and a `canvas` +page inside it. `snowbound` picks a platform module at compile time +(`macos.rs` or `linux.rs`, both mounted as `platform`), and everything else +in the crate is shared: library and settings, the sidebar, menus, page and +section management, templates, and screenshot and replay support. +Accessibility for the page goes through AccessKit's winit adapter on both. + +### macOS + +- The title bar is transparent. The app draws its own around AppKit's traffic + lights, as part of the same frame as the rest of the chrome. +- AppKit supplies the open and save panels, alerts, the date picker, date + formatting for new page titles and conflict labels, the account's full name + (used as the author, as OneNote uses Office's user name), and the caret and + selection colours. +- Frames present inside Core Animation's transaction, so a resize pairs each + frame with the window's new size instead of stretching the last one. +- A notebook on a mounted SMB share is opened through the embedded SMB client, + signed in with the password the keychain keeps for that mount (see + [sync](sync.md) for why the mount itself isn't enough). +- `tools/canvas/build_macos.py` builds and ad-hoc signs `target/Snowbound.app`. + +### Linux + +- X11 and Wayland. Title bars come from the window manager, or on Wayland from + the compositor where it offers server-side decorations and a client-side + frame otherwise. The app draws its own window controls only where no frame + could be made. +- zenity or kdialog provide the pickers and alerts. The XDG settings portal + provides the colour scheme. Text conventions come from the C library's + locale. Fontconfig is loaded at run time, so builds need no headers for it. +- Wayland's clipboard goes through the window's own connection, since not every + compositor offers a clipboard to clients without a window. +- `crates/snowbound/linux` packages a tarball, plus an installer that adds the + launcher entry the Wayland desktop needs to find the icon. + +### Windows and older macOS + +The readme names both as goals. Nothing platform-specific exists for them yet. +Keeping `ui` and `draw` free of platform toolkits is what keeps them within +reach. + +## iOS: native around the canvas + +On iOS the split moves. Touch text editing depends on affordances that users +know by feel and that are expensive to imitate: the loupe, selection handles, +the edit menu, autocorrect, dictation and hardware keyboard commands. So UIKit +owns everything around the page: + +- **UIKit** handles navigation (a three-column split view of notebooks, pages + and the page), scrolling and zoom (`UIScrollView`), the keyboard and text + input (`UITextInput`), caret, selection highlight and handles, the format + bar, and connecting to servers. +- **`canvas`** draws the page into a `CAMetalLayer` and makes every editing + decision. The C surface exposes the active outline's text as the flat UTF-16 + model `UITextInput` speaks. That is the same unit `onestore` ops measure + text in, so autocorrect and dictation become ordinary text ops with no + translation layer. +- **`crates/mobile`** is that surface: a static library with a C header, + covering libraries, shares, sections and views, built by an Xcode build + phase (`apps/ios/build-rust.sh`). All views share one GPU device and one + renderer, and text engines are pooled across views. + +No `ui` crate ships on iOS. Everything below the view is the desktop's code: +`notebook::session` with its replica and background publishing, the embedded +SMB client (credentials kept in the Keychain, as Files keeps its own), conflict +pages, search, and page management through the same ops. Notebooks from Files +are read and written under `NSFileCoordinator`, so file providers see every +change. + +## What stays shared, on purpose + +- **Behaviour**: the editor, hit-testing, the placement grid, undo, conflicts + and search all live in `canvas` or `notebook`. A host that reimplements one + of them has made a bug. +- **Storage**: every platform writes through the same ops and the same + replica, and publishes through the same sync step. +- **Look of the page**: the page renders through `draw` everywhere, so a page + looks the same on a phone as on the desktop. diff --git a/arc/sync.md b/arc/sync.md new file mode 100644 index 0000000000000000000000000000000000000000..42b576effb8da009984b87d006c49e9a808600c0 --- /dev/null +++ b/arc/sync.md @@ -0,0 +1,184 @@ +# The data layer and sync + +OneNote 2010 got multi-user editing without a server. A notebook is a folder +on a file share. Every client edits the section files in place, and the +format's append-only revisions plus some careful file locking let clients +merge each other's work. Snowbound joins that arrangement as one more peer. It +has no service, no account and no sync protocol of its own. The share is the +source of truth, and whatever OneNote can do to a file while Snowbound is +using it, Snowbound has to handle. + +This essay follows an edit from the keyboard to the share, then covers what +happens when the share is busy, changed or gone. + +## Three threads + +```text +UI thread (winit, UIKit) section thread sync thread +──────────────────────── ────────────────────────────── ────────────────────────────── +editor emits Edit (ops) ───► apply to the parsed Section poll the stamp (header + length) + and returns at once write each burst to SQLite unchanged: ask for a seal, +page reads ◄──────────────── answered from the Section publish it +events ◄──────────────────── Changed, Rejected, published changed: read once, ask + seal ◄────────────────────────── for a rebase + rebase onto the new image ◄───── acknowledge: base += transaction +``` + +- **The UI thread only emits ops.** The editor records the ops each change + lowers to as it makes the change. Submitting them to the session returns + immediately. Parsing, revision building and SQLite never run on a frame. +- **The section thread** (`notebook::working`) is the only owner of the parsed + `onestore::Section`. It applies edits, writes each burst of them to the + replica in one durable commit, and answers page reads. A rebase runs on a + fresh thread that builds its own section while the old one keeps answering + reads, so opening a page never waits on the network. +- **The sync thread** (`notebook::worker`, stepping `notebook::sync`) does all + network I/O. It polls, publishes and rereads. When the queue is idle it + reads nothing but the header. + +`notebook::session` wraps this up for apps. `Notebook` covers discovery and +structure, `Section` covers one section's pages, edits, events and conflict +pages. Both desktop and iOS use exactly this surface. + +## The replica + +Each open section has a replica: a SQLite database in the app's cache, +identified by the section's logical identity, so the same file reopens the +same queue after a relaunch. It holds: + +- **the base**: the last remote image the queue applies to, stored in chunks so + that acknowledging a publication rewrites only the chunks the transaction + touched; +- **batches of edits**: each edit's ops, serialized, grouped into the batch + that will publish together; +- **payloads**: picture and attachment bytes, stored once each, keyed by hash. + +The working state is never stored. It is the base with each sealed batch +replayed and the open batch applied, rebuilt on open. The database runs in WAL +mode with full synchronous commits, and every setting is read back to check it +took. + +A keystroke that publishes immediately costs three commits: the edit, the seal +(which also records the publication attempt), and the receipt. Each one is +ordered against something outside the cache. The edit must be durable before +`apply` answers. The attempt must be durable before any byte reaches the +share, because an attempt that might have landed is never replayed blindly. +The receipt follows the file's own commit. Merging any two of them would open a +window where a crash forgets or duplicates work. + +## A sync step + +```text +stamp = remote.stamp() one small, uncoordinated read +if stamp == base.stamp: + publish the sealed batch (if any) Ok ─► acknowledge + NotCommitted ─► retry later + Unknown ─► attempted; confirm before anything else +else: + image = remote.read() coordinated snapshot, only now + rebase the queue onto image merge, maybe conflict pages + base := image +``` + +A batch stays open while the sync thread is busy, so keystrokes that arrive +during one round trip publish together in the next. Polling costs a +kilobyte-sized read. A publication costs one appended revision, not the +section. + +Uncertainty is handled explicitly. A publication attempt is recorded before +the first network byte. If the reply is lost, the attempt confirms only when +the remote holds its revisions (or every page it changed, as it changed them). +Otherwise it waits as `AwaitingConfirmation`, and `release` resolves it +explicitly: it exports a recovery archive first, then republishes or abandons. + +## Merging + +When the stamp has moved, someone else committed. The queue replays on the new +image one op at a time (`notebook::merge`): + +- an op whose objects the remote left alone applies as it is; +- a text op on text the remote also changed shifts past the remote's changes, + provided its range stays clear of them; +- a move, deletion or setting the remote already made is dropped as done; +- anything else conflicts. + +Conflicts follow OneNote 2010 exactly, because the other clients are OneNote. +The conflicting op is dropped, along with every later op that names what it +named. The remote's version stays the page. The local version becomes a +read-only **conflict page** under it, with the conflicting objects marked and +the page labelled with its author, so OneNote and Snowbound both show the +same bar, the same highlighted paragraphs and the same Delete Conflict Page. A +conflict never blocks the queue: it becomes one more queued edit. Page-list +edits merge the way OneNote merges page series (a page someone moved keeps +their placement, and a page deleted remotely but edited locally comes back as a +copy). Each rule was checked against a capture of two real OneNote clients +doing it first (`corpus/conflict-page`). + +## Why an embedded SMB client + +OneNote doesn't use lock files. It coordinates through the section file +itself, with share modes on open and one-byte locks far past the end of the +data: + +| Role | Open | Byte locks | +| --- | --- | --- | +| reader | read, sharing read/write/delete | shared lock on the reader byte, held for the read | +| writer | read/write, denying other writers | the reader byte shared, plus an exclusive writer byte | +| maintenance (Optimize) | as a writer | an exclusive range that covers the reader byte | + +Maintenance rewrites the file under a new name and renames it into place, so a +handle to the old file goes stale even though its contents still parse. + +Snowbound has to take exactly these locks, or OneNote will either trample it +or be locked out. macOS's `smbfs` can't express them. POSIX byte-range locks +are unsupported. `flock` of either kind becomes an exclusive lock over the +whole file, which fails OneNote's reads. The only exclusive-byte primitive is +private and has no shared form. On top of that, `smbfs` serves reads from its +own lease-backed cache, defers closes, writes whole cached pages back on +`fsync`, and leaves AppleDouble `._` files beside sections it writes. On iOS +an app sees a share only through Files, with no control over locks. + +So `notebook::smb` speaks SMB2 itself (on the `smb2` crate's message layer) +and takes OneNote's opens and bytes exactly, without leases. It never denies +OneNote a read. The leases matter because a Windows client holding a lease +keeps its byte locks local and only reveals them when another open breaks the +lease. So a peer must open before it locks, and never hold handles between +operations. A commit is a handful of compound requests: open; take the locks, +check identity and stamp; write appended bytes and patches, then flush; write +the header, the counter and the version, each flushed in order; close. + +A notebook on a share that macOS has mounted is opened through the embedded +client with the account the system keeps for that mount. It falls back to the +mount only when it can't sign in that way. + +A file's identity is its root object space, not its server file ID, which +changes when maintenance replaces the file. Polling opens by path each time +for the same reason. + +## Offline + +Offline is just a sync step that fails. Edits keep landing in the replica, and +the sync thread retries with backoff and wakes on new local edits. A notebook's +last listing lets it open while the server is unreachable, and a section opens +from its replica. On reconnect the queue publishes, or rebases and publishes, +through the same path as always. A failed directory read keeps the previous +catalog, so an unreachable share never looks like an emptied notebook. + +## Notebook structure + +Sections, groups and the notebook's own colour live in the `.onetoc2` files, +which are revision stores edited through the same transaction path. Moving or +renaming a file also rewrites two header fields OneNote checks on open (the +parent TOC's identity and a CRC of the file's name). Without them OneNote +treats the file as a stranger and re-identifies it. Deleting sends sections and +pages to `OneNote_RecycleBin`, as OneNote does. + +## Recovery and migration + +`export_recovery` snapshots a replica into a single read-only archive: base and +remote images, queue, attempts, receipts and media. It is the evidence behind +any decision that could lose work. Cache schema conversions check their own +output: every converted page must equal the page the old cache held, or the +conversion rolls back and names the archive it made first. + +The [notebook README](../crates/notebook/README.md) documents the API in detail. diff --git a/arc/testing.md b/arc/testing.md new file mode 100644 index 0000000000000000000000000000000000000000..0f351d96b32fbba420f4df9004d73af7a227728f --- /dev/null +++ b/arc/testing.md @@ -0,0 +1,147 @@ +# Testing, and why the code can be trusted + +Snowbound writes into other people's notebooks, often while their copy of +OneNote is writing to the same file. A bug doesn't just crash an app. It can +corrupt a shared notebook, or silently drop a paragraph someone else typed. +The testing strategy follows from that. **Never grade the code with itself.** +Every important claim is checked against something the code under test didn't +produce: a real OneNote, a separate model, a second path through the system, +or a disk that loses bytes on purpose. + +This essay explains where the confidence comes from. The how-to for running +each lane is in [tools/TESTING.md](../tools/TESTING.md) and the crate READMEs. + +## The reference is a real OneNote + +The only authority on "OneNote accepts this" is OneNote. The lab (`tools/w7`) +runs OneNote 2010 in disposable QEMU clones of a sealed Windows 7 image. An +agent inside each clone runs AutoHotkey and PowerShell (OneNote's COM API), +returns screenshots and files, and the clone is discarded afterwards. + +Every storage feature goes through the same loop: + +```text +observe author the edit in OneNote (COM, or driving its UI) and dump what it stored +write make Snowbound store the same thing through ops; export a candidate notebook +cold-open a fresh clone with a fresh OneNote cache opens the candidate + ─► integrity check passes, XML export, screenshots, PDF where layout matters +gate a VM-free test compares the capture with what the candidate meant to say +install candidate + capture become a corpus row (corpus//…) +``` + +*Cold* matters. A warm OneNote has its own cached copy and can hide a file it +would reject. Some failures appear only at scale. OneNote once silently dropped +elements in about one build in five because of an identity choice its integrity +check accepted, and it refuses revision chains past a certain depth. Gates +therefore include large mixed candidates, not only one small row per feature. + +The corpus is what makes this sustainable. Each row keeps the native capture +next to the candidate, and its `tools/test_*.py` gate checks it without a VM, +on every run, forever. Re-running the VM step is needed only when the bytes +Snowbound writes change. + +## Oracles inside the build + +Most checking needs no VM. It works by making independent views of one edit +agree: + +- **The model oracle.** `onestore::op::model` interprets ops on the page model + without going near bytes. After a random edit is applied to a `Section`, + sealed and read back, the page must equal what the model predicted. + Refused edits must leave the page unchanged. +- **Differential editing.** For every editor action, alone and in random + sequences with undo and redo, on every page of a set of corpus sections, + four views must agree: the page stored through the ops, the model's + prediction from those ops, the sealed image read back, and the editor's own + page (`crates/canvas/tests/ops_differential.rs`). +- **Seals check themselves.** Each seal validates exactly what it appended. The + full-file validator runs on open and over the tests' images. +- **Layout agrees with itself and with OneNote.** Incremental layout must + equal a fresh layout after long structural histories. Undo and redo must + restore geometry exactly. Unicode range replacement is compared with plain + string replacement across hundreds of combinations. Wraps and heights are + compared with OneNote's own XML and PDF exports. +- **Conversions verify their output.** A cache schema migration must reproduce + every page the old cache held, or it rolls back. + +## Failure, on purpose + +Durability claims are tested by making things fail at every point that +matters. + +- **Torn writes.** The storage crash model is a disk that, at an injected + failure, persists a random subset of the bytes written since the last flush. + Every I/O point of ordinary commits and counter-rollover commits is cut. + Afterwards the file must read as the old image or the new one, never a + third thing. The same model drives the commit fuzzer. +- **Crashes in the replica.** SQLite transactions are cut while their frames + are in the log but the commit frame isn't, at every step of edit, seal, + publish and acknowledge. Reopening must recover the queue as it was. +- **Lost replies.** `tools/smb-proxy.py` sits between the client and a lab + Samba server and withholds a chosen request or response. The matrix cuts + every message of a commit. Each outcome must keep its promise: + `NotCommitted` really didn't publish, `Committed` really did, and `Unknown` + cases really went either way, with the confirmation that follows finding + out which. +- **Power cuts.** The lab can kill a VM outright mid-workload, and the + surviving files are then cold-opened by a real OneNote. +- **Schedules.** Deterministic multi-actor tests run several replicas editing + offline and publishing through a fault-injecting in-memory remote, then + check convergence and conflict pages. + +## Real clients on a real share + +The collaboration lab puts several OneNote 2010 clients and several Snowbound +writers and readers on one section in a Samba share. They make random edits +through outages, reconnects, offline stretches and OneNote's own maintenance +(Optimize rewrites the file underneath everyone). The pass criteria are strict: + +- every intent from every client is in the final file exactly once, or is + accounted for as a conflict page; +- a wire trace shows OneNote was never refused a read; the only refusals it + met are the ones its own protocol makes between two writers; +- a fresh OneNote cold-opens the result and agrees. + +## Fuzzing + +`fuzz/` is its own cargo-fuzz workspace. It covers parsing (storage, property +streams, revisions, documents), the commit protocol with interruptions, edits +of many kinds, the page model, protected sections, the offline queue, and the +canvas editor's state machine. The parsers see arbitrary bytes, and the +writers see arbitrary sequences of edits whose results must still validate. + +## Two tiers of tests + +Tests fall into two kinds, and the suite is being split to match: + +- **Correctness tests** are deterministic and run on every change: `cargo test` + over the workspace, clippy, and the Python gates over retained captures. + `tools/check_public.py` runs all of it from a clean checkout, with no private + notebooks, VMs or credentials. +- **Sweeps and fuzzing** are random sequences over large corpora and + coverage-guided fuzzers. They are opt-in (today, sweeps widen through + environment variables such as `CANVAS_SWEEP_*` and `OPS_SWEEP_SECTIONS`) and + are meant to run for days or weeks on dedicated machines. What they find gets + reduced to a small deterministic test in the first tier. + +Lab lanes (real OneNote, Samba VMs, the proxy) sit beside both tiers. In Rust +they appear as ignored tests that name the environment they need, and the rest +live in Python harnesses under `tools/`. + +## What counts as evidence + +The project is strict about what a result establishes, and that strictness is +itself a source of confidence: + +- An ignored test, a missing capture, an unreachable lab, a mocked harness or + an iOS build that only compiled establishes nothing about native + compatibility. +- A process exit, a VM interruption and a physical power loss are different + fault models, and one never stands in for another. The evidence covers + transport failures and VM cuts, not every server's lock implementation or + real hardware losing power. +- Notebooks under test are always disposable copies with recorded hashes. + Nothing points an editing harness, a replay or the app at an original. +- Accessibility is tested by asserting on the AccessKit tree the app builds + and on the running app's accessibility hierarchy. Nobody turns a screen + reader on to listen for it. diff --git a/arc/ui.md b/arc/ui.md new file mode 100644 index 0000000000000000000000000000000000000000..4332e07a89fe9c143e319f8643ab2b236192beb2 --- /dev/null +++ b/arc/ui.md @@ -0,0 +1,127 @@ +# The interface kit and the renderer + +Snowbound draws its own interface. The section tabs, toolbar, page tabs, +sidebar, menus and command palette are all painted by the app through one GPU +renderer, the same one that paints the page. This essay explains why, and how +the `ui` and `draw` crates are built to make that pleasant rather than +heroic. + +## The look: half OneNote, half now + +The shell is a deliberate homage. Section tabs lean over one another at 45° +with a faint highlight along their tops. The open tab lies on top, in its +section's colour, and frames the page. That colour shades gently down the +window and becomes the accent for the whole interface, easing to the next +section's colour when you switch. Page tabs sit on the right, and the open +one joins the page with rounded inside corners. It is OneNote 2010's layout, +drawn with soft shadows, concentric corner radii and a dark appearance. Call +it half-skeuomorphic: the shapes that made OneNote's notebook metaphor +legible, without the 2010 chrome. + +The motion comes from [File Pilot](https://filepilot.tech): things move +quickly and never feel like they're waiting on an animation. Animated values +ease exponentially toward their targets with a short half-life. That is +frame-rate independent, a retargeted animation continues smoothly from where +it is, and it settles fast. A context menu opens instantly at the pointer. A +drop-down grows out of its anchor. + +## Why not native widgets + +- **The page has to be custom-drawn anyway.** OneNote-faithful layout needs + Windows line metrics, OneNote's grid, and its tag and list geometry. No + platform text view does that, so a renderer and a text-editing core exist + regardless. Drawing the chrome with them costs little more. +- **One renderer, one pass.** The page and the chrome share a glyph atlas, an + image cache and a frame. A text field in the toolbar edits with exactly the + keys, chords, click counts and caret movement the page uses, because both go + through `draw::edit`. +- **The shell is not a platform idiom.** Leaning section tabs and a + colour-framed page don't exist in AppKit or GTK. The app would be fighting + its toolkit. +- **Portability.** The same interface runs on macOS and Linux today, and the + project aims further (Windows, and old versions of OS X). + +The platform still owns what it's best at and what people expect to be +native. That means file pickers, alerts and date pickers (AppKit on macOS, +zenity or kdialog on Linux), the caret and selection colours, each platform's +editing chords, the keychain, the traffic lights and window frames. On iOS the +split goes further (see [platforms](platforms.md)). + +## Immediate mode, with memory + +`ui` is an immediate-mode kit in the sense Casey Muratori and Ryan Fleury use +the term. The *API* is immediate: every frame, builder code declares boxes +from application state, and there's no widget tree to keep in sync with the +model. The *implementation* remembers plenty: + +```text +frame N + route input ─── against frame N-1's layout (hover, press, focus, drags, wheel) + build ───────── app code declares boxes; each reads its Signal (clicked, dragging, events…) + solve layout ── per axis: pixels · label size · fraction of an ancestor · sum of children + overflow is shared out by each box's strictness + paint ───────── Layers: primitives under a clip, or a Custom box the host paints + after paint ─── work the frame asked for (page requests, saving) runs, then asks for a frame +``` + +- **Identity** is a hash of the parent's id and a part the builder chooses. A + cache keyed by those ids keeps hover, press, focus, scroll and animation + state between frames. +- **Input is answered one layout late.** Events route against the previous + frame's boxes before building, so a box can read what happened to it while + it is being declared. It sounds odd and is invisible in practice. +- **Layout is solved after building**, per axis. A box can be sized in fixed + pixels, by its label, as a fraction of an ancestor, or by its children. When + siblings overflow, each gives up space according to its *strictness*. That + one knob covers most of what flexbox is usually needed for. +- **Nothing runs while nothing changes.** The kit reports whether it wants + another frame (queued input, an animation still settling) and when a timed + change such as a caret blink is due. The app sleeps in between. +- **Lists are virtual for free.** `ui::list` builds only the rows in view from + the source data, and holds its place on the selection as items arrive and + leave above it. No separate model sits between the data and the rows. + +The page is a **custom box**. `ui` routes it the pointer, wheel, key and +input-method events that land on it, in order. The host hands them to +`canvas`'s `PageView` and paints the page into a clipped layer of the same +frame. The page's scrollbars are ordinary `ui` widgets; the canvas only +reports its scroll bounds. + +`ui::popup` builds menus, filterable lists, a colour grid and a fuzzy command +palette on a popup layer that takes input above everything else. `ui::shell` +has the OneNote-specific controls: section tabs and compact toolbar buttons. +The kit doesn't know what a notebook is. `snowbound` assembles the window from +these parts. + +## `draw`: the renderer + +`draw` is a small wgpu renderer. A frame is a list of layers, each with its +own transform and clip. A layer is a flat sequence of primitives: glyph runs, +SVG icons (tinted or in their own colours), SVG paths filled or stroked, +rounded or gradient rectangles, pen strokes and raster images. + +- **Glyphs** are rasterized with Swash into an atlas, at quarter-pixel phases, + so text placed at fractional positions stays crisp and doesn't shimmer as it + moves. Icons and paths rasterize once per size and phase into the same + atlas. +- **The renderer never measures text.** Text reaches it through a trait that + visits positioned glyph runs. Line metrics stay with whoever laid the text + out, which is how the page keeps its Windows metrics while the chrome uses + ordinary ones. +- **Batches** merge consecutive primitives that share a texture and clip, + without reordering paint. +- **Caches are bounded.** The glyph atlas and image cache have fixed budgets. + Eviction keeps everything the current frame needs and rebuilds after + pressure. Images are filtered in linear light with premultiplied alpha. + +GPU readback tests pin the rasterization down: successive quarter-pixel +translations have to move the ink's centroid in quarter-pixel steps, and +repaint, cache eviction and a new renderer must all produce identical pixels. + +## Testing an interface you can't click + +A covered window gets no redraws, so interaction is scripted. +`SNOWBOUND_REPLAY` feeds the app a file of pointer, key, wait and snapshot +steps. `--screenshot` draws the whole window offscreen in each appearance +(the README's screenshots are made this way). `ui`'s own tests build frames +headlessly and assert on layout, routing and signals. diff --git a/tools/canvas/README.md b/tools/canvas/README.md index 1bcfc9e0ff7a71d81edb30aa7e8abae707f046c3..380163c536e2b1c0d20b98b600865c836c55651e 100644 --- a/tools/canvas/README.md +++ b/tools/canvas/README.md @@ -50,7 +50,7 @@ snowbound title bar · toolbar · section frame · page tabs · the page as a `--notebook FOLDER` opens a notebook laid out as OneNote lays one out. The title bar shows the application's icon and the notebook's name; below it two rows of toolbar buttons follow the Home ribbon's Basic Text group, the first nine tags and zoom (only zoom acts yet). Readable top-level sections are tabs in the notebook's order, each leaning over the next at 45° with a faint highlight along its top, and the open one on top, casting a soft shadow and framing the page and the page tabs on the right in its colour, which shades slightly down the window; a tab that opens rises to meet the frame as its outline fades in. The frame's top corners and the page's corners are rounded concentric with the window's. The open page's tab is the page's colour and joins it with rounded inside corners; subpages are indented. The interface follows the system's light or dark appearance, and in the dark one the page's paper darkens and text left at OneNote's automatic colour turns light (except on a highlight), resolved when painting so nothing re-lays out; explicit colours, highlights, pictures and ink keep theirs, except that the background art of OneNote 2010's page templates, recognised by the picture's hash, paints from bundled vector recreations drawn for the paper. Sections take their stored colour's hue at a saturation and lightness chosen for each appearance. `--ui-font FONT_FILE_OR_FOLDER` sets the interface's typeface in place of the system's; fonts are not bundled. The section's colour is also the interface accent, easing to the next section's when another opens. The search box, New Page and the page-tab toggle sit above the page tabs. Command-F searches the page titles; Enter opens the first match and Escape clears it. A page that also changed on another computer shows Keep mine / Keep theirs above it. `--section FILE TITLE` opens one section the same way at that page. Section files must be regular files; discovery rejects symbolic links. -Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar and recent fonts) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the mount; the embedded SMB client is not yet used for it. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). +Launched without a file, the app reopens the notebooks open when it last closed, at the one shown last, or shows "No notebooks open" with New Notebook and Open Existing. Settings (the open notebooks, the sidebar and recent fonts) are JSON in `~/Library/Application Support/Snowbound/settings.json` or `$XDG_CONFIG_HOME/snowbound`; `--settings FILE` uses another file, `--notebook FOLDER` adds a notebook to them, and `--screenshot` never writes them. Open Existing takes a notebook folder, its `Open Notebook.onetoc2` or a section file, which opens in the notebook its folders make up. A notebook on a mounted SMB share opens through the embedded SMB client, signed in with the password the system keychain holds, and through the mount only when that fails. The notebook button left of the section tabs opens OneNote 2010's navigation bar: the open notebooks with their sections and section groups, the open section marked; dragging a row reorders a folder or, onto a group, moves into it, and right-clicking a row, a section tab or a page tab offers OneNote's commands (Rename, Delete to the notebook's recycle bin, New Section, New Section Group, Close This Notebook; Delete, New Page, Make Subpage, Promote Subpage). Page tabs drag to reorder. The `+` above the page tabs adds a page at the end of the section, titled with its date and time as OneNote titles one, and puts the caret in its title; until its body is typed in, templates offer our recreations of OneNote 2010's art (Ivy first), its page colours and Informal Meeting Notes' content, written as ops (`corpus/notebook-management`). The `ui` crate builds every frame from the application's state. A cache keyed by stable box ids keeps hover, press, focus, scroll and animation values, and the previous frame's layout routes the frame's input before building, so a frame answers input one layout late and paints with its own. Sizes are solved per axis after building: fixed, label-sized, a fraction of an ancestor, or the sum of children, with overflow shared out by each box's strictness. The page is a custom box: `ui` routes it the pointer, wheel, key and input-method events that land on it, in order, and the host hands them to `PageView` and paints the page in a clipped layer of the same frame. Page scrollbars are `ui` widgets over that box; the canvas reports only its scroll bounds. Text fields edit through `draw::edit`, as the page does, so keys, clicks and drags select and move the same way in both. Work the frame asks for (page requests, saving) runs after the frame is painted and requests the frame that shows it. Opening a section or page reads it on a thread of its own while the current page stays live; the newest read is laid out and replaces the page once the pictures it shows first are drawn, or after 200 ms. -- 2.54.0