web based infinite canvas
2
fork

Configure Feed

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

refactor: styling

* simplify/prune README

+452 -578
+9 -133
README.md
··· 1 - # INKFINITE 2 - 3 - An infinite canvas whiteboard application for creative visual thinking and collaboration. 1 + # Inkfinite 4 2 5 - ## Overview 6 - 7 - Inkfinite is a web-based infinite canvas application. 3 + A web-based infinite canvas application for creative visual thinking. 8 4 9 5 ## Architecture 10 6 11 - Inkfinite is built with a reactive architecture and optimized canvas rendering. 12 - There are pan, zoom, and shape manipulation tools. 7 + Inkfinite is built with reactivity, vector math, and optimized canvas rendering. 13 8 14 9 The project is organized as a pnpm monorepo with the following structure: 15 10 ··· 119 114 120 115 ### Development 121 116 122 - ```bash 123 - cd apps/desktop 124 - 125 - # Development mode (with hot reload) 126 - pnpm tauri dev 127 - 128 - # Build production app 129 - pnpm tauri build 130 - ``` 131 - 132 - **Note:** The web app automatically detects when running in Tauri and switches from IndexedDB to file-based persistence. 133 - 134 - </details> 135 - 136 - ## Development 137 - 138 - ### Prerequisites 117 + #### Prerequisites 139 118 140 119 **Standard Setup:** 141 120 ··· 147 126 - Nix with flakes enabled 148 127 - For desktop app: Rust via [rustup](https://rustup.rs) (not Nix) 149 128 150 - ### Setup 151 - 152 - <details> 153 - <summary> 154 - Standard 155 - </summary> 156 - 157 129 ```bash 158 - # Install dependencies 159 - pnpm install 160 - 161 - # Run tests 162 - pnpm test 163 - 164 - # Build all packages 165 - pnpm build 166 - 167 - # Start web app in development 168 - cd apps/web 169 - pnpm dev 170 - ``` 171 - 172 - </details> 173 - 174 - <details> 175 - <summary> 176 - Nix Shell 177 - </summary> 178 - 179 - ```bash 180 - # Enter Nix development shell (provides Node.js & pnpm) 181 - nix-shell 182 - 183 - # Install dependencies 184 - pnpm install 185 - 186 - # For desktop app development, ensure Rust is installed via rustup: 187 - # curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh --no-modify-path -y 188 - 189 - # Run web app 190 - cd apps/web 191 - pnpm dev 192 - 193 - # Run desktop app (Tauri) 194 130 cd apps/desktop 195 - pnpm tauri dev 196 - ``` 197 - 198 - - Node.js and pnpm are provided by Nix for consistency 199 - - Rust must be installed via rustup to avoid macOS framework linking issues 200 - - The shell automatically configures paths and SDK for Tauri development 201 - 202 - </details> 203 131 204 - ### Project 132 + # Development mode (with hot reload) 133 + pnpm tauri dev 205 134 206 - <details> 207 - <summary> 208 - Structure 209 - </summary> 210 - 211 - ```sh 212 - . 213 - ├── packages/ 214 - │ ├── core/ 215 - │ │ ├── src/ 216 - │ │ │ ├── math.ts # Vector and matrix math 217 - │ │ │ ├── camera.ts # Camera transforms 218 - │ │ │ ├── geom.ts # Geometry utilities 219 - │ │ │ ├── model.ts # Data structures 220 - │ │ │ ├── reactivity.ts # State management 221 - │ │ │ └── actions.ts # Input system 222 - │ │ └── package.json 223 - │ └── renderer/ 224 - │ ├── src/ 225 - │ │ └── index.ts # Canvas renderer 226 - │ └── package.json 227 - └── apps/ 228 - └── web/ 229 - ├── src/ 230 - │ ├── routes/ # SvelteKit routes 231 - │ └── lib/ # Svelte components 232 - └── package.json 135 + # Build production app 136 + pnpm tauri build 233 137 ``` 234 138 235 - </details> 236 - 237 - <details> 238 - <summary> 239 - Design Principles 240 - </summary> 241 - 242 - ### Code Organization 243 - 244 - - **Namespace pattern** - Types and operations co-located (e.g., `Vec2` type + `Vec2.add()` function) 245 - - **Pure functions** - Immutable operations, no side effects 246 - - **Type safety** - Full TypeScript coverage with strict mode 247 - 248 - ### Coordinate Systems 249 - 250 - - **World space** - Infinite 2D plane for shape coordinates 251 - - **Screen space** - Viewport pixels, origin at top-left 252 - - **Camera** - Mediates between world and screen coordinates 253 - 254 - </details> 255 - 256 - <details> 257 - <summary> 258 - Theme 259 - </summary> 260 - 261 - - **Light:** Nord color palette 262 - - **Dark:** Iceberg.vim color palette 263 - - **Font:** Open Sans 139 + **Note:** The web app automatically detects when running in Tauri and switches from IndexedDB to file-based persistence. 264 140 265 141 </details>
+7 -3
apps/web/src/app.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..700&display=swap'); 1 + @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@300..700&display=swap'); 2 2 3 3 :root { 4 4 --bg-primary: #eceff4; ··· 143 143 } 144 144 145 145 body { 146 - font-family: 'Open Sans', sans-serif; 146 + font-family: 'Work Sans', sans-serif; 147 147 background-color: var(--surface); 148 148 color: var(--text); 149 - line-height: 1.6; 149 + line-height: 1.25; 150 + } 151 + 152 + button { 153 + font-family: 'Work Sans', sans-serif; 150 154 } 151 155 152 156 ::selection {
+4 -6
apps/web/src/lib/canvas/Canvas.svelte
··· 1 1 <script lang="ts"> 2 2 import HistoryViewer from '$lib/components/HistoryViewer.svelte'; 3 3 import StatusBar from '$lib/components/StatusBar.svelte'; 4 - import TitleBar from '$lib/components/TitleBar.svelte'; 5 4 import Toolbar from '$lib/components/Toolbar.svelte'; 6 5 import FileBrowser from '$lib/filebrowser/FileBrowser.svelte'; 7 6 import { createCanvasController } from './canvas-store.svelte.ts'; ··· 47 46 </script> 48 47 49 48 <div class="editor"> 50 - <TitleBar 49 + <Toolbar 51 50 {platform} 52 51 desktop={{ 53 52 fileName: c.desktop.fileName, ··· 57 56 onSaveAs: () => c.desktop.handleSaveAs(null), 58 57 onSelectBoard: c.desktop.handleRecentSelect 59 58 }} 60 - onOpenBrowser={c.fileBrowser.handleOpen} /> 61 - <Toolbar 59 + onOpenBrowser={c.fileBrowser.handleOpen} 62 60 currentTool={c.tools.currentToolId} 63 61 onToolChange={c.tools.handleChange} 64 62 onHistoryClick={c.history.handleClick} ··· 193 191 border: 1px solid var(--accent); 194 192 background: var(--surface); 195 193 color: var(--text); 196 - padding: 4px; 194 + padding: 0.25rem; 197 195 transform-origin: top left; 198 196 resize: none; 199 197 outline: none; ··· 219 217 box-shadow: 220 218 0 0 0 1px rgba(0, 0, 0, 0.05), 221 219 0 8px 20px rgba(0, 0, 0, 0.15); 222 - border-radius: 4px; 220 + border-radius: 0.25rem; 223 221 } 224 222 225 223 .canvas-markdown-editor {
+9 -9
apps/web/src/lib/components/ArrowPopover.svelte
··· 198 198 border: 1px solid var(--border); 199 199 background: var(--surface); 200 200 color: var(--text); 201 - padding: 8px 12px; 202 - border-radius: 4px; 201 + padding: 0.5rem 0.75rem; 202 + border-radius: 0.25rem; 203 203 cursor: pointer; 204 204 font-size: 13px; 205 205 min-width: 60px; ··· 228 228 border: 1px solid var(--border); 229 229 border-radius: 6px; 230 230 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 231 - padding: 12px; 231 + padding: 0.75rem; 232 232 display: flex; 233 233 flex-direction: column; 234 - gap: 12px; 234 + gap: 0.75rem; 235 235 z-index: 10; 236 236 min-width: 200px; 237 237 } ··· 243 243 } 244 244 245 245 .arrow-popover__label { 246 - font-size: 12px; 246 + font-size: 0.75rem; 247 247 font-weight: 500; 248 248 color: var(--text); 249 249 } ··· 258 258 border: 1px solid var(--border); 259 259 background: var(--surface); 260 260 color: var(--text); 261 - padding: 6px 12px; 262 - border-radius: 4px; 261 + padding: 6px 0.75rem; 262 + border-radius: 0.25rem; 263 263 cursor: pointer; 264 - font-size: 12px; 264 + font-size: 0.75rem; 265 265 transition: all 0.15s; 266 266 } 267 267 ··· 286 286 background: var(--surface); 287 287 color: var(--text); 288 288 padding: 6px 8px; 289 - border-radius: 4px; 289 + border-radius: 0.25rem; 290 290 font-size: 13px; 291 291 } 292 292
+7 -7
apps/web/src/lib/components/BrushPopover.svelte
··· 230 230 border: 1px solid var(--border); 231 231 background: var(--surface); 232 232 color: var(--text); 233 - padding: 8px 12px; 234 - border-radius: 4px; 233 + padding: 0.5rem 0.75rem; 234 + border-radius: 0.25rem; 235 235 cursor: pointer; 236 236 font-size: 13px; 237 237 min-width: 60px; ··· 260 260 border: 1px solid var(--border); 261 261 border-radius: 6px; 262 262 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 263 - padding: 12px; 263 + padding: 0.75rem; 264 264 display: flex; 265 265 flex-direction: column; 266 - gap: 12px; 266 + gap: 0.75rem; 267 267 z-index: 10; 268 268 min-width: 200px; 269 269 } ··· 278 278 display: flex; 279 279 justify-content: space-between; 280 280 align-items: center; 281 - font-size: 12px; 281 + font-size: 0.75rem; 282 282 color: var(--text); 283 283 } 284 284 ··· 293 293 294 294 .brush-popover__control input[type='range'] { 295 295 width: 100%; 296 - height: 4px; 296 + height: 0.25rem; 297 297 border-radius: 2px; 298 298 background: var(--border); 299 299 outline: none; ··· 338 338 .brush-popover__control--color input[type='color'] { 339 339 width: 100%; 340 340 border: 1px solid var(--border); 341 - border-radius: 4px; 341 + border-radius: 0.25rem; 342 342 height: 32px; 343 343 background: var(--surface); 344 344 cursor: pointer;
+8 -8
apps/web/src/lib/components/HistoryViewer.svelte
··· 116 116 } 117 117 118 118 .history-actions button { 119 - padding: 6px 12px; 119 + padding: 6px 0.75rem; 120 120 border: 1px solid #ccc; 121 - border-radius: 4px; 121 + border-radius: 0.25rem; 122 122 background-color: white; 123 123 cursor: pointer; 124 124 font-size: 14px; ··· 149 149 150 150 .empty-state { 151 151 margin: 0; 152 - padding: 12px; 152 + padding: 0.75rem; 153 153 text-align: center; 154 154 color: #999; 155 155 font-size: 14px; ··· 166 166 display: flex; 167 167 justify-content: space-between; 168 168 align-items: center; 169 - padding: 8px 12px; 170 - margin-bottom: 4px; 171 - border-radius: 4px; 169 + padding: 0.5rem 0.75rem; 170 + margin-bottom: 0.25rem; 171 + border-radius: 0.25rem; 172 172 background-color: #f9f9f9; 173 173 border-left: 3px solid #4dabf7; 174 174 } ··· 190 190 } 191 191 192 192 .entry-time { 193 - font-size: 12px; 193 + font-size: 0.75rem; 194 194 color: #666; 195 195 } 196 196 197 197 .entry-index { 198 - font-size: 12px; 198 + font-size: 0.75rem; 199 199 color: #999; 200 200 font-weight: 500; 201 201 }
+15 -19
apps/web/src/lib/components/StatusBar.svelte
··· 141 141 <span class="status-bar__value">{formatSelection()}</span> 142 142 </div> 143 143 144 - <div class="status-bar__section status-bar__section--snap"> 145 - <span class="status-bar__label">Snap</span> 144 + <div class="status-bar__section"> 146 145 <div class="status-bar__toggle-row"> 147 146 <label class="status-bar__toggle"> 147 + <span>Snap</span> 148 148 <input 149 149 type="checkbox" 150 150 checked={snapSnapshot.snapEnabled} 151 151 onchange={handleSnapToggle} 152 152 aria-label="Enable main snapping" /> 153 - <span>Main</span> 154 153 </label> 155 154 <label class="status-bar__toggle"> 155 + <span>Show Grid</span> 156 156 <input 157 157 type="checkbox" 158 158 checked={snapSnapshot.gridEnabled} 159 159 onchange={handleGridToggle} 160 160 aria-label="Enable grid snapping" /> 161 - <span>Grid</span> 162 161 </label> 163 162 </div> 164 163 </div> ··· 177 176 .status-bar { 178 177 display: grid; 179 178 grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 180 - gap: 12px; 181 - padding: 8px 16px; 179 + gap: 1rem; 180 + padding: 0.5rem 1rem; 182 181 background: var(--surface-elevated); 183 182 border-top: 1px solid var(--border); 184 - font-size: 13px; 183 + font-size: 0.75rem; 185 184 align-items: center; 186 - min-height: 48px; 185 + min-height: 40px; 187 186 } 188 187 189 188 .status-bar__section { 190 189 display: flex; 191 - flex-direction: column; 192 - gap: 2px; 190 + flex-direction: row; 191 + align-items: center; 192 + gap: 0.5rem; 193 193 position: relative; 194 194 } 195 195 196 - .status-bar__section--snap { 197 - align-items: flex-start; 198 - } 199 - 200 196 .status-bar__toggle-row { 201 197 display: flex; 202 - gap: 8px; 198 + gap: 1rem; 203 199 } 204 200 205 201 .status-bar__toggle { 206 202 display: flex; 207 203 align-items: center; 208 - gap: 4px; 209 - font-size: 12px; 204 + gap: 0.25rem; 205 + font-size: 0.75rem; 210 206 color: var(--text); 211 207 } 212 208 ··· 221 217 } 222 218 223 219 .status-bar__label { 224 - font-size: 11px; 220 + font-size: 0.75rem; 225 221 color: var(--text-muted); 226 222 text-transform: uppercase; 227 223 letter-spacing: 0.05em; ··· 237 233 } 238 234 239 235 .status-bar__mode { 240 - font-size: 12px; 236 + font-size: 0.75rem; 241 237 color: var(--text-muted); 242 238 } 243 239 </style>
-318
apps/web/src/lib/components/TitleBar.svelte
··· 1 - <script lang="ts"> 2 - import Dialog from '$lib/components/Dialog.svelte'; 3 - import Icon from '$lib/components/Icon.svelte'; 4 - import type { Platform } from '$lib/platform'; 5 - import type { BoardMeta } from 'inkfinite-core'; 6 - import icon from '../assets/favicon.svg'; 7 - 8 - const helpLinks = [ 9 - { 10 - label: 'Project README', 11 - href: 'https://github.com/stormlightlabs/inkfinite', 12 - external: true 13 - }, 14 - { 15 - label: 'Issue Tracker', 16 - href: 'https://github.com/stormlightlabs/inkfinite/issues', 17 - external: true 18 - } 19 - ]; 20 - 21 - const keyboardTips = [ 22 - '⌘/Ctrl + Z to undo, ⇧ + ⌘/Ctrl + Z to redo', 23 - 'Hold space to pan the canvas', 24 - 'Scroll to zoom, double-click to reset view' 25 - ]; 26 - 27 - type DesktopControls = { 28 - fileName: string | null; 29 - recentBoards: BoardMeta[]; 30 - onOpen?: () => void | Promise<void>; 31 - onNew?: () => void | Promise<void>; 32 - onSaveAs?: () => void | Promise<void>; 33 - onSelectBoard?: (boardId: string) => void | Promise<void>; 34 - }; 35 - 36 - type Props = { platform?: Platform; desktop?: DesktopControls; onOpenBrowser?: () => void }; 37 - 38 - let { platform = 'web', desktop, onOpenBrowser }: Props = $props(); 39 - 40 - let infoOpen = $state(false); 41 - function openInfo() { 42 - infoOpen = true; 43 - } 44 - function closeInfo() { 45 - infoOpen = false; 46 - } 47 - 48 - function invokeDesktopAction(action?: () => void | Promise<void>) { 49 - if (action) { 50 - void action(); 51 - } 52 - } 53 - 54 - function handleRecentSelect(event: Event) { 55 - if (!desktop?.onSelectBoard) { 56 - return; 57 - } 58 - const select = event.currentTarget as HTMLSelectElement; 59 - const boardId = select.value; 60 - if (boardId) { 61 - void desktop.onSelectBoard(boardId); 62 - } 63 - select.value = ''; 64 - } 65 - 66 - function desktopFileLabel() { 67 - return desktop?.fileName ?? 'Unsaved board'; 68 - } 69 - </script> 70 - 71 - <header class="titlebar"> 72 - <div class="titlebar__brand"> 73 - <div class="titlebar__logo"> 74 - <img src={icon} alt="Inkfinite Icon" /> 75 - </div> 76 - <div> 77 - <div class="titlebar__name">Inkfinite</div> 78 - <div class="titlebar__tagline">Infinite canvas playground</div> 79 - </div> 80 - </div> 81 - {#if platform === 'desktop' && desktop} 82 - <div class="titlebar__desktop"> 83 - <div class="titlebar__file" aria-live="polite">{desktopFileLabel()}</div> 84 - <div class="titlebar__desktop-actions"> 85 - <button 86 - class="titlebar__desktop-button" 87 - type="button" 88 - onclick={() => invokeDesktopAction(desktop.onNew)} 89 - aria-label="Create new board"> 90 - New… 91 - </button> 92 - <button 93 - class="titlebar__desktop-button" 94 - type="button" 95 - onclick={() => invokeDesktopAction(desktop.onOpen)} 96 - aria-label="Open board from disk"> 97 - Open… 98 - </button> 99 - <button 100 - class="titlebar__desktop-button" 101 - type="button" 102 - onclick={() => invokeDesktopAction(desktop.onSaveAs)} 103 - aria-label="Save board as new file"> 104 - Save As… 105 - </button> 106 - {#if desktop.recentBoards.length > 0} 107 - <label class="titlebar__recent"> 108 - <span>Recent</span> 109 - <select onchange={handleRecentSelect} aria-label="Switch to recent board"> 110 - <option value="">Select…</option> 111 - {#each desktop.recentBoards as board (`${board.id}:${board.name}`)} 112 - <option value={board.id}>{board.name}</option> 113 - {/each} 114 - </select> 115 - </label> 116 - {/if} 117 - </div> 118 - </div> 119 - {/if} 120 - <div class="titlebar__spacer"></div> 121 - {#if platform === 'web' && onOpenBrowser} 122 - <button class="titlebar__info" onclick={onOpenBrowser} aria-label="Browse boards"> 123 - <Icon name="folder" size={16} /> 124 - <span class="titlebar__info-label">Boards</span> 125 - </button> 126 - {/if} 127 - <button class="titlebar__info" onclick={openInfo} aria-label="About Inkfinite"> 128 - <Icon name="info-circle" size={16} /> 129 - <span class="titlebar__info-label">Info</span> 130 - </button> 131 - </header> 132 - 133 - <Dialog bind:open={infoOpen} onClose={closeInfo} title="About Inkfinite"> 134 - <section class="about"> 135 - <h1>About Inkfinite</h1> 136 - <p> 137 - Inkfinite is a Svelte-native infinite canvas prototype. The goal is to build a cross-platform 138 - editor with a framework-agnostic core so the same engine powers both the web and desktop 139 - apps. 140 - </p> 141 - 142 - <div class="about__section"> 143 - <h2>Quick Tips</h2> 144 - <ul> 145 - {#each keyboardTips as tip (tip)} 146 - <li>{tip}</li> 147 - {/each} 148 - </ul> 149 - </div> 150 - 151 - <div class="about__section"> 152 - <h2>Need help?</h2> 153 - <ul> 154 - {#each helpLinks as link (link.href)} 155 - <li> 156 - <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> 157 - <a href={link.href} target={link.external ? '_blank' : undefined} rel="noreferrer"> 158 - {link.label} 159 - </a> 160 - </li> 161 - {/each} 162 - </ul> 163 - </div> 164 - </section> 165 - </Dialog> 166 - 167 - <style> 168 - .titlebar { 169 - display: flex; 170 - align-items: center; 171 - padding: 8px 16px; 172 - gap: 12px; 173 - background: var(--surface-elevated); 174 - border-bottom: 1px solid var(--border); 175 - } 176 - 177 - .titlebar__brand { 178 - display: flex; 179 - align-items: center; 180 - gap: 12px; 181 - } 182 - 183 - .titlebar__desktop { 184 - display: flex; 185 - align-items: center; 186 - gap: 12px; 187 - } 188 - 189 - .titlebar__file { 190 - font-size: 13px; 191 - color: var(--text-secondary); 192 - } 193 - 194 - .titlebar__desktop-actions { 195 - display: flex; 196 - align-items: center; 197 - gap: 8px; 198 - flex-wrap: wrap; 199 - } 200 - 201 - .titlebar__desktop-button { 202 - border: 1px solid var(--border); 203 - background: var(--surface); 204 - color: var(--text); 205 - border-radius: 6px; 206 - padding: 4px 10px; 207 - font-size: 13px; 208 - cursor: pointer; 209 - } 210 - 211 - .titlebar__desktop-button:hover { 212 - background: var(--surface-elevated); 213 - } 214 - 215 - .titlebar__recent { 216 - display: flex; 217 - align-items: center; 218 - gap: 6px; 219 - font-size: 12px; 220 - color: var(--text-secondary); 221 - } 222 - 223 - .titlebar__recent select { 224 - font-size: 12px; 225 - padding: 4px 6px; 226 - border-radius: 4px; 227 - border: 1px solid var(--border); 228 - background: var(--surface); 229 - color: var(--text); 230 - } 231 - 232 - .titlebar__logo { 233 - width: 36px; 234 - height: 36px; 235 - border-radius: 8px; 236 - background: var(--accent); 237 - color: var(--surface); 238 - font-weight: 600; 239 - display: flex; 240 - align-items: center; 241 - justify-content: center; 242 - font-size: 18px; 243 - } 244 - 245 - .titlebar__name { 246 - font-weight: 600; 247 - color: var(--text); 248 - } 249 - 250 - .titlebar__tagline { 251 - font-size: 12px; 252 - color: var(--text-muted); 253 - } 254 - 255 - .titlebar__spacer { 256 - flex: 1; 257 - } 258 - 259 - .titlebar__info { 260 - display: inline-flex; 261 - align-items: center; 262 - gap: 8px; 263 - border: 1px solid var(--border); 264 - background: var(--surface); 265 - color: var(--text); 266 - border-radius: 999px; 267 - padding: 4px 10px; 268 - cursor: pointer; 269 - font-size: 14px; 270 - } 271 - 272 - .titlebar__info:hover { 273 - background: var(--surface-elevated); 274 - } 275 - 276 - .titlebar__info-label { 277 - font-size: 12px; 278 - color: var(--text-secondary); 279 - } 280 - 281 - .about { 282 - padding: 24px; 283 - max-width: 480px; 284 - } 285 - 286 - .about h1 { 287 - margin-top: 0; 288 - font-size: 22px; 289 - } 290 - 291 - .about__section { 292 - margin-top: 20px; 293 - } 294 - 295 - .about__section h2 { 296 - margin-bottom: 8px; 297 - font-size: 16px; 298 - color: var(--text-secondary); 299 - } 300 - 301 - .about__section ul { 302 - margin: 0; 303 - padding-left: 20px; 304 - } 305 - 306 - .about__section li + li { 307 - margin-top: 4px; 308 - } 309 - 310 - .about__section a { 311 - color: var(--accent); 312 - text-decoration: none; 313 - } 314 - 315 - .about__section a:hover { 316 - text-decoration: underline; 317 - } 318 - </style>
+313 -38
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 + import Icon from '$lib/components/Icon.svelte'; 3 + import { 4 + DEFAULT_FILL_COLOR, 5 + DEFAULT_STROKE_COLOR, 6 + HELP_LINKS, 7 + KEYBOARD_TIPS, 8 + TOOLS, 9 + ZOOM_PRESETS 10 + } from '$lib/constants'; 11 + import type { Platform } from '$lib/platform'; 2 12 import type { BrushSettings, BrushStore } from '$lib/status'; 3 13 import type { 4 14 ArrowShape, 15 + BoardMeta, 5 16 Box2, 6 17 EditorState as EditorStateType, 7 18 EllipseShape, ··· 21 32 shapeBounds, 22 33 SnapshotCommand 23 34 } from 'inkfinite-core'; 35 + import icon from '../assets/favicon.svg'; 24 36 import ArrowPopover from './ArrowPopover.svelte'; 25 37 import BrushPopover from './BrushPopover.svelte'; 38 + import Dialog from './Dialog.svelte'; 26 39 27 40 type Viewport = { width: number; height: number }; 28 41 42 + type DesktopControls = { 43 + fileName: string | null; 44 + recentBoards: BoardMeta[]; 45 + onOpen?: () => void | Promise<void>; 46 + onNew?: () => void | Promise<void>; 47 + onSaveAs?: () => void | Promise<void>; 48 + onSelectBoard?: (boardId: string) => void | Promise<void>; 49 + }; 50 + 29 51 type Props = { 30 52 currentTool: ToolId; 31 53 onToolChange: (toolId: ToolId) => void; ··· 34 56 getViewport: () => Viewport; 35 57 canvas?: HTMLCanvasElement; 36 58 brushStore: BrushStore; 59 + platform?: Platform; 60 + desktop?: DesktopControls; 61 + onOpenBrowser?: () => void; 37 62 }; 38 63 39 64 let { ··· 43 68 store, 44 69 getViewport, 45 70 canvas, 46 - brushStore 71 + brushStore, 72 + platform = 'web', 73 + desktop, 74 + onOpenBrowser 47 75 }: Props = $props(); 48 - 49 - const DEFAULT_FILL_COLOR = '#4a90e2'; 50 - const DEFAULT_STROKE_COLOR = '#2e5c8a'; 51 76 52 77 let editorState = $derived<EditorStateType>(store.getState()); 53 78 let zoomMenuOpen = $state(false); ··· 62 87 let strokeDisabled = $state(true); 63 88 let brush = $derived<BrushSettings>(brushStore.get()); 64 89 let hasArrowSelection = $derived(getSelectedShapes(editorState).some((s) => s.type === 'arrow')); 90 + let infoOpen = $state(false); 65 91 66 92 $effect(() => { 67 93 editorState = store.getState(); ··· 140 166 return () => document.removeEventListener('pointerdown', handlePointerDown); 141 167 }); 142 168 143 - const tools: Array<{ id: ToolId; label: string; icon: string }> = [ 144 - { id: 'select', label: 'Select', icon: '⌖' }, 145 - { id: 'rect', label: 'Rectangle', icon: '▭' }, 146 - { id: 'ellipse', label: 'Ellipse', icon: '○' }, 147 - { id: 'line', label: 'Line', icon: '╱' }, 148 - { id: 'arrow', label: 'Arrow', icon: '→' }, 149 - { id: 'text', label: 'Text', icon: 'T' }, 150 - { id: 'markdown', label: 'Markdown', icon: 'M↓' }, 151 - { id: 'pen', label: 'Pen', icon: '✎' } 152 - ]; 169 + function openInfo() { 170 + infoOpen = true; 171 + } 153 172 154 - const zoomPresets = [ 155 - { label: '50%', value: 50 }, 156 - { label: '100%', value: 100 }, 157 - { label: '200%', value: 200 } 158 - ]; 173 + function closeInfo() { 174 + infoOpen = false; 175 + } 159 176 160 177 function handleToolClick(toolId: ToolId) { 161 178 onToolChange(toolId); ··· 394 411 function handleBrushChange(newBrush: BrushSettings) { 395 412 brushStore.set(newBrush); 396 413 } 414 + 415 + function invokeDesktopAction(action?: () => void | Promise<void>) { 416 + if (action) { 417 + void action(); 418 + } 419 + } 420 + 421 + function handleRecentSelect(event: Event) { 422 + if (!desktop?.onSelectBoard) { 423 + return; 424 + } 425 + const select = event.currentTarget as HTMLSelectElement; 426 + const boardId = select.value; 427 + if (boardId) { 428 + void desktop.onSelectBoard(boardId); 429 + } 430 + select.value = ''; 431 + } 432 + 433 + function desktopFileLabel() { 434 + return desktop?.fileName ?? 'Unsaved board'; 435 + } 397 436 </script> 398 437 399 438 <div class="toolbar" role="toolbar" aria-label="Drawing tools"> 400 - {#each tools as tool (`${tool.id}:${tool.label}`)} 439 + <div class="toolbar__brand"> 440 + <div class="toolbar__logo"> 441 + <img src={icon} alt="Inkfinite Icon" /> 442 + </div> 443 + <div style="display: flex; gap: 0.125rem; flex-direction:column;"> 444 + <div class="toolbar__name">Inkfinite</div> 445 + <div class="toolbar__tagline">Stormlight Labs</div> 446 + </div> 447 + </div> 448 + {#if platform === 'desktop' && desktop} 449 + <div class="toolbar__desktop"> 450 + <div class="toolbar__file" aria-live="polite">{desktopFileLabel()}</div> 451 + <div class="toolbar__desktop-actions"> 452 + <button 453 + class="toolbar__desktop-button" 454 + type="button" 455 + onclick={() => invokeDesktopAction(desktop.onNew)} 456 + aria-label="Create new board"> 457 + New… 458 + </button> 459 + <button 460 + class="toolbar__desktop-button" 461 + type="button" 462 + onclick={() => invokeDesktopAction(desktop.onOpen)} 463 + aria-label="Open board from disk"> 464 + Open… 465 + </button> 466 + <button 467 + class="toolbar__desktop-button" 468 + type="button" 469 + onclick={() => invokeDesktopAction(desktop.onSaveAs)} 470 + aria-label="Save board as new file"> 471 + Save As… 472 + </button> 473 + {#if desktop.recentBoards.length > 0} 474 + <label class="toolbar__recent"> 475 + <span>Recent</span> 476 + <select onchange={handleRecentSelect} aria-label="Switch to recent board"> 477 + <option value="">Select…</option> 478 + {#each desktop.recentBoards as board (`${board.id}:${board.name}`)} 479 + <option value={board.id}>{board.name}</option> 480 + {/each} 481 + </select> 482 + </label> 483 + {/if} 484 + </div> 485 + </div> 486 + {/if} 487 + <div class="toolbar__divider"></div> 488 + {#each TOOLS as tool (`${tool.id}:${tool.label}`)} 401 489 <button 402 490 class="toolbar__tool-button tool-button" 403 491 class:toolbar__tool-button--active={currentTool === tool.id} ··· 435 523 <div class="toolbar__divider"></div> 436 524 437 525 <BrushPopover {brush} onBrushChange={handleBrushChange} disabled={currentTool !== 'pen'} /> 438 - 439 526 <ArrowPopover {store} disabled={!hasArrowSelection} /> 440 - 441 527 <div class="toolbar__zoom"> 442 528 <button 443 529 class="toolbar__zoom-button" ··· 451 537 452 538 {#if zoomMenuOpen} 453 539 <div class="toolbar__zoom-menu" bind:this={zoomMenuEl} role="menu" aria-label="Zoom options"> 454 - {#each zoomPresets as preset (`${preset.label}:${preset.value}`)} 540 + {#each ZOOM_PRESETS as preset (`${preset.label}:${preset.value}`)} 455 541 <button 456 542 class="toolbar__menu-item" 457 543 role="menuitem" ··· 479 565 {/if} 480 566 </div> 481 567 482 - <!-- Export controls --> 483 568 <div class="toolbar__export"> 484 569 <button 485 570 class="toolbar__export-button" ··· 522 607 {/if} 523 608 </div> 524 609 610 + <div class="toolbar__info-actions"> 611 + {#if platform === 'web' && onOpenBrowser} 612 + <button class="toolbar__info" onclick={onOpenBrowser} aria-label="Browse boards"> 613 + <Icon name="folder" size={16} /> 614 + <span class="toolbar__info-label">Boards</span> 615 + </button> 616 + {/if} 617 + <button class="toolbar__info" onclick={openInfo} aria-label="About Inkfinite"> 618 + <Icon name="info-circle" size={16} /> 619 + <span class="toolbar__info-label">Info</span> 620 + </button> 621 + </div> 622 + 525 623 {#if onHistoryClick} 526 624 <div class="toolbar__divider"></div> 527 625 <button ··· 536 634 {/if} 537 635 </div> 538 636 637 + <Dialog bind:open={infoOpen} onClose={closeInfo} title="About Inkfinite"> 638 + <section class="about"> 639 + <h1>About Inkfinite</h1> 640 + <p> 641 + Inkfinite is an infinite canvas prototype. The goal is to build a cross-platform editor with 642 + a framework-agnostic core so the same engine powers both the web and desktop apps. 643 + </p> 644 + 645 + <div class="about__section"> 646 + <h2>Quick Tips</h2> 647 + <ul> 648 + {#each KEYBOARD_TIPS as tip (tip)} 649 + <li>{tip}</li> 650 + {/each} 651 + </ul> 652 + </div> 653 + 654 + <div class="about__section"> 655 + <h2>Need help?</h2> 656 + <ul> 657 + {#each HELP_LINKS as link (link.href)} 658 + <li> 659 + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> 660 + <a href={link.href} target={link.external ? '_blank' : undefined} rel="noreferrer"> 661 + {link.label} 662 + </a> 663 + </li> 664 + {/each} 665 + </ul> 666 + </div> 667 + </section> 668 + </Dialog> 669 + 539 670 <style> 540 671 .toolbar { 541 672 display: flex; 542 - gap: 8px; 543 - padding: 12px; 673 + gap: 0.5rem; 674 + padding: 0.75rem; 544 675 background: var(--surface-elevated); 545 676 border-bottom: 1px solid var(--border); 546 677 align-items: center; ··· 550 681 display: flex; 551 682 flex-direction: column; 552 683 align-items: center; 553 - gap: 4px; 554 - padding: 8px 12px; 684 + gap: 0.25rem; 685 + padding: 0.5rem 0.75rem; 555 686 border: 1px solid var(--border); 556 - border-radius: 4px; 687 + border-radius: 0.25rem; 557 688 background: var(--surface); 558 689 color: var(--text); 559 690 cursor: pointer; ··· 579 710 } 580 711 581 712 .toolbar__tool-icon { 582 - font-size: 20px; 713 + font-size: 1.5rem; 583 714 line-height: 1; 584 715 } 585 716 586 717 .toolbar__tool-label { 587 - font-size: 11px; 718 + font-size: 0.75rem; 588 719 line-height: 1; 589 720 white-space: nowrap; 590 721 } ··· 598 729 599 730 .toolbar__colors { 600 731 display: flex; 601 - gap: 12px; 732 + gap: 0.75rem; 602 733 align-items: center; 603 734 } 604 735 605 736 .toolbar__color-control { 606 737 display: flex; 607 738 flex-direction: column; 608 - gap: 4px; 609 - font-size: 11px; 739 + gap: 0.25rem; 740 + font-size: 0.75rem; 610 741 color: var(--text-muted); 611 742 } 612 743 ··· 635 766 border: 1px solid var(--border); 636 767 background: var(--surface); 637 768 color: var(--text); 638 - padding: 8px 12px; 639 - border-radius: 4px; 769 + padding: 0.5rem 0.75rem; 770 + border-radius: 0.25rem; 640 771 cursor: pointer; 641 772 font-size: 13px; 642 773 min-width: 60px; ··· 666 797 padding: 8px; 667 798 display: flex; 668 799 flex-direction: column; 669 - gap: 4px; 800 + gap: 0.25rem; 670 801 z-index: 10; 671 802 min-width: 150px; 672 803 } ··· 676 807 background: transparent; 677 808 color: var(--text); 678 809 padding: 4px 8px; 679 - border-radius: 4px; 810 + border-radius: 0.25rem; 680 811 text-align: left; 681 812 cursor: pointer; 682 813 font-size: 13px; ··· 699 830 700 831 .toolbar__tool-button--history { 701 832 margin-left: auto; 833 + } 834 + 835 + .toolbar__brand { 836 + display: flex; 837 + align-items: center; 838 + gap: 0.5rem; 839 + } 840 + 841 + .toolbar__desktop { 842 + display: flex; 843 + align-items: center; 844 + gap: 0.75rem; 845 + } 846 + 847 + .toolbar__file { 848 + font-size: 13px; 849 + color: var(--text-secondary); 850 + } 851 + 852 + .toolbar__desktop-actions { 853 + display: flex; 854 + align-items: center; 855 + gap: 8px; 856 + flex-wrap: wrap; 857 + } 858 + 859 + .toolbar__desktop-button { 860 + border: 1px solid var(--border); 861 + background: var(--surface); 862 + color: var(--text); 863 + border-radius: 6px; 864 + padding: 4px 10px; 865 + font-size: 13px; 866 + cursor: pointer; 867 + } 868 + 869 + .toolbar__desktop-button:hover { 870 + background: var(--surface-elevated); 871 + } 872 + 873 + .toolbar__recent { 874 + display: flex; 875 + align-items: center; 876 + gap: 6px; 877 + font-size: 0.75rem; 878 + color: var(--text-secondary); 879 + } 880 + 881 + .toolbar__recent select { 882 + font-size: 0.75rem; 883 + padding: 4px 6px; 884 + border-radius: 0.25rem; 885 + border: 1px solid var(--border); 886 + background: var(--surface); 887 + color: var(--text); 888 + } 889 + 890 + .toolbar__logo { 891 + width: 36px; 892 + height: 36px; 893 + border-radius: 8px; 894 + background: var(--accent); 895 + color: var(--surface); 896 + font-weight: 600; 897 + display: flex; 898 + align-items: center; 899 + justify-content: center; 900 + font-size: 18px; 901 + } 902 + 903 + .toolbar__name { 904 + font-weight: 600; 905 + color: var(--text); 906 + } 907 + 908 + .toolbar__tagline { 909 + font-size: 0.75rem; 910 + color: var(--text-muted); 911 + } 912 + 913 + .toolbar__info { 914 + display: inline-flex; 915 + align-items: center; 916 + gap: 8px; 917 + border: 1px solid var(--border); 918 + background: var(--surface); 919 + color: var(--text); 920 + border-radius: 999px; 921 + padding: 4px 10px; 922 + cursor: pointer; 923 + font-size: 14px; 924 + } 925 + 926 + .toolbar__info:hover { 927 + background: var(--surface-elevated); 928 + } 929 + 930 + .toolbar__info-label { 931 + font-size: 0.75rem; 932 + color: var(--text-secondary); 933 + } 934 + 935 + .about { 936 + padding: 24px; 937 + max-width: 480px; 938 + } 939 + 940 + .about h1 { 941 + margin-top: 0; 942 + font-size: 22px; 943 + } 944 + 945 + .about__section { 946 + margin-top: 20px; 947 + } 948 + 949 + .about__section h2 { 950 + margin-bottom: 8px; 951 + font-size: 16px; 952 + color: var(--text-secondary); 953 + } 954 + 955 + .about__section ul { 956 + margin: 0; 957 + padding-left: 20px; 958 + } 959 + 960 + .about__section li + li { 961 + margin-top: 0.25rem; 962 + } 963 + 964 + .about__section a { 965 + color: var(--accent); 966 + text-decoration: none; 967 + } 968 + 969 + .about__section a:hover { 970 + text-decoration: underline; 971 + } 972 + 973 + .toolbar__info-actions { 974 + display: flex; 975 + flex-direction: column; 976 + gap: 0.25rem; 702 977 } 703 978 </style>
+29
apps/web/src/lib/constants.ts
··· 1 + import type { ToolId } from "inkfinite-core"; 2 + 3 + export const HELP_LINKS = [{ 4 + label: "Project README", 5 + href: "https://github.com/stormlightlabs/inkfinite", 6 + external: true, 7 + }, { label: "Issue Tracker", href: "https://github.com/stormlightlabs/inkfinite/issues", external: true }]; 8 + 9 + export const KEYBOARD_TIPS = [ 10 + "⌘/Ctrl + Z to undo, ⇧ + ⌘/Ctrl + Z to redo", 11 + "Hold space to pan the canvas", 12 + "Scroll to zoom, double-click to reset view", 13 + ]; 14 + 15 + export const DEFAULT_FILL_COLOR = "#4a90e2"; 16 + export const DEFAULT_STROKE_COLOR = "#2e5c8a"; 17 + 18 + export const TOOLS: Array<{ id: ToolId; label: string; icon: string }> = [ 19 + { id: "select", label: "Select", icon: "⌖" }, 20 + { id: "rect", label: "Rectangle", icon: "▭" }, 21 + { id: "ellipse", label: "Ellipse", icon: "○" }, 22 + { id: "line", label: "Line", icon: "╱" }, 23 + { id: "arrow", label: "Arrow", icon: "→" }, 24 + { id: "text", label: "Text", icon: "T" }, 25 + { id: "markdown", label: "Markdown", icon: "M↓" }, 26 + { id: "pen", label: "Pen", icon: "✎" }, 27 + ]; 28 + 29 + export const ZOOM_PRESETS = [{ label: "50%", value: 50 }, { label: "100%", value: 100 }, { label: "200%", value: 200 }];
+12 -12
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 488 488 color: var(--text-secondary, #666); 489 489 font-size: 1.5rem; 490 490 cursor: pointer; 491 - padding: 4px; 492 - border-radius: 4px; 491 + padding: 0.25rem; 492 + border-radius: 0.25rem; 493 493 display: flex; 494 494 align-items: center; 495 495 } ··· 506 506 background-color: var(--primary, #007bff); 507 507 color: white; 508 508 border: none; 509 - border-radius: 4px; 509 + border-radius: 0.25rem; 510 510 cursor: pointer; 511 511 font-size: 0.875rem; 512 512 font-weight: 500; ··· 543 543 padding: 0.25rem 0.5rem; 544 544 background-color: transparent; 545 545 border: 1px solid var(--border, #e0e0e0); 546 - border-radius: 4px; 546 + border-radius: 0.25rem; 547 547 cursor: pointer; 548 548 font-size: 0.75rem; 549 549 color: var(--text); ··· 563 563 background-color: var(--primary, #007bff); 564 564 color: white; 565 565 border: none; 566 - border-radius: 4px; 566 + border-radius: 0.25rem; 567 567 cursor: pointer; 568 568 font-size: 0.875rem; 569 569 } ··· 588 588 width: 100%; 589 589 padding: 0.5rem; 590 590 border: 1px solid var(--border, #e0e0e0); 591 - border-radius: 4px; 591 + border-radius: 0.25rem; 592 592 font-size: 0.875rem; 593 593 background-color: var(--input-bg, white); 594 594 color: var(--text); ··· 610 610 width: 100%; 611 611 padding: 0.5rem; 612 612 border: 1px solid var(--border, #e0e0e0); 613 - border-radius: 4px; 613 + border-radius: 0.25rem; 614 614 font-size: 0.875rem; 615 615 margin-bottom: 0.5rem; 616 616 background-color: var(--input-bg, white); ··· 631 631 .filebrowser__btn { 632 632 padding: 0.375rem 0.75rem; 633 633 border: none; 634 - border-radius: 4px; 634 + border-radius: 0.25rem; 635 635 cursor: pointer; 636 636 font-size: 0.875rem; 637 637 } ··· 746 746 display: flex; 747 747 align-items: center; 748 748 justify-content: center; 749 - border-radius: 4px; 749 + border-radius: 0.25rem; 750 750 transition: background-color 0.15s; 751 751 } 752 752 ··· 765 765 margin: 1rem; 766 766 background-color: var(--error-bg, #f8d7da); 767 767 color: var(--error-text, #721c24); 768 - border-radius: 4px; 768 + border-radius: 0.25rem; 769 769 border: 1px solid var(--error-border, #f5c6cb); 770 770 } 771 771 ··· 821 821 justify-content: space-between; 822 822 padding: 0.5rem; 823 823 background-color: var(--surface-hover, #f5f5f5); 824 - border-radius: 4px; 824 + border-radius: 0.25rem; 825 825 } 826 826 827 827 .inspector__migration-id { ··· 839 839 padding: 0.75rem; 840 840 background-color: var(--warning-bg, #fff3cd); 841 841 border: 1px solid var(--warning-border, #ffeaa7); 842 - border-radius: 4px; 842 + border-radius: 0.25rem; 843 843 } 844 844 845 845 .inspector__pending-title {
+2 -2
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 119 119 120 120 it("should render the header with info button", () => { 121 121 const { container } = render(Canvas); 122 - const titleBar = container.querySelector(".titlebar"); 122 + const titleBar = container.querySelector(".toolbar"); 123 123 expect(titleBar).toBeTruthy(); 124 - expect(titleBar?.querySelector(".titlebar__info")).toBeTruthy(); 124 + expect(titleBar?.querySelector(".toolbar__info")).toBeTruthy(); 125 125 }); 126 126 127 127 it("should render all tool buttons in toolbar", () => {
+36 -22
apps/web/src/lib/tests/TitleBar.svelte.test.ts
··· 1 - import { tick } from "svelte"; 1 + import { createBrushStore } from "$lib/status"; 2 + import { type ComponentProps, tick } from "svelte"; 2 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { cleanup, render } from "vitest-browser-svelte"; 4 - import TitleBar from "../components/TitleBar.svelte"; 5 + import Toolbar from "../components/Toolbar.svelte"; 6 + import { createStoreWithRect } from "./Toolbar.colors.test"; 7 + 8 + const renderToolbar = (overrides: Partial<ComponentProps<typeof Toolbar>> = {}) => { 9 + const onOpen = vi.fn(); 10 + const onNew = vi.fn(); 11 + const onSaveAs = vi.fn(); 12 + const onSelectBoard = vi.fn(); 13 + const recentBoards = [{ id: "board-1", name: "Board 1", createdAt: Date.now(), updatedAt: Date.now() }]; 14 + const brushStore = createBrushStore(); 15 + 16 + const { container } = render(Toolbar, { 17 + currentTool: "select", 18 + onToolChange: () => {}, 19 + store: createStoreWithRect(), 20 + getViewport: () => ({ width: 800, height: 600 }), 21 + brushStore, 22 + platform: "web", 23 + desktop: { fileName: "Board 1", recentBoards, onOpen, onNew, onSaveAs, onSelectBoard }, 24 + ...overrides, 25 + }); 5 26 6 - describe("TitleBar", () => { 27 + return { container, onNew, onOpen, onSaveAs, onSelectBoard, recentBoards }; 28 + }; 29 + 30 + describe("TitleBar (merged into Toolbar)", () => { 7 31 beforeEach(() => { 8 32 cleanup(); 9 33 }); 10 34 11 35 it("renders title/logo and info button", () => { 12 - const { container } = render(TitleBar); 13 - expect(container.querySelector(".titlebar")).toBeTruthy(); 14 - expect(container.querySelector(".titlebar__logo img")).toBeTruthy(); 15 - expect(container.querySelector(".titlebar__info")).toBeTruthy(); 36 + const { container } = renderToolbar(); 37 + expect(container.querySelector(".toolbar")).toBeTruthy(); 38 + expect(container.querySelector(".toolbar__logo img")).toBeTruthy(); 39 + expect(container.querySelector(".toolbar__info")).toBeTruthy(); 16 40 }); 17 41 18 42 it("opens info dialog when button clicked", async () => { 19 - const { container } = render(TitleBar); 20 - const button = container.querySelector(".titlebar__info") as HTMLButtonElement; 43 + const { container } = renderToolbar(); 44 + const button = container.querySelector(".toolbar__info") as HTMLButtonElement; 21 45 expect(button).toBeTruthy(); 22 46 23 47 button.click(); ··· 26 50 }); 27 51 28 52 it("shows desktop controls when running on desktop", async () => { 29 - const onOpen = vi.fn(); 30 - const onNew = vi.fn(); 31 - const onSaveAs = vi.fn(); 32 - const onSelectBoard = vi.fn(); 33 - const recentBoards = [{ id: "board-1", name: "Board 1", createdAt: Date.now(), updatedAt: Date.now() }]; 34 - 35 - const { container } = render(TitleBar, { 36 - platform: "desktop", 37 - desktop: { fileName: "Board 1", recentBoards, onOpen, onNew, onSaveAs, onSelectBoard }, 38 - }); 39 - 40 - const buttons = container.querySelectorAll(".titlebar__desktop-button"); 53 + const { container, onNew, onOpen, onSaveAs, onSelectBoard, recentBoards } = renderToolbar({ platform: "desktop" }); 54 + const buttons = container.querySelectorAll(".toolbar__desktop-button"); 41 55 expect(buttons).toHaveLength(3); 42 56 43 57 (buttons[0] as HTMLButtonElement).click(); ··· 52 66 await tick(); 53 67 expect(onSaveAs).toHaveBeenCalled(); 54 68 55 - const select = container.querySelector(".titlebar__recent select") as HTMLSelectElement; 69 + const select = container.querySelector(".toolbar__recent select") as HTMLSelectElement; 56 70 select.value = recentBoards[0].id; 57 71 select.dispatchEvent(new Event("change", { bubbles: true })); 58 72 await tick();
+1 -1
apps/web/src/lib/tests/Toolbar.colors.test.ts
··· 4 4 import Toolbar from "../components/Toolbar.svelte"; 5 5 import { createBrushStore } from "../status"; 6 6 7 - function createStoreWithRect() { 7 + export function createStoreWithRect() { 8 8 const store = new Store(); 9 9 const base = EditorState.create(); 10 10 const pageId = "page:rect";