Files
notescraft/docs/ARCHITECTURE.md
Marcos Paulo 5e9b5d3cfb
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:31:52 -03:00

235 lines
90 KiB
Markdown

# NotesCraft architecture
Last updated: 2026-10-19 (editor previews; code blocks follow-up: embedded TOML and TypeScript grammars, language labels) · branch `develop` · see `SPEC.md` for the contract and `ROADMAP.md` for status.
## Shape
Rust workspace of one crate per concern (`crates/<name>`, package `notescraft-<name>`), layered so a crate may depend only on strictly lower layers (`cargo xtask layers`). Pure data and format crates sit at the bottom; the engine (command registry and `Session`) sits in the middle; egui lives only in L5 crates. Every user-visible action will be an engine command shared by the UI, `notescraft-cli`, the control channel, MCP and plugins (SPEC 10).
```
L0 vault markdown syntax
L1 index canvas diagrams whiteboard
L2 backlinks graph templates
L3 plugins themes calendar
L4 engine
L5 editor mcp i18n
L6 ui-egui (egui only in editor and ui-egui, and in apps)
testkit (test helpers) apps/notescraft apps/notescraft-cli xtask
```
> [!note]
> Crate status and public API
Status: **done** (implemented, tested, clippy-clean, merged), **partial**, **stub** (placeholder with one `TODO(phase N)`).
| Crate | Layer | Status | Src / test LOC | Tests |
|---|---|---|---|---|
| `vault` | L0 | done | 1016 / 587 | 42 |
| `markdown` | L0 | done | 2051 / 222 | 51 |
| `math` | L0 | done | 2157 / 699 | 54 |
| `syntax` | L0 | done: fenced-code highlighting, language suggestions, grammar fetch/validate, embedded TOML and TypeScript grammars | 1146 / 393 | 39 |
| `index` | L1 | done (links, tags, BM25 search) | 2887 | 108 |
| `backlinks` | L2 | done | 686 | 14 |
| `canvas` | L1 | done | 1193 / 379 | 31 |
| `diagrams` | L1 | done: flowchart, sequence, class, state, ER, gantt, pie | 5226 | 102 |
| `whiteboard` | L1 | done | 1789 / 551 | 60 |
| `graph` | L2 | done | 915 / 302 | 33 |
| `templates` | L2 | done | 1102 / 287 | 40 |
| `plugins` | L3 | done | 1558 / (integration tests) | 58 |
| `calendar` | L3 | done: events, recurrence, quick add, queries, reminders, ICS and Google feeds | ~3900 / ~2300 | 110+ |
| `themes` | L3 | done | 1539 / 319 | 15 |
| `engine` | L4 | done for v1 scope: registry, `Session`, buffers, search, daily notes, themes, settings, plugin host bridge, attachments, slash menu, batch review | 16182 / 8442 | 443 |
| `i18n` | L5 | done | ~900 | 47 |
| `ui-egui` | L6 | partial: shell, editing, settings, canvas and whiteboard widgets (graph and diagram views pending) | 51447 / 6918 | 962 |
| `editor` | L5 | partial: highlighter, key behaviours, completion, preview scanners for colours and trees (wired into the app) | 2806 / 952 | 112 |
| `mcp` | L5 | done | 2093 | 74 |
| `acp` | L0 | done | 1954 / 480 | 42 |
### `notescraft-vault` (L0)
Vault root, lock, path-traversal guard, atomic IO, watcher. `Vault::open(root)` / `open_read_only`, `resolve(rel)` (rejects `..`, absolute, drive/UNC, NUL, escaping symlinks, with `Error::OutsideVault`), `read`, `write` (via `craft-atomic`, returns `WriteOutcome::{Written, Unchanged}`), `rename`, `delete_to_trash` (into `.notescraft/trash/`), `create_dir`, `list` (sorted, hidden dirs skipped, depth 64), `watch()` (notify, 250 ms debounce, self-write suppression, `VaultEvent`), `write_private` for `.notescraft/`. `.obsidian/` and `.notescraft/` are `Error::Reserved` for normal writes. Lock `.notescraft/lock` with stale-pid takeover (`/proc`, `kill -0`, `tasklist`). Read cap `MAX_READ_BYTES` = 64 MiB.
### `notescraft-markdown` (L0)
Lossless Obsidian-flavoured parsing. `parse(&str) -> Document { source, frontmatter, items }`; `Document::write()` returns the source unchanged; edits go through `replace_range(Range, &str) -> Result<String>` (never clamps; `Error::InvalidRange` / `NotCharBoundary`) and `text(range)`. Items: `Heading`, `WikiLink`, `Embed`, `Link`, `Tag`, `BlockId`, `Highlight`, `Comment`, `Callout`, `Math`, `TaskItem`, with iterators `wikilinks()`, `headings()`, `tags()`, `embeds()`, `callouts()`, `block_ids()`, `all_tags()`. `CalloutKind` covers the 13 types plus aliases and `Unknown`. Frontmatter is a minimal YAML subset with `get`/`keys`, `edit_key` / `remove_key` that change only that key's lines, and the typed properties layer `frontmatter_edit` (SPEC 6 item 20): `set_key(src, key, &YamlValueText)`, `remove_key`, `rename_key` and `value_raw(src, key)` rewrite exactly one entry (append missing keys, create the block at byte 0 when absent, keep comments/quoting/CRLF byte-exact, edit and remove duplicate keys at the first occurrence, cap single-line entries at 1 MiB), and `PropValue` (`Text`/`Number`/`Bool`/`Date`/`DateTime`/`List`) with `render`/`infer`/`infer_list`, `PropType` with `infer_type` and `parse_types_json` for `.obsidian/types.json`. Helpers: `parse_link_target`, `anchor`, `anchors_match`, `decode_percent`, `is_internal_dest`. Nesting depth capped at 64.
### `notescraft-vim` (L0)
Pure modal-editing state machine (SPEC item 29), no egui and no IO. `Vim::handle(Key, &mut Doc) -> Outcome` runs Normal, Insert, Visual, VisualLine, Replace and CommandLine modes over a concrete `Doc { text, cursor, anchor }`; counts and pending keys cap at 100,000 / 4,096 keys, documents at 8 MiB, registers at 32 MiB, and every cursor update is UTF-8-bounded (`get`/`prev`/`next`, never indexing). Motions (`h j k l w b e W B E 0 ^ $ gg G { } % f F t T ; ,` plus viewport-relative `H M L` `Ctrl-d/u`, arrow keys, Home/End/PageUp/PageDown), operators (`d c y > < g~ gu gU` with doubled forms and text objects `iw aw iW aW ip ap i( i) i[ i{ i" i'` plus backtick forms, including `a` variants), single keys (`x X s S C D Y r R J ~ o O A I a i p P u Ctrl-r .` dot-repeat), registers (`"a-z`, unnamed, `"0`, `"-`, `"_`, `"+`/`"*` surfaced as `Outcome.clipboard_set`), marks, `n N *` smart-case literal search with `:noh` highlights and ex line (`:w :q :q! :wq :x :e <path> :bn :bp :s/// :%s/// :noh :set :<number>` and `word.word` ids as `ExCommand::Command`). Markdown helpers `gx gf za` emit `ExCommand::{FollowLink, OpenWikilink, Fold}`. Every edit is a coarse document `Edit` in the outcome, grouped by `UndoGroup`; invalid input returns a message and restores the document. 341 table-driven integration cases plus a 20,000-sequence xorshift fuzz test over a multi-byte document.
### `notescraft-math` (L0)
Pure TeX-math subset parser and type-setter (SPEC 30), no egui and no IO. `parse(src: &str) -> Result<Node>` covers fractions (`\frac \dfrac \tfrac`), roots (optional order), scripts (`^ _ '`, double-script errors), big operators with display limits (`\sum`-family takes them, `\int`/`\oint` keep scripts beside, matching TeX), relations/arrows/Greek/big-ops as Unicode code points (`symbols.rs`, ~140 commands), accents, font commands (`\mathrm \mathbf \mathbb \mathcal \mathscr \mathit`), `\text{}`, `\left..\right` (incl. `.` and `\{`), spacing (`\, \: \; \! \quad \qquad`) and environments (`matrix pmatrix bmatrix vmatrix cases aligned`) with `&`/`\\` cells. Caps: 20 000 chars in, nesting depth 64; errors (`Unknown`, `Unbalanced`, `MissingRight`, `DoubleScript`, `EnvMismatch`, `DepthLimit`, `TooLong`) all carry byte positions and positions are recomputed from the opening tokens, never from the scanner index. `layout(node, &dyn Metrics, Style { size_pt, display }) -> MathBox` turns nodes into `Item::Glyphs`/`Item::Rule`/`Item::Path` (surd hook, stretched parens/bracks/braces/angles as sampled polylines) in points relative to the box baseline, driven by a `Metrics` trait (glyph width, ascent, descent, x-height, rule thickness) so layout is testable without a window; TeX-like rules (Appendix G simplified): 0.7/0.5 script ladder, fraction rule on the math axis with 2-rule clearances, inter-atom spacing table (7 classes, thin/med/thick), italic correction on script bases, environments centred on the axis with 0.3 em gaps. `serde::Serialize` on `MathBox` makes the draw list ready for the `math.render` MCP surface. 54 tests: 29 unit modules, 12 layout-geometry integration cases over a 107-formula corpus, 11 error-position cases and a deterministic 20 000-input fuzz (xorshift over a TeX-ish token alphabet plus mutations of valid formulas) asserting every laid-out box is finite and never panics.
### `notescraft-index` (L1)
In-memory note, link and tag index fed `(path, content)` pairs (no vault dependency). `Index::update_note` / `add_file` / `remove_note` / `rename_note` (re-keys only, never rewrites other notes), queries `notes`, `note`, `headings`, `outgoing`, `backlinks` (source, byte range, context line), `unresolved`, `orphans`, `tags() -> TagTree` (nested counts), `notes_with_tag`, `all_link_edges` and `node_ids` (stable numbering by sorted path, for the graph crate). Link resolution follows SPEC 4.3: case-insensitive basename, nearest-folder ambiguity, path suffix and vault-absolute, alias below basename, `#Heading` / `#^block` subpath validation (`Resolution::Resolved { path, ambiguous, subpath_ok }`), markdown links and embeds. Links are stored unresolved and resolved at query time, so incremental updates need no invalidation. Measured in release: 20 000 notes indexed in 123 ms, worst `update_note` 16.6 us.
**Search** (`index::search`): `SearchIndex::{update, remove, rename, search, len}` is a separate in-memory inverted index with weighted fields (title x4, headings x2, tags x2, body x1), BM25 (k1 1.2, b 0.75), positions for phrases, deterministic ties. `SearchQuery::parse` supports terms, `"phrase"`, `-term`, `OR`, `path:`, `file:`, `tag:#a/b` (matches children) and `line:(a b)`; `/regex/` returns `Error::Unsupported` until a regex crate is approved. `Hit { path, score, title_match, snippets }`, at most 3 snippets with highlight ranges. Release numbers: 10 000 notes indexed in 208 ms, average search 3.15 ms. `SearchIndex` is not yet fed by `Session` (the engine owns that wiring).
### `notescraft-pdf` (L1)
PDF 1.7 export, pure: markdown source in, bytes out, no IO. `export(doc_source, &ExportOptions, &dyn AssetSource) -> Result<Vec<u8>>` parses with `notescraft-markdown`, segments the note into blocks (`scan.rs`), lays them out into flat slots with real font metrics (`layout/`), paginates with widow/orphan control, table rows kept whole and headings kept with the text that follows, then paints each page into a content stream. Rendering: H1-H6 at 1.8/1.5/1.3/1.15/1.05/1.0 x base, bold/italic/strikethrough/inline-code/`==highlight==` (yellow rect), links as blue underlined text plus real `/Link` annotations for `http(s)` (wikilinks print as text), bullet/numbered/task lists with nesting (checkboxes are vector squares, never font glyphs), block quotes (left bar), the 13 callout types (tinted box + bar + type name, fixed colour table), tables (bold header, content-driven column widths with a floor, cell wrapping, rows never split mid-row), fenced and indented code (monospace, grey background, no syntax colours, hard wrap), rules, images (PNG decoded and re-encoded as `FlateDecode` with an `SMask` for alpha, JPEG passed through as `DCTDecode`; anything else is a grey placeholder box with the file name), math as italic monospace TeX source (documented limitation, `notescraft-math` is not visible at L1), mermaid/whiteboard/canvas embeds as placeholders, footnotes as a numbered list at the end, and a footer with the title and `page x of y`. Theme-independent: black on white, always. Fonts are caller-supplied (`ExportOptions::fonts: FontSet{regular, bold, italic?, mono}`, `Arc<[u8]>`); a hand-written TrueType parser (`fonts/`) reads `cmap` format 4/12, `hmtx`, `head`, `hhea`, `maxp` and `OS/2` for advances and glyph ids, and programs embed whole as `CIDFontType2` + `Identity-H` with a `ToUnicode` `CMap`, so exported text stays selectable and searchable; no subsetting means a document carries the three whole programs (flated, about 1.1 MB), and a file over `MAX_FONT_BYTES` (2 MiB) is refused. A face without a real italic file is sheared 12 degrees in the text matrix; a missing glyph is the notdef box, never a panic. Caps, all `Error::TooLarge` and never a silent truncation: 2 MiB source, 2000 pages, 128 MiB of image bytes, 200 images, 3 levels of note embeds with cycle detection. Output is byte-for-byte deterministic (`CreationDate` comes from `ExportOptions::created`); `page_count(pdf)` reads the page count back out of the bytes. The writer (`writer/`) builds the objects, xref and trailer by hand and compresses every stream with flate2 level 6. Tests: 79 (unit plus `tests/render.rs` round-tripping ten fixtures through an independent content-stream extractor that reads the xref, inflates the `ToUnicode` `CMap`s and rebuilds the text, and `tests/hostile.rs` with thirty hostile documents); the CLI and engine add their own.
### `notescraft-canvas` (L1)
JSON Canvas 1.0: `Canvas { nodes, edges, extra }`, `Node` (`Text`, `File`, `Link`, `Group`, `Unknown`), `Edge`, `Color`, `Side`, `End`; tolerant `from_str`, canonical `to_string_pretty` (stable key order, unknown fields preserved), `validate() -> Vec<Diagnostic>`, `add_node` / `remove_node` / `add_edge`, `bounding_box`, `file_nodes`, `new_id(seed)` / `IdGen` (16 hex). Caps: 32 MiB, 100 000 nodes. Modules: `error`, `model`, `read`, `write`, `validate`, `id`.
### `notescraft-whiteboard` (L1)
Excalidraw v2 JSON: `Whiteboard { elements, app_state, files, extra }`, `Element` + `ElementKind` (rectangle, diamond, ellipse, arrow, line, freedraw, text, image, frame, unknown), unknown fields preserved, canonical `to_string_pretty`, builders and `IdGen`. `rough::outline(&Element, Style::{HandDrawn, Clean})` returns deterministic polylines (seeded xorshift; same seed gives identical output). Caps `MAX_ELEMENTS`, `MAX_INPUT_BYTES`. Files carry the `.draw` extension in the vault; `.excalidraw` is read and written back under its own name (see `apps/notescraft-cli/src/commands/notes.rs`).
### `notescraft-diagrams` (L1, partial)
Mermaid subset. `parse(&str) -> Result<Diagram>` with `Diagram::{Flowchart, Unsupported{kind, source}}`; flowchart supports `flowchart`/`graph` + direction, shapes `[] () {} (()) ([])`, edges `--> --- -.-> ==>` with labels (both syntaxes), chains, `&`, nested `subgraph` (depth 8); malformed lines become `Diagnostic`s; `source()` is always the input. `layout(&Diagram) -> Result<Layout>` is Sugiyama-lite (iterative cycle breaking, longest-path ranks, 4 barycenter sweeps, deterministic); `to_svg` / `to_svg_with(Palette)` produce escaped SVG. Caps: 1 MiB, 5000 nodes, 20 000 edges. Sequence diagrams are parsed and laid out too (participants/actors with aliases, all arrow kinds, notes, activation, autonumber, `loop`/`alt`/`opt`/`par`/`critical`/`break` blocks, caps 200 participants and 5000 events; 500 messages lay out in 78 us). Class diagrams (UML: members with visibility, stereotypes, generics, relations `<|-- *-- o-- --> -- ..> ..|>` with cardinalities and labels, caps 500 classes/5000 members; 300 classes lay out in 1.8 ms) and state diagrams (`[*]`, transitions, composites to depth 8, choice/fork; converted to the flowchart model) are parsed and laid out as well. ER, gantt and pie are recognised but not yet parsed.
### `notescraft-backlinks` (L2)
Sits one layer above `index` (it depends on it). `ContentSource` trait (no file IO; implemented for `BTreeMap<String, String>`), `linked_mentions` (path, byte range, line, snippet, matched text), `unlinked_mentions` (title or alias, case-insensitive, word boundaries; skips frontmatter, code, existing links, math, comments; caps 10 000 results and skips notes over 2 MiB, reported in `UnlinkedReport`), `link_mention` (replaces a mention with `[[Title]]` or `[[Title|text]]`, fails with `Error::Stale` if the text changed), `counts`. 20 000-note scan: 12 ms.
### `notescraft-graph` (L2)
`Graph::new(nodes, edges)`; deterministic Fruchterman-Reingold `layout(&Graph, &LayoutConfig) -> Vec<[f32;2]>` with a spatial grid, `Layout` for incremental `step`, pinning, `local_graph(depth)`, `degrees`, `components`, `orphans`. 5000 nodes / 15 000 edges / 300 iterations lays out in about 525 ms in release.
### `notescraft-sync` (L1)
Pure sync logic shared by the future client and server, no I/O (`serde`, `sha2`, `thiserror`). `Ledger` keeps one `FileRecord` per path plus a revision counter: `apply(Write)` returns `Applied`, `Unchanged` (same hash, no revision spent) or `Conflict(current)`; a writer that saw the current `seq` wins, a stale one conflicts, a stale edit over a newer delete wins and a stale delete over a newer edit conflicts; `changes_since(seq, limit)` gives a `Changes` page with `cursor()`. `plan(local, bases, remote)` turns a disk scan, the last agreed `Base` per path and a `/changes` page into `Action`s (`Download`, `DeleteLocal`, `Upload`, `DeleteRemote`, `Reconcile`, `Adopt`). `merge3` is a line-level three-way merge that returns `Conflict` on any overlap or on input over 4 M diff cells. `conflict_path` names the conflict copy. `check_path` / `check_syncable` reject empty, absolute, `..`, backslash and control-character paths and the `.notescraft`, `.trash`, `.git`, `.obsidian` roots; `hash_hex` / `check_hash` are SHA-256 lowercase hex. 26 integration tests.
### `notescraft-sync-server` (app, exempt from layers)
HTTP service over `notescraft-sync`. Env: `NOTESCRAFT_SYNC_ADMIN_TOKEN` (24+ chars), `NOTESCRAFT_SYNC_DATA` (default `./data`: `sync.db`, `blobs/<vault>/<sha256>`), `NOTESCRAFT_SYNC_BIND` (default `127.0.0.1:8480`), `NOTESCRAFT_SYNC_QUOTA_MB` (default 5120, 0 = unlimited). Source folders: `api/` (routes, accounts, vaults, devices, members, invites, audit, admin), `store/` (SQLite: users, vaults, devices, files, invites, pairings, audit), plus `roles.rs` (`authorize(Need::{Read, Write, Admin})`), `cache.rs` (LRU of 64 ledgers), `limits.rs` (pairing lockout: 5 failures in 10 minutes, shared by all callers, in memory), `middleware.rs` (`X-Request-Id`, one stderr line per request). Accounts: a user owns many vaults and belongs to others (roles owner, editor, viewer; non-members get 404); one device token works for every vault of its user; admin `POST /v1/users`, admin pairing codes (`?user=` optional, a well-formed new vault id is created by its first code), `GET/POST/PATCH/DELETE /v1/vaults` (device token; 50 owned per user; delete is soft for 14 days, `restore`, `purge`), members and invites (single-use 48 h codes, stored as SHA-256), `files-meta`. Sync: `GET /changes?since&limit&wait` (long poll up to 25 s), `GET|PUT|DELETE /files/{path}` (`X-Base-Seq`, `X-Mtime-Ms`, `ETag`, 201/200/409, 25 MiB cap, per-vault quota 413), `POST /batch/check`, admin `POST /v1/vaults/{v}/gc` (blobs unreferenced and past the 14-day tombstone grace). Ops: `GET /healthz`, `GET /readyz` (db write probe in a rolled-back transaction plus a blob-folder probe), admin `GET /v1/audit` (no secrets in any row). Known ceilings: blobs are never garbage-collected unless the admin runs `gc`, pairing lockout is global (no per-IP limit behind the tunnel) and in memory, blobs on local disk (MinIO later). 40 HTTP tests including the real engine client (`tests/client_e2e.rs`).
History (`history/`, `store/revisions.rs`): every applied write inserts a `file_revisions` row in the same transaction as the `files` upsert; `GET /files-history/{path}` (newest first, max 200), `GET /files/{path}?seq=N`, `POST /files-restore/{path} {seq}` (writes the old bytes as a new revision, 404 unknown, 410 blob collected), per-vault `retention_days` (1..=365, default 30) with the newest 20 revisions always kept, admin `gc` removes expired revisions and unreferenced blobs, `bytes_used` counts unique live plus retained blobs.
Live editing (`live/`): under `/v1/vaults/{vault}/live/{path}/` `join` (snapshot, rev, peers; viewers may join, 413 over 2 MiB, 415 non-UTF-8), `ops` (OT submit, 409 stale, 400 malformed), `events` (SSE `op`, `ack`, `presence`, `leave`, keepalive), `presence`, `leave`; rooms in memory with a 64-slot channel per peer, caps 256 rooms and 64 peers, a sweeper flushes 2 s after the last op through the same blob and ledger path as `PUT` (`flush`), a write that lost a race becomes a `conflict_path(path, "live", date)` copy. 14 tests.
### Engine sync (`crates/engine/src/sync/`)
`Remote` trait with `HttpRemote` (blocking `ureq`), `pair`, `SyncState` (`.notescraft/sync/state.json`), `sync_once` (scan, changes, `notescraft_sync::plan`, perform, base copies in `.notescraft/sync/base/<hash>` for `merge3`), `SyncWorker` (one thread: debounce 2 s, 15-minute heartbeat, backoff 5 s to 5 min, phases Idle/Syncing/Offline/SignInAgain/Error, writes `.notescraft/sync/status.json`), binding and token files (`binding.json`, `credentials.json` mode 0600), conflict copies (`list_conflicts`, `resolve` Mine/Theirs/Both), commands `sync.pair`, `sync.status`, `sync.now`, `sync.unlink`, `sync.conflicts`, `sync.resolve` (gate: status and conflicts read, now and resolve write, pair and unlink delete). `Vault::sibling` gives the worker thread a writable handle without the lock.
### Vault catalog and launcher
`AppSettings.vault_catalog: Vec<VaultMeta{path, name, pinned, last_opened_ms}>` (a list, because the settings store merges tables key by key), `list_rows` (pinned first, folder checks inside a 500 ms budget, local sync badge from `binding.json`, `pending.json`), `touch`, `set_pinned`, `set_name`, `forget`, commands `vaults.list/pin/rename/remove`. `ui-egui` `launcher::Launcher` (desktop rail and cards, phone one column, keyboard, rename, filter) returns a `LauncherAction`; the app host (`apps/notescraft/src/launcher_window.rs`) performs it, starting vaults as separate processes. Shown when no recent vault exists, with `--launcher`, and from File > Vaults....
### Capture (`crates/engine/src/capture/`)
`note.capture {text, title?, target?, source?, attachment?, utc_offset_minutes?}`: `target` is `daily` (default: appends `- HH:MM text` or a `### HH:MM title` block to today's daily note, through the open buffer when there is one so undo works), `inbox` or a vault folder (creates `<folder>/<YYYY-MM-DD HHmmss> <slug>.md` with frontmatter `created` and `source`, numbering a taken name); `attachment {name, base64}` is saved through the attachment helper and embedded. Caps: text 1 MiB, title 200 and source 2000 characters, attachment 20 MiB; absolute, `..`, hidden and reserved targets are refused. Gate: write.
### `notescraft-collab` (L1)
Live co-editing core, no I/O: `TextOp` (Retain/Insert/Delete over Unicode scalar counts; wire format `[5,"hi",-2,3]`), `apply`, `compose`, `invert`, `transform` (TP1 property-tested), `transform_index`, `Selection`, `diff`, the OT `Client` state machine, the server `Room` (history cap 1000, size cap 2 MiB), `Presence` (64 peers, sanitised names). 22 tests.
### Compact (phone) layout
`shell/compact/`: below 600 pt one page at a time (`CompactPage` Notes, Note, Search, Graph, Calendar, More), bottom tab bar (88 pt, 44 pt hit areas), back chevron top bar; `UiState.compact_page` persisted.
### `notescraft-templates` (L2)
No chrono: own `Date`/`DateTime`/`Weekday` (civil-from-days), `format()` (Moment token subset), `expand(template, &Context)` for `{{title}}`, `{{date[:FMT]}}`, `{{time[:FMT]}}`, `daily_note_path`, `parse_daily_name`, `month_grid` for the calendar widget.
### `notescraft-lint` (L2) and `notescraft-blocks` (L1)
`lint` is pure (no IO): `check(text, &Context) -> Vec<Issue>` with rules `heading-increment`, `list-marker`, `double-space`, `trailing-space`, `blank-around-headings|fences|lists`, `frontmatter-unclosed`, `frontmatter-duplicate-key`, `duplicate-heading`, `unresolved-link` (closest title by edit distance, never a safe fix) and a `spelling` slot that never reports. A `Fix` is edits plus `safe`; `fix::apply` returns `Error::Stale` when a range no longer fits, `apply_all_safe` applies back to front and skips overlaps. `style::Vote` learns list marker, heading style and date format per note, incrementally (20 notes, 80%). Engine `crates/engine/src/lint/`: `lint.check|fix|fix_all_safe|rules|dismiss|state|state_clear|style`, dismissals in `.notescraft/lint-state.json`, `session.lint.*` config (enabled, learn_style, learn_list_marker, learn_date_format, fix_on_save, disabled, severities); checks are reads for the assistant gate, fixes writes; MCP aliases `lint_check`, `lint_fix_all_safe`. `blocks` parses top-level blocks (paragraph, heading, quote, callout, task, list item with children, table, code fence, divider, frontmatter, embed) and returns one `Edited` replacement per operation (`move_block`, `duplicate_block`, `delete_block`, `turn_into`, `insert_component`), so a UI applies one undo step; `notescraft-editor` re-exports it as `editor::blocks` and adds `marks` (toggle bold, italic, strike, code, link, highlight). `notescraft-blocks` also owns `detect`: `detect_language(body) -> Option<&'static str>` names the language of an unlabeled fence only on strong evidence, so a batch review never offers a guess it cannot defend. The rules run in order and the first match wins: shebang (`python`, `bash`/`sh`/`zsh`, `node`), `<?php`, `<!doctype html`/`<html`, `<?xml`, JSON, the YAML shape, `diff --git` or `--- a/` with `+++ b/`, all-`$ ` prompt lines, Rust, Python, SQL, TOML; anything else is `None`. Only the first `MAX_BYTES` 4 KiB and the first `MAX_LINES` 40 non-blank lines are examined. Engine `block.list|move|duplicate|delete|turn_into`. UI: `panels/lint_panel*`, `views/settings_view/categories/lint.rs`, `widgets/slash_popup/*` (groups, preview, Visual and Source), `widgets/block_menu.rs`, `views/note_view/grip.rs`, `views/note_view/blocks/{table,embed*}.rs`.
### `notescraft-knowledge` (L2)
Pure retrieval over notes, no IO. `chunk` splits a note into passages (headings then paragraphs, at most 1,200 characters, UTF-8 safe, byte ranges and heading paths), `Index` is a passage inverted index (BM25 k1 1.2, b 0.75; postings and ranges only, text is read on demand; `.obsidian` and `.notescraft` and configured folders are never indexed), `Vocab` learns word pairs per passage (NPMI, incremental, capped, JSON cache) and expands a query with at most 6 words of weight at most 0.6, `Signals` gives links, tags and modified times, `rank::retrieve` fuses text, link (+0.30), tag (+0.25 or +0.10) and optional recency (+0.08) and lists the signals that fired, `rank::related` queries with a note's own distinctive terms, `rank::budget` trims to a token budget (4 characters per token). The evaluation set in `tests/eval` (35 notes, 28 questions, 8 vocabulary gaps) gives recall@6 0.964 for the hybrid against 0.786 for plain BM25 (written by the same author as the algorithm, so read it as a regression guard). Engine: `crates/engine/src/knowledge/` owns the index in `Session`, feeds it wherever the search index is fed, rebuilds on `knowledge.excluded` changes and caches the vocabulary in `.notescraft/knowledge-vocab.json`. Commands: `knowledge.search {query, limit?, folder?}`, `knowledge.context {query, budget?}`, `knowledge.related {path, limit?}`, `knowledge.status`, `knowledge.rebuild`; all are reads for the assistant gate (core plugin `knowledge`). UI: `panels/related_panel.rs` (Related tab), `assistant_panel/chat/sources*.rs` (Context card, markers, chips; sources are saved in the transcript as a system line), `views/settings_view/categories/knowledge.rs`, `widgets/bottom_sheet.rs` and `style/compact.rs` (width class under 600 pt). Known limits: a note edited outside the app between runs keeps stale word pairs until a rebuild; the editor cannot select a byte range, so opening a source only jumps to its start.
### `notescraft-calendar` (L3)
Pure logic, no I/O; dates come from `templates`. `EventTime` (a day or a minute, ISO text, orders day-then-clock), `Event`/`Reminder`/`Calendar`, and `parse_event`/`write_event`: one event per Markdown note, in its frontmatter, through `notescraft-markdown`'s single-entry editors so unknown keys, comments and the body survive (caps: 1 MiB file, 4 KiB field, 1000 list items). `Rule` is the repeat subset (`FREQ` daily/weekly/monthly/yearly, `INTERVAL`, weekly `BYDAY`, `UNTIL` xor `COUNT`; shorthand `daily` etc.); `occurrences(start, skip, from, to)` is capped at 10 000 results and jumps close to the window, times are wall-clock in the event's zone (DST never moves them), `split` cuts a series for "this and following". `parse_quick(text, today)` reads English phrases (day, time, range, length, repeat, reminder, `#tag`, `@calendar`) and names the unreadable part. `Item` is one agenda line (event occurrence or dated task, `source` set for imported ones), `run_query` filters, sorts and groups (`Query`, `SavedFilter`), plus `conflicts`, `free_slots`, `year_counts`/`heat_level`, `week_review`. Reminders (`remind.rs`): `alerts_for`, `tick`, `catch_up` (what fired while closed is *missed*, never notified), `snooze`, `dismiss`, `inbox`, quiet hours across midnight, a `Notifier` trait, all with an injected clock. Feeds (`feed/`): `parse_ics` (read-only, capped: 4 MiB, 8 KiB line, depth 8, 10 000 events), `Feed`/`FeedStatus`/`CachedFeed` and `refresh` (a failed fetch keeps the cached events and records a redacted error; addresses never reach statuses or logs), `due` (schedule, 15-minute retry), and `google` (SHA-256, PKCE S256, sign-in URL, token/calendar/event JSON; read-only scope `calendar.readonly`). It is a module of this crate, not a separate crate, because an L3 crate may not depend on another L3 crate and the engine needs both.
### `notescraft-plugins` (L3)
Sandboxed WebAssembly host on `wasmi` (SPEC 7). `Manifest::from_json` (id charset, permissions `VaultRead`/`VaultWrite`/`Editor`, command declarations), `trait HostApi` (log, vault_read/write/list, search, links, backlinks, active_note, insert_text, notice, now; implemented by the app later), `Plugin::load(manifest, wasm, Arc<dyn HostApi>, Limits)` validates the module, requires the ABI v1 exports and memory, and links only the 11 `ns` imports (any other import is an error). Permissions are checked before the host trait is touched. `Limits` defaults: fuel 50 M, memory 64 MiB, 2 s, 4 MiB results, 8 host calls/ms; breaches are `Error::PluginLimit` / `PluginTrap`, and three consecutive failures disable the plugin. `PluginSet` lists commands as `plugin.<id>.<command>` and dispatches by that id. Guest pointers are bounds-checked with `get`. 15 `wat` fixtures cover hostile modules (loops, memory growth, bad pointers, oversize results, missing exports, wrong ABI, foreign imports, traps).
### `notescraft-engine` (L4, partial: registry + session)
Generic command registry `Registry<C>` (the context `C` becomes `Session` later). `CommandSpec<C>` has id, label, menu path, shortcut, typed `ParamSpec`s, `enabled` and `run` as plain function pointers. `register` validates ids (`[a-z0-9._-]`, 1..=128, no empty segments, no duplicates, cap 10 000); `register_plugin_command` forces the `plugin.<id>.` prefix and `unregister_prefix` removes a plugin's commands. `list()` returns serialisable `CommandInfo` sorted by id (for CLI `commands` and MCP `list_commands`). `run` checks, in order: unknown id, size cap (4 MiB, iterative walk), object-or-null, enabled, required/typed/unknown params, then calls the command inside `catch_unwind` so a panic becomes `Error::Panicked(id)` and the registry stays usable. `panic_hunt::adversarial_params()` plus a test runs 12 sample commands against 22 hostile inputs. `Session::open(root)` opens the vault and indexes every note (unreadable files become `diagnostics()`, never an abort); `open_read_only`. Buffers: `Buffer::edit` with the markdown range rules, undo/redo (1000 steps, 50 MiB), dirty tracking, `open_buffer`/`close_buffer(force)`/`save_buffer` (through `Vault::write`, then re-index). Note operations: `read_note`, `write_note`, `create_note`, `delete_note` (to trash), `rename_note(from, to, update_links)` which rewrites inbound links (keeps alias and `#heading`/`#^block`, keeps bare vs path form) all-or-nothing (`Error::RenameIncomplete`). `apply_event(VaultEvent)` re-indexes external changes and records a conflict instead when the buffer is dirty. `register_core` registers 16 commands: `vault.info`, `note.{list,read,write,create,delete,rename}`, `links.{outgoing,backlinks,unresolved}`, `tags.list`, `buffer.{open,edit,save,undo,redo}`; write commands are disabled on read-only sessions. Wiring slice: `SessionConfig` (serde, validated paths: daily folder/format/template, templates folder, attachment folder, update-links-on-rename, theme, week start) with `Session::open_with`; the `SearchIndex` is kept in sync with every write, rename, delete, save and external event (`search` command, default limit 20, max 200); daily notes from a template (`daily.open`, `calendar.month`, `template.list`, `template.create_note`, `template.insert` through the buffer so undo works); themes (`theme.list`, `theme.get`, `theme.set`, unknown id falls back to `dark` with a diagnostic). 25 command ids in total. Settings: `AppSettings` implements `craft_settings::Settings` (app id `notescraft`, schema 1): the `SessionConfig`, an opaque UI blob (cap 256 KiB), recent vaults (max 20), assistant settings (agent, command, review/write/delete flags, per-plugin tool toggles), enabled plugins and hotkeys, with `validate()` repairs; `load`/`save` go through the craft-settings store (atomic, backup, recovery; `NOTESCRAFT_CONFIG_DIR` override for tests); commands `settings.get`, `settings.set`, `settings.reset`. `Index::remove_file` exists now. Properties (SPEC 6 item 20): `note.properties` lists `{key, type, value, raw}` rows in file order plus `has_frontmatter`, importing Obsidian's `.obsidian/types.json` (read-only, junk ignored); `note.property_set` validates the value against the type (explicit, from types.json, or inferred), then `note.property_remove` and `note.property_rename` rewrite exactly one frontmatter entry through `notescraft-markdown`; a write goes through the open buffer as one undoable dirty edit (undo keeps the editor's own edits, disk updates on save) or through `write_note` with re-indexing, and invalid values or a read-only session refuse before anything is written. Gaps: plugins and the control channel are not wired into `Session` yet.
**Attachments (SPEC 9):** `attachments.rs` saves pasted or dropped bytes with `Session::save_attachment(hint, ext, bytes)` (`save_attachment_at` takes an injected clock, `save_attachment_from_path` copies an outside file, size-checked by metadata first). The type must be one of 15 allowed extensions (`unsupported_attachment_type` otherwise); a magic-byte sniff (png, jpeg, gif, webp, pdf) fixes a mismatched declared extension, an `.svg` must look like an SVG document, and anything over the `files.max_attachment_mib` cap (default 64 MiB, validated and repaired like every session setting) is refused with `attachment_too_large` before any byte is written. The hint is sanitised (last path component, no control characters, leading dots dropped, 120-byte stem, extension forced), an unnamed paste becomes `Pasted image YYYYMMDDHHMMSS.<ext>`, and collisions get ` 1`, ` 2`, … suffixes, never an overwrite. The folder is created through the vault guard (reserved dirs refused), the file is written through `Vault::write` (craft-atomic) and registered with `Index::add_file`, and the returned `SavedAttachment { path, link }` carries the `![[name]]` embed (bare name when unique in the vault index, vault-relative path otherwise). Commands `attachment.save` (a base64 payload through the strict hand-written decoder in `base64.rs`, 96 MiB decoded cap, correct padding required, ASCII whitespace skipped) and `attachment.save_path`; both writable-gated, and `attachment.save` is the MCP alias `save_attachment`.
**Slash menu (SPEC 21):** `slash.rs` holds the menu data: `SlashEntry { id, label, keywords, icon, insert }`, `filter_entries` (case-insensitive prefix beats word-start beats in-order subsequence, ties in menu order) and `trigger_ok(line, col)` (the `/` opens the menu at line start or after a space or tab, never inside inline code, math, an open `[[..]]` or `(..)` link target, or a URL; 41-case table). `slash_menu.rs` is the fixed entry table; `slash_commands.rs` registers the commands, each an ordinary command on menu `["Insert", <label>]` with the flag and keywords, so the command palette lists it too. The insert commands are pure and return `{ text, cursor, selection_end? }`: headings, bullet/numbered/task, quote, code and divider, the link/embed triggers, the 13 callouts (`slash.callout` with a `kind` param plus one `slash.callout.<kind>` per type, caret on the selected `Title` placeholder), `slash.table` (bounded rows and columns), `slash.date` (today in the configured daily format, clock injected), `slash.template` (expanded through the templates crate) and `slash.canvas` (creates `Untitled*.canvas`, writable-gated). `slash.list` serves the filtered entries (empty inside a code fence) and `slash.trigger_ok` the rule; plugin manifest commands cannot be flagged yet because the manifest has no field for it.
Generic command registry `Registry<C>` (the context `C` becomes `Session` later). `CommandSpec<C>` has id, label, menu path, shortcut, typed `ParamSpec`s, `enabled` and `run` as plain function pointers. `register` validates ids (`[a-z0-9._-]`, 1..=128, no empty segments, no duplicates, cap 10 000); `register_plugin_command` forces the `plugin.<id>.` prefix and `unregister_prefix` removes a plugin's commands. `list()` returns serialisable `CommandInfo` sorted by id (for CLI `commands` and MCP `list_commands`). `run` checks, in order: unknown id, size cap (4 MiB, iterative walk), object-or-null, enabled, required/typed/unknown params, then calls the command inside `catch_unwind` so a panic becomes `Error::Panicked(id)` and the registry stays usable. `panic_hunt::adversarial_params()` plus a test runs 12 sample commands against 22 hostile inputs. `Session::open(root)` opens the vault and indexes every note (unreadable files become `diagnostics()`, never an abort); `open_read_only`. Buffers: `Buffer::edit` with the markdown range rules, undo/redo (1000 steps, 50 MiB), dirty tracking, `open_buffer`/`close_buffer(force)`/`save_buffer` (through `Vault::write`, then re-index). Note operations: `read_note`, `write_note`, `create_note`, `delete_note` (to trash), `rename_note(from, to, update_links)` which rewrites inbound links (keeps alias and `#heading`/`#^block`, keeps bare vs path form) all-or-nothing (`Error::RenameIncomplete`). `apply_event(VaultEvent)` re-indexes external changes and records a conflict instead when the buffer is dirty. `register_core` registers 16 commands: `vault.info`, `note.{list,read,write,create,delete,rename}`, `links.{outgoing,backlinks,unresolved}`, `tags.list`, `buffer.{open,edit,save,undo,redo}`; write commands are disabled on read-only sessions. Wiring slice: `SessionConfig` (serde, validated paths: daily folder/format/template, templates folder, attachment folder, update-links-on-rename, theme, week start) with `Session::open_with`; the `SearchIndex` is kept in sync with every write, rename, delete, save and external event (`search` command, default limit 20, max 200); daily notes from a template (`daily.open`, `calendar.month`, `template.list`, `template.create_note`, `template.insert` through the buffer so undo works); themes (`theme.list`, `theme.get`, `theme.set`, unknown id falls back to `dark` with a diagnostic). 25 command ids in total. Settings: `AppSettings` implements `craft_settings::Settings` (app id `notescraft`, schema 1): the `SessionConfig`, an opaque UI blob (cap 256 KiB), recent vaults (max 20), assistant settings (agent, command, review/write/delete flags, per-plugin tool toggles), enabled plugins and hotkeys, with `validate()` repairs; `load`/`save` go through the craft-settings store (atomic, backup, recovery; `NOTESCRAFT_CONFIG_DIR` override for tests); commands `settings.get`, `settings.set`, `settings.reset`. `Index::remove_file` exists now. Gaps: plugin tools are wired; the control channel is described below.
**Control channel (SPEC 10, D9):** `notescraft-engine::control` turns a live app session into a small local JSON server, std-only (no async, no new dependency). `ControlServer::start(vault)` binds `127.0.0.1:0` (loopback only), writes `.notescraft/control.json` `{port, token, pid, started}` atomically through the vault's `write_private`, and spawns one listener thread plus one thread per connection (max 8, 1 MiB lines, 30 s idle by default via `ControlOptions`). The wire is newline-delimited JSON: one request line `{"id":N,"token":"…","method":"run"|"commands"|"info"|"ping","params":{…}}`, one reply `{"id":N,"ok":…}`; wrong tokens get a constant-time compare, a 1 s delay and the connection closed after three failures; the server thread never touches the session, it queues `(request, reply)` for `ControlHandle::pump(&mut Session, &Registry)`, which the UI calls once per frame (`NotesApp::control_pump`) and which dispatches through the same `Registry::run` path as UI and MCP under a budget of 16 requests / 4 ms, so a flood (1000 pipelined requests is a test) only costs sliced frames and repaints. Dropping the handle shuts the server down and removes the descriptor; a stale one (dead pid) is overwritten by `start` and refused by `ControlClient::connect(vault)`, the CLI side (`notescraft-cli control ping`, and write subcommands defer to the app first, falling back to local execution when it is offline). Token bytes come from `/dev/urandom` with a documented `RandomState`-mix fallback; the file is owner-only.
**Calendar commands (`engine/src/calendar/`, SPEC 26):** reads `calendar.{agenda,next,free,conflicts,year,parse,review,filters,reminders,feeds,google_state}`, writes `calendar.{add,edit,move,delete,note,bulk,bulk_delete,filter_save,filter_delete,reminders_tick,reminder_snooze,reminder_dismiss,feed_add,feed_remove,sync_due,sync_apply,google_calendars,google_disconnect}`. Events are notes in `SessionConfig::calendar_folder` (default `Calendar`); `load` expands repeats and scans the other notes for `- [ ] text 📅 YYYY-MM-DD` and a `due:` key (every call reads every note: an index of dated tasks replaces that past a few thousand notes). A repeating event needs `scope` (`this`, `following`, `all`) and, for the first two, an `occurrence`; `this` adds an exception and writes a standalone event, `following` cuts the series. `bulk` and `bulk_delete` count first and write only with `confirm: true`. Private state lives in `.notescraft/`: `calendar-filters.json`, `calendar-reminders.json`, `calendar-feeds.json`, `calendar-cache/<feed>.json`, `calendar-sync.log` (last 200 lines), `calendar-google.json` (calendar ids and names, no secret). Fetching is not in the engine: `sync_due` names the feeds to fetch, the caller fetches and hands the answer to `sync_apply`. `SessionConfig::reminders` (quiet hours, default reminders, snooze choices) is reachable as `session.reminders.*`. The assistant gate treats `calendar.*` as the core plugin `calendar` (on until switched off in Settings, AI assistant); `calendar.delete` and `calendar.bulk_delete` need the delete flag. Over MCP every command is a tool (`calendar_agenda`); the in-app assistant's short list carries `calendar_agenda`, `calendar_add` and `calendar_edit`, the rest stay callable by name and through `run_command`.
### `notescraft-ui-egui` (L5, partial) and `apps/notescraft`
Pure egui (no eframe in the library). Shell: `style::apply` maps theme tokens to egui visuals (no strokes, 8-step type scale), `layout` paints the `canvas` colour and three floating cards (sidebar 260, centre fluid, right 300; right panel hidden below 900 pt, sidebar below 640 pt; 16 pt radius, 8 pt gaps), `tree` (folders first, collapse state, 32 pt rows), `tabs` (craft-ui fitting, cap 32), `statusbar` (words, chars, ~tokens), `state` (serde, corrupt JSON gives defaults), `app::NotesApp` over a `Session`. Views: `note_view` (read-only markdown rendering: headings, lists, tasks, code, highlights, tags, wikilinks coloured resolved/unresolved, frontmatter card, the 13 callouts, 2 MiB cap), `palette` (fuzzy command palette over the engine registry), `backlinks_panel`, `outline`. `apps/notescraft` opens `notescraft <vault>` (or `NOTESCRAFT_VAULT`) with eframe on the **glow** renderer (PhotoCraft uses wgpu; switch now that disk allows). Visual pass (PaperCraft design): Inter and JetBrains Mono fonts (`assets/fonts`, OFL), Lucide icons (`assets/icons`, ISC) through craft-ui's SVG catalogue (`fonts`, `icons`), a sidebar with vault header, search field, tree with folder/file icons, calendar card and tags (`sidebar`), pill tabs with file icons, breadcrumb with chevrons and the Visual/Markdown pill toggle, a properties card with icon keys and tag chips, callout cards with kind icons and a 4 pt accent bar, mention cards, pill tabs in the right panel. `cargo run -p notescraft-ui-egui --example snapshot -- --vault V --out ui.png --size 1440x900 --open "note.md"` renders the UI offscreen through egui_kittest (wgpu) so the result can be looked at without a window. Motion and sound: `motion` (easing curves, `progress`/`value`/`appear` over egui's animation manager, one switch `UiState::animations` makes everything jump) drives the tree (rows fade in when a folder opens, chevron rotates, hover fades), the active-tab and right-tab pills (they glide between tabs), the note body (fades in on switch) and the palette (pop with overshoot). `sounds` synthesises four short cues (`Pop`, `Tick`, `Close`, `Open`: pitch-glide sine bursts, no sample files, click-free, under 0.2 s) and defines the `SoundSink` trait plus a per-cue `Throttle`; the library never opens an audio device. `apps/notescraft` implements the sink with `cpal` (`audio.rs`) and a tested voice `mixer.rs` (12 voices, soft clip); no device or an unsupported format means silence. More motion: a new tab grows in and a closed tab shrinks and fades before it is removed (`NotesApp::begin_close`, `Tabs::closing`, `TabsResponse::finished`; animations off closes at once); the opened note rises 16 pt as it fades in; hover fades with a pointing-hand cursor on the search field, settings icon, calendar days, the Visual/Markdown segments, the right-panel tabs, tab and tab-close targets and mention cards (`motion::hover`); the selected tree row fades in. Bounce: `motion::spring` (a damped spring, about 8% overshoot, settles in ~0.5 s) drives the sliding tab and right-panel pills; `motion::wobble` (a decaying sine, 0.5 s) makes a clicked tree row swell and its content swing sideways; `ease_out_back` pops new tabs, the folder chevron and the rows that drop in when a folder opens; `ease_in_back` makes a closing tab swell a little before it shrinks away. Toggles: `UiState::sound`, `volume`, `animations`, commands `ui.toggle_sound` and `ui.toggle_animations`. `cpal` is a new dependency here (SoundCraft, FilmCraft and EffectCraft already use 0.16). Whiteboard view (`whiteboard_view`, L6): `show_whiteboard(ui, &Tokens, &mut WhiteboardView, &mut Whiteboard) -> WhiteboardResponse { changed, selected_ids, export_requested, cues }`. Modules by concern: `state` (view flags, toolbar/drag state, undo snapshots, cue queue with one cue per kind per frame), `geometry` (zoom, pan, document/screen mapping), `edit` (drafts, commit, delete, eraser, keyboard tools, undo/redo, `Cmd+Shift+L` opens the layers panel), `render` (dot grid, seeded hand-drawn strokes, per-element appear scale 0.85 to 1.0 about the centre, fading selection ring), `panels` + `panels_rows` (floating properties card: stroke width, opacity, fill, line, roundness, roughness, font size; rows share one segmented-button widget that paints from the row rect only, since a second allocation makes the panel `Area` ratchet over the canvas), `palette_panel` + `palette_apply` (Stroke/Fill/Canvas popover: quick picks, 13x5 hue grid, recents, custom colour), `zoom_bar` (zoom in/out/fit, grid and layers toggles), `layers_panel` (`show_layers_panel(ctx, &Tokens, &mut WhiteboardView, &mut Whiteboard, Rect) -> bool`, reorder, hide/show, lock, returns true when the panel asked to close). Panels and popovers animate in once per open (`motion::appear`, 8 pt rise plus opacity); every interactive target paints a 2 pt accent focus ring when focused and sets `WidgetInfo` for AccessKit. Sound is queued, never played: the widget returns `Cue`s that `CenterResponse::whiteboard_cues` forwards to the app sink. Tests: 5 new suites (`whiteboard_cues`, `whiteboard_motion`, `whiteboard_zoom_bar`, `whiteboard_layers`, `whiteboard_a11y`). Export goes through the engine (`file.export_whiteboard_svg`); the shell asks before overwriting and toasts the result. Not yet: the editor, settings panel, graph/canvas/diagram renderers, assistant panel, Windows/Linux custom title bar, craft-quit and craft-settings integration, wiring the palette/backlinks/outline/note view into `NotesApp`.
Whiteboard selection/property fixes: IDs allocated after reopening are checked against existing element and group IDs, including deleted elements; bulk duplication builds the occupied-ID set once. Picking uses visible shape geometry with a six-screen-pixel tolerance, exclusive topmost clicks, Shift/Cmd toggling, a three-pixel click/drag threshold and intersection marquees. `whiteboard_view::controls::relevant_controls(tool: WhiteboardTool, selected_kinds: &[&ElementKind]) -> ControlSet` is the pure applicability contract shared by contextual panels and typed property mutations; mixed selections expose the union of supported controls and edits affect only supporting, selected, unlocked elements. Fill popovers anchor to the clicked control, flip/shift within the viewport, consume dismissal gestures and provide transparent, palette and explicit custom-hex picks; each pick is one undo step. New-element defaults now reach all supported fields, and fill paths follow their shape transforms. Fifty regression tests cover pointer selection, reopened IDs, property field isolation/defaults/undo, tool/kind applicability, popup placement/dismissal and contextual visibility.
**Calendar view (`ui-egui/src/views/calendar_view/`):** `show_calendar(ui, tokens, &mut CalendarView, &CalendarData) -> CalendarResponse` draws month, week, day, agenda and year from loaded items and reports what to open or run; the shell (`Views::calendar`) loads the window and invalidates on any `calendar.`, `note.`, `buffer.` or `daily.` command and on watcher events. `keymap.rs` is a pure state machine (`h l j k H L [ ] t`, `g` prefixes, `Tab` focus, `v V` grab or select, `dd yy p .`, `/ n N f s c b i z`; Esc backs out), `popups/` holds the quick-add, go-to and find prompts, the bulk bar, the filter popover with saved filters, the week review and the reminders inbox. Long titles are elided to one line with the full text on hover. Settings has a Calendar page (header with the plugin switch and a test notification; General: week start, week numbers, 12/24 h; Sources: dated tasks; Display: completed tasks; Notifications: default reminder and snooze chips, quiet hours; Accounts: Google calendars with event counts, ICS feeds, last sync and the sync log) and the Core plugins page shows the bundled plugins as cards. `SessionConfig::calendar` (`time_24h`, `week_numbers`, `show_completed`, `tasks`) and `week_start` are reachable as `session.calendar.*` and `session.week_start`; the sidebar calendar honours the week start. `apps/notescraft` implements `services::Services` (`desktop.rs`): feeds over `curl --config -` so addresses and tokens stay off the process list (`net.rs`), notifications through `osascript` or `notify-send` (`notify.rs`), the refresh token in the system keychain through `security` or `secret-tool` (`keychain.rs`), and the Google loopback sign-in (`google.rs`; needs `NOTESCRAFT_GOOGLE_CLIENT_ID`). Limits: macOS `security` takes the secret as an argument, so it is visible to the same user for an instant; Windows has no notification or keychain tool in this build.
### Fast open and large vaults (specs/fast-open-and-explorer)
`vault`: `VaultEntry { path, is_dir, size, modified }` (Unix milliseconds); `count_files(root, limit) -> FileCount { files, notes, over_limit }` is a bounded read-only walk that stops past `limit` files or folders (`LARGE_FOLDER_FILES` = 5000) and runs before `Vault::open`, which would create the state folder. `engine/src/vault/indexing.rs`: `Session::open_lazy(root)` lists the vault, registers attachments and queues the notes newest first; `index_step(budget) -> IndexProgress { total, done, paused, finished }` reads at least one note and stops at the budget, finishing the scan (`knowledge_end_scan`) when the queue is empty; `index_progress`, `search_partial`; `open` and `open_read_only` run the same steps to the end (CLI, MCP, tests). `open_buffer` indexes that note at once. Commands `index.pause` and `index.resume` (reads for the gate). `rename_note` and `rename_folder` return `Error::Indexing` (code `indexing`) until the scan ends, because they rewrite links from the backlinks. `engine/src/vault/content_search.rs`: `ContentSearch::start(root, query, limit)` greps the notes on a thread with the ripgrep crates (`ignore`, `grep-searcher`, `grep-regex`; engine only), every word on one line, case-insensitive, files over 8 MiB, binary files and hidden folders skipped, snippets cut at 240 characters, at most 3 lines and `limit` notes; `poll()` takes the hits, `cancel()` or drop stops it.
UI: `NotesApp::new` builds the Explorer tree from the listing (`vault/indexing.rs: tree_from_listing`), `ui()` calls `step_indexing` (8 ms per frame, repaint while work remains) and, when the scan ends, rebuilds the sidebar, right-panel, graph and embed data once. While it runs: the sidebar shows an indexing card (`panels/sidebar/index_status.rs`, pause and resume), the status bar a percent, search merges matches from disk and marks the list partial (`panels/search_panel/partial.rs`), links that do not resolve yet are neutral, unresolved embeds stay unknown, the graph tab waits, backlinks say they may be incomplete. `panels/tree.rs` virtualizes the rows (`tree/rows.rs`: `flatten`, `cached_rows`, `row_window` with 10 rows of overscan, `row_top`; `TreeNode.notes` counts notes per folder; `TreeModel.skipped` counts what the 64 000-node cap left out), `tree/filter.rs` narrows to a filter text, `tree/crumbs.rs` draws the sticky folder path. `panels/sidebar/explorer_nav.rs` draws the filter box, collapse-all and follow buttons and the pinned folders; the commands `explorer.collapse_all`, `explorer.reveal_active`, `explorer.focus_filter` and `explorer.toggle_follow` (no key binding) are in `shell/app/vault/explorer.rs`; `UiState` keeps `pinned_folders` (20 at most) and `explorer_follow`. `shell/app/vault/large_folder.rs`: a folder over the limit opens the window on a throw-away vault with the app dialog; "Open anyway" relaunches with `--large-ok`. Release numbers (owner's Mac): 5 000 notes, `open_lazy` 8.8 ms and the whole index 435 ms; 10 000 notes, control channel up after about 0.5 s and the index complete about 2 s after launch (the eager open was 2.6 s before any window); 50 500 explorer rows, median frame 46 us. No index cache: no stage justified one. Measure again with `cargo test --release -p notescraft-engine --test perf_open -- --ignored --nocapture` and `cargo test --release -p notescraft-ui-egui --lib perf_tree_frames -- --ignored --nocapture`.
### Code blocks, grammars and formatting (specs/code-blocks-and-formatting)
`notescraft-syntax` (L0, no egui) wraps the owner-approved `syntect` with the pure-Rust `fancy-regex` backend. `load` builds the grammar set once per process (syntect's bundled pack, two original grammars embedded with `include_str!` from `crates/syntax/grammars/` — TOML and TypeScript, which the bundled pack lacks — and then the installed `.sublime-syntax` files; one that fails to parse is skipped and the rest still load) and answers for a fence label with `Grammars::find` and `is_known`; `set_installed` rebuilds it and bumps a `generation` counter so installed colours are never reused. `scopes::classify` maps a scope prefix onto one of the eight `TokenKind`s (keyword, string, number, comment, type, function, property, operator; an unknown scope gets no token), `Highlighter::new(language, body)` and `Highlighter::lines(range)` return `Highlighted` token ranges and parse sequentially with a `ParseState` checkpoint every `STRIDE` = 100 lines, so a window deep in a block does not re-parse from the top and a checkpointed window equals the full run. `suggest_language` names the closest bundled or installed grammar for a typo (`jsno` → `json`) and returns `None` for an exact match. Caps, never a panic: `MAX_LINE_BYTES` 10 KiB, `MAX_BLOCK_BYTES` 1 MiB, `MAX_BLOCK_LINES` 50 000. `cache` (`SyntaxCache`, `global`, `set_repaint_hook`) keys on the hash of (language, body, line window), so an edit misses instead of serving stale colours; one worker thread with a bounded queue (newest request wins) parses and the `on_ready` hook asks for a repaint, with age eviction at `MAX_ENTRIES` 256 or `MAX_BYTES` 8 MiB. `get` never blocks: it returns `None` on a miss.
JSON is a pure helper in `notescraft-blocks` (`json`): `check_json`, `looks_like_json` (an unlabelled block or a lone template string), `json_span`, `format_json`, `minify_json`, and the `format_json_block` / `minify_json_block` splices. Formatting is a re-indenter over the already-validated text (two spaces per level), not a re-serialisation, so key order, `1e5` and `1.0` stay byte-for-byte what the author wrote; `MAX_JSON_BYTES` is 1 MiB and the parser's 128-level nesting limit stays on. Invalid JSON returns `Err` and the text is not touched. Formatted and minified JSON takes the line endings of the note it came from (`match_line_endings`), so a CRLF file stays CRLF. Engine commands `json.format_block` and `json.minify_block` (`path`, `index`, writable) commit through `Session::lint_commit`, so a rewrite is one undo step; both are listed in the assistant gate.
Grammars. Engine commands: `grammar.info` (`label?`) reports the installed grammars and, for a fence label, whether it is bundled or installed plus a suggestion; `grammar.install` (`id`, `url?`) downloads from the registry or from a pasted link; `grammar.install_text` (`id`, `text`, `license?`) saves source the caller already fetched; `grammar.remove` (`id`) deletes the file and the entry, after which blocks of that language fall back to plain monospace. Every path runs the gates of `fetch` and `validate` first (https only, no credentials, no IP literal or localhost, host allow-list, no redirects, `MAX_GRAMMAR_BYTES` 512 KiB, sha256 against the registry entry, `.sublime-syntax` only so a `.tmLanguage` is refused, the file parses as a grammar and its sample highlights) over a bounded `curl` transport (`Transport` trait, `Curl`, injectable so tests never touch the network). The first failing gate becomes a `CommandError` with the code `grammar_refused`, `offline`, `download_failed` or `not_found`, and then nothing is written and no state changes. The registry is the owner's public Gitea repo `https://git.marcospaulo.dev.br/masi/notescraft-registry`, host allow-list `REGISTRY_HOST` (`git.marcospaulo.dev.br`), branch `main`, raw layout `raw/branch/main/`: the app reads `index.json` (cap 256 KiB; entries of `id`, `format`, `file`, `sha256`, `license`, `sample`) and downloads one listed `file` from that host only, never through a redirect. An installed grammar is `<config dir>/grammars/<id>.sublime-syntax`, written through `craft-atomic`, and `AppSettings::grammars` holds the matching `GrammarInfo { id, file, name, size, license, installed_at }` entries sorted by id, at most `MAX_GRAMMARS` 64. It is a list, not a table: craft-settings merges tables key by key, so a removed key would come back.
Themes. `Theme::syntax` is a `SyntaxColors` holding the eight keys of the optional `[syntax]` table (`keyword`, `string`, `number`, `comment`, `type`, `function`, `property`, `operator`, see `SyntaxColors::KEYS`). Every built-in theme carries all eight; a user theme overrides them one by one and falls back per variant to the built-in of the same variant (`all`, `uniform`, `to_toml` writes the table back). Rule: every token holds 4.5:1 or better on `code_background` in every built-in theme, and the inline-code chip is `text` on `surface_2`, which the existing WCAG contrast test already covers.
Source mode. `editor::fence_syntax::fence_assigns` splits a fence body into `Sty::Syntax(TokenKind)` assignments on `code_background` from the same cache, called from `highlight::layout_job`; a cache miss paints plain `Sty::Code` and the colours arrive on a later frame. Source mode has no viewport, so a fence over `MAX_FENCE_LINES` 2 000 or `MAX_FENCE_BYTES` 256 KiB stays plain. Rendered mode. The card (`views/note_view/blocks/code_card.rs`, actions in `code_actions.rs`) hides the fence lines and draws a header with the uppercase language label (`Text` when there is none), Copy (the body without the fence lines on the clipboard, confirmed on the button for `COPIED_SECS`), Wrap for blocks up to `VIRTUAL_FROM` 1 000 lines, and, on a `json` block, a chip reading `Valid` or the first error with its line, plus Format and Minify. A mistyped label offers the suggestion and rewrites the label; an unknown one offers "Add grammar". Body rows are `Span::CodeTok(text, Color32)` in the mono font with the theme's syntax palette. A block over 1 000 lines draws only the rows near the viewport (20 rows of margin, fixed pitch) and asks the cache for that window only; blocks up to 200 lines highlight in one job. An unlabelled block that parses as JSON gets an offer card above it; nothing changes until the user accepts, and a declined offer is remembered by body hash (`MAX_DISMISSED` 500 hashes). Inline code is a chip too: `note_view/painting.rs` paints a `radius_small` rect filled with `surface_2` behind every `Span::Mono` run and the galley on top, the click hit rects do not change, and rows carrying inline math keep the old flat background (documented gap).
The grammar flow in the app. The code card asks `is_known` before it offers anything, offers the `suggest_language` match for a mistyped label, rewrites the label with a char-boundary-safe edit and shows the typo chip; its "Add grammar" leaves a request that the frame loop takes with `take_grammar_request` and answers by opening `shell/app/vault/grammar_dialog.rs`. That dialog takes a link (or the registry entry when the link is empty) and runs `engine::fetch_grammar` — the gates of `fetch` and `validate` over the bounded `curl` transport — on a worker thread, polled every `POLL` 100 ms; "Test and install" turns the answer into the file's size, name and license, and only then does "Add grammar" hand the checked text to `grammar.install_text`, so a gate that refuses saves nothing and leaves the app as it was. Installed grammars reach `syntax::load::set_installed` through `Session::load_grammars`, called once per frame; it rebuilds the set and bumps the `generation` counter of `load::current`, so colours cached under the old grammar set are never reused. A dismissed JSON offer is kept as the hex body hash in `UiState.dismissed_json` and handed back to the note view on the next frame, so it stays dismissed across restarts.
**Batch format and lint** (`engine/src/batch/`). The module is pure: `propose_note(path, text, issues, checks)` reads one note plus the lint issues found in it and returns a `Proposal { path, content_hash, status, reason, changes }`; `Checks { json, lint, labels }` picks what runs, a `json`-labeled fence is reformatted as a safe change, an unlabeled one whose body is a JSON value is a guess and lands as `NeedsReview`, a lint issue whose fix is a single edit becomes one change each (multi-edit and stale fixes are skipped, not errors), and the third check adds one `ChangeKind::Label` per unlabeled fence whose body `detect_language` names (a zero-length insert right after the fence run, `after` the language, never safe, because the language is a guess; blocks the JSON check already covers keep their combined change). Changes are sorted, an overlap drops the later one, the survivors are numbered in `Change { id, kind, range, before, after, rule, safe, decision }` and the note ends as `Status::Proposed`, `NeedsReview`, `NotEdited` or `Clean`. `apply_changes` splices the accepted ones from the highest start to the lowest (no change shifts another's range) and returns the new text with the ids applied and skipped; an accepted range that is out of bounds, off a `char` boundary, still overlapping or no longer holding `before` is skipped and listed. `hash_text` is the content hash a proposal keeps of the text it was read from. `Decision` (`Pending`, `Accept`, `Skip`) is re-exported at the crate root as `BatchDecision`, so it does not clash with the assistant gate's. The per-session `BatchState` holds the queue (reversed, so `pop` reads in index order), the checks, the proposals read so far and the `(path, title)` list lint's link rules need, built once per batch by `Session::lint_titles`.
Commands: `batch.start` (`scope` `note`|`folder`|`vault`, `path?`, `json?`, `lint?`, `labels?`, the last three on by default) needs `require_full_index`, replaces any earlier batch and never queues `.obsidian/` or `.notescraft/`; `batch.step` (`budget_ms?`, 1 to 1000, `DEFAULT_BUDGET_MS` 8) reads at least one note inside the budget, and a note over `MAX_NOTE_BYTES` 2 MiB, one that cannot be read or one whose lint fails is listed as `NotEdited` with the reason, never dropped; `batch.cancel` empties the queue and keeps the proposals read; `batch.status`, `batch.proposals` (`status?` one of the four names, at most `MAX_LIST` 5000 rows, `truncated` says when the list was cut) and `batch.proposal` (`path`, adds each change's line) are reads; `batch.decide` (`decision` `accept`|`skip`|`pending`, `path?`, `change_id?`, `all?`) flips one change, one note or, only with `all`, every note; `batch.apply` (writable) re-checks `hash_text` before every write, skips a note changed since the dry run, commits each file through `Session::lint_commit` (one undo step per file) and answers `{written, skipped, failed}`. Every answer carries the progress object `total`, `done`, `finished`, `running`, `cancelled`, `proposed`, `needs_review`, `not_edited`, `clean`, `accepted`.
UI. `views/batch_view` (`mod.rs: show`, `list.rs`, `review.rs`) draws three cards over a dimmed backdrop and only returns `Action`s, so nothing in it touches a note: scope (current note, folder, whole vault) and the three check switches on the left (JSON, lint and "Add missing language labels") with Run dry run and the count box; the notes in the middle with the filter tabs (All, Changes, Review, Skipped), a badge and a change count per row, "Reviewed n of N" and Previous/Next in review order; on the right the selected note's changes as before/after diffs with Accept and Skip per change, Accept all in file and Apply n accepted. `shell/app/vault/batch_review.rs` runs the commands and turns their JSON into `Data`: while the dry run is going it calls `batch.step` with `STEP_BUDGET_MS` 8 once per frame and asks for a repaint, so a whole-vault run never blocks the window; `start_batch_review` (opens the review and runs a dry run to the end) and `select_batch_note` exist for the snapshot example, which takes `--batch` and `--batch-note <path>`. The UI command `batch.review` (`shell/app/vault/explorer.rs`) sets `UiState::batch_review_requested`, which the frame loop reads before drawing the view.
Release numbers (owner's Mac, `cargo test --release -p notescraft-ui-egui --test perf_code_blocks -- --ignored --nocapture`): a note with 50 code blocks, first frame 15 ms median — 87 ms in a cold process, which installs the fonts and builds the grammar set — and 1.5 ms steady once the worker-thread colours have arrived; one block of 5 000 lines, first frame 3 ms. Both are inside the SPEC budget of 50 ms. Not built: the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. Regex literals in TypeScript are not part of the embedded grammar either (a `/` stays a division operator).
### Editor previews (specs/editor-previews)
`editor::preview` (L5, pure, no egui and no IO): `detect_colors(text, named, in_code, Limits) -> Vec<Detected { range, rgb, alpha }>` scans hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`) and functional `rgb`/`rgba`/`hsl`/`hsla` (3 or 4 arguments, comma or space separated, optional `/ alpha`); a hex may not touch a word character, out-of-range or malformed arguments give nothing, hits are sorted and never overlap and every range lies on a `char` boundary of the text. `DEFAULT_LIMITS { max_colors: 2_000, max_line_bytes: 4_096 }` keeps the scan linear: longer lines are skipped and detection stops at the cap. `NAMED_COLORS` is the 16 basic CSS names plus `orange`; they match whole words, case-insensitively, only when the caller says the run is inline code and named colours are on. `detect_tree(label, body) -> Option<Vec<TreeRow { prefix, name, folder }>>` accepts an empty, `text` or `tree` label with at least two rows whose prefix is a glyph run of `│ ├ └ ─ |` (or `'` and `` ` ``) followed by dashes — `├── name`, `|-- name`, `'-- name`, `` `-- name `` — plus an optional root line; box-drawing tables, ASCII art and bodies over 64 KiB or 2,000 lines are rejected. The copy text never enters the result: the card keeps the source.
Settings. `EditorSettings.previews: PreviewSettings { colors: true, named_colors: false }`, reachable as `editor.previews.colors` and `editor.previews.named_colors` (`settings.rs` extends its group guard so `editor` nests `blocks` and `previews` one level deeper); `serde(default)` keeps an old `config.toml` loadable and `settings.set` round-trips both through `settings.reset editor.previews`.
UI. `widgets/previews.rs` publishes the two switches for the frame (`publish` / `settings`), paints the swatch (12 pt, radius 3, alpha over the surface, one border) and the external-link arrow (16 pt, vector strokes, no font), answers `is_external` and carries the menu's `request_setting` / `take_setting_request`. `widgets/color_menu.rs` draws the right-click menu: the colour and its hex, Copy color, Copy as rgb (`rgb(r, g, b)`, `rgba(…, 0.5)` when translucent) and the two switches, which never touch the note — the app applies them through `settings.set`. `note_view::inlines` gained `Span::Color { text, rgb, alpha, style: ColorText }` and `colorize(spans, named)`, which splits plain and inline-code runs around the values and returns `None` when there is nothing to split. Nothing is written into the text: `painting.rs` reserves the room in the layout job (`SWATCH_RESERVE` as leading space before a value, an empty section with `ARROW_RESERVE` after an external link), so the galley, the selection, the caret offsets and the saved bytes stay exactly what the user wrote, and consecutive inline-code runs share one rounded chip with the colours cut out of it. Fenced colours are drawn for `css`, `scss`, `less`, `html`, `xml`, `svg`, `json`, `yaml`, `yml`, `toml` and `ini` only. `blocks/tree_card.rs` draws the TREE card (Copy puts the source bytes on the clipboard). Mermaid fences go through `diagram_view::show_fenced` with a Diagram/Source toggle (Diagram by default); Source hands the text to the ordinary code card through `request_source` / `take_source_request`, and each fence's view is keyed by the fence's index among the note's Mermaid fences, so an edit above the block keeps the choice. A failure leaves the source visible behind a chip: `Fallback::Error` (the first line of the parse or layout error), `Unsupported` (a recognised but unrenderable kind) or `TooBig` (over a cap), and the toggle is hidden while the fallback stands.
Limits, documented rather than hidden: no swatches or arrows in Source mode or in rows carrying inline math (those rows use the flow renderer); a hex Obsidian reads as a tag (`#cba6f7` in prose) is a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only when the parser returns such a kind — today `diagrams::parse` answers `NoHeader` for a header it does not know, so those fences show the Error chip.
Measured (owner's Mac, release): scanning 100 rows of 10 colour values (1,000 hits) takes 0.05 ms median and 0.15 ms worst over 500 runs.
### `notescraft-regex` (L0)
A small regular-expression engine with no third-party dependency: `Regex::new(pattern, ignore_case)`, `find_all(text, limit)`, `expand(match, text, "$1")`. Syntax: literals, `.`, classes, `^ $` (line anchors), `\b \B \d \w \s`, groups, `(?:)`, alternation, `* + ? {n,m}` and lazy forms. Matching is a memoised backtracker over (instruction, position), so time is `O(text x program)` for every pattern (no catastrophic backtracking); pattern over 1,000 bytes, program over 4,000 instructions and `text x program` over 256 Mi are refused with `Error`. Users: `blocks::find_regex` (find bar `.*` switch, `$1` in the replacement) and the engine's `/pattern/` search form (line by line, 50,000 notes of up to 1 MiB, three snippets per note).
### Development hook
With `NOTESCRAFT_DEV_DIR=<folder>` set, `shell/app/dev_hook.rs` polls `<folder>/request.json` (`open`, `commands`, `screenshot`) and writes `response.txt`; `screenshot` asks the window for a copy of its own frame and saves an uncompressed PNG, so no screen-recording permission is needed. It needs a window the compositor presents; in a sandbox without one (as in the build agent's session) it gives up after 20 s and says so.
### Obsidian basics and the graph view (specs/obsidian-basics)
Engine. `vault/find/` holds `search`, `content_search`, `switcher` (`switcher.search`: fuzzy notes and aliases, `note#heading`, `note^block`; `Session::switcher`) and `random_unique` (`note.random`, `note.unique`). `FilesConfig::link_text(from, target, alias)` writes a link by `link_format` and `link_path`; `session.files.deleted_files = permanent` makes `note.delete` and `folder.delete` call `Vault::delete_permanently` instead of the vault trash. `backlinks::link_mention_as` links a mention with caller-written text.
UI. `shell/app/vault/{switcher,link_preview,history}.rs` (switcher rows and jump, 400 ms link hover card, back/forward), `widgets/tabs.rs` (`Tabs::{toggle_pin, move_tab}`, pinned tabs persist in `UiState.pinned_tabs`), `panels/tree.rs` (`move_of`: drag a row onto a folder or the empty area to move it through `note.rename` / `folder.rename`), `views/note_view/list_fold.rs` (list item folding), line numbers in `views/center/source.rs`. Settings rows: default view, font size, readable line length, line numbers, new-note folder, link format, link path, deleted files. PDF embeds draw the page as an image: `Session::pdf_preview(path, page)` runs the system tool (PDFKit through `osascript` on macOS, with `sips` as the first-page fallback; `pdftoppm` elsewhere), caches the PNG in `.notescraft/cache/` keyed by size and modified time, and `Images::load` hands it to the note view; without a tool the embed falls back to the file card. Audio plays in the app (`views/media_view/audio/`): the tab (`mod.rs`) and the note embed card (`embed.rs`, `![[song.mp3]]` and `ogg`, `wav`, `flac`, `m4a`, `aac`, `opus`, `3gp`, `weba`) drive one `AudioPlayer` (`player.rs`, ticked from `vault/explorer.rs`; commands and state in `bus.rs`): `ffplay -nodisp -ss -af atempo -volume` makes the sound (started on a worker thread, pause kills and restarts at the kept position, the position is a wall clock times the speed minus a 0.2 s start lag), one sound at a time, and a video that starts stops it. The waveform (`waveform.rs`, `peaks.rs`) decodes the whole file once at 8 kHz mono (at most 30 minutes, 100 min/max buckets per second, 1500 columns, a cache of 16 files, 2 decodes at once) and seeks on click or drag. The spectrum (`spectrum.rs`, `ring.rs`, `fft.rs`) decodes a second 22 kHz mono stream throttled to the speed into an 8 s ring, takes the 2048-sample Hann window at the clock, runs an own radix-2 FFT and draws 48 log bands (60 Hz to 10 kHz) with attack/decay and peak hold; a toggle remembers waveform only or both. Without `ffplay` the tab offers Open with the system. The old `shell/app/player.rs` (system player) is no longer used by embeds. Video embeds draw a still (`Session::video_poster`: `qlmanage` on macOS, `ffmpeg` elsewhere) in the file card; with `ffmpeg` installed Play decodes raw RGBA frames on a reader thread (`shell/app/video.rs`, 640 px wide, 24 fps, `-re` pacing, two frames of queue), paints the newest one as a texture and plays the sound through `ffplay`; without it the card keeps Open in default app. Split view: `shell/app/split.rs` keeps a `Split { tabs, main_on_right, texts, docs, folds }`; the centre card is cut into two halves (`halves`), the second pane draws through `Views::show_note` in the Visual view without an editor until it is clicked, and its tab clicks, closes and links come back through `handle_split`; a press inside the pane swaps the two tab lists (`focus_split`, `Split.main_on_right`), so the pane that was clicked is the one with the editor. Spelling: `lint::vocabulary(text)` per note, the engine counts in how many notes each word occurs (`lint.word_notes`), words in two or more notes are the known set handed to the `spelling` rule; suggestions are the closest known words (edit distance 2). Strict line breaks (`editor.strict_line_breaks`, `blocks/paragraph.rs`) join the lines of a paragraph into one wrapped row; off, the default, keeps every line break like Obsidian. Show hidden files (`session.files.show_hidden_files`, `Vault::list_with_hidden`) lists dot folders and notes in the Explorer except `.notescraft` and `.git`.
Graph view. `views/graph_view/`: caps 50,000 notes, 250,000 edges, 2,048 ghosts (over the ghost cap the ghosts are left out, the notes stay); node radius grows with the square root of the degree and shrinks with zoom; labels fade in with zoom; the hovered node's edges light up; the camera follows the layout until the user pans or zooms and fits without the outermost 1% of nodes. `forces.rs`: four sliders (centre, repel, link, distance) applied when released, and up to five colour groups (`path` or `tag:name`), kept for the session. Measured (release, 6,566-note Obsidian Hub vault, owner's Mac): eager CLI open and `info` 2.8 s, `search` 3.0 s and `switcher.search` 3.0 s including that open; the app opens lazily.
### `notescraft-acp` (L0)
Client side of the Agent Client Protocol for the assistant panel (SPEC 10.3): std + `thiserror`/`serde`/`serde_json`, no async runtime and no new dependency. `AgentProcess::spawn(AgentCommand { command_line, cwd, env })` splits the command line with the crate's own POSIX shell-words parser (`split_command_line`: quotes, backslash escapes, empty/unterminated quote is an `Error::CommandLine`, never a shell), starts the agent with the vault root as working directory and piped stdio, and starts three threads: a stdout reader that parses newline-delimited JSON, a stderr collector keeping the last 200 lines (`stderr_tail()`), and a pump feeding `events() -> &Receiver<Event>`; `Drop` kills and reaps the child, `close()` is the graceful path (stdin closed, 2 s grace, then kill, then join). Messages: `initialize` (protocolVersion 1, clientInfo `NotesCraft`, no fs and no terminal capabilities), `session/new` (cwd plus the `McpServerConfig` list handed to the agent), `session/prompt` (`PromptBlock::Text` / `ResourceLink`), the `session/cancel` notification and `answer_permission(&RequestId, Option<&str>)` (`None` = the user cancelled); `initialize_blocking(timeout)` / `new_session_blocking(cwd, servers, timeout)` wait for the answer (`DEFAULT_HANDSHAKE_TIMEOUT` = 20 s) through a request tracker that matches response ids to the pending request kind. Incoming lines become `Event::{Update(SessionUpdate), PermissionRequest, PromptDone, Error, Exited}`; unknown update shapes are `SessionUpdate::Other(Value)`, never errors, and agent requests we do not implement (`fs/*`, `terminal/*`) get a `-32601 method not found` reply. Limits: 4 MiB per stdout line (longer lines are dropped with a diagnostic, the stream continues), 10 000 buffered events (backpressure drops the oldest `Update` first and never a permission request), completed-outcome table capped at 128. Malformed lines, unknown response ids and broken pipes become `Event::Error` or `Err`, never a panic. 42 tests: 29 unit (30-case shell-words table plus 10 hostile command lines, reader/queue/tracker/parser units and three deterministic fuzz tests) plus 13 integration tests against the `examples/stub_agent.rs` stub (exact event order, permission allow and cancel round trip, `session/cancel`, garbage lines, a 5 MiB line, `-32601` for `fs/read_text_file`, handshake timeout, mid-prompt crash, stderr tail, kill on drop).
**Editor tools (`ui-egui`, SPEC 6 items 9, 20, 21):** `properties_view::show_properties` emits typed set/remove/rename and raw frontmatter actions with bounded syntax checks; per-note drafts, imported Obsidian types, calendar controls, chips and a capped expansion map feed the shared command/buffer dispatch after painting. Raw drafts apply on blur and retain invalid input; validation covers the frontmatter subset rather than full YAML grammar. `slash_popup` validates caret triggers through engine commands, filters and navigates raised command rows, and applies returned text/cursor/selection as one undoable buffer edit, cancelling pending choices when the caret leaves the token. `attachment_drop` collects local image-path paste and native/virtual file drops, dispatches attachment commands, inserts one batch of embed links, and preloads bounded image bytes for Visual embeds/lightboxes without disk IO during painting. Disabled image pastes are consumed with a hint, and image width hints preserve aspect ratio. Binary clipboard images need a direct workspace `arboard` dependency; virtual payloads remain subject to the engine registry's parameter cap. Snapshot flags `--editable`, `--properties-raw` and `--slash` expose these states for visual checks.
### `notescraft-mcp` (L5)
Hand-rolled JSON-RPC 2.0 MCP server over `Registry<Session>` (no SDK). `Server::handle_line` maps one stdio line to at most one reply; `serve` is the read-answer-flush loop. `initialize` (protocol version negotiated from a supported list), `ping`, `tools/list`, `tools/call`, `resources/list` (paged, `note://<path>`), `resources/read`, batches up to 32, lines capped at 8 MiB, errors -32700/-32600/-32601/-32602, never a panic. Every engine command is a tool (id with `.` replaced by `_`, schema built from the `ParamSpec`s) plus the SPEC 10 aliases (`read_note`, `write_note`, `search`, `get_backlinks`, `daily_note`, `run_command`, ...). Tool failures are `isError: true` results. Safety: write commands need a read-write session (`--write`), delete needs `--allow-delete`; paths go through the engine's vault guard.
### `notescraft-cli` (app)
`notescraft-cli [--vault P] [--json] <command>`: `info`, `search`, `read`, `write --yes`, `append`, `links`, `backlinks`, `tags`, `daily`, `template list|new|insert`, `export pdf <path> [--out file] [--page a4|letter] [--margin-mm N] [--print]`, `commands`, `run <id> [json]`, `doctor`, `mcp`, `help`. Text by default, `--json` gives `{ok, result, error}`; exit codes 0 ok / 1 command error / 2 usage / 3 locked vault or unsupported; read commands open the session read-only so a running app is not disturbed. Hand-written argument parser (no clap). 57 tests, including the real binary.
### Additions: views, settings, plugins bridge
**Editing (`ui-egui`):** the centre card edits in Source mode (`Cmd/Ctrl+E`, the pill reads SOURCE) with the `editor` crate: every change reaches the session buffer as a minimal diff (`editing::diff_edit`) so undo records, dirty tabs show a dot, `Cmd/Ctrl+S` and a 2 s autosave write through the buffer, `.notescraft/recovery/<hash>.md` mirrors unsaved text and is applied on reopen, autocomplete (titles, aliases, headings, block ids, tags, callouts) pops under the caret, the backlinks `Link` button rewrites the other note, and closing with dirty buffers asks Save all / Discard / Cancel through `craft-quit`. **Settings view:** category nav, search, animated controls, debounced change list applied through `settings.set`. **Canvas view:** pan/zoom, select, move, resize, edges, undo, 2 000-node draw cap, JSON preserved. **Plugins in the engine:** `plugins.rs` loads `.notescraft/plugins/<id>/`, honours enable and permission approvals from settings, and runs plugin commands on a worker thread with a `SessionBridge` that serves host calls on the calling thread (no unsafe); commands `plugins.list/enable/disable` and `plugin.<id>.<command>`. **CLI:** `notescraft-cli mcp [--write] [--allow-delete]` serves MCP over stdio.
### Git and sidebar tabs
**`notescraft-git` (L0):** the `git` command line only (no shell, `--` separators, validated paths and revisions, `GIT_TERMINAL_PROMPT=0`, 30 s timeout, output caps): `status` (porcelain v2), `diff` (hunks with line numbers), `stage`, `unstage`, `restore`, `commit` (message on stdin), `log`, `show`, `restore_version`, `init` (writes a `.gitignore` for `.notescraft/`). **Engine:** `Session::git()`, ten `git.*` commands, `Session::refresh_paths` re-reads files rewritten behind the session (a git restore); dirty buffers keep their text and record a conflict. **UI:** the sidebar has five icon tabs: Explorer, Search (ranked results with snippets), Tags (tag tree to Search filter), Buffers (open notes, dirty dots, Save all) and Source (git: Staged and Changes lists with stage/unstage/discard, commit box, history with restore, coloured diff viewer in the centre card, optional auto-snapshot via `git.auto_commit_minutes`); git runs on one worker thread, never on the UI thread; the UI refreshes sessions after a restore through `take_restored` + `refresh_paths`.
### `notescraft-i18n` (L5)
`Catalog` (flat dotted keys, TOML with nested tables flattened, caps 2 MiB / 20 000 keys), `I18n` with the embedded English catalog (`locales/en.toml`, 60+ keys), `t`, `t_args` (single-pass `{name}` replacement, no re-expansion), `plural` (`zero`/`one`/`other`), missing-key recording, `coverage` reports (missing, extra, mismatched placeholders) and locale id validation. The UI crates still hold their strings in constants blocks and move to this crate later.
### `notescraft-themes` (L3)
`Theme { colors, callouts, graph, syntax, radii, fonts }`, `Rgba` + WCAG `contrast_ratio`, `Theme::from_toml` with per-key diagnostics and fallback to the built-in of the same variant, `to_toml`, eight built-ins (`dark`, `light`, `catppuccin-{mocha,latte,frappe,macchiato}`, `solarized-{dark,light}`) verified for contrast in tests, and the optional `[syntax]` table of the eight fenced-code token colours (see "Code blocks, grammars and formatting"; each must hold 4.5:1 on `code_background`). Some Latte colours are darkened from stock Catppuccin to pass 4.5:1 (documented in `builtin.rs`).
### Not yet implemented
`engine` parts: plugin wiring, `editor`, `ui-egui`, `mcp`, `i18n`, apps. Their stubs carry `TODO(phase N)`.
## Key decisions
1. Native egui editor on `ropey` (no CodeMirror/JS). 2. `notescraft-*` crate names; `craft-*` reserved for craft-libs. 3. WASM plugins via `wasmi`. 4. Hand-rolled MCP over `serde_json`. 5. Hand-rolled in-memory BM25 index, not Tantivy. 6. Hand-written force layout. 7. Own minimal YAML-subset frontmatter parser; unknown YAML preserved. 8. No wasm/web build in v1. 9. Everything is an engine command. 10. Spec in root `SPEC.md`. Details and reasons: SPEC section 1.
Lossless-by-construction rule: parsers keep the original source and byte-range items; writers edit only the targeted bytes, so files stay readable and byte-stable for Obsidian.
## Quality gates
`cargo xtask ci` = fmt check + clippy `-D warnings` (pedantic) + tests + layer check. Perf numbers recorded in tests marked `#[ignore]` (run with `--release -- --ignored`).
In the Visual view (`ui-egui/src/views/note_view/edit.rs`) a click on a rendered row or block opens its markdown in place as a text field (Enter continues lists, Up/Down move between blocks, Backspace joins, `Esc` leaves, Cmd+B/I/E and Cmd+Shift+H/X format); edits are reported as source replacements (`NoteEdit`) and applied through the same buffer path as the Source view. Edits made in a block have the text field's own undo only.
`cargo xtask i18n` is the SPEC §12 coverage check for user strings: it scans `crates/ui-egui/src/**/*.rs` and `apps/*/src/**/*.rs` (test files, `#[cfg(test)]` modules and example directories are skipped), lexes every file with a small hand-written lexer (comments, raw strings, char literals and escapes handled), and reports string literals passed to egui text APIs (`.label(`, `RichText::new(`, `.button(`, `Button::new(`, `.on_hover_text(`, `.heading(`, `Label::new(`, `selectable_label`, `hint_text(`, painter `.text(`) that are not already covered by a `// i18n-ok` marker line or the ignore rules (empty/symbol-only, format placeholders, identifiers/ids/path-like, `Id::new`). Literals inside `mod strings` blocks are counted separately as "pending migration"; none remain, UI copy lives in `crates/ui-egui/src/text/*.rs` (functions that look up `ui.<panel>.<name>` in `crates/i18n/locales/en.toml` via `notescraft_i18n::english()`). The command is non-failing by default (per-file counts, first 5 offenders, totals; `--json` for tooling) and only `cargo xtask i18n --strict` fails when any non-table literal is found; it is part of `ci`.
## Build and workflow notes
Use rustup stable (1.99 here); the Homebrew cargo is too old for `rust-version = 1.95`.
## Source layout
Rule (AGENTS.md, `cargo xtask tree`): no source directory holds more than 12 loose `.rs` files; groups are sub-folders, nested when they grow, and public paths stay stable through `pub use` in `lib.rs`.
- `ui-egui/src`: `shell/` (app, layout, panel resize, title bar, status bar, state, Vim navigation; `shell/app/` splits into `editor/`, `vault/`, `window/`), `views/` (note, canvas, diagram, graph, folder, math, diff, properties, settings, whiteboard with `scene/ shape/ chrome/ layers/ palette/`), `panels/` (search, source control, backlinks, bookmarks, buffers, tags, outline, tree, sidebar, right card; `assistant_panel/` with `input/ backend/ chat/ state/`), `widgets/` (dialog, context menu, palette, slash popup, tabs, export dialog, autocomplete, previews, colour menu), `design/` (icons, Lottie icons, fonts, theme, style, motion, sounds), `editing/`, `text/` (copy tables by area: `settings/ views/ panels/`).
- `engine/src`: `vault/` (session, notes, buffers, folders, bookmarks, attachments, search, properties, daily), `builtin/` (the registered commands), `ai/` (assistant gate, queue, chat history, connections), `prefs/` (settings, themes, config), `batch/` (review proposals, commands, per-session state), `grammars/`, `plugin_host/`, `slash_kit/`, `control/`.
- `acp/src`: `client/` (process, protocol, reader, queue), `openai/` (curl backend and SSE stream).
- `diagrams/src`: `parsers/`, `layout/`. `vim/src`: `motions/`, `edit/`, `input/`. `mcp/src`: `protocol/`, `calls/`. `whiteboard/src`: `doc/`, `render/`. `lottie/src`: `decode/`, `eval/`. `math/src`: `syntax/`, `layout/`. `index/src`: `reading/`, `lookup/`, `search/`. `markdown/src`: `scanning/`, `frontmatter/`. `backlinks/src`: `mentions/`. `plugins/src`: `sandbox/`.