diff --git a/TODO.txt b/TODO.txt
index d28e3d6..d033930 100644
--- a/TODO.txt
+++ b/TODO.txt
@@ -131,188 +131,10 @@ and history-driven syncing.
14. Milestone N: Status Bar (Editor HUD) *wb-N*
================================================================================
-Goal:
-Add a bottom status bar that surfaces the "always-useful" editor telemetry:
-cursor position, zoom, active tool/mode, selection summary, and persistence
-state - with a clean core → UI boundary.
-
-Design inspirations:
-- Zoom controls commonly expose percentage + "zoom to fit/selection" shortcuts.
-Svelte integration detail:
-- Use $effect to subscribe/unsubscribe to external sources (runs client-side;
- cleanup function runs on re-run/unmount).
-
-------------------------------------------------------------------------------
-N1. Define the StatusBar view model (core, pure TS)
-------------------------------------------------------------------------------
-
-/packages/core/src/ui/statusbar.ts
-[x] Define StatusBarVM (single object the UI renders):
- - cursorWorld: { x, y } " world coords (always)
- - cursorScreen: { x, y }? " optional dev-only
- - zoomPct: number " e.g. 100, 67, 250
- - toolId: ToolId " select/rect/pen/...
- - mode: string " 'idle'|'dragging'|'panning'|'text-edit'
- - selection:
- - count: number
- - kind?: string " optional: 'rect', 'mixed', etc.
- - bounds?: { w, h } " optional v1
- - snap:
- - enabled: boolean
- - gridSize?: number " if grid enabled
- - angleStepDeg?: number " if angle snapping exists
- - persistence:
- - backend: 'indexeddb'
- - state: 'saved'|'saving'|'error'
- - lastSavedAt?: number " epoch ms
- - pendingWrites?: number " queue depth if you batch writes
- - errorMsg?: string
-
-Notes:
-- This VM is intentionally READ-ONLY and derived from existing editor state,
- input state, and persistence sink state.
-
-(DoD):
-- StatusBarVM compiles and is stable enough to render even before UI exists.
-
-------------------------------------------------------------------------------
-N2. Provide selectors / derivations for StatusBarVM
-------------------------------------------------------------------------------
-
-/packages/core/src/ui/statusbar.ts
-[x] Implement pure functions:
- - getZoomPct(state) -> number
- - getToolId(state) -> ToolId
- - getSelectionSummary(state) -> { count, kind?, bounds? }
- - getSnapSummary(state) -> snap summary (default safe values)
-
-Cursor position source:
-[x] Define a minimal CursorState in core (NOT persisted):
- - cursorWorld: Vec2
- - cursorScreen?: Vec2
- - lastMoveAt: number
-
-[x] Add updateCursor(world, screen?) action + reducer handler (or direct setter)
- that ONLY touches CursorState (no history command, no persistence).
-
-(DoD):
-- You can compute StatusBarVM from (EditorState + CursorState + PersistState).
-
-------------------------------------------------------------------------------
-N3. Wire cursor updates from pointer movement (apps/web)
-------------------------------------------------------------------------------
-
-/apps/web/src/lib/input.ts
-[x] On pointermove (or mousemove when not captured):
- - compute world coords using camera.screenToWorld
- - dispatch updateCursor(world, screen)
-
-Performance:
-[x] Throttle cursor updates:
- - v0: requestAnimationFrame coalescing (only publish latest per frame)
- - avoid flooding render/history/persistence
-
-(DoD):
-- Cursor world coordinates update smoothly while moving the mouse.
-
-------------------------------------------------------------------------------
-N4. Add persistence status signals (Dexie + persistence sink integration)
-------------------------------------------------------------------------------
-
-Goal:
-Expose persistence state without touching the history system (Milestone L is
-done; persistence is already hooked to history in Milestone M).
-
-/apps/web/src/lib/status.ts
-[x] Extend your persistence sink (from Milestone M) to expose a small status:
- - pendingWrites counter (increment on enqueue, decrement on commit)
- - lastSavedAt timestamp (set on successful commit)
- - lastError (set on failed commit)
-[x] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB
- and reflect it in the UI (helps confirm persisted state across tabs).
-
-(DoD):
-- Status bar can show: "Saving…" when pendingWrites > 0, and "Saved" with time
- when pendingWrites reaches 0.
-
-------------------------------------------------------------------------------
-N5. Implement StatusBar.svelte using runes
-------------------------------------------------------------------------------
-
-/apps/web/src/lib/components/StatusBar.svelte
-[ ] Render left → right (suggested):
- - Tool + mode
- - Cursor: X,Y (world)
- - Selection summary
- - Snap/grid summary
- - Zoom %
- - Save state ("Saved 3s ago" / "Saving…" / "Error")
-
-[ ] Consume state via runes:
- - keep a local $state(snapshot) for EditorState
- - keep a local $state(cursor) for CursorState
- - keep a local $state(persist) for PersistStatus
-
-Subscriptions:
-[ ] Use $effect to subscribe to any external streams and return cleanup
- unsubscribe.
-
-Formatting:
-[ ] Cursor formatting:
- - v0: integers
- - v1: configurable precision (e.g. 0.1 units when zoomed in)
-
-(DoD):
-- Status bar is visible, updates live, and never causes noticeable jank.
-
-------------------------------------------------------------------------------
-N6. Interactions (small, high-value)
-------------------------------------------------------------------------------
-
-Zoom control:
-[ ] Clicking zoomPct opens a tiny menu:
- - 50%, 100%, 200%
- - Zoom to fit
- - Zoom to selection
-(Inspiration: zoom/view options + shortcuts in Figma/FigJam.)
-
-Snap toggles:
-[ ] Add quick toggles (optional v0, recommended v1):
- - snap enabled
- - grid enabled
-
-(DoD):
-- Zoom is discoverable and controllable from the status bar.
-
-------------------------------------------------------------------------------
-N7. Tests
-------------------------------------------------------------------------------
-
-Core unit tests (/packages/core/test/statusbar.test.ts):
-[ ] getZoomPct returns expected values from camera zoom
-[ ] selection summary is correct (0, 1, many)
-[ ] snap summary defaults safe when features disabled
-
-Web integration tests (optional v0):
-[ ] cursor update throttling: 100 pointermoves in a tick results in <= 1 state
- publication per frame (if you implement rAF coalescing)
-
-Persistence tests (web):
-[ ] pendingWrites transitions: 0 -> N -> 0 yields state 'saving' then 'saved'
-[ ] error sets 'error' state and preserves lastSavedAt
-
-------------------------------------------------------------------------------
-Definition of Done
-------------------------------------------------------------------------------
-
-- Status bar shows:
- - cursor world position
- - zoom percentage
- - active tool/mode
- - selection count
- - persistence state (Saved/Saving/Error + lastSavedAt)
-- Cursor updates are throttled and do not spam history or persistence.
-- UI subscriptions use $effect with cleanup.
+The HUD is now powered end-to-end via a `StatusBarVM` + cursor store, a web
+persistence/snap manager, and `StatusBar.svelte` with zoom menu and snap/grid
+toggles backed by unit/integration tests for selectors, cursor throttling,
+persistence transitions, and Canvas wiring.
================================================================================
15. Milestone O: Export (PNG/SVG) *wb-O*
diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte
index c32228c..6489ee6 100644
--- a/apps/web/src/lib/canvas/Canvas.svelte
+++ b/apps/web/src/lib/canvas/Canvas.svelte
@@ -1,8 +1,15 @@
@@ -233,6 +266,7 @@
+
diff --git a/apps/web/src/lib/status.ts b/apps/web/src/lib/status.ts
index 56a6584..4a16c78 100644
--- a/apps/web/src/lib/status.ts
+++ b/apps/web/src/lib/status.ts
@@ -20,6 +20,15 @@ type LiveQueryFactory = typeof liveQuery;
export type PersistenceManagerOptions = { sink?: PersistenceSinkOptions; liveQueryFn?: LiveQueryFactory };
+export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number };
+
+export type SnapStore = {
+ get(): SnapSettings;
+ subscribe(listener: (snap: SnapSettings) => void): () => void;
+ update(updater: (snap: SnapSettings) => SnapSettings): void;
+ set(next: SnapSettings): void;
+};
+
export type PersistenceManager = {
sink: PersistenceSink;
status: StatusStore;
@@ -120,7 +129,7 @@ export function createPersistenceManager(
};
}
-function createStatusStore(initial: PersistenceStatus): StatusStore {
+export function createStatusStore(initial: PersistenceStatus): StatusStore {
let value = initial;
const listeners = new Set();
@@ -166,3 +175,34 @@ function hasPatchChanges(patch: DocPatch): boolean {
return false;
}
+
+export function createSnapStore(initial?: Partial): SnapStore {
+ const defaults: SnapSettings = { snapEnabled: false, gridEnabled: false, gridSize: 10 };
+ let value: SnapSettings = { ...defaults, ...initial };
+ const listeners = new Set<(snap: SnapSettings) => void>();
+
+ return {
+ get() {
+ return value;
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ listener(value);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ update(updater) {
+ value = updater(value);
+ for (const listener of listeners) {
+ listener(value);
+ }
+ },
+ set(next) {
+ value = next;
+ for (const listener of listeners) {
+ listener(value);
+ }
+ },
+ };
+}
diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts
index 8b825ea..4e297e1 100644
--- a/apps/web/src/lib/tests/Canvas.history.test.ts
+++ b/apps/web/src/lib/tests/Canvas.history.test.ts
@@ -43,7 +43,23 @@ vi.mock("../input", () => {
};
});
-vi.mock("$lib/status", () => ({ createPersistenceManager: persistenceMocks.createPersistenceManager }));
+vi.mock(
+ "$lib/status",
+ () => ({
+ createPersistenceManager: persistenceMocks.createPersistenceManager,
+ createStatusStore: () => ({
+ get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }),
+ subscribe: () => () => {},
+ update: () => {},
+ }),
+ createSnapStore: () => ({
+ get: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 10 }),
+ subscribe: () => () => {},
+ update: () => {},
+ set: () => {},
+ }),
+ }),
+);
vi.mock("inkfinite-renderer", () => {
return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) };
@@ -259,6 +275,18 @@ vi.mock("inkfinite-core", () => {
},
createWebDocRepo,
createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })),
+ buildStatusBarVM: () => ({
+ cursorWorld: { x: 0, y: 0 },
+ zoomPct: 100,
+ toolId: "select",
+ mode: "idle",
+ selection: { count: 0 },
+ snap: { enabled: false },
+ persistence: { backend: "indexeddb", state: "saved" },
+ }),
+ getSelectedShapes: () => [],
+ getShapesOnCurrentPage: () => [],
+ shapeBounds: () => ({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }),
diffDoc: vi.fn(() => ({})),
InkfiniteDB: class {},
__storeInstances: storeInstances,
diff --git a/apps/web/src/lib/tests/Canvas.svelte.test.ts b/apps/web/src/lib/tests/Canvas.svelte.test.ts
index b623bf5..3a673d9 100644
--- a/apps/web/src/lib/tests/Canvas.svelte.test.ts
+++ b/apps/web/src/lib/tests/Canvas.svelte.test.ts
@@ -17,6 +17,17 @@ vi.mock("$lib/status", () => {
setActiveBoard: () => {},
dispose: () => {},
}),
+ createStatusStore: () => ({
+ get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }),
+ subscribe: () => () => {},
+ update: () => {},
+ }),
+ createSnapStore: () => ({
+ get: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 10 }),
+ subscribe: () => () => {},
+ update: () => {},
+ set: () => {},
+ }),
};
});
@@ -86,6 +97,13 @@ describe("Canvas component", () => {
expect(style.flexDirection).toBe("column");
});
+ it("should render the status bar", () => {
+ const { container } = render(Canvas);
+ const statusBar = container.querySelector(".status-bar");
+
+ expect(statusBar).toBeTruthy();
+ });
+
it("should render all tool buttons in toolbar", () => {
const { container } = render(Canvas);
const toolButtons = container.querySelectorAll(".tool-button");