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");