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,408 @@
1
+ /**
2
+ * Canvas Renderer -- draws the pixel canvas, grid, and cursor onto
3
+ * an HTML Canvas 2D context.
4
+ *
5
+ * Performance strategy: pixel data is drawn to an offscreen canvas at
6
+ * 1:1 scale, then scaled up with drawImage() (hardware-accelerated,
7
+ * nearest-neighbor when imageSmoothingEnabled = false).
8
+ */
9
+
10
+ import type { PixelBuffer } from './pixel-buffer.js';
11
+ import type { ShapePreview } from './shape-preview-state.svelte.js';
12
+
13
+ // --- Offscreen canvas cache (reused between frames) ---
14
+
15
+ let offscreen: OffscreenCanvas | null = null;
16
+ let offCtx: OffscreenCanvasRenderingContext2D | null = null;
17
+ let offWidth = 0;
18
+ let offHeight = 0;
19
+
20
+ /** Ensure the offscreen canvas matches the pixel buffer dimensions. */
21
+ function ensureOffscreen(w: number, h: number): void {
22
+ if (offscreen && offWidth === w && offHeight === h) return;
23
+ offscreen = new OffscreenCanvas(w, h);
24
+ const ctx = offscreen.getContext('2d');
25
+ if (!ctx) throw new Error('Failed to get 2d context from OffscreenCanvas');
26
+ offCtx = ctx;
27
+ offWidth = w;
28
+ offHeight = h;
29
+ }
30
+
31
+ // --- Theme-aware color helpers ---
32
+
33
+ /**
34
+ * Read a CSS custom property from the document root at render time.
35
+ * Falls back to the provided default when running outside a browser
36
+ * or when the variable is not set.
37
+ */
38
+ function cssVar(name: string, fallback: string): string {
39
+ if (typeof document === 'undefined') return fallback;
40
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
41
+ return value || fallback;
42
+ }
43
+
44
+ /** Workspace background color (the area outside the pixel canvas). */
45
+ function getWorkspaceBg(): string {
46
+ return cssVar('--bg-canvas', '#121212');
47
+ }
48
+
49
+ /** Border/outline color used around the pixel canvas. */
50
+ function getBorderColor(): string {
51
+ return cssVar('--border', '#3a3a3a');
52
+ }
53
+
54
+ /** Detect whether the active theme is light. */
55
+ function isLightTheme(): boolean {
56
+ if (typeof document === 'undefined') return false;
57
+ return document.documentElement.dataset["theme"] === 'light';
58
+ }
59
+
60
+ /** Grid line color -- dark on light backgrounds, light on dark backgrounds. */
61
+ function getGridColor(): string {
62
+ return isLightTheme() ? 'rgba(0, 0, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)';
63
+ }
64
+
65
+ /** Cursor highlight fill -- subtle overlay that contrasts with both themes. */
66
+ function getCursorFillColor(): string {
67
+ return isLightTheme() ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.15)';
68
+ }
69
+
70
+ /** Cursor outline -- must be clearly visible on the canvas. */
71
+ function getCursorStrokeColor(): string {
72
+ return isLightTheme() ? 'rgba(0, 0, 0, 0.5)' : 'rgba(255, 255, 255, 0.6)';
73
+ }
74
+
75
+ /** Border around the small color indicator next to the cursor. */
76
+ function getIndicatorBorderColor(): string {
77
+ return isLightTheme() ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.5)';
78
+ }
79
+
80
+ // --- Main render ---
81
+
82
+ /**
83
+ * Render the full canvas view: background, pixel data, grid, and cursor.
84
+ *
85
+ * @param ctx - The destination 2D context (the on-screen canvas).
86
+ * @param buffer - The pixel data to render.
87
+ * @param zoom - Zoom factor (each pixel = zoom x zoom screen pixels).
88
+ * @param panX - Horizontal pan offset in screen pixels.
89
+ * @param panY - Vertical pan offset in screen pixels.
90
+ */
91
+ export function renderCanvas(
92
+ ctx: CanvasRenderingContext2D,
93
+ buffer: PixelBuffer,
94
+ zoom: number,
95
+ panX: number,
96
+ panY: number,
97
+ ): void {
98
+ const { width: cw, height: ch } = ctx.canvas;
99
+
100
+ // Clear to workspace background color (reads CSS variable so it follows theme)
101
+ ctx.fillStyle = getWorkspaceBg();
102
+ ctx.fillRect(0, 0, cw, ch);
103
+
104
+ // Draw pixel data via offscreen canvas for crisp nearest-neighbor scaling
105
+ ensureOffscreen(buffer.width, buffer.height);
106
+ // After ensureOffscreen, both offCtx and offscreen are guaranteed non-null.
107
+ if (!offCtx || !offscreen) return;
108
+ offCtx.putImageData(buffer.toImageData(), 0, 0);
109
+
110
+ ctx.imageSmoothingEnabled = false;
111
+ ctx.drawImage(
112
+ offscreen,
113
+ 0, 0, buffer.width, buffer.height,
114
+ panX, panY, buffer.width * zoom, buffer.height * zoom,
115
+ );
116
+
117
+ // Canvas border (1px outline around the canvas area)
118
+ renderBorder(ctx, buffer.width, buffer.height, zoom, panX, panY);
119
+ }
120
+
121
+ // --- Border ---
122
+
123
+ /** Draw a 1px outline around the canvas area so the user can see its bounds. */
124
+ function renderBorder(
125
+ ctx: CanvasRenderingContext2D,
126
+ width: number,
127
+ height: number,
128
+ zoom: number,
129
+ panX: number,
130
+ panY: number,
131
+ ): void {
132
+ // Use the theme border color so the outline is visible on both themes
133
+ ctx.strokeStyle = getBorderColor();
134
+ ctx.lineWidth = 1;
135
+ // Offset by 0.5 to land on exact pixel boundaries and avoid anti-aliasing
136
+ ctx.strokeRect(
137
+ panX - 0.5,
138
+ panY - 0.5,
139
+ width * zoom + 1,
140
+ height * zoom + 1,
141
+ );
142
+ }
143
+
144
+ // --- Grid ---
145
+
146
+ /**
147
+ * Draw pixel grid lines. Only meaningful when zoom >= 4.
148
+ * Uses theme-aware semi-transparent lines that don't obscure the art.
149
+ */
150
+ export function renderGrid(
151
+ ctx: CanvasRenderingContext2D,
152
+ width: number,
153
+ height: number,
154
+ zoom: number,
155
+ panX: number,
156
+ panY: number,
157
+ ): void {
158
+ if (zoom < 4) return;
159
+
160
+ const totalW = width * zoom;
161
+ const totalH = height * zoom;
162
+
163
+ ctx.strokeStyle = getGridColor();
164
+ ctx.lineWidth = 0.5;
165
+
166
+ ctx.beginPath();
167
+
168
+ // Vertical lines
169
+ for (let x = 0; x <= width; x++) {
170
+ const sx = panX + x * zoom;
171
+ ctx.moveTo(sx, panY);
172
+ ctx.lineTo(sx, panY + totalH);
173
+ }
174
+
175
+ // Horizontal lines
176
+ for (let y = 0; y <= height; y++) {
177
+ const sy = panY + y * zoom;
178
+ ctx.moveTo(panX, sy);
179
+ ctx.lineTo(panX + totalW, sy);
180
+ }
181
+
182
+ ctx.stroke();
183
+ }
184
+
185
+ // --- Cursor overlay ---
186
+
187
+ /**
188
+ * Draw a cursor highlight over the pixel(s) the pointer is on.
189
+ *
190
+ * @param ctx - The destination 2D context.
191
+ * @param x - Canvas pixel X coordinate.
192
+ * @param y - Canvas pixel Y coordinate.
193
+ * @param zoom - Current zoom level.
194
+ * @param panX - Horizontal pan offset.
195
+ * @param panY - Vertical pan offset.
196
+ * @param toolSize - Size of the tool brush in pixels (1 = single pixel).
197
+ * @param color - Current drawing color as [r, g, b, a].
198
+ */
199
+ export function renderCursor(
200
+ ctx: CanvasRenderingContext2D,
201
+ x: number,
202
+ y: number,
203
+ zoom: number,
204
+ panX: number,
205
+ panY: number,
206
+ toolSize: number = 1,
207
+ color: [number, number, number, number] = [255, 255, 255, 255],
208
+ ): void {
209
+ // Pixel highlight: semi-transparent outline around the cursor pixel(s)
210
+ const half = Math.floor(toolSize / 2);
211
+ const sx = panX + (x - half) * zoom;
212
+ const sy = panY + (y - half) * zoom;
213
+ const size = toolSize * zoom;
214
+
215
+ // Fill with a subtle overlay
216
+ ctx.fillStyle = getCursorFillColor();
217
+ ctx.fillRect(sx, sy, size, size);
218
+
219
+ // Outline
220
+ ctx.strokeStyle = getCursorStrokeColor();
221
+ ctx.lineWidth = 1;
222
+ ctx.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1);
223
+
224
+ // Small color indicator (4x4 square in the top-right corner of cursor area)
225
+ const indicatorSize = Math.max(4, Math.min(8, zoom / 2));
226
+ const ix = sx + size + 2;
227
+ const iy = sy - indicatorSize - 2;
228
+ ctx.fillStyle = `rgba(${String(color[0])}, ${String(color[1])}, ${String(color[2])}, ${String(color[3] / 255)})`;
229
+ ctx.fillRect(ix, iy, indicatorSize, indicatorSize);
230
+ ctx.strokeStyle = getIndicatorBorderColor();
231
+ ctx.lineWidth = 0.5;
232
+ ctx.strokeRect(ix, iy, indicatorSize, indicatorSize);
233
+ }
234
+
235
+ // --- Isometric guide overlay ---
236
+
237
+ /**
238
+ * Draw an isometric diamond guide inscribed in the canvas rectangle,
239
+ * plus center crosshair lines. Helps users align isometric tile sprites.
240
+ *
241
+ * The diamond vertices sit at the midpoints of the canvas edges:
242
+ * Top: (width/2, 0)
243
+ * Right: (width, height/2)
244
+ * Bottom: (width/2, height)
245
+ * Left: (0, height/2)
246
+ *
247
+ * All coordinates are in canvas pixel space, converted to screen space
248
+ * via zoom and pan.
249
+ */
250
+ export function renderIsoGuide(
251
+ ctx: CanvasRenderingContext2D,
252
+ width: number,
253
+ height: number,
254
+ zoom: number,
255
+ panX: number,
256
+ panY: number,
257
+ ): void {
258
+ // Convert canvas-pixel vertices to screen-space
259
+ const topX = panX + (width / 2) * zoom;
260
+ const topY = panY;
261
+ const rightX = panX + width * zoom;
262
+ const rightY = panY + (height / 2) * zoom;
263
+ const bottomX = panX + (width / 2) * zoom;
264
+ const bottomY = panY + height * zoom;
265
+ const leftX = panX;
266
+ const leftY = panY + (height / 2) * zoom;
267
+
268
+ const guideColor = isLightTheme() ? 'rgba(200, 0, 200,' : 'rgba(0, 220, 220,';
269
+
270
+ ctx.save();
271
+
272
+ // Diamond outline -- dashed, 60% opacity
273
+ ctx.strokeStyle = `${guideColor} 0.6)`;
274
+ ctx.lineWidth = 1;
275
+ ctx.setLineDash([4, 4]);
276
+
277
+ ctx.beginPath();
278
+ ctx.moveTo(topX, topY);
279
+ ctx.lineTo(rightX, rightY);
280
+ ctx.lineTo(bottomX, bottomY);
281
+ ctx.lineTo(leftX, leftY);
282
+ ctx.closePath();
283
+ ctx.stroke();
284
+
285
+ // Center crosshair lines -- 30% opacity
286
+ ctx.strokeStyle = `${guideColor} 0.3)`;
287
+ ctx.setLineDash([4, 4]);
288
+
289
+ const centerX = panX + (width / 2) * zoom;
290
+ const centerY = panY + (height / 2) * zoom;
291
+
292
+ ctx.beginPath();
293
+ // Horizontal line through center
294
+ ctx.moveTo(panX, centerY);
295
+ ctx.lineTo(panX + width * zoom, centerY);
296
+ // Vertical line through center
297
+ ctx.moveTo(centerX, panY);
298
+ ctx.lineTo(centerX, panY + height * zoom);
299
+ ctx.stroke();
300
+
301
+ ctx.restore();
302
+ }
303
+
304
+ // --- Shape preview overlay ---
305
+
306
+ /**
307
+ * Draw a live rubber-band preview for shape tools (rect, ellipse, diamond, line).
308
+ *
309
+ * The preview is drawn as a canvas 2D overlay at the zoomed/panned position,
310
+ * NOT as pixel data. It uses dashed strokes and semi-transparent fills so the
311
+ * user can see what will be drawn without modifying the pixel buffer.
312
+ *
313
+ * Coordinates in the preview are in canvas pixel space; this function converts
314
+ * them to screen space using zoom and pan.
315
+ */
316
+ export function renderShapePreview(
317
+ ctx: CanvasRenderingContext2D,
318
+ preview: ShapePreview,
319
+ zoom: number,
320
+ panX: number,
321
+ panY: number,
322
+ ): void {
323
+ // Normalize so x0,y0 is top-left for rect/ellipse/diamond
324
+ // (line uses raw start/end)
325
+ const x0 = Math.min(preview.startX, preview.endX);
326
+ const y0 = Math.min(preview.startY, preview.endY);
327
+ const x1 = Math.max(preview.startX, preview.endX);
328
+ const y1 = Math.max(preview.startY, preview.endY);
329
+
330
+ // Screen coordinates: offset by +0.5 pixel to cover the full pixel cell
331
+ // (a pixel at canvas coord N spans screen range [N*zoom, (N+1)*zoom])
332
+ const sx0 = panX + x0 * zoom;
333
+ const sy0 = panY + y0 * zoom;
334
+ const sw = (x1 - x0 + 1) * zoom;
335
+ const sh = (y1 - y0 + 1) * zoom;
336
+
337
+ ctx.save();
338
+
339
+ // Semi-transparent version of the drawing color for fills
340
+ const fillColor = preview.color + '33'; // ~20% opacity hex
341
+ // Slightly more opaque for strokes
342
+ const strokeColor = preview.color + '99'; // ~60% opacity hex
343
+
344
+ ctx.lineWidth = 1;
345
+ ctx.setLineDash([4, 4]);
346
+ ctx.strokeStyle = strokeColor;
347
+ ctx.fillStyle = fillColor;
348
+
349
+ switch (preview.type) {
350
+ case 'rect':
351
+ if (preview.filled) {
352
+ ctx.fillRect(sx0, sy0, sw, sh);
353
+ }
354
+ ctx.strokeRect(sx0, sy0, sw, sh);
355
+ break;
356
+
357
+ case 'ellipse': {
358
+ // Derive center and radii in screen space
359
+ const cx = sx0 + sw / 2;
360
+ const cy = sy0 + sh / 2;
361
+ const rx = sw / 2;
362
+ const ry = sh / 2;
363
+
364
+ ctx.beginPath();
365
+ ctx.ellipse(cx, cy, Math.max(0, rx), Math.max(0, ry), 0, 0, Math.PI * 2);
366
+ if (preview.filled) {
367
+ ctx.fill();
368
+ }
369
+ ctx.stroke();
370
+ break;
371
+ }
372
+
373
+ case 'diamond': {
374
+ // Four vertices at midpoints of the bounding box edges
375
+ const midX = sx0 + sw / 2;
376
+ const midY = sy0 + sh / 2;
377
+
378
+ ctx.beginPath();
379
+ ctx.moveTo(midX, sy0); // top
380
+ ctx.lineTo(sx0 + sw, midY); // right
381
+ ctx.lineTo(midX, sy0 + sh); // bottom
382
+ ctx.lineTo(sx0, midY); // left
383
+ ctx.closePath();
384
+ if (preview.filled) {
385
+ ctx.fill();
386
+ }
387
+ ctx.stroke();
388
+ break;
389
+ }
390
+
391
+ case 'line': {
392
+ // Line uses raw start/end (not normalized)
393
+ // +0.5 pixel offset so the line goes through pixel centers
394
+ const lx0 = panX + (preview.startX + 0.5) * zoom;
395
+ const ly0 = panY + (preview.startY + 0.5) * zoom;
396
+ const lx1 = panX + (preview.endX + 0.5) * zoom;
397
+ const ly1 = panY + (preview.endY + 0.5) * zoom;
398
+
399
+ ctx.beginPath();
400
+ ctx.moveTo(lx0, ly0);
401
+ ctx.lineTo(lx1, ly1);
402
+ ctx.stroke();
403
+ break;
404
+ }
405
+ }
406
+
407
+ ctx.restore();
408
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Canvas State -- reactive singleton holding viewport and cursor state.
3
+ *
4
+ * Uses Svelte 5 runes ($state) so that any component reading these
5
+ * properties will automatically re-render when they change.
6
+ *
7
+ * Module-level singleton -- one canvas state per app instance.
8
+ */
9
+
10
+ // --- Canvas dimensions ---
11
+
12
+ /**
13
+ * Maximum canvas dimension in pixels. Previously 512; raised to 4096 so that
14
+ * effects producing larger buffers (rotate 90/270 on tall canvases, upscale)
15
+ * don't get silently clamped. 4096 is a conservative sanity ceiling -- the
16
+ * compositor and PixelBuffer are size-agnostic, and the browser's ImageData
17
+ * supports much larger sizes, but 4096x4096 RGBA already costs 64 MB per
18
+ * layer/frame, so raising it further would invite real memory trouble.
19
+ */
20
+ export const MAX_CANVAS_SIZE = 4096;
21
+
22
+ /** Minimum canvas dimension in pixels. */
23
+ export const MIN_CANVAS_SIZE = 1;
24
+
25
+ /** Width of the pixel canvas. */
26
+ let canvasWidth = $state(32);
27
+
28
+ /** Height of the pixel canvas. */
29
+ let canvasHeight = $state(32);
30
+
31
+ // --- Viewport (how the canvas is displayed in the browser) ---
32
+
33
+ /** Zoom level: each canvas pixel is rendered as zoom x zoom screen pixels. */
34
+ let zoom = $state(8);
35
+
36
+ /** Horizontal pan offset in screen pixels. */
37
+ let panX = $state(0);
38
+
39
+ /** Vertical pan offset in screen pixels. */
40
+ let panY = $state(0);
41
+
42
+ // --- Cursor ---
43
+
44
+ /** Cursor X in canvas pixel coordinates (0-based). */
45
+ let cursorX = $state(0);
46
+
47
+ /** Cursor Y in canvas pixel coordinates (0-based). */
48
+ let cursorY = $state(0);
49
+
50
+ /** Whether the cursor is currently over the canvas area. */
51
+ let cursorInBounds = $state(false);
52
+
53
+ // --- Display toggles ---
54
+
55
+ /** Whether the pixel grid overlay is shown (at sufficient zoom). */
56
+ let showGrid = $state(true);
57
+
58
+ /** Whether the sub-pixel grid overlay is shown (finer grid within each pixel). */
59
+ let showPixelGrid = $state(false);
60
+
61
+ /** Whether tile mode is active (3x3 ghost grid + coordinate wrapping). */
62
+ let tileMode = $state(false);
63
+
64
+ /** Whether onion skinning is active (ghost frames before/after current). */
65
+ let onionSkin = $state(false);
66
+
67
+ /** Whether the isometric diamond guide overlay is shown on the canvas. */
68
+ let isoGuide = $state(false);
69
+
70
+ // --- Animated zoom/pan ---
71
+
72
+ /** Target values for smooth interpolation. */
73
+ let targetZoom = $state(8);
74
+ let targetPanX = $state(0);
75
+ let targetPanY = $state(0);
76
+ let isAnimating = false;
77
+
78
+ /** Lerp factor per frame -- 0.25 gives snappy-but-smooth motion. */
79
+ const LERP = 0.25;
80
+
81
+ /** Thresholds below which we snap to the target to avoid endless ticking. */
82
+ const ZOOM_EPSILON = 0.01;
83
+ const PAN_EPSILON = 0.5;
84
+
85
+ /**
86
+ * Internal animation tick that lerps zoom/pan toward their targets.
87
+ * Self-schedules via requestAnimationFrame until close enough.
88
+ */
89
+ function animationTick(): void {
90
+ const zoomDiff = targetZoom - zoom;
91
+ const panXDiff = targetPanX - panX;
92
+ const panYDiff = targetPanY - panY;
93
+
94
+ if (
95
+ Math.abs(zoomDiff) < ZOOM_EPSILON &&
96
+ Math.abs(panXDiff) < PAN_EPSILON &&
97
+ Math.abs(panYDiff) < PAN_EPSILON
98
+ ) {
99
+ // Close enough -- snap to targets and stop animating
100
+ zoom = targetZoom;
101
+ panX = targetPanX;
102
+ panY = targetPanY;
103
+ isAnimating = false;
104
+ return;
105
+ }
106
+
107
+ zoom += zoomDiff * LERP;
108
+ panX += panXDiff * LERP;
109
+ panY += panYDiff * LERP;
110
+
111
+ requestAnimationFrame(animationTick);
112
+ }
113
+
114
+ function startAnimation(): void {
115
+ if (isAnimating) return;
116
+ isAnimating = true;
117
+ requestAnimationFrame(animationTick);
118
+ }
119
+
120
+ // --- Coordinate conversion ---
121
+
122
+ /**
123
+ * Convert screen (viewport) coordinates to canvas pixel coordinates.
124
+ * Returns fractional values; floor them to get the pixel index.
125
+ */
126
+ function screenToCanvas(screenX: number, screenY: number): { x: number; y: number } {
127
+ return {
128
+ x: (screenX - panX) / zoom,
129
+ y: (screenY - panY) / zoom,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Convert canvas pixel coordinates to screen (viewport) coordinates.
135
+ * Returns the top-left corner of the pixel on screen.
136
+ */
137
+ function canvasToScreen(canvasX: number, canvasY: number): { x: number; y: number } {
138
+ return {
139
+ x: canvasX * zoom + panX,
140
+ y: canvasY * zoom + panY,
141
+ };
142
+ }
143
+
144
+ // --- Public API (exported as a singleton object) ---
145
+
146
+ export const canvasState = {
147
+ get canvasWidth() { return canvasWidth; },
148
+ set canvasWidth(v: number) {
149
+ canvasWidth = Math.max(MIN_CANVAS_SIZE, Math.min(MAX_CANVAS_SIZE, Math.round(v)));
150
+ },
151
+
152
+ get canvasHeight() { return canvasHeight; },
153
+ set canvasHeight(v: number) {
154
+ canvasHeight = Math.max(MIN_CANVAS_SIZE, Math.min(MAX_CANVAS_SIZE, Math.round(v)));
155
+ },
156
+
157
+ get zoom() { return zoom; },
158
+ set zoom(v: number) { zoom = v; targetZoom = v; },
159
+
160
+ get panX() { return panX; },
161
+ set panX(v: number) { panX = v; targetPanX = v; },
162
+
163
+ get panY() { return panY; },
164
+ set panY(v: number) { panY = v; targetPanY = v; },
165
+
166
+ get cursorX() { return cursorX; },
167
+ set cursorX(v: number) { cursorX = v; },
168
+
169
+ get cursorY() { return cursorY; },
170
+ set cursorY(v: number) { cursorY = v; },
171
+
172
+ get cursorInBounds() { return cursorInBounds; },
173
+ set cursorInBounds(v: boolean) { cursorInBounds = v; },
174
+
175
+ get showGrid() { return showGrid; },
176
+ set showGrid(v: boolean) { showGrid = v; },
177
+
178
+ get showPixelGrid() { return showPixelGrid; },
179
+ set showPixelGrid(v: boolean) { showPixelGrid = v; },
180
+
181
+ get tileMode() { return tileMode; },
182
+ set tileMode(v: boolean) { tileMode = v; },
183
+
184
+ get onionSkin() { return onionSkin; },
185
+ set onionSkin(v: boolean) { onionSkin = v; },
186
+
187
+ get isoGuide() { return isoGuide; },
188
+ set isoGuide(v: boolean) { isoGuide = v; },
189
+
190
+ screenToCanvas,
191
+ canvasToScreen,
192
+
193
+ /**
194
+ * Animate zoom toward a target value while keeping a pivot point stable.
195
+ * The pivot is in screen-space (relative to the canvas element).
196
+ */
197
+ setZoomAnimated(newZoom: number, pivotScreenX: number, pivotScreenY: number): void {
198
+ // Canvas-space position under the pivot at the current (or target) zoom
199
+ const before = {
200
+ x: (pivotScreenX - targetPanX) / targetZoom,
201
+ y: (pivotScreenY - targetPanY) / targetZoom,
202
+ };
203
+
204
+ targetZoom = newZoom;
205
+
206
+ // Adjust target pan so the same canvas-space point stays under the pivot
207
+ targetPanX = pivotScreenX - before.x * newZoom;
208
+ targetPanY = pivotScreenY - before.y * newZoom;
209
+
210
+ startAnimation();
211
+ },
212
+
213
+ /** Animate pan to a specific screen offset. */
214
+ setPanAnimated(x: number, y: number): void {
215
+ targetPanX = x;
216
+ targetPanY = y;
217
+ startAnimation();
218
+ },
219
+ };
220
+
221
+ // --- Serialization ---
222
+
223
+ /** Serialize canvas dimensions for project save. */
224
+ export function serializeCanvasState(): { width: number; height: number } {
225
+ return { width: canvasState.canvasWidth, height: canvasState.canvasHeight };
226
+ }
227
+
228
+ /** Restore canvas dimensions from a saved project. */
229
+ export function deserializeCanvasState(data: { width: number; height: number }): void {
230
+ canvasState.canvasWidth = data.width;
231
+ canvasState.canvasHeight = data.height;
232
+ }