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

743 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<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:
```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<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).