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,151 @@
1
+ /**
2
+ * Animation Preview -- playback controller for frame animation.
3
+ *
4
+ * Supports loop, pingpong, and side-by-side modes. The tick() method
5
+ * should be called from requestAnimationFrame; it advances frames
6
+ * based on per-frame timing from the frame model.
7
+ *
8
+ * Uses Svelte 5 $state runes for reactive playback state.
9
+ *
10
+ * Module-level singleton -- one preview state per app instance.
11
+ */
12
+
13
+ import * as frameModel from './frame-model.svelte.js';
14
+
15
+ // --- Types ---
16
+
17
+ export type PreviewMode = 'loop' | 'pingpong' | 'side-by-side';
18
+
19
+ // --- Reactive state ---
20
+
21
+ let playing = $state<boolean>(false);
22
+ let mode = $state<PreviewMode>('loop');
23
+ let speedMultiplier = $state<number>(1.0);
24
+ let previewFrameIndex = $state<number>(0);
25
+
26
+ // Internal: accumulated time since last frame advance
27
+ let accumulator = 0;
28
+ // Internal: pingpong direction (1 = forward, -1 = backward)
29
+ let pingpongDirection = 1;
30
+
31
+ // --- Queries ---
32
+
33
+ export function isPlaying(): boolean {
34
+ return playing;
35
+ }
36
+
37
+ export function getMode(): PreviewMode {
38
+ return mode;
39
+ }
40
+
41
+ export function getSpeedMultiplier(): number {
42
+ return speedMultiplier;
43
+ }
44
+
45
+ export function getPreviewFrameIndex(): number {
46
+ return previewFrameIndex;
47
+ }
48
+
49
+ // --- Mutations ---
50
+
51
+ export function play(): void {
52
+ playing = true;
53
+ accumulator = 0;
54
+ }
55
+
56
+ export function pause(): void {
57
+ playing = false;
58
+ }
59
+
60
+ export function stop(): void {
61
+ playing = false;
62
+ previewFrameIndex = 0;
63
+ accumulator = 0;
64
+ pingpongDirection = 1;
65
+ }
66
+
67
+ export function step(direction: 1 | -1): void {
68
+ const totalFrames = frameModel.getFrames().length;
69
+ if (totalFrames === 0) return;
70
+
71
+ let next = previewFrameIndex + direction;
72
+ // Wrap around
73
+ if (next < 0) next = totalFrames - 1;
74
+ if (next >= totalFrames) next = 0;
75
+
76
+ previewFrameIndex = next;
77
+ }
78
+
79
+ export function setMode(newMode: PreviewMode): void {
80
+ mode = newMode;
81
+ // Reset pingpong direction when changing modes
82
+ pingpongDirection = 1;
83
+ }
84
+
85
+ export function setSpeed(multiplier: number): void {
86
+ speedMultiplier = Math.max(0.1, Math.min(10, multiplier));
87
+ }
88
+
89
+ /**
90
+ * Advance the preview by deltaMs milliseconds.
91
+ * Call from requestAnimationFrame. Respects per-frame duration and speed.
92
+ */
93
+ export function tick(deltaMs: number): void {
94
+ if (!playing) return;
95
+
96
+ const totalFrames = frameModel.getFrames().length;
97
+ if (totalFrames <= 1) return;
98
+
99
+ accumulator += deltaMs;
100
+
101
+ // Advance as many frames as needed (handles large deltas).
102
+ // Recompute frameDuration each iteration because advanceFrame()
103
+ // changes previewFrameIndex and each frame may have a different duration.
104
+ let frameDuration = frameModel.getFrameDuration(previewFrameIndex) / speedMultiplier;
105
+ while (accumulator >= frameDuration) {
106
+ accumulator -= frameDuration;
107
+ advanceFrame(totalFrames);
108
+ frameDuration = frameModel.getFrameDuration(previewFrameIndex) / speedMultiplier;
109
+ }
110
+ }
111
+
112
+ /** Internal: advance one frame based on the current mode. */
113
+ function advanceFrame(totalFrames: number): void {
114
+ if (mode === 'loop') {
115
+ previewFrameIndex = (previewFrameIndex + 1) % totalFrames;
116
+ } else if (mode === 'pingpong') {
117
+ const next = previewFrameIndex + pingpongDirection;
118
+ if (next >= totalFrames) {
119
+ // Reverse direction, step back one
120
+ pingpongDirection = -1;
121
+ previewFrameIndex = totalFrames - 2;
122
+ // Edge case: only 2 frames
123
+ if (previewFrameIndex < 0) previewFrameIndex = 0;
124
+ } else if (next < 0) {
125
+ // Reverse direction, step forward one
126
+ pingpongDirection = 1;
127
+ previewFrameIndex = 1;
128
+ // Edge case: only 2 frames
129
+ if (previewFrameIndex >= totalFrames) previewFrameIndex = 0;
130
+ } else {
131
+ previewFrameIndex = next;
132
+ }
133
+ } else {
134
+ // mode === 'side-by-side' -- the only remaining case.
135
+ // All frames shown simultaneously, but we still advance the
136
+ // "highlight" index for the UI.
137
+ previewFrameIndex = (previewFrameIndex + 1) % totalFrames;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Reset all preview state. Intended for tests only.
143
+ */
144
+ export function _resetForTesting(): void {
145
+ playing = false;
146
+ mode = 'loop';
147
+ speedMultiplier = 1.0;
148
+ previewFrameIndex = 0;
149
+ accumulator = 0;
150
+ pingpongDirection = 1;
151
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Clipboard -- internal and system clipboard operations for frames.
3
+ *
4
+ * The internal clipboard preserves layer structure (rich paste),
5
+ * while system clipboard operations flatten to PNG (interop paste).
6
+ */
7
+
8
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
9
+ import type { Frame } from './frame-model.svelte.js';
10
+ import type { Layer } from '../layers/layer-types.js';
11
+
12
+ // --- Types ---
13
+
14
+ export interface InternalClipboardData {
15
+ type: 'frame' | 'selection';
16
+ layers: { id: string; name: string; pixelData: ArrayBuffer }[];
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ // --- Internal clipboard state ---
22
+
23
+ let internalClipboard: InternalClipboardData | null = null;
24
+
25
+ // --- Internal clipboard operations ---
26
+
27
+ /**
28
+ * Copy a frame's pixel data to the internal clipboard, preserving layer info.
29
+ * Only copies layers that have pixel data in the frame.
30
+ */
31
+ export function copyFrameToInternal(frame: Frame, layerTree: Layer[]): void {
32
+ const layers: InternalClipboardData['layers'] = [];
33
+ let width = 0;
34
+ let height = 0;
35
+
36
+ // Walk all pixel layers in the tree to maintain order
37
+ function collectLayers(tree: Layer[]): void {
38
+ for (const layer of tree) {
39
+ if (layer.type === 'pixel') {
40
+ const buffer = frame.pixelData.get(layer.id);
41
+ if (buffer) {
42
+ // Store a copy of the raw data as ArrayBuffer
43
+ layers.push({
44
+ id: layer.id,
45
+ name: layer.name,
46
+ pixelData: buffer.data.buffer.slice(0) as ArrayBuffer,
47
+ });
48
+ width = buffer.width;
49
+ height = buffer.height;
50
+ }
51
+ } else if (layer.children) {
52
+ collectLayers(layer.children);
53
+ }
54
+ }
55
+ }
56
+
57
+ collectLayers(layerTree);
58
+
59
+ internalClipboard = {
60
+ type: 'frame',
61
+ layers,
62
+ width,
63
+ height,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Read from the internal clipboard. Returns null if empty.
69
+ */
70
+ export function pasteFromInternal(): InternalClipboardData | null {
71
+ return internalClipboard;
72
+ }
73
+
74
+ /**
75
+ * Copy a composited (flattened) frame to the system clipboard as PNG.
76
+ * Uses the Clipboard API (requires secure context / user gesture).
77
+ */
78
+ export async function copyToSystemClipboard(composited: PixelBuffer): Promise<void> {
79
+ // Create an offscreen canvas to convert PixelBuffer to PNG blob
80
+ const canvas = new OffscreenCanvas(composited.width, composited.height);
81
+ const ctx = canvas.getContext('2d');
82
+ if (!ctx) throw new Error('Could not get 2d context from OffscreenCanvas.');
83
+
84
+ ctx.putImageData(composited.toImageData(), 0, 0);
85
+ const blob = await canvas.convertToBlob({ type: 'image/png' });
86
+
87
+ await navigator.clipboard.write([
88
+ new ClipboardItem({ 'image/png': blob }),
89
+ ]);
90
+ }
91
+
92
+ /**
93
+ * Paste an image from the system clipboard as a PixelBuffer.
94
+ * Returns null if no image is available.
95
+ */
96
+ export async function pasteFromSystemClipboard(): Promise<PixelBuffer | null> {
97
+ const items = await navigator.clipboard.read();
98
+
99
+ for (const item of items) {
100
+ if (item.types.includes('image/png')) {
101
+ const blob = await item.getType('image/png');
102
+ const bitmap = await createImageBitmap(blob);
103
+
104
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
105
+ const ctx = canvas.getContext('2d');
106
+ if (!ctx) continue;
107
+
108
+ ctx.drawImage(bitmap, 0, 0);
109
+ const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
110
+
111
+ return new PixelBuffer(
112
+ imageData.width,
113
+ imageData.height,
114
+ new Uint8ClampedArray(imageData.data),
115
+ );
116
+ }
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Clear the internal clipboard.
124
+ */
125
+ export function clearInternal(): void {
126
+ internalClipboard = null;
127
+ }
128
+
129
+ /**
130
+ * Reset clipboard state. Intended for tests only.
131
+ */
132
+ export function _resetForTesting(): void {
133
+ internalClipboard = null;
134
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Frame Model -- reactive state for animation frames.
3
+ *
4
+ * Each frame holds per-layer pixel data (the layer structure itself is shared
5
+ * across all frames via layer-tree.svelte.ts). Uses Svelte 5 $state runes.
6
+ *
7
+ * Module-level singleton -- one frame model per app instance.
8
+ */
9
+
10
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
11
+ import { SvelteMap } from 'svelte/reactivity';
12
+
13
+ // --- Types ---
14
+
15
+ export interface Frame {
16
+ id: string;
17
+ index: number;
18
+ /** null = use global FPS; number = per-frame override in milliseconds */
19
+ durationMs: number | null;
20
+ /** layerId -> pixel data for this frame */
21
+ pixelData: Map<string, PixelBuffer>;
22
+ }
23
+
24
+ export interface SerializedFrame {
25
+ id: string;
26
+ index: number;
27
+ durationMs: number | null;
28
+ pixelData: Record<string, { width: number; height: number; data: number[] }>;
29
+ }
30
+
31
+ export interface FramesSerialized {
32
+ frames: SerializedFrame[];
33
+ currentFrameIndex: number;
34
+ globalFps: number;
35
+ originX: number;
36
+ originY: number;
37
+ }
38
+
39
+ // --- Reactive state ---
40
+
41
+ let frames = $state<Frame[]>([]);
42
+ let currentFrameIndex = $state<number>(0);
43
+ let globalFps = $state<number>(12);
44
+ let originX = $state<number>(0);
45
+ let originY = $state<number>(0);
46
+
47
+ // --- Internal helpers ---
48
+
49
+ function createFrame(index: number): Frame {
50
+ return {
51
+ id: crypto.randomUUID(),
52
+ index,
53
+ durationMs: null,
54
+ pixelData: new Map(),
55
+ };
56
+ }
57
+
58
+ /** Re-index all frames so frame.index matches array position. */
59
+ function reindex(): void {
60
+ for (let i = 0; i < frames.length; i++) {
61
+ const f = frames[i];
62
+ if (f) f.index = i;
63
+ }
64
+ }
65
+
66
+ // --- Queries ---
67
+
68
+ export function getFrames(): Frame[] {
69
+ return frames;
70
+ }
71
+
72
+ export function getCurrentFrameIndex(): number {
73
+ return currentFrameIndex;
74
+ }
75
+
76
+ export function getGlobalFps(): number {
77
+ return globalFps;
78
+ }
79
+
80
+ export function getOriginX(): number {
81
+ return originX;
82
+ }
83
+
84
+ export function getOriginY(): number {
85
+ return originY;
86
+ }
87
+
88
+ export function getCurrentFrame(): Frame {
89
+ if (frames.length === 0) {
90
+ throw new Error('No frames exist.');
91
+ }
92
+ const frame = frames[currentFrameIndex];
93
+ if (!frame) throw new Error(`Current frame index ${String(currentFrameIndex)} out of range.`);
94
+ return frame;
95
+ }
96
+
97
+ /**
98
+ * Get the actual duration in ms for a frame.
99
+ * Uses per-frame override if set, otherwise derives from global FPS.
100
+ */
101
+ export function getFrameDuration(index: number): number {
102
+ if (index < 0 || index >= frames.length) {
103
+ throw new Error(`Frame index ${String(index)} out of range (0..${String(frames.length - 1)}).`);
104
+ }
105
+ const frame = frames[index];
106
+ if (!frame) throw new Error(`Frame index ${String(index)} missing`);
107
+ if (frame.durationMs !== null) {
108
+ return frame.durationMs;
109
+ }
110
+ // Derive from global FPS: 1000ms / fps
111
+ return Math.round(1000 / globalFps);
112
+ }
113
+
114
+ export function getPixelDataForLayer(frameIndex: number, layerId: string): PixelBuffer | undefined {
115
+ if (frameIndex < 0 || frameIndex >= frames.length) return undefined;
116
+ return frames[frameIndex]?.pixelData.get(layerId);
117
+ }
118
+
119
+ // --- Mutations ---
120
+
121
+ export function addFrame(options?: { afterIndex?: number; duplicate?: boolean }): Frame {
122
+ const insertAfter = options?.afterIndex ?? frames.length - 1;
123
+ const insertIndex = Math.max(0, Math.min(insertAfter + 1, frames.length));
124
+
125
+ const newFrame = createFrame(insertIndex);
126
+
127
+ // If duplicating, copy pixel data from the source frame
128
+ if (options?.duplicate && frames.length > 0) {
129
+ const sourceIndex = Math.min(insertAfter, frames.length - 1);
130
+ const source = frames[sourceIndex >= 0 ? sourceIndex : 0];
131
+ if (source) {
132
+ for (const [layerId, buffer] of source.pixelData) {
133
+ newFrame.pixelData.set(layerId, buffer.clone());
134
+ }
135
+ }
136
+ }
137
+
138
+ frames.splice(insertIndex, 0, newFrame);
139
+ reindex();
140
+ return newFrame;
141
+ }
142
+
143
+ export function removeFrame(index: number): void {
144
+ if (index < 0 || index >= frames.length) {
145
+ throw new Error(`Frame index ${String(index)} out of range.`);
146
+ }
147
+ if (frames.length <= 1) {
148
+ throw new Error('Cannot remove the last frame.');
149
+ }
150
+
151
+ frames.splice(index, 1);
152
+ reindex();
153
+
154
+ // Adjust current frame index if needed
155
+ if (currentFrameIndex >= frames.length) {
156
+ currentFrameIndex = frames.length - 1;
157
+ }
158
+ }
159
+
160
+ export function duplicateFrame(index: number): Frame {
161
+ if (index < 0 || index >= frames.length) {
162
+ throw new Error(`Frame index ${String(index)} out of range.`);
163
+ }
164
+ return addFrame({ afterIndex: index, duplicate: true });
165
+ }
166
+
167
+ export function reorderFrame(fromIndex: number, toIndex: number): void {
168
+ if (fromIndex < 0 || fromIndex >= frames.length) {
169
+ throw new Error(`Source frame index ${String(fromIndex)} out of range.`);
170
+ }
171
+ if (toIndex < 0 || toIndex >= frames.length) {
172
+ throw new Error(`Target frame index ${String(toIndex)} out of range.`);
173
+ }
174
+ if (fromIndex === toIndex) return;
175
+
176
+ const [moved] = frames.splice(fromIndex, 1) as [Frame];
177
+ frames.splice(toIndex, 0, moved);
178
+ reindex();
179
+
180
+ // Keep current frame tracking the same frame object
181
+ if (currentFrameIndex === fromIndex) {
182
+ currentFrameIndex = toIndex;
183
+ } else if (fromIndex < currentFrameIndex && toIndex >= currentFrameIndex) {
184
+ currentFrameIndex--;
185
+ } else if (fromIndex > currentFrameIndex && toIndex <= currentFrameIndex) {
186
+ currentFrameIndex++;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Move a set of frames (preserving their relative order) to a target position.
192
+ * The target index refers to the position in the original array where the
193
+ * group should land after extraction and re-insertion.
194
+ */
195
+ export function reorderFrames(fromIndices: number[], toIndex: number): void {
196
+ if (fromIndices.length === 0) return;
197
+
198
+ // De-dup, validate, and sort ascending
199
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- function-local dedup helper
200
+ const sorted = [...new Set(fromIndices)]
201
+ .filter((i) => i >= 0 && i < frames.length)
202
+ .sort((a, b) => a - b);
203
+
204
+ if (sorted.length === 0) return;
205
+
206
+ // Extract frames at those indices (in ascending order to preserve relative order)
207
+ const extracted: Frame[] = sorted.map((i) => frames[i] as Frame);
208
+
209
+ // Remove them from the array in reverse order to preserve indices during removal
210
+ for (let i = sorted.length - 1; i >= 0; i--) {
211
+ const idx = sorted[i];
212
+ if (idx !== undefined) frames.splice(idx, 1);
213
+ }
214
+
215
+ // Calculate adjusted target index: account for removed items that were before the target
216
+ let adjustedTarget = toIndex;
217
+ for (const idx of sorted) {
218
+ if (idx < toIndex) {
219
+ adjustedTarget--;
220
+ }
221
+ }
222
+
223
+ // Clamp to valid range after removals
224
+ adjustedTarget = Math.max(0, Math.min(adjustedTarget, frames.length));
225
+
226
+ // Insert extracted frames at the adjusted target position
227
+ frames.splice(adjustedTarget, 0, ...extracted);
228
+ reindex();
229
+
230
+ // Clamp currentFrameIndex to valid range
231
+ if (currentFrameIndex >= frames.length) {
232
+ currentFrameIndex = frames.length - 1;
233
+ }
234
+ }
235
+
236
+ export function setCurrentFrame(index: number): void {
237
+ if (index < 0 || index >= frames.length) {
238
+ throw new Error(`Frame index ${String(index)} out of range.`);
239
+ }
240
+ currentFrameIndex = index;
241
+ }
242
+
243
+ export function setGlobalFps(fps: number): void {
244
+ if (fps < 1 || fps > 120) {
245
+ throw new Error(`FPS must be between 1 and 120, got ${String(fps)}.`);
246
+ }
247
+ globalFps = fps;
248
+ }
249
+
250
+ export function setFrameDuration(index: number, durationMs: number | null): void {
251
+ if (index < 0 || index >= frames.length) {
252
+ throw new Error(`Frame index ${String(index)} out of range.`);
253
+ }
254
+ if (durationMs !== null && durationMs <= 0) {
255
+ throw new Error('Frame duration must be positive.');
256
+ }
257
+ const fr = frames[index];
258
+ if (fr) fr.durationMs = durationMs;
259
+ }
260
+
261
+ export function setOrigin(x: number, y: number): void {
262
+ originX = x;
263
+ originY = y;
264
+ }
265
+
266
+ // --- Server state application ---
267
+
268
+ /**
269
+ * Replace all animation state from server-native data.
270
+ * Each frame's pixelData is already a Map<string, PixelBuffer>.
271
+ * Unlike deserialize(), this avoids the wasteful number-array conversion
272
+ * since the server sends base64-encoded pixel data decoded upstream.
273
+ */
274
+ export function applyServerState(
275
+ serverFrames: Array<{
276
+ id: string;
277
+ durationMs: number | null;
278
+ pixelData: Map<string, PixelBuffer>;
279
+ }>,
280
+ newCurrentFrameIndex: number,
281
+ newGlobalFps: number,
282
+ newOriginX: number,
283
+ newOriginY: number,
284
+ ): void {
285
+ frames = serverFrames.map((sf, i) => ({
286
+ id: sf.id,
287
+ index: i,
288
+ durationMs: sf.durationMs,
289
+ // Ensure reactive Map for Svelte 5
290
+ pixelData:
291
+ sf.pixelData instanceof SvelteMap
292
+ ? sf.pixelData
293
+ : new SvelteMap(sf.pixelData),
294
+ }));
295
+ currentFrameIndex = Math.max(
296
+ 0,
297
+ Math.min(newCurrentFrameIndex, frames.length - 1),
298
+ );
299
+ globalFps = Math.max(1, Math.min(newGlobalFps, 120));
300
+ originX = newOriginX;
301
+ originY = newOriginY;
302
+ }
303
+
304
+ // --- Serialization ---
305
+
306
+ export function serialize(): FramesSerialized {
307
+ return {
308
+ frames: frames.map((frame) => {
309
+ const pixelData: Record<string, { width: number; height: number; data: number[] }> = {};
310
+ for (const [layerId, buffer] of frame.pixelData) {
311
+ pixelData[layerId] = {
312
+ width: buffer.width,
313
+ height: buffer.height,
314
+ data: Array.from(buffer.data),
315
+ };
316
+ }
317
+ return {
318
+ id: frame.id,
319
+ index: frame.index,
320
+ durationMs: frame.durationMs,
321
+ pixelData,
322
+ };
323
+ }),
324
+ currentFrameIndex,
325
+ globalFps,
326
+ originX,
327
+ originY,
328
+ };
329
+ }
330
+
331
+ export function deserialize(data: FramesSerialized): void {
332
+ frames = data.frames.map((sf) => {
333
+ const pixelData = new SvelteMap<string, PixelBuffer>();
334
+ for (const [layerId, bufData] of Object.entries(sf.pixelData)) {
335
+ pixelData.set(
336
+ layerId,
337
+ new PixelBuffer(bufData.width, bufData.height, new Uint8ClampedArray(bufData.data)),
338
+ );
339
+ }
340
+ return {
341
+ id: sf.id,
342
+ index: sf.index,
343
+ durationMs: sf.durationMs,
344
+ pixelData,
345
+ };
346
+ });
347
+ currentFrameIndex = data.currentFrameIndex;
348
+ globalFps = data.globalFps;
349
+ originX = data.originX;
350
+ originY = data.originY;
351
+ }
352
+
353
+ /**
354
+ * Insert a frame object at a specific index (for undo restore).
355
+ * Does NOT reindex -- caller is responsible for calling reindex if needed.
356
+ */
357
+ export function _insertFrameAt(index: number, frame: Frame): void {
358
+ frames.splice(index, 0, frame);
359
+ reindex();
360
+ }
361
+
362
+ /**
363
+ * Remove a frame by index and return it (for undo snapshots).
364
+ */
365
+ export function _removeFrameAt(index: number): Frame {
366
+ const [removed] = frames.splice(index, 1) as [Frame];
367
+ reindex();
368
+ if (currentFrameIndex >= frames.length) {
369
+ currentFrameIndex = Math.max(0, frames.length - 1);
370
+ }
371
+ return removed;
372
+ }
373
+
374
+ /**
375
+ * Remove multiple frames at the given indices.
376
+ * Indices are de-duped, sorted, and removed in reverse order to avoid index shifting.
377
+ * Cannot remove ALL frames -- at least one frame is always kept (the lowest-indexed survivor).
378
+ * Returns the removed Frame objects in their original index order (for undo).
379
+ */
380
+ export function removeFrames(indices: number[]): Frame[] {
381
+ if (indices.length === 0) return [];
382
+
383
+ // De-dup and sort ascending. Local transient Set -- not reactive state.
384
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- function-local dedup helper
385
+ const unique = [...new Set(indices)]
386
+ .filter((i) => i >= 0 && i < frames.length)
387
+ .sort((a, b) => a - b);
388
+
389
+ if (unique.length === 0) return [];
390
+
391
+ // If indices cover ALL frames, keep the first one
392
+ let toRemove = unique;
393
+ if (toRemove.length >= frames.length) {
394
+ toRemove = toRemove.slice(1); // drop index 0 so frame 0 survives
395
+ }
396
+
397
+ // Remove in reverse order (highest first) to preserve lower indices
398
+ const removed: Frame[] = [];
399
+ for (let i = toRemove.length - 1; i >= 0; i--) {
400
+ const idx = toRemove[i];
401
+ if (idx === undefined) continue;
402
+ const [r] = frames.splice(idx, 1) as [Frame];
403
+ removed.unshift(r); // prepend so result is in ascending index order
404
+ }
405
+
406
+ reindex();
407
+
408
+ // Clamp currentFrameIndex to valid range
409
+ if (currentFrameIndex >= frames.length) {
410
+ currentFrameIndex = frames.length - 1;
411
+ }
412
+
413
+ return removed;
414
+ }
415
+
416
+ /**
417
+ * Insert multiple frames starting at the given index.
418
+ * Useful for undo of bulk delete and for paste-multiple.
419
+ */
420
+ export function insertFramesAt(index: number, framesToInsert: Frame[]): void {
421
+ if (framesToInsert.length === 0) return;
422
+
423
+ const clampedIndex = Math.max(0, Math.min(index, frames.length));
424
+ frames.splice(clampedIndex, 0, ...framesToInsert);
425
+ reindex();
426
+ }
427
+
428
+ /**
429
+ * Reset all frame state. Intended for tests only.
430
+ */
431
+ export function _resetForTesting(): void {
432
+ frames = [];
433
+ currentFrameIndex = 0;
434
+ globalFps = 12;
435
+ originX = 0;
436
+ originY = 0;
437
+ }