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,139 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { canvasState, MAX_CANVAS_SIZE } from './canvas-state.svelte.js';
3
+
4
+ describe('Canvas State', () => {
5
+ describe('screenToCanvas / canvasToScreen roundtrip', () => {
6
+ it('should roundtrip at zoom 1 with no pan', () => {
7
+ canvasState.zoom = 1;
8
+ canvasState.panX = 0;
9
+ canvasState.panY = 0;
10
+
11
+ const screen = canvasState.canvasToScreen(5, 10);
12
+ const canvas = canvasState.screenToCanvas(screen.x, screen.y);
13
+
14
+ expect(canvas.x).toBeCloseTo(5, 5);
15
+ expect(canvas.y).toBeCloseTo(10, 5);
16
+ });
17
+
18
+ it('should roundtrip at zoom 8 with no pan', () => {
19
+ canvasState.zoom = 8;
20
+ canvasState.panX = 0;
21
+ canvasState.panY = 0;
22
+
23
+ const screen = canvasState.canvasToScreen(3, 7);
24
+ expect(screen.x).toBe(24); // 3 * 8
25
+ expect(screen.y).toBe(56); // 7 * 8
26
+
27
+ const canvas = canvasState.screenToCanvas(24, 56);
28
+ expect(canvas.x).toBeCloseTo(3, 5);
29
+ expect(canvas.y).toBeCloseTo(7, 5);
30
+ });
31
+
32
+ it('should roundtrip at zoom 16 with pan offset', () => {
33
+ canvasState.zoom = 16;
34
+ canvasState.panX = 100;
35
+ canvasState.panY = 50;
36
+
37
+ const screen = canvasState.canvasToScreen(2, 4);
38
+ expect(screen.x).toBe(2 * 16 + 100); // 132
39
+ expect(screen.y).toBe(4 * 16 + 50); // 114
40
+
41
+ const canvas = canvasState.screenToCanvas(132, 114);
42
+ expect(canvas.x).toBeCloseTo(2, 5);
43
+ expect(canvas.y).toBeCloseTo(4, 5);
44
+ });
45
+
46
+ it('should handle fractional screen coordinates', () => {
47
+ canvasState.zoom = 4;
48
+ canvasState.panX = 10;
49
+ canvasState.panY = 10;
50
+
51
+ // Screen position in the middle of a pixel
52
+ const canvas = canvasState.screenToCanvas(12, 12);
53
+ expect(canvas.x).toBeCloseTo(0.5, 5);
54
+ expect(canvas.y).toBeCloseTo(0.5, 5);
55
+ });
56
+
57
+ it('should handle negative pan (canvas scrolled left/up)', () => {
58
+ canvasState.zoom = 8;
59
+ canvasState.panX = -50;
60
+ canvasState.panY = -30;
61
+
62
+ const screen = canvasState.canvasToScreen(0, 0);
63
+ expect(screen.x).toBe(-50);
64
+ expect(screen.y).toBe(-30);
65
+
66
+ const canvas = canvasState.screenToCanvas(-50, -30);
67
+ expect(canvas.x).toBeCloseTo(0, 5);
68
+ expect(canvas.y).toBeCloseTo(0, 5);
69
+ });
70
+ });
71
+
72
+ describe('zoom and dimension bounds', () => {
73
+ it(`should clamp canvasWidth to 1-${String(MAX_CANVAS_SIZE)}`, () => {
74
+ canvasState.canvasWidth = 0;
75
+ expect(canvasState.canvasWidth).toBe(1);
76
+
77
+ canvasState.canvasWidth = MAX_CANVAS_SIZE + 1000;
78
+ expect(canvasState.canvasWidth).toBe(MAX_CANVAS_SIZE);
79
+
80
+ canvasState.canvasWidth = 64;
81
+ expect(canvasState.canvasWidth).toBe(64);
82
+ });
83
+
84
+ it(`should clamp canvasHeight to 1-${String(MAX_CANVAS_SIZE)}`, () => {
85
+ canvasState.canvasHeight = -5;
86
+ expect(canvasState.canvasHeight).toBe(1);
87
+
88
+ canvasState.canvasHeight = MAX_CANVAS_SIZE + 1000;
89
+ expect(canvasState.canvasHeight).toBe(MAX_CANVAS_SIZE);
90
+
91
+ canvasState.canvasHeight = 128;
92
+ expect(canvasState.canvasHeight).toBe(128);
93
+ });
94
+
95
+ it('should store zoom value directly', () => {
96
+ canvasState.zoom = 4;
97
+ expect(canvasState.zoom).toBe(4);
98
+
99
+ canvasState.zoom = 32;
100
+ expect(canvasState.zoom).toBe(32);
101
+ });
102
+
103
+ it('should store pan values directly', () => {
104
+ canvasState.panX = -123;
105
+ canvasState.panY = 456;
106
+ expect(canvasState.panX).toBe(-123);
107
+ expect(canvasState.panY).toBe(456);
108
+ });
109
+ });
110
+
111
+ describe('cursor state', () => {
112
+ it('should store cursor coordinates', () => {
113
+ canvasState.cursorX = 15;
114
+ canvasState.cursorY = 20;
115
+ expect(canvasState.cursorX).toBe(15);
116
+ expect(canvasState.cursorY).toBe(20);
117
+ });
118
+
119
+ it('should store cursorInBounds', () => {
120
+ canvasState.cursorInBounds = true;
121
+ expect(canvasState.cursorInBounds).toBe(true);
122
+
123
+ canvasState.cursorInBounds = false;
124
+ expect(canvasState.cursorInBounds).toBe(false);
125
+ });
126
+ });
127
+
128
+ // Reset state after tests to avoid leaking between test files
129
+ it('should reset to defaults', () => {
130
+ canvasState.canvasWidth = 32;
131
+ canvasState.canvasHeight = 32;
132
+ canvasState.zoom = 8;
133
+ canvasState.panX = 0;
134
+ canvasState.panY = 0;
135
+ canvasState.cursorX = 0;
136
+ canvasState.cursorY = 0;
137
+ canvasState.cursorInBounds = false;
138
+ });
139
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Input Handler -- translates pointer/keyboard events on the canvas
3
+ * element into canvas-space coordinates and viewport actions.
4
+ *
5
+ * Handles:
6
+ * - Zoom: scroll wheel (centered on cursor position)
7
+ * - Pan: middle-click drag OR spacebar + left-click drag
8
+ * - Pointer tracking: screen -> canvas coordinate conversion
9
+ * - Pressure: captures pressure from Pointer Events API (tablets)
10
+ */
11
+
12
+ import { canvasState } from './canvas-state.svelte.js';
13
+ import { zoomStepUp, zoomStepDown } from './zoom-utils.js';
14
+
15
+ // --- Tool callback hooks ---
16
+ // Set by input-plugin.ts to route pointer events to the active tool.
17
+
18
+ type ToolEventCallback = (e: PointerEvent) => void;
19
+
20
+ let toolDown: ToolEventCallback | null = null;
21
+ let toolMove: ToolEventCallback | null = null;
22
+ let toolUp: ToolEventCallback | null = null;
23
+
24
+ export function setToolCallbacks(callbacks: {
25
+ onPointerDown?: ToolEventCallback;
26
+ onPointerMove?: ToolEventCallback;
27
+ onPointerUp?: ToolEventCallback;
28
+ }): void {
29
+ toolDown = callbacks.onPointerDown ?? null;
30
+ toolMove = callbacks.onPointerMove ?? null;
31
+ toolUp = callbacks.onPointerUp ?? null;
32
+ }
33
+
34
+ // --- Input state ---
35
+ // These are plain module-level variables. `export let` was previously used
36
+ // here, but that syntax belongs to Svelte 4 component scripts -- in a plain
37
+ // .ts module it only creates a named export that can't be reassigned by
38
+ // importers, which is misleading. isDrawing/isPanning are internal-only;
39
+ // pressure is exposed via getPressure() for imperative reads from input-plugin.
40
+
41
+ let isDrawing = false;
42
+ let isPanning = false;
43
+ let pressure = 0;
44
+
45
+ /** Current pointer pressure (0-1). Read imperatively inside event handlers. */
46
+ export function getPressure(): number {
47
+ return pressure;
48
+ }
49
+
50
+ /** True when the user is mid-stroke or panning (used to suppress shortcuts like Space). */
51
+ export function isInteracting(): boolean {
52
+ return isDrawing || isPanning;
53
+ }
54
+
55
+ // --- Internal state ---
56
+
57
+ let spaceHeld = false;
58
+ let panStartX = 0;
59
+ let panStartY = 0;
60
+ let panStartPanX = 0;
61
+ let panStartPanY = 0;
62
+ let boundElement: HTMLCanvasElement | null = null;
63
+
64
+ // --- Event handlers ---
65
+
66
+ function onPointerDown(e: PointerEvent): void {
67
+ if (!boundElement) return;
68
+ boundElement.setPointerCapture(e.pointerId);
69
+
70
+ if (e.button === 1 || (e.button === 0 && spaceHeld)) {
71
+ // Middle-click or space+left-click: start panning
72
+ isPanning = true;
73
+ panStartX = e.clientX;
74
+ panStartY = e.clientY;
75
+ panStartPanX = canvasState.panX;
76
+ panStartPanY = canvasState.panY;
77
+ e.preventDefault();
78
+ return;
79
+ }
80
+
81
+ if (e.button === 0) {
82
+ isDrawing = true;
83
+ pressure = e.pressure;
84
+ updateCursor(e);
85
+ toolDown?.(e);
86
+ }
87
+ }
88
+
89
+ function onPointerMove(e: PointerEvent): void {
90
+ if (isPanning) {
91
+ canvasState.panX = panStartPanX + (e.clientX - panStartX);
92
+ canvasState.panY = panStartPanY + (e.clientY - panStartY);
93
+ return;
94
+ }
95
+
96
+ pressure = e.pressure;
97
+ updateCursor(e);
98
+
99
+ // Dispatch to the active tool only while drawing (not just hovering)
100
+ if (isDrawing) {
101
+ toolMove?.(e);
102
+ }
103
+ }
104
+
105
+ function onPointerUp(e: PointerEvent): void {
106
+ if (boundElement) {
107
+ boundElement.releasePointerCapture(e.pointerId);
108
+ }
109
+
110
+ if (isPanning) {
111
+ isPanning = false;
112
+ return;
113
+ }
114
+
115
+ // Notify the tool before clearing isDrawing, so it knows the stroke is ending.
116
+ // Update cursor first so the tool gets the final pointer position.
117
+ if (isDrawing) {
118
+ updateCursor(e);
119
+ toolUp?.(e);
120
+ }
121
+
122
+ isDrawing = false;
123
+ pressure = 0;
124
+ }
125
+
126
+ function onPointerLeave(): void {
127
+ canvasState.cursorInBounds = false;
128
+ }
129
+
130
+ function onWheel(e: WheelEvent): void {
131
+ e.preventDefault();
132
+
133
+ // Get screen position of cursor relative to the canvas element
134
+ if (!boundElement) return;
135
+ const rect = boundElement.getBoundingClientRect();
136
+ const screenX = e.clientX - rect.left;
137
+ const screenY = e.clientY - rect.top;
138
+
139
+ // Step zoom (use current zoom for step calculation)
140
+ const oldZoom = canvasState.zoom;
141
+ const newZoom = e.deltaY < 0 ? zoomStepUp(oldZoom) : zoomStepDown(oldZoom);
142
+
143
+ // Smoothly animate toward the new zoom, keeping the cursor as pivot
144
+ canvasState.setZoomAnimated(newZoom, screenX, screenY);
145
+ }
146
+
147
+ function onKeyDown(e: KeyboardEvent): void {
148
+ if (e.code === 'Space' && !e.repeat) {
149
+ spaceHeld = true;
150
+ e.preventDefault();
151
+ }
152
+ }
153
+
154
+ function onKeyUp(e: KeyboardEvent): void {
155
+ if (e.code === 'Space') {
156
+ spaceHeld = false;
157
+ // If we were panning via space, stop
158
+ if (isPanning) {
159
+ isPanning = false;
160
+ }
161
+ }
162
+ }
163
+
164
+ /** Update cursor position from a pointer event. */
165
+ function updateCursor(e: PointerEvent): void {
166
+ if (!boundElement) return;
167
+ const rect = boundElement.getBoundingClientRect();
168
+ const screenX = e.clientX - rect.left;
169
+ const screenY = e.clientY - rect.top;
170
+
171
+ const { x, y } = canvasState.screenToCanvas(screenX, screenY);
172
+ const px = Math.floor(x);
173
+ const py = Math.floor(y);
174
+
175
+ canvasState.cursorX = px;
176
+ canvasState.cursorY = py;
177
+ // In tile mode coordinates wrap, so the cursor is always "in bounds"
178
+ canvasState.cursorInBounds =
179
+ canvasState.tileMode ||
180
+ (px >= 0 && px < canvasState.canvasWidth &&
181
+ py >= 0 && py < canvasState.canvasHeight);
182
+ }
183
+
184
+ // --- Context menu suppression (prevent right-click menu on canvas) ---
185
+
186
+ function onContextMenu(e: Event): void {
187
+ e.preventDefault();
188
+ }
189
+
190
+ // --- Bind / Unbind ---
191
+
192
+ /**
193
+ * Attach all input event listeners to a canvas element.
194
+ * Returns an unbind function that removes all listeners.
195
+ */
196
+ export function bindInputHandler(element: HTMLCanvasElement): () => void {
197
+ boundElement = element;
198
+
199
+ element.addEventListener('pointerdown', onPointerDown);
200
+ element.addEventListener('pointermove', onPointerMove);
201
+ element.addEventListener('pointerup', onPointerUp);
202
+ element.addEventListener('pointerleave', onPointerLeave);
203
+ element.addEventListener('wheel', onWheel, { passive: false });
204
+ element.addEventListener('contextmenu', onContextMenu);
205
+
206
+ // Keyboard events need to be on window/document since canvas might not have focus
207
+ window.addEventListener('keydown', onKeyDown);
208
+ window.addEventListener('keyup', onKeyUp);
209
+
210
+ return () => {
211
+ element.removeEventListener('pointerdown', onPointerDown);
212
+ element.removeEventListener('pointermove', onPointerMove);
213
+ element.removeEventListener('pointerup', onPointerUp);
214
+ element.removeEventListener('pointerleave', onPointerLeave);
215
+ element.removeEventListener('wheel', onWheel);
216
+ element.removeEventListener('contextmenu', onContextMenu);
217
+ window.removeEventListener('keydown', onKeyDown);
218
+ window.removeEventListener('keyup', onKeyUp);
219
+ boundElement = null;
220
+ };
221
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Input Plugin -- routes pointer events from input-handler to the active tool.
3
+ *
4
+ * Bridges the gap between raw pointer input (input-handler.ts) and tool
5
+ * definitions (ToolDefinition.onPointerDown/Move/Up). Constructs a
6
+ * ToolContext with canvas coordinates, pressure, and foreground color.
7
+ *
8
+ * Locked-layer guard: before dispatching to a tool, checks whether the
9
+ * active layer is locked. If it is, the event is swallowed and a one-shot
10
+ * notification is shown. The notification flag resets when the active layer
11
+ * changes, when the lock state changes, or on a new pointer-down.
12
+ */
13
+
14
+ import type { PluginModule } from '../core/plugin-loader.js';
15
+ import type { ToolContext } from '../core/plugin-types.js';
16
+ import { toolRegistry } from '../core/registries.svelte.js';
17
+ import { getActiveTool } from '../shortcuts/shortcut-state.svelte.js';
18
+ import { canvasState } from './canvas-state.svelte.js';
19
+ import { wrapCoordinate } from './tile-mode.js';
20
+ import { getForegroundColor, setForegroundColor } from '../color/color-state.svelte.js';
21
+ import { getActiveLayer } from '../layers/layer-tree.svelte.js';
22
+ import { getRenderBuffer } from './render-state.svelte.js';
23
+ import { rgbToHex } from '../color/color-utils.js';
24
+ // getPressure() reads the current pointer pressure imperatively -- makeContext()
25
+ // is only called inside event handlers, so a plain getter is sufficient.
26
+ import { getPressure, setToolCallbacks } from './input-handler.js';
27
+
28
+ /** Stable ID reused for the locked-layer notification so it replaces rather than stacks. */
29
+ const LOCKED_NOTIFICATION_ID = 'input-plugin:layer-locked';
30
+
31
+ export const inputPlugin: PluginModule = {
32
+ name: 'builtin/input',
33
+ version: '1.0.0',
34
+ description: 'Routes pointer events to the active tool',
35
+
36
+ register(api) {
37
+ // --- Locked-layer one-shot notification state ---
38
+ // Tracks which layer+locked combo was last notified so we don't spam
39
+ // the same message on every pointer event. Resets when the layer or
40
+ // its lock state changes, or on a fresh pointer-down.
41
+ let notifiedLayerId: string | null = null;
42
+ let notifiedLocked = false;
43
+
44
+ /**
45
+ * Check if the active layer is locked. If it is, fire a one-shot
46
+ * notification (unless we already notified for this exact layer+locked
47
+ * state) and return true (= block the event). If the layer/lock
48
+ * context changed since last notification, reset the flag.
49
+ *
50
+ * @param resetFlag Pass true on pointer-down to allow re-notification
51
+ * for a new stroke even if the layer hasn't changed.
52
+ */
53
+ function isActiveLayerLocked(resetFlag: boolean): boolean {
54
+ const layer = getActiveLayer();
55
+ if (!layer) return false;
56
+
57
+ // Detect context change: different layer or lock state flipped
58
+ const contextChanged =
59
+ layer.id !== notifiedLayerId || layer.locked !== notifiedLocked;
60
+
61
+ if (contextChanged || resetFlag) {
62
+ notifiedLayerId = layer.id;
63
+ notifiedLocked = layer.locked;
64
+ // Context changed -- allow a fresh notification if still locked
65
+ if (!layer.locked) return false;
66
+ // Layer is locked and we haven't notified for this context yet
67
+ api.notify({
68
+ id: LOCKED_NOTIFICATION_ID,
69
+ message: 'Layer is locked',
70
+ type: 'warning',
71
+ autoDismissMs: 3000,
72
+ });
73
+ return true;
74
+ }
75
+
76
+ // Same context as before -- just silently block if locked
77
+ return layer.locked;
78
+ }
79
+
80
+ /** Build a ToolContext from the current state. */
81
+ function makeContext(): ToolContext {
82
+ let x = canvasState.cursorX;
83
+ let y = canvasState.cursorY;
84
+
85
+ // In tile mode, wrap coordinates so drawing seamlessly wraps around edges
86
+ if (canvasState.tileMode) {
87
+ const wrapped = wrapCoordinate(x, y, canvasState.canvasWidth, canvasState.canvasHeight);
88
+ x = wrapped.x;
89
+ y = wrapped.y;
90
+ }
91
+
92
+ return {
93
+ canvasX: x,
94
+ canvasY: y,
95
+ pressure: getPressure(),
96
+ color: getForegroundColor(),
97
+ api,
98
+ };
99
+ }
100
+
101
+ setToolCallbacks({
102
+ onPointerDown(e: PointerEvent) {
103
+ // Alt+click: quick eyedropper -- sample pixel color regardless of active tool
104
+ if (e.altKey) {
105
+ const buffer = getRenderBuffer();
106
+ if (!buffer) return;
107
+
108
+ let x = Math.floor(canvasState.cursorX);
109
+ let y = Math.floor(canvasState.cursorY);
110
+
111
+ if (canvasState.tileMode) {
112
+ const wrapped = wrapCoordinate(x, y, canvasState.canvasWidth, canvasState.canvasHeight);
113
+ x = wrapped.x;
114
+ y = wrapped.y;
115
+ }
116
+
117
+ if (!buffer.inBounds(x, y)) return;
118
+
119
+ const [r, g, b, a] = buffer.getPixel(x, y);
120
+ if (a === 0) return;
121
+
122
+ setForegroundColor(rgbToHex(r, g, b));
123
+ return;
124
+ }
125
+
126
+ if (isActiveLayerLocked(true)) return;
127
+ const tool = toolRegistry.get(getActiveTool());
128
+ if (tool?.onPointerDown) {
129
+ tool.onPointerDown(e, makeContext());
130
+ }
131
+ },
132
+
133
+ onPointerMove(e: PointerEvent) {
134
+ if (isActiveLayerLocked(false)) return;
135
+ const tool = toolRegistry.get(getActiveTool());
136
+ if (tool?.onPointerMove) {
137
+ tool.onPointerMove(e, makeContext());
138
+ }
139
+ },
140
+
141
+ onPointerUp(e: PointerEvent) {
142
+ if (isActiveLayerLocked(false)) return;
143
+ const tool = toolRegistry.get(getActiveTool());
144
+ if (tool?.onPointerUp) {
145
+ tool.onPointerUp(e, makeContext());
146
+ }
147
+ },
148
+ });
149
+ },
150
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Onion Skin Renderer -- draws semi-transparent tinted ghosts of adjacent
3
+ * animation frames behind the current frame.
4
+ *
5
+ * Previous frame: 25% opacity, red tint.
6
+ * Next frame: 25% opacity, blue tint.
7
+ *
8
+ * Uses source-atop compositing to tint only non-transparent pixels,
9
+ * then draws the result onto the main canvas at reduced opacity.
10
+ */
11
+
12
+ import type { PixelBuffer } from './pixel-buffer.js';
13
+
14
+ /** Tint color and opacity for a single onion ghost frame. */
15
+ interface OnionGhost {
16
+ buffer: PixelBuffer;
17
+ tintColor: string;
18
+ opacity: number;
19
+ }
20
+
21
+ /**
22
+ * Render a single onion ghost frame onto the destination context.
23
+ *
24
+ * 1. Draw the composited buffer to a temporary OffscreenCanvas at 1:1.
25
+ * 2. Apply a color tint via source-atop (only tints non-transparent pixels).
26
+ * 3. Draw the tinted result onto the main canvas at reduced globalAlpha.
27
+ */
28
+ function renderGhost(
29
+ ctx: CanvasRenderingContext2D,
30
+ ghost: OnionGhost,
31
+ zoom: number,
32
+ panX: number,
33
+ panY: number,
34
+ ): void {
35
+ const { width, height } = ghost.buffer;
36
+
37
+ // Build a 1:1 temporary canvas with the pixel data
38
+ const tmp = new OffscreenCanvas(width, height);
39
+ const tmpCtx = tmp.getContext('2d');
40
+ if (!tmpCtx) return;
41
+ tmpCtx.putImageData(ghost.buffer.toImageData(), 0, 0);
42
+
43
+ // Tint non-transparent pixels using source-atop: the fill only lands
44
+ // where existing alpha is non-zero, preserving transparency elsewhere.
45
+ tmpCtx.globalCompositeOperation = 'source-atop';
46
+ tmpCtx.fillStyle = ghost.tintColor;
47
+ tmpCtx.fillRect(0, 0, width, height);
48
+
49
+ // Draw the tinted ghost onto the main canvas at reduced opacity
50
+ ctx.save();
51
+ ctx.imageSmoothingEnabled = false;
52
+ ctx.globalAlpha = ghost.opacity;
53
+ ctx.drawImage(
54
+ tmp,
55
+ 0, 0, width, height,
56
+ panX, panY, width * zoom, height * zoom,
57
+ );
58
+ ctx.restore();
59
+ }
60
+
61
+ /**
62
+ * Render onion skin ghosts for the given adjacent-frame buffers.
63
+ *
64
+ * @param ctx - The destination 2D context (the on-screen canvas).
65
+ * @param prevFrame - Composited pixel buffer for the previous frame, or null.
66
+ * @param nextFrame - Composited pixel buffer for the next frame, or null.
67
+ * @param zoom - Zoom factor.
68
+ * @param panX - Horizontal pan offset in screen pixels.
69
+ * @param panY - Vertical pan offset in screen pixels.
70
+ */
71
+ export function renderOnionSkin(
72
+ ctx: CanvasRenderingContext2D,
73
+ prevFrame: PixelBuffer | null,
74
+ nextFrame: PixelBuffer | null,
75
+ zoom: number,
76
+ panX: number,
77
+ panY: number,
78
+ ): void {
79
+ if (prevFrame) {
80
+ renderGhost(ctx, {
81
+ buffer: prevFrame,
82
+ tintColor: 'rgba(255, 0, 0, 0.5)',
83
+ opacity: 0.25,
84
+ }, zoom, panX, panY);
85
+ }
86
+
87
+ if (nextFrame) {
88
+ renderGhost(ctx, {
89
+ buffer: nextFrame,
90
+ tintColor: 'rgba(0, 0, 255, 0.5)',
91
+ opacity: 0.25,
92
+ }, zoom, panX, panY);
93
+ }
94
+ }