Files
notescraft/docs/ARCHITECTURE.md
T
Marcos Paulo 5e8020aa38
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: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

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

82 KiB

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 CMaps 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 Diagnostics; 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.

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-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 ParamSpecs, 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 ParamSpecs, 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 Cues 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 TokenKinds (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 Actions, 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 embeds get Play/Stop (shell/app/player.rs: afplay, ffplay/paplay/aplay, or PowerShell, one sound at a time, killed on Stop and on exit). 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 ParamSpecs) 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/.