Files
notescraft/docs/WHITEBOARD-SPEC.md
Marcos Paulo ec82aaeffc
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
ci / ci (macos-latest) (push) Has been cancelled
docs: source layout, roadmap and skill paths after the folder restructure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:16:24 -03:00

42 KiB
Raw Permalink Blame History

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:

    export PATH="$HOME/.cargo/bin:$PATH"
    cargo test -p notescraft-<crate>
    cargo clippy -p notescraft-<crate> --all-targets -- -D warnings
    cargo fmt -p notescraft-<crate>
    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 <file>, 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<Draft{start, points}>) 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<Element> 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:

    /// 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<PaletteTab> 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<String>, 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<Cue> (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 <same-stem>.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 <file> --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 <stem>.svg exists on disk with the expected SVG prolog and one <path> 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).