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,249 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { PixelBuffer } from './pixel-buffer.js';
3
+
4
+ // jsdom does not provide ImageData; polyfill it for tests
5
+ beforeAll(() => {
6
+ if (typeof globalThis.ImageData === 'undefined') {
7
+ // Minimal ImageData polyfill: stores width, height, and a data copy
8
+ (globalThis as Record<string, unknown>)["ImageData"] = class ImageData {
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly data: Uint8ClampedArray;
12
+ constructor(data: Uint8ClampedArray, width: number, height: number) {
13
+ this.width = width;
14
+ this.height = height;
15
+ this.data = new Uint8ClampedArray(data);
16
+ }
17
+ };
18
+ }
19
+ });
20
+
21
+ describe('PixelBuffer', () => {
22
+ it('should create a buffer of the correct size, initialized to transparent', () => {
23
+ const buf = new PixelBuffer(4, 3);
24
+ expect(buf.width).toBe(4);
25
+ expect(buf.height).toBe(3);
26
+ expect(buf.data.length).toBe(4 * 3 * 4);
27
+
28
+ // Every byte should be 0 (transparent black)
29
+ for (let i = 0; i < buf.data.length; i++) {
30
+ expect(buf.data[i]).toBe(0);
31
+ }
32
+ });
33
+
34
+ it('should roundtrip setPixel / getPixel', () => {
35
+ const buf = new PixelBuffer(8, 8);
36
+ buf.setPixel(3, 5, 10, 20, 30, 255);
37
+ expect(buf.getPixel(3, 5)).toEqual([10, 20, 30, 255]);
38
+ });
39
+
40
+ it('should not crash on out-of-bounds setPixel', () => {
41
+ const buf = new PixelBuffer(4, 4);
42
+ // These should be silent no-ops
43
+ buf.setPixel(-1, 0, 255, 0, 0, 255);
44
+ buf.setPixel(0, -1, 255, 0, 0, 255);
45
+ buf.setPixel(4, 0, 255, 0, 0, 255);
46
+ buf.setPixel(0, 4, 255, 0, 0, 255);
47
+ buf.setPixel(100, 100, 255, 0, 0, 255);
48
+
49
+ // Buffer should still be all zeros
50
+ for (let i = 0; i < buf.data.length; i++) {
51
+ expect(buf.data[i]).toBe(0);
52
+ }
53
+ });
54
+
55
+ it('should return [0,0,0,0] for out-of-bounds getPixel', () => {
56
+ const buf = new PixelBuffer(4, 4);
57
+ buf.fill(255, 128, 64, 255);
58
+
59
+ expect(buf.getPixel(-1, 0)).toEqual([0, 0, 0, 0]);
60
+ expect(buf.getPixel(0, -1)).toEqual([0, 0, 0, 0]);
61
+ expect(buf.getPixel(4, 0)).toEqual([0, 0, 0, 0]);
62
+ expect(buf.getPixel(0, 4)).toEqual([0, 0, 0, 0]);
63
+ expect(buf.getPixel(999, 999)).toEqual([0, 0, 0, 0]);
64
+ });
65
+
66
+ it('should fill the entire buffer with a color', () => {
67
+ const buf = new PixelBuffer(3, 2);
68
+ buf.fill(100, 150, 200, 255);
69
+
70
+ for (let y = 0; y < 2; y++) {
71
+ for (let x = 0; x < 3; x++) {
72
+ expect(buf.getPixel(x, y)).toEqual([100, 150, 200, 255]);
73
+ }
74
+ }
75
+ });
76
+
77
+ it('should fillRect within the specified region only', () => {
78
+ const buf = new PixelBuffer(8, 8);
79
+ buf.fillRect(2, 3, 3, 2, 255, 0, 0, 255);
80
+
81
+ // Pixels inside the rect
82
+ expect(buf.getPixel(2, 3)).toEqual([255, 0, 0, 255]);
83
+ expect(buf.getPixel(3, 3)).toEqual([255, 0, 0, 255]);
84
+ expect(buf.getPixel(4, 3)).toEqual([255, 0, 0, 255]);
85
+ expect(buf.getPixel(2, 4)).toEqual([255, 0, 0, 255]);
86
+ expect(buf.getPixel(3, 4)).toEqual([255, 0, 0, 255]);
87
+ expect(buf.getPixel(4, 4)).toEqual([255, 0, 0, 255]);
88
+
89
+ // Pixels outside the rect should remain transparent
90
+ expect(buf.getPixel(1, 3)).toEqual([0, 0, 0, 0]);
91
+ expect(buf.getPixel(5, 3)).toEqual([0, 0, 0, 0]);
92
+ expect(buf.getPixel(2, 2)).toEqual([0, 0, 0, 0]);
93
+ expect(buf.getPixel(2, 5)).toEqual([0, 0, 0, 0]);
94
+ });
95
+
96
+ it('should clip fillRect to buffer bounds', () => {
97
+ const buf = new PixelBuffer(4, 4);
98
+ // Rect extends past right and bottom edges
99
+ buf.fillRect(2, 2, 10, 10, 0, 255, 0, 255);
100
+
101
+ expect(buf.getPixel(2, 2)).toEqual([0, 255, 0, 255]);
102
+ expect(buf.getPixel(3, 3)).toEqual([0, 255, 0, 255]);
103
+ expect(buf.getPixel(1, 1)).toEqual([0, 0, 0, 0]);
104
+ });
105
+
106
+ it('should clone into an independent copy', () => {
107
+ const buf = new PixelBuffer(4, 4);
108
+ buf.setPixel(1, 1, 255, 0, 0, 255);
109
+
110
+ const cloned = buf.clone();
111
+
112
+ // Same content
113
+ expect(cloned.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
114
+ expect(cloned.width).toBe(4);
115
+ expect(cloned.height).toBe(4);
116
+
117
+ // Modify original -- clone should be unaffected
118
+ buf.setPixel(1, 1, 0, 0, 0, 0);
119
+ expect(cloned.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
120
+
121
+ // Modify clone -- original should be unaffected
122
+ cloned.setPixel(2, 2, 0, 255, 0, 255);
123
+ expect(buf.getPixel(2, 2)).toEqual([0, 0, 0, 0]);
124
+ });
125
+
126
+ it('should extract a sub-region with getRegion', () => {
127
+ const buf = new PixelBuffer(8, 8);
128
+ buf.fillRect(2, 2, 4, 4, 100, 200, 50, 255);
129
+
130
+ const region = buf.getRegion(2, 2, 4, 4);
131
+
132
+ expect(region.width).toBe(4);
133
+ expect(region.height).toBe(4);
134
+
135
+ // Every pixel in the region should have the filled color
136
+ for (let y = 0; y < 4; y++) {
137
+ for (let x = 0; x < 4; x++) {
138
+ expect(region.getPixel(x, y)).toEqual([100, 200, 50, 255]);
139
+ }
140
+ }
141
+ });
142
+
143
+ it('should handle getRegion that extends beyond buffer bounds', () => {
144
+ const buf = new PixelBuffer(4, 4);
145
+ buf.fill(255, 128, 64, 255);
146
+
147
+ // Region extends past bottom-right
148
+ const region = buf.getRegion(2, 2, 4, 4);
149
+ expect(region.width).toBe(4);
150
+ expect(region.height).toBe(4);
151
+
152
+ // In-bounds portion has data
153
+ expect(region.getPixel(0, 0)).toEqual([255, 128, 64, 255]);
154
+ expect(region.getPixel(1, 1)).toEqual([255, 128, 64, 255]);
155
+
156
+ // Out-of-bounds portion is transparent
157
+ expect(region.getPixel(2, 2)).toEqual([0, 0, 0, 0]);
158
+ expect(region.getPixel(3, 3)).toEqual([0, 0, 0, 0]);
159
+ });
160
+
161
+ it('should paste with full alpha overwrite', () => {
162
+ const dst = new PixelBuffer(8, 8);
163
+ dst.fill(50, 50, 50, 255);
164
+
165
+ const src = new PixelBuffer(2, 2);
166
+ src.fill(255, 0, 0, 255);
167
+
168
+ dst.paste(src, 3, 3);
169
+
170
+ expect(dst.getPixel(3, 3)).toEqual([255, 0, 0, 255]);
171
+ expect(dst.getPixel(4, 4)).toEqual([255, 0, 0, 255]);
172
+ // Unaffected pixels remain original
173
+ expect(dst.getPixel(2, 2)).toEqual([50, 50, 50, 255]);
174
+ });
175
+
176
+ it('should paste with alpha blending', () => {
177
+ const dst = new PixelBuffer(4, 4);
178
+ dst.fill(0, 0, 0, 255); // opaque black background
179
+
180
+ const src = new PixelBuffer(1, 1);
181
+ // 50% transparent red
182
+ src.setPixel(0, 0, 255, 0, 0, 128);
183
+
184
+ dst.paste(src, 1, 1);
185
+
186
+ const [r, g, b, a] = dst.getPixel(1, 1);
187
+ // With ~50% red over black, red channel should be roughly 128
188
+ expect(r).toBeGreaterThan(120);
189
+ expect(r).toBeLessThan(135);
190
+ expect(g).toBe(0);
191
+ expect(b).toBe(0);
192
+ expect(a).toBe(255);
193
+ });
194
+
195
+ it('should skip fully transparent source pixels during paste', () => {
196
+ const dst = new PixelBuffer(4, 4);
197
+ dst.fill(100, 100, 100, 255);
198
+
199
+ const src = new PixelBuffer(2, 2);
200
+ // Only top-left pixel is opaque; rest are transparent
201
+ src.setPixel(0, 0, 255, 0, 0, 255);
202
+
203
+ dst.paste(src, 1, 1);
204
+
205
+ expect(dst.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
206
+ // Transparent source pixels should not overwrite destination
207
+ expect(dst.getPixel(2, 1)).toEqual([100, 100, 100, 255]);
208
+ });
209
+
210
+ describe('inBounds', () => {
211
+ const buf = new PixelBuffer(10, 5);
212
+
213
+ it('should return true for valid coordinates', () => {
214
+ expect(buf.inBounds(0, 0)).toBe(true);
215
+ expect(buf.inBounds(9, 4)).toBe(true);
216
+ expect(buf.inBounds(5, 2)).toBe(true);
217
+ });
218
+
219
+ it('should return false for coordinates outside bounds', () => {
220
+ expect(buf.inBounds(-1, 0)).toBe(false);
221
+ expect(buf.inBounds(0, -1)).toBe(false);
222
+ expect(buf.inBounds(10, 0)).toBe(false);
223
+ expect(buf.inBounds(0, 5)).toBe(false);
224
+ expect(buf.inBounds(10, 5)).toBe(false);
225
+ });
226
+ });
227
+
228
+ it('should create correct ImageData via toImageData', () => {
229
+ const buf = new PixelBuffer(2, 2);
230
+ buf.setPixel(0, 0, 255, 0, 0, 255);
231
+ buf.setPixel(1, 1, 0, 255, 0, 255);
232
+
233
+ const imgData = buf.toImageData();
234
+
235
+ expect(imgData.width).toBe(2);
236
+ expect(imgData.height).toBe(2);
237
+ expect(imgData.data.length).toBe(2 * 2 * 4);
238
+
239
+ // Pixel (0,0) = red
240
+ expect(imgData.data[0]).toBe(255);
241
+ expect(imgData.data[1]).toBe(0);
242
+ expect(imgData.data[2]).toBe(0);
243
+ expect(imgData.data[3]).toBe(255);
244
+
245
+ // toImageData returns a copy, not a reference
246
+ imgData.data[0] = 0;
247
+ expect(buf.getPixel(0, 0)[0]).toBe(255);
248
+ });
249
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * PixelBuffer -- manages raw RGBA pixel data for a canvas.
3
+ *
4
+ * Backed by a Uint8ClampedArray of size width * height * 4.
5
+ * Each pixel is 4 consecutive bytes: [R, G, B, A].
6
+ * Coordinate (0,0) is top-left.
7
+ */
8
+ export class PixelBuffer {
9
+ readonly width: number;
10
+ readonly height: number;
11
+ readonly data: Uint8ClampedArray;
12
+
13
+ constructor(width: number, height: number, data?: Uint8ClampedArray) {
14
+ this.width = width;
15
+ this.height = height;
16
+ this.data = data ?? new Uint8ClampedArray(width * height * 4);
17
+ }
18
+
19
+ /** Check if (x, y) is within the buffer bounds. */
20
+ inBounds(x: number, y: number): boolean {
21
+ return x >= 0 && x < this.width && y >= 0 && y < this.height;
22
+ }
23
+
24
+ /** Get the byte offset for a pixel at (x, y). */
25
+ private offset(x: number, y: number): number {
26
+ return (y * this.width + x) * 4;
27
+ }
28
+
29
+ /** Read the RGBA values at (x, y). Returns [0,0,0,0] if out of bounds. */
30
+ getPixel(x: number, y: number): [r: number, g: number, b: number, a: number] {
31
+ if (!this.inBounds(x, y)) return [0, 0, 0, 0];
32
+ const i = this.offset(x, y);
33
+ // Typed-array indexing is technically `number | undefined` under
34
+ // noUncheckedIndexedAccess; the `?? 0` fallback is never taken in practice.
35
+ return [this.data[i] ?? 0, this.data[i + 1] ?? 0, this.data[i + 2] ?? 0, this.data[i + 3] ?? 0];
36
+ }
37
+
38
+ /** Write RGBA values at (x, y). No-op if out of bounds. */
39
+ setPixel(x: number, y: number, r: number, g: number, b: number, a: number): void {
40
+ if (!this.inBounds(x, y)) return;
41
+ const i = this.offset(x, y);
42
+ this.data[i] = r;
43
+ this.data[i + 1] = g;
44
+ this.data[i + 2] = b;
45
+ this.data[i + 3] = a;
46
+ }
47
+
48
+ /** Fill the entire buffer with a single color. */
49
+ fill(r: number, g: number, b: number, a: number): void {
50
+ for (let i = 0; i < this.data.length; i += 4) {
51
+ this.data[i] = r;
52
+ this.data[i + 1] = g;
53
+ this.data[i + 2] = b;
54
+ this.data[i + 3] = a;
55
+ }
56
+ }
57
+
58
+ /** Fill a rectangular region with a single color. Clips to bounds. */
59
+ fillRect(x: number, y: number, w: number, h: number, r: number, g: number, b: number, a: number): void {
60
+ const x0 = Math.max(0, x);
61
+ const y0 = Math.max(0, y);
62
+ const x1 = Math.min(this.width, x + w);
63
+ const y1 = Math.min(this.height, y + h);
64
+
65
+ for (let py = y0; py < y1; py++) {
66
+ for (let px = x0; px < x1; px++) {
67
+ const i = this.offset(px, py);
68
+ this.data[i] = r;
69
+ this.data[i + 1] = g;
70
+ this.data[i + 2] = b;
71
+ this.data[i + 3] = a;
72
+ }
73
+ }
74
+ }
75
+
76
+ /** Create an independent copy of this buffer. */
77
+ clone(): PixelBuffer {
78
+ return new PixelBuffer(this.width, this.height, new Uint8ClampedArray(this.data));
79
+ }
80
+
81
+ /** Extract a rectangular sub-region as a new PixelBuffer. Clips to bounds. */
82
+ getRegion(x: number, y: number, w: number, h: number): PixelBuffer {
83
+ const result = new PixelBuffer(w, h);
84
+ for (let dy = 0; dy < h; dy++) {
85
+ for (let dx = 0; dx < w; dx++) {
86
+ const sx = x + dx;
87
+ const sy = y + dy;
88
+ if (this.inBounds(sx, sy)) {
89
+ const srcOff = this.offset(sx, sy);
90
+ const dstOff = (dy * w + dx) * 4;
91
+ result.data[dstOff] = this.data[srcOff] ?? 0;
92
+ result.data[dstOff + 1] = this.data[srcOff + 1] ?? 0;
93
+ result.data[dstOff + 2] = this.data[srcOff + 2] ?? 0;
94
+ result.data[dstOff + 3] = this.data[srcOff + 3] ?? 0;
95
+ }
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * Paste another PixelBuffer onto this one at (destX, destY).
103
+ * Uses standard alpha blending: result = src * srcA + dst * (1 - srcA).
104
+ */
105
+ paste(source: PixelBuffer, destX: number, destY: number): void {
106
+ for (let sy = 0; sy < source.height; sy++) {
107
+ for (let sx = 0; sx < source.width; sx++) {
108
+ const dx = destX + sx;
109
+ const dy = destY + sy;
110
+ if (!this.inBounds(dx, dy)) continue;
111
+
112
+ const srcOff = (sy * source.width + sx) * 4;
113
+ const dstOff = this.offset(dx, dy);
114
+
115
+ // Typed-array indexing is `number | undefined` under
116
+ // noUncheckedIndexedAccess; `?? 0` fallbacks never trigger in practice.
117
+ const sr = source.data[srcOff] ?? 0;
118
+ const sg = source.data[srcOff + 1] ?? 0;
119
+ const sb = source.data[srcOff + 2] ?? 0;
120
+ const srcA = (source.data[srcOff + 3] ?? 0) / 255;
121
+ if (srcA === 0) continue;
122
+
123
+ if (srcA === 1) {
124
+ // Fully opaque: overwrite
125
+ this.data[dstOff] = sr;
126
+ this.data[dstOff + 1] = sg;
127
+ this.data[dstOff + 2] = sb;
128
+ this.data[dstOff + 3] = source.data[srcOff + 3] ?? 0;
129
+ } else {
130
+ // Alpha blend
131
+ const dr = this.data[dstOff] ?? 0;
132
+ const dg = this.data[dstOff + 1] ?? 0;
133
+ const db = this.data[dstOff + 2] ?? 0;
134
+ const dstA = (this.data[dstOff + 3] ?? 0) / 255;
135
+ const outA = srcA + dstA * (1 - srcA);
136
+ if (outA === 0) continue;
137
+
138
+ this.data[dstOff] = Math.round((sr * srcA + dr * dstA * (1 - srcA)) / outA);
139
+ this.data[dstOff + 1] = Math.round((sg * srcA + dg * dstA * (1 - srcA)) / outA);
140
+ this.data[dstOff + 2] = Math.round((sb * srcA + db * dstA * (1 - srcA)) / outA);
141
+ this.data[dstOff + 3] = Math.round(outA * 255);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ /** Create an ImageData suitable for canvas rendering. */
148
+ toImageData(): ImageData {
149
+ return new ImageData(new Uint8ClampedArray(this.data), this.width, this.height);
150
+ }
151
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Render State -- shared reactive buffer that CanvasViewport reads from.
3
+ *
4
+ * Holds the current composited PixelBuffer (the final result of flattening
5
+ * all visible layers). Updated by the canvas-init plugin after each command.
6
+ */
7
+
8
+ import type { PixelBuffer } from './pixel-buffer';
9
+
10
+ let buffer = $state<PixelBuffer | null>(null);
11
+
12
+ export function getRenderBuffer(): PixelBuffer | null {
13
+ return buffer;
14
+ }
15
+
16
+ export function setRenderBuffer(b: PixelBuffer | null): void {
17
+ buffer = b;
18
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shape Preview State -- reactive singleton for live rubber-band previews.
3
+ *
4
+ * Shape tools (rect, ellipse, diamond, line) set the preview during drag;
5
+ * CanvasViewport reads it each frame and draws a visual overlay on the
6
+ * 2D context after the main render pass.
7
+ *
8
+ * All coordinates are in canvas pixel space (integer pixel indices).
9
+ */
10
+
11
+ export interface ShapePreview {
12
+ type: 'rect' | 'ellipse' | 'diamond' | 'line';
13
+ startX: number;
14
+ startY: number;
15
+ endX: number;
16
+ endY: number;
17
+ color: string; // hex color from the active foreground
18
+ filled: boolean;
19
+ }
20
+
21
+ let preview = $state<ShapePreview | null>(null);
22
+
23
+ /** Read the current preview (null when no drag is in progress). */
24
+ export function getShapePreview(): ShapePreview | null {
25
+ return preview;
26
+ }
27
+
28
+ /** Set or update the preview during a shape drag. */
29
+ export function setShapePreview(p: ShapePreview): void {
30
+ preview = p;
31
+ }
32
+
33
+ /** Clear the preview (call on pointer-up after dispatching the draw command). */
34
+ export function clearShapePreview(): void {
35
+ preview = null;
36
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tests for the tile/wrap mode coordinate wrapping logic.
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { wrapCoordinate } from './tile-mode.js';
7
+
8
+ describe('wrapCoordinate', () => {
9
+ const W = 8;
10
+ const H = 8;
11
+
12
+ it('returns coordinates unchanged when already in bounds', () => {
13
+ expect(wrapCoordinate(0, 0, W, H)).toEqual({ x: 0, y: 0 });
14
+ expect(wrapCoordinate(3, 5, W, H)).toEqual({ x: 3, y: 5 });
15
+ expect(wrapCoordinate(7, 7, W, H)).toEqual({ x: 7, y: 7 });
16
+ });
17
+
18
+ it('wraps positive overflow', () => {
19
+ expect(wrapCoordinate(8, 0, W, H)).toEqual({ x: 0, y: 0 });
20
+ expect(wrapCoordinate(9, 0, W, H)).toEqual({ x: 1, y: 0 });
21
+ expect(wrapCoordinate(0, 8, W, H)).toEqual({ x: 0, y: 0 });
22
+ expect(wrapCoordinate(0, 10, W, H)).toEqual({ x: 0, y: 2 });
23
+ });
24
+
25
+ it('wraps negative coordinates', () => {
26
+ expect(wrapCoordinate(-1, 0, W, H)).toEqual({ x: 7, y: 0 });
27
+ expect(wrapCoordinate(0, -1, W, H)).toEqual({ x: 0, y: 7 });
28
+ expect(wrapCoordinate(-1, -1, W, H)).toEqual({ x: 7, y: 7 });
29
+ expect(wrapCoordinate(-8, 0, W, H)).toEqual({ x: 0, y: 0 });
30
+ });
31
+
32
+ it('wraps large negative offsets', () => {
33
+ expect(wrapCoordinate(-9, -17, W, H)).toEqual({ x: 7, y: 7 });
34
+ expect(wrapCoordinate(-16, 0, W, H)).toEqual({ x: 0, y: 0 });
35
+ });
36
+
37
+ it('wraps large positive offsets', () => {
38
+ expect(wrapCoordinate(16, 24, W, H)).toEqual({ x: 0, y: 0 });
39
+ expect(wrapCoordinate(19, 27, W, H)).toEqual({ x: 3, y: 3 });
40
+ });
41
+
42
+ it('handles non-square dimensions', () => {
43
+ expect(wrapCoordinate(-1, -1, 4, 6)).toEqual({ x: 3, y: 5 });
44
+ expect(wrapCoordinate(5, 7, 4, 6)).toEqual({ x: 1, y: 1 });
45
+ });
46
+
47
+ it('wraps edge values (exactly at boundary)', () => {
48
+ // x = width should wrap to 0
49
+ expect(wrapCoordinate(W, 0, W, H)).toEqual({ x: 0, y: 0 });
50
+ // y = height should wrap to 0
51
+ expect(wrapCoordinate(0, H, W, H)).toEqual({ x: 0, y: 0 });
52
+ });
53
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tile/Wrap Mode -- provides coordinate wrapping and a 3x3 tiled preview
3
+ * for seamless sprite/texture creation.
4
+ *
5
+ * When tile mode is active, drawing coordinates that go out of bounds wrap
6
+ * around to the opposite edge. The renderer shows the main canvas tiled
7
+ * in a 3x3 grid so the user can see seam continuity.
8
+ */
9
+
10
+ import type { PixelBuffer } from './pixel-buffer.js';
11
+
12
+ // --- Coordinate wrapping ---
13
+
14
+ /**
15
+ * Wrap a coordinate pair so it falls within [0, width) x [0, height).
16
+ * Handles negative values and overflow.
17
+ *
18
+ * Uses true modulo (not JS remainder) so that negative inputs wrap correctly:
19
+ * wrapCoordinate(-1, 0, 8, 8) => { x: 7, y: 0 }
20
+ */
21
+ export function wrapCoordinate(
22
+ x: number,
23
+ y: number,
24
+ width: number,
25
+ height: number,
26
+ ): { x: number; y: number } {
27
+ return {
28
+ x: ((x % width) + width) % width,
29
+ y: ((y % height) + height) % height,
30
+ };
31
+ }
32
+
33
+ // --- Tile preview renderer ---
34
+
35
+ /**
36
+ * Render the 8 surrounding ghost tiles for the tiled preview.
37
+ *
38
+ * The center tile is already drawn by the main renderCanvas() call, so this
39
+ * function only draws the surrounding 8 tiles at reduced opacity so the user
40
+ * can distinguish the primary canvas from its repeats.
41
+ *
42
+ * Expects an offscreen canvas or ImageBitmap-based approach to be set up
43
+ * by the caller for the pixel data. This function uses putImageData + drawImage
44
+ * to avoid creating its own offscreen resources (keeps it a pure render helper).
45
+ *
46
+ * @param ctx - Destination 2D context.
47
+ * @param buffer - The pixel buffer to tile.
48
+ * @param zoom - Zoom factor.
49
+ * @param panX - Horizontal pan offset in screen pixels.
50
+ * @param panY - Vertical pan offset in screen pixels.
51
+ */
52
+ export function renderTilePreview(
53
+ ctx: CanvasRenderingContext2D,
54
+ buffer: PixelBuffer,
55
+ zoom: number,
56
+ panX: number,
57
+ panY: number,
58
+ ): void {
59
+ const tileW = buffer.width * zoom;
60
+ const tileH = buffer.height * zoom;
61
+
62
+ // Build a temporary canvas with the pixel data at 1:1, then scale it.
63
+ // We create a small temporary canvas here to keep this function self-contained.
64
+ const tmp = new OffscreenCanvas(buffer.width, buffer.height);
65
+ const tmpCtx = tmp.getContext('2d');
66
+ if (!tmpCtx) return;
67
+ tmpCtx.putImageData(buffer.toImageData(), 0, 0);
68
+
69
+ ctx.imageSmoothingEnabled = false;
70
+
71
+ // Draw the 8 surrounding ghost tiles. The center tile (dx===0, dy===0)
72
+ // is already rendered by the main renderCanvas() call, so we skip it to
73
+ // avoid double-drawing (which makes semi-transparent pixels too opaque).
74
+ ctx.globalAlpha = 0.3;
75
+ for (let dy = -1; dy <= 1; dy++) {
76
+ for (let dx = -1; dx <= 1; dx++) {
77
+ if (dx === 0 && dy === 0) continue;
78
+
79
+ ctx.drawImage(
80
+ tmp,
81
+ 0, 0, buffer.width, buffer.height,
82
+ panX + dx * tileW,
83
+ panY + dy * tileH,
84
+ tileW,
85
+ tileH,
86
+ );
87
+ }
88
+ }
89
+
90
+ // Reset alpha
91
+ ctx.globalAlpha = 1.0;
92
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Viewport Utilities -- DOM helpers for locating and measuring the canvas
3
+ * viewport element. Used by view commands that need to pivot zoom around
4
+ * the viewport center.
5
+ */
6
+
7
+ /**
8
+ * Get the center of the canvas viewport element in local element coordinates.
9
+ * Returns [0, 0] if the viewport element isn't mounted yet (e.g. during
10
+ * early command dispatch before Svelte has mounted the canvas).
11
+ */
12
+ export function viewportCenter(): [number, number] {
13
+ const el = document.querySelector<HTMLElement>('.canvas-viewport');
14
+ if (!el) return [0, 0];
15
+ return [el.clientWidth / 2, el.clientHeight / 2];
16
+ }
17
+
18
+ /**
19
+ * Get the canvas viewport element itself, or null if it isn't mounted yet.
20
+ * Used by fit_to_view / center_canvas which need the bounding rect.
21
+ */
22
+ export function getViewportElement(): HTMLElement | null {
23
+ return document.querySelector<HTMLElement>('.canvas-viewport');
24
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Zoom Utilities -- shared zoom step logic used by both the scroll-wheel
3
+ * zoom in input-handler.ts and the menu-driven zoom commands in view-commands.ts.
4
+ *
5
+ * Keeping these in one place ensures both paths step through the same
6
+ * discrete zoom levels and stay in sync.
7
+ */
8
+
9
+ /** Predefined zoom steps, from smallest to largest. */
10
+ export const ZOOM_STEPS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32] as const;
11
+
12
+ /** Default zoom level used by "Reset Zoom". */
13
+ export const DEFAULT_ZOOM = 8;
14
+
15
+ /** Find the next zoom step strictly greater than current. Clamped to max. */
16
+ export function zoomStepUp(current: number): number {
17
+ for (const step of ZOOM_STEPS) {
18
+ if (step > current) return step;
19
+ }
20
+ // ZOOM_STEPS has fixed length > 0, so this is never undefined.
21
+ return ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? DEFAULT_ZOOM;
22
+ }
23
+
24
+ /** Find the next zoom step strictly less than current. Clamped to min. */
25
+ export function zoomStepDown(current: number): number {
26
+ for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
27
+ const step = ZOOM_STEPS[i];
28
+ if (step !== undefined && step < current) return step;
29
+ }
30
+ return ZOOM_STEPS[0];
31
+ }
File without changes