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,267 @@
1
+ """In-memory authoritative state for PixelWeaver projects.
2
+
3
+ The server is the single source of truth. All state mutations flow through
4
+ ServerState, which is then persisted to disk by the auto-saver.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from pixelweaver.mcp_lock import MCPLock
15
+
16
+
17
+ @dataclass
18
+ class FrameState:
19
+ """Single animation frame within a canvas.
20
+
21
+ Each frame holds its own pixel data per layer. The id is a stable UUID
22
+ that matches the frontend Frame.id. duration_ms=None means "derive
23
+ from global_fps".
24
+ """
25
+
26
+ id: str
27
+ duration_ms: int | None = None
28
+ pixel_data: dict[str, bytes] = field(default_factory=dict)
29
+
30
+
31
+ @dataclass
32
+ class CanvasState:
33
+ """State for a single canvas within a project."""
34
+
35
+ name: str
36
+ width: int
37
+ height: int
38
+ layers: list[dict[str, Any]] = field(default_factory=list)
39
+ # Pixel data lives exclusively in FrameState.pixel_data (per frame,
40
+ # per layer). Access via canvas.current_frame().pixel_data[layer_id].
41
+ frames: list[FrameState] = field(default_factory=list)
42
+ current_frame_index: int = 0
43
+ global_fps: float = 12.0
44
+ origin_x: int = 0
45
+ origin_y: int = 0
46
+
47
+ def current_frame(self) -> FrameState:
48
+ """Return the currently selected frame (bounds-checked)."""
49
+ if not self.frames:
50
+ raise ValueError("Canvas has no frames")
51
+ idx = max(0, min(self.current_frame_index, len(self.frames) - 1))
52
+ return self.frames[idx]
53
+
54
+ def frame_at(self, index: int) -> FrameState:
55
+ """Return frame at the given index (bounds-checked)."""
56
+ if index < 0 or index >= len(self.frames):
57
+ raise IndexError(
58
+ f"Frame index {index} out of range [0, {len(self.frames)})"
59
+ )
60
+ return self.frames[index]
61
+
62
+ @property
63
+ def frame_count(self) -> int:
64
+ """Number of frames in this canvas."""
65
+ return len(self.frames)
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ """Serialize to a JSON-compatible dict (excludes heavy pixel data)."""
69
+ return {
70
+ "name": self.name,
71
+ "width": self.width,
72
+ "height": self.height,
73
+ "layers": self.layers,
74
+ "frames": [
75
+ {"id": f.id, "duration_ms": f.duration_ms} for f in self.frames
76
+ ],
77
+ "current_frame_index": self.current_frame_index,
78
+ "global_fps": self.global_fps,
79
+ }
80
+
81
+ def to_full_dict(self) -> dict[str, Any]:
82
+ """Serialize including info about which layers have pixel data."""
83
+ d = self.to_dict()
84
+ d["pixel_data_layers"] = list(self.current_frame().pixel_data.keys())
85
+ return d
86
+
87
+
88
+ @dataclass
89
+ class ProjectState:
90
+ """State for a single project."""
91
+
92
+ name: str
93
+ width: int
94
+ height: int
95
+ canvases: dict[str, CanvasState] = field(default_factory=dict)
96
+ command_history: list[dict[str, Any]] = field(default_factory=list)
97
+ # Commands that were undone -- available for redo
98
+ redo_stack: list[dict[str, Any]] = field(default_factory=list)
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ """Serialize to a JSON-compatible dict for state sync."""
102
+ return {
103
+ "name": self.name,
104
+ "width": self.width,
105
+ "height": self.height,
106
+ "canvases": {k: v.to_dict() for k, v in self.canvases.items()},
107
+ "history": self.command_history,
108
+ }
109
+
110
+
111
+ class ServerState:
112
+ """In-memory authoritative state for all projects."""
113
+
114
+ def __init__(self) -> None:
115
+ self.projects: dict[str, ProjectState] = {}
116
+ self.active_project: str | None = None
117
+ # Shared mutex protecting concurrent state mutations from MCP tool
118
+ # calls and WebSocket command handlers within the same process.
119
+ self.mutation_lock = MCPLock()
120
+
121
+ # Hard ceiling on canvas dimensions to prevent OOM allocation.
122
+ # MCP caps at 1024, REST at 4096; this is the last line of defense.
123
+ MAX_DIMENSION = 4096
124
+
125
+ def create_project(self, name: str, width: int, height: int) -> ProjectState:
126
+ """Create a new project with a default canvas and layer."""
127
+ if not (1 <= width <= self.MAX_DIMENSION and 1 <= height <= self.MAX_DIMENSION):
128
+ raise ValueError(
129
+ f"Dimensions must be 1..{self.MAX_DIMENSION}, got {width}x{height}"
130
+ )
131
+ project = ProjectState(name=name, width=width, height=height)
132
+
133
+ # Create one default canvas with a single frame and layer
134
+ default_layer_id = str(uuid.uuid4())
135
+ pixel_data = {default_layer_id: bytes(width * height * 4)}
136
+ frame = FrameState(id=str(uuid.uuid4()), pixel_data=pixel_data)
137
+ canvas = CanvasState(
138
+ name="canvas-0",
139
+ width=width,
140
+ height=height,
141
+ layers=[
142
+ {
143
+ "id": default_layer_id,
144
+ "name": "Layer 0",
145
+ "visible": True,
146
+ "opacity": 1.0,
147
+ }
148
+ ],
149
+ frames=[frame],
150
+ )
151
+
152
+ project.canvases["canvas-0"] = canvas
153
+ self.projects[name] = project
154
+
155
+ if self.active_project is None:
156
+ self.active_project = name
157
+
158
+ return project
159
+
160
+ def get_project(self, name: str) -> ProjectState | None:
161
+ """Get a project by name."""
162
+ return self.projects.get(name)
163
+
164
+ def get_active_project(self) -> ProjectState | None:
165
+ """Get the currently active project."""
166
+ if self.active_project is None:
167
+ return None
168
+ return self.projects.get(self.active_project)
169
+
170
+ def delete_project(self, name: str) -> bool:
171
+ """Delete a project. Returns True if it existed."""
172
+ if name in self.projects:
173
+ del self.projects[name]
174
+ if self.active_project == name:
175
+ # Switch to another project or None
176
+ self.active_project = next(iter(self.projects), None)
177
+ return True
178
+ return False
179
+
180
+ def list_projects(self) -> list[str]:
181
+ """Return names of all projects."""
182
+ return list(self.projects.keys())
183
+
184
+ def get_sync_state(self, project_name: str | None = None) -> dict[str, Any]:
185
+ """Build a full state snapshot for a sync response.
186
+
187
+ If project_name is None, uses the active project.
188
+ """
189
+ name = project_name or self.active_project
190
+ if name is None or name not in self.projects:
191
+ return {
192
+ "project": None,
193
+ "canvases": {},
194
+ "layers": {},
195
+ "history": [],
196
+ }
197
+
198
+ project = self.projects[name]
199
+ return {
200
+ "project": {
201
+ "name": project.name,
202
+ "width": project.width,
203
+ "height": project.height,
204
+ },
205
+ "canvases": {k: v.to_dict() for k, v in project.canvases.items()},
206
+ "layers": {
207
+ canvas_name: canvas.layers
208
+ for canvas_name, canvas in project.canvases.items()
209
+ },
210
+ "history": project.command_history,
211
+ }
212
+
213
+
214
+ def build_full_state_patch(project: ProjectState) -> dict[str, Any]:
215
+ """Build a full-state patch from the current project state.
216
+
217
+ Serializes all canvas data including per-frame pixel buffers as base64
218
+ strings. Used when MCP modifies state and needs to notify WebSocket
219
+ clients. Keys are camelCase for JS consumers.
220
+ """
221
+ canvases: dict[str, Any] = {}
222
+ for name, canvas in project.canvases.items():
223
+ # Layer metadata (without pixel data -- that lives in frames now)
224
+ layers_data = []
225
+ for layer in canvas.layers:
226
+ layers_data.append({
227
+ "id": layer["id"],
228
+ "name": layer.get("name", ""),
229
+ "type": layer.get("type", "pixel"),
230
+ "visible": layer.get("visible", True),
231
+ "opacity": layer.get("opacity", 1.0),
232
+ "blendMode": layer.get("blendMode", "normal"),
233
+ "locked": layer.get("locked", False),
234
+ })
235
+
236
+ # Per-frame pixel data
237
+ frames_data = []
238
+ for frame in canvas.frames:
239
+ frames_data.append({
240
+ "id": frame.id,
241
+ "durationMs": frame.duration_ms,
242
+ "pixelData": {
243
+ layer_id: base64.b64encode(raw_bytes).decode("ascii")
244
+ for layer_id, raw_bytes in frame.pixel_data.items()
245
+ },
246
+ })
247
+
248
+ canvases[name] = {
249
+ "name": canvas.name,
250
+ "width": canvas.width,
251
+ "height": canvas.height,
252
+ "layers": layers_data,
253
+ "frames": frames_data,
254
+ "currentFrameIndex": canvas.current_frame_index,
255
+ "globalFps": canvas.global_fps,
256
+ "originX": canvas.origin_x,
257
+ "originY": canvas.origin_y,
258
+ }
259
+ return {
260
+ "type": "full_state",
261
+ "snapshot": {
262
+ "name": project.name,
263
+ "width": project.width,
264
+ "height": project.height,
265
+ "canvases": canvases,
266
+ },
267
+ }
@@ -0,0 +1,293 @@
1
+ """Project file I/O for PixelWeaver.
2
+
3
+ Saves/loads projects as directory structures:
4
+ <base_dir>/<project_name>/
5
+ project.json -- project metadata
6
+ canvases/<canvas_name>/
7
+ canvas.json -- canvas metadata (dimensions, layer tree)
8
+ frames.json -- frame manifest (IDs, durations, playback state)
9
+ frames/<uuid>/
10
+ layer-<id>.png -- raw pixel data as PNG per layer
11
+ history.json -- command history for this canvas
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import logging
19
+ import shutil
20
+ from io import BytesIO
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from PIL import Image
25
+
26
+ from pixelweaver.state import CanvasState, FrameState, ProjectState
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _assert_within_base(project_dir: Path, base_dir: Path) -> None:
32
+ """Raise ValueError if project_dir escapes base_dir (path traversal guard)."""
33
+ if not project_dir.resolve().is_relative_to(base_dir.resolve()):
34
+ raise ValueError(
35
+ f"Path traversal detected: {project_dir} is not inside {base_dir}"
36
+ )
37
+
38
+
39
+ def save_project(project: ProjectState, base_dir: str) -> None:
40
+ """Save a project to disk as a directory structure."""
41
+ project_dir = Path(base_dir) / project.name
42
+ _assert_within_base(project_dir, Path(base_dir))
43
+ project_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ # project.json -- top-level metadata
46
+ project_meta: dict[str, Any] = {
47
+ "name": project.name,
48
+ "width": project.width,
49
+ "height": project.height,
50
+ "canvases": list(project.canvases.keys()),
51
+ }
52
+ (project_dir / "project.json").write_text(json.dumps(project_meta, indent=2))
53
+
54
+ # Each canvas
55
+ for canvas_name, canvas in project.canvases.items():
56
+ _save_canvas(canvas, project_dir / "canvases" / canvas_name)
57
+
58
+ # Global command history (all commands across canvases)
59
+ (project_dir / "history.json").write_text(json.dumps(project.command_history, indent=2))
60
+
61
+
62
+ def _save_canvas(canvas: CanvasState, canvas_dir: Path) -> None:
63
+ """Save a single canvas to its directory."""
64
+ canvas_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ # canvas.json -- layer metadata (unchanged)
67
+ canvas_meta: dict[str, Any] = {
68
+ "name": canvas.name,
69
+ "width": canvas.width,
70
+ "height": canvas.height,
71
+ "layers": canvas.layers,
72
+ }
73
+ (canvas_dir / "canvas.json").write_text(json.dumps(canvas_meta, indent=2))
74
+
75
+ # frames.json -- frame manifest with playback state
76
+ frames_manifest: dict[str, Any] = {
77
+ "frames": [
78
+ {"id": frame.id, "duration_ms": frame.duration_ms}
79
+ for frame in canvas.frames
80
+ ],
81
+ "current_frame_index": canvas.current_frame_index,
82
+ "global_fps": canvas.global_fps,
83
+ "origin_x": canvas.origin_x,
84
+ "origin_y": canvas.origin_y,
85
+ }
86
+ (canvas_dir / "frames.json").write_text(json.dumps(frames_manifest, indent=2))
87
+
88
+ # Collect the set of frame IDs that should exist on disk
89
+ live_frame_ids = {frame.id for frame in canvas.frames}
90
+
91
+ # Save each frame's pixel data in frames/<uuid>/
92
+ frames_dir = canvas_dir / "frames"
93
+ frames_dir.mkdir(parents=True, exist_ok=True)
94
+
95
+ for frame in canvas.frames:
96
+ frame_dir = frames_dir / frame.id
97
+ frame_dir.mkdir(parents=True, exist_ok=True)
98
+ for layer in canvas.layers:
99
+ layer_id = layer["id"]
100
+ pixel_bytes = frame.pixel_data.get(layer_id)
101
+ if pixel_bytes is None:
102
+ continue
103
+ png_path = frame_dir / f"layer-{layer_id}.png"
104
+ _save_layer_png(pixel_bytes, canvas.width, canvas.height, png_path)
105
+
106
+ # Cleanup: remove frame directories that are no longer in canvas.frames
107
+ # (includes old "0" directory from the pre-UUID format)
108
+ if frames_dir.is_dir():
109
+ for child in frames_dir.iterdir():
110
+ if child.is_dir() and child.name not in live_frame_ids:
111
+ shutil.rmtree(child)
112
+
113
+
114
+ def _save_layer_png(pixel_bytes: bytes, width: int, height: int, path: Path) -> None:
115
+ """Write raw RGBA bytes as a PNG file."""
116
+ expected_size = width * height * 4
117
+ if len(pixel_bytes) != expected_size:
118
+ logger.warning(
119
+ "Pixel data size mismatch for %s: expected %d, got %d",
120
+ path,
121
+ expected_size,
122
+ len(pixel_bytes),
123
+ )
124
+ return
125
+
126
+ img = Image.frombytes("RGBA", (width, height), pixel_bytes)
127
+ img.save(path, format="PNG")
128
+
129
+
130
+ def load_project(project_dir: str) -> ProjectState:
131
+ """Load a project from its directory on disk."""
132
+ pdir = Path(project_dir)
133
+
134
+ project_meta = json.loads((pdir / "project.json").read_text())
135
+
136
+ # Defense-in-depth: the name stored in project.json will be used later by
137
+ # save_project / autosave. Verify it cannot escape the parent data dir.
138
+ loaded_name = project_meta["name"]
139
+ _assert_within_base(pdir.parent / loaded_name, pdir.parent)
140
+
141
+ project = ProjectState(
142
+ name=loaded_name,
143
+ width=project_meta["width"],
144
+ height=project_meta["height"],
145
+ )
146
+
147
+ # Load canvases -- verify each canvas name stays inside the project dir
148
+ canvas_names: list[str] = project_meta.get("canvases", [])
149
+ for canvas_name in canvas_names:
150
+ canvas_dir = pdir / "canvases" / canvas_name
151
+ _assert_within_base(canvas_dir, pdir)
152
+ if canvas_dir.is_dir():
153
+ project.canvases[canvas_name] = _load_canvas(canvas_dir)
154
+
155
+ # Load global command history
156
+ history_path = pdir / "history.json"
157
+ if history_path.exists():
158
+ project.command_history = json.loads(history_path.read_text())
159
+
160
+ return project
161
+
162
+
163
+ def _load_canvas(canvas_dir: Path) -> CanvasState:
164
+ """Load a single canvas from its directory.
165
+
166
+ Requires frames.json to exist. Old pre-UUID format (frames/0/) is not
167
+ supported -- use the migration script (Step 11) to convert old data.
168
+ """
169
+ canvas_meta = json.loads((canvas_dir / "canvas.json").read_text())
170
+ layers = canvas_meta.get("layers", [])
171
+
172
+ frames_json_path = canvas_dir / "frames.json"
173
+ if not frames_json_path.exists():
174
+ raise FileNotFoundError(
175
+ f"Missing frames.json in {canvas_dir}. "
176
+ "Old format (frames/0/) is no longer supported. "
177
+ "Run the migration script to convert existing projects."
178
+ )
179
+
180
+ manifest = json.loads(frames_json_path.read_text())
181
+
182
+ # Reconstruct each FrameState from manifest + layer PNGs on disk
183
+ frames: list[FrameState] = []
184
+ for frame_entry in manifest["frames"]:
185
+ frame_id = frame_entry["id"]
186
+ duration_ms = frame_entry.get("duration_ms")
187
+ pixel_data: dict[str, bytes] = {}
188
+
189
+ frame_dir = canvas_dir / "frames" / frame_id
190
+ for layer in layers:
191
+ layer_id = layer["id"]
192
+ png_path = frame_dir / f"layer-{layer_id}.png"
193
+ if png_path.exists():
194
+ pixel_data[layer_id] = _load_layer_png(png_path)
195
+ # Missing PNG = transparent layer (empty bytes not stored)
196
+
197
+ frames.append(FrameState(id=frame_id, duration_ms=duration_ms, pixel_data=pixel_data))
198
+
199
+ canvas = CanvasState(
200
+ name=canvas_meta["name"],
201
+ width=canvas_meta["width"],
202
+ height=canvas_meta["height"],
203
+ layers=layers,
204
+ frames=frames,
205
+ current_frame_index=manifest.get("current_frame_index", 0),
206
+ global_fps=manifest.get("global_fps", 12.0),
207
+ origin_x=manifest.get("origin_x", 0),
208
+ origin_y=manifest.get("origin_y", 0),
209
+ )
210
+
211
+ return canvas
212
+
213
+
214
+ def _load_layer_png(path: Path) -> bytes:
215
+ """Read a PNG file and return raw RGBA bytes."""
216
+ img = Image.open(path).convert("RGBA")
217
+ return img.tobytes()
218
+
219
+
220
+ def list_projects(base_dir: str) -> list[str]:
221
+ """List all project directories that contain a project.json."""
222
+ base = Path(base_dir)
223
+ if not base.is_dir():
224
+ return []
225
+ return sorted(
226
+ d.name for d in base.iterdir() if d.is_dir() and (d / "project.json").exists()
227
+ )
228
+
229
+
230
+ def export_frame_png(project: ProjectState, canvas_name: str, frame: int = 0) -> bytes:
231
+ """Composite all visible layers of a canvas frame into a single PNG.
232
+
233
+ Returns PNG bytes suitable for an HTTP response.
234
+
235
+ The ``frame`` parameter selects which frame to composite by index.
236
+ ``canvas.frame_at()`` performs bounds checking and raises ``IndexError``
237
+ for out-of-range indices.
238
+ """
239
+ canvas = project.canvases.get(canvas_name)
240
+ if canvas is None:
241
+ raise ValueError(f"Canvas {canvas_name!r} not found")
242
+
243
+ # frame_at() raises IndexError for out-of-range indices
244
+ target_frame = canvas.frame_at(frame)
245
+
246
+ # Start with a transparent base image
247
+ composite = Image.new("RGBA", (canvas.width, canvas.height), (0, 0, 0, 0))
248
+
249
+ # Composite visible layers bottom-to-top
250
+ for layer in canvas.layers:
251
+ if not layer.get("visible", True):
252
+ continue
253
+ layer_id = layer["id"]
254
+ pixel_bytes = target_frame.pixel_data.get(layer_id)
255
+ if pixel_bytes is None:
256
+ continue
257
+ expected_size = canvas.width * canvas.height * 4
258
+ if len(pixel_bytes) != expected_size:
259
+ continue
260
+ layer_img = Image.frombytes("RGBA", (canvas.width, canvas.height), pixel_bytes)
261
+ opacity = layer.get("opacity", 1.0)
262
+ if opacity < 1.0:
263
+ # Reduce alpha channel by opacity factor
264
+ r, g, b, a = layer_img.split()
265
+ a = a.point(lambda x: int(x * opacity))
266
+ layer_img = Image.merge("RGBA", (r, g, b, a))
267
+ composite = Image.alpha_composite(composite, layer_img)
268
+
269
+ buf = BytesIO()
270
+ composite.save(buf, format="PNG")
271
+ return buf.getvalue()
272
+
273
+
274
+ def make_thumbnail_base64(png_bytes: bytes, max_size: int = 128) -> str:
275
+ """Return a base64-encoded PNG thumbnail of the given PNG bytes.
276
+
277
+ Used by MCP tool handlers to attach a preview of the active canvas to
278
+ mutation results without sending the full composited image. The image
279
+ is only downscaled when one of its dimensions exceeds ``max_size``;
280
+ smaller images pass through untouched. Nearest-neighbor resampling is
281
+ used to preserve pixel-art edges.
282
+
283
+ This helper replaces the previously inlined ``Image.thumbnail`` calls
284
+ in ``mcp_registry.py`` and supersedes the deleted ``thumbnails.py``
285
+ module, which worked from raw RGBA data rather than composited PNG
286
+ bytes.
287
+ """
288
+ img = Image.open(BytesIO(png_bytes))
289
+ if img.width > max_size or img.height > max_size:
290
+ img.thumbnail((max_size, max_size), Image.NEAREST)
291
+ buf = BytesIO()
292
+ img.save(buf, format="PNG")
293
+ return base64.b64encode(buf.getvalue()).decode("ascii")