···128128and history-driven syncing.
129129130130================================================================================
131131-14. Milestone N: Desktop packaging (Tauri) *wb-N*
131131+14. Milestone N: Status Bar (Editor HUD) *wb-N*
132132================================================================================
133133134134-Goal: same app works as a desktop app with filesystem access.
134134+Goal:
135135+Add a bottom status bar that surfaces the "always-useful" editor telemetry:
136136+cursor position, zoom, active tool/mode, selection summary, and persistence
137137+state - with a clean core → UI boundary.
138138+139139+Design inspirations:
140140+- Zoom controls commonly expose percentage + "zoom to fit/selection" shortcuts.
141141+Svelte integration detail:
142142+- Use $effect to subscribe/unsubscribe to external sources (runs client-side;
143143+ cleanup function runs on re-run/unmount).
144144+145145+------------------------------------------------------------------------------
146146+N1. Define the StatusBar view model (core, pure TS)
147147+------------------------------------------------------------------------------
148148+149149+/packages/core/src/ui/statusbar/types.ts
150150+[ ] Define StatusBarVM (single object the UI renders):
151151+ - cursorWorld: { x, y } " world coords (always)
152152+ - cursorScreen: { x, y }? " optional dev-only
153153+ - zoomPct: number " e.g. 100, 67, 250
154154+ - toolId: ToolId " select/rect/pen/...
155155+ - mode: string " 'idle'|'dragging'|'panning'|'text-edit'
156156+ - selection:
157157+ - count: number
158158+ - kind?: string " optional: 'rect', 'mixed', etc.
159159+ - bounds?: { w, h } " optional v1
160160+ - snap:
161161+ - enabled: boolean
162162+ - gridSize?: number " if grid enabled
163163+ - angleStepDeg?: number " if angle snapping exists
164164+ - persistence:
165165+ - backend: 'indexeddb'
166166+ - state: 'saved'|'saving'|'error'
167167+ - lastSavedAt?: number " epoch ms
168168+ - pendingWrites?: number " queue depth if you batch writes
169169+ - errorMsg?: string
170170+171171+Notes:
172172+- This VM is intentionally READ-ONLY and derived from existing editor state,
173173+ input state, and persistence sink state.
174174+175175+(DoD):
176176+- StatusBarVM compiles and is stable enough to render even before UI exists.
177177+178178+------------------------------------------------------------------------------
179179+N2. Provide selectors / derivations for StatusBarVM
180180+------------------------------------------------------------------------------
181181+182182+/packages/core/src/ui/statusbar/selectors.ts
183183+[ ] Implement pure functions:
184184+ - getZoomPct(state) -> number
185185+ - getToolId(state) -> ToolId
186186+ - getSelectionSummary(state) -> { count, kind?, bounds? }
187187+ - getSnapSummary(state) -> snap summary (default safe values)
188188+189189+Cursor position source:
190190+[ ] Define a minimal CursorState in core (NOT persisted):
191191+ - cursorWorld: Vec2
192192+ - cursorScreen?: Vec2
193193+ - lastMoveAt: number
194194+195195+[ ] Add updateCursor(world, screen?) action + reducer handler (or direct setter)
196196+ that ONLY touches CursorState (no history command, no persistence).
197197+198198+(DoD):
199199+- You can compute StatusBarVM from (EditorState + CursorState + PersistState).
200200+201201+------------------------------------------------------------------------------
202202+N3. Wire cursor updates from pointer movement (apps/web)
203203+------------------------------------------------------------------------------
204204+205205+/apps/web/src/lib/pointer.ts
206206+[ ] On pointermove (or mousemove when not captured):
207207+ - compute world coords using camera.screenToWorld
208208+ - dispatch updateCursor(world, screen)
209209+210210+Performance:
211211+[ ] Throttle cursor updates:
212212+ - v0: requestAnimationFrame coalescing (only publish latest per frame)
213213+ - avoid flooding render/history/persistence
214214+215215+(DoD):
216216+- Cursor world coordinates update smoothly while moving the mouse.
217217+218218+------------------------------------------------------------------------------
219219+N4. Add persistence status signals (Dexie + persistence sink integration)
220220+------------------------------------------------------------------------------
221221+222222+Goal:
223223+Expose persistence state without touching the history system (Milestone L is
224224+done; persistence is already hooked to history in Milestone M).
225225+226226+/apps/web/src/lib/status.ts
227227+[ ] Extend your persistence sink (from Milestone M) to expose a small status:
228228+ - pendingWrites counter (increment on enqueue, decrement on commit)
229229+ - lastSavedAt timestamp (set on successful commit)
230230+ - lastError (set on failed commit)
231231+[ ] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB
232232+ and reflect it in the UI (helps confirm persisted state across tabs).
233233+234234+(DoD):
235235+- Status bar can show: "Saving…" when pendingWrites > 0, and "Saved" with time
236236+ when pendingWrites reaches 0.
237237+238238+------------------------------------------------------------------------------
239239+N5. Implement StatusBar.svelte using runes
240240+------------------------------------------------------------------------------
241241+242242+/apps/web/src/lib/components/StatusBar.svelte
243243+[ ] Render left → right (suggested):
244244+ - Tool + mode
245245+ - Cursor: X,Y (world)
246246+ - Selection summary
247247+ - Snap/grid summary
248248+ - Zoom %
249249+ - Save state ("Saved 3s ago" / "Saving…" / "Error")
135250136136-Tauri + SvelteKit integration:
137137-[ ] Configure SvelteKit for static/SPA output
138138-[ ] Ensure SSR is disabled for desktop build
139139-[ ] Configure Tauri to load the built assets
251251+[ ] Consume state via runes:
252252+ - keep a local $state(snapshot) for EditorState
253253+ - keep a local $state(cursor) for CursorState
254254+ - keep a local $state(persist) for PersistStatus
255255+256256+Subscriptions:
257257+[ ] Use $effect to subscribe to any external streams and return cleanup
258258+ unsubscribe.
140259141141-File dialogs + FS:
142142-[ ] Implement "Save As…" using Tauri dialog + fs APIs
143143-[ ] Implement "Open…" using Tauri dialog + fs APIs
144144-[ ] Add recent files list (v0: store paths in Tauri local storage)
260260+Formatting:
261261+[ ] Cursor formatting:
262262+ - v0: integers
263263+ - v1: configurable precision (e.g. 0.1 units when zoomed in)
145264146265(DoD):
147147-- Desktop app opens/saves JSON files on disk and reopens them correctly.
266266+- Status bar is visible, updates live, and never causes noticeable jank.
267267+268268+------------------------------------------------------------------------------
269269+N6. Interactions (small, high-value)
270270+------------------------------------------------------------------------------
271271+272272+Zoom control:
273273+[ ] Clicking zoomPct opens a tiny menu:
274274+ - 50%, 100%, 200%
275275+ - Zoom to fit
276276+ - Zoom to selection
277277+(Inspiration: zoom/view options + shortcuts in Figma/FigJam.)
278278+279279+Snap toggles:
280280+[ ] Add quick toggles (optional v0, recommended v1):
281281+ - snap enabled
282282+ - grid enabled
283283+284284+(DoD):
285285+- Zoom is discoverable and controllable from the status bar.
286286+287287+------------------------------------------------------------------------------
288288+N7. Tests
289289+------------------------------------------------------------------------------
290290+291291+Core unit tests (/packages/core/test/statusbar.test.ts):
292292+[ ] getZoomPct returns expected values from camera zoom
293293+[ ] selection summary is correct (0, 1, many)
294294+[ ] snap summary defaults safe when features disabled
295295+296296+Web integration tests (optional v0):
297297+[ ] cursor update throttling: 100 pointermoves in a tick results in <= 1 state
298298+ publication per frame (if you implement rAF coalescing)
299299+300300+Persistence tests (web):
301301+[ ] pendingWrites transitions: 0 -> N -> 0 yields state 'saving' then 'saved'
302302+[ ] error sets 'error' state and preserves lastSavedAt
303303+304304+------------------------------------------------------------------------------
305305+Definition of Done
306306+------------------------------------------------------------------------------
307307+308308+- Status bar shows:
309309+ - cursor world position
310310+ - zoom percentage
311311+ - active tool/mode
312312+ - selection count
313313+ - persistence state (Saved/Saving/Error + lastSavedAt)
314314+- Cursor updates are throttled and do not spam history or persistence.
315315+- UI subscriptions use $effect with cleanup.
148316149317================================================================================
15031815. Milestone O: Export (PNG/SVG) *wb-O*
···165333(DoD):
166334- One-click export works in both web and desktop.
167335336336+================================================================================
337337+16. Milestone P: Desktop packaging (Tauri) *wb-P*
338338+================================================================================
339339+340340+Goal: same app works as a desktop app with filesystem access.
341341+342342+Tauri + SvelteKit integration:
343343+[ ] Configure SvelteKit for static/SPA output
344344+[ ] Ensure SSR is disabled for desktop build
345345+[ ] Configure Tauri to load the built assets
346346+347347+File dialogs + FS:
348348+[ ] Implement "Save As…" using Tauri dialog + fs APIs
349349+[ ] Implement "Open…" using Tauri dialog + fs APIs
350350+[ ] Add recent files list (v0: store paths in Tauri local storage)
351351+352352+(DoD):
353353+- Desktop app opens/saves JSON files on disk and reopens them correctly.
168354169355================================================================================
170170-16. Milestone P: Performance + big docs (pragmatic) *wb-P*
356356+17. Milestone Q: Performance + big docs (pragmatic) *wb-Q*
171357================================================================================
172358173359Goal: the editor stays responsive with many shapes.
···175361[ ] Add spatial index (v0: simple grid buckets):
176362 - rebuild index on doc changes
177363 - query nearby shapes for hit testing
178178-179364[ ] Add view culling:
180365 - compute viewport bounds in world space
181366 - render only shapes whose bounds intersect viewport
182182-183367[ ] Reduce redraw frequency:
184368 - rAF only while dirty
185369 - optionally batch multiple store updates into one redraw
186186-187370[ ] Add microbench harness:
188371 - generate 10k shapes doc
189372 - measure hit test and render time
···192375- 10k simple shapes pans/zooms smoothly on a typical machine.
193376194377================================================================================
195195-17. Milestone Q: File Browser (web: Dexie inspector, desktop: FS) *wb-Q*
378378+18. Milestone R: File Browser (web: Dexie inspector, desktop: FS) *wb-R*
196379================================================================================
197380198198-Goal: A unified “Open board” experience:
381381+Goal: A unified "Open board" experience:
199382- Web: browse Dexie-backed boards + a useful persistence/migration inspector
200383- Desktop: browse real directories/files (native file browser semantics)
201384202385--------------------------------------------------------------------------------
203203-Q1. Shared UX contracts
386386+R1. Shared UX contracts
204387--------------------------------------------------------------------------------
205388206389/packages/core/src/persist/DocRepo.ts:
···219402- Svelte UI can render the browser purely from the ViewModel.
220403221404--------------------------------------------------------------------------------
222222-Q2. Web: Boards list + Dexie “Inspector” drawer
405405+R2. Web: Boards list + Dexie "Inspector" drawer
223406--------------------------------------------------------------------------------
224407225408/apps/web/src/lib/filebrowser/FileBrowser.svelte:
···229412 - open / create / rename / delete
230413231414Inspector drawer (selected board):
232232-[ ] Show “Storage: IndexedDB (Dexie)”
415415+[ ] Show "Storage: IndexedDB (Dexie)"
233416[ ] Show schema info:
234417 - declared schema version (your constant)
235418 - installed schema version (best-effort display)
···239422 - last updatedAt
240423[ ] Show migration info:
241424 - list applied migrations from migrations table (id + appliedAt)
242242- - show “pending” migrations if any (based on known list vs applied)
425425+ - show "pending" migrations if any (based on known list vs applied)
243426244427Safe deletes:
245428[ ] deleteBoard must be a single atomic transaction (boards + related tables)
···248431- Web: you can browse boards, open one, and verify migrations + row counts.
249432250433--------------------------------------------------------------------------------
251251-Q3. Desktop: real directory + files (Tauri)
434434+R3. Desktop: real directory + files (Tauri)
252435--------------------------------------------------------------------------------
253436254254-[ ] Add “Workspace folder” concept:
437437+[ ] Add "Workspace folder" concept:
255438 - pick directory
256439 - remember last workspace path
257440[ ] Implement directory listing:
···268451- Desktop: pick a folder, browse files, open/save boards from disk.
269452270453--------------------------------------------------------------------------------
271271-Q4. Parity behaviors
454454+R4. Parity behaviors
272455--------------------------------------------------------------------------------
273456274457[ ] Same shortcuts:
···281464- Web and desktop feel like the same app, with storage differences made explicit.
282465283466================================================================================
284284-18. Milestone R: Quality polish (what makes it feel "real") *wb-R*
467467+19. Milestone S: Quality polish (what makes it feel "real") *wb-S*
285468================================================================================
286469287470Goal: the UX crosses the "this is legit" threshold.
+63
packages/core/src/cursor.ts
···11+import { BehaviorSubject, type Subscription } from "rxjs";
22+import { Vec2 } from "./math";
33+44+/**
55+ * Cursor position + timing in world/screen space.
66+ *
77+ * CursorState is intentionally separate from EditorState so it can be updated
88+ * with high frequency (e.g., on pointer move) without touching history or
99+ * triggering document persistence.
1010+ */
1111+export type CursorState = { cursorWorld: Vec2; cursorScreen?: Vec2; lastMoveAt: number };
1212+1313+export const CursorState = {
1414+ /**
1515+ * Create a cursor state positioned at origin with no screen point.
1616+ */
1717+ create(world?: Vec2, screen?: Vec2, timestamp = Date.now()): CursorState {
1818+ return {
1919+ cursorWorld: Vec2.clone(world ?? { x: 0, y: 0 }),
2020+ cursorScreen: screen ? Vec2.clone(screen) : undefined,
2121+ lastMoveAt: timestamp,
2222+ };
2323+ },
2424+};
2525+2626+export type CursorListener = (state: CursorState) => void;
2727+2828+/**
2929+ * Store that tracks cursor movement separately from the undoable editor state.
3030+ */
3131+export class CursorStore {
3232+ private readonly state$: BehaviorSubject<CursorState>;
3333+3434+ constructor(initialState?: CursorState) {
3535+ this.state$ = new BehaviorSubject(initialState ?? CursorState.create());
3636+ }
3737+3838+ /**
3939+ * Read the latest cursor snapshot.
4040+ */
4141+ getState(): CursorState {
4242+ return this.state$.value;
4343+ }
4444+4545+ /**
4646+ * Subscribe to cursor updates.
4747+ */
4848+ subscribe(listener: CursorListener): () => void {
4949+ const subscription: Subscription = this.state$.subscribe(listener);
5050+ return () => subscription.unsubscribe();
5151+ }
5252+5353+ /**
5454+ * Update the cursor position without touching editor history/persistence.
5555+ */
5656+ updateCursor(world: Vec2, screen?: Vec2, timestamp = Date.now()): void {
5757+ this.state$.next({
5858+ cursorWorld: Vec2.clone(world),
5959+ cursorScreen: screen ? Vec2.clone(screen) : undefined,
6060+ lastMoveAt: timestamp,
6161+ });
6262+ }
6363+}
+2
packages/core/src/index.ts
···11export * from "./actions";
22export * from "./camera";
33+export * from "./cursor";
34export * from "./geom";
45export * from "./history";
56export * from "./math";
···89export * from "./persistence/web";
910export * from "./reactivity";
1011export * from "./tools";
1212+export * from "./ui/statusbar";
+115
packages/core/src/ui/statusbar.ts
···11+import type { CursorState } from "../cursor";
22+import { shapeBounds } from "../geom";
33+import { type Box2, Box2 as Box2Ops, type Vec2, Vec2 as Vec2Ops } from "../math";
44+import type { EditorState, ToolId } from "../reactivity";
55+import { getSelectedShapes } from "../reactivity";
66+77+export type SelectionSummary = { count: number; kind?: string; bounds?: { w: number; h: number } };
88+99+export type SnapSummary = { enabled: boolean; gridSize?: number; angleStepDeg?: number };
1010+1111+export type PersistenceStatus = {
1212+ backend: "indexeddb";
1313+ state: "saved" | "saving" | "error";
1414+ lastSavedAt?: number;
1515+ pendingWrites?: number;
1616+ errorMsg?: string;
1717+};
1818+1919+export type StatusBarVM = {
2020+ cursorWorld: Vec2;
2121+ cursorScreen?: Vec2;
2222+ zoomPct: number;
2323+ toolId: ToolId;
2424+ mode: "idle" | "dragging" | "panning" | "text-edit" | string;
2525+ selection: SelectionSummary;
2626+ snap: SnapSummary;
2727+ persistence: PersistenceStatus;
2828+};
2929+3030+/**
3131+ * Convert the current camera zoom factor into a human-friendly percentage.
3232+ */
3333+export function getZoomPct(state: EditorState): number {
3434+ const pct = state.camera.zoom * 100;
3535+ if (!Number.isFinite(pct)) {
3636+ return 100;
3737+ }
3838+ return Math.round(pct);
3939+}
4040+4141+/**
4242+ * Get the active tool identifier from UI state.
4343+ */
4444+export function getToolId(state: EditorState): ToolId {
4545+ return state.ui.toolId;
4646+}
4747+4848+/**
4949+ * Summarize the current selection for display.
5050+ */
5151+export function getSelectionSummary(state: EditorState): SelectionSummary {
5252+ const shapes = getSelectedShapes(state);
5353+ const count = shapes.length;
5454+5555+ if (count === 0) {
5656+ return { count: 0 };
5757+ }
5858+5959+ const combinedBounds = combineBounds(shapes.map((shape) => shapeBounds(shape)));
6060+6161+ const kind = count === 1
6262+ ? shapes[0].type
6363+ : (shapes.every((shape) => shape.type === shapes[0].type) ? shapes[0].type : "mixed");
6464+6565+ return {
6666+ count,
6767+ kind,
6868+ bounds: combinedBounds ? { w: Box2Ops.width(combinedBounds), h: Box2Ops.height(combinedBounds) } : undefined,
6969+ };
7070+}
7171+7272+const SNAP_DEFAULT: SnapSummary = { enabled: false };
7373+7474+/**
7575+ * Provide safe defaults for snap/grid summary until features are enabled.
7676+ */
7777+export function getSnapSummary(_: EditorState): SnapSummary {
7878+ return { ...SNAP_DEFAULT };
7979+}
8080+8181+/**
8282+ * Compose the full StatusBar view model from editor/cursor/persistence state.
8383+ */
8484+export function buildStatusBarVM(
8585+ editorState: EditorState,
8686+ cursorState: CursorState,
8787+ persistence: PersistenceStatus,
8888+ mode: StatusBarVM["mode"] = "idle",
8989+): StatusBarVM {
9090+ return {
9191+ cursorWorld: Vec2Ops.clone(cursorState.cursorWorld),
9292+ cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined,
9393+ zoomPct: getZoomPct(editorState),
9494+ toolId: getToolId(editorState),
9595+ mode,
9696+ selection: getSelectionSummary(editorState),
9797+ snap: getSnapSummary(editorState),
9898+ persistence: { ...persistence },
9999+ };
100100+}
101101+102102+function combineBounds(boxes: Box2[]): Box2 | null {
103103+ if (boxes.length === 0) {
104104+ return null;
105105+ }
106106+ let combined = Box2Ops.clone(boxes[0]);
107107+ for (let index = 1; index < boxes.length; index++) {
108108+ const box = boxes[index];
109109+ combined = {
110110+ min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) },
111111+ max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) },
112112+ };
113113+ }
114114+ return combined;
115115+}