pixelweaver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. package/vite.config.ts +32 -0
@@ -0,0 +1,1235 @@
1
+ # PixelWeaver Audit -- Implementation Plan
2
+
3
+ This document transforms the 10 design decisions from `audit-design-decisions.md` into
4
+ a concrete, step-by-step execution plan. Each item includes exact file paths, function
5
+ names, interface modifications, and line-level references to the current codebase.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [I-01: makeStrokeTool Factory (old #24)](#i-01-makestroketool-factory)
12
+ - [I-02: makeSnapshotUndo Factory (old #25)](#i-02-makesnapshotundo-factory)
13
+ - [I-03: Generic Command\<P\>, Typed CommandContext, Separate Snapshot (old #20+#23)](#i-03-generic-commandp-typed-commandcontext-separate-snapshot)
14
+ - [I-04: Undoable Flag on CommandDefinition (old #1)](#i-04-undoable-flag-on-commanddefinition)
15
+ - [I-05: Native \<dialog\> for Modals (old #22)](#i-05-native-dialog-for-modals)
16
+ - [I-06: Reusable PromptDialog Component (old #26)](#i-06-reusable-promptdialog-component)
17
+ - [I-07: God File Split (old #14)](#i-07-god-file-split)
18
+ - [I-08: Add getSelection() to PluginAPI (old #12)](#i-08-add-getselection-to-pluginapi)
19
+ - [I-09: Add Mutator Methods to PluginAPI (old #13)](#i-09-add-mutator-methods-to-pluginapi)
20
+ - [I-10: Enable Strict TypeScript Flags (old #19)](#i-10-enable-strict-typescript-flags)
21
+ - [Internal Consistency Check](#internal-consistency-check)
22
+
23
+ ---
24
+
25
+ ## Re-indexed Items in Implementation Order
26
+
27
+ | New Index | Old # | Title |
28
+ |-----------|-------|-------|
29
+ | I-01 | #24 | makeStrokeTool factory |
30
+ | I-02 | #25 | makeSnapshotUndo factory |
31
+ | I-03 | #20+#23 | Generic Command\<P\>, typed CommandContext, separate snapshot |
32
+ | I-04 | #1 | Undoable flag on CommandDefinition |
33
+ | I-05 | #22 | Native \<dialog\> for modals |
34
+ | I-06 | #26 | Reusable PromptDialog component |
35
+ | I-07 | #14 | God file split (8 domain files + 3 utilities) |
36
+ | I-08 | #12 | Add getSelection() to PluginAPI |
37
+ | I-09 | #13 | Add mutator methods to PluginAPI |
38
+ | I-10 | #19 | Enable strict TypeScript flags |
39
+
40
+ ---
41
+
42
+ ## I-01: makeStrokeTool Factory
43
+
44
+ ### Summary
45
+
46
+ `pencil-tool.ts` (128 lines) and `eraser-tool.ts` (123 lines) are structurally
47
+ identical. They differ only in command name, description verb, pixel color resolution,
48
+ icon, and toolbar order. Both maintain identical stroke state (`drawing`, `lastX`,
49
+ `lastY`, `strokePixels`, `strokeLayerId`), identical `onPointerDown`/`onPointerMove`/
50
+ `onPointerUp` handlers with Bresenham interpolation, and identical command
51
+ `execute`/`undo`/`describe` definitions using the `_snapshot` pattern. A factory
52
+ function will eliminate this duplication and make it trivial to add future stroke-based
53
+ tools (e.g., pixel-perfect pencil, tinted eraser).
54
+
55
+ ### Files Touched
56
+
57
+ | File Path | Action | What Changes |
58
+ |-----------|--------|--------------|
59
+ | `plugins/builtin/make-stroke-tool.ts` | Create | New factory function `makeStrokeTool(config: StrokeToolConfig): PluginModule` containing the shared command definition (execute/undo/describe), stroke state management, pointer handlers, and toolbar registration |
60
+ | `plugins/builtin/pencil-tool.ts` | Modify | Reduce from ~128 lines to ~20 lines. Remove all inline command definition, stroke state, and pointer handlers. Import `makeStrokeTool` and call it with config: `{ pluginName: 'builtin/pencil', commandName: 'draw_pixels', toolId: 'pencil', icon: PencilIcon, toolbarOrder: 10, toolbarGroup: 'draw', describeVerb: 'Drew', resolveColor: (ctx) => hexToRgba(ctx.color) }` |
61
+ | `plugins/builtin/eraser-tool.ts` | Modify | Reduce from ~123 lines to ~20 lines. Same transformation as pencil. Config: `{ pluginName: 'builtin/eraser', commandName: 'erase_pixels', toolId: 'eraser', icon: EraserIcon, toolbarOrder: 20, toolbarGroup: 'draw', describeVerb: 'Erased', resolveColor: () => ({ r: 0, g: 0, b: 0, a: 0 }) }` |
62
+
63
+ ### Step-by-Step Instructions
64
+
65
+ - Define the `StrokeToolConfig` interface in `make-stroke-tool.ts`:
66
+ - `pluginName: string` -- used in `Command.plugin` field (e.g., `'builtin/pencil'`)
67
+ - `commandName: string` -- used in `api.addCommand(name, ...)` (e.g., `'draw_pixels'`)
68
+ - `toolId: string` -- used in `api.addTool(name, ...)` (e.g., `'pencil'`)
69
+ - `icon: string | Component` -- the Svelte icon component
70
+ - `toolbarOrder: number` -- the `order` field in `ToolbarContribution`
71
+ - `toolbarGroup: string` -- the `group` field in `ToolbarContribution`
72
+ - `describeVerb: string` -- used in `describe()` like `"${verb} ${n} pixel(s)"`
73
+ - `resolveColor: (ctx: ToolContext) => { r: number; g: number; b: number; a: number }` -- returns RGBA for each stroke pixel
74
+ - Import shared dependencies from `drawing-utils.ts`: `bresenhamLine`, `snapshotPixels`, `applyPixels`; import types `PluginModule` from `plugin-loader.js`, `PixelBuffer` from `pixel-buffer.ts`, `PixelData` from `drawing-utils.ts`, `ToolContext` from `plugin-types.js`
75
+ - Implement `makeStrokeTool(config)` returning a `PluginModule`:
76
+ - The `register(api)` function body is extracted from the current `pencil-tool.ts` lines 23-127
77
+ - Command definition (lines 23-53 of pencil-tool.ts): `execute` uses `snapshotPixels` + `applyPixels`, `undo` restores snapshot, `describe` returns `"${config.describeVerb} ${pixels.length} pixel(s)"`
78
+ - Stroke state variables (lines 56-61): `drawing`, `lastX`, `lastY`, `strokePixels`, `strokeColor`, `strokeLayerId`
79
+ - Tool definition (lines 71-126): `onPointerDown` calls `config.resolveColor(ctx)` instead of hardcoded `hexToRgba(strokeColor)` or `{r:0,g:0,b:0,a:0}`, `onPointerMove` uses bresenham interpolation with resolved color, `onPointerUp` dispatches the command using `config.commandName` and `config.pluginName`
80
+ - Toolbar registration uses `config.toolId`, `config.toolbarGroup`, `config.toolbarOrder`
81
+ - In `pencil-tool.ts`, replace the entire body with:
82
+ - Import `makeStrokeTool` from `./make-stroke-tool.js`
83
+ - Import `hexToRgba` from `./drawing-utils.js`
84
+ - Import `PencilIcon` from `~icons/lucide/pencil`
85
+ - Export `pencilToolPlugin = makeStrokeTool({ ... })`
86
+ - In `eraser-tool.ts`, same pattern but with eraser-specific config
87
+ - Handle `strokeColor` capturing: in pencil, `resolveColor` receives the `ToolContext` on each call, so it can read `ctx.color` at pointer-down time. The factory should call `resolveColor(ctx)` in `onPointerDown` and cache the result for the duration of the stroke (the current pencil does this with `strokeColor = ctx.color` on line 79)
88
+
89
+ ### Preconditions
90
+
91
+ - None. This is fully independent.
92
+
93
+ ### Postconditions / Verification
94
+
95
+ - `tsc --noEmit` passes
96
+ - Draw with pencil: pixels appear in foreground color
97
+ - Draw with eraser: pixels become transparent (r=0,g=0,b=0,a=0)
98
+ - Undo pencil stroke: pixels revert to pre-stroke state
99
+ - Undo eraser stroke: pixels revert to pre-erase state
100
+ - Both tools appear in the drawing-tools toolbar at their correct positions (pencil order=10, eraser order=20)
101
+ - Both tools display correct icons
102
+ - `grep -r "let drawing = false" plugins/builtin/` returns only `make-stroke-tool.ts` (no duplication)
103
+
104
+ ### Risks and Edge Cases
105
+
106
+ - The factory must capture `resolveColor(ctx)` once at `onPointerDown` and reuse the cached RGBA for the entire stroke, matching current pencil behavior (line 79: `strokeColor = ctx.color`). If `resolveColor` is called per-pixel in `onPointerMove`, the eraser will work fine (constant output) but the pencil could produce mixed colors if the user changes foreground color mid-stroke.
107
+ - The `pattern-stamp-tool.ts` should NOT use this factory; it has fundamentally different data flow (pattern buffers, tiling modes). Verify it remains unchanged.
108
+ - The `_snapshot` convention is still used here. I-02 (makeSnapshotUndo) and I-03 (Generic Command) will update this later.
109
+
110
+ ---
111
+
112
+ ## I-02: makeSnapshotUndo Factory
113
+
114
+ ### Summary
115
+
116
+ 32 out of 33 undo handlers across plugin files are byte-for-byte identical: get the
117
+ active buffer from context, bail if missing, apply snapshot pixels. This factory
118
+ extracts the pattern into a single reusable function in `drawing-utils.ts`. The single
119
+ exception (`move_selection` in `selection-tool.ts`) has custom undo logic and remains
120
+ manual. After I-03 changes the snapshot architecture (separate slot on UndoEntry), this
121
+ factory's signature will be updated, but it can be built now against the current
122
+ `_snapshot`-in-params pattern.
123
+
124
+ ### Files Touched
125
+
126
+ | File Path | Action | What Changes |
127
+ |-----------|--------|--------------|
128
+ | `plugins/builtin/drawing-utils.ts` | Modify | Add `makeSnapshotUndo()` function after the existing `applyPixels` function (after line 399). Returns `(params: Record<string, unknown>, ctx: CommandContext) => void` |
129
+ | `plugins/builtin/make-stroke-tool.ts` | Modify | Import and use `makeSnapshotUndo()` as the `undo` handler in the command definition (created in I-01) |
130
+ | `plugins/builtin/pencil-tool.ts` | Modify | If not already using factory from I-01, replace inline undo handler. If using I-01 factory, no change needed here |
131
+ | `plugins/builtin/eraser-tool.ts` | Modify | Same as pencil-tool.ts |
132
+ | `plugins/builtin/rect-tool.ts` | Modify | Replace lines 56-60 (undo handler) with `undo: makeSnapshotUndo()` |
133
+ | `plugins/builtin/circle-tool.ts` | Modify | Replace inline undo handler with `makeSnapshotUndo()` |
134
+ | `plugins/builtin/line-tool.ts` | Modify | Replace inline undo handler with `makeSnapshotUndo()` |
135
+ | `plugins/builtin/diamond-tool.ts` | Modify | Replace inline undo handler with `makeSnapshotUndo()` |
136
+ | `plugins/builtin/fill-tool.ts` | Modify | Replace lines 41-46 (undo handler) with `undo: makeSnapshotUndo()` |
137
+ | `plugins/builtin/advanced-fill-tool.ts` | Modify | Replace inline undo handler |
138
+ | `plugins/builtin/noise-tool.ts` | Modify | Replace inline undo handler |
139
+ | `plugins/builtin/gradient-tool.ts` | Modify | Replace inline undo handler |
140
+ | `plugins/builtin/dither-tool.ts` | Modify | Replace inline undo handler |
141
+ | `plugins/builtin/pattern-stamp-tool.ts` | Modify | Replace inline undo handler |
142
+ | `plugins/builtin/drawing-primitives-plugin.ts` | Modify | Replace 6 inline undo handlers (one per primitive command) |
143
+ | `plugins/builtin/effects/blur.ts` | Modify | Replace inline undo handler |
144
+ | `plugins/builtin/effects/sharpen.ts` | Modify | Replace inline undo handler |
145
+ | `plugins/builtin/effects/glow.ts` | Modify | Replace inline undo handler |
146
+ | `plugins/builtin/effects/shadow.ts` | Modify | Replace inline undo handler |
147
+ | `plugins/builtin/effects/scale.ts` | Modify | Replace inline undo handler |
148
+ | `plugins/builtin/effects/flip.ts` | Modify | Replace inline undo handler |
149
+ | `plugins/builtin/effects/outline.ts` | Modify | Replace inline undo handler |
150
+ | `plugins/builtin/effects/color-effects.ts` | Modify | Replace 5 inline undo handlers |
151
+ | `plugins/builtin/effects/rotate.ts` | Modify | Replace inline undo handler |
152
+
153
+ ### Step-by-Step Instructions
154
+
155
+ - In `plugins/builtin/drawing-utils.ts`, after `applyPixels` (line 399), add:
156
+ ```
157
+ export function makeSnapshotUndo(): (params: Record<string, unknown>, ctx: Record<string, unknown>) => void {
158
+ return (params, ctx) => {
159
+ const buffer = (ctx as { getActiveBuffer?: () => PixelBuffer }).getActiveBuffer?.();
160
+ if (!buffer) return;
161
+ const snapshot = params._snapshot as PixelData[];
162
+ applyPixels(buffer, snapshot);
163
+ };
164
+ }
165
+ ```
166
+ - Note: The `ctx` cast pattern `(ctx as { getActiveBuffer?: () => PixelBuffer })` matches the current pattern used in all 32 call sites. This will be cleaned up in I-03 when `CommandContext` gets a proper `getActiveBuffer` definition.
167
+ - For each of the 23 files listed above, perform the following mechanical replacement:
168
+ - Add `makeSnapshotUndo` to the import from `./drawing-utils.js` (or `../../plugins/builtin/drawing-utils.js` for files under `src/`)
169
+ - Replace the inline `undo(params, ctx) { ... }` block with `undo: makeSnapshotUndo(),`
170
+ - The replaced block always follows this exact 4-line pattern:
171
+ ```
172
+ undo(params, ctx) {
173
+ const buffer = (ctx as { getActiveBuffer?: () => PixelBuffer }).getActiveBuffer?.();
174
+ if (!buffer) return;
175
+ const snapshot = params._snapshot as PixelData[];
176
+ applyPixels(buffer, snapshot);
177
+ },
178
+ ```
179
+ - Verify that `selection-tool.ts` `move_selection` undo handler is NOT touched (it has custom logic: restoring selected pixel positions, not just pixel colors)
180
+ - If I-01 has been completed, update `make-stroke-tool.ts` to use `makeSnapshotUndo()` in its command definition instead of the inline handler
181
+
182
+ ### Preconditions
183
+
184
+ - None strictly required. Ideally done after I-01 so the stroke tool factory can benefit immediately.
185
+
186
+ ### Postconditions / Verification
187
+
188
+ - `tsc --noEmit` passes
189
+ - All 39 tests pass (run `npx vitest run`)
190
+ - Undo/redo works for every drawing tool: pencil, eraser, rect, circle, line, diamond, fill, advanced fill, noise, gradient, dither, pattern stamp
191
+ - Undo/redo works for all effects: blur, sharpen, glow, shadow, scale, flip, outline, rotate, and all color effects (brightness, contrast, hue shift, saturation, invert colors)
192
+ - Undo for `move_selection` still works (untouched)
193
+ - `grep -rn "const buffer = (ctx as" plugins/builtin/ | grep -v "make-stroke-tool\|drawing-utils\|selection-tool" | grep undo` returns 0 results (all inline undo handlers eliminated except the factory definition and the selection exception)
194
+
195
+ ### Risks and Edge Cases
196
+
197
+ - The `drawing-primitives-plugin.ts` has 6 commands (`draw_line_prim`, `draw_rect_prim`, etc.) sharing the same file. Each needs its `undo` replaced individually. Count them carefully during implementation.
198
+ - The `color-effects.ts` has 5 commands (`brightness`, `contrast`, `hue_shift`, `saturation`, `invert_colors`). Same careful counting applies.
199
+ - When I-03 moves `_snapshot` out of `params` into a dedicated `UndoEntry.snapshot` slot, this factory will need updating. The update is mechanical: change from `params._snapshot` to a `snapshot` argument. This is a known forward dependency.
200
+
201
+ ---
202
+
203
+ ## I-03: Generic Command\<P\>, Typed CommandContext, Separate Snapshot
204
+
205
+ ### Summary
206
+
207
+ This is the most impactful single change. It addresses three sub-problems: (A) untyped
208
+ `Command.params` (currently `Record<string, unknown>`) forcing ~170 `params.field as Type`
209
+ casts, (B) the `_snapshot` field smuggled into `params` as a mutable side-channel
210
+ (used in 21+ files), and (C) untyped `CommandContext` lacking `getActiveBuffer`,
211
+ causing 73 `(ctx as { getActiveBuffer?: ... })` casts across 24 plugin files.
212
+ After this change, each plugin defines a typed params interface, the snapshot lives on
213
+ the undo stack entry, and `CommandContext.getActiveBuffer` is properly typed.
214
+
215
+ ### Files Touched
216
+
217
+ | File Path | Action | What Changes |
218
+ |-----------|--------|--------------|
219
+ | `src/lib/core/commands.ts` | Modify | Make `Command<P = Record<string, unknown>>` generic (line 11); make `CommandDefinition<P = Record<string, unknown>>` generic (line 44); `execute` returns `unknown` (snapshot data) instead of `void`; `undo` takes a third `snapshot: unknown` argument; add `getActiveBuffer` to `CommandContext` interface (line 67) |
220
+ | `src/lib/core/dispatcher.ts` | Modify | Capture `execute()` return value as snapshot data (line 118); store `snapshot` field on `UndoEntry` (add to interface around line 35); pass snapshot to `undo()` call (line 165); pass snapshot to `redo()` re-execution |
221
+ | `src/lib/core/registries.svelte.ts` | Modify | Change `commandRegistry` type from `ReactiveRegistry<CommandDefinition>` to `ReactiveRegistry<CommandDefinition<any>>` (line 66). This is the type-erasure boundary. |
222
+ | `src/lib/core/plugin-types.ts` | Modify | Update `PluginAPI.addCommand` signature: `addCommand<P>(name: string, definition: CommandDefinition<P>): void` (line 15) |
223
+ | `src/lib/core/plugin-api.ts` | Modify | Update `addCommand` implementation to accept `CommandDefinition<any>` for the registry `.set()` call; import `PixelBuffer` and wire `getActiveBuffer` into the `context` object |
224
+ | `plugins/builtin/drawing-utils.ts` | Modify | Update `makeSnapshotUndo` (from I-02) to accept `snapshot: unknown` as third param instead of reading `params._snapshot`; remove `PixelData[]` cast from params, cast from snapshot instead |
225
+ | All 23 tool/effect plugin files | Modify | Define typed params interfaces (e.g., `DrawPixelsParams`, `DrawRectParams`, `FloodFillParams`); remove all `params.field as Type` casts; remove `_snapshot: []` from dispatch params; change `execute` to return snapshot data instead of mutating params; change `undo` to accept third `snapshot` arg; remove `(ctx as { getActiveBuffer? })` casts |
226
+ | `src/lib/canvas/canvas-init-plugin.ts` | Modify | Remove the manual ctx cast for `getActiveBuffer`; it will now be a first-class `CommandContext` method |
227
+ | Test files: `tool-plugins.test.ts`, `effects.test.ts`, `gradient.test.ts`, etc. | Modify | Update mock `CommandContext` objects to include `getActiveBuffer`; update test dispatch calls to not include `_snapshot` in params |
228
+
229
+ ### Step-by-Step Instructions
230
+
231
+ - **Step 1: Update `commands.ts` (lines 11-68)**
232
+ - Make `Command` generic: `export interface Command<P = Record<string, unknown>>` with `params: P` instead of `params: Record<string, unknown>`
233
+ - Make `CommandDefinition` generic: `export interface CommandDefinition<P = Record<string, unknown>>`
234
+ - Change `execute` signature from `(params: Record<string, unknown>, context: CommandContext) => void` to `(params: P, context: CommandContext) => unknown | void`
235
+ - Change `undo` signature from `(params: Record<string, unknown>, context: CommandContext) => void` to `(params: P, context: CommandContext, snapshot: unknown) => void`
236
+ - Change `describe` signature from `(params: Record<string, unknown>) => string` to `(params: P) => string`
237
+ - Add to `CommandContext` interface (currently empty index signature at line 67):
238
+ ```
239
+ getActiveBuffer?: () => PixelBuffer | null;
240
+ ```
241
+ - Import `PixelBuffer` type: `import type { PixelBuffer } from '../canvas/pixel-buffer.js';`
242
+
243
+ - **Step 2: Update `UndoEntry` in `commands.ts` (line 35)**
244
+ - Add `snapshot: unknown;` field to the `UndoEntry` interface
245
+
246
+ - **Step 3: Update `dispatcher.ts`**
247
+ - In `dispatch()` (line 118): capture execute return value: `const snapshot = definition.execute(command.params, context);`
248
+ - In the `UndoEntry` construction (lines 121-126): add `snapshot` field: `snapshot,`
249
+ - In `undoLast()` (line 165): pass snapshot: `definition.undo(entry.command.params, context, entry.snapshot);`
250
+ - In `redoLast()` (line 185): capture new snapshot from re-execution: `const newSnapshot = definition.execute(entry.command.params, context);` and update: `entry.snapshot = newSnapshot;`
251
+
252
+ - **Step 4: Update `registries.svelte.ts` (line 66)**
253
+ - Change `createRegistry<CommandDefinition>()` to `createRegistry<CommandDefinition<any>>()`
254
+ - Update the import to use the generic type
255
+
256
+ - **Step 5: Update `plugin-types.ts` (line 15)**
257
+ - Change `addCommand(name: string, definition: CommandDefinition): void` to `addCommand<P>(name: string, definition: CommandDefinition<P>): void`
258
+
259
+ - **Step 6: Wire `getActiveBuffer` into `CommandContext`**
260
+ - In `plugin-api.ts` or `canvas-init-plugin.ts`, ensure the dispatcher context includes `getActiveBuffer`. Currently `canvas-init-plugin.ts` sets this on the context; verify it uses the proper interface method name.
261
+
262
+ - **Step 7: Define param interfaces and update plugins (23 files)**
263
+ - For each plugin file, define a params interface at the top. Example for `rect-tool.ts`:
264
+ ```
265
+ interface DrawRectParams {
266
+ x: number;
267
+ y: number;
268
+ w: number;
269
+ h: number;
270
+ filled: boolean;
271
+ color: string;
272
+ layerId: string;
273
+ }
274
+ ```
275
+ - Change `execute(params, ctx)` to `execute(params: DrawRectParams, ctx)` and remove all `as` casts
276
+ - Change `execute` to return snapshot data: `return snapshotPixels(buffer, points);` instead of `params._snapshot = snapshotPixels(buffer, points);`
277
+ - Change `undo(params, ctx)` to `undo(params: DrawRectParams, ctx, snapshot)` and use `snapshot as PixelData[]` instead of `params._snapshot as PixelData[]`
278
+ - Remove `_snapshot: []` from all `api.dispatch()` calls (e.g., rect-tool.ts line 120)
279
+ - Remove `(ctx as { getActiveBuffer?: () => PixelBuffer })` casts; use `ctx.getActiveBuffer?.()` directly
280
+
281
+ - **Step 8: Update `makeSnapshotUndo` (from I-02)**
282
+ - Change signature to accept `snapshot: unknown` as third argument:
283
+ ```
284
+ export function makeSnapshotUndo(): (params: unknown, ctx: CommandContext, snapshot: unknown) => void {
285
+ return (_params, ctx, snapshot) => {
286
+ const buffer = ctx.getActiveBuffer?.();
287
+ if (!buffer) return;
288
+ applyPixels(buffer, snapshot as PixelData[]);
289
+ };
290
+ }
291
+ ```
292
+
293
+ - **Step 9: Update test files**
294
+ - All test mocks that create a `CommandContext` need `getActiveBuffer` added
295
+ - All test dispatch calls need `_snapshot` removed from params
296
+ - Mock `getActiveBuffer` should return a test `PixelBuffer` instance
297
+
298
+ ### Preconditions
299
+
300
+ - I-01 and I-02 should be completed first (they create `make-stroke-tool.ts` and `makeSnapshotUndo`, which both need updating here). However, I-03 can be done independently if the implementor is willing to handle the duplication in pencil/eraser at the same time.
301
+
302
+ ### Postconditions / Verification
303
+
304
+ - `tsc --noEmit` passes with zero errors
305
+ - All tests pass (`npx vitest run`)
306
+ - `grep -rn "params\.\w\+ as " plugins/builtin/` returns 0 results (all param casts eliminated)
307
+ - `grep -rn "(ctx as {" plugins/builtin/` returns 0 results (all context casts eliminated)
308
+ - `grep -rn "_snapshot" plugins/builtin/` returns 0 results (snapshot no longer stored in params)
309
+ - Undo/redo works correctly for all tools and effects
310
+ - The dispatcher correctly captures snapshot from `execute()` and passes it to `undo()`
311
+
312
+ ### Risks and Edge Cases
313
+
314
+ - **Redo snapshot freshness:** When redoing, the re-execution of `execute()` must return a fresh snapshot (the buffer state has changed since the original execution). The plan handles this by capturing `newSnapshot` in `redoLast()`.
315
+ - **Type erasure boundary:** The `commandRegistry` stores `CommandDefinition<any>`. This means the registry itself does not enforce param types. Type safety exists at the plugin registration boundary (each plugin's `register()` function) and at dispatch time (the `Command` object carries typed params). This is acceptable and standard for heterogeneous registries.
316
+ - **Incremental migration:** All 23 plugin files must be updated atomically because the `execute` return type and `undo` signature change globally. A partial migration would cause type errors. Consider doing the dispatcher + commands.ts change first (backward compatible with `void` return from execute), then migrating plugins file by file.
317
+ - **`_snapshot` on first-execute guard:** Currently, plugins check `if (!(params._snapshot as PixelData[])?.length)` to avoid re-snapshotting on redo. With the separate snapshot slot, this guard is unnecessary -- on first execute, snapshot is `undefined`; on redo, the dispatcher manages it. But the `execute` function must always return a snapshot, even on redo. Verify the snapshot is taken before pixels are modified (current order: snapshot first, then applyPixels).
318
+
319
+ ---
320
+
321
+ ## I-04: Undoable Flag on CommandDefinition
322
+
323
+ ### Summary
324
+
325
+ All 4 UI entry points (menu-builder.ts line 66, ToolbarPanel.svelte line 184,
326
+ ContextMenu.svelte line 49, command-palette-state.svelte.ts line 54) call
327
+ `cmd.execute({}, {})` directly, bypassing the dispatcher. This means destructive
328
+ commands triggered from the menu (flatten_image, resize_canvas, merge_down, cut, paste)
329
+ never enter the undo stack. Adding an `undoable` flag to `CommandDefinition` lets each
330
+ UI entry point check the flag and route through `dispatch()` for commands that need
331
+ undo tracking.
332
+
333
+ ### Files Touched
334
+
335
+ | File Path | Action | What Changes |
336
+ |-----------|--------|--------------|
337
+ | `src/lib/core/commands.ts` | Modify | Add `undoable?: boolean` to `CommandDefinition` interface (after `tier?` on line 50) |
338
+ | `src/lib/ui/menu-builder.ts` | Modify | In `buildMenuItem` (line 55), change `action` from `cmd.execute({}, {})` to a conditional: if `cmd.undoable`, dispatch through the dispatcher; otherwise, call `execute` directly |
339
+ | `src/lib/ui/ToolbarPanel.svelte` | Modify | In `executeCommand` function (line 182), add the same conditional routing |
340
+ | `src/lib/ui/ContextMenu.svelte` | Modify | In the action handler (line 49), add the same conditional routing |
341
+ | `src/lib/ui/command-palette/command-palette-state.svelte.ts` | Modify | In the execute callback (line 54), add the same conditional routing |
342
+ | `src/lib/ui/menu-commands-plugin.ts` (or split successors after I-07) | Modify | Mark ~15 destructive commands with `undoable: true`: `flatten_image`, `resize_canvas`, `crop_to_selection`, `merge_down`, `cut`, `paste`, `select_all`, `invert_selection`, `select_by_color` |
343
+
344
+ ### Step-by-Step Instructions
345
+
346
+ - **Step 1: Add field to `commands.ts`**
347
+ - After the `tier?: UndoTier` field (line 50), add:
348
+ ```
349
+ /** Whether this command should be routed through the dispatcher (undo stack) when triggered from UI. Default: false. */
350
+ undoable?: boolean;
351
+ ```
352
+
353
+ - **Step 2: Create a shared dispatch helper**
354
+ - To avoid repeating the routing logic in 4 places, create a helper function in a new module or in `dispatcher.ts`:
355
+ ```
356
+ export function executeOrDispatch(commandId: string): void {
357
+ const def = commandRegistry.get(commandId);
358
+ if (!def) return;
359
+ if (def.undoable) {
360
+ dispatch({
361
+ type: commandId,
362
+ plugin: 'ui',
363
+ version: '1.0.0',
364
+ params: {},
365
+ id: crypto.randomUUID(),
366
+ timestamp: Date.now(),
367
+ });
368
+ } else {
369
+ def.execute({} as any, {} as any);
370
+ }
371
+ }
372
+ ```
373
+ - Alternatively, add this to `dispatcher.ts` since it already has access to `commandRegistry` and `dispatch`. Place it after `redoLast()` (line 204).
374
+
375
+ - **Step 3: Update menu-builder.ts (line 66)**
376
+ - Change: `action: cmd ? () => cmd.execute({}, {}) : undefined,`
377
+ - To: `action: cmd ? () => executeOrDispatch(contrib.commandId) : undefined,`
378
+ - Import `executeOrDispatch` from `../core/dispatcher.js`
379
+
380
+ - **Step 4: Update ToolbarPanel.svelte (line 184)**
381
+ - Change: `cmd?.execute({}, {});`
382
+ - To: `if (targetId) executeOrDispatch(targetId);`
383
+ - Import `executeOrDispatch`
384
+
385
+ - **Step 5: Update ContextMenu.svelte (line 49)**
386
+ - Change: `cmd.execute({}, {})`
387
+ - To: `executeOrDispatch(item.commandId)` (or however the command ID is referenced)
388
+
389
+ - **Step 6: Update command-palette-state.svelte.ts (line 54)**
390
+ - Change: `execute: () => def.execute({}, {}),`
391
+ - To: `execute: () => executeOrDispatch(commandId),`
392
+ - Import `executeOrDispatch`
393
+
394
+ - **Step 7: Mark destructive commands as undoable**
395
+ - In `menu-commands-plugin.ts` (or its split successors), add `undoable: true` to the command definitions for:
396
+ - `flatten_image`
397
+ - `resize_canvas`
398
+ - `crop_to_selection`
399
+ - `merge_down`
400
+ - `cut`
401
+ - `paste`
402
+ - `select_all`
403
+ - `invert_selection`
404
+ - `select_by_color`
405
+ - Do NOT mark as undoable:
406
+ - `undo` / `redo` (would cause infinite recursion)
407
+ - View/zoom commands (non-destructive)
408
+ - Dialog-opening commands (`new_project_dialog`, `export_dialog`, `open_file_dialog`)
409
+ - `about`, `keyboard_shortcuts`, `report_bug`
410
+
411
+ ### Preconditions
412
+
413
+ - I-03 should be completed first so that `CommandDefinition` is already generic and the `execute`/`undo` signatures are correct. The `executeOrDispatch` helper needs to construct a properly typed `Command` object.
414
+
415
+ ### Postconditions / Verification
416
+
417
+ - `tsc --noEmit` passes
418
+ - Trigger `flatten_image` from the Image menu: it appears on the undo stack; Ctrl+Z undoes it
419
+ - Trigger `zoom_in` from the View menu: it does NOT appear on the undo stack
420
+ - Trigger `undo` from the Edit menu: it calls `undoLast()` directly, does not recurse
421
+ - The command palette's execute callbacks respect the `undoable` flag
422
+ - Context menu actions respect the `undoable` flag
423
+
424
+ ### Risks and Edge Cases
425
+
426
+ - **`undo`/`redo` recursion:** These commands must NEVER be marked `undoable: true`. The `executeOrDispatch` helper would dispatch them, which would push them onto the undo stack, and undoing them would call undo again. Add a runtime guard in `executeOrDispatch` that throws if `commandId === 'undo' || commandId === 'redo'` and `undoable` is true.
427
+ - **Params for undoable menu commands:** Some undoable commands need params (e.g., `resize_canvas` needs width/height from the prompt). Currently they call `execute({}, {})` with empty params because they gather input internally. After I-03, these commands will have typed params. The `executeOrDispatch` helper passes `{}` as params, which works for commands that gather their own input but breaks for commands that expect typed params. This is acceptable for now because all current menu-triggered destructive commands handle their own input gathering inside `execute()`.
428
+ - **Future work:** Eventually, commands like `resize_canvas` should be refactored so that the dialog gathers input and passes it as typed params to `dispatch()`. This is outside the scope of I-04.
429
+
430
+ ---
431
+
432
+ ## I-05: Native \<dialog\> for Modals
433
+
434
+ ### Summary
435
+
436
+ All 3 modal dialogs (`NewProjectDialog.svelte`, `ExportDialog.svelte`,
437
+ `AboutDialog.svelte`) plus `CommandPalette.svelte` use manual `<div class="modal-overlay">`
438
+ or `<div class="about-backdrop">` patterns. These lack focus traps, proper ARIA
439
+ attributes, and consistent Escape handling (AboutDialog has no Escape handler at all).
440
+ Replacing with the native `<dialog>` element and `.showModal()` provides free focus
441
+ trapping, `::backdrop` pseudo-element, `aria-modal="true"`, Escape-to-close, and
442
+ top-layer rendering. Tauri's Chromium WebView fully supports `<dialog>`.
443
+
444
+ ### Files Touched
445
+
446
+ | File Path | Action | What Changes |
447
+ |-----------|--------|--------------|
448
+ | `src/lib/ui/NewProjectDialog.svelte` | Modify | Replace `<div class="modal-overlay">` (line 117) with `<dialog>` element; add `bind:this` for dialog ref; use `$effect` to call `.showModal()` / `.close()` based on `open` prop; replace `.modal-overlay` CSS with `dialog::backdrop`; remove manual Escape handler (lines 48-53) and `<svelte:window>` binding (line 203) since `<dialog>` handles Escape natively; remove `<!-- svelte-ignore a11y_no_static_element_interactions -->` comment (line 115) |
449
+ | `src/lib/ui/ExportDialog.svelte` | Modify | Same transformation as NewProjectDialog |
450
+ | `src/lib/ui/AboutDialog.svelte` | Modify | Replace `<div class="about-backdrop">` (line 8 in template) with `<dialog>`; add Escape support (currently missing); add `bind:this` + `$effect` for showModal/close; restyle with `dialog::backdrop` |
451
+ | `src/lib/ui/command-palette/CommandPalette.svelte` | Modify | Replace the overlay `<div>` with `<dialog>`; the existing Escape handler (line 20-22) can delegate to the `close` event; add `bind:this` + `$effect` |
452
+ | `src/lib/ui/dialog-state.svelte.ts` | Modify | No changes strictly needed (dialog refs are local to each component, not global state). However, if `.showModal()` needs to be called externally, add optional `HTMLDialogElement` ref storage. |
453
+ | `src/app.css` (or relevant global CSS) | Modify | Add base `dialog` and `dialog::backdrop` styles using design tokens. Remove any `.modal-overlay` global styles if they exist. |
454
+
455
+ ### Step-by-Step Instructions
456
+
457
+ - **Step 1: Establish the `<dialog>` pattern**
458
+ - The pattern for each dialog component is:
459
+ ```svelte
460
+ <script>
461
+ let dialogEl: HTMLDialogElement | undefined = $state();
462
+
463
+ $effect(() => {
464
+ if (!dialogEl) return;
465
+ if (open) {
466
+ dialogEl.showModal();
467
+ } else {
468
+ dialogEl.close();
469
+ }
470
+ });
471
+
472
+ function handleClose() {
473
+ // Native <dialog> fires 'close' event on Escape and on .close()
474
+ onClose();
475
+ }
476
+ </script>
477
+
478
+ <dialog bind:this={dialogEl} onclose={handleClose}>
479
+ <!-- dialog content -->
480
+ </dialog>
481
+ ```
482
+
483
+ - **Step 2: Convert NewProjectDialog.svelte**
484
+ - Add `let dialogEl: HTMLDialogElement | undefined = $state();` to the script block
485
+ - Add the `$effect` for showModal/close (see pattern above)
486
+ - Replace lines 116-201 (the `{#if open}` block containing `<div class="modal-overlay">`) with a `<dialog bind:this={dialogEl}>` containing the `.modal` div content directly
487
+ - Remove the `{#if open}` guard -- the `<dialog>` element is always in the DOM; visibility is controlled by `.showModal()` / `.close()`
488
+ - Remove the `handleKeydown` function (lines 48-53) and the `<svelte:window>` binding (line 203)
489
+ - Remove the `onclick={onClose}` on the overlay div (replace with backdrop click handling via `dialogEl.addEventListener('click', ...)` that checks if click target is the dialog itself, indicating a backdrop click)
490
+ - Replace `.modal-overlay` CSS (lines 206-217) with:
491
+ ```css
492
+ dialog::backdrop {
493
+ background: rgba(0, 0, 0, 0.5);
494
+ }
495
+ dialog {
496
+ border: none;
497
+ padding: 0;
498
+ background: transparent;
499
+ max-width: 90vw;
500
+ }
501
+ ```
502
+ - Keep the `.modal` class CSS for the inner content box
503
+
504
+ - **Step 3: Convert ExportDialog.svelte**
505
+ - Same transformation as NewProjectDialog. Examine the first 30 lines; it has the same `Props` interface with `open` and `onClose`.
506
+
507
+ - **Step 4: Convert AboutDialog.svelte**
508
+ - Currently uses `<div class="about-backdrop">` with no `open` prop -- it is mounted/unmounted by `menu-commands-plugin.ts` using Svelte's `mount`/`unmount` API
509
+ - Two options:
510
+ - Option A: Add an `open` prop and use the same `<dialog>` pattern
511
+ - Option B: Keep mount/unmount but have `onMount` call `.showModal()`
512
+ - Option B is simpler since it preserves the existing mount/unmount flow in `menu-commands-plugin.ts`. Add:
513
+ ```svelte
514
+ import { onMount } from 'svelte';
515
+ let dialogEl: HTMLDialogElement;
516
+ onMount(() => dialogEl.showModal());
517
+ ```
518
+ - Replace `<div class="about-backdrop">` with `<dialog bind:this={dialogEl}>`
519
+ - Add `onclose={onClose}` to the `<dialog>` element for Escape handling
520
+
521
+ - **Step 5: Convert CommandPalette.svelte**
522
+ - Currently uses `commandPaletteState.isOpen` to conditionally render
523
+ - Add `dialogEl` ref and `$effect` like the pattern above, keyed on `commandPaletteState.isOpen`
524
+ - The existing Escape handler (line 20-22) can be simplified: instead of manually checking `e.key === 'Escape'`, rely on the native `<dialog>` close event. But keep the ArrowDown/ArrowUp/Enter handlers.
525
+
526
+ - **Step 6: Handle backdrop clicks**
527
+ - Native `<dialog>` does not close on backdrop click by default. Add a click handler:
528
+ ```svelte
529
+ function handleDialogClick(e: MouseEvent) {
530
+ if (e.target === dialogEl) onClose();
531
+ }
532
+ ```
533
+ - This works because clicking the `::backdrop` pseudo-element fires the click on the `<dialog>` element itself, not on any child.
534
+
535
+ ### Preconditions
536
+
537
+ - None. This is fully independent of the command system changes.
538
+
539
+ ### Postconditions / Verification
540
+
541
+ - Tab key stays within each dialog (focus trap works natively)
542
+ - Escape closes each dialog (including AboutDialog, which currently lacks this)
543
+ - Backdrop click closes each dialog
544
+ - Screen readers announce `role="dialog"` and `aria-modal="true"` (provided automatically by `<dialog>`)
545
+ - No `z-index: 1000` hacks remain in dialog CSS (top-layer handles stacking)
546
+ - No `<!-- svelte-ignore a11y_no_static_element_interactions -->` comments remain in dialog components
547
+ - Dialogs render correctly in Tauri desktop build (test on macOS and Linux if possible)
548
+ - `tsc --noEmit` passes
549
+
550
+ ### Risks and Edge Cases
551
+
552
+ - **Tauri WebView compatibility:** Chromium-based WebView supports `<dialog>` fully. Test the `::backdrop` styling in Tauri specifically, as some older WebView versions may not render `::backdrop` with CSS custom properties.
553
+ - **Double-close guard:** If `onClose()` sets `open = false` and the `$effect` calls `.close()`, but `.close()` fires the `close` event which calls `onClose()` again, there could be a loop. Guard against this by checking `dialogEl.open` before calling `.close()` in the effect.
554
+ - **Focus restoration:** When a `<dialog>` is closed, the browser restores focus to the element that was focused before `.showModal()`. Verify this works correctly with the dockview panel system.
555
+ - **CommandPalette auto-focus:** The CommandPalette currently uses a `$effect` with `queueMicrotask` to focus the input (line 15). With `<dialog>`, the browser auto-focuses the first focusable element inside the dialog. Adding `autofocus` attribute to the input element may be sufficient, eliminating the manual focus code.
556
+
557
+ ---
558
+
559
+ ## I-06: Reusable PromptDialog Component
560
+
561
+ ### Summary
562
+
563
+ Two `window.prompt()` calls exist in `menu-commands-plugin.ts`: one for `resize_canvas`
564
+ (line 382) and one for `set_frame_duration_dialog` (line 669). `window.prompt()` fails
565
+ silently on macOS in Tauri (returns null without showing) and is unreliable on Linux.
566
+ This item creates a generic `PromptDialog.svelte` component using native `<dialog>`
567
+ (from I-05) and wires it through `dialogState` so any command can open a themed,
568
+ validatable prompt.
569
+
570
+ ### Files Touched
571
+
572
+ | File Path | Action | What Changes |
573
+ |-----------|--------|--------------|
574
+ | `src/lib/ui/PromptDialog.svelte` | Create | New Svelte component: native `<dialog>`, text input, validation error display, confirm/cancel buttons, all themed with design tokens |
575
+ | `src/lib/ui/dialog-state.svelte.ts` | Modify | Add prompt state: `promptOpen` boolean, `promptConfig` object with `title`, `message`, `defaultValue`, `validate`, `onConfirm`, `onCancel` fields; add `openPrompt(config)` and `closePrompt()` methods |
576
+ | `src/App.svelte` | Modify | Import and render `<PromptDialog>` alongside existing dialogs (after line 29); bind to `dialogState.promptOpen` and `dialogState.promptConfig` |
577
+ | `src/lib/ui/menu-commands-plugin.ts` (or split successors) | Modify | Replace `prompt()` call on line 382 (resize_canvas) with `dialogState.openPrompt({...})`. Replace `prompt()` call on line 669 (set_frame_duration_dialog) with `dialogState.openPrompt({...})` |
578
+
579
+ ### Step-by-Step Instructions
580
+
581
+ - **Step 1: Extend dialog-state.svelte.ts**
582
+ - Define a `PromptConfig` interface:
583
+ ```
584
+ export interface PromptConfig {
585
+ title: string;
586
+ message: string;
587
+ defaultValue: string;
588
+ validate?: (value: string) => string | null; // returns error message or null
589
+ onConfirm: (value: string) => void;
590
+ onCancel?: () => void;
591
+ }
592
+ ```
593
+ - Add reactive state:
594
+ ```
595
+ let promptOpen = $state(false);
596
+ let promptConfig = $state<PromptConfig | null>(null);
597
+ ```
598
+ - Add methods to `dialogState`:
599
+ ```
600
+ get promptOpen() { return promptOpen; },
601
+ get promptConfig() { return promptConfig; },
602
+
603
+ openPrompt(config: PromptConfig) {
604
+ promptConfig = config;
605
+ promptOpen = true;
606
+ },
607
+ closePrompt() {
608
+ promptOpen = false;
609
+ promptConfig = null;
610
+ },
611
+ ```
612
+
613
+ - **Step 2: Create PromptDialog.svelte**
614
+ - Props: `open: boolean`, `config: PromptConfig | null`, `onClose: () => void`
615
+ - Local state: `inputValue` (initialized from `config.defaultValue`), `errorMessage: string`
616
+ - Use `<dialog>` with `bind:this` + `$effect` for showModal/close (same pattern as I-05)
617
+ - Template structure:
618
+ - `<h2>` with `config.title`
619
+ - `<p>` with `config.message`
620
+ - `<input>` bound to `inputValue`, with `autofocus`
621
+ - Error display `<p class="error">` shown when `errorMessage` is non-empty
622
+ - Cancel and Confirm buttons
623
+ - On confirm: call `config.validate(inputValue)` if provided; if validation fails, set `errorMessage`; if passes, call `config.onConfirm(inputValue)` and close
624
+ - On cancel: call `config.onCancel?.()` and close
625
+ - Style with design tokens (--bg-panel, --border, --accent, etc.)
626
+
627
+ - **Step 3: Wire into App.svelte**
628
+ - Import `PromptDialog` from `./lib/ui/PromptDialog.svelte`
629
+ - Add after line 29:
630
+ ```svelte
631
+ <PromptDialog
632
+ open={dialogState.promptOpen}
633
+ config={dialogState.promptConfig}
634
+ onClose={() => dialogState.closePrompt()}
635
+ />
636
+ ```
637
+
638
+ - **Step 4: Replace prompt() calls in menu-commands-plugin.ts**
639
+ - For `resize_canvas` (line 382):
640
+ ```
641
+ dialogState.openPrompt({
642
+ title: 'Resize Canvas',
643
+ message: 'Enter new canvas size (WxH):',
644
+ defaultValue: `${canvasState.canvasWidth}x${canvasState.canvasHeight}`,
645
+ validate: (v) => {
646
+ const match = v.match(/^\s*(\d+)\s*[xX]\s*(\d+)\s*$/);
647
+ if (!match) return 'Format must be WxH (e.g., 64x64)';
648
+ const w = parseInt(match[1], 10);
649
+ const h = parseInt(match[2], 10);
650
+ if (w < 1 || w > 4096 || h < 1 || h > 4096) return 'Dimensions must be 1-4096';
651
+ return null;
652
+ },
653
+ onConfirm: (value) => {
654
+ const match = value.match(/^\s*(\d+)\s*[xX]\s*(\d+)\s*$/)!;
655
+ const newW = parseInt(match[1], 10);
656
+ const newH = parseInt(match[2], 10);
657
+ // ... existing resize logic ...
658
+ },
659
+ });
660
+ ```
661
+ - For `set_frame_duration_dialog` (line 669):
662
+ ```
663
+ dialogState.openPrompt({
664
+ title: 'Set Frame Duration',
665
+ message: 'Enter frame duration in ms (empty for global FPS):',
666
+ defaultValue: '',
667
+ validate: (v) => {
668
+ if (v.trim() === '') return null; // empty is valid (means global FPS)
669
+ const n = Number(v);
670
+ if (isNaN(n) || n <= 0) return 'Must be a positive number';
671
+ return null;
672
+ },
673
+ onConfirm: (value) => {
674
+ // ... existing frame duration logic ...
675
+ },
676
+ });
677
+ ```
678
+ - Import `dialogState` if not already imported (it is already imported on line 33)
679
+
680
+ ### Preconditions
681
+
682
+ - I-05 must be completed first (PromptDialog uses native `<dialog>`)
683
+
684
+ ### Postconditions / Verification
685
+
686
+ - `tsc --noEmit` passes
687
+ - Resize canvas command (Image menu) opens a themed prompt dialog, not a browser prompt
688
+ - Entering a valid "WxH" format resizes the canvas
689
+ - Entering invalid input (e.g., "abc") shows inline validation error
690
+ - Pressing Escape or Cancel closes without action
691
+ - Set Frame Duration command opens a themed prompt dialog
692
+ - Entering a valid number sets the frame duration
693
+ - Entering empty string resets to global FPS
694
+ - `grep -rn "window\.prompt\|= prompt(" src/ plugins/` returns 0 results
695
+ - Works in Tauri on macOS and Linux (where `window.prompt()` fails)
696
+
697
+ ### Risks and Edge Cases
698
+
699
+ - **Async flow:** `window.prompt()` is synchronous and blocking. `dialogState.openPrompt()` is async (callback-based). The `execute()` function for `resize_canvas` currently does the resize inline after `prompt()` returns. With the callback pattern, the resize logic moves into `onConfirm`. This changes the control flow but not the end result. Ensure `onConfirm` captures all needed closure variables.
700
+ - **Multiple prompts:** If two commands try to open prompts simultaneously, the second would overwrite the first. This is unlikely in practice since prompts are modal. Add a guard: if `promptOpen` is already true, queue the second or reject it.
701
+ - **Enter key:** The prompt should submit on Enter key press (in addition to clicking Confirm). Add `onkeydown` handler on the input that checks for Enter.
702
+
703
+ ---
704
+
705
+ ## I-07: God File Split
706
+
707
+ ### Summary
708
+
709
+ `menu-commands-plugin.ts` is 1333 lines containing 42 commands, 55 menu items,
710
+ 9 toolbar items, duplicated zoom logic, clipboard state, DOM queries, and now-replaced
711
+ `prompt()` calls. This item splits it into 8 domain-specific plugin files + 3 shared
712
+ utility modules, then deletes the original. Each new file is a `PluginModule`
713
+ auto-discovered by `bootstrap.ts` via the existing `import.meta.glob` pattern
714
+ (which matches `src/lib/**/*-commands.ts`).
715
+
716
+ ### Files Touched
717
+
718
+ | File Path | Action | What Changes |
719
+ |-----------|--------|--------------|
720
+ | `src/lib/canvas/zoom-utils.ts` | Create | Export `ZOOM_STEPS`, `zoomStepUp()`, `zoomStepDown()`, `DEFAULT_ZOOM` (currently duplicated in menu-commands-plugin.ts lines 96-113 and input-handler.ts) |
721
+ | `src/lib/canvas/viewport-utils.ts` | Create | Export `viewportCenter()` function (currently at menu-commands-plugin.ts lines 116-120, duplicated as DOM query) |
722
+ | `src/lib/edit/clipboard-state.ts` | Create | Export `clipboardBuffer` reactive state and its type (currently at menu-commands-plugin.ts line 92) |
723
+ | `src/lib/ui/file-commands.ts` | Create | `PluginModule` with commands: `new_project_dialog`, `open_file_dialog`, `save_project`, `export_dialog`; menu items for File menu (~80 lines) |
724
+ | `src/lib/ui/edit-commands.ts` | Create | `PluginModule` with commands: `undo`, `redo`, `cut`, `copy`, `paste`, `select_all`, `deselect`; menu items for Edit menu (~150 lines). Imports `clipboardBuffer` from `clipboard-state.ts` |
725
+ | `src/lib/ui/image-commands.ts` | Create | `PluginModule` with commands: `resize_canvas`, `crop_to_selection`, `flatten_image`, `canvas_size`; menu items for Image menu (~120 lines). Uses `dialogState.openPrompt()` instead of `prompt()` (from I-06) |
726
+ | `src/lib/ui/layer-menu-commands.ts` | Create | `PluginModule` with commands: `merge_down`, `move_layer_up`, `move_layer_down`; menu items referencing existing layer commands from `layer-commands.ts` (~100 lines) |
727
+ | `src/lib/ui/animation-menu-commands.ts` | Create | `PluginModule` with commands: `play_animation`, `set_frame_duration_dialog`; menu items for Animation menu (~80 lines). Uses `dialogState.openPrompt()` for frame duration |
728
+ | `src/lib/ui/select-commands.ts` | Create | `PluginModule` with commands: `invert_selection`, `select_by_color`; shared menu items for Select menu (~60 lines) |
729
+ | `src/lib/ui/view-commands.ts` | Create | `PluginModule` with all 17 view/zoom commands + 9 toolbar items (~350 lines). Imports from `zoom-utils.ts` and `viewport-utils.ts` |
730
+ | `src/lib/ui/help-commands.ts` | Create | `PluginModule` with commands: `about`, `keyboard_shortcuts`, `report_bug`; menu items for Help menu (~70 lines) |
731
+ | `src/lib/ui/menu-commands-plugin.ts` | Delete | Entire file removed after all commands migrated |
732
+ | `src/lib/canvas/input-handler.ts` | Modify | Replace local `ZOOM_STEPS`/`zoomStepUp`/`zoomStepDown` with imports from `zoom-utils.ts` |
733
+ | `src/lib/ui/CanvasViewport.svelte` | Modify | Replace local zoom constants with imports from `zoom-utils.ts` if applicable |
734
+
735
+ ### Step-by-Step Instructions
736
+
737
+ - **Step 1: Create the 3 shared utilities**
738
+ - `zoom-utils.ts`: Copy `ZOOM_STEPS`, `zoomStepUp`, `zoomStepDown`, `DEFAULT_ZOOM` from menu-commands-plugin.ts lines 96-113. Export all four.
739
+ - `viewport-utils.ts`: Copy `viewportCenter()` from menu-commands-plugin.ts lines 116-120. Export it.
740
+ - `clipboard-state.ts`: Move the clipboard buffer declaration from line 92. Make it a reactive `$state` if needed for Svelte reactivity, or keep as plain `let` since it is module-level.
741
+ - Run `tsc --noEmit` to verify these utility files compile.
742
+
743
+ - **Step 2: Create domain files one at a time**
744
+ - For each domain file:
745
+ - Create the file with a `PluginModule` export (matching the `*-commands.ts` glob pattern)
746
+ - Move relevant `api.addCommand()` and `api.addMenuItem()` calls from `menu-commands-plugin.ts`
747
+ - Move relevant imports (icons, state modules)
748
+ - Do NOT import from `menu-commands-plugin.ts` -- each file is self-contained
749
+ - Run `tsc --noEmit` after each file
750
+
751
+ - Order of creation (to minimize broken imports):
752
+ 1. `file-commands.ts` -- simplest, fewest dependencies
753
+ 2. `help-commands.ts` -- self-contained, only icons and mount/unmount for AboutDialog
754
+ 3. `view-commands.ts` -- largest but self-contained, imports from `zoom-utils.ts` and `viewport-utils.ts`
755
+ 4. `edit-commands.ts` -- depends on `clipboard-state.ts` and dispatcher
756
+ 5. `select-commands.ts` -- depends on selection-tool imports
757
+ 6. `layer-menu-commands.ts` -- references existing layer commands
758
+ 7. `animation-menu-commands.ts` -- references existing animation commands, uses PromptDialog
759
+ 8. `image-commands.ts` -- uses PromptDialog for resize, references selection and layer state
760
+
761
+ - **Step 3: Update `input-handler.ts`**
762
+ - Replace local zoom logic with: `import { ZOOM_STEPS, zoomStepUp, zoomStepDown } from './zoom-utils.js';`
763
+ - Remove the duplicated zoom constants and functions
764
+
765
+ - **Step 4: Verify bootstrap discovery**
766
+ - All 8 new files match the glob pattern `src/lib/**/*-commands.ts` used in `bootstrap.ts` (line 23)
767
+ - Exception: files with `-menu-commands.ts` suffix also match `*-commands.ts`
768
+ - Verify each file exports a `PluginModule` with `name`, `version`, and `register` fields, which passes the `isPluginModule` type guard in `bootstrap.ts` (lines 28-39)
769
+
770
+ - **Step 5: Delete `menu-commands-plugin.ts`**
771
+ - Only after ALL commands and menu items are accounted for in the new files
772
+ - Verify by counting: 42 commands, 55 menu items, 9 toolbar items must all exist across the 8 new files
773
+
774
+ - **Step 6: Verify no remaining references**
775
+ - `grep -rn "menu-commands-plugin" src/` should return 0 results
776
+ - `grep -rn "menuCommandsPlugin" src/ plugins/` should return 0 results
777
+
778
+ ### Preconditions
779
+
780
+ - I-04 (undoable flag) should be completed so destructive commands are already marked `undoable: true` before being moved
781
+ - I-06 (PromptDialog) should be completed so `prompt()` calls are already replaced before the split
782
+ - I-05 (native dialog) should be completed so AboutDialog already uses `<dialog>`
783
+
784
+ ### Postconditions / Verification
785
+
786
+ - `tsc --noEmit` passes
787
+ - All 42 commands appear in the command palette (verify count)
788
+ - All menus render correctly: File (4 items), Edit (7 items), Image (4 items), Layer (6 items), Animation (5 items), Select (4 items), View (17+ items), Help (3 items), Context menus
789
+ - All 9 view toolbar items appear in the view-controls toolbar
790
+ - Zoom from keyboard, mouse wheel, and menu all use the same step logic from `zoom-utils.ts`
791
+ - `bootstrap.ts` discovers all 8 new plugin modules (check console or debugger)
792
+ - `grep -rn "menu-commands-plugin" src/` returns 0 results
793
+ - No duplicated zoom constants remain in the codebase
794
+
795
+ ### Risks and Edge Cases
796
+
797
+ - **Plugin load order:** The new domain files may have implicit dependencies (e.g., `edit-commands.ts` assumes `selection-tool` commands are already registered for menu items). Add explicit `dependencies` arrays to each `PluginModule` if needed. For example, `edit-commands.ts` might need `dependencies: ['builtin/selection']` if it references `deselect` commands.
798
+ - **AboutDialog mount/unmount:** The `help-commands.ts` file needs to import `mount`/`unmount` from Svelte and the `AboutDialog.svelte` component. This cross-concern import is acceptable for now. After I-05 converts it to `<dialog>`, it may become a simpler dialogState-driven flow.
799
+ - **Context menu items:** Some context menu items (e.g., `context/canvas/cut`, `context/layer/delete`) are currently registered in `menu-commands-plugin.ts`. These need to be distributed to the correct domain file (cut goes to `edit-commands.ts`, layer delete goes to `layer-menu-commands.ts`).
800
+ - **Clipboard state reactivity:** If `clipboardBuffer` is used in `$derived` expressions anywhere, moving it to a separate module needs careful handling. Currently it is a plain `let` variable, not a `$state`, so no reactivity concern.
801
+
802
+ ---
803
+
804
+ ## I-08: Add getSelection() to PluginAPI
805
+
806
+ ### Summary
807
+
808
+ `menu-commands-plugin.ts` line 38 directly imports `selectRect`, `deselectAll`,
809
+ `hasSelection`, and `getSelectedPixels` from `plugins/builtin/selection-tool.ts`. This
810
+ breaks the plugin boundary -- one plugin reaches into another's internal module.
811
+ Adding `getSelection()` to `PluginAPI` provides a proper read-only interface for
812
+ selection state. Mutations (selecting, deselecting) should go through `dispatch()` to
813
+ existing `select_rect` and `deselect` commands.
814
+
815
+ ### Files Touched
816
+
817
+ | File Path | Action | What Changes |
818
+ |-----------|--------|--------------|
819
+ | `src/lib/core/plugin-types.ts` | Modify | Add `SelectionView` interface and `getSelection(): SelectionView` to `PluginAPI` interface (after `getActiveLayers()` on line 39) |
820
+ | `src/lib/core/plugin-api.ts` | Modify | Implement `getSelection()` by importing `hasSelection` and `getSelectedPixels` from `plugins/builtin/selection-tool.ts` internally; return a `SelectionView` object |
821
+ | `src/lib/ui/edit-commands.ts` (or `menu-commands-plugin.ts` if I-07 not done) | Modify | Replace `import { hasSelection, getSelectedPixels } from '...selection-tool.js'` with `api.getSelection()` calls inside command handlers. Replace `selectRect(...)` calls with `api.dispatch({ type: 'select_rect', ... })`. Replace `deselectAll()` calls with `api.dispatch({ type: 'deselect', ... })` |
822
+ | `src/lib/ui/image-commands.ts` (or `menu-commands-plugin.ts`) | Modify | Same replacement for `crop_to_selection` and any other selection-reading commands |
823
+ | `src/lib/ui/select-commands.ts` (or `menu-commands-plugin.ts`) | Modify | Same replacement for `invert_selection` and `select_by_color` |
824
+
825
+ ### Step-by-Step Instructions
826
+
827
+ - **Step 1: Define `SelectionView` in `plugin-types.ts`**
828
+ - Add before the `PluginAPI` interface (or inside it as a nested type):
829
+ ```
830
+ export interface SelectionView {
831
+ hasSelection(): boolean;
832
+ getSelectedPixels(): ReadonlySet<string>;
833
+ }
834
+ ```
835
+ - Add to `PluginAPI` interface (after `getActiveLayers(): unknown`):
836
+ ```
837
+ getSelection(): SelectionView;
838
+ ```
839
+
840
+ - **Step 2: Implement in `plugin-api.ts`**
841
+ - Import from selection-tool: `import { hasSelection, getSelectedPixels } from '../../../plugins/builtin/selection-tool.js';`
842
+ - Add to the returned object:
843
+ ```
844
+ getSelection() {
845
+ return {
846
+ hasSelection: () => hasSelection(),
847
+ getSelectedPixels: () => getSelectedPixels(),
848
+ };
849
+ },
850
+ ```
851
+
852
+ - **Step 3: Update consumer files**
853
+ - In each consumer (edit-commands, image-commands, select-commands), the `register(api)` function has access to `api`. Inside command `execute()` bodies:
854
+ - Replace `hasSelection()` with `api.getSelection().hasSelection()` -- but note that `api` is available in `register()` scope, not inside `execute(params, ctx)`. Options:
855
+ - Option A: Store `api` reference in closure: `const sel = api.getSelection;` then use `sel().hasSelection()`
856
+ - Option B: Add `getSelection` to `CommandContext` as well
857
+ - Option A is simpler and maintains the current pattern where closures capture the `api` reference.
858
+ - Replace direct `selectRect(x, y, w, h)` calls with:
859
+ ```
860
+ api.dispatch({
861
+ type: 'select_rect',
862
+ plugin: 'ui/edit-commands',
863
+ version: '1.0.0',
864
+ params: { x, y, w, h },
865
+ });
866
+ ```
867
+ - Replace `deselectAll()` calls with:
868
+ ```
869
+ api.dispatch({
870
+ type: 'deselect',
871
+ plugin: 'ui/edit-commands',
872
+ version: '1.0.0',
873
+ params: {},
874
+ });
875
+ ```
876
+
877
+ - **Step 4: Remove direct imports**
878
+ - Remove `import { selectRect, deselect as deselectAll, hasSelection, getSelectedPixels } from '../../../plugins/builtin/selection-tool.js';` from all consumer files
879
+
880
+ ### Preconditions
881
+
882
+ - I-07 (god file split) should be completed first so the consumers are already in separate files, making the import changes cleaner. If I-07 is not done, the changes apply to `menu-commands-plugin.ts` instead.
883
+
884
+ ### Postconditions / Verification
885
+
886
+ - `tsc --noEmit` passes
887
+ - `cut` command works: reads selection via `api.getSelection()`, copies pixels to clipboard, clears them
888
+ - `crop_to_selection` works: reads selection bounds, resizes canvas
889
+ - `select_all` and `deselect` work via `api.dispatch()`
890
+ - `grep -rn "from.*selection-tool" src/lib/ui/` returns 0 results (no direct imports from UI command files)
891
+ - `grep -rn "from.*selection-tool" src/lib/core/plugin-api.ts` returns 1 result (the internal implementation import, which is acceptable)
892
+
893
+ ### Risks and Edge Cases
894
+
895
+ - **Circular import risk:** `plugin-api.ts` imports from `selection-tool.ts`, which imports from `plugin-loader.ts` (for `PluginModule` type), which imports from `plugin-api.ts`. Check this chain:
896
+ - `plugin-api.ts` -> `selection-tool.ts` -> `plugin-loader.ts` -> `plugin-api.ts`
897
+ - `plugin-loader.ts` only imports the TYPE `PluginAPI` from `plugin-api.ts`, not the value. Since TypeScript erases type-only imports, there is no runtime circular dependency. But verify with `import type` usage.
898
+ - **Selection state timing:** `getSelection()` returns a live view (calls `hasSelection()` at invocation time). If selection state changes between the read and the subsequent operation, the command could operate on stale data. This matches the current behavior (direct function calls have the same timing) so it is not a regression.
899
+ - **Dispatch for mutations:** Using `api.dispatch()` for `select_rect` and `deselect` means these operations go through the undo stack. If the consuming command is itself on the undo stack (e.g., `select_all` dispatches `select_rect`), this creates nested undo entries. Verify this is the desired behavior. If `select_all` should be a single undo entry, it may need to call the selection functions directly or use a transaction pattern.
900
+
901
+ ---
902
+
903
+ ## I-09: Add Mutator Methods to PluginAPI
904
+
905
+ ### Summary
906
+
907
+ All 3 importers (`aseprite-importer-plugin.ts`, `piskel-importer-plugin.ts`,
908
+ `sky-spec-plugin.ts`) bypass the `PluginAPI` entirely to set canvas size, add layers
909
+ and frames, and write pixel data via direct imports of internal modules. The API
910
+ currently only has read-only getters that return `unknown`. Adding ~15 mutator methods
911
+ lets importers work through the API, enforcing encapsulation and enabling future
912
+ validation/event hooks.
913
+
914
+ ### Files Touched
915
+
916
+ | File Path | Action | What Changes |
917
+ |-----------|--------|--------------|
918
+ | `src/lib/core/plugin-types.ts` | Modify | Add 15 new method signatures to `PluginAPI` interface, organized in 6 groups (canvas, layers, frames, pixel data, palette, composite) |
919
+ | `src/lib/core/plugin-api.ts` | Modify | Implement all 15 methods as thin wrappers around existing module functions. Add imports for `canvasState`, `layerTree`, `frameModel`, `PixelBuffer`, color palette state |
920
+ | `plugins/builtin/importers/aseprite-importer-plugin.ts` | Modify | Replace direct imports of internal modules with `api.*` method calls |
921
+ | `plugins/builtin/importers/piskel-importer-plugin.ts` | Modify | Same replacement |
922
+ | `plugins/builtin/importers/sky-spec-plugin.ts` | Modify | Same replacement |
923
+
924
+ ### Step-by-Step Instructions
925
+
926
+ - **Step 1: Canvas methods**
927
+ - Add to `PluginAPI` in `plugin-types.ts`:
928
+ ```
929
+ setCanvasSize(width: number, height: number): void;
930
+ ```
931
+ - Implement in `plugin-api.ts`:
932
+ ```
933
+ setCanvasSize(width, height) {
934
+ canvasState.canvasWidth = width;
935
+ canvasState.canvasHeight = height;
936
+ },
937
+ ```
938
+ - `canvasState` is already imported in `plugin-api.ts` (line 22)
939
+
940
+ - **Step 2: Layer methods**
941
+ - Add to `PluginAPI`:
942
+ ```
943
+ addLayer(name: string): unknown; // returns the new layer object
944
+ resetLayers(): void;
945
+ setLayerVisibility(id: string, visible: boolean): void;
946
+ setLayerOpacity(id: string, opacity: number): void;
947
+ ```
948
+ - Implement using `layerTree` module functions (already partially imported via `getLayers`, `getActiveLayer` on lines 24-25 of plugin-api.ts):
949
+ ```
950
+ addLayer(name) {
951
+ return layerTree.addLayer(name);
952
+ },
953
+ resetLayers() {
954
+ layerTree.deserialize({ layers: [], activeLayerId: '' });
955
+ },
956
+ ```
957
+ - Add missing `layerTree` imports: `addLayer`, `deserialize`, `setVisibility`, `setOpacity`
958
+
959
+ - **Step 3: Frame methods**
960
+ - Add to `PluginAPI`:
961
+ ```
962
+ addFrame(): unknown; // returns the new frame object
963
+ resetFrames(): void;
964
+ setFrameDuration(index: number, ms: number): void;
965
+ setCurrentFrame(index: number): void;
966
+ setGlobalFps(fps: number): void;
967
+ getFrames(): unknown;
968
+ ```
969
+ - Implement using `frameModel` module (import `addFrame`, `deserialize`, `setFrameDuration`, `setCurrentFrameIndex`, `setGlobalFps`, `getFrames` from `frame-model.svelte.js`)
970
+
971
+ - **Step 4: Pixel data methods**
972
+ - Add to `PluginAPI`:
973
+ ```
974
+ createPixelBuffer(width: number, height: number): unknown; // returns PixelBuffer
975
+ ```
976
+ - Implement:
977
+ ```
978
+ createPixelBuffer(width, height) {
979
+ return new PixelBuffer(width, height);
980
+ },
981
+ ```
982
+ - Import `PixelBuffer` from `pixel-buffer.ts`
983
+
984
+ - **Step 5: Palette method**
985
+ - Add to `PluginAPI`:
986
+ ```
987
+ setProjectPalette(colors: string[]): void;
988
+ ```
989
+ - Implement by importing the palette state module and calling its setter
990
+
991
+ - **Step 6: Composite method**
992
+ - Add to `PluginAPI`:
993
+ ```
994
+ resetProject(): void;
995
+ ```
996
+ - Implement as a convenience that calls `resetLayers()`, `resetFrames()`, and resets canvas to defaults
997
+
998
+ - **Step 7: Migrate importers one at a time**
999
+ - For each importer:
1000
+ - Identify all direct imports of internal modules (`canvasState`, `layerTree`, `frameModel`, `PixelBuffer`)
1001
+ - Replace with `api.*` method calls
1002
+ - The `api` is available as the parameter to `register(api)`; importer functions that are called later (e.g., the `import()` method in `ImporterDefinition`) need access to `api` via closure
1003
+ - Remove the direct imports
1004
+ - Run `tsc --noEmit` after each importer migration
1005
+
1006
+ ### Preconditions
1007
+
1008
+ - None strictly required. However, doing this after I-07 (god file split) means the `plugin-api.ts` is cleaner and easier to modify. Also, I-03 (typed CommandContext) should be done first so the `PluginAPI` interface is already updated with `getActiveBuffer`.
1009
+
1010
+ ### Postconditions / Verification
1011
+
1012
+ - `tsc --noEmit` passes after each incremental step
1013
+ - Import an Aseprite file: layers, frames, and pixel data appear correctly
1014
+ - Import a Piskel file: same verification
1015
+ - Import a Sky Spec file: same verification
1016
+ - `grep -rn "from.*canvas-state" plugins/builtin/importers/` returns 0 results
1017
+ - `grep -rn "from.*layer-tree" plugins/builtin/importers/` returns 0 results
1018
+ - `grep -rn "from.*frame-model" plugins/builtin/importers/` returns 0 results
1019
+
1020
+ ### Risks and Edge Cases
1021
+
1022
+ - **Return types:** The methods return `unknown` to avoid coupling the `PluginAPI` interface to internal implementation types. This is acceptable for now. In the future, define stable return interfaces (e.g., `LayerInfo`, `FrameInfo`) that are part of the public API contract.
1023
+ - **Importer closure pattern:** Importers define their `import()` function inside `register(api)`, capturing `api` in the closure. This is the same pattern used by tools that capture `api` for dispatch. Verify that all 3 importers follow this pattern and have access to `api` inside their import function.
1024
+ - **Incremental shippability:** Each step (canvas, layers, frames, etc.) can be shipped independently. Do not wait for all 6 steps before migrating any importer -- migrate one importer per step if possible.
1025
+
1026
+ ---
1027
+
1028
+ ## I-10: Enable Strict TypeScript Flags
1029
+
1030
+ ### Summary
1031
+
1032
+ `tsconfig.app.json` currently extends `@tsconfig/svelte/tsconfig.json` with `target: "es2023"`,
1033
+ `module: "esnext"`, `allowJs: true`, `checkJs: true`, but does not enable several strict
1034
+ flags. This item enables 5 additional flags in ascending blast-radius order:
1035
+ `noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`,
1036
+ `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`. Each flag is added
1037
+ independently with all resulting errors fixed before moving to the next.
1038
+
1039
+ ### Files Touched
1040
+
1041
+ | File Path | Action | What Changes |
1042
+ |-----------|--------|--------------|
1043
+ | `tsconfig.app.json` | Modify | Add 5 flags incrementally to `compilerOptions` |
1044
+ | ~7 files | Modify | Fix `noUnusedLocals` / `noUnusedParameters` errors (delete dead imports, prefix unused params with `_`) |
1045
+ | ~11 files | Modify | Fix `exactOptionalPropertyTypes` errors (narrow optional properties before passing, or change types to explicitly include `undefined`) |
1046
+ | ~54 files | Modify | Fix `noUncheckedIndexedAccess` errors (add null checks, non-null assertions in tests, guard clauses) |
1047
+
1048
+ ### Step-by-Step Instructions
1049
+
1050
+ - **Step 1: `noFallthroughCasesInSwitch`**
1051
+ - Add to `tsconfig.app.json` compilerOptions: `"noFallthroughCasesInSwitch": true`
1052
+ - Run `tsc --noEmit` -- expected 0 errors (no switch statements with fallthrough in the codebase)
1053
+ - Commit
1054
+
1055
+ - **Step 2: `noUnusedLocals` + `noUnusedParameters`**
1056
+ - Add both flags: `"noUnusedLocals": true, "noUnusedParameters": true`
1057
+ - Run `tsc --noEmit` -- expected ~7 errors
1058
+ - Fix each:
1059
+ - Delete unused imports (e.g., an imported icon that is no longer used)
1060
+ - Prefix unused function parameters with `_` (e.g., `_name` in menu-builder.ts `resolveLabel` at line 46, which already uses `_name`)
1061
+ - Delete unused local variables
1062
+ - Commit
1063
+
1064
+ - **Step 3: `exactOptionalPropertyTypes`**
1065
+ - Add: `"exactOptionalPropertyTypes": true`
1066
+ - Run `tsc --noEmit` -- expected ~11 errors
1067
+ - This flag means `{ x?: number }` does not accept `{ x: undefined }`. Fixes:
1068
+ - Where a value might be `undefined`, change the type to `x?: number | undefined` or narrow before assignment
1069
+ - Common in PanelConfig, ToolbarContribution, MenuContribution where optional fields are sometimes explicitly set to `undefined`
1070
+ - Commit
1071
+
1072
+ - **Step 4: `noUncheckedIndexedAccess`**
1073
+ - Add: `"noUncheckedIndexedAccess": true`
1074
+ - Run `tsc --noEmit` -- expected ~470 errors
1075
+ - This is the highest-impact flag. For each error:
1076
+ - **In test files** (~257 errors): Add non-null assertion `!` where test data is controlled and values are known to exist
1077
+ - **In production code** (~213 errors): Add proper null checks with early returns or fallback values. Categories:
1078
+ - Array index access: add bounds check or use `.at()` with null check
1079
+ - Map `.get()` followed by property access: add undefined check
1080
+ - `ZOOM_STEPS[i]` access: add bounds check (already handled by `zoomStepUp`/`zoomStepDown` logic)
1081
+ - `Object.entries()` destructuring: values are now `T | undefined`
1082
+ - Some of these will surface real latent bugs (array access without bounds checking). Fix those properly.
1083
+ - Commit after all errors fixed
1084
+
1085
+ - **Step 5: Evaluate `noPropertyAccessFromIndexSignature`**
1086
+ - This flag has ~698 errors and forces bracket notation on all index signature access. It is the most invasive and arguably lowest value.
1087
+ - Evaluate whether the benefits justify the churn after all other flags are enabled. If the prior I-03 changes have eliminated most `Record<string, unknown>` usage, the error count may be significantly lower.
1088
+ - If enabled: add to `tsconfig.app.json` and fix all resulting errors by switching to bracket notation where needed
1089
+ - If deferred: document the decision and the remaining error count
1090
+
1091
+ ### Preconditions
1092
+
1093
+ - I-03 (Generic Command\<P\>) must be completed first. The generic params eliminate many `Record<string, unknown>` index access patterns that would otherwise generate hundreds of `noUncheckedIndexedAccess` errors.
1094
+ - All prior items should ideally be done so the codebase is stable before the flag-by-flag sweep.
1095
+
1096
+ ### Postconditions / Verification
1097
+
1098
+ - `tsc --noEmit` passes with all enabled flags
1099
+ - All tests pass (`npx vitest run`)
1100
+ - No `as any` or `@ts-ignore` comments added to suppress errors (unless documented with justification)
1101
+ - No runtime regressions (all fixes are compile-time type narrowing)
1102
+ - The `noUncheckedIndexedAccess` fixes have surfaced and fixed any real latent bugs (document which bugs were found)
1103
+
1104
+ ### Risks and Edge Cases
1105
+
1106
+ - **Blast radius of `noUncheckedIndexedAccess`:** 470 errors across 54 files is a large changeset. Consider doing it in multiple commits: one per directory (e.g., `plugins/builtin/`, `src/lib/core/`, `src/lib/ui/`, etc.).
1107
+ - **Test file noise:** 257 of the 470 errors are in test files. Using `!` assertions in tests is acceptable since test data is controlled, but it should be done thoughtfully (only where the value is truly guaranteed).
1108
+ - **`noPropertyAccessFromIndexSignature` cost-benefit:** 698 errors with minimal type-safety benefit for most cases. Strongly consider deferring this flag or enabling it only for new code via eslint rules instead.
1109
+ - **Interaction with Svelte:** Svelte 5's `$state`, `$derived`, and `$props` runes may interact with strict flags in unexpected ways. Test compilation of `.svelte` files specifically after enabling each flag.
1110
+ - **Third-party types:** The `@tsconfig/svelte` base config and `dockview-core` types may not be compatible with all strict flags. If a third-party type causes errors, use targeted type overrides or `d.ts` augmentations rather than disabling the flag.
1111
+
1112
+ ---
1113
+
1114
+ ## Internal Consistency Check
1115
+
1116
+ ### 1. Dependency Validation
1117
+
1118
+ | Item | Precondition | What Precondition Produces | Verified? |
1119
+ |------|-------------|---------------------------|-----------|
1120
+ | I-01 | None | N/A | Yes |
1121
+ | I-02 | None (ideally after I-01) | I-01 produces `make-stroke-tool.ts` which I-02 will import `makeSnapshotUndo` into | Yes -- I-02's file list includes `make-stroke-tool.ts` as a modification target |
1122
+ | I-03 | I-01 + I-02 (ideally) | I-01 produces the factory, I-02 produces `makeSnapshotUndo`. I-03 updates both to use generic params and separate snapshot. | Yes -- I-03's step 8 explicitly updates `makeSnapshotUndo` |
1123
+ | I-04 | I-03 | I-03 produces typed `CommandDefinition<P>`, `Command<P>`, and updated dispatcher with snapshot support. I-04 needs the typed `Command` for `executeOrDispatch` to construct properly. | Yes -- I-04's `executeOrDispatch` helper constructs a `Command` object using the interface from I-03 |
1124
+ | I-05 | None | N/A | Yes |
1125
+ | I-06 | I-05 | I-05 establishes the `<dialog>` pattern. I-06's `PromptDialog.svelte` uses native `<dialog>`. | Yes -- I-06's step 2 uses `<dialog>` with `.showModal()` |
1126
+ | I-07 | I-04 + I-06 | I-04 provides `undoable` flag so destructive commands are marked before splitting. I-06 provides `dialogState.openPrompt()` so `prompt()` calls are already replaced. | Yes -- I-07 references `undoable: true` on destructive commands and `dialogState.openPrompt()` for resize and frame duration |
1127
+ | I-08 | I-07 | I-07 splits `menu-commands-plugin.ts` into domain files, making it cleaner to replace direct selection-tool imports. | Yes -- I-08 references `edit-commands.ts`, `image-commands.ts`, `select-commands.ts` (products of I-07) |
1128
+ | I-09 | I-03 + I-07 (ideally) | I-03 provides typed `CommandContext` with `getActiveBuffer`. I-07 provides a clean `plugin-api.ts`. | Yes -- I-09 builds on the `PluginAPI` interface already modified by I-03 (adding `getActiveBuffer` to context) and I-08 (adding `getSelection`) |
1129
+ | I-10 | I-03 | I-03 eliminates ~263 type casts, dramatically reducing the error count for strict flags (especially `noUncheckedIndexedAccess` which would flag `Record<string, unknown>` index access). | Yes -- without I-03, `noUncheckedIndexedAccess` would have ~263 more errors from the cast patterns |
1130
+
1131
+ ### 2. File Conflict Check
1132
+
1133
+ Files touched by more than one item:
1134
+
1135
+ | File Path | Touched By | Conflict Analysis |
1136
+ |-----------|-----------|-------------------|
1137
+ | `src/lib/core/commands.ts` | I-03 (generic Command, typed CommandContext, UndoEntry.snapshot), I-04 (add `undoable` field) | **Safe.** I-03 runs first and restructures the interfaces. I-04 adds one new field (`undoable?: boolean`) to `CommandDefinition`, which is additive and does not conflict with I-03's generic params. |
1138
+ | `src/lib/core/dispatcher.ts` | I-03 (snapshot capture/pass), I-04 (add `executeOrDispatch` helper) | **Safe.** I-03 modifies `dispatch()`, `undoLast()`, and `redoLast()` internals. I-04 adds a new `executeOrDispatch()` function. No overlap in modified lines. |
1139
+ | `src/lib/core/plugin-types.ts` | I-03 (generic `addCommand`), I-08 (`getSelection`), I-09 (mutator methods) | **Safe.** Each item adds new members to the `PluginAPI` interface. I-03 modifies the `addCommand` signature (line 15) and adds `getActiveBuffer` to `CommandContext` (line 67). I-08 adds `getSelection()`. I-09 adds 15 mutator methods. All additive, no conflicts. Order: I-03 first, then I-08, then I-09. |
1140
+ | `src/lib/core/plugin-api.ts` | I-03 (implement `getActiveBuffer` on context), I-08 (implement `getSelection`), I-09 (implement 15 mutators) | **Safe.** Same analysis as plugin-types.ts -- all additive implementations. |
1141
+ | `src/lib/core/registries.svelte.ts` | I-03 (change to `CommandDefinition<any>`) | Only I-03 touches this. No conflict. |
1142
+ | `plugins/builtin/drawing-utils.ts` | I-02 (add `makeSnapshotUndo`), I-03 (update `makeSnapshotUndo` signature) | **Safe.** I-02 adds the function. I-03 updates its signature (params type, adds snapshot arg). Order is correct: I-02 creates, I-03 updates. |
1143
+ | `plugins/builtin/pencil-tool.ts` | I-01 (reduce to factory call), I-02 (if I-01 not done, replace undo handler), I-03 (add typed params) | **Safe.** I-01 reduces the file to ~20 lines (factory call). I-02 may be absorbed into I-01 if done together. I-03 adds typed params to the factory config. No conflict because I-01's output is a thin wrapper that I-03 updates. |
1144
+ | `plugins/builtin/eraser-tool.ts` | I-01, I-02, I-03 | Same analysis as pencil-tool.ts. |
1145
+ | All 23 plugin files | I-02 (replace undo handlers), I-03 (generic params, remove casts) | **Safe.** I-02 replaces the `undo` function body. I-03 replaces the `execute` and `describe` function signatures and removes `_snapshot` from params. Different lines in the same files. Order is correct: I-02 simplifies undo first, I-03 changes signatures second. |
1146
+ | `src/lib/ui/menu-builder.ts` | I-04 (change `action` routing) | Only I-04 touches the `action` field. No conflict. |
1147
+ | `src/lib/ui/dialog-state.svelte.ts` | I-06 (add prompt state) | Only I-06 touches this. I-05 does not need to modify it (dialog refs are local to components). |
1148
+ | `src/App.svelte` | I-06 (add PromptDialog rendering) | Only I-06 touches this. I-05 converts existing dialog components but does not change App.svelte. |
1149
+ | `src/lib/ui/menu-commands-plugin.ts` | I-04 (mark undoable), I-06 (replace prompt), I-07 (delete) | **Safe if ordered correctly.** I-04 adds `undoable: true` to some commands. I-06 replaces `prompt()` calls. I-07 splits and deletes the file. All three happen in order: I-04 first, I-06 second, I-07 third (which deletes the file). No simultaneous conflict. |
1150
+ | `tsconfig.app.json` | I-10 (add strict flags) | Only I-10 touches this. |
1151
+ | `src/lib/canvas/input-handler.ts` | I-07 (replace zoom imports) | Only I-07 touches this. |
1152
+
1153
+ ### 3. Import Chain Validation
1154
+
1155
+ Key import chains after all changes:
1156
+
1157
+ - **Plugin registration chain:**
1158
+ `pencil-tool.ts` -> `make-stroke-tool.ts` -> `drawing-utils.ts` -> `pixel-buffer.ts`
1159
+ `pencil-tool.ts` -> `make-stroke-tool.ts` -> `plugin-loader.ts` -> `plugin-api.ts` -> `plugin-types.ts`
1160
+ No circularity. `plugin-types.ts` does not import from `plugin-api.ts`.
1161
+
1162
+ - **Dispatcher chain:**
1163
+ `dispatcher.ts` -> `commands.ts` (types only)
1164
+ `dispatcher.ts` -> `registries.svelte.ts` -> `commands.ts` (types only)
1165
+ No circularity.
1166
+
1167
+ - **PluginAPI -> selection-tool chain (after I-08):**
1168
+ `plugin-api.ts` -> `selection-tool.ts` -> `plugin-loader.ts` -> `plugin-api.ts`
1169
+ This looks circular but is safe: `plugin-loader.ts` imports only the TYPE `PluginAPI` from `plugin-api.ts` (using `import type`). TypeScript erases type-only imports at compile time, so there is no runtime circular dependency. `selection-tool.ts` imports `PluginModule` (a type) from `plugin-loader.ts`. Verified: `plugin-loader.ts` line 12 uses `import type { PluginAPI }`.
1170
+
1171
+ - **Menu builder -> dispatcher chain (after I-04):**
1172
+ `menu-builder.ts` -> `dispatcher.ts` (for `executeOrDispatch`)
1173
+ `menu-builder.ts` -> `registries.svelte.ts` (already imported, line 10)
1174
+ No new circularity.
1175
+
1176
+ - **Domain command files -> zoom-utils chain (after I-07):**
1177
+ `view-commands.ts` -> `zoom-utils.ts` (new)
1178
+ `input-handler.ts` -> `zoom-utils.ts` (new)
1179
+ No circularity. `zoom-utils.ts` is a pure utility with no imports from the project.
1180
+
1181
+ ### 4. Type System Coherence
1182
+
1183
+ After I-03 (Generic Command\<P\>) and I-10 (strict flags):
1184
+
1185
+ - **Generic param flow through dispatch:**
1186
+ - Plugin calls `api.dispatch({ type: 'draw_rect', plugin: '...', version: '...', params: { x: 1, y: 2, ... } })`
1187
+ - `PluginAPI.dispatch` accepts `Omit<Command, 'id' | 'timestamp'>` which after I-03 becomes `Omit<Command<any>, 'id' | 'timestamp'>` since the dispatch API does type erasure at the boundary
1188
+ - `dispatcher.dispatch()` receives `Command` (type-erased to `Command<any>`) and calls `definition.execute(command.params, context)` where `definition` is `CommandDefinition<any>` from the registry
1189
+ - This means: type safety exists at plugin registration time (the `CommandDefinition<DrawRectParams>` enforces that `execute` receives `DrawRectParams`), but is erased at the registry/dispatcher boundary. This is correct and expected.
1190
+
1191
+ - **Snapshot flow through undo stack:**
1192
+ - `execute(params: P, ctx: CommandContext)` returns `unknown | void`
1193
+ - Dispatcher captures return value and stores as `UndoEntry.snapshot: unknown`
1194
+ - `undo(params: P, ctx: CommandContext, snapshot: unknown)` receives it
1195
+ - Each plugin's undo handler casts `snapshot as PixelData[]` (or its specific snapshot type)
1196
+ - This is one `as` cast per undo handler, which is acceptable at the boundary between the generic undo system and the specific plugin data. `makeSnapshotUndo` centralizes this cast.
1197
+
1198
+ - **`noUncheckedIndexedAccess` interaction:**
1199
+ - After I-03, `Command.params` is typed as `P` (not `Record<string, unknown>`), so index access on params is no longer needed. The ~170 `params.field as Type` casts are gone, replaced by typed property access.
1200
+ - Remaining index access patterns (arrays, Maps) will need null checks under `noUncheckedIndexedAccess`. These are independent of the generic Command changes.
1201
+
1202
+ ### 5. Plugin System Coherence
1203
+
1204
+ After I-08 (`getSelection`) and I-09 (mutator methods):
1205
+
1206
+ - **Pattern consistency:** The existing `PluginAPI` methods follow two patterns:
1207
+ - Registrars: `addCommand`, `addTool`, `addPanel`, etc. -- take a name + definition, store in registry
1208
+ - Getters: `getCanvas`, `getProject`, `getActiveFrame`, `getActiveLayers` -- return current state
1209
+
1210
+ The new methods add two more patterns:
1211
+ - Query: `getSelection` -- returns a view object with methods (consistent with `getActiveLayers` which returns `{ all, active }`)
1212
+ - Mutators: `setCanvasSize`, `addLayer`, `addFrame`, etc. -- thin wrappers around internal modules
1213
+
1214
+ All patterns are consistent: methods are verb-prefixed (`add`, `get`, `set`, `reset`), take simple parameters, and return simple values or `unknown`.
1215
+
1216
+ - **No overlap between getter return types and mutator inputs:** `getActiveLayers()` returns `unknown` (not a typed layer object). `addLayer(name)` takes a string. There is no type inconsistency between reads and writes because both sides use `unknown` or simple primitives.
1217
+
1218
+ - **`api` instance scoping:** Each plugin receives its own `api` instance from `createPluginAPI(pluginName)`. However, all instances share the same registries and state. The `_pluginName` parameter (line 34 of plugin-api.ts) is currently unused (`eslint-disable-next-line @typescript-eslint/no-unused-vars`). The new methods do not use it either, which is consistent. If namespace scoping is needed later, all methods have access to `_pluginName` via closure.
1219
+
1220
+ ### 6. Dead Code Check
1221
+
1222
+ | Item | Potential Dead Code | Resolution |
1223
+ |------|-------------------|------------|
1224
+ | I-01 | The full `register()` bodies in `pencil-tool.ts` and `eraser-tool.ts` are replaced by factory calls. The old code (130+ lines) is deleted, not orphaned. | No dead code. |
1225
+ | I-02 | All 32 inline undo handler bodies are replaced by `makeSnapshotUndo()` calls. The old code is deleted. | No dead code. |
1226
+ | I-03 | The `_snapshot` field in dispatch `params` objects (21+ files) is removed. The old snapshot-in-params convention is fully eliminated. However, verify no external code reads `command.params._snapshot` from the undo stack for display purposes. | Check: does any UI component (e.g., a history panel) read `_snapshot` from `Command.params`? If so, update it to read from `UndoEntry.snapshot`. |
1227
+ | I-03 | The current `CommandContext` empty interface `{ [key: string]: unknown }` (line 67 of commands.ts) is replaced with typed members. If any code relies on adding arbitrary keys to the context, the index signature removal could break it. | Check: `dispatcher.ts` `setContext()` (line 253) uses `Object.assign(context, partial)`. The typed `CommandContext` must still allow extension. Keep the index signature alongside the typed members: `{ getActiveBuffer?: ...; [key: string]: unknown; }` |
1228
+ | I-04 | The `executeOrDispatch` helper is new code, not dead code. The old direct `.execute({}, {})` calls in 4 files are replaced. | No dead code. |
1229
+ | I-05 | The `.modal-overlay` CSS class and the manual Escape handlers are removed from 3 dialog components. Any global `.modal-overlay` styles in `app.css` become dead CSS. | Check: `grep -rn "modal-overlay" src/` after I-05 should return 0 results. Remove any orphaned global styles. |
1230
+ | I-06 | The `window.prompt()` calls are replaced. No dead code created (the replacement is `dialogState.openPrompt()`). | No dead code. |
1231
+ | I-07 | The original `menu-commands-plugin.ts` is deleted. All its exports (`menuCommandsPlugin`) become dead references. | Check: `grep -rn "menuCommandsPlugin" src/` should return 0 after deletion. The `bootstrap.ts` glob discovers modules by file pattern, not by explicit import, so deletion is safe. |
1232
+ | I-07 | The `ZOOM_STEPS`, `zoomStepUp`, `zoomStepDown`, and `viewportCenter` functions are moved to new utility files. The originals in `menu-commands-plugin.ts` are deleted with the file. But duplicates in `input-handler.ts` must also be removed and replaced with imports. | Check: after I-07, `grep -rn "ZOOM_STEPS" src/` should return only `zoom-utils.ts` and its importers. |
1233
+ | I-08 | The direct imports of `selectRect`, `deselectAll`, `hasSelection`, `getSelectedPixels` from `selection-tool.ts` in UI command files are removed. The functions themselves in `selection-tool.ts` remain (they are used by `plugin-api.ts` internally and by the tool's own `register()` function). | No dead code in `selection-tool.ts`. Orphaned imports in consumer files are removed. |
1234
+ | I-09 | No dead code created. New methods are additive. The direct imports in importers are removed and replaced with `api.*` calls. | No dead code. |
1235
+ | I-10 | Unused locals and parameters are deleted (step 2). No new dead code is created by the other flags. | Step 2 specifically eliminates dead code. |