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,282 @@
1
+ """WebSocket message handler for PixelWeaver.
2
+
3
+ Routes incoming messages to the appropriate handler based on type, validates
4
+ them through the protocol models, and coordinates responses/broadcasts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from wesktop import WebSocket
15
+
16
+ from pixelweaver.connections import ConnectionManager
17
+ from pixelweaver.protocol import (
18
+ CommandMessage,
19
+ RedoMessage,
20
+ UndoMessage,
21
+ parse_client_message,
22
+ )
23
+ from pixelweaver.state import ServerState, build_full_state_patch
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Callback type for notifying the auto-saver that state changed
28
+ DirtyCallback = Callable[[], None] | None
29
+
30
+
31
+ async def handle_message(
32
+ websocket: WebSocket,
33
+ raw: dict[str, Any],
34
+ state: ServerState,
35
+ manager: ConnectionManager,
36
+ on_dirty: DirtyCallback = None,
37
+ ) -> None:
38
+ """Dispatch a parsed JSON message from a client.
39
+
40
+ Never raises -- all errors are sent back to the client as error messages.
41
+ """
42
+ try:
43
+ msg = parse_client_message(raw)
44
+ except (ValueError, Exception) as exc:
45
+ # Malformed message -- send error back
46
+ msg_id = raw.get("id", str(uuid.uuid4()))
47
+ await manager.send(
48
+ websocket,
49
+ {"type": "error", "id": msg_id, "error": f"Invalid message: {exc}"},
50
+ )
51
+ return
52
+
53
+ match msg:
54
+ case CommandMessage():
55
+ await _handle_command(websocket, msg, state, manager, on_dirty)
56
+ case _ if msg.type == "sync_request":
57
+ await _handle_sync_request(websocket, msg, state, manager)
58
+ case UndoMessage():
59
+ await _handle_undo(websocket, msg, state, manager, on_dirty)
60
+ case RedoMessage():
61
+ await _handle_redo(websocket, msg, state, manager, on_dirty)
62
+
63
+
64
+ async def _handle_command(
65
+ websocket: WebSocket,
66
+ msg: CommandMessage,
67
+ state: ServerState,
68
+ manager: ConnectionManager,
69
+ on_dirty: DirtyCallback = None,
70
+ ) -> None:
71
+ """Validate command, store in history, ack sender, broadcast to others."""
72
+ project = state.get_active_project()
73
+ if project is None:
74
+ await manager.send(
75
+ websocket,
76
+ {
77
+ "type": "command_reject",
78
+ "id": msg.id,
79
+ "command_id": msg.command.id,
80
+ "error": "No active project",
81
+ },
82
+ )
83
+ return
84
+
85
+ # Store command in history (as a plain dict for JSON serialization).
86
+ # Hold the shared lock so we don't interleave with an in-flight MCP
87
+ # mutation -- both paths must see a consistent history/redo stack.
88
+ cmd_dict = msg.command.model_dump()
89
+ async with state.mutation_lock:
90
+ project.command_history.append(cmd_dict)
91
+ # Any new command clears the redo stack
92
+ project.redo_stack.clear()
93
+
94
+ # Ack the sender
95
+ await manager.send(
96
+ websocket,
97
+ {
98
+ "type": "command_ack",
99
+ "id": msg.id,
100
+ "command_id": msg.command.id,
101
+ "success": True,
102
+ },
103
+ )
104
+
105
+ # Broadcast to other clients
106
+ await manager.broadcast(
107
+ {"type": "command_broadcast", "command": cmd_dict, "source": "client"},
108
+ exclude=websocket,
109
+ )
110
+
111
+ if on_dirty:
112
+ on_dirty()
113
+
114
+
115
+ async def _handle_sync_request(
116
+ websocket: WebSocket,
117
+ msg: Any,
118
+ state: ServerState,
119
+ manager: ConnectionManager,
120
+ ) -> None:
121
+ """Send full state snapshot to the requesting client."""
122
+ sync_state = state.get_sync_state()
123
+ await manager.send(
124
+ websocket,
125
+ {
126
+ "type": "state_sync",
127
+ "id": msg.id,
128
+ "state": sync_state,
129
+ },
130
+ )
131
+
132
+
133
+ async def _handle_undo(
134
+ websocket: WebSocket,
135
+ msg: UndoMessage,
136
+ state: ServerState,
137
+ manager: ConnectionManager,
138
+ on_dirty: DirtyCallback = None,
139
+ ) -> None:
140
+ """Pop last command from history onto redo stack."""
141
+ # Lock around the check + pop so a concurrent MCP/WS mutation can't race
142
+ # the empty-history guard. The lock is released before any I/O.
143
+ undone: dict[str, Any] | None = None
144
+ error: str | None = None
145
+ async with state.mutation_lock:
146
+ project = state.get_active_project()
147
+ if project is None:
148
+ error = "No active project"
149
+ elif not project.command_history:
150
+ error = "Nothing to undo"
151
+ else:
152
+ undone = project.command_history.pop()
153
+ project.redo_stack.append(undone)
154
+
155
+ if undone is None:
156
+ await manager.send(
157
+ websocket,
158
+ {"type": "error", "id": msg.id, "error": error},
159
+ )
160
+ return
161
+
162
+ await manager.send(
163
+ websocket,
164
+ {
165
+ "type": "command_ack",
166
+ "id": msg.id,
167
+ "command_id": undone["id"],
168
+ "success": True,
169
+ },
170
+ )
171
+
172
+ # Broadcast undo to other clients
173
+ undo_broadcast = {
174
+ "type": "command_broadcast",
175
+ "command": {"type": "undo", "undone": undone},
176
+ "source": "client",
177
+ }
178
+ await manager.broadcast(undo_broadcast, exclude=websocket)
179
+
180
+ if on_dirty:
181
+ on_dirty()
182
+
183
+
184
+ async def _handle_redo(
185
+ websocket: WebSocket,
186
+ msg: RedoMessage,
187
+ state: ServerState,
188
+ manager: ConnectionManager,
189
+ on_dirty: DirtyCallback = None,
190
+ ) -> None:
191
+ """Pop last undone command from redo stack back into history."""
192
+ redone: dict[str, Any] | None = None
193
+ error: str | None = None
194
+ async with state.mutation_lock:
195
+ project = state.get_active_project()
196
+ if project is None:
197
+ error = "No active project"
198
+ elif not project.redo_stack:
199
+ error = "Nothing to redo"
200
+ else:
201
+ redone = project.redo_stack.pop()
202
+ project.command_history.append(redone)
203
+
204
+ if redone is None:
205
+ await manager.send(
206
+ websocket,
207
+ {"type": "error", "id": msg.id, "error": error},
208
+ )
209
+ return
210
+
211
+ await manager.send(
212
+ websocket,
213
+ {
214
+ "type": "command_ack",
215
+ "id": msg.id,
216
+ "command_id": redone["id"],
217
+ "success": True,
218
+ },
219
+ )
220
+
221
+ # Broadcast redo to other clients
222
+ redo_broadcast = {
223
+ "type": "command_broadcast",
224
+ "command": {"type": "redo", "redone": redone},
225
+ "source": "client",
226
+ }
227
+ await manager.broadcast(redo_broadcast, exclude=websocket)
228
+
229
+ if on_dirty:
230
+ on_dirty()
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Patch broadcasting -- used by MCP when it mutates server state
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ async def broadcast_patches(
239
+ connections: ConnectionManager,
240
+ project_name: str,
241
+ patches: list[dict[str, Any]],
242
+ command_id: str | None = None,
243
+ exclude_ws: WebSocket | None = None,
244
+ ) -> None:
245
+ """Broadcast data patches to all connected WebSocket clients.
246
+
247
+ After MCP modifies server state, call this with the resulting patches
248
+ so every frontend can apply the changes. *exclude_ws* allows skipping
249
+ the originating client (e.g. the one that sent a frontend command --
250
+ it already applied the mutation locally).
251
+ """
252
+ message = {
253
+ "type": "state_patch",
254
+ "project_name": project_name,
255
+ "patches": patches,
256
+ "command_id": command_id,
257
+ }
258
+ await connections.broadcast(message, exclude=exclude_ws)
259
+
260
+
261
+ async def broadcast_full_state(
262
+ state: ServerState,
263
+ connections: ConnectionManager,
264
+ project_name: str | None = None,
265
+ command_id: str | None = None,
266
+ exclude_ws: WebSocket | None = None,
267
+ ) -> None:
268
+ """Build a full-state patch from the active project and broadcast it.
269
+
270
+ Convenience wrapper combining build_full_state_patch + broadcast_patches.
271
+ Used by MCP tools after they mutate pixel data / layers / canvas.
272
+ """
273
+ name = project_name or state.active_project
274
+ if name is None:
275
+ return
276
+ project = state.get_project(name)
277
+ if project is None:
278
+ return
279
+ patch = build_full_state_patch(project)
280
+ await broadcast_patches(
281
+ connections, name, [patch], command_id=command_id, exclude_ws=exclude_ws,
282
+ )
File without changes
@@ -0,0 +1,17 @@
1
+ import pytest
2
+ from wesktop.testing import TestClient
3
+ from pixelweaver.main import app, state
4
+
5
+
6
+ @pytest.fixture
7
+ def client():
8
+ return TestClient(app)
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def _reset_state():
13
+ state.projects.clear()
14
+ state.active_project = None
15
+ yield
16
+ state.projects.clear()
17
+ state.active_project = None
@@ -0,0 +1,96 @@
1
+ """Tests for the REST API endpoints."""
2
+
3
+ from pixelweaver.main import state
4
+
5
+
6
+ class TestCreateProject:
7
+ def test_create_returns_201(self, client, tmp_path):
8
+ """Creating a project should return 201 with project metadata."""
9
+ from pixelweaver.config import set_data_dir
10
+ set_data_dir(str(tmp_path))
11
+
12
+ response = client.post("/api/projects", json={
13
+ "name": "my-sprite",
14
+ "width": 32,
15
+ "height": 32,
16
+ })
17
+ assert response.status_code == 201
18
+ data = response.json()
19
+ assert data["name"] == "my-sprite"
20
+ assert data["width"] == 32
21
+
22
+ def test_create_duplicate_returns_409(self, client, tmp_path):
23
+ from pixelweaver.config import set_data_dir
24
+ set_data_dir(str(tmp_path))
25
+
26
+ body = {"name": "dup", "width": 16, "height": 16}
27
+ client.post("/api/projects", json=body)
28
+ response = client.post("/api/projects", json=body)
29
+ assert response.status_code == 409
30
+
31
+
32
+ class TestListProjects:
33
+ def test_list_empty(self, client):
34
+ response = client.get("/api/projects")
35
+ assert response.status_code == 200
36
+ assert response.json() == {"projects": []}
37
+
38
+ def test_list_includes_created(self, client, tmp_path):
39
+ from pixelweaver.config import set_data_dir
40
+ set_data_dir(str(tmp_path))
41
+
42
+ client.post("/api/projects", json={"name": "proj-a", "width": 8, "height": 8})
43
+ response = client.get("/api/projects")
44
+ assert "proj-a" in response.json()["projects"]
45
+
46
+
47
+ class TestGetProject:
48
+ def test_get_existing(self, client, tmp_path):
49
+ from pixelweaver.config import set_data_dir
50
+ set_data_dir(str(tmp_path))
51
+
52
+ client.post("/api/projects", json={"name": "proj-b", "width": 16, "height": 16})
53
+ response = client.get("/api/projects/proj-b")
54
+ assert response.status_code == 200
55
+ data = response.json()
56
+ assert data["name"] == "proj-b"
57
+ assert "canvases" in data
58
+
59
+ def test_get_nonexistent_returns_404(self, client):
60
+ response = client.get("/api/projects/no-such-project")
61
+ assert response.status_code == 404
62
+
63
+
64
+ class TestDeleteProject:
65
+ def test_delete_existing_returns_204(self, client, tmp_path):
66
+ from pixelweaver.config import set_data_dir
67
+ set_data_dir(str(tmp_path))
68
+
69
+ client.post("/api/projects", json={"name": "to-delete", "width": 8, "height": 8})
70
+ response = client.delete("/api/projects/to-delete")
71
+ assert response.status_code == 204
72
+
73
+ # Verify it's gone
74
+ response = client.get("/api/projects/to-delete")
75
+ assert response.status_code == 404
76
+
77
+ def test_delete_nonexistent_returns_404(self, client):
78
+ response = client.delete("/api/projects/no-such-project")
79
+ assert response.status_code == 404
80
+
81
+
82
+ class TestExportPng:
83
+ def test_export_returns_png(self, client, tmp_path):
84
+ from pixelweaver.config import set_data_dir
85
+ set_data_dir(str(tmp_path))
86
+
87
+ client.post("/api/projects", json={"name": "export-test", "width": 8, "height": 8})
88
+ response = client.get("/api/projects/export-test/export/png/canvas-0/0")
89
+ assert response.status_code == 200
90
+ assert response.headers["content-type"] == "image/png"
91
+ # PNG magic bytes
92
+ assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
93
+
94
+ def test_export_nonexistent_project_returns_404(self, client):
95
+ response = client.get("/api/projects/nope/export/png/canvas-0/0")
96
+ assert response.status_code == 404
@@ -0,0 +1,161 @@
1
+ """Tests for DesktopBridge (pywebview file I/O bridge)."""
2
+
3
+ import base64
4
+ import sys
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from pixelweaver.bridge import DesktopBridge
11
+
12
+
13
+ @pytest.fixture()
14
+ def mock_webview():
15
+ """Inject a mock webview module into sys.modules for late imports."""
16
+ mock = MagicMock()
17
+ mock.SAVE_DIALOG = 20
18
+ mock.OPEN_DIALOG = 10
19
+ mock.FOLDER_DIALOG = 30
20
+ with patch.dict(sys.modules, {"webview": mock}):
21
+ yield mock
22
+
23
+
24
+ def test_save_file_writes_data(mock_webview, tmp_path):
25
+ """save_file decodes base64 data and writes it to the chosen path."""
26
+ mock_window = MagicMock()
27
+ mock_webview.windows = [mock_window]
28
+
29
+ out_path = str(tmp_path / "test.pwv")
30
+ mock_window.create_file_dialog.return_value = (out_path,)
31
+
32
+ bridge = DesktopBridge()
33
+ data = b"hello world"
34
+ result = bridge.save_file(
35
+ base64.b64encode(data).decode(), "test.pwv", "PixelWeaver", ["pwv"]
36
+ )
37
+
38
+ assert result == out_path
39
+ assert Path(out_path).read_bytes() == data
40
+ mock_window.create_file_dialog.assert_called_once_with(
41
+ 20,
42
+ save_filename="test.pwv",
43
+ file_types=("PixelWeaver (*.pwv)",),
44
+ )
45
+
46
+
47
+ def test_save_file_cancelled(mock_webview, tmp_path):
48
+ """save_file returns None when the user cancels the dialog."""
49
+ mock_window = MagicMock()
50
+ mock_webview.windows = [mock_window]
51
+ mock_window.create_file_dialog.return_value = None
52
+
53
+ bridge = DesktopBridge()
54
+ result = bridge.save_file(
55
+ base64.b64encode(b"data").decode(), "test.pwv", "PixelWeaver", ["pwv"]
56
+ )
57
+
58
+ assert result is None
59
+ # Nothing should have been written
60
+ assert not list(tmp_path.iterdir())
61
+
62
+
63
+ def test_open_file_reads_data(mock_webview, tmp_path):
64
+ """open_file reads the selected file and returns path + base64 data."""
65
+ mock_window = MagicMock()
66
+ mock_webview.windows = [mock_window]
67
+
68
+ test_file = tmp_path / "image.png"
69
+ content = b"\x89PNG\r\n\x1a\nfake png data"
70
+ test_file.write_bytes(content)
71
+
72
+ mock_window.create_file_dialog.return_value = (str(test_file),)
73
+
74
+ bridge = DesktopBridge()
75
+ result = bridge.open_file("Images", ["png", "jpg"])
76
+
77
+ assert result is not None
78
+ assert result["path"] == str(test_file)
79
+ assert base64.b64decode(result["data"]) == content
80
+ mock_window.create_file_dialog.assert_called_once_with(
81
+ 10,
82
+ file_types=("Images (*.png *.jpg)",),
83
+ )
84
+
85
+
86
+ def test_open_file_cancelled(mock_webview):
87
+ """open_file returns None when the user cancels the dialog."""
88
+ mock_window = MagicMock()
89
+ mock_webview.windows = [mock_window]
90
+ mock_window.create_file_dialog.return_value = None
91
+
92
+ bridge = DesktopBridge()
93
+ result = bridge.open_file("Images", ["png"])
94
+
95
+ assert result is None
96
+
97
+
98
+ def test_write_file_success(tmp_path):
99
+ """write_file decodes and writes data to the given path."""
100
+ out_path = str(tmp_path / "output.bin")
101
+ data = b"\x00\x01\x02\x03"
102
+
103
+ bridge = DesktopBridge()
104
+ result = bridge.write_file(out_path, base64.b64encode(data).decode())
105
+
106
+ assert result is True
107
+ assert Path(out_path).read_bytes() == data
108
+
109
+
110
+ def test_write_file_creates_parent_dirs(tmp_path):
111
+ """write_file creates intermediate directories as needed."""
112
+ out_path = str(tmp_path / "deep" / "nested" / "dir" / "file.dat")
113
+ data = b"nested file content"
114
+
115
+ bridge = DesktopBridge()
116
+ result = bridge.write_file(out_path, base64.b64encode(data).decode())
117
+
118
+ assert result is True
119
+ assert Path(out_path).read_bytes() == data
120
+
121
+
122
+ def test_pick_directory_returns_path(mock_webview):
123
+ """pick_directory returns the selected directory path."""
124
+ mock_window = MagicMock()
125
+ mock_webview.windows = [mock_window]
126
+ mock_window.create_file_dialog.return_value = ("/home/user/exports",)
127
+
128
+ bridge = DesktopBridge()
129
+ result = bridge.pick_directory()
130
+
131
+ assert result == "/home/user/exports"
132
+ mock_window.create_file_dialog.assert_called_once_with(30)
133
+
134
+
135
+ def test_pick_directory_cancelled(mock_webview):
136
+ """pick_directory returns None when the user cancels."""
137
+ mock_window = MagicMock()
138
+ mock_webview.windows = [mock_window]
139
+ mock_window.create_file_dialog.return_value = None
140
+
141
+ bridge = DesktopBridge()
142
+ result = bridge.pick_directory()
143
+
144
+ assert result is None
145
+
146
+
147
+ def test_write_files_to_directory(tmp_path):
148
+ """write_files_to_directory writes all files to the target directory."""
149
+ files = [
150
+ {"name": "frame_0.png", "data": base64.b64encode(b"png0").decode()},
151
+ {"name": "frame_1.png", "data": base64.b64encode(b"png1").decode()},
152
+ {"name": "sub/frame_2.png", "data": base64.b64encode(b"png2").decode()},
153
+ ]
154
+
155
+ bridge = DesktopBridge()
156
+ result = bridge.write_files_to_directory(str(tmp_path / "output"), files)
157
+
158
+ assert result is True
159
+ assert (tmp_path / "output" / "frame_0.png").read_bytes() == b"png0"
160
+ assert (tmp_path / "output" / "frame_1.png").read_bytes() == b"png1"
161
+ assert (tmp_path / "output" / "sub" / "frame_2.png").read_bytes() == b"png2"
@@ -0,0 +1,9 @@
1
+ """Test the health check endpoint."""
2
+
3
+
4
+ def test_health_returns_ok(client):
5
+ """Health endpoint should return 200 with status ok."""
6
+ response = client.get("/health")
7
+
8
+ assert response.status_code == 200
9
+ assert response.json() == {"status": "ok"}
@@ -0,0 +1,86 @@
1
+ """Integration test: full project lifecycle through the API."""
2
+
3
+ from pixelweaver.main import app
4
+ from wesktop.testing import TestClient
5
+
6
+
7
+ def test_project_lifecycle(client, tmp_path):
8
+ """Create, read, export, and delete a project."""
9
+ from pixelweaver.config import set_data_dir
10
+ set_data_dir(str(tmp_path))
11
+
12
+ # Create
13
+ resp = client.post("/api/projects", json={
14
+ "name": "integration-test",
15
+ "width": 16,
16
+ "height": 16,
17
+ })
18
+ assert resp.status_code == 201
19
+
20
+ # List
21
+ resp = client.get("/api/projects")
22
+ assert resp.status_code == 200
23
+ assert "integration-test" in resp.json()["projects"]
24
+
25
+ # Get
26
+ resp = client.get("/api/projects/integration-test")
27
+ assert resp.status_code == 200
28
+ data = resp.json()
29
+ assert "canvases" in data
30
+ assert len(data["canvases"]) > 0
31
+
32
+ # Export PNG
33
+ canvas_name = list(data["canvases"].keys())[0]
34
+ resp = client.get(f"/api/projects/integration-test/export/png/{canvas_name}/0")
35
+ assert resp.status_code == 200
36
+ assert resp.headers.get("content-type") == "image/png"
37
+ assert len(resp.content) > 0
38
+
39
+ # State endpoints (MCP bridge)
40
+ resp = client.get("/api/state/full")
41
+ assert resp.status_code == 200
42
+ state = resp.json()
43
+ assert "integration-test" in state.get("projects", {})
44
+
45
+ # Delete
46
+ resp = client.delete("/api/projects/integration-test")
47
+ assert resp.status_code == 204
48
+
49
+ # Verify deleted
50
+ resp = client.get("/api/projects/integration-test")
51
+ assert resp.status_code == 404
52
+
53
+
54
+ def test_validation_errors(client):
55
+ """Test that invalid requests return proper error codes."""
56
+ # Invalid project name (special chars)
57
+ resp = client.post("/api/projects", json={
58
+ "name": "../evil",
59
+ "width": 16,
60
+ "height": 16,
61
+ })
62
+ assert resp.status_code == 422
63
+
64
+ # Width too large
65
+ resp = client.post("/api/projects", json={
66
+ "name": "too-big",
67
+ "width": 9999,
68
+ "height": 16,
69
+ })
70
+ assert resp.status_code == 422
71
+
72
+ # Missing fields
73
+ resp = client.post("/api/projects", json={"name": "incomplete"})
74
+ assert resp.status_code == 422
75
+
76
+ # Get nonexistent
77
+ resp = client.get("/api/projects/does-not-exist")
78
+ assert resp.status_code == 404
79
+
80
+ # Delete nonexistent
81
+ resp = client.delete("/api/projects/does-not-exist")
82
+ assert resp.status_code == 404
83
+
84
+ # Export from nonexistent
85
+ resp = client.get("/api/projects/nope/export/png/canvas-0/0")
86
+ assert resp.status_code == 404