# NotesCraft Whiteboard Upgrade Spec — `.draw` Version: 1.0 · 2026-10-15 · Status: ready for implementation Scope: the whiteboard (`Excalidraw`-compatible drawing editor) inside NotesCraft — file extension, live drawing preview, design-token consistency, Excalidraw color palette, properties/zoom/layers panels, animations, sounds, export wiring. Audience: any implementing model or human. Every value is explicit; no research needed. Sources: codebase recon of this repo (2026-10-15), Excalidraw v0.18.1 source (colors.ts, constants.ts, Tools.tsx), Obsidian Excalidraw plugin source, GitHub issue ranking (2026-10-15), WCAG 1.4.11. --- ## 0. How to use this spec - Implement **one phase per request/PR**. Phases are ordered by dependency; P1–P4 are small and independent and fix the user-visible bugs first. - Line numbers cited below are anchors from 2026-10-15; code drifts — re-locate by symbol name, not line. - After every phase run: ```sh export PATH="$HOME/.cargo/bin:$PATH" cargo test -p notescraft- cargo clippy -p notescraft- --all-targets -- -D warnings cargo fmt -p notescraft- cargo xtask i18n cargo xtask ci ``` - Never claim a phase done without the phase's "Done when" checks passing. ### Ground rules (apply to every phase, non-negotiable) 1. **Never crash**: no `unwrap`/`expect`/`panic!`/`todo!` in non-test code; the workspace clippy lint set (`clippy::unwrap_used` etc. denied) stays green. Index with `get()`, `split_at_checked`; use `checked_*`/`saturating_*`; cap allocations; bound recursion. 2. **No new third-party dependencies.** Everything here is achievable with egui stdlib (`egui::color_edit_button_srgba`, native `Slider`, existing `motion.rs`/`sounds.rs`), plus SVG icon assets (already bundled lucide set, `assets/icons/`, `LICENSE-lucide.txt`). 3. **UI chrome colours and radii always come from tokens** (`theme.rs`, `Tokens`), never literals. **Document colours** (stroke/fill hexes stored in the file) are data, not chrome — they live in `crates/whiteboard` constants (P5). 4. **User-facing strings** go in the existing `pub(super) mod strings` block (`whiteboard_view/state.rs:8-24`) — the pending-table pattern the `cargo xtask i18n` audit accepts. No literals scattered in widget code. 5. **Files ≤ ~400 lines.** Split `whiteboard_view` by concern as you go. 6. **Every disk write** goes through `craft-atomic` (P12 is the only phase writing files). 7. **Existing tests keep passing** — especially Excalidraw round-trip with unknown-field preservation (`crates/whiteboard/tests/roundtrip.rs`) and the pointer-gesture tests (`whiteboard_view.rs:14-127`). Update tests alongside behavior; write the failing test first. 8. All user-visible behaviour stays engine-command compatible (id, label, params, `enabled`, `run`) where a command already exists; reuse existing commands instead of inventing parallel surfaces. ### Current-state map (findings already verified — do not re-research) - Model crate `crates/whiteboard` (L1): `Whiteboard{elements, app_state, files, extra}` (`board.rs:17-27`), `Element` (`model.rs:12-54`), builders (`build.rs:10-106`), seeded hand-drawn outlines `rough::outline(&Element, Style)` with `Style::{HandDrawn, Clean}` (`rough.rs:20-26`), camelCase serde with unknown-field preservation (`serde_impl.rs`, `codec.rs`), caps: 32 MiB input / 100 000 elements (`board.rs:14,19`). - UI crate `crates/ui-egui` (L6): `whiteboard_view.rs` + `whiteboard_view/{state,edit, interaction,render,geometry,export}.rs`. Entry `show_whiteboard` (`interaction.rs:20-44`). - Wiring: `TabKind::Whiteboard` from extension `"excalidraw"` (`tabs/kind.rs:36-38`); new-file creation `("excalidraw", content)` → `Untitled.excalidraw` (`app/commands.rs:240-243, 260`); state per path in `app/views.rs:19-23`; save flows through the editor text buffer (`app/views.rs:113-121`). - Known bugs this spec fixes: - **No live drag preview**: `paint_board` (`render.rs:17-33`) paints only `board.elements`; the in-progress `view.draft` (`state.rs:121,127-131`) is never painted, and an `Element` is only constructed in `finish_draft` on pointer release (`edit.rs:132-137, 202-243`). Also no `ctx.request_repaint()` anywhere in `whiteboard_view/` (`interaction.rs:20-44`). - **Wrong border radius**: panels use `corner_radius(tokens.radius_medium())` = 6.0 (`interaction.rs:61,96`), buttons/overlay `radius_small()` = 4.0 (`interaction.rs:78,147`) — while every other floating overlay in the app uses `PANEL_RADIUS = 16.0` (`theme.rs:11`, used by `layout.rs:184-191`, `palette.rs:202`, `vim_nav/overlays.rs:58`, `editing/prompt.rs:93`) and SPEC §8.1 mandates inner controls 8/12/999. - **Dead Export button**: `WhiteboardResponse.export_requested` (`state.rs:117`) is set by the button (`interaction.rs:121-123`) but `app/views.rs:114-116` only consumes `.changed` — export never happens. - Wrong tool icons (`state.rs:60-71`): Select and Freedraw share "pencil", Diamond is "star", Ellipse "circle-check", Text "file-text", Eraser "x", Hand "panel-left". - Text editor opens on mere selection of a text element (`interaction.rs:140-147`) — accidental edits. - 5 hardcoded swatches with a thin 1 pt selection ring (`interaction.rs:99-104`); no fill, no full palette, no canvas-background picker; `fill_style`, `opacity`, `stroke_style`, `roughness`, `font_size` exist in the model but are not exposed. - Zoom is wheel-only and invisible; fit is hidden behind Shift+1. - No arrow-key nudge; no visible keyboard focus rings; painter-drawn controls are invisible to AccessKit. - Motion system (`crates/ui-egui/src/design/motion.rs`): `FAST=0.12`, `MEDIUM=0.18`, `SLOW=0.26`; `ease_out_cubic`, `ease_in_out_cubic`, `ease_out_back`, `ease_in_back`; `spring` (stiffness 320, damping 22); `pop_in` (staggered rows); `appear`; `hover`; `mix`; `progress`. Global off switch `motion::set_enabled` driven by `UiState::animations`. - Sound system (`crates/ui-egui/src/design/sounds.rs`): synthesized `Cue::{Pop, Tick, Close, Open}` (Pop ≈120 ms soft pop; Tick ≈35 ms 2200→1800 Hz tick; Open ≈160 ms rising chirp), `SoundSink` trait, `NullSink`, `Throttle`. Host plays via `NotesApp::cue` (apps/notescraft/src/audio.rs, cpal, 12-voice mixer), already honouring the sound on/off setting and volume. `ui-egui` never touches audio directly. - Style helpers (`crates/ui-egui/src/design/style.rs`): `TEXT_TINY=12`, `TEXT_BODY=15`, `SPACING=8`, `POPUP_SHADOW {offset [0,8], blur 28, spread 0, black α110}`, `radius_u8`. - Theme tokens (`crates/themes/src/builtin.rs`, mapping SPEC §8.2): | role | Mocha | Latte | |---|---|---| | background (card) | `#1e1e2e` | `#eff1f5` | | surface (panel) | `#181825` | `#e6e9ef` | | surface_2 | `#313244` | `#ccd0da` | | canvas | `#11111b` | `#dce0e8` | | border | `#45475a` | `#bcc0cc` | | text | `#cdd6f4` | `#4c4f69` | | text_muted | `#a6adc8` | `#54566c` | | accent | `#cba6f7` (mauve) | `#8839ef` | | on_accent | `#11111b` | `#eff1f5` | | danger | `#f38ba8` | `#ce0f38` | | selection | `#45475a` | `#bcc0cc` | ### Layout target (PaperCraft: floating rounded cards on canvas, tone+shadow, no borders) ``` ┌─────────────────────── editor card (canvas token) ───────────────────────┐ │ 12pt │ │ ┌ Layers (toggle) ─┐ ┌──────── toolbar (always) ─────────┐ │ │ │ left edge, w 200 │ │ 10 × 44pt tool buttons, gap 4 │ │ │ └───────────────────┘ └───────────────────────────────────┘ │ │ ┌ Properties (always) ─ 232pt ─┐ │ │ [color popover opens LEFT of it] │ chips/widths/opacity/styles │ │ │ └────────────────────────────────┘ │ │ ┌ ( − ) 100% ( + ) | fit | grid | layers | export ─ pill, bottom-left ─┐ │ │ └────────────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────────┘ ``` - All floating panels: `Frame::new().fill(surface).corner_radius(theme::PANEL_RADIUS) .shadow(style::POPUP_SHADOW)` (shadows are currently missing — add). - Inner radii: controls **8**, text-editor overlay **12**, pills/circle buttons/swatch quick-picks **999** (`CornerRadius` max), grid cells 8. Define named constants in `whiteboard_view` (e.g. `const CONTROL_RADIUS: f32 = 8.0;`) — no raw literals. - Do **not** touch the themes `[radii]` table (`small=4, medium=6`) — other widgets use it; the whiteboard simply stops using it. Reconciling that table with SPEC §8.1 is a separate task, out of scope. --- ## P1 — `.draw` file extension **Goal.** NotesCraft's whiteboard documents are named `*.draw`. Interop is preserved: `.excalidraw` files still open, and the JSON payload stays the Excalidraw v2 schema (keeps SPEC §6.26 compatibility, existing tests, and drop-in use in real Excalidraw / Obsidian plugin). The extension is a naming choice; the payload is not excalidraw the product — no branding changes inside files beyond the informational `source` field. **Decision record.** `.draw` is unclaimed by modern canvas apps (only a legacy RISC OS "DrawFile" association). Keep `"type": "excalidraw"` in the JSON header so `crates/whiteboard/tests/roundtrip.rs` and Excalidraw importers keep working; set `"source"` to the NotesCraft identifier on newly created files. Obsidian's plugin uses `.excalidraw.md`, not `.draw` — no conflict. **Steps.** 1. `crates/ui-egui/src/tabs/kind.rs`: recognize `"draw"` as `TabKind::Whiteboard` next to the existing `"excalidraw"` arm (case-insensitive, existing pattern at `kind.rs:34-38`). 2. `crates/ui-egui/src/app/commands.rs` `ui.new_whiteboard`: new-file template becomes `("draw", content)` and `Untitled.draw` / `Untitled 1.draw` naming (`commands.rs:240-260`). Set the template's `source` top-level key to NotesCraft's identifier. 3. Grep the whole repo (excluding `.worktrees/`) for remaining `excalidraw` literals touching file *extensions*: embeds (`![[board.excalidraw]]` handling), engine CLI (`whiteboard export `, SPEC §11), MCP tool docs, sidebar "new file" lists. Wherever a `.excalidraw` path is accepted, accept `.draw` too. Keep `.excalidraw` accepted everywhere for old vaults. 4. Update `SPEC.md` §6.26 whiteboard bullet: primary extension `.draw`, `.excalidraw` still readable; keep the Excalidraw v2 read/write + unknown-field preservation requirement verbatim. **Tests.** - `crates/ui-egui/tests/tab_kinds.rs`: add `("a.draw", TabKind::Whiteboard)`; keep `("a.excalidraw", TabKind::Whiteboard)`. - `crates/ui-egui/tests/wiring.rs`: the new-whiteboard flow must create `Untitled.draw` and it must round-trip through save with unknown fields preserved (adapt the existing `whiteboard_gesture_and_save_shortcut_preserve_unknown_json` test, which uses `draw.excalidraw`, to also cover a `.draw` path). - `crates/whiteboard/tests/roundtrip.rs`: unchanged (payload assertion `type == "excalidraw"` still passes). **Done when.** New whiteboard is `Untitled.draw`; opening an old `.excalidraw` works; `grep -rn "\.excalidraw"` only shows interop/compat code paths; `cargo xtask ci` green. --- ## P2 — Live drag preview (the headline bug) **Goal.** While the pointer is held down with a shape tool active, the in-progress shape is visible every frame and tracks the pointer 1:1. Today nothing renders until release. **Root cause.** `paint_board` (`render.rs:17-33`) iterates only `board.elements`; `view.draft` (`Option`) is updated during `dragged` (`edit.rs:86-109`) but never painted; `finish_draft` constructs the real `Element` only on `drag_stopped`/`clicked` (`edit.rs:111-137, 202-243`). No `request_repaint` exists in the whiteboard path. **Steps.** 1. In `crates/whiteboard` add a pure, testable preview constructor: `pub fn draft_element(draft: &Draft, tool: ..., defaults: &StyleDefaults) -> Element` — reuse the exact geometry of `finish_draft` (`edit.rs:202-226`) so preview and committed element are identical in shape (same points normalization, same default size for click-without-drag). Where `Draft`/tool types live in `ui-egui`, put the constructor in `whiteboard_view/edit.rs` but keep it a pure function taking data only. 2. `render.rs`: after painting `board.elements`, if `view.draft.is_some()` paint the preview element: - Shape tools: `rough::outline(&preview, Style::Clean)` — a single crisp stroke per frame (cheap; the committed element still gets the user's style). - Freedraw: draw the already-smoothed draft points (`geometry.rs smooth_points`, 10 000-point cap) live. - Use the current stroke colour/width defaults (`WhiteboardView.stroke_color`, `stroke_width`); apply the same empty-colour fallback as `render.rs:34-38` (empty string → theme `text` token). - **No animation on the preview** — it must track the pointer with zero latency. 3. `interaction.rs show_whiteboard`: after `handle_pointer`, add `if view.draft.is_some() { ui.ctx().request_repaint(); }` so drag frames keep coming even when the OS is quiet (trackpad pressure devices, remote frames). 4. Perf guard: if profiling on a large board shows per-frame `rough::outline` cost, cache the outline while `draft` points count is unchanged (optional; do not add caching preemptively). **Tests (write first, failing).** - Unit: `draft_element` produces the same bounds/points as `finish_draft` for identical inputs (rectangle, arrow, line, ellipse, diamond). - UI (`ctx.run_ui` pattern, `whiteboard_view.rs:14-127`): send pointer-down + drag events; assert `board.elements` is still empty **and** `view.draft` is painted during drag (expose a `WhiteboardView.preview: Option` field set by `paint_board` for testability, or assert on the painted shape list from the test harness); on pointer-up assert exactly one element with the right kind exists. - Existing `pointer_gestures_create_each_supported_element_kind` keeps passing. **Done when.** Holding the mouse shows the shape live for every tool incl. freedraw; no flicker; `cargo xtask ci` green. --- ## P3 — Border radius + shadow consistency **Goal.** Whiteboard chrome uses the app-wide radius system: floating panels 16 (`PANEL_RADIUS`), inner controls 8, text overlay 12, pills 999. Spec §8.1 compliance. **Steps.** 1. `whiteboard_view/interaction.rs`: - toolbar Frame (`interaction.rs:61`): `corner_radius(tokens.radius_medium())` → `theme::PANEL_RADIUS` (16.0) + `.shadow(style::POPUP_SHADOW)`. - style/properties panel Frame (`interaction.rs:96`): same change. - tool buttons (`interaction.rs:78`): `tokens.radius_small()` → `CONTROL_RADIUS` (8.0, new named constant). - text editor overlay (`interaction.rs:147`): `radius_small()` → 12.0 (named constant, e.g. `OVERLAY_RADIUS`). 2. Apply the same to every new panel built in P6–P9 (popover, zoom bar, layers): panel 16 + shadow; circle buttons/swatches 999; inner chips/buttons 8. 3. Do not change shape *rendering* radii (selection outline 4.0, handles 2.0 in `render.rs:25-27`) — those are document-space visuals, not chrome. **Tests.** - `crates/ui-egui/src/view_tests.rs` pattern (existing `PANEL_RADIUS` assertion at `view_tests.rs:191`): add an assertion that `show_whiteboard`'s toolbar Frame corner radius equals `theme::PANEL_RADIUS`. - Visual: regenerate `examples/whiteboard_view.rs` snapshot PNG; compare against workspace.png — panels must now match the other floating cards' roundness. **Done when.** Whiteboard panels are pixel-consistent with command palette / quick switcher cards at both themes and both scales; `cargo xtask ci` green. --- ## P4 — Tool icons, tooltips, and small interaction fixes **Steps.** 1. Add lucide SVGs to `assets/icons/` (set already bundles lucide with `LICENSE-lucide.txt`): `mouse-pointer-2`, `diamond`, `circle`, `move-up-right`, `slash`, `pen`, `type`, `eraser`, `hand` (keep existing `square`, `layers`, `minus`, `plus`, `download`, `chevron-down`; add `maximize`, `grid-3x3`, `lock`, `unlock`, `eye`, `eye-off` for later phases). 2. `whiteboard_view/state.rs` icon map (`state.rs:60-81`): Select → `mouse-pointer-2`, Rectangle → `square`, Diamond → `diamond`, Ellipse → `circle`, Arrow → `move-up-right`, Line → `slash`, Freedraw → `pen`, Text → `type`, Eraser → `eraser`, Hand → `hand`. 3. Tooltips become `"Label (Key)"` — e.g. `"Rectangle (R)"` — label + shortcut, via the `strings` block. 4. Text editing trigger: single click only *selects*; double-click (or Enter with the text element selected) opens the overlay (`interaction.rs:140-147`). Keep Esc/focus loss committing. 5. Keyboard additions in `edit.rs handle_keys`: - Arrow keys nudge selection 1 pt document-space; Shift+Arrow 10 pt (goes through the existing move/transform code so undo snapshots fire). - Esc priority: close color popover (P6) first, then clear selection (existing). 6. Keep all existing shortcuts (V/R/D/O/A/L/P/T/E/H, Delete, ⌘D duplicate, group keys, z-order ⌘[/⌘], Shift+1 fit, undo/redo). **Tests.** UI test: double-click on a text element opens the overlay, single click does not. UI test: Arrow-key nudge moves the selected element by exactly 1 pt (and Shift by 10) and pushes one undo snapshot per keypress burst (reuse existing snapshot-count assertions). **Done when.** Icons are distinct and correct; tooltips show shortcuts; nudge works; `cargo xtask ci` green. --- ## P5 — Document palette in the model crate **Goal.** The Excalidraw palette as pure data in `crates/whiteboard` — verbatim from Excalidraw v0.18.1 `colors.ts` (Open Color shades 50/200/400/600/800 + bronze row + black/white/transparent). Document colours are data, exempt from theme-token rules and from contrast requirements. **Steps.** 1. New module `crates/whiteboard/src/palette.rs` with rustdoc: ```rust /// Excalidraw-compatible element palette (Open Color shades 50/200/400/600/800). pub const BLACK: &str = "#1e1e1e"; pub const WHITE: &str = "#ffffff"; pub const TRANSPARENT: &str = "transparent"; /// Rows in UI order; columns = shades [50, 200, 400, 600, 800]. pub const HUE_ROWS: &[(&str, [&str; 5])] = &[ /* table below */ ]; pub const STROKE_QUICK_PICKS: [&str; 5] = [BLACK, "#fa5252", "#40c057", "#228be6", "#fab005"]; pub const FILL_QUICK_PICKS: [&str; 5] = [TRANSPARENT, "#ffc9c9", "#b2f2bb", "#a5d8ff", "#ffec99"]; pub const CANVAS_QUICK_PICKS: [&str; 5] = ["#ffffff", "#f8f9fa", "#f5faff", "#fffce8", "#fdf8f6"]; ``` Hue rows, verbatim (shade order 50, 200, 400, 600, 800): | row | 50 | 200 | 400 | 600 | 800 | |---|---|---|---|---|---| | gray | `#f8f9fa` | `#e9ecef` | `#ced4da` | `#868e96` | `#343a40` | | bronze | `#f8f1ee` | `#eaddd7` | `#d2bab0` | `#a18072` | `#846358` | | cyan | `#e3fafc` | `#99e9f2` | `#3bc9db` | `#15aabf` | `#0c8599` | | blue | `#e7f5ff` | `#a5d8ff` | `#4dabf7` | `#228be6` | `#1971c2` | | violet | `#f3f0ff` | `#d0bfff` | `#9775fa` | `#7950f2` | `#6741d9` | | grape | `#f8f0fc` | `#eebefa` | `#da77f2` | `#be4bdb` | `#9c36b5` | | pink | `#fff0f6` | `#fcc2d7` | `#f783ac` | `#e64980` | `#c2255c` | | green | `#ebfbee` | `#b2f2bb` | `#69db7c` | `#40c057` | `#2f9e44` | | teal | `#e6fcf5` | `#96f2d7` | `#38d9a9` | `#12b886` | `#099268` | | yellow | `#fff9db` | `#ffec99` | `#ffd43b` | `#fab005` | `#f08c00` | | orange | `#fff4e6` | `#ffd8a8` | `#ffa94d` | `#fd7e14` | `#e8590c` | | red | `#fff5f5` | `#ffc9c9` | `#ff8787` | `#fa5252` | `#e03131` | Row order in `HUE_ROWS` = table order above (gray, bronze, cyan, blue, violet, grape, pink, green, teal, yellow, orange, red) - Excalidraw UI arrangement. 2. Add to `Whiteboard` a small typed helper over the opaque `app_state`: `pub fn view_background(&self) -> Option<&str>` reading `appState["viewBackgroundColor"]`, and `pub fn set_view_background(&mut self, color)` writing it — implemented over the existing `serde_json::Value` without breaking unknown-field preservation. Absent key ⇒ renderer uses the theme `canvas` token (current behavior). 3. Defaults for new elements stay: stroke `""` (empty ⇒ theme `text` at render time), fill `transparent`, stroke width 2, opacity 100, roughness from the panel (P7). **Tests.** Unit tests: constants match the tables above byte-for-byte (assert a few sentinel hexes to catch typos, e.g. `HUE_ROWS` blue.600 == `#228be6`); `set_view_background` round-trips through `to_string_pretty`/`from_str` with unknown appState keys preserved. **Done when.** `cargo test -p notescraft-whiteboard` green; palette importable by `ui-egui`. --- ## P6 — Color palette popover **Goal.** Replace the 5 hardcoded swatches with an Excalidraw-style color system: popover with Stroke / Fill / Canvas tabs, quick picks, full grid, recent custom colors, native color picker. **Steps.** 1. New module `whiteboard_view/palette_panel.rs` (keep `interaction.rs` under 400 lines). Popover opened from a color chip in the properties panel (P7) and from a chevron on the quick-pick row; `WhiteboardView.palette_open: Option` with `enum PaletteTab { Stroke, Fill, Canvas }`. 2. Frame: `surface` fill, radius `PANEL_RADIUS`, `POPUP_SHADOW`, inner margin 12. Placement: to the **left** of the properties panel, top-aligned with the trigger; if the canvas is too narrow (popover + 232 + gaps), fall back to below-trigger, right-aligned. Not modal; closes on click-away / Esc / apply. No exit animation. 3. Contents, top → bottom: - Segmented tab pill Stroke | Fill | Canvas: height 28, radius 999, active segment `surface_2` fill + `text` label (12 pt), inactive `text_muted`. - Quick picks: 5 × 24 pt circles (radius 999), gap 6: - Stroke: `STROKE_QUICK_PICKS` (black, red 400, green 400, blue 400, yellow 400). - Fill: `FILL_QUICK_PICKS` (transparent, red 200, green 200, blue 200, yellow 200). - Canvas: a "Theme" chip first (selects absence of `viewBackgroundColor`), then `CANVAS_QUICK_PICKS`, then the native picker. - Full grid: 13 columns × 5 rows, cell 22×22, radius 8, gap 4 (grid width = 13×22 + 12×4 gaps + 2×12 margin = 358 pt). Column 1 stacks transparent / white / black (rows 4-5 empty non-interactive); columns 2-13 are the 12 hue rows from P5 in order gray, bronze, cyan, blue, violet, grape, pink, green, teal, yellow, orange, red (shade order top→bottom 50→800). - Recent row: up to 6 × 24 pt circles, session-only in `WhiteboardView` (`recent_colors: Vec`, FIFO), hidden when empty; every explicitly picked color (grid, quick pick, custom) is pushed here. - Custom: `egui::color_edit_button_srgba`, height 28. - Transparent cell glyph: 1 pt diagonal slash in `border` token over `surface_2`. 4. Selection ring: **2 pt `accent` ring drawn 1 pt outside the swatch** (replaces the current too-thin 1 pt ring, `interaction.rs:101`). Hover: 1 pt `border` ring + PointingHand + tooltip with the hex. Disabled (e.g. fill controls while fill is transparent): `multiply_opacity(0.4)`. 5. Applying colors: - Stroke/Fill tabs: selection exists → write `element.stroke_color` / `element.background_color` for every selected element via the existing `apply_style` pattern (and push one undo snapshot per apply, reusing the snapshot system at `state.rs:163-201`); no selection → set `WhiteboardView` defaults for the next element. - Canvas tab: `board.set_view_background(...)`; "Theme" chip removes the key. 6. Keyboard: Arrow keys navigate the grid, Enter applies, Esc closes (before clearing selection — P4 ordering). 7. No live updates while the popover is open mid-drag (applies on click only). **Tests.** - Unit: apply stroke to a 2-element selection sets both and pushes exactly one undo snapshot; undo restores both. - Unit: canvas tab writes `appState.viewBackgroundColor` and "Theme" removes it; round-trip preserves it and unknown appState keys. - UI: popover opens on chip click, closes on Esc; grid has 13×5 cells; quick picks match `STROKE_QUICK_PICKS`/`FILL_QUICK_PICKS`. **Done when.** Full Excalidraw palette reachable from the whiteboard with tabs, quick picks, recent colors and custom picker; `cargo xtask ci` green. --- ## P7 — Properties panel (replaces current style panel) **Goal.** One right-side panel, 232 pt wide, that styles the selection (or sets defaults when nothing is selected) using the model fields that already exist. **Steps.** 1. Move the existing style panel into `whiteboard_view/panels.rs`; panel frame per P3 (radius 16 + shadow), position `canvas.right_top() + (-12, 72)`, inner margin 12, height content-driven, `ScrollArea` when exceeding `canvas.height - 72 - 12`. 2. Mode line (12 pt, `text_muted`): active tool label when idle, `"Selection (n)"` when selecting. Mixed-value controls show a `—` placeholder until a value is chosen. 3. Controls top→bottom (all inner radius 8, height 28; group gap 12, in-group gap 4; labels 12 pt `text_muted`; hover = `motion::mix(surface, surface_2, hover_t)`): - **Color rows**: stroke chip + fill chip, each with a 5-slot quick-pick strip and a chevron opening the P6 popover (per-tab). - **Stroke width**: S/M/L presets **1 / 2 / 4** pt (Excalidraw thin/medium/bold; replaces the current 1-8 slider, `interaction.rs:111-113`); glyph = horizontal 20 pt line at 1/2/4 pt in `text` token. Default 2. - **Opacity**: native `egui::Slider` 0-100 step 10, default 100, monospace 12 pt `"100%"` label. - **Fill style**: Solid / Hachure / Cross-hatch (model `FillStyle`; `zigzag` stays in the model but is not exposed — Excalidraw parity). Disabled while fill is transparent. - **Line style**: Solid / Dashed / Dotted (`stroke_style`), preview line painted with the pattern in `text` token. - **Edge style**: Sharp / Round. Round persists `element.extra["roundness"] = {"type": 2}` (Excalidraw adaptive radius; rectangle corner radius = 0.25 × min(width, height) at render time) until a typed field lands — out of scope here. - **Roughness**: Clean (0) / Hand-drawn (2) — **per element** (`element.roughness`), replacing the global view-level toggle (`interaction.rs:107-111`). Renderer change (see step 5). Default for new elements preserves today's default look: map the current default `view.style` (Hand-drawn ⇒ 2, Clean ⇒ 0). - **Font size** (Text tool/selection only): S/M/L/XL = 16/20/28/36 px. - **Actions row**: Duplicate, Delete, Export SVG (28 pt icon buttons, tooltips). 4. Every control writes through the same `apply_style`-style helper: selection → mutate selected elements + one undo snapshot; no selection → view defaults. 5. Renderer updates (`render.rs`): - `rough::outline` derives style from `element.roughness` (0 ⇒ clean geometry, 1-3 ⇒ hand-drawn jitter scaled by value) instead of the global `view.style`. - Elements with `background_color != transparent` get a fill: Solid = filled path; Hachure = 45° lines clipped to the element bounds, spacing `max(4, stroke_width * 2)`; Cross-hatch adds the perpendicular pass. Deterministic per element (reuse the seeded generator). - `element.opacity` (0-100) is applied as an alpha multiplier to stroke and fill. - `stroke_style` dashed/dotted apply to all painted polylines (Excalidraw dash 8-4 / dot 1-3 at document scale). 6. Disabled/hidden logic: fill style + fill chip disabled when fill is transparent; font size row hidden unless Text is involved; eraser/hand tools show the panel in defaults mode. **Tests.** - Unit: each control mutates the right model field for the selection and sets the matching default when nothing is selected; mixed selection shows placeholder. - Unit: `rough::outline` output differs for roughness 0 vs 2 (deterministic, same seed). - Unit: opacity 50 halves painter alpha; dashed style produces ≥ 2 disjoint segments for a long line. - Existing undo/redo identical-JSON test keeps passing (new ops snapshot correctly). **Done when.** The right panel exposes and correctly applies color, width, opacity, fill, line style, edge, roughness, font size; renderer respects all of them; `cargo xtask ci` green. --- ## P8 — Zoom & canvas bar + grid **Goal.** Visible zoom controls, fit, and a toggleable dot grid (top community ask: persistent grid background, 83 👍). **Steps.** 1. New pill bar bottom-left: `canvas.left_bottom() + (12, -12)`, height 36, radius 999, `surface` fill, `POPUP_SHADOW`, inner margin 4. Contents (28×28 radius-999 icon buttons, icon 16 pt, gap 2; 1 pt vertical `border` dividers with 6 pt gaps): 1. zoom out (existing `minus`) — ÷1.25, clamp 10%-800% (existing zoom range). 2. readout `"100%"` — 12 pt monospace, `text`; click resets to 100%. 3. zoom in (existing `plus`) — ×1.25. 4. fit (new `maximize` icon) — same code path as Shift+1 (`edit.rs:322-331`). 5. grid toggle (new `grid-3x3`) — active state = `accent` fill + `on_accent` icon. 6. layers toggle (existing `layers`) — opens P9; active state accent. 7. export (existing `download`) — triggers P12. 2. Grid: `WhiteboardView.show_grid: bool` (session-only; persistence out of scope). When on, paint a dot grid **before elements** in `render.rs`: 20 pt document-space spacing, dots radius 1.0 pt screen, `border` token colour, covering the visible canvas rect only. No snap-to-grid in v1 (out of scope). 3. Wheel zoom keeps working (existing `interaction.rs:156-167`); no sounds on wheel. **Tests.** UI: zoom-in button changes `view.zoom` by ×1.25 and clamps at 800%; readout click resets; grid toggle flips painting (assert painted dot count > 0 in the harness). **Done when.** Zoom is discoverable, grid toggle renders dots; `cargo xtask ci` green. --- ## P9 — Layers panel **Goal.** Left-side panel listing elements top-most first, with select/lock/reorder — making the existing invisible z-order commands discoverable (community: layers panel). **Steps.** 1. New `whiteboard_view/layers_panel.rs`. Toggle: button in P8 bar + `Cmd+Shift+L` (add to `handle_keys`). Off by default. 2. Frame: left edge `canvas.left_top() + (12, 72)`, width 200, radius 16 + `POPUP_SHADOW`, inner margin 8, max height 50% of canvas + `ScrollArea`. Header: "Layers" (12 pt muted) + count + close `x` (20 pt). 3. Rows (28 pt): `[16 pt type glyph][name 12 pt like "Rectangle 3" (type + index)] [lock icon]`, delete on hover. Top row = topmost element (reverse z-order). Row click selects (Cmd/Shift adds to selection); selected rows use `selection` token fill, radius 8. Hover reveals `chevron-up`/`chevron-down` reorder buttons wired to the existing z-order logic (`edit.rs` ⌘[/⌘] paths, one undo snapshot per move). Lock writes `element.locked` (move already respects it); locked icon `lock`/`unlock`. Deleted elements (`is_deleted`) are not listed. 4. Panel updates live: creation, deletion, undo/redo, reorder all reflect immediately. **Tests.** UI: with 3 elements the panel lists 3 rows top-most first; clicking row 1 selects the last-created element; lock disables move; reorder button swaps z-order and pushes an undo snapshot; undo restores. **Done when.** Layers panel fully functional; `cargo xtask ci` green. --- ## P10 — Animations **Goal.** Tasteful motion, all through the existing `motion.rs` helpers so `UiState::animations` keeps acting as the single off-switch. Restraint is the design (Excalidraw ships none of these; NotesCraft adds only what aids perception). **Rules.** No motion during continuous gestures (drawing, panning, wheel zoom, resizing, rotating, marquee, typing, slider scrub). Preview (P2) is never animated. Snap guides (future) would be instant. **Steps.** 1. Panel appear (toolbar, properties, zoom bar, layers on open): `motion::appear(id, SLOW, ease_out_back)` + opacity fade `ease_out_cubic` + y-offset −8 pt·(1−t). Once per tab open. **No stagger between panels** (pattern already proven in `palette.rs:194-196`). 2. Color popover open: same `appear` recipe, keyed `"wb-palette"`. 3. Popover grid rows: `motion::pop_in` per row, stagger 0.035 s/row (5 rows ⇒ max 0.14 s offset). 4. Hover on inner controls: existing `motion::hover` FAST + `mix(surface, surface_2, t)` (keep current toolbar pattern `interaction.rs:72-73`). 5. Tool activation: keep existing `motion::spring` icon bounce (scale 1 + bounce·0.04). 6. Element created (pointer-up commit): per-element `appear(("wb-el", element.id), SLOW, ease_out_back)`; scale 0.85→1.0 about the element center + alpha `ease_out_cubic`. Only when ≤ 2 elements commit in one frame (avoid noisy redo of big groups). 7. Selection border + handles: fade in via `motion::progress` FAST (no scale). 8. Optional polish (only if trivially cheap): erase fade — scale 1→0.9 + fade over MEDIUM `ease_in_back` via a `view.dying` list; skip if it complicates undo. **Tests.** With animations disabled (`motion::set_enabled(false)`), all painting is at final state immediately (assert no tween values); with animations enabled, creation scale starts < 1 on the first frame. **Done when.** Motion table implemented, gated, and never applied to gestures; `cargo xtask ci` green. --- ## P11 — Sounds **Goal.** Small, quiet cues through the existing system. Excalidraw/tldraw/Miro ship zero sounds — NotesCraft's existing cue system is the app's own signature, so the whiteboard joins it with a minimal set. No new settings; the existing sound on/off and volume keep governing. Silent during all continuous gestures — hard rule. **Plumbing (do this first).** 1. `WhiteboardResponse` (`state.rs`) gains `pub cues: Vec` (enum from `sounds.rs`; `ui-egui` already owns both types — no audio code, just cue *names*). 2. `WhiteboardView` gains a per-frame cue queue; `show_whiteboard` drains it into the response. Dedupe: at most one cue of each kind per frame. 3. Host wiring in `app/views.rs`: forward each cue to `NotesApp::cue` (existing path: respects `state.sound`, `state.volume`, global `Throttle`). `ui-egui` itself stays audio-free. **Cue table.** | Event | Cue | Notes | |---|---|---| | Tool switch (click or key) | `Tick` | once per switch | | Color popover open | `Open` | | | Color popover close (apply/Esc/click-away) | `Close` | | | Swatch / preset click | `Tick` | | | Element created (pointer-up commit) | `Pop` | once per gesture, never mid-drag | | Element erased / Delete key | `Close` | once per command | | Selection becomes non-empty or changes target | `Tick` | silent when clearing; silent during marquee drag | | Undo / Redo | `Tick` | | | Zoom ± buttons, fit, grid/layers toggle | `Tick` | **wheel zoom: never** | | Export invoked | none | OS flow gives feedback | | Drawing, panning, wheel, resize, rotate, typing, scrubbing | **none — hard rule** | | **Tests.** UI: a drag-create gesture queues exactly one `Pop` on release and zero cues during drag frames; tool switch queues one `Tick`; clearing selection queues none; queue is empty when `state.sound` is off (host side) and when disabled via settings. **Done when.** Cues fire per table and nothing fires during gestures; existing sound settings gate everything; `cargo xtask ci` green. --- ## P12 — Export wiring (fix the dead button) **Goal.** The Export SVG button actually exports. **Steps.** 1. `app/views.rs` (currently consumes only `.changed`, `views.rs:114-116`): also consume `response.export_requested`; run an engine command (id `file.export_whiteboard_svg`, label from `strings`) that writes `.svg` beside the `.draw` file via `craft-atomic`, content from the existing pure `export_svg(&Whiteboard) -> String` (`whiteboard_view/export.rs:13-40`). Never overwrite without the existing save/confirm machinery if the target exists — reuse the app's existing overwrite prompt pattern. 2. Surface completion via the app's existing toast/status mechanism; failures surface as errors, never silently. 3. Reserved-path rules: never write into `.obsidian/`/`.notescraft/` via this command; validate the target path with the same rules as note saving. 4. Keep the CLI (`whiteboard export --out out.svg`, SPEC §11) accepting both `.draw` and `.excalidraw` inputs (P1 step 3 covered recognition). **Tests.** Integration: open a `.draw` fixture, trigger export command, assert `.svg` exists on disk with the expected SVG prolog and one `` per element; existing overwrite-prompt tests keep passing; unknown-field preservation unaffected. **Done when.** Button produces a real SVG file; `cargo xtask ci` green. --- ## P13 — Accessibility pass **Steps.** 1. Visible keyboard focus: 2 pt `accent` ring on `response.has_focus()` for every painter-drawn control (tool buttons, swatches, chips) — mandatory in a border-less design. Instant, no animation. 2. AccessKit: every painter-drawn control (`allocate_exact_size` + painter: tool buttons `interaction.rs:73`, all swatches) gets `response.widget_info(|| WidgetInfo::new(WidgetType::Button, …))` with a proper name (tool label; color name/hex). Verify the exact `WidgetInfo` signature against the pinned egui in `Cargo.lock`. Prefer real widgets (`egui::Button`, native `Slider`, existing `icons::show`) wherever possible so roles come free. 3. Contrast (informational, verified pairs): selection ring `accent` on `canvas` ≈ 8:1 Mocha / 4.2:1 Latte ✓; snap-style guides (future) must use `danger` (Mocha `#f38ba8` ≈ 6:1, Latte `#ce0f38` ≈ 5:1) — never `border` (2.1:1). Grid dots at 2.1:1 are decorative — acceptable, do not "fix" by lightening. Document colours (swatches) are data, exempt; do not adjust black `#1e1e1e` for contrast. 4. Selected state never relies on hue alone (accent ring + tooltip text). 5. Keyboard map documented in tooltips and the help layer if one exists in the app. **Done when.** All interactive whiteboard controls are focusable with visible focus and expose names to AccessKit; `cargo xtask ci` green. --- ## P14 — Final sweep: tests, snapshots, docs **Steps.** 1. Full test matrix pass (below); all new tests run (check they execute, not just compile). 2. Regenerate snapshot PNGs via the existing offscreen examples (`cargo run -p notescraft-ui-egui --example whiteboard_view`) at 1440×900, both themes (`--theme catppuccin-latte`), scale 1 and 2; eyeball against PaperCraft reference and the other workspace screenshots (panels must read as the same design family: 16 pt cards, shadows, tone separation, no borders). 3. Update `ROADMAP.md`: whiteboard phase status reflects shipped work (the current "renderers not started" line is stale); update `docs/ARCHITECTURE.md` (whiteboard crate + `whiteboard_view` module APIs, new `palette.rs`, panel modules, cue plumbing); update `SPEC.md` §6.26 for `.draw` (P1) if not already done. 4. `cargo xtask i18n` clean — all new strings in the pending-table `strings` blocks. **Final acceptance checklist (ties back to the original ask).** - [ ] New whiteboards are `.draw`; old `.excalidraw` files still open and round-trip. - [ ] Holding the pointer previews every shape live; freedraw too; zero lag feel. - [ ] Every whiteboard panel uses the app-wide radius (16) and shadows; inner controls 8/12/999; matches command palette/quick switcher family in both themes. - [ ] Full Excalidraw palette available (tabs Stroke/Fill/Canvas, quick picks, 13×5 grid, recent, custom picker); selection ring 2 pt accent. - [ ] Properties panel exposes width S/M/L, opacity, fill, line style, edge, per-element roughness, font size — all undoable. - [ ] Zoom bar (visible zoom, fit, readout), grid dots toggle, layers panel all work. - [ ] Animations per P10 table, gated by the global animations setting; none during gestures. - [ ] Sounds per P11 table through the existing cue system, gated by existing sound setting; silent during gestures. - [ ] Export SVG actually writes a file. - [ ] `cargo xtask ci` green end-to-end; no new dependencies; no-crash lints clean. **Test matrix summary.** - `crates/whiteboard`: palette constants; `view_background` get/set round-trip; fill rendering determinism (same seed ⇒ identical outline); hostile input caps unchanged. - `crates/ui-egui` unit: draft preview equality; each properties control's mutation + undo; popover structure; zoom clamp; layers ops; cue queue; focus/widget_info presence; disabled-animations painting. - `crates/ui-egui/tests`: `.draw` tab kind; `Untitled.draw` creation; gesture + save preserves unknown JSON on `.draw`; export writes SVG; panels stay inside the center card (`wiring.rs:200` pattern). - Visual: snapshot PNGs both themes. --- ## Out of scope (explicitly not in this spec; candidates for later) Community-ranked Excalidraw asks that are features, not chrome: tables (top request), rich text / colored text ranges, multi-page tabs, LaTeX, adjustable per-shape corner radius slider (comes after P7's edge-style MVP), highlighter tool. Also: snap-to-object / snap-to-grid geometry, frames & image *rendering* (model supports; renderer skips), arrow binding UX and arrowhead pickers, laser pointer, Mermaid/AI generation, multi-line text editor with 800 px wrap, font-family picker (no bundled fonts yet), per-canvas persistent custom colors / grid / layers state across sessions, OS reduced-motion auto-detection, sounds during gestures (banned by design).