Files
notescraft/SPEC.md
T
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity
- Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys
- Visual view: tables, note embeds, %% comments, wrapping property chips
- Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin
- Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:36:00 -03:00

47 KiB
Raw Permalink Blame History

NotesCraft: specification

Status: approved 2026-10-13. Phase 0 (foundation) may start; later phases follow the ROADMAP.

NotesCraft is a clean-room, native, Obsidian-compatible markdown note-taking app in the Craft suite. It is written in Rust only (egui/eframe on wgpu), like PhotoCraft and VectorCraft. The product name is always written NotesCraft in user-facing text; machine names are lowercase (notescraft-* crates, binaries, ids).

1. Decisions taken (from the review of PhotoCraft)

# Decision Reason
D1 Editor is a native egui editor on ropey, not CodeMirror 6. PhotoCraft AGENTS.md forbids JS/TS and webview UI. CM6 is JS.
D2 App crates are named notescraft-<name>. Only shared libs are craft-*. Matches photocraft-* / vectorcraft-*. craft-* is reserved for craft-libs, which forbids app code.
D3 Plugins are sandboxed WebAssembly hosted by wasmi. Same as photocraft-plugins / vectorcraft-plugins. No JS engine. Obsidian JS plugins do not run.
D4 MCP server is a hand-rolled JSON-RPC layer over serde_json; no rmcp. Same as vectorcraft-mcp. No new dependency.
D5 Full-text index is hand-rolled and in-memory (inverted index, BM25), not Tantivy. No heavy new dependency; vaults are tens of thousands of notes at most. Revisit if a benchmark in perf/ misses its budget.
D6 Force layout is hand-written (Fruchterman–Reingold with a grid cutoff). About 100 lines; no crate needed.
D7 Frontmatter uses an own minimal YAML-subset parser, not a YAML crate. The subset Obsidian needs is small (scalars, lists, inline lists, booleans, numbers, dates). Unknown YAML is preserved verbatim.
D8 No wasm/web build in v1. FS code is still cfg(not(target_arch = "wasm32")) so L0–L3 stay portable. Watcher and locking are native.
D9 Everything user-visible is an engine command (id, label, menu path, shortcut, params, enabled, run). UI, CLI, control channel and MCP dispatch by id. PhotoCraft golden rule 1.
D10 Spec lives in root SPEC.md. Requested deliverable.

2. Dependencies

2.1 Craft libs (pinned to the same git rev as PhotoCraft/VectorCraft)

Crate Used for
craft-atomic every write of a note, canvas, settings, plugin data, template, image
craft-settings per-app config.toml, migrations, backup
craft-ui icons, IconButton, tabs, toolbar fitting, panel docking, theme helpers
craft-quit native quit and unsaved-document prompts
craft-print submit the exported PDF to the system spooler. NotesCraft renders the PDF itself

No change to the craft-libs API surface is planned. If one is needed it is raised before it is made.

2.2 Third-party additions (new to PhotoCraft and VectorCraft)

Each needs a written reason in the PR that adds it.

Crate Reason Crate using it
pulldown-cmark CommonMark/GFM tokenizer; we layer the Obsidian extensions on top. Writing a conformant CommonMark parser is not justified. markdown
notify cross-platform FS events for the vault watcher. vault
ropey rope buffer for large notes and cheap undo of edits. editor

Already in the workspace and reused: wasmi, serde, serde_json, thiserror, egui, eframe, rfd, log, rayon, proptest, image, criterion, egui_kittest. Licences: all MIT OR Apache-2.0 compatible. Any further dependency is a question to the owner first.

3. Workspace

Edition 2024, resolver = "3", rust-version = "1.95", license MIT OR Apache-2.0, [lints] workspace = true in every crate, unsafe_code = "forbid". Members: crates/*, apps/*, xtask. Every crate is crates/<name>, named notescraft-<name>, and registered in the root [workspace.dependencies].

3.1 Layers (enforced by cargo xtask layers)

A crate depends only on lower layers. Nothing below L5 uses egui, eframe, winit or rfd (L5 is the editor widget; ui-egui is L6 so it can compose it).

Layer Crate Scope
L0 vault vault root, lock file, path-traversal guard, FS watcher, file read/write through craft-atomic
L0 git git versioning of the vault through the git command line: status, diff, stage, commit, log, restore
L0 markdown Obsidian-flavored parser and writer, lossless round trip, AST with source spans
L1 index in-memory BM25 full-text index, link graph, tag index, alias/path resolution
L2 backlinks backlinks and unlinked mentions from index
L1 canvas Obsidian-Canvas JSON model, read/write, validation
L2 graph force-directed layout (pure data, no rendering)
L2 templates daily notes and templates (date formatting, variable expansion)
L3 plugins wasmi plugin host and ABI
L3 themes theme model, loading, validation
L4 engine Session, command registry, settings glue, undo, dirty tracking
L5 editor native egui editor widget: cursor, selection, live-preview styling, autocomplete popup, image paste
L6 ui-egui app shell, panels, graph and canvas renderers, command palette, settings panel
L5 mcp MCP server tools over the engine
L5 i18n string tables and lookup
L1 diagrams Mermaid-subset parser, layout and SVG/egui shapes (flowchart, sequence, class/state UML, ER, gantt, pie)
L1 whiteboard Excalidraw-compatible model, read/write, seeded hand-drawn shape generator
– testkit fixtures: sample vaults, fuzz helpers

Apps: apps/notescraft (desktop, eframe/wgpu, control server), apps/notescraft-cli (headless: info, search, links, export, commands, mcp).

Crates are added beyond this list only with written justification.

3.2 Non-negotiables

  • Never crash. Every crate starts with #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]. No unwrap() in production paths. get() instead of indexing for input-derived indices. Bound recursion (embed cycles, link cycles, deeply nested lists). Cap allocations sized by input.
  • No silent panics across the plugin boundary. Every host call into a plugin returns Result; traps, fuel exhaustion and memory-limit hits become errors.
  • Public APIs have rustdoc. Every module has a //! comment.
  • clippy::pedantic clean. Exceptions only through a written entry in clippy.toml.
  • Every crate has at least one failing-then-passing test before merge.
  • Error type pattern: one thiserror enum per crate (Error) with a pub type Result<T>; no anyhow in libraries.
  • Tests: unit in src, integration in tests/, benches in perf/, examples in examples/.
  • No hardcoded colours or radii: they come from theme tokens.
  • No font files committed; fonts come from craft-fonts via CRAFT_FONTS_DIR.

4. Vault format and Obsidian compatibility

A vault is a directory. Notes are UTF-8 .md files. Vault files stay readable and writable by Obsidian; any change that breaks that needs owner approval.

4.1 Layout

<vault>/
  *.md, subfolders/        notes, attachments (any file type)
  *.canvas                 canvas files (Obsidian Canvas JSON)
  .notescraft/             NotesCraft-private state (lock, cache, plugins, themes)
  • NotesCraft never writes to .obsidian/. It may read .obsidian/app.json and .obsidian/daily-notes.json once to import the attachment folder, new-note location and daily-note folder/format as defaults, with a visible import prompt. Reading never modifies.
  • Unknown files and folders are left untouched. Hidden folders are not indexed.
  • Line endings and trailing newline of a file are preserved unless the user edits that region. Frontmatter bytes are preserved verbatim unless a command edits a key.
  • A note is saved only if its content changed. Saves go through craft-atomic (temp file, fsync, rename).

4.2 Safety

  • Path-traversal guard. Every path that enters vault (from UI, CLI, MCP, plugin, link text) is normalised and must resolve inside the vault root after symlink resolution. .., absolute paths, Windows drive and UNC prefixes, and NUL are rejected with Error::OutsideVault. Symlinks that escape the vault are not followed.
  • Lock file. .notescraft/lock holds pid and start time. A second instance opens the vault read-only with a banner, or offers to take over if the pid is dead.
  • External changes. The watcher debounces events. If a file changes on disk while the buffer is dirty, the user gets a three-way choice (keep mine / take disk / open both); a clean buffer reloads silently. Our own writes are recognised and do not echo.
  • Case-insensitive file systems. Link resolution is case-insensitive; the on-disk case is used when writing.

Obsidian "shortest path when possible":

  1. [[Name]] matches a note by basename (without .md), case-insensitive, anywhere in the vault.
  2. If several match, the one nearest to the linking note wins, then lexicographic path. The index reports the ambiguity.
  3. [[folder/Name]] and [[/folder/Name]] match by path suffix and by vault-absolute path.
  4. Aliases from frontmatter aliases resolve like names but rank below exact basenames.
  5. [[Name#Heading]], [[Name#^block-id]], [[#Heading]] and [[#^block-id]] target a heading or block inside a note.
  6. Unresolved links are kept in the index as "unresolved" and shown in the graph and a dedicated list.
  7. Rename and move update inbound links by default (setting links.update_on_rename).

5. Markdown dialect

notescraft-markdown parses CommonMark + GFM (tables, task lists, strikethrough, footnotes) via pulldown-cmark, plus these Obsidian extensions. The writer round-trips: write(parse(text)) == text for every input in the corpus and in proptest (lossless; unknown constructs are kept as raw spans).

Construct Syntax AST node
Wikilink [[target]], [[target|alias]], [[target#heading]], [[target#^id]] WikiLink { target, subpath, alias }
Embed ![[note]], ![[note#heading]], ![[img.png|300]], ![[img.png|300x200]], ![[doc.pdf#page=3]], ![[x.canvas]] Embed { target, subpath, size }
Markdown links [text](path/to/note.md#heading), URL-encoded paths, <path with spaces> Link (internal when it resolves in the vault)
Callout > [!type] Title, > [!type]+ (open), > [!type]- (collapsed), nested Callout { kind, title, fold, body }
Frontmatter --- YAML block at byte 0 Frontmatter { raw, parsed }
Tags #tag, #nested/tag, frontmatter tags / tag Tag
Block ref ^block-id at end of a paragraph/list item, [[note#^id]] BlockId
Highlight ==text== Highlight
Comment %%text%% (inline and block), hidden in preview Comment
Inline code / code fences / math $..$, $$..$$ kept as Math nodes (rendered by item 30) Math

Rules:

  • Wikilinks, tags and ==..== are not recognised inside code spans, code fences, math or comments.
  • Tag grammar: # followed by letters, digits, _, -, / with at least one non-digit character; not preceded by a word character.
  • Heading anchors for #Heading links follow Obsidian: strip markdown, collapse whitespace, case-insensitive match.
  • Block IDs: [A-Za-z0-9-]+. A block ID on its own line after a block (list, quote, table) applies to the whole block.
  • Unsupported constructs are never dropped: they stay as raw text and are saved byte-for-byte.

5.1 Callouts

All 13 Obsidian types, with their aliases. Unknown types render with the note style and keep their own name.

Type Aliases Icon token
note – pencil
abstract summary, tldr clipboard
info – info
todo – check-circle
tip hint, important flame
success check, done check
question help, faq help-circle
warning caution, attention alert-triangle
failure fail, missing x
danger error zap
bug – bug
example – list
quote cite quote

Colours come from theme tokens callout.<type>; icons from the craft-ui SVG icon system (missing icons are added to craft-ui only after owner approval, otherwise NotesCraft ships its own app-local SVGs).

6. Features in v1

Each row is a command group; acceptance criteria are in §12.

  1. Vault and files. Open/create vault, file tree, create/rename/move/delete note and folder (delete goes to the OS trash), recent vaults.

  2. Editor. Source and live-preview modes, wikilink/tag/heading/block autocomplete ([[, #, [[note#, [[note#^), list/task continuation, undo/redo, find and replace in note, tabs and split panes via craft-ui, unsaved prompt via craft-quit.

  3. Wikilinks. Create, follow (click, Ctrl/Cmd-click opens in a new tab), hover preview, alias, path, heading and block targets, embeds of notes (cycle-safe, depth limit 5) and images.

  4. Backlinks pane. Linked and unlinked mentions for the active note, with context snippets, counts, and "link it" for an unlinked mention.

  5. Graph view. Global and local (depth 1–3) force-directed graph, node size by degree, tag/folder colour groups, filters (orphans, unresolved, tags, attachments), zoom/pan, click to open, drag to pin.

  6. Full-text search. Ranked (BM25) over body, title, headings, tags, frontmatter; operators path:, file:, tag:, line:, "phrase", -term, regex /…/; result snippets; updates incrementally from the watcher.

  7. Tag tree. Nested tags with counts; click filters search.

  8. Daily notes and templates. Daily note command with folder, filename format (Moment-style tokens YYYY-MM-DD subset) and template; templates folder; variables {{title}}, {{date}}, {{date:FORMAT}}, {{time}}. Core-plugin Templater syntax is not supported.

  9. Image paste. Paste or drop image data/files: saved into the configured attachment folder as Pasted image YYYYMMDDHHMMSS.png, inserted as ![[name.png]]; name collisions get a numeric suffix; size capped (setting, default 64 MiB).

  10. Callouts. Render all 13 types and aliases, foldable, nested; insert via command and autocomplete after > [!.

  11. Canvas. Open, edit, save .canvas files (§9). Pan/zoom, add text/file/link/group nodes, edges with sides and labels, colours, resize, multi-select, undo/redo. Note files can be embedded as file nodes.

  12. Themes. Built-in Dark, Light, Catppuccin (Latte/Mocha), Solarized (Light/Dark); live switch without restart; user themes (§8).

  13. Command palette. Fuzzy search over every registered command with shortcut display; also quick switcher for notes (Ctrl/Cmd-O).

  14. Settings panel. Backed by craft-settings; categories: Editor, Files and links, Appearance, Hotkeys, Daily notes, Core plugins, Community plugins, AI assistant (agent command, review-writes, write permission, per-plugin tool toggles), Advanced. A setting that is not read by any code is not shown.

  15. Atomic save. Via craft-atomic; autosave after 2 s idle (setting); crash recovery buffer in .notescraft/recovery/.

  16. PDF export. notescraft-ui-egui/engine renders the note (headings, lists, tables, callouts, images, code) to PDF; craft-print submits it to the spooler. A4/Letter, margins, theme-independent print style.

  17. Plugin host. WASM (§7).

  18. MCP server. (§10).

  19. Localization. English only in v1, with the full i18n plumbing (every user string goes through it) and an xtask coverage check like PhotoCraft's i18n_coverage.

  20. Properties panel. Typed editor for frontmatter above the note body: text, number, checkbox, date, datetime, list, tags, aliases. Types are inferred from values and from Obsidian's .obsidian/types.json (read-only import). Edits go through the same command as MCP patch_frontmatter, touch only the changed key, and keep unknown YAML verbatim. A raw-YAML toggle shows the source.

  21. Slash menu. Typing / at the start of a line or after a space opens a filterable popup of insert actions: heading 1–3, bullet/numbered/task list, quote, callout (all 13 types), table, code block, divider, link to note, embed note/image, today's date, insert template, new canvas. Every entry is an engine command, so plugins add entries by registering commands with the slash flag. Escape or a non-matching query closes it; the inserted text is plain markdown.

  22. Outline pane. Heading tree of the active note, click to jump, current heading follows the cursor, collapsible levels. Shown as a tab beside Backlinks.

  23. Bookmarks. Star a note, heading, block, search query or canvas; shown in the sidebar, groupable. Stored in .notescraft/bookmarks.json, never in .obsidian/. .obsidian/bookmarks.json may be imported once on request.

  24. Breadcrumbs. Folder path and note title above the editor, each folder segment clickable (opens a folder picker listing notes). Pure view over the vault tree; no extra data.

  25. Assistant panel (agent chat). A docked chat panel that drives an external coding or chat agent the user already has (Claude, Codex, Cursor, Gemini, Copilot or a custom command). The agent is chosen once in Settings → AI assistant, not in the panel; the panel shows no agent switcher. NotesCraft contains no model, API key, or network code: it spawns the user-configured agent command over stdio and exposes the vault through the §10 MCP server. The panel shows the conversation, the active note as an attachable context chip, and tool-call cards (tool, target path, status RUNNING/DONE/FAILED, one-line summary). Agent writes go through the same engine commands as the user: they are undoable, atomic, and shown as a diff the user accepts before save when assistant.review_writes is on (default on). The MCP server runs in read-only mode unless the user grants write per session. Transcripts are kept in .notescraft/chat/, never in the vault notes. No telemetry.

  26. Core plugins integrated with the assistant. Calendar, Templates, Diagrams and Whiteboard ship as bundled core plugins that register ordinary engine commands. Every command a plugin registers (core or WASM) is exposed to the assistant as an MCP tool named plugin_<id>_<command>, gated by a per-plugin toggle in Settings → AI assistant → Plugin tools. The panel's quick-action chips (Template, Calendar, Diagram) are shortcuts to these tools.

    • Calendar: a sidebar month grid (days that have a daily note are emphasised, today is filled; click opens or creates that day's note, feature 8) and a full Calendar tab (View > Calendar, Mod-Alt-C) with month, week, day, agenda and year views. Events are one note each in the calendar folder (default Calendar/), with frontmatter title, start, end (YYYY-MM-DD or YYYY-MM-DDTHH:MM), tz, repeat, exceptions, calendar, reminders (minutes before), tags, priority, status, link, note; unknown keys and the body are kept. Dated tasks (- [ ] text 📅 YYYY-MM-DD or due: frontmatter) share the agenda. Repeats: daily, weekly (with weekdays), monthly, yearly, interval, UNTIL or COUNT, exceptions; changing a repeating event asks this, following or all. Quick add reads English phrases ("lunch tomorrow at 12:30 for 1h #food @Home every friday remind 10m") and previews them; an unreadable part is named and nothing is saved. Filter, sort, group by calendar, kind, status, priority, tag, range and reminder, saved by name; multi-day selection feeds a bulk bar that shows the count before it applies. Keyboard: h l day, j k week, H L month, [ ] year, t today, gd go to date, gm gw gD ga gy views, Tab focus an event, e/Enter open, o add, v grab (then h j k l move, J K resize, Enter drop, y copy) or select a range, V weeks, x mark, b bulk bar, dd delete, yy yank, p paste, . repeat, / n N find, f free time, s sort, c calendar filter, gn meeting note, gR week in review, i reminders inbox, z zoom, Esc back out. Reminders fire while the app runs (system notification, toast, sound), respect quiet hours, can be snoozed or dismissed, and those that fired while the app was closed wait in the inbox as missed. Imports are read-only: ICS feeds (https://, webcal://) refreshed in the background with a cache that keeps showing offline, and Google Calendar (view-only, sign-in in the browser, refresh token in the system keychain, needs a configured client id). Apple, Outlook, CalDAV and two-way sync are not built. Commands (all calendar.*, so the UI, CLI run, MCP and the assistant share them): agenda, next, free, conflicts, year, parse, review, add, edit, move, delete, note, bulk, bulk_delete, filters, filter_save, filter_delete, reminders, reminders_tick, reminder_snooze, reminder_dismiss, feeds, feed_add, feed_remove, sync_due, sync_apply, google_state, google_calendars, google_disconnect, plus month. MCP tools are named calendar_<command>; the assistant's short list holds calendar_agenda, calendar_add, calendar_edit. The calendar plugin toggle (Settings, AI assistant, Plugin tools) is on until switched off; deletes need the delete flag; assistant writes become pending changes. Spec and plan: specs/calendar-plugin-redesign/.
    • Templates: picker listing the templates folder with preview; variables per feature 8. Tools: templates_list, templates_insert, templates_create_note.
    • Diagrams: fenced blocks ```mermaid render inline. Native Rust renderer (crate diagrams), no JavaScript. v1 supports a Mermaid subset: flowchart/graph, sequenceDiagram, classDiagram and stateDiagram-v2 (UML), erDiagram, gantt, pie. Unsupported syntax shows the source with a diagnostic and is never dropped. Output is also exportable as SVG/PNG and embeds in PDF export. Tools: diagrams_render, diagrams_insert.
    • Whiteboard (Excalidraw alternative): freeform drawing surface stored in .draw files (.excalidraw is still readable for legacy vaults): rectangle, ellipse, diamond, arrow, line, freehand, text, image, with a hand-drawn rendering style (seeded roughness, so output is deterministic) and a clean style toggle. Read/write targets Excalidraw v2 element JSON (type: "excalidraw"); unknown fields are preserved. Embeds in notes as ![[board.draw]]; .excalidraw stays readable and is saved back in place. Distinct from Canvas, which stays strictly JSON Canvas. Tools: whiteboard_read, whiteboard_add_shapes. 26b. Vault knowledge (retrieval). Passage-level retrieval over the vault with no embedding model and no network: notes are split at headings and paragraphs (at most 1,200 characters), ranked with BM25, then boosted by the link graph, shared tags and a vocabulary learned from the user's own notes (words that co-occur), optionally recency. Commands knowledge.{search,context,related,status,rebuild}, MCP alias vault_context; the assistant panel adds the best passages to a prompt within a token budget and lists them as sources ([n] markers and chips); the right card has a Related tab; Settings > AI assistant > Knowledge holds the switches, sliders and excluded folders (session.knowledge.*). Plugin knowledge toggles the tools. Screens lay out down to 390 pt (sources as a bottom sheet). Spec: specs/vault-knowledge/.
  27. Visual / Markdown toggle and status bar. The editor switches between live preview and raw markdown with one control; a footer shows the git branch (if the vault is a repo, read-only), word and character counts and an approximate token count.

  28. Versioning with git. A vault that is a git repository gets a Source panel (sidebar tab) with changed files grouped Staged / Changes, per-file stage, unstage and discard, a commit box, history (whole vault and per note), a coloured unified diff viewer and "restore this version". Init repository offers to create the repo with a .gitignore for .notescraft/ and .obsidian/workspace*. Everything runs through the git command line (no libgit2; git must be installed, otherwise the tab explains it), never through a shell. No network operations in v1 (no push, pull, fetch); branch name and ahead/behind are shown read-only. Engine commands git.status, git.diff, git.stage, git.unstage, git.commit, git.log, git.show, git.restore, git.init make it available to the CLI, MCP and plugins. Optional auto-snapshot (setting git.auto_commit, off by default): commit all changes after N minutes of inactivity.

  29. Sidebar tabs. The sidebar switches between Explorer (tree, calendar, tags), Search (query box and ranked results), Tags (tag tree with counts), Buffers (open notes with dirty markers, click to switch, close) and Source (git). Icon tabs with tooltips, animated pill.

  30. Vim mode. An opt-in modal editing mode (setting editor.vim, off by default, toggle in Settings > Editor and the command editor.toggle_vim). Pure crate vim (state machine over text, no egui): Normal, Insert, Visual (char, line), Replace modes; counts; motions h j k l w b e W B E 0 ^ $ gg G { } % f F t T ; , H M L Ctrl-d Ctrl-u; operators d c y > < g~ gu gU with motions and text objects (iw aw ip ap i( a( i[ a[ i{ a{ i" a" i'); x X s S r R J p P u Ctrl-r . ~ o O A I a i; registers ("a.."z, unnamed, "+ system clipboard); marks ma 'a; search / ? n N *; ex line :w :q :wq :e <note> :bn :bp :s/a/b/ :%s :noh :set plus any engine command by id (:note.create). Markdown-aware: gx follows the link under the cursor, gf opens a wikilink, za folds a heading. App-wide navigation when vim mode is on and no text field has focus: j k move in lists and trees, h l collapse/expand folders, Enter open, gt gT next/previous tab, Ctrl-w h/l move focus between sidebar, editor and right card, Space leader menu (f quick switcher, / search, g git, b buffers, t tags, e explorer, s settings), : opens a command line at the bottom of the window, ? shows the key help card. A status line shows the mode, pending keys and the command line. Never panics on any key sequence (fuzz-tested).

  31. LaTeX math. $inline$ and $$display$$ (also \(..\) and \[..\]) render as typeset formulas in the Visual view, in the Source view's live preview line under the cursor, in callouts and tables, and in exports. Pure crate math (L0, no egui): a TeX-math subset parser (fractions \frac \dfrac \tfrac, roots \sqrt[n]{}, sub/superscripts, big operators with limits \sum \prod \int \oint \lim \max, Greek and symbols, relations and arrows, accents \hat \bar \vec \dot \tilde, \left..\right delimiters, \text \mathrm \mathbf \mathbb \mathcal \mathit, spacing \, \; \quad, matrices matrix pmatrix bmatrix vmatrix cases aligned) laid out by TeX-style rules into a draw list (glyph runs, rules, radical paths) from a Metrics trait, so layout is testable without a window. egui side paints the draw list with a bundled OFL math font (STIX Two Math or Latin Modern Math), theme colours, baseline aligned with surrounding text, scales with the UI scale. Invalid TeX never fails the page: the formula shows its source in the danger colour with the parser message as a tooltip. Display math is centred and scrolls horizontally when wider than the card. Performance: layouts are cached by (source, size). Engine/MCP: math.render returns the draw list as JSON for plugins; markdown export keeps $..$ source.

6.1 Deferred (documented, not built)

Sync, mobile apps, publish, git remotes (push/pull/fetch), a visual merge tool, Bases/database views, built-in model hosting or API-key management for AI, JS plugin compatibility, community-plugin/theme store, web (wasm) build, Obsidian Publish, Web Clipper, Slides, Audio recorder, Templater, Dataview-style queries, Workspaces core plugin, additional languages.

7. Plugin contract (notescraft-plugins)

Choice: sandboxed WebAssembly via wasmi (pure Rust, no JIT, resource-limited), the same model as photocraft-plugins. JavaScript plugins are not supported; reasons: PhotoCraft rule against JS, no second engine to audit, deterministic resource limits.

7.1 Package

A plugin is a folder in <vault>/.notescraft/plugins/<id>/:

manifest.json    { "id", "name", "version", "min_app_version", "description", "author", "permissions": [..], "commands": [{ "id", "name" }] }
plugin.wasm      the module

permissions is an explicit allow-list; the user approves it on first enable. Unknown permissions reject the plugin. Disabled by default; enabling is a command.

7.2 ABI (version 1)

The module exports:

  • ns_abi_version() -> i32 (must equal 1)
  • ns_alloc(len: i32) -> i32 / ns_free(ptr: i32, len: i32)
  • ns_on_load() -> i32, ns_on_unload() -> i32
  • ns_run_command(cmd_ptr: i32, cmd_len: i32, args_ptr: i32, args_len: i32) -> i64 returning packed (ptr << 32 | len) of a JSON result in guest memory
  • ns_on_event(ptr: i32, len: i32) -> i32 (JSON events: note.opened, note.saved, note.changed, vault.indexed)

The host imports (module ns), all JSON in/out through guest memory, each returning a packed (ptr,len) or a negative error code:

  • ns.log(level, ptr, len)
  • ns.vault_read(path) : needs vault.read
  • ns.vault_write(path, content) : needs vault.write; writes go through the same path guard and craft-atomic
  • ns.vault_list(glob), ns.search(query), ns.links(path), ns.backlinks(path) : need vault.read
  • ns.active_note(), ns.insert_text(text) : need editor
  • ns.notice(text)

No network, no filesystem outside the vault API, no clock beyond ns.now(), no threads.

7.3 Limits (defaults, configurable by the user per plugin)

Fuel 50 M instructions per call, memory 64 MiB, call wall-clock 2 s, result size 4 MiB, 8 host calls per ms. Exceeding any limit aborts the call, returns Error::PluginLimit, and after 3 consecutive failures the plugin is auto-disabled with a notice. Plugin code never panics the host: all guest traps map to Error::PluginTrap.

7.4 Registration

Plugin commands are registered into the engine registry as plugin.<id>.<command> and appear in the command palette. A hostile-module test suite (WAT fixtures) covers infinite loops, huge allocations, bad pointers, oversize results and missing exports.

8. Theme contract (notescraft-themes)

A theme is a TOML file (<vault>/.notescraft/themes/<id>.toml or app-bundled):

id = "catppuccin-mocha"
name = "Catppuccin Mocha"
variant = "dark"            # dark | light
[colors]
background = "#1e1e2e"
surface = "#181825"
text = "#cdd6f4"
text_muted = "#a6adc8"
accent = "#cba6f7"
link = "#89b4fa"
link_unresolved = "#f38ba8"
selection = "#45475a"
border = "#313244"
code_background = "#11111b"
highlight = "#f9e2af55"
[callouts]
note = "#89b4fa"  # … one entry per type in §5.1; missing ones derive from `accent`
[graph]
node = "#a6adc8"  node_active = "#cba6f7"  edge = "#45475a"  unresolved = "#f38ba8"
[radii]
small = 4.0  medium = 6.0
[fonts]
ui = "…"  editor = "…"  mono = "…"

Rules: all keys optional except id, name, variant; missing colours fall back to the built-in theme of the same variant. Colours parse as #rrggbb or #rrggbbaa; invalid values reject only that key with a diagnostic. No CSS, no scripting. Built-ins in v1: Dark, Light, Catppuccin Latte/Mocha, Solarized Light/Dark. Themes map onto craft-ui theme helpers. Switching is live and persisted via craft-settings.

8.1 Cross-platform window chrome

  • macOS: native title bar and traffic lights (transparent, content extends under it).
  • Windows and Linux: custom 40 px title bar drawn by ui-egui (app icon, File/Edit/View/Help menus, centred note and vault title, minimise/maximise/close controls 48×40, drag to move, double-click to maximise, edge resize, snap and maximise behaviour left to the OS). Window decorations are turned off on these platforms; if the compositor refuses (some Linux Wayland setups) the app falls back to native decorations.
  • Rounded, floating panels. The window background is the canvas token; the sidebar, editor and right panel are separate cards with 16 px radius and 8 px gaps (inset look). Inner controls use 8, 12 or 999 px radii; nothing in the UI has square corners except the OS window edge. Panels separate by tone (bg, surface, surface-2), not by borders; borders appear only on focus rings and form inputs that need an affordance.
  • Design reference: PaperCraft project NotesCraft (screens Workspace / macOS, Workspace / Windows + Linux, Graph view, Command palette).

8.2 Catppuccin as the reference palette

The default Dark theme is Catppuccin Mocha and the default Light theme is Catppuccin Latte; Frappé and Macchiato ship as extra built-ins (§8 lists 6 built-ins; this makes 8). Token mapping: bg=base, surface=mantle, surface-2=surface0, border=surface1, text=text, text-muted=subtext0 (Latte: subtext1, for 4.5:1), accent=mauve, link=blue, link_unresolved=red. Every text/background pair must hold 4.5:1; Latte callout colours are darkened where the stock colour fails.

9. Canvas contract (notescraft-canvas)

Compatible with the JSON Canvas 1.0 format that Obsidian reads and writes.

{ "nodes": [ … ], "edges": [ … ] }

Nodes (all have id: string, type, x, y, width, height: integers; optional color):

  • text: text: string (markdown)
  • file: file: string (vault path), optional subpath (starts with #)
  • link: url: string
  • group: optional label, background (path), backgroundStyle (cover | ratio | repeat)

color: preset "1".."6" (red, orange, yellow, green, cyan, purple) or #rrggbb.

Edges: id, fromNode, toNode; optional fromSide/toSide (top | right | bottom | left), fromEnd/toEnd (none | arrow; defaults: from none, to arrow), color, label.

Requirements:

  • Read is tolerant: unknown fields on nodes and edges and at the top level are preserved and written back; unknown node types are kept opaque and rendered as a placeholder.
  • Write is canonical: 2-space indent, keys in a stable order, z-order = array order (later nodes on top), so diffs are small. Written with craft-atomic.
  • Validation (reported as diagnostics, never a panic): duplicate IDs, edges to missing nodes (kept, flagged), non-finite or absurd coordinates (clamped for display only, not rewritten), file size cap 32 MiB, node cap 100 000.
  • Round-trip test: every sample in the corpus (the JSON Canvas spec examples plus hand-made files) satisfies read(write(read(x))) == read(x).
  • IDs for new nodes: 16 lowercase hex characters, matching Obsidian's style.

10. MCP tool surface (notescraft-mcp)

Transport: stdio JSON-RPC 2.0 (notescraft-cli mcp --vault <path>), MCP protocol revision recorded in the crate. Implemented methods: initialize, tools/list, tools/call, resources/list, resources/read. Tools dispatch engine commands; the server holds no logic of its own. All paths pass the vault guard; every tool returns structured JSON plus a text summary, and errors as MCP tool errors, not protocol crashes.

Tool Params Effect
vault_info – root, note count, tag count, unresolved link count
list_notes folder?, tag?, limit?, cursor? paged note list
read_note path, include_links? raw markdown, parsed frontmatter, outgoing links
create_note path, content?, template? creates; fails if it exists
write_note path, content, expected_hash? replaces; optional optimistic-concurrency hash
append_note path, content, heading? appends, or under a heading
patch_frontmatter path, set?, remove? edits keys without touching the rest
rename_note from, to renames and updates inbound links
delete_note path to trash; requires the server flag --allow-delete
search query, limit? ranked results with snippets; same operators as the UI
get_backlinks path, unlinked? linked and unlinked mentions
get_links path outgoing resolved and unresolved links
list_tags prefix? tag tree with counts
notes_by_tag tag notes carrying the tag
daily_note date? opens or creates the daily note
read_canvas / write_canvas path, canvas? JSON Canvas read and validated write
list_commands / run_command id, params? generic access to the registry

Resources: note://<path> for every note. The server is read-only unless started with --write; destructive tools need their own flag. A fuzz test sends adversarial params to every tool and must not panic (PhotoCraft's panic_hunt pattern). The desktop app also exposes the same tools on its control channel.

10.1 CLI (notescraft-cli)

Headless binary over the same engine; no UI dependency. Every engine command is reachable, so CLI, MCP, control channel and UI are one surface.

notescraft-cli [--vault <path>] [--json] <command>
  info                         vault stats (notes, tags, unresolved links)
  search <query>               same operators as the UI search
  read <path> | write <path> | append <path>     (write needs --yes or stdin content)
  links <path> | backlinks <path> [--unlinked]
  tags [prefix]
  daily [--date D]             open or create the daily note, print its path
  template insert|list|new     templates
  diagram render <file|-> [--out out.svg|png]    Mermaid subset to SVG/PNG
  whiteboard export <file.draw|file.excalidraw> --out out.svg|png
  canvas validate <file.canvas>
  export pdf <path> [--print]  PDF export; --print submits via craft-print
  plugins list|enable|disable|run <id> <command> [json]
  commands                     list every registered command with params
  run <command-id> [json]      run any command by id
  mcp [--write] [--allow-delete]   start the MCP server on stdio (see 10)
  doctor                       validate vault: duplicate names, broken embeds, bad frontmatter

Rules: output is human text by default and a stable JSON envelope { "ok": bool, "result": ..., "error": { "code", "message" } } with --json; exit codes 0 ok, 1 command error, 2 usage error, 3 vault locked or outside vault; writes are atomic; commands are read-only unless they say otherwise; a CLI call never prompts. The app and the CLI can use the same vault at once through the lock rules in 4.2 (the CLI takes a shared lock for reads and defers to the running app for writes by talking to its control channel).

10.2 Plugin and core-plugin tools over MCP

Every command registered by a core plugin (Calendar, Templates, Diagrams, Whiteboard) or a WASM plugin is also an MCP tool named plugin_<id>_<command> with its JSON schema derived from the plugin manifest. A plugin tool is listed only if its per-plugin toggle (Settings, AI assistant, Plugin tools) is on and the plugin has the permissions the call needs. Tool calls from MCP, the CLI (plugins run) and the assistant panel go through the same permission check.

10.3 Assistant panel bridge

The assistant panel is an MCP client of the same server: it starts notescraft-cli mcp (or connects in-process) and hands that endpoint to the agent configured in Settings. The panel never sees API keys. External agents (Claude Code, Codex, Cursor, Gemini, Copilot, others) can use the same server without the panel by adding it to their MCP config:

{ "mcpServers": { "notescraft": { "command": "notescraft-cli", "args": ["mcp", "--vault", "/path/to/vault"] } } }

Write access is off unless --write is passed or the user grants it for the session in the panel. Both the CLI and MCP server honour assistant.review_writes: when on, an agent write is queued as a pending diff in the app instead of being applied.

Protocol and write gating (decided). The panel talks to the configured agent with the Agent Client Protocol (ACP): JSON-RPC 2.0 over the agent process's stdio (initialize, session/new carrying the NotesCraft MCP server in mcpServers, session/prompt, session/cancel; the agent streams session/update notifications: message chunks, thought chunks, plan, tool_call and tool_call_update; it may call session/request_permission). Crate acp (L0) implements the client side. The MCP server the agent receives is notescraft-cli mcp --via-control: every tool call is forwarded to the running app over the control channel with source: "assistant", so one gate (engine assistant module) enforces assistant.allow_write, assistant.allow_delete, the per-plugin tool toggles and assistant.review_writes; with review on, a write is stored as a pending change (path, old text, new text, tool) that the panel shows as a diff and applies on Accept or drops on Reject; reads are never gated. To keep the agent's context small, --via-control lists only the aliases, search and the enabled plugin tools; every other command stays callable by name and through run_command. Transcripts are JSON lines in .notescraft/chat/<session>.jsonl.

11. Architecture notes

  • Engine/UI seam. Session owns the vault, the index, open buffers and undo stacks. Commands mutate Session; the UI renders from it. UI-only state (panel sizes, zoom) lives in ui-egui/src/state.rs (serde) so the control channel can read and drive it.
  • Index. Built on open from a parallel scan (rayon), updated per file on watcher events; persisted nowhere in v1 (rebuild is fast); a cache in .notescraft/ is a later optimisation if the budget is missed.
  • Editor. ropey buffer, byte-range edits, tree of markdown spans for styling the visible region only (live preview hides markup off the cursor line). IME and bidi text must work; egui's text input path is used for composition.
  • Control channel and CLI. Same pattern as PhotoCraft: TCP JSON control server in the app, headless CLI with commands, info, search, export, mcp.
  • Packaging. packaging/ and xtask mirror PhotoCraft's layout for macOS (dmg), Linux (AppImage, flatpak, deb/rpm via nfpm) and Windows (wix). Bundle id ai.storyteller.notescraft. Web packaging is deferred.

12. Definition of done (v1)

A phase is done when cargo xtask verify is green (fmt + clippy -D warnings + tests across the workspace), cargo xtask layers is green, and each acceptance item for the phase's features below is covered by a test or a recorded manual check.

Feature Acceptance
Vault Opening a vault written by Obsidian and saving without edits changes no bytes. Path guard rejects .., absolute, UNC, escaping symlinks. Second instance opens read-only.
Markdown Round trip is byte-exact on the corpus and 10 000 proptest cases. All §5 constructs parse; none are recognised in code.
Links All six resolution rules in §4.3 have tests. Rename updates inbound links in wikilinks and markdown links.
Backlinks Linked and unlinked mentions match a hand-checked fixture vault.
Graph 5 000 nodes lay out in under 2 s on the CI machine; the layout is deterministic for a fixed seed.
Search Operators in §6.6 tested; incremental update visible within 500 ms of a file change; 10 000 notes searched in under 50 ms.
Canvas Round trip on corpus; unknown fields preserved; Obsidian opens a file we saved (manual check, recorded).
Callouts 13 types + aliases render; fold state toggles; nesting works.
Image paste Pasted PNG lands in the attachment folder with the link inserted; collision suffix works; oversize rejected with an error.
Themes Six built-ins switch live; an invalid user theme key does not reject the whole theme.
Plugins Hostile-module suite passes; limits enforced; permission denied returns an error, not a crash.
CLI Every subcommand has a text and a --json test; exit codes match 10.1; run can invoke every registered command; read-only commands never modify the vault.
MCP Every tool has a happy-path and a bad-params test; plugin tools appear and disappear with their toggle; read-only mode blocks writes; fuzz test green.
PDF Export of a fixture note opens in a PDF viewer; craft-print submission path is exercised with a spooler stub.
Settings/save Corrupt config.toml falls back to defaults and is backed up; a kill during save leaves the old file intact.
Properties Editing one key changes only that frontmatter line; unknown YAML round-trips byte-exact; each type renders and validates.
Diagrams Every supported Mermaid type renders from a fixture; unsupported syntax yields a diagnostic, not a crash; output is deterministic; 1 000-node flowchart lays out in under 1 s.
Whiteboard Excalidraw sample files round-trip with unknown fields preserved; same seed gives identical output.
Calendar / templates Calendar marks days with notes and opens/creates the right file; template variables expand per feature 8; every plugin command appears as an MCP tool and respects its toggle.
Assistant panel A stub agent (test binary) exchanging MCP calls shows tool cards in order; a write shows a diff and is applied only after accept; read-only mode rejects writes; killing the agent process leaves the vault unchanged.
Slash menu Opens on / rules above, filters by prefix and fuzzy, inserts valid markdown, never fires inside code fences, and each entry is reachable from the command palette.
Outline, bookmarks, breadcrumbs Outline matches the heading AST (ATX and setext); bookmarks survive rename and move; breadcrumbs match the vault path.
i18n Coverage check: zero hardcoded user strings.
Never crash A panic_hunt-style test runs every command with adversarial params. No unwrap/expect/panic/indexing in non-test code (enforced by clippy).

13. Phases (detail goes in ROADMAP.md after approval)

  1. Foundation: workspace, stubs, xtask verify and layers, CI, docs skeleton.
  2. Vault + markdown: vault, markdown, corpus, round-trip.
  3. Index + links: index, backlinks, link resolution, rename. diagrams and whiteboard parsers start here (pure data).
  4. Engine + app shell: engine, command registry, settings, ui-egui shell, file tree, tabs.
  5. Editor: editor, autocomplete, callouts, image paste, atomic save, recovery, properties panel, slash menu.
  6. Search, tags, palette: search UI, tag tree, command palette, quick switcher, outline pane, bookmarks, breadcrumbs.
  7. Graph: graph and renderer.
  8. Daily notes, calendar, templates, themes.
  9. Canvas, whiteboard and diagrams UI.
  10. Plugins.
  11. MCP, CLI, control channel, assistant panel.
  12. PDF export, packaging, i18n coverage, hardening, beta.

One PR per phase; each PR is reviewed before merge.

14. Risks

Risk Mitigation
Native editor is the largest unknown (IME, selection, large files, live preview). Phase 4 starts with a spike and a written decision gate; fall back to source-only mode if live preview slips.
Byte-exact round trip with extensions is easy to get subtly wrong. Round trip is tested first (Phase 1), fuzzed, and gates the rest.
Hand-rolled index may miss budget on very large vaults. Benchmarks in perf/ from Phase 2; Tantivy is the fallback and would need owner approval.
Obsidian format drift. Compatibility fixtures are recorded from real Obsidian output; unknown fields are always preserved.
craftrules standards are not on disk here. This spec follows PhotoCraft's AGENTS.md; reconcile against craftrules before Phase 0 merges.