web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: BEM CSS classes

* Panning viewport

* Keyboard shortcuts

* Accessibility attrs

+1069 -124
+22 -11
TODO.txt
··· 293 293 294 294 Goal: the UX crosses the "this is legit" threshold. 295 295 296 - [ ] Snapping: 297 - - snap move to grid 298 - - snap to other shape edges/centers 296 + [x] BEM-ify CSS classes 297 + - Dialog, Sheet, Toolbar, and StatusBar now use BEM naming 298 + - Fixed hardcoded white backgrounds in Dialog/Sheet (now use CSS vars) 299 + - All text colors use proper CSS variables for dark mode support 300 + [x] Panning viewport 301 + - Hold space + drag to pan the canvas 302 + - Camera.pan integration in Canvas.svelte 303 + [x] Keyboard affordances: 304 + - Arrow keys nudge selected shapes (1px, 10px with Shift) 305 + - Ctrl/Cmd+D duplicates selected shapes 306 + - Ctrl/Cmd+] brings shapes forward 307 + - Ctrl/Cmd+[ sends shapes backward 308 + [x] Accessibility: 309 + - Tool buttons have ARIA labels and aria-pressed states 310 + - Zoom and Export menus have proper ARIA attributes (haspopup, expanded, role=menu) 311 + - Visible focus states on all interactive elements 312 + - Checkboxes in StatusBar have ARIA labels 313 + - All controls keyboard-navigable with Tab 314 + 315 + [ ] Editable Text 316 + [ ] Snapping refinement 317 + - Guideline positioning 299 318 [ ] Handles: 300 319 - resize handles for rect/ellipse 301 320 - rotate handle 302 321 - cursor affordances 303 - [ ] Keyboard affordances: 304 - - nudge with arrow keys 305 - - duplicate (Ctrl/Cmd+D) 306 - - bring forward/back 307 - [ ] Accessibility: 308 - - tool buttons navigable with keyboard 309 - - visible focus states 310 - - ARIA labels for controls 311 322 312 323 (DoD): 313 324 - A user can comfortably draw and edit without surprises.
+3 -12
apps/web/.prettierrc
··· 2 2 "useTabs": true, 3 3 "singleQuote": true, 4 4 "trailingComma": "none", 5 - "printWidth": 120, 5 + "printWidth": 99, 6 6 "objectWrap": "collapse", 7 7 "bracketSameLine": true, 8 - "plugins": [ 9 - "prettier-plugin-svelte" 10 - ], 11 - "overrides": [ 12 - { 13 - "files": "*.svelte", 14 - "options": { 15 - "parser": "svelte" 16 - } 17 - } 18 - ] 8 + "plugins": [ "prettier-plugin-svelte" ], 9 + "overrides": [ { "files": "*.svelte", "options": { "parser": "svelte" } } ] 19 10 }
+283 -7
apps/web/src/lib/canvas/Canvas.svelte
··· 13 13 } from '$lib/status'; 14 14 import { 15 15 ArrowTool, 16 + Camera, 16 17 CursorStore, 17 18 EditorState, 18 19 EllipseTool, ··· 20 21 LineTool, 21 22 RectTool, 22 23 SelectTool, 24 + ShapeRecord, 23 25 SnapshotCommand, 24 26 Store, 25 27 TextTool, ··· 41 43 let repo: ReturnType<typeof createWebDocRepo> | null = null; 42 44 let sink: PersistenceSink | null = null; 43 45 let persistenceManager: ReturnType<typeof createPersistenceManager> | null = null; 44 - const fallbackStatusStore = createStatusStore({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }); 46 + const fallbackStatusStore = createStatusStore({ 47 + backend: 'indexeddb', 48 + state: 'saved', 49 + pendingWrites: 0 50 + }); 45 51 let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 46 52 let activeBoardId: string | null = null; 47 53 ··· 57 63 const cursorStore = new CursorStore(); 58 64 const snapStore: SnapStore = createSnapStore(); 59 65 const pointerState = $state({ isPointerDown: false }); 66 + const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); 60 67 const snapProvider = { get: () => snapStore.get() }; 61 68 const cursorProvider = { get: () => cursorStore.getState() }; 62 69 const pointerStateProvider = { get: () => pointerState }; ··· 67 74 store.setState((state) => ({ 68 75 ...state, 69 76 doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, 70 - ui: { ...state.ui, currentPageId: firstPageId } 77 + ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } 71 78 })); 79 + initializeSelection(firstPageId, doc); 80 + } 81 + 82 + function initializeSelection(pageId: string | null, doc: LoadedDoc) { 83 + if (!pageId) { 84 + return; 85 + } 86 + const page = doc.pages[pageId]; 87 + const firstShapeId = page?.shapeIds[0]; 88 + if (!firstShapeId) { 89 + return; 90 + } 91 + const state = store.getState(); 92 + if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { 93 + return; 94 + } 95 + const before = EditorState.clone(state); 96 + const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; 97 + const command = new SnapshotCommand( 98 + 'Initialize Selection', 99 + 'ui', 100 + before, 101 + EditorState.clone(after) 102 + ); 103 + store.executeCommand(command); 72 104 } 73 105 74 106 const selectTool = new SelectTool(); ··· 98 130 historyViewerOpen = false; 99 131 } 100 132 133 + function handleBringForward() { 134 + const currentState = store.getState(); 135 + const selectedIds = currentState.ui.selectionIds; 136 + const currentPageId = currentState.ui.currentPageId; 137 + 138 + if (selectedIds.length === 0 || !currentPageId) { 139 + return; 140 + } 141 + 142 + const before = EditorState.clone(currentState); 143 + const page = currentState.doc.pages[currentPageId]; 144 + if (!page) return; 145 + 146 + const newShapeIds = [...page.shapeIds]; 147 + 148 + for (const shapeId of selectedIds) { 149 + const currentIndex = newShapeIds.indexOf(shapeId); 150 + if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { 151 + [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ 152 + newShapeIds[currentIndex + 1], 153 + newShapeIds[currentIndex] 154 + ]; 155 + } 156 + } 157 + 158 + const after = { 159 + ...currentState, 160 + doc: { 161 + ...currentState.doc, 162 + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } 163 + } 164 + }; 165 + 166 + const command = new SnapshotCommand('Bring Forward', 'doc', before, EditorState.clone(after)); 167 + store.executeCommand(command); 168 + } 169 + 170 + function handleSendBackward() { 171 + const currentState = store.getState(); 172 + const selectedIds = currentState.ui.selectionIds; 173 + const currentPageId = currentState.ui.currentPageId; 174 + 175 + if (selectedIds.length === 0 || !currentPageId) { 176 + return; 177 + } 178 + 179 + const before = EditorState.clone(currentState); 180 + const page = currentState.doc.pages[currentPageId]; 181 + if (!page) return; 182 + 183 + const newShapeIds = [...page.shapeIds]; 184 + 185 + for (let i = selectedIds.length - 1; i >= 0; i--) { 186 + const shapeId = selectedIds[i]; 187 + const currentIndex = newShapeIds.indexOf(shapeId); 188 + if (currentIndex > 0) { 189 + [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ 190 + newShapeIds[currentIndex - 1], 191 + newShapeIds[currentIndex] 192 + ]; 193 + } 194 + } 195 + 196 + const after = { 197 + ...currentState, 198 + doc: { 199 + ...currentState.doc, 200 + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } 201 + } 202 + }; 203 + 204 + const command = new SnapshotCommand('Send Backward', 'doc', before, EditorState.clone(after)); 205 + store.executeCommand(command); 206 + } 207 + 208 + function handleDuplicate() { 209 + const currentState = store.getState(); 210 + const selectedIds = currentState.ui.selectionIds; 211 + 212 + if (selectedIds.length === 0) { 213 + return; 214 + } 215 + 216 + const before = EditorState.clone(currentState); 217 + const newShapes = { ...currentState.doc.shapes }; 218 + const newPages = { ...currentState.doc.pages }; 219 + const duplicatedIds: string[] = []; 220 + 221 + const DUPLICATE_OFFSET = 20; 222 + 223 + for (const shapeId of selectedIds) { 224 + const shape = currentState.doc.shapes[shapeId]; 225 + if (!shape) continue; 226 + 227 + const cloned = ShapeRecord.clone(shape); 228 + const newId = `shape:${crypto.randomUUID()}`; 229 + const duplicated = { 230 + ...cloned, 231 + id: newId, 232 + x: shape.x + DUPLICATE_OFFSET, 233 + y: shape.y + DUPLICATE_OFFSET 234 + }; 235 + 236 + newShapes[newId] = duplicated; 237 + duplicatedIds.push(newId); 238 + 239 + const currentPageId = currentState.ui.currentPageId; 240 + if (currentPageId) { 241 + const page = newPages[currentPageId]; 242 + if (page) { 243 + newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; 244 + } 245 + } 246 + } 247 + 248 + const after = { 249 + ...currentState, 250 + doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, 251 + ui: { ...currentState.ui, selectionIds: duplicatedIds } 252 + }; 253 + 254 + const command = new SnapshotCommand('Duplicate', 'doc', before, EditorState.clone(after)); 255 + store.executeCommand(command); 256 + } 257 + 258 + function handleNudge(arrowKey: string, largeNudge: boolean) { 259 + const currentState = store.getState(); 260 + const selectedIds = currentState.ui.selectionIds; 261 + 262 + if (selectedIds.length === 0) { 263 + return; 264 + } 265 + 266 + const nudgeDistance = largeNudge ? 10 : 1; 267 + let deltaX = 0; 268 + let deltaY = 0; 269 + 270 + switch (arrowKey) { 271 + case 'ArrowLeft': 272 + deltaX = -nudgeDistance; 273 + break; 274 + case 'ArrowRight': 275 + deltaX = nudgeDistance; 276 + break; 277 + case 'ArrowUp': 278 + deltaY = -nudgeDistance; 279 + break; 280 + case 'ArrowDown': 281 + deltaY = nudgeDistance; 282 + break; 283 + } 284 + 285 + const before = EditorState.clone(currentState); 286 + const newShapes = { ...currentState.doc.shapes }; 287 + 288 + for (const shapeId of selectedIds) { 289 + const shape = newShapes[shapeId]; 290 + if (shape) { 291 + newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; 292 + } 293 + } 294 + 295 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 296 + const command = new SnapshotCommand('Nudge', 'doc', before, EditorState.clone(after)); 297 + store.executeCommand(command); 298 + } 299 + 101 300 function applyActionWithHistory(action: Action) { 102 301 const before = store.getState(); 103 302 const nextState = routeAction(before, action, tools); ··· 107 306 108 307 const kind = getCommandKind(before, nextState); 109 308 const commandName = describeAction(action, kind); 110 - const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); 309 + const command = new SnapshotCommand( 310 + commandName, 311 + kind, 312 + EditorState.clone(before), 313 + EditorState.clone(nextState) 314 + ); 111 315 store.executeCommand(command); 112 316 } 113 317 114 318 function handleAction(action: Action) { 319 + if (action.type === 'key-down' && action.key === ' ') { 320 + panState.spaceHeld = true; 321 + return; 322 + } 323 + 324 + if (action.type === 'key-up' && action.key === ' ') { 325 + panState.spaceHeld = false; 326 + panState.isPanning = false; 327 + return; 328 + } 329 + 330 + if (action.type === 'pointer-down' && action.button === 0 && panState.spaceHeld) { 331 + panState.isPanning = true; 332 + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 333 + return; 334 + } 335 + 336 + if (action.type === 'pointer-move' && panState.isPanning) { 337 + const deltaX = action.screen.x - panState.lastScreen.x; 338 + const deltaY = action.screen.y - panState.lastScreen.y; 339 + const currentCamera = store.getState().camera; 340 + const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); 341 + store.setState((state) => ({ ...state, camera: newCamera })); 342 + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 343 + return; 344 + } 345 + 346 + if (action.type === 'pointer-up' && action.button === 0 && panState.isPanning) { 347 + panState.isPanning = false; 348 + return; 349 + } 350 + 351 + if (panState.isPanning || panState.spaceHeld) { 352 + return; 353 + } 354 + 115 355 const actionWithSnap = applySnapping(action); 116 356 117 357 if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { ··· 124 364 return; 125 365 } 126 366 127 - if (actionWithSnap.type === 'pointer-move' && pointerState.isPointerDown && pendingCommandStart) { 367 + if ( 368 + actionWithSnap.type === 'pointer-move' && 369 + pointerState.isPointerDown && 370 + pendingCommandStart 371 + ) { 128 372 void applyImmediateAction(actionWithSnap); 129 373 return; 130 374 } ··· 145 389 (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || 146 390 (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); 147 391 148 - if (isPrimary && !actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { 392 + if ( 393 + isPrimary && 394 + !actionWithSnap.modifiers.shift && 395 + (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') 396 + ) { 149 397 store.undo(); 150 398 return; 151 399 } 152 400 153 - if (isPrimary && actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { 401 + if ( 402 + isPrimary && 403 + actionWithSnap.modifiers.shift && 404 + (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') 405 + ) { 154 406 store.redo(); 407 + return; 408 + } 409 + 410 + if (isPrimary && (actionWithSnap.key === 'd' || actionWithSnap.key === 'D')) { 411 + handleDuplicate(); 412 + return; 413 + } 414 + 415 + if (isPrimary && actionWithSnap.key === ']') { 416 + handleBringForward(); 417 + return; 418 + } 419 + 420 + if (isPrimary && actionWithSnap.key === '[') { 421 + handleSendBackward(); 422 + return; 423 + } 424 + 425 + if (actionWithSnap.key.startsWith('Arrow')) { 426 + handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); 155 427 return; 156 428 } 157 429 } ··· 285 557 return store.getState().camera; 286 558 } 287 559 288 - renderer = createRenderer(canvas!, store, { snapProvider, cursorProvider, pointerStateProvider }); 560 + renderer = createRenderer(canvas!, store, { 561 + snapProvider, 562 + cursorProvider, 563 + pointerStateProvider 564 + }); 289 565 inputAdapter = createInputAdapter({ 290 566 canvas: canvas!, 291 567 getCamera,
+6 -5
apps/web/src/lib/components/Dialog.svelte
··· 61 61 </script> 62 62 63 63 {#if open} 64 - <div class="dialog-backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 64 + <div class="dialog__backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 65 65 <div 66 66 bind:this={dialogElement} 67 - class="dialog-content {className}" 67 + class="dialog__content {className}" 68 68 role="dialog" 69 69 aria-modal="true" 70 70 aria-label={title} ··· 75 75 {/if} 76 76 77 77 <style> 78 - .dialog-backdrop { 78 + .dialog__backdrop { 79 79 position: fixed; 80 80 top: 0; 81 81 left: 0; ··· 89 89 animation: fadeIn 0.15s ease-out; 90 90 } 91 91 92 - .dialog-content { 93 - background-color: white; 92 + .dialog__content { 93 + background-color: var(--surface); 94 + color: var(--text); 94 95 border-radius: 8px; 95 96 box-shadow: 96 97 0 10px 25px rgba(0, 0, 0, 0.1),
+14 -14
apps/web/src/lib/components/Sheet.svelte
··· 6 6 * 7 7 * A sliding panel that appears from the side of the screen. 8 8 * Built on top of Dialog primitive with custom positioning. 9 - * 10 - * Features: 11 - * - Slides in from left, right, top, or bottom 12 - * - Same accessibility features as Dialog 13 - * - Escape key and backdrop click to close 14 9 */ 15 10 16 11 type Side = 'left' | 'right' | 'top' | 'bottom'; ··· 78 73 </script> 79 74 80 75 {#if open} 81 - <div class="sheet-backdrop" role="presentation" onclick={handleBackdropClick} onkeydown={handleKeyDown}> 76 + <div 77 + class="sheet__backdrop" 78 + role="presentation" 79 + onclick={handleBackdropClick} 80 + onkeydown={handleKeyDown}> 82 81 <div 83 82 bind:this={sheetElement} 84 - class="sheet-content sheet-{side} {className}" 83 + class="sheet sheet__content sheet__content--{side} sheet-{side} {className}" 85 84 role="dialog" 86 85 aria-modal="true" 87 86 aria-label={title} ··· 92 91 {/if} 93 92 94 93 <style> 95 - .sheet-backdrop { 94 + .sheet__backdrop { 96 95 position: fixed; 97 96 top: 0; 98 97 left: 0; ··· 104 103 animation: fadeIn 0.15s ease-out; 105 104 } 106 105 107 - .sheet-content { 108 - background-color: white; 106 + .sheet__content { 107 + background-color: var(--surface); 108 + color: var(--text); 109 109 box-shadow: 110 110 0 10px 25px rgba(0, 0, 0, 0.1), 111 111 0 4px 10px rgba(0, 0, 0, 0.08); ··· 114 114 } 115 115 116 116 /* Right side (default) */ 117 - .sheet-right { 117 + .sheet__content--right { 118 118 position: fixed; 119 119 top: 0; 120 120 right: 0; ··· 124 124 } 125 125 126 126 /* Left side */ 127 - .sheet-left { 127 + .sheet__content--left { 128 128 position: fixed; 129 129 top: 0; 130 130 left: 0; ··· 134 134 } 135 135 136 136 /* Top side */ 137 - .sheet-top { 137 + .sheet__content--top { 138 138 position: fixed; 139 139 top: 0; 140 140 left: 0; ··· 144 144 } 145 145 146 146 /* Bottom side */ 147 - .sheet-bottom { 147 + .sheet__content--bottom { 148 148 position: fixed; 149 149 bottom: 0; 150 150 left: 0;
+56 -31
apps/web/src/lib/components/StatusBar.svelte
··· 16 16 17 17 let editorSnapshot: EditorState = EditorStateOps.create(); 18 18 let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 19 - let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 19 + let persistenceSnapshot: PersistenceStatus = { 20 + backend: 'indexeddb', 21 + state: 'saved', 22 + pendingWrites: 0 23 + }; 20 24 let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: true, gridSize: 25 }); 21 25 let statusVm = $state(buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot)); 22 26 ··· 27 31 $effect(() => { 28 32 const currentStore = store; 29 33 editorSnapshot = currentStore.getState(); 34 + updateVm(); 30 35 const unsubscribe = currentStore.subscribe((state) => { 31 36 editorSnapshot = state; 32 37 updateVm(); ··· 37 42 $effect(() => { 38 43 const currentCursor = cursor; 39 44 cursorSnapshot = currentCursor.getState(); 45 + updateVm(); 40 46 const unsubscribe = currentCursor.subscribe((state) => { 41 47 cursorSnapshot = state; 42 48 updateVm(); ··· 47 53 $effect(() => { 48 54 const currentPersistence = persistence; 49 55 persistenceSnapshot = currentPersistence.get(); 56 + updateVm(); 50 57 const unsubscribe = currentPersistence.subscribe((state) => { 51 58 persistenceSnapshot = state; 52 59 updateVm(); ··· 57 64 $effect(() => { 58 65 const currentSnap = snap; 59 66 snapSnapshot = currentSnap.get(); 67 + updateVm(); 60 68 const unsubscribe = currentSnap.subscribe((state) => { 61 69 snapSnapshot = state; 62 70 updateVm(); ··· 115 123 </script> 116 124 117 125 <div class="status-bar"> 118 - <div class="status-section"> 119 - <span class="label">Tool</span> 120 - <span class="value">{statusVm.toolId}</span> 121 - <span class="mode">{statusVm.mode}</span> 126 + <div class="status-bar__section"> 127 + <span class="status-bar__label">Tool</span> 128 + <span class="status-bar__value">{statusVm.toolId}</span> 129 + <span class="status-bar__mode">{statusVm.mode}</span> 122 130 </div> 123 131 124 - <div class="status-section"> 125 - <span class="label">Cursor</span> 126 - <span class="value"> 132 + <div class="status-bar__section"> 133 + <span class="status-bar__label">Cursor</span> 134 + <span class="status-bar__value"> 127 135 {formatCursorCoord(statusVm.cursorWorld.x)}, {formatCursorCoord(statusVm.cursorWorld.y)} 128 136 </span> 129 137 </div> 130 138 131 - <div class="status-section"> 132 - <span class="label">Selection</span> 133 - <span class="value">{formatSelection()}</span> 139 + <div class="status-bar__section"> 140 + <span class="status-bar__label">Selection</span> 141 + <span class="status-bar__value">{formatSelection()}</span> 134 142 </div> 135 143 136 - <div class="status-section snap"> 137 - <span class="label">Snap</span> 138 - <div class="toggle-row"> 139 - <label class="toggle"> 140 - <input type="checkbox" checked={snapSnapshot.snapEnabled} onchange={handleSnapToggle} /> 144 + <div class="status-bar__section status-bar__section--snap"> 145 + <span class="status-bar__label">Snap</span> 146 + <div class="status-bar__toggle-row"> 147 + <label class="status-bar__toggle"> 148 + <input 149 + type="checkbox" 150 + checked={snapSnapshot.snapEnabled} 151 + onchange={handleSnapToggle} 152 + aria-label="Enable main snapping" /> 141 153 <span>Main</span> 142 154 </label> 143 - <label class="toggle"> 144 - <input type="checkbox" checked={snapSnapshot.gridEnabled} onchange={handleGridToggle} /> 155 + <label class="status-bar__toggle"> 156 + <input 157 + type="checkbox" 158 + checked={snapSnapshot.gridEnabled} 159 + onchange={handleGridToggle} 160 + aria-label="Enable grid snapping" /> 145 161 <span>Grid</span> 146 162 </label> 147 163 </div> 148 164 </div> 149 165 150 - <div class="status-section persistence"> 151 - <span class="label">Sync</span> 152 - <span class="value" class:error={statusVm.persistence.state === 'error'}>{formatPersistenceSummary()}</span> 166 + <div class="status-bar__section status-bar__section--persistence"> 167 + <span class="status-bar__label">Sync</span> 168 + <span 169 + class="status-bar__value" 170 + class:status-bar__value--error={statusVm.persistence.state === 'error'} 171 + >{formatPersistenceSummary()}</span> 153 172 </div> 154 173 </div> 155 174 ··· 166 185 min-height: 48px; 167 186 } 168 187 169 - .status-section { 188 + .status-bar__section { 170 189 display: flex; 171 190 flex-direction: column; 172 191 gap: 2px; 173 192 position: relative; 174 193 } 175 194 176 - .status-section.snap { 195 + .status-bar__section--snap { 177 196 align-items: flex-start; 178 197 } 179 198 180 - .toggle-row { 199 + .status-bar__toggle-row { 181 200 display: flex; 182 201 gap: 8px; 183 202 } 184 203 185 - .toggle { 204 + .status-bar__toggle { 186 205 display: flex; 187 206 align-items: center; 188 207 gap: 4px; ··· 190 209 color: var(--text); 191 210 } 192 211 193 - .toggle input { 212 + .status-bar__toggle input { 194 213 margin: 0; 214 + cursor: pointer; 215 + } 216 + 217 + .status-bar__toggle input:focus { 218 + outline: 2px solid var(--accent); 219 + outline-offset: 2px; 195 220 } 196 221 197 - .label { 222 + .status-bar__label { 198 223 font-size: 11px; 199 224 color: var(--text-muted); 200 225 text-transform: uppercase; 201 226 letter-spacing: 0.05em; 202 227 } 203 228 204 - .value { 229 + .status-bar__value { 205 230 font-weight: 500; 206 231 color: var(--text); 207 232 } 208 233 209 - .value.error { 210 - color: var(--error, #d14343); 234 + .status-bar__value--error { 235 + color: var(--color-error); 211 236 } 212 237 213 - .mode { 238 + .status-bar__mode { 214 239 font-size: 12px; 215 240 color: var(--text-muted); 216 241 }
+114 -44
apps/web/src/lib/components/Toolbar.svelte
··· 210 210 </script> 211 211 212 212 <div class="toolbar" role="toolbar" aria-label="Drawing tools"> 213 - {#each tools as tool} 213 + {#each tools as tool (`${tool.id}:${tool.label}`)} 214 214 <button 215 - class="tool-button" 215 + class="toolbar__tool-button tool-button" 216 + class:toolbar__tool-button--active={currentTool === tool.id} 216 217 class:active={currentTool === tool.id} 217 218 onclick={() => handleToolClick(tool.id)} 218 219 aria-label={tool.label} 219 220 aria-pressed={currentTool === tool.id} 220 221 data-tool-id={tool.id}> 221 - <span class="tool-icon">{tool.icon}</span> 222 - <span class="tool-label">{tool.label}</span> 222 + <span class="toolbar__tool-icon">{tool.icon}</span> 223 + <span class="toolbar__tool-label">{tool.label}</span> 223 224 </button> 224 225 {/each} 225 226 226 - <div class="toolbar-divider"></div> 227 + <div class="toolbar__divider"></div> 227 228 228 - <!-- Zoom controls --> 229 - <div class="toolbar-zoom"> 230 - <button class="zoom-button" bind:this={zoomButtonEl} onclick={() => (zoomMenuOpen = !zoomMenuOpen)}> 229 + <div class="toolbar__zoom"> 230 + <button 231 + class="toolbar__zoom-button" 232 + bind:this={zoomButtonEl} 233 + onclick={() => (zoomMenuOpen = !zoomMenuOpen)} 234 + aria-label="Zoom level" 235 + aria-haspopup="true" 236 + aria-expanded={zoomMenuOpen}> 231 237 {getZoomPct()}% 232 238 </button> 233 239 234 240 {#if zoomMenuOpen} 235 - <div class="zoom-menu" bind:this={zoomMenuEl}> 236 - {#each zoomPresets as preset} 237 - <button onclick={() => setZoomPercent(preset.value)}>{preset.label}</button> 241 + <div class="toolbar__zoom-menu" bind:this={zoomMenuEl} role="menu" aria-label="Zoom options"> 242 + {#each zoomPresets as preset (`${preset.label}:${preset.value}`)} 243 + <button 244 + class="toolbar__menu-item" 245 + role="menuitem" 246 + onclick={() => setZoomPercent(preset.value)} 247 + aria-label="Zoom to {preset.label}"> 248 + {preset.label} 249 + </button> 238 250 {/each} 239 - <div class="menu-divider"></div> 240 - <button onclick={zoomToFit}>Zoom to fit</button> 241 - <button onclick={zoomToSelection}>Zoom to selection</button> 251 + <div class="toolbar__menu-divider"></div> 252 + <button 253 + class="toolbar__menu-item" 254 + role="menuitem" 255 + onclick={zoomToFit} 256 + aria-label="Zoom to fit all shapes"> 257 + Zoom to fit 258 + </button> 259 + <button 260 + class="toolbar__menu-item" 261 + role="menuitem" 262 + onclick={zoomToSelection} 263 + aria-label="Zoom to selected shapes"> 264 + Zoom to selection 265 + </button> 242 266 </div> 243 267 {/if} 244 268 </div> 245 269 246 270 <!-- Export controls --> 247 - <div class="toolbar-export"> 248 - <button class="export-button" bind:this={exportButtonEl} onclick={() => (exportMenuOpen = !exportMenuOpen)}> 271 + <div class="toolbar__export"> 272 + <button 273 + class="toolbar__export-button" 274 + bind:this={exportButtonEl} 275 + onclick={() => (exportMenuOpen = !exportMenuOpen)} 276 + aria-label="Export drawing" 277 + aria-haspopup="true" 278 + aria-expanded={exportMenuOpen}> 249 279 Export 250 280 </button> 251 281 252 282 {#if exportMenuOpen} 253 - <div class="export-menu" bind:this={exportMenuEl}> 254 - <button onclick={exportPNGViewport}>PNG (Viewport)</button> 255 - <button onclick={exportSVGAll}>SVG (All)</button> 256 - <button onclick={exportSVGSelection}>SVG (Selection)</button> 283 + <div 284 + class="toolbar__export-menu" 285 + bind:this={exportMenuEl} 286 + role="menu" 287 + aria-label="Export options"> 288 + <button 289 + class="toolbar__menu-item" 290 + role="menuitem" 291 + onclick={exportPNGViewport} 292 + aria-label="Export current view as PNG"> 293 + PNG (Viewport) 294 + </button> 295 + <button 296 + class="toolbar__menu-item" 297 + role="menuitem" 298 + onclick={exportSVGAll} 299 + aria-label="Export all shapes as SVG"> 300 + SVG (All) 301 + </button> 302 + <button 303 + class="toolbar__menu-item" 304 + role="menuitem" 305 + onclick={exportSVGSelection} 306 + aria-label="Export selected shapes as SVG"> 307 + SVG (Selection) 308 + </button> 257 309 </div> 258 310 {/if} 259 311 </div> 260 312 261 313 {#if onHistoryClick} 262 - <div class="toolbar-divider"></div> 263 - <button class="tool-button history-button" onclick={onHistoryClick} aria-label="History"> 264 - <span class="tool-icon">⏱</span> 265 - <span class="tool-label">History</span> 314 + <div class="toolbar__divider"></div> 315 + <button 316 + class="toolbar__tool-button toolbar__tool-button--history tool-button history-button" 317 + data-tool-id="history" 318 + onclick={onHistoryClick} 319 + aria-label="History" 320 + aria-pressed="false"> 321 + <span class="toolbar__tool-icon">⏱</span> 322 + <span class="toolbar__tool-label">History</span> 266 323 </button> 267 324 {/if} 268 325 </div> ··· 277 334 align-items: center; 278 335 } 279 336 280 - .tool-button { 337 + .toolbar__tool-button { 281 338 display: flex; 282 339 flex-direction: column; 283 340 align-items: center; ··· 292 349 min-width: 60px; 293 350 } 294 351 295 - .tool-button:hover { 352 + .toolbar__tool-button:hover { 296 353 background: var(--surface-elevated); 297 354 border-color: var(--text-muted); 298 355 } 299 356 300 - .tool-button:focus { 357 + .toolbar__tool-button:focus { 301 358 outline: 2px solid var(--accent); 302 359 outline-offset: 2px; 303 360 } 304 361 362 + .toolbar__tool-button--active, 305 363 .tool-button.active { 306 364 background: var(--accent); 307 365 color: var(--surface); 308 366 border-color: var(--accent-hover); 309 367 } 310 368 311 - .tool-icon { 369 + .toolbar__tool-icon { 312 370 font-size: 20px; 313 371 line-height: 1; 314 372 } 315 373 316 - .tool-label { 374 + .toolbar__tool-label { 317 375 font-size: 11px; 318 376 line-height: 1; 319 377 white-space: nowrap; 320 378 } 321 379 322 - .toolbar-divider { 380 + .toolbar__divider { 323 381 width: 1px; 324 382 background-color: var(--border); 325 383 margin: 0 8px; 326 384 height: 40px; 327 385 } 328 386 329 - .toolbar-zoom, 330 - .toolbar-export { 387 + .toolbar__zoom, 388 + .toolbar__export { 331 389 position: relative; 332 390 } 333 391 334 - .zoom-button, 335 - .export-button { 392 + .toolbar__zoom-button, 393 + .toolbar__export-button { 336 394 border: 1px solid var(--border); 337 395 background: var(--surface); 396 + color: var(--text); 338 397 padding: 8px 12px; 339 398 border-radius: 4px; 340 399 cursor: pointer; ··· 342 401 min-width: 60px; 343 402 } 344 403 345 - .zoom-button:hover, 346 - .export-button:hover { 404 + .toolbar__zoom-button:hover, 405 + .toolbar__export-button:hover { 347 406 background: var(--surface-elevated); 348 407 } 349 408 350 - .zoom-menu, 351 - .export-menu { 409 + .toolbar__zoom-button:focus, 410 + .toolbar__export-button:focus { 411 + outline: 2px solid var(--accent); 412 + outline-offset: 2px; 413 + } 414 + 415 + .toolbar__zoom-menu, 416 + .toolbar__export-menu { 352 417 position: absolute; 353 418 top: calc(100% + 4px); 354 419 left: 0; 355 420 background: var(--surface); 421 + color: var(--text); 356 422 border: 1px solid var(--border); 357 423 border-radius: 6px; 358 424 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); ··· 364 430 min-width: 150px; 365 431 } 366 432 367 - .zoom-menu button, 368 - .export-menu button { 433 + .toolbar__menu-item { 369 434 border: none; 370 435 background: transparent; 436 + color: var(--text); 371 437 padding: 4px 8px; 372 438 border-radius: 4px; 373 439 text-align: left; ··· 375 441 font-size: 13px; 376 442 } 377 443 378 - .zoom-menu button:hover, 379 - .export-menu button:hover { 444 + .toolbar__menu-item:hover { 380 445 background: var(--surface-elevated); 381 446 } 382 447 383 - .menu-divider { 448 + .toolbar__menu-item:focus { 449 + outline: 2px solid var(--accent); 450 + outline-offset: -2px; 451 + } 452 + 453 + .toolbar__menu-divider { 384 454 height: 1px; 385 455 background: var(--border); 386 456 margin: 6px 0; 387 457 } 388 458 389 - .history-button { 459 + .toolbar__tool-button--history { 390 460 margin-left: auto; 391 461 } 392 462 </style>
+7
apps/web/src/lib/tests/Canvas.history.test.ts
··· 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 1 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 3 import { cleanup, render } from "vitest-browser-svelte"; 3 4 ··· 261 262 Store: MockStore, 262 263 EditorState, 263 264 SnapshotCommand, 265 + Camera: { 266 + pan(camera: { x: number; y: number; zoom: number }, delta: { x: number; y: number }) { 267 + return { ...camera, x: camera.x - delta.x, y: camera.y - delta.y }; 268 + }, 269 + }, 270 + ShapeRecord: { clone: (shape: any) => ({ ...shape }) }, 264 271 createToolMap: (toolList: any[]) => new Map(toolList.map((tool) => [tool.id, tool])), 265 272 routeAction, 266 273 switchTool: (state: any, toolId: string) => ({ ...state, ui: { ...state.ui, toolId } }),
+291
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 1 + import type { Action, Command } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { cleanup, render } from "vitest-browser-svelte"; 4 + 5 + const actionHandlers: Array<(action: Action) => void> = []; 6 + const coreMocks = vi.hoisted(() => ({ storeInstances: [] as unknown[], executeCommandSpy: vi.fn() })); 7 + 8 + vi.mock("../input", () => { 9 + return { 10 + createInputAdapter: vi.fn((config) => { 11 + actionHandlers.push(config.onAction); 12 + return { dispose: vi.fn() }; 13 + }), 14 + }; 15 + }); 16 + 17 + vi.mock( 18 + "$lib/status", 19 + () => ({ 20 + createPersistenceManager: () => ({ 21 + sink: { enqueueDocPatch: vi.fn(), flush: vi.fn() }, 22 + status: { 23 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 24 + subscribe: () => () => {}, 25 + update: () => {}, 26 + }, 27 + setActiveBoard: vi.fn(), 28 + dispose: vi.fn(), 29 + }), 30 + createStatusStore: () => ({ 31 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 32 + subscribe: () => () => {}, 33 + update: () => {}, 34 + }), 35 + createSnapStore: () => ({ 36 + get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), 37 + subscribe: () => () => {}, 38 + update: () => {}, 39 + set: () => {}, 40 + }), 41 + }), 42 + ); 43 + 44 + vi.mock("inkfinite-renderer", () => { 45 + return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; 46 + }); 47 + 48 + vi.mock("inkfinite-core", async () => { 49 + const actual = await vi.importActual<typeof import("inkfinite-core")>("inkfinite-core"); 50 + const { executeCommandSpy } = coreMocks; 51 + 52 + class MockStore extends actual.Store { 53 + executeCommand(command: unknown) { 54 + executeCommandSpy(command); 55 + return super.executeCommand(command as Command); 56 + } 57 + } 58 + 59 + class MockInkfiniteDB { 60 + boards = { toArray: async () => [], add: async () => "board-1" }; 61 + boardDocuments = { get: async () => null, put: async () => {} }; 62 + } 63 + 64 + return { 65 + ...actual, 66 + Store: MockStore, 67 + InkfiniteDB: MockInkfiniteDB, 68 + createWebDocRepo: vi.fn(() => ({ 69 + listBoards: async () => [{ id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }], 70 + createBoard: async () => "board-1", 71 + loadDoc: async () => ({ 72 + pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] } }, 73 + shapes: { 74 + "shape:1": { 75 + id: "shape:1", 76 + type: "rect", 77 + pageId: "page:1", 78 + x: 100, 79 + y: 100, 80 + rot: 0, 81 + props: { w: 50, h: 50, fill: "#ff0000", stroke: "#000000", radius: 0 }, 82 + }, 83 + }, 84 + bindings: {}, 85 + order: { pageIds: ["page:1"] }, 86 + }), 87 + })), 88 + }; 89 + }); 90 + 91 + import Canvas from "../canvas/Canvas.svelte"; 92 + 93 + describe("Canvas keyboard shortcuts", () => { 94 + beforeEach(() => { 95 + cleanup(); 96 + actionHandlers.length = 0; 97 + coreMocks.executeCommandSpy.mockClear(); 98 + }); 99 + 100 + it("should handle space key for panning mode", async () => { 101 + render(Canvas); 102 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 103 + 104 + const handler = actionHandlers[0]; 105 + 106 + // Press space key 107 + handler({ 108 + type: "key-down", 109 + key: " ", 110 + code: "Space", 111 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 112 + repeat: false, 113 + timestamp: Date.now(), 114 + }); 115 + 116 + // Space key should enable panning mode (no command executed) 117 + expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); 118 + 119 + // Release space key 120 + handler({ 121 + type: "key-up", 122 + key: " ", 123 + code: "Space", 124 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 125 + timestamp: Date.now(), 126 + }); 127 + 128 + expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); 129 + }); 130 + 131 + it("should nudge selected shapes with arrow keys", async () => { 132 + render(Canvas); 133 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 134 + 135 + const handler = actionHandlers[0]; 136 + 137 + // Wait for shapes to load 138 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 139 + coreMocks.executeCommandSpy.mockClear(); 140 + 141 + // Press ArrowRight to nudge 142 + handler({ 143 + type: "key-down", 144 + key: "ArrowRight", 145 + code: "ArrowRight", 146 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 147 + repeat: false, 148 + timestamp: Date.now(), 149 + }); 150 + 151 + // Should execute a nudge command 152 + await vi.waitFor(() => { 153 + const calls = coreMocks.executeCommandSpy.mock.calls; 154 + const nudgeCalls = calls.filter((call) => call[0]?.name === "Nudge"); 155 + expect(nudgeCalls.length).toBeGreaterThan(0); 156 + }); 157 + }); 158 + 159 + it("should nudge by 10px with shift modifier", async () => { 160 + render(Canvas); 161 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 162 + 163 + const handler = actionHandlers[0]; 164 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 165 + coreMocks.executeCommandSpy.mockClear(); 166 + 167 + // Press ArrowDown with Shift 168 + handler({ 169 + type: "key-down", 170 + key: "ArrowDown", 171 + code: "ArrowDown", 172 + modifiers: { ctrl: false, shift: true, alt: false, meta: false }, 173 + repeat: false, 174 + timestamp: Date.now(), 175 + }); 176 + 177 + await vi.waitFor(() => { 178 + const calls = coreMocks.executeCommandSpy.mock.calls; 179 + const nudgeCalls = calls.filter((call) => call[0]?.name === "Nudge"); 180 + expect(nudgeCalls.length).toBeGreaterThan(0); 181 + }); 182 + }); 183 + 184 + it("should duplicate selected shapes with Cmd/Ctrl+D", async () => { 185 + render(Canvas); 186 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 187 + 188 + const handler = actionHandlers[0]; 189 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 190 + coreMocks.executeCommandSpy.mockClear(); 191 + 192 + // Press Cmd+D (on Mac) or Ctrl+D (on other platforms) 193 + const isMac = navigator.userAgent.toUpperCase().includes("MAC"); 194 + handler({ 195 + type: "key-down", 196 + key: "d", 197 + code: "KeyD", 198 + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, 199 + repeat: false, 200 + timestamp: Date.now(), 201 + }); 202 + 203 + await vi.waitFor(() => { 204 + const calls = coreMocks.executeCommandSpy.mock.calls; 205 + const duplicateCalls = calls.filter((call) => call[0]?.name === "Duplicate"); 206 + expect(duplicateCalls.length).toBeGreaterThan(0); 207 + }); 208 + }); 209 + 210 + it("should bring shapes forward with Cmd/Ctrl+]", async () => { 211 + render(Canvas); 212 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 213 + 214 + const handler = actionHandlers[0]; 215 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 216 + coreMocks.executeCommandSpy.mockClear(); 217 + 218 + const isMac = navigator.userAgent.toUpperCase().includes("MAC"); 219 + handler({ 220 + type: "key-down", 221 + key: "]", 222 + code: "BracketRight", 223 + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, 224 + repeat: false, 225 + timestamp: Date.now(), 226 + }); 227 + 228 + await vi.waitFor(() => { 229 + const calls = coreMocks.executeCommandSpy.mock.calls; 230 + const bringForwardCalls = calls.filter((call) => call[0]?.name === "Bring Forward"); 231 + expect(bringForwardCalls.length).toBeGreaterThan(0); 232 + }); 233 + }); 234 + 235 + it("should send shapes backward with Cmd/Ctrl+[", async () => { 236 + render(Canvas); 237 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 238 + 239 + const handler = actionHandlers[0]; 240 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 241 + coreMocks.executeCommandSpy.mockClear(); 242 + 243 + const isMac = navigator.userAgent.toUpperCase().includes("MAC"); 244 + handler({ 245 + type: "key-down", 246 + key: "[", 247 + code: "BracketLeft", 248 + modifiers: { ctrl: !isMac, shift: false, alt: false, meta: isMac }, 249 + repeat: false, 250 + timestamp: Date.now(), 251 + }); 252 + 253 + await vi.waitFor(() => { 254 + const calls = coreMocks.executeCommandSpy.mock.calls; 255 + const sendBackwardCalls = calls.filter((call) => call[0]?.name === "Send Backward"); 256 + expect(sendBackwardCalls.length).toBeGreaterThan(0); 257 + }); 258 + }); 259 + 260 + it("should not process tool actions while space is held", async () => { 261 + render(Canvas); 262 + await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 263 + 264 + const handler = actionHandlers[0]; 265 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 266 + coreMocks.executeCommandSpy.mockClear(); 267 + 268 + // Press space 269 + handler({ 270 + type: "key-down", 271 + key: " ", 272 + code: "Space", 273 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 274 + repeat: false, 275 + timestamp: Date.now(), 276 + }); 277 + 278 + // Try to nudge with arrow key while space is held 279 + handler({ 280 + type: "key-down", 281 + key: "ArrowRight", 282 + code: "ArrowRight", 283 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 284 + repeat: false, 285 + timestamp: Date.now(), 286 + }); 287 + 288 + // Should not execute nudge command while panning mode is active 289 + expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); 290 + }); 291 + });
+109
apps/web/src/lib/tests/StatusBar.accessibility.test.ts
··· 1 + import { CursorStore, Store } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it } from "vitest"; 3 + import { cleanup, render } from "vitest-browser-svelte"; 4 + import StatusBar from "../components/StatusBar.svelte"; 5 + 6 + const createMockStatusStore = () => ({ 7 + get: () => ({ backend: "indexeddb" as const, state: "saved" as const, pendingWrites: 0 }), 8 + subscribe: () => () => {}, 9 + update: () => {}, 10 + }); 11 + 12 + const createMockSnapStore = () => ({ 13 + get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), 14 + subscribe: () => () => {}, 15 + update: () => {}, 16 + set: () => {}, 17 + }); 18 + 19 + describe("StatusBar accessibility", () => { 20 + beforeEach(() => { 21 + cleanup(); 22 + }); 23 + 24 + it("should have ARIA labels on snap checkboxes", () => { 25 + const store = new Store(); 26 + const cursor = new CursorStore(); 27 + const persistence = createMockStatusStore(); 28 + const snap = createMockSnapStore(); 29 + 30 + const { container } = render(StatusBar, { store, cursor, persistence, snap }); 31 + 32 + const checkboxes = container.querySelectorAll(".status-bar__toggle input[type=\"checkbox\"]"); 33 + expect(checkboxes.length).toBe(2); 34 + 35 + const mainSnapCheckbox = checkboxes[0]; 36 + expect(mainSnapCheckbox.getAttribute("aria-label")).toBe("Enable main snapping"); 37 + 38 + const gridSnapCheckbox = checkboxes[1]; 39 + expect(gridSnapCheckbox.getAttribute("aria-label")).toBe("Enable grid snapping"); 40 + }); 41 + 42 + it("should have proper checkbox states", () => { 43 + const store = new Store(); 44 + const cursor = new CursorStore(); 45 + const persistence = createMockStatusStore(); 46 + const snap = { 47 + get: () => ({ snapEnabled: true, gridEnabled: false, gridSize: 25 }), 48 + subscribe: () => () => {}, 49 + update: () => {}, 50 + set: () => {}, 51 + }; 52 + 53 + const { container } = render(StatusBar, { store, cursor, persistence, snap }); 54 + 55 + const checkboxes = container.querySelectorAll(".status-bar__toggle input[type=\"checkbox\"]") as NodeListOf< 56 + HTMLInputElement 57 + >; 58 + 59 + expect(checkboxes[0].checked).toBe(true); 60 + expect(checkboxes[1].checked).toBe(false); 61 + }); 62 + 63 + it("should have visible focus states on checkboxes", () => { 64 + const store = new Store(); 65 + const cursor = new CursorStore(); 66 + const persistence = createMockStatusStore(); 67 + const snap = createMockSnapStore(); 68 + 69 + const { container } = render(StatusBar, { store, cursor, persistence, snap }); 70 + 71 + const checkbox = container.querySelector(".status-bar__toggle input[type=\"checkbox\"]") as HTMLInputElement; 72 + checkbox.focus(); 73 + 74 + expect(document.activeElement).toBe(checkbox); 75 + }); 76 + 77 + it("should display error state with proper styling", () => { 78 + const store = new Store(); 79 + const cursor = new CursorStore(); 80 + const persistence = { 81 + get: () => ({ backend: "indexeddb" as const, state: "error" as const, pendingWrites: 0, errorMsg: "Test error" }), 82 + subscribe: () => () => {}, 83 + update: () => {}, 84 + }; 85 + 86 + const snap = createMockSnapStore(); 87 + const { container } = render(StatusBar, { store, cursor, persistence, snap }); 88 + const persistenceValue = container.querySelector(".status-bar__section--persistence .status-bar__value"); 89 + expect(persistenceValue).toBeTruthy(); 90 + expect(persistenceValue?.textContent).toContain("Error"); 91 + expect(persistenceValue?.classList.contains("status-bar__value--error")).toBe(true); 92 + }); 93 + 94 + it("should use semantic HTML structure", () => { 95 + const store = new Store(); 96 + const cursor = new CursorStore(); 97 + const persistence = createMockStatusStore(); 98 + const snap = createMockSnapStore(); 99 + 100 + const { container } = render(StatusBar, { store, cursor, persistence, snap }); 101 + const labels = container.querySelectorAll(".status-bar__toggle"); 102 + expect(labels.length).toBe(2); 103 + 104 + labels.forEach((label) => { 105 + const input = label.querySelector("input[type=\"checkbox\"]"); 106 + expect(input).toBeTruthy(); 107 + }); 108 + }); 109 + });
+164
apps/web/src/lib/tests/Toolbar.accessibility.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { cleanup, render } from "vitest-browser-svelte"; 3 + import Toolbar from "../components/Toolbar.svelte"; 4 + import { Store } from "inkfinite-core"; 5 + 6 + describe("Toolbar accessibility", () => { 7 + beforeEach(() => { 8 + cleanup(); 9 + }); 10 + 11 + it("should have proper ARIA labels on tool buttons", () => { 12 + const store = new Store(); 13 + const { container } = render(Toolbar, { 14 + props: { 15 + currentTool: "select", 16 + onToolChange: () => {}, 17 + store, 18 + getViewport: () => ({ width: 800, height: 600 }), 19 + }, 20 + }); 21 + 22 + const selectButton = container.querySelector('[data-tool-id="select"]'); 23 + expect(selectButton?.getAttribute("aria-label")).toBe("Select"); 24 + expect(selectButton?.getAttribute("aria-pressed")).toBe("true"); 25 + 26 + const rectButton = container.querySelector('[data-tool-id="rect"]'); 27 + expect(rectButton?.getAttribute("aria-label")).toBe("Rectangle"); 28 + expect(rectButton?.getAttribute("aria-pressed")).toBe("false"); 29 + }); 30 + 31 + it("should have ARIA attributes on zoom button", () => { 32 + const store = new Store(); 33 + const { container } = render(Toolbar, { 34 + props: { 35 + currentTool: "select", 36 + onToolChange: () => {}, 37 + store, 38 + getViewport: () => ({ width: 800, height: 600 }), 39 + }, 40 + }); 41 + 42 + const zoomButton = container.querySelector(".toolbar__zoom-button"); 43 + expect(zoomButton?.getAttribute("aria-label")).toBe("Zoom level"); 44 + expect(zoomButton?.getAttribute("aria-haspopup")).toBe("true"); 45 + expect(zoomButton?.getAttribute("aria-expanded")).toBe("false"); 46 + }); 47 + 48 + it("should have proper menu roles when zoom menu is open", async () => { 49 + const store = new Store(); 50 + const { container } = render(Toolbar, { 51 + props: { 52 + currentTool: "select", 53 + onToolChange: () => {}, 54 + store, 55 + getViewport: () => ({ width: 800, height: 600 }), 56 + }, 57 + }); 58 + 59 + const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 60 + zoomButton.click(); 61 + 62 + await new Promise((resolve) => setTimeout(resolve, 0)); 63 + 64 + const zoomMenu = container.querySelector(".toolbar__zoom-menu"); 65 + expect(zoomMenu?.getAttribute("role")).toBe("menu"); 66 + expect(zoomMenu?.getAttribute("aria-label")).toBe("Zoom options"); 67 + 68 + const menuItems = container.querySelectorAll(".toolbar__zoom-menu .toolbar__menu-item"); 69 + menuItems.forEach((item) => { 70 + expect(item.getAttribute("role")).toBe("menuitem"); 71 + expect(item.getAttribute("aria-label")).toBeTruthy(); 72 + }); 73 + }); 74 + 75 + it("should have ARIA attributes on export button", () => { 76 + const store = new Store(); 77 + const { container } = render(Toolbar, { 78 + props: { 79 + currentTool: "select", 80 + onToolChange: () => {}, 81 + store, 82 + getViewport: () => ({ width: 800, height: 600 }), 83 + }, 84 + }); 85 + 86 + const exportButton = container.querySelector(".toolbar__export-button"); 87 + expect(exportButton?.getAttribute("aria-label")).toBe("Export drawing"); 88 + expect(exportButton?.getAttribute("aria-haspopup")).toBe("true"); 89 + expect(exportButton?.getAttribute("aria-expanded")).toBe("false"); 90 + }); 91 + 92 + it("should have proper menu roles when export menu is open", async () => { 93 + const store = new Store(); 94 + const { container } = render(Toolbar, { 95 + props: { 96 + currentTool: "select", 97 + onToolChange: () => {}, 98 + store, 99 + getViewport: () => ({ width: 800, height: 600 }), 100 + }, 101 + }); 102 + 103 + const exportButton = container.querySelector(".toolbar__export-button") as HTMLButtonElement; 104 + exportButton.click(); 105 + 106 + await new Promise((resolve) => setTimeout(resolve, 0)); 107 + 108 + const exportMenu = container.querySelector(".toolbar__export-menu"); 109 + expect(exportMenu?.getAttribute("role")).toBe("menu"); 110 + expect(exportMenu?.getAttribute("aria-label")).toBe("Export options"); 111 + 112 + const menuItems = container.querySelectorAll(".toolbar__export-menu .toolbar__menu-item"); 113 + expect(menuItems.length).toBe(3); 114 + menuItems.forEach((item) => { 115 + expect(item.getAttribute("role")).toBe("menuitem"); 116 + expect(item.getAttribute("aria-label")).toBeTruthy(); 117 + }); 118 + }); 119 + 120 + it("should have visible focus states on buttons", () => { 121 + const store = new Store(); 122 + const { container } = render(Toolbar, { 123 + props: { 124 + currentTool: "select", 125 + onToolChange: () => {}, 126 + store, 127 + getViewport: () => ({ width: 800, height: 600 }), 128 + }, 129 + }); 130 + 131 + const selectButton = container.querySelector(".toolbar__tool-button") as HTMLElement; 132 + selectButton.focus(); 133 + 134 + const style = window.getComputedStyle(selectButton); 135 + // Focus styles are defined in CSS, just verify the button can receive focus 136 + expect(document.activeElement).toBe(selectButton); 137 + }); 138 + 139 + it("should update aria-expanded when menus are toggled", async () => { 140 + const store = new Store(); 141 + const { container } = render(Toolbar, { 142 + props: { 143 + currentTool: "select", 144 + onToolChange: () => {}, 145 + store, 146 + getViewport: () => ({ width: 800, height: 600 }), 147 + }, 148 + }); 149 + 150 + const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 151 + 152 + expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 153 + 154 + zoomButton.click(); 155 + await new Promise((resolve) => setTimeout(resolve, 0)); 156 + 157 + expect(zoomButton.getAttribute("aria-expanded")).toBe("true"); 158 + 159 + zoomButton.click(); 160 + await new Promise((resolve) => setTimeout(resolve, 0)); 161 + 162 + expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 163 + }); 164 + });