743 lines
42 KiB
Markdown
743 lines
42 KiB
Markdown
# 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). |