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,281 @@
1
+ /**
2
+ * Keyboard Shortcut Manager -- core engine for parsing, matching, and
3
+ * dispatching keyboard shortcuts.
4
+ *
5
+ * Custom implementation with no external dependencies.
6
+ * Uses event.key (layout-independent) for matching.
7
+ * Normalizes all shortcut strings to a canonical lowercase form
8
+ * with modifiers in fixed order: ctrl+alt+shift+meta+key.
9
+ */
10
+
11
+ // --- Types ---
12
+
13
+ export interface ParsedShortcut {
14
+ key: string; // lowercase key name (e.g., "z", "b", "escape")
15
+ ctrl: boolean;
16
+ shift: boolean;
17
+ alt: boolean;
18
+ meta: boolean;
19
+ }
20
+
21
+ export interface ShortcutBinding {
22
+ action: () => void;
23
+ description: string;
24
+ /** The action name used for profile export/import */
25
+ name: string;
26
+ }
27
+
28
+ // --- Modifier detection order for canonical form ---
29
+ const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta'] as const;
30
+
31
+ // Map of recognized modifier tokens to their ParsedShortcut field
32
+ const MODIFIER_MAP: Record<string, keyof Omit<ParsedShortcut, 'key'>> = {
33
+ ctrl: 'ctrl',
34
+ control: 'ctrl',
35
+ alt: 'alt',
36
+ shift: 'shift',
37
+ meta: 'meta',
38
+ cmd: 'meta',
39
+ command: 'meta',
40
+ win: 'meta',
41
+ super: 'meta',
42
+ };
43
+
44
+ // --- Parsing ---
45
+
46
+ /**
47
+ * Parse a key string like "Ctrl+Shift+Z" into its normalized components.
48
+ * Accepts modifiers in any order and multiple aliases (e.g., "Cmd" = "Meta").
49
+ */
50
+ export function parseShortcut(shortcutStr: string): ParsedShortcut {
51
+ const parts = shortcutStr.split('+').map((p) => p.trim().toLowerCase());
52
+
53
+ const parsed: ParsedShortcut = {
54
+ key: '',
55
+ ctrl: false,
56
+ shift: false,
57
+ alt: false,
58
+ meta: false,
59
+ };
60
+
61
+ for (const part of parts) {
62
+ const modField = MODIFIER_MAP[part];
63
+ if (modField) {
64
+ parsed[modField] = true;
65
+ } else {
66
+ // Last non-modifier part is the key; if multiple non-modifier parts
67
+ // exist, the last one wins (unusual but defensive)
68
+ parsed.key = part;
69
+ }
70
+ }
71
+
72
+ return parsed;
73
+ }
74
+
75
+ /**
76
+ * Produce a canonical string form from a shortcut string.
77
+ * Modifiers always appear in fixed order: ctrl+alt+shift+meta+key.
78
+ * Everything is lowercase.
79
+ */
80
+ export function normalizeShortcut(shortcutStr: string): string {
81
+ const parsed = parseShortcut(shortcutStr);
82
+ return buildCanonical(parsed);
83
+ }
84
+
85
+ /** Build canonical string from a ParsedShortcut */
86
+ function buildCanonical(parsed: ParsedShortcut): string {
87
+ const parts: string[] = [];
88
+ for (const mod of MODIFIER_ORDER) {
89
+ if (parsed[mod]) parts.push(mod);
90
+ }
91
+ parts.push(parsed.key);
92
+ return parts.join('+');
93
+ }
94
+
95
+ // --- Event matching ---
96
+
97
+ /**
98
+ * Check if a KeyboardEvent matches a ParsedShortcut.
99
+ * Compares both the target key and all modifier flags.
100
+ */
101
+ export function matchesEvent(parsed: ParsedShortcut, event: KeyboardEvent): boolean {
102
+ const eventKey = event.key.toLowerCase();
103
+
104
+ return (
105
+ eventKey === parsed.key &&
106
+ event.ctrlKey === parsed.ctrl &&
107
+ event.shiftKey === parsed.shift &&
108
+ event.altKey === parsed.alt &&
109
+ event.metaKey === parsed.meta
110
+ );
111
+ }
112
+
113
+ // --- ShortcutManager ---
114
+
115
+ export class ShortcutManager {
116
+ /** Map from normalized shortcut string to its binding */
117
+ private bindings = new Map<string, ShortcutBinding>();
118
+
119
+ /** Reverse map from action name to normalized shortcut string */
120
+ private nameToShortcut = new Map<string, string>();
121
+
122
+ /** Pre-parsed shortcuts for fast event matching */
123
+ private parsedCache = new Map<string, ParsedShortcut>();
124
+
125
+ /** Whether the manager processes keyboard events */
126
+ private enabled = true;
127
+
128
+ // --- Bind / unbind ---
129
+
130
+ /**
131
+ * Bind a shortcut string to an action.
132
+ * If a name is provided it is used for profile export/import and rebinding.
133
+ */
134
+ bind(shortcut: string, action: () => void, description = '', name = ''): void {
135
+ const normalized = normalizeShortcut(shortcut);
136
+ const parsed = parseShortcut(shortcut);
137
+
138
+ this.bindings.set(normalized, { action, description, name });
139
+ this.parsedCache.set(normalized, parsed);
140
+
141
+ if (name) {
142
+ this.nameToShortcut.set(name, normalized);
143
+ }
144
+ }
145
+
146
+ /** Remove a binding by its shortcut string. */
147
+ unbind(shortcut: string): void {
148
+ const normalized = normalizeShortcut(shortcut);
149
+ const binding = this.bindings.get(normalized);
150
+
151
+ if (binding?.name) {
152
+ this.nameToShortcut.delete(binding.name);
153
+ }
154
+
155
+ this.bindings.delete(normalized);
156
+ this.parsedCache.delete(normalized);
157
+ }
158
+
159
+ /**
160
+ * Move an existing binding from one key to another.
161
+ * Preserves the action, description, and name.
162
+ */
163
+ rebind(oldShortcut: string, newShortcut: string): void {
164
+ const oldNorm = normalizeShortcut(oldShortcut);
165
+ const binding = this.bindings.get(oldNorm);
166
+ if (!binding) return;
167
+
168
+ // Remove old entry
169
+ this.bindings.delete(oldNorm);
170
+ this.parsedCache.delete(oldNorm);
171
+
172
+ // Insert under new key
173
+ const newNorm = normalizeShortcut(newShortcut);
174
+ const newParsed = parseShortcut(newShortcut);
175
+
176
+ this.bindings.set(newNorm, binding);
177
+ this.parsedCache.set(newNorm, newParsed);
178
+
179
+ if (binding.name) {
180
+ this.nameToShortcut.set(binding.name, newNorm);
181
+ }
182
+ }
183
+
184
+ // --- Query ---
185
+
186
+ /** Return all current bindings keyed by normalized shortcut string. */
187
+ getBindings(): Map<string, { action: () => void; description: string }> {
188
+ return new Map(this.bindings);
189
+ }
190
+
191
+ /**
192
+ * Check if a shortcut string conflicts with an existing binding.
193
+ * Returns the description of the conflicting binding, or null.
194
+ */
195
+ getConflict(shortcut: string): string | null {
196
+ const normalized = normalizeShortcut(shortcut);
197
+ const existing = this.bindings.get(normalized);
198
+ return existing ? existing.description : null;
199
+ }
200
+
201
+ /** Look up the current shortcut string for a named action. */
202
+ getShortcutForName(name: string): string | undefined {
203
+ return this.nameToShortcut.get(name);
204
+ }
205
+
206
+ // --- Event handling ---
207
+
208
+ /**
209
+ * Process a keydown event. Returns true if a shortcut was matched and fired.
210
+ * Calls event.preventDefault() for matched shortcuts to suppress browser defaults.
211
+ */
212
+ handleKeyDown(event: KeyboardEvent): boolean {
213
+ if (!this.enabled) return false;
214
+
215
+ for (const [normalized, parsed] of this.parsedCache) {
216
+ if (matchesEvent(parsed, event)) {
217
+ const binding = this.bindings.get(normalized);
218
+ if (binding) {
219
+ event.preventDefault();
220
+ binding.action();
221
+ return true;
222
+ }
223
+ }
224
+ }
225
+
226
+ return false;
227
+ }
228
+
229
+ /** Enable or disable the manager (useful when a text input has focus). */
230
+ setEnabled(enabled: boolean): void {
231
+ this.enabled = enabled;
232
+ }
233
+
234
+ /** Whether the manager is currently enabled. */
235
+ isEnabled(): boolean {
236
+ return this.enabled;
237
+ }
238
+
239
+ // --- Reset ---
240
+
241
+ /** Remove all bindings and reset state. */
242
+ reset(): void {
243
+ this.bindings.clear();
244
+ this.nameToShortcut.clear();
245
+ this.parsedCache.clear();
246
+ this.enabled = true;
247
+ }
248
+
249
+ // --- Profile import/export ---
250
+
251
+ /**
252
+ * Export current bindings as a profile: { actionName: shortcutString }.
253
+ * Only named bindings are exported.
254
+ */
255
+ exportProfile(): Record<string, string> {
256
+ const profile: Record<string, string> = {};
257
+ for (const [shortcut, binding] of this.bindings) {
258
+ if (binding.name) {
259
+ profile[binding.name] = shortcut;
260
+ }
261
+ }
262
+ return profile;
263
+ }
264
+
265
+ /**
266
+ * Import a profile, rebinding named actions to new shortcut strings.
267
+ * Actions not present in the profile keep their current bindings.
268
+ * Profile entries whose names don't match any current binding are ignored.
269
+ */
270
+ importProfile(profile: Record<string, string>): void {
271
+ for (const [name, newShortcut] of Object.entries(profile)) {
272
+ const currentShortcut = this.nameToShortcut.get(name);
273
+ if (currentShortcut === undefined) continue;
274
+
275
+ const newNorm = normalizeShortcut(newShortcut);
276
+ if (currentShortcut === newNorm) continue; // already correct
277
+
278
+ this.rebind(currentShortcut, newShortcut);
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Reactive shortcut state for PixelWeaver UI.
3
+ *
4
+ * Module-level singleton using Svelte 5 $state runes.
5
+ * Tracks the current set of bindings (for display in settings/help),
6
+ * whether shortcuts are globally enabled, and the currently active tool name.
7
+ */
8
+
9
+ import { SvelteMap } from 'svelte/reactivity';
10
+
11
+ // --- Types ---
12
+
13
+ export interface BindingInfo {
14
+ name: string;
15
+ key: string;
16
+ description: string;
17
+ }
18
+
19
+ // --- Reactive state ---
20
+
21
+ /**
22
+ * Current bindings, keyed by action name.
23
+ * Reactive so UI panels (shortcut list, settings) update automatically.
24
+ */
25
+ const bindings = new SvelteMap<string, BindingInfo>();
26
+
27
+ /** Whether shortcuts are globally enabled (disabled during text input focus). */
28
+ let enabled = $state(true);
29
+
30
+ /** The currently active tool name (e.g. "pencil", "eraser"). */
31
+ let activeTool = $state('pencil');
32
+
33
+ // --- Public API ---
34
+
35
+ export function getBindings(): ReadonlyMap<string, BindingInfo> {
36
+ return bindings;
37
+ }
38
+
39
+ /** Replace all bindings at once (used during initialization and profile import). */
40
+ export function setBindings(entries: BindingInfo[]): void {
41
+ bindings.clear();
42
+ for (const entry of entries) {
43
+ bindings.set(entry.name, entry);
44
+ }
45
+ }
46
+
47
+ /** Update a single binding's key (used during rebind). */
48
+ export function updateBindingKey(name: string, newKey: string): void {
49
+ const existing = bindings.get(name);
50
+ if (existing) {
51
+ bindings.set(name, { ...existing, key: newKey });
52
+ }
53
+ }
54
+
55
+ export function isEnabled(): boolean {
56
+ return enabled;
57
+ }
58
+
59
+ export function setEnabled(value: boolean): void {
60
+ enabled = value;
61
+ }
62
+
63
+ export function getActiveTool(): string {
64
+ return activeTool;
65
+ }
66
+
67
+ export function setActiveTool(name: string): void {
68
+ activeTool = name;
69
+ }
70
+
71
+ /**
72
+ * Reset all shortcut state to defaults. Intended for tests only.
73
+ */
74
+ export function _resetForTesting(): void {
75
+ bindings.clear();
76
+ enabled = true;
77
+ activeTool = '';
78
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shortcuts Plugin -- wraps shortcut system initialization as a plugin.
3
+ * Binds default keyboard shortcuts and attaches global key listeners.
4
+ */
5
+
6
+ import type { PluginModule } from '../core/plugin-loader.js';
7
+ import { initializeShortcuts } from './shortcut-init.js';
8
+
9
+ export const shortcutsPlugin: PluginModule = {
10
+ name: 'builtin/shortcuts',
11
+ version: '1.0.0',
12
+ description: 'Keyboard shortcut system with default bindings',
13
+
14
+ register() {
15
+ initializeShortcuts();
16
+ },
17
+ };
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Patch Applicator -- applies server-broadcast data patches to local state.
3
+ *
4
+ * When the server (via MCP or other operations) modifies state, it broadcasts
5
+ * pre-computed data patches. This module applies them directly to the
6
+ * frontend's reactive state without re-executing commands.
7
+ */
8
+
9
+ import type {
10
+ StatePatch,
11
+ PatchMessage,
12
+ FullStatePatch,
13
+ PixelPatch,
14
+ CanvasPatch,
15
+ LayerPatch,
16
+ FramePatch,
17
+ } from './patch-types.js';
18
+ import { applySnapshot, type ProjectSnapshot } from '../save/project-snapshot.js';
19
+ import { canvasState } from '../canvas/canvas-state.svelte.js';
20
+ import {
21
+ getFrames,
22
+ getCurrentFrame,
23
+ addFrame,
24
+ removeFrame,
25
+ reorderFrame,
26
+ setFrameDuration,
27
+ applyServerState,
28
+ } from '../animation/frame-model.svelte.js';
29
+ import {
30
+ getLayers,
31
+ getLayer,
32
+ addLayer,
33
+ removeLayer,
34
+ renameLayer,
35
+ setVisibility,
36
+ setOpacity,
37
+ setBlendMode,
38
+ setLocked,
39
+ } from '../layers/layer-tree.svelte.js';
40
+ import type { BlendMode } from '../layers/layer-types.js';
41
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
42
+ import { SvelteMap } from 'svelte/reactivity';
43
+ import { composite } from '../layers/compositor.js';
44
+ import { setRenderBuffer } from '../canvas/render-state.svelte.js';
45
+
46
+ /** Decode pixel data from either base64 string or number array (legacy). */
47
+ function decodePixelData(data: string | number[]): Uint8ClampedArray {
48
+ if (typeof data === 'string') {
49
+ const binary = atob(data);
50
+ const bytes = new Uint8ClampedArray(binary.length);
51
+ for (let i = 0; i < binary.length; i++) {
52
+ bytes[i] = binary.charCodeAt(i);
53
+ }
54
+ return bytes;
55
+ }
56
+ return new Uint8ClampedArray(data);
57
+ }
58
+
59
+ /**
60
+ * Apply a batch of state patches from the server.
61
+ * After all patches are applied, triggers a recomposite to update the canvas.
62
+ */
63
+ export function applyPatches(message: PatchMessage): void {
64
+ for (const patch of message.patches) {
65
+ applyPatch(patch);
66
+ }
67
+ recomposite();
68
+ }
69
+
70
+ function applyPatch(patch: StatePatch): void {
71
+ switch (patch.type) {
72
+ case 'full_state':
73
+ applyFullState(patch);
74
+ break;
75
+ case 'canvas':
76
+ applyCanvasPatch(patch);
77
+ break;
78
+ case 'pixel':
79
+ applyPixelPatch(patch);
80
+ break;
81
+ case 'layer':
82
+ applyLayerPatch(patch);
83
+ break;
84
+ case 'frame':
85
+ applyFramePatch(patch);
86
+ break;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Apply a full state replacement from the server.
92
+ *
93
+ * Detects which format the snapshot is in and routes accordingly:
94
+ * - ProjectSnapshot format (from save/load): has `version`, `canvas`, `layers`
95
+ * - Server-native format (from MCP broadcast): has `canvases` dict
96
+ * - Minimal format: just `width` and `height`
97
+ */
98
+ function applyFullState(patch: FullStatePatch): void {
99
+ const snapshot = patch.snapshot as Record<string, unknown>;
100
+
101
+ if (snapshot['version'] != null && snapshot['canvas'] != null && snapshot['layers'] != null) {
102
+ // ProjectSnapshot format (from save/load)
103
+ applySnapshot(snapshot as unknown as ProjectSnapshot);
104
+ } else if (snapshot['canvases'] != null) {
105
+ // Server-native format (from MCP broadcast)
106
+ applyServerNativeState(snapshot);
107
+ } else if (typeof snapshot['width'] === 'number' && typeof snapshot['height'] === 'number') {
108
+ // Minimal format (just dimensions)
109
+ canvasState.canvasWidth = snapshot['width'];
110
+ canvasState.canvasHeight = snapshot['height'];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Apply a server-native format snapshot.
116
+ *
117
+ * The server sends: { name, width, height, canvases: { "canvas-0": { ... } } }
118
+ * PixelWeaver currently uses a single canvas, so we grab the first entry.
119
+ *
120
+ * Multi-frame path: if the canvas has a `frames` array, decode each frame's
121
+ * pixel data and call applyServerState() to replace all animation state.
122
+ * Legacy path: fall back to writing pixel data into the current frame only.
123
+ */
124
+ function applyServerNativeState(snapshot: Record<string, unknown>): void {
125
+ // 1. Canvas dimensions
126
+ if (typeof snapshot['width'] === 'number' && typeof snapshot['height'] === 'number') {
127
+ canvasState.canvasWidth = snapshot['width'];
128
+ canvasState.canvasHeight = snapshot['height'];
129
+ }
130
+
131
+ // 2. Find the first canvas
132
+ const canvases = snapshot['canvases'] as Record<string, Record<string, unknown>> | undefined;
133
+ if (!canvases) return;
134
+ const canvasData = Object.values(canvases)[0];
135
+ if (!canvasData?.['layers']) return;
136
+
137
+ const cw = (canvasData['width'] as number | undefined) ?? canvasState.canvasWidth;
138
+ const ch = (canvasData['height'] as number | undefined) ?? canvasState.canvasHeight;
139
+
140
+ // 3. Multi-frame path: server sends a `frames` array with per-frame pixel data
141
+ const serverFrames = canvasData['frames'] as Array<Record<string, unknown>> | undefined;
142
+ if (serverFrames && serverFrames.length > 0) {
143
+ const builtFrames = serverFrames.map((sf) => {
144
+ const pixelData = new SvelteMap<string, PixelBuffer>();
145
+ const pd = sf['pixelData'] as Record<string, string> | undefined;
146
+ if (pd) {
147
+ for (const [layerId, base64] of Object.entries(pd)) {
148
+ const pixels = decodePixelData(base64);
149
+ pixelData.set(layerId, new PixelBuffer(cw, ch, pixels));
150
+ }
151
+ }
152
+ return {
153
+ id: sf['id'] as string,
154
+ durationMs: (sf['durationMs'] as number | null) ?? null,
155
+ pixelData,
156
+ };
157
+ });
158
+
159
+ applyServerState(
160
+ builtFrames,
161
+ (canvasData['currentFrameIndex'] as number | undefined) ?? 0,
162
+ (canvasData['globalFps'] as number | undefined) ?? 12,
163
+ (canvasData['originX'] as number | undefined) ?? 0,
164
+ (canvasData['originY'] as number | undefined) ?? 0,
165
+ );
166
+ } else {
167
+ // Legacy path: single-frame (no frames array from server)
168
+ const existingFrames = getFrames();
169
+ if (existingFrames.length === 0) return;
170
+ const frame = getCurrentFrame();
171
+
172
+ const layers = canvasData['layers'] as Array<Record<string, unknown>>;
173
+ for (const layerData of layers) {
174
+ const layerId = layerData['id'] as string | undefined;
175
+ const rawPixel = layerData['pixelData'] as string | number[] | undefined;
176
+ if (!layerId || !rawPixel || (Array.isArray(rawPixel) && rawPixel.length === 0)) continue;
177
+
178
+ const pixels = decodePixelData(rawPixel);
179
+
180
+ const existing = frame.pixelData.get(layerId);
181
+ if (existing && pixels.length === existing.data.length) {
182
+ // Overwrite existing buffer data in-place
183
+ existing.data.set(pixels);
184
+ } else {
185
+ // Create new buffer (layer exists on server but has different size or is new)
186
+ const buffer = new PixelBuffer(cw, ch, pixels);
187
+ frame.pixelData.set(layerId, buffer);
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ function applyCanvasPatch(patch: CanvasPatch): void {
194
+ canvasState.canvasWidth = patch.width;
195
+ canvasState.canvasHeight = patch.height;
196
+ }
197
+
198
+ function applyPixelPatch(patch: PixelPatch): void {
199
+ const frame = getFrames()[patch.frameIndex];
200
+ if (!frame) return;
201
+ const pixels = decodePixelData(patch.data);
202
+ const buffer = frame.pixelData.get(patch.layerId);
203
+ if (buffer && pixels.length === buffer.data.length) {
204
+ // Same size: overwrite in-place
205
+ buffer.data.set(pixels);
206
+ } else {
207
+ // Size mismatch or missing buffer: create a new PixelBuffer
208
+ if (buffer) {
209
+ console.warn(
210
+ `applyPixelPatch: buffer size mismatch for layer "${patch.layerId}" ` +
211
+ `(expected ${String(buffer.data.length)}, got ${String(pixels.length)}). Creating new buffer.`,
212
+ );
213
+ }
214
+ const newBuffer = new PixelBuffer(patch.width, patch.height, pixels);
215
+ frame.pixelData.set(patch.layerId, newBuffer);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Apply a layer-tree structure patch (add / remove / update).
221
+ * The server sends serialized layer data; we route to the appropriate
222
+ * layer-tree mutation functions.
223
+ */
224
+ function applyLayerPatch(patch: LayerPatch): void {
225
+ switch (patch.action) {
226
+ case 'add': {
227
+ // patch.layer is a serialized Layer object with at least { name, id }
228
+ const data = patch.layer as Record<string, unknown> | undefined;
229
+ if (!data) break;
230
+ const name = (data['name'] ?? 'Layer') as string;
231
+ const id = data['id'] as string | undefined;
232
+ const layer = addLayer(name, { ...(id !== undefined && { id }) });
233
+ // Apply any additional properties from the serialized data
234
+ if (typeof data['visible'] === 'boolean') setVisibility(layer.id, data['visible']);
235
+ if (typeof data['opacity'] === 'number') setOpacity(layer.id, data['opacity']);
236
+ if (typeof data['blendMode'] === 'string') setBlendMode(layer.id, data['blendMode'] as BlendMode);
237
+ if (typeof data['locked'] === 'boolean') setLocked(layer.id, data['locked']);
238
+ break;
239
+ }
240
+ case 'remove': {
241
+ const id = patch.layerId ?? (patch.layer as Record<string, unknown> | undefined)?.['id'] as string | undefined;
242
+ if (id) removeLayer(id);
243
+ break;
244
+ }
245
+ case 'update': {
246
+ // patch.layer contains partial layer data with at least an id
247
+ const data = patch.layer as Record<string, unknown> | undefined;
248
+ if (!data) break;
249
+ const id = (data['id'] ?? patch.layerId) as string | undefined;
250
+ if (!id) break;
251
+ const existing = getLayer(id);
252
+ if (!existing) break;
253
+ if (typeof data['name'] === 'string') renameLayer(id, data['name']);
254
+ if (typeof data['visible'] === 'boolean') setVisibility(id, data['visible']);
255
+ if (typeof data['opacity'] === 'number') setOpacity(id, data['opacity']);
256
+ if (typeof data['blendMode'] === 'string') setBlendMode(id, data['blendMode'] as BlendMode);
257
+ if (typeof data['locked'] === 'boolean') setLocked(id, data['locked']);
258
+ break;
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Apply a frame structure patch (add / remove / reorder / set_duration).
265
+ */
266
+ function applyFramePatch(patch: FramePatch): void {
267
+ switch (patch.action) {
268
+ case 'add':
269
+ // Insert a frame; afterIndex = frameIndex - 1 so the new frame lands at frameIndex
270
+ addFrame({ afterIndex: (patch.frameIndex ?? getFrames().length) - 1 });
271
+ break;
272
+ case 'remove':
273
+ if (patch.frameIndex !== undefined) removeFrame(patch.frameIndex);
274
+ break;
275
+ case 'reorder':
276
+ if (patch.fromIndex !== undefined && patch.toIndex !== undefined)
277
+ reorderFrame(patch.fromIndex, patch.toIndex);
278
+ break;
279
+ case 'set_duration':
280
+ if (patch.frameIndex !== undefined)
281
+ setFrameDuration(patch.frameIndex, patch.durationMs ?? null);
282
+ break;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Recomposite all visible layers into the render buffer.
288
+ * Mirrors the recomposite logic in canvas-init-plugin / file-open.
289
+ */
290
+ function recomposite(): void {
291
+ const currentFrame = getCurrentFrame();
292
+ const layers = getLayers();
293
+ const result = composite(
294
+ layers,
295
+ currentFrame.pixelData,
296
+ canvasState.canvasWidth,
297
+ canvasState.canvasHeight,
298
+ );
299
+ setRenderBuffer(result);
300
+ }