42 KiB
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)
- Never crash: no
unwrap/expect/panic!/todo!in non-test code; the workspace clippy lint set (clippy::unwrap_usedetc. denied) stays green. Index withget(),split_at_checked; usechecked_*/saturating_*; cap allocations; bound recursion. - No new third-party dependencies. Everything here is achievable with egui stdlib
(
egui::color_edit_button_srgba, nativeSlider, existingmotion.rs/sounds.rs), plus SVG icon assets (already bundled lucide set,assets/icons/,LICENSE-lucide.txt). - 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 incrates/whiteboardconstants (P5). - User-facing strings go in the existing
pub(super) mod stringsblock (whiteboard_view/state.rs:8-24) — the pending-table pattern thecargo xtask i18naudit accepts. No literals scattered in widget code. - Files ≤ ~400 lines. Split
whiteboard_viewby concern as you go. - Every disk write goes through
craft-atomic(P12 is the only phase writing files). - 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. - 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 outlinesrough::outline(&Element, Style)withStyle::{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. Entryshow_whiteboard(interaction.rs:20-44). - Wiring:
TabKind::Whiteboardfrom extension"excalidraw"(tabs/kind.rs:36-38); new-file creation("excalidraw", content)→Untitled.excalidraw(app/commands.rs:240-243, 260); state per path inapp/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 onlyboard.elements; the in-progressview.draft(state.rs:121,127-131) is never painted, and anElementis only constructed infinish_drafton pointer release (edit.rs:132-137, 202-243). Also noctx.request_repaint()anywhere inwhiteboard_view/(interaction.rs:20-44). - Wrong border radius: panels use
corner_radius(tokens.radius_medium())= 6.0 (interaction.rs:61,96), buttons/overlayradius_small()= 4.0 (interaction.rs:78,147) — while every other floating overlay in the app usesPANEL_RADIUS = 16.0(theme.rs:11, used bylayout.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) butapp/views.rs:114-116only 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_sizeexist 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.
- No live drag preview:
- 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 switchmotion::set_enableddriven byUiState::animations. - Sound system (
crates/ui-egui/src/design/sounds.rs): synthesizedCue::{Pop, Tick, Close, Open}(Pop ≈120 ms soft pop; Tick ≈35 ms 2200→1800 Hz tick; Open ≈160 ms rising chirp),SoundSinktrait,NullSink,Throttle. Host plays viaNotesApp::cue(apps/notescraft/src/audio.rs, cpal, 12-voice mixer), already honouring the sound on/off setting and volume.ui-eguinever 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#eff1f5surface (panel) #181825#e6e9efsurface_2 #313244#ccd0dacanvas #11111b#dce0e8border #45475a#bcc0cctext #cdd6f4#4c4f69text_muted #a6adc8#54566caccent #cba6f7(mauve)#8839efon_accent #11111b#eff1f5danger #f38ba8#ce0f38selection #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 (
CornerRadiusmax), grid cells 8. Define named constants inwhiteboard_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.
crates/ui-egui/src/tabs/kind.rs: recognize"draw"asTabKind::Whiteboardnext to the existing"excalidraw"arm (case-insensitive, existing pattern atkind.rs:34-38).crates/ui-egui/src/app/commands.rsui.new_whiteboard: new-file template becomes("draw", content)andUntitled.draw/Untitled 1.drawnaming (commands.rs:240-260). Set the template'ssourcetop-level key to NotesCraft's identifier.- Grep the whole repo (excluding
.worktrees/) for remainingexcalidrawliterals touching file extensions: embeds (![[board.excalidraw]]handling), engine CLI (whiteboard export <file>, SPEC §11), MCP tool docs, sidebar "new file" lists. Wherever a.excalidrawpath is accepted, accept.drawtoo. Keep.excalidrawaccepted everywhere for old vaults. - Update
SPEC.md§6.26 whiteboard bullet: primary extension.draw,.excalidrawstill 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 createUntitled.drawand it must round-trip through save with unknown fields preserved (adapt the existingwhiteboard_gesture_and_save_shortcut_preserve_unknown_jsontest, which usesdraw.excalidraw, to also cover a.drawpath).crates/whiteboard/tests/roundtrip.rs: unchanged (payload assertiontype == "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.
- In
crates/whiteboardadd a pure, testable preview constructor:pub fn draft_element(draft: &Draft, tool: ..., defaults: &StyleDefaults) -> Element— reuse the exact geometry offinish_draft(edit.rs:202-226) so preview and committed element are identical in shape (same points normalization, same default size for click-without-drag). WhereDraft/tool types live inui-egui, put the constructor inwhiteboard_view/edit.rsbut keep it a pure function taking data only. render.rs: after paintingboard.elements, ifview.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 asrender.rs:34-38(empty string → themetexttoken). - No animation on the preview — it must track the pointer with zero latency.
- Shape tools:
interaction.rs show_whiteboard: afterhandle_pointer, addif view.draft.is_some() { ui.ctx().request_repaint(); }so drag frames keep coming even when the OS is quiet (trackpad pressure devices, remote frames).- Perf guard: if profiling on a large board shows per-frame
rough::outlinecost, cache the outline whiledraftpoints count is unchanged (optional; do not add caching preemptively).
Tests (write first, failing).
- Unit:
draft_elementproduces the same bounds/points asfinish_draftfor identical inputs (rectangle, arrow, line, ellipse, diamond). - UI (
ctx.run_uipattern,whiteboard_view.rs:14-127): send pointer-down + drag events; assertboard.elementsis still empty andview.draftis painted during drag (expose aWhiteboardView.preview: Option<Element>field set bypaint_boardfor 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_kindkeeps 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.
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).
- toolbar Frame (
- 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.
- 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.rspattern (existingPANEL_RADIUSassertion atview_tests.rs:191): add an assertion thatshow_whiteboard's toolbar Frame corner radius equalstheme::PANEL_RADIUS.- Visual: regenerate
examples/whiteboard_view.rssnapshot 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.
- Add lucide SVGs to
assets/icons/(set already bundles lucide withLICENSE-lucide.txt):mouse-pointer-2,diamond,circle,move-up-right,slash,pen,type,eraser,hand(keep existingsquare,layers,minus,plus,download,chevron-down; addmaximize,grid-3x3,lock,unlock,eye,eye-offfor later phases). whiteboard_view/state.rsicon 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.- Tooltips become
"Label (Key)"— e.g."Rectangle (R)"— label + shortcut, via thestringsblock. - 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. - 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).
- 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.
-
New module
crates/whiteboard/src/palette.rswith 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#343a40bronze #f8f1ee#eaddd7#d2bab0#a18072#846358cyan #e3fafc#99e9f2#3bc9db#15aabf#0c8599blue #e7f5ff#a5d8ff#4dabf7#228be6#1971c2violet #f3f0ff#d0bfff#9775fa#7950f2#6741d9grape #f8f0fc#eebefa#da77f2#be4bdb#9c36b5pink #fff0f6#fcc2d7#f783ac#e64980#c2255cgreen #ebfbee#b2f2bb#69db7c#40c057#2f9e44teal #e6fcf5#96f2d7#38d9a9#12b886#099268yellow #fff9db#ffec99#ffd43b#fab005#f08c00orange #fff4e6#ffd8a8#ffa94d#fd7e14#e8590cred #fff5f5#ffc9c9#ff8787#fa5252#e03131Row order in
HUE_ROWS= table order above (gray, bronze, cyan, blue, violet, grape, pink, green, teal, yellow, orange, red) - Excalidraw UI arrangement. -
Add to
Whiteboarda small typed helper over the opaqueapp_state:pub fn view_background(&self) -> Option<&str>readingappState["viewBackgroundColor"], andpub fn set_view_background(&mut self, color)writing it — implemented over the existingserde_json::Valuewithout breaking unknown-field preservation. Absent key ⇒ renderer uses the themecanvastoken (current behavior). -
Defaults for new elements stay: stroke
""(empty ⇒ themetextat render time), filltransparent, 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.
- New module
whiteboard_view/palette_panel.rs(keepinteraction.rsunder 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>withenum PaletteTab { Stroke, Fill, Canvas }. - Frame:
surfacefill, radiusPANEL_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. - Contents, top → bottom:
- Segmented tab pill Stroke | Fill | Canvas: height 28, radius 999, active segment
surface_2fill +textlabel (12 pt), inactivetext_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), thenCANVAS_QUICK_PICKS, then the native picker.
- Stroke:
- 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
bordertoken oversurface_2.
- Segmented tab pill Stroke | Fill | Canvas: height 28, radius 999, active segment
- Selection ring: 2 pt
accentring drawn 1 pt outside the swatch (replaces the current too-thin 1 pt ring,interaction.rs:101). Hover: 1 ptborderring + PointingHand + tooltip with the hex. Disabled (e.g. fill controls while fill is transparent):multiply_opacity(0.4). - Applying colors:
- Stroke/Fill tabs: selection exists → write
element.stroke_color/element.background_colorfor every selected element via the existingapply_stylepattern (and push one undo snapshot per apply, reusing the snapshot system atstate.rs:163-201); no selection → setWhiteboardViewdefaults for the next element. - Canvas tab:
board.set_view_background(...); "Theme" chip removes the key.
- Stroke/Fill tabs: selection exists → write
- Keyboard: Arrow keys navigate the grid, Enter applies, Esc closes (before clearing selection — P4 ordering).
- 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.viewBackgroundColorand "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.
- Move the existing style panel into
whiteboard_view/panels.rs; panel frame per P3 (radius 16 + shadow), positioncanvas.right_top() + (-12, 72), inner margin 12, height content-driven,ScrollAreawhen exceedingcanvas.height - 72 - 12. - 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. - 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 intexttoken. Default 2. - Opacity: native
egui::Slider0-100 step 10, default 100, monospace 12 pt"100%"label. - Fill style: Solid / Hachure / Cross-hatch (model
FillStyle;zigzagstays 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 intexttoken. - 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 defaultview.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).
- Every control writes through the same
apply_style-style helper: selection → mutate selected elements + one undo snapshot; no selection → view defaults. - Renderer updates (
render.rs):rough::outlinederives style fromelement.roughness(0 ⇒ clean geometry, 1-3 ⇒ hand-drawn jitter scaled by value) instead of the globalview.style.- Elements with
background_color != transparentget a fill: Solid = filled path; Hachure = 45° lines clipped to the element bounds, spacingmax(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_styledashed/dotted apply to all painted polylines (Excalidraw dash 8-4 / dot 1-3 at document scale).
- 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::outlineoutput 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.
- New pill bar bottom-left:
canvas.left_bottom() + (12, -12), height 36, radius 999,surfacefill,POPUP_SHADOW, inner margin 4. Contents (28×28 radius-999 icon buttons, icon 16 pt, gap 2; 1 pt verticalborderdividers with 6 pt gaps):- zoom out (existing
minus) — ÷1.25, clamp 10%-800% (existing zoom range). - readout
"100%"— 12 pt monospace,text; click resets to 100%. - zoom in (existing
plus) — ×1.25. - fit (new
maximizeicon) — same code path as Shift+1 (edit.rs:322-331). - grid toggle (new
grid-3x3) — active state =accentfill +on_accenticon. - layers toggle (existing
layers) — opens P9; active state accent. - export (existing
download) — triggers P12.
- zoom out (existing
- Grid:
WhiteboardView.show_grid: bool(session-only; persistence out of scope). When on, paint a dot grid before elements inrender.rs: 20 pt document-space spacing, dots radius 1.0 pt screen,bordertoken colour, covering the visible canvas rect only. No snap-to-grid in v1 (out of scope). - 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.
- New
whiteboard_view/layers_panel.rs. Toggle: button in P8 bar +Cmd+Shift+L(add tohandle_keys). Off by default. - 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 + closex(20 pt). - 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 useselectiontoken fill, radius 8. Hover revealschevron-up/chevron-downreorder buttons wired to the existing z-order logic (edit.rs⌘[/⌘] paths, one undo snapshot per move). Lock writeselement.locked(move already respects it); locked iconlock/unlock. Deleted elements (is_deleted) are not listed. - 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.
- Panel appear (toolbar, properties, zoom bar, layers on open):
motion::appear(id, SLOW, ease_out_back)+ opacity fadeease_out_cubic+ y-offset −8 pt·(1−t). Once per tab open. No stagger between panels (pattern already proven inpalette.rs:194-196). - Color popover open: same
appearrecipe, keyed"wb-palette". - Popover grid rows:
motion::pop_inper row, stagger 0.035 s/row (5 rows ⇒ max 0.14 s offset). - Hover on inner controls: existing
motion::hoverFAST +mix(surface, surface_2, t)(keep current toolbar patterninteraction.rs:72-73). - Tool activation: keep existing
motion::springicon bounce (scale 1 + bounce·0.04). - 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 + alphaease_out_cubic. Only when ≤ 2 elements commit in one frame (avoid noisy redo of big groups). - Selection border + handles: fade in via
motion::progressFAST (no scale). - Optional polish (only if trivially cheap): erase fade — scale 1→0.9 + fade over
MEDIUM
ease_in_backvia aview.dyinglist; 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).
WhiteboardResponse(state.rs) gainspub cues: Vec<Cue>(enum fromsounds.rs;ui-eguialready owns both types — no audio code, just cue names).WhiteboardViewgains a per-frame cue queue;show_whiteboarddrains it into the response. Dedupe: at most one cue of each kind per frame.- Host wiring in
app/views.rs: forward each cue toNotesApp::cue(existing path: respectsstate.sound,state.volume, globalThrottle).ui-eguiitself 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.
app/views.rs(currently consumes only.changed,views.rs:114-116): also consumeresponse.export_requested; run an engine command (idfile.export_whiteboard_svg, label fromstrings) that writes<same-stem>.svgbeside the.drawfile viacraft-atomic, content from the existing pureexport_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.- Surface completion via the app's existing toast/status mechanism; failures surface as errors, never silently.
- Reserved-path rules: never write into
.obsidian//.notescraft/via this command; validate the target path with the same rules as note saving. - Keep the CLI (
whiteboard export <file> --out out.svg, SPEC §11) accepting both.drawand.excalidrawinputs (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.
- Visible keyboard focus: 2 pt
accentring onresponse.has_focus()for every painter-drawn control (tool buttons, swatches, chips) — mandatory in a border-less design. Instant, no animation. - AccessKit: every painter-drawn control (
allocate_exact_size+ painter: tool buttonsinteraction.rs:73, all swatches) getsresponse.widget_info(|| WidgetInfo::new(WidgetType::Button, …))with a proper name (tool label; color name/hex). Verify the exactWidgetInfosignature against the pinned egui inCargo.lock. Prefer real widgets (egui::Button, nativeSlider, existingicons::show) wherever possible so roles come free. - Contrast (informational, verified pairs): selection ring
accentoncanvas≈ 8:1 Mocha / 4.2:1 Latte ✓; snap-style guides (future) must usedanger(Mocha#f38ba8≈ 6:1, Latte#ce0f38≈ 5:1) — neverborder(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#1e1e1efor contrast. - Selected state never relies on hue alone (accent ring + tooltip text).
- 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.
- Full test matrix pass (below); all new tests run (check they execute, not just compile).
- 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). - Update
ROADMAP.md: whiteboard phase status reflects shipped work (the current "renderers not started" line is stale); updatedocs/ARCHITECTURE.md(whiteboard crate +whiteboard_viewmodule APIs, newpalette.rs, panel modules, cue plumbing); updateSPEC.md§6.26 for.draw(P1) if not already done. cargo xtask i18nclean — all new strings in the pending-tablestringsblocks.
Final acceptance checklist (ties back to the original ask).
- New whiteboards are
.draw; old.excalidrawfiles 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 cigreen end-to-end; no new dependencies; no-crash lints clean.
Test matrix summary.
crates/whiteboard: palette constants;view_backgroundget/set round-trip; fill rendering determinism (same seed ⇒ identical outline); hostile input caps unchanged.crates/ui-eguiunit: 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:.drawtab kind;Untitled.drawcreation; gesture + save preserves unknown JSON on.draw; export writes SVG; panels stay inside the center card (wiring.rs:200pattern).- 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).