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,241 @@
1
+ /**
2
+ * Tests for the Command Replay Engine.
3
+ *
4
+ * Registers test commands that set pixels on a PixelBuffer, then exercises
5
+ * the replay engine's core operations: replay, reorder, toggle, and
6
+ * parameter editing.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from 'vitest';
10
+ import type { CommandDefinition, CommandContext } from '../core/commands.js';
11
+ import { commandRegistry } from '../core/registries.svelte.js';
12
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
13
+ import { replayCommands, previewReorder } from './replay-engine.js';
14
+ import type { ActionLogEntry } from './action-log.svelte.js';
15
+
16
+ // --- Test command: sets a single pixel ---
17
+
18
+ function registerSetPixelCommand(): void {
19
+ const def: CommandDefinition = {
20
+ execute(params, ctx: CommandContext) {
21
+ // CommandContext's index signature means ctx["pixelBuffer"] is
22
+ // typed as unknown but may actually be undefined at runtime if
23
+ // the context was built without one, hence the guard.
24
+ const buf = ctx["pixelBuffer"] as PixelBuffer | undefined;
25
+ if (!buf) return;
26
+ const x = params["x"] as number;
27
+ const y = params["y"] as number;
28
+ const r = (params["r"] ?? 255) as number;
29
+ const g = (params["g"] ?? 0) as number;
30
+ const b = (params["b"] ?? 0) as number;
31
+ const a = (params["a"] ?? 255) as number;
32
+ buf.setPixel(x, y, r, g, b, a);
33
+ },
34
+ undo() { /* not needed for replay tests */ },
35
+ describe(params) {
36
+ return `Set pixel at (${String(params["x"])}, ${String(params["y"])})`;
37
+ },
38
+ tier: 'frame',
39
+ };
40
+ commandRegistry.set('set_pixel', def);
41
+ }
42
+
43
+ // --- Test command: fills a rect ---
44
+
45
+ function registerFillRectCommand(): void {
46
+ const def: CommandDefinition = {
47
+ execute(params, ctx: CommandContext) {
48
+ const buf = ctx["pixelBuffer"] as PixelBuffer | undefined;
49
+ if (!buf) return;
50
+ buf.fillRect(
51
+ params["x"] as number,
52
+ params["y"] as number,
53
+ params["w"] as number,
54
+ params["h"] as number,
55
+ (params["r"] ?? 0) as number,
56
+ (params["g"] ?? 255) as number,
57
+ (params["b"] ?? 0) as number,
58
+ (params["a"] ?? 255) as number,
59
+ );
60
+ },
61
+ undo() {},
62
+ describe(params) {
63
+ return `Fill rect at (${String(params["x"])}, ${String(params["y"])})`;
64
+ },
65
+ tier: 'frame',
66
+ };
67
+ commandRegistry.set('fill_rect', def);
68
+ }
69
+
70
+ /** Create a mock ActionLogEntry for testing. */
71
+ function makeLogEntry(
72
+ type: string,
73
+ params: Record<string, unknown>,
74
+ enabled = true,
75
+ ): ActionLogEntry {
76
+ return {
77
+ commandId: crypto.randomUUID(),
78
+ type,
79
+ plugin: 'test/plugin',
80
+ description: `Test ${type}`,
81
+ timestamp: Date.now(),
82
+ enabled,
83
+ params,
84
+ };
85
+ }
86
+
87
+ // --- Tests ---
88
+
89
+ describe('Replay Engine', () => {
90
+ beforeEach(() => {
91
+ registerSetPixelCommand();
92
+ registerFillRectCommand();
93
+ });
94
+
95
+ it('should replay a single command and produce correct pixels', () => {
96
+ const buf = replayCommands(
97
+ [{ type: 'set_pixel', params: { x: 2, y: 3, r: 100, g: 150, b: 200, a: 255 } }],
98
+ 8, 8, 'layer1',
99
+ );
100
+
101
+ expect(buf.getPixel(2, 3)).toEqual([100, 150, 200, 255]);
102
+ // Other pixels should be untouched (default zero)
103
+ expect(buf.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
104
+ });
105
+
106
+ it('should replay multiple commands in order', () => {
107
+ const buf = replayCommands(
108
+ [
109
+ { type: 'set_pixel', params: { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 } },
110
+ { type: 'set_pixel', params: { x: 1, y: 0, r: 0, g: 255, b: 0, a: 255 } },
111
+ { type: 'set_pixel', params: { x: 2, y: 0, r: 0, g: 0, b: 255, a: 255 } },
112
+ ],
113
+ 8, 8, 'layer1',
114
+ );
115
+
116
+ expect(buf.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
117
+ expect(buf.getPixel(1, 0)).toEqual([0, 255, 0, 255]);
118
+ expect(buf.getPixel(2, 0)).toEqual([0, 0, 255, 255]);
119
+ });
120
+
121
+ it('should replay overlapping commands where later overwrites earlier', () => {
122
+ const buf = replayCommands(
123
+ [
124
+ { type: 'set_pixel', params: { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 } },
125
+ { type: 'set_pixel', params: { x: 0, y: 0, r: 0, g: 255, b: 0, a: 255 } },
126
+ ],
127
+ 8, 8, 'layer1',
128
+ );
129
+
130
+ // Second command overwrites the first at (0,0)
131
+ expect(buf.getPixel(0, 0)).toEqual([0, 255, 0, 255]);
132
+ });
133
+
134
+ it('should produce different result when overlapping commands are reordered', () => {
135
+ const entry1 = makeLogEntry('set_pixel', { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 });
136
+ const entry2 = makeLogEntry('set_pixel', { x: 0, y: 0, r: 0, g: 255, b: 0, a: 255 });
137
+
138
+ // Original order: entry1 then entry2 -> pixel is green
139
+ const buf1 = previewReorder(
140
+ [entry1, entry2],
141
+ [entry1.commandId, entry2.commandId],
142
+ 8, 8, 'layer1',
143
+ );
144
+ expect(buf1.getPixel(0, 0)).toEqual([0, 255, 0, 255]);
145
+
146
+ // Reversed order: entry2 then entry1 -> pixel is red
147
+ const buf2 = previewReorder(
148
+ [entry1, entry2],
149
+ [entry2.commandId, entry1.commandId],
150
+ 8, 8, 'layer1',
151
+ );
152
+ expect(buf2.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
153
+ });
154
+
155
+ it('should skip disabled entries during replay', () => {
156
+ const entry1 = makeLogEntry('set_pixel', { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 });
157
+ const entry2 = makeLogEntry('set_pixel', { x: 1, y: 0, r: 0, g: 255, b: 0, a: 255 }, false);
158
+
159
+ const buf = previewReorder(
160
+ [entry1, entry2],
161
+ [entry1.commandId, entry2.commandId],
162
+ 8, 8, 'layer1',
163
+ );
164
+
165
+ // entry1 pixel should be set
166
+ expect(buf.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
167
+ // entry2 is disabled, so pixel should be untouched
168
+ expect(buf.getPixel(1, 0)).toEqual([0, 0, 0, 0]);
169
+ });
170
+
171
+ it('should handle previewReorder without modifying original entries', () => {
172
+ const entry1 = makeLogEntry('set_pixel', { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 });
173
+ const entry2 = makeLogEntry('set_pixel', { x: 1, y: 0, r: 0, g: 255, b: 0, a: 255 });
174
+
175
+ const originalEntries = [entry1, entry2];
176
+ const originalIds = originalEntries.map((e) => e.commandId);
177
+
178
+ // Preview with reversed order
179
+ previewReorder(
180
+ originalEntries,
181
+ [entry2.commandId, entry1.commandId],
182
+ 8, 8, 'layer1',
183
+ );
184
+
185
+ // Original entries should be unchanged
186
+ expect(originalEntries.map((e) => e.commandId)).toEqual(originalIds);
187
+ expect(originalEntries).toHaveLength(2);
188
+ });
189
+
190
+ it('should preview parameter edit with modified params', () => {
191
+ // We need entries in the action log for previewParamEdit.
192
+ // Use previewReorder to test param editing at the replay level directly.
193
+ const entry1 = makeLogEntry('set_pixel', { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 });
194
+
195
+ // Replay with modified params: change color from red to blue
196
+ const commands = [
197
+ { type: entry1.type, params: { x: 0, y: 0, r: 0, g: 0, b: 255, a: 255 } },
198
+ ];
199
+ const buf = replayCommands(commands, 8, 8, 'layer1');
200
+
201
+ expect(buf.getPixel(0, 0)).toEqual([0, 0, 255, 255]);
202
+ });
203
+
204
+ it('should create a fresh buffer of the correct dimensions', () => {
205
+ const buf = replayCommands([], 16, 32, 'layer1');
206
+
207
+ expect(buf.width).toBe(16);
208
+ expect(buf.height).toBe(32);
209
+ // Empty replay produces a blank buffer
210
+ expect(buf.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
211
+ });
212
+
213
+ it('should skip unknown command types gracefully', () => {
214
+ const buf = replayCommands(
215
+ [
216
+ { type: 'nonexistent_command', params: {} },
217
+ { type: 'set_pixel', params: { x: 0, y: 0, r: 255, g: 0, b: 0, a: 255 } },
218
+ ],
219
+ 8, 8, 'layer1',
220
+ );
221
+
222
+ // Unknown command skipped, known command still executed
223
+ expect(buf.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
224
+ });
225
+
226
+ it('should replay fill_rect command covering multiple pixels', () => {
227
+ const buf = replayCommands(
228
+ [{ type: 'fill_rect', params: { x: 0, y: 0, w: 3, h: 2, r: 128, g: 64, b: 32, a: 255 } }],
229
+ 8, 8, 'layer1',
230
+ );
231
+
232
+ // All pixels in the 3x2 rect should be filled
233
+ for (let y = 0; y < 2; y++) {
234
+ for (let x = 0; x < 3; x++) {
235
+ expect(buf.getPixel(x, y)).toEqual([128, 64, 32, 255]);
236
+ }
237
+ }
238
+ // Pixel outside the rect should be untouched
239
+ expect(buf.getPixel(3, 0)).toEqual([0, 0, 0, 0]);
240
+ });
241
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Command Replay Engine -- the core of the semantic history system.
3
+ *
4
+ * Instead of traditional undo/redo that walks backward through state snapshots,
5
+ * the replay engine re-executes commands from scratch on a fresh PixelBuffer.
6
+ * This enables powerful operations like reordering, toggling, and parameter
7
+ * editing that would be impossible with snapshot-based undo.
8
+ *
9
+ * The engine looks up command definitions in the command registry and calls
10
+ * their execute() functions with a temporary command context containing a
11
+ * fresh PixelBuffer.
12
+ */
13
+
14
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
15
+ import { commandRegistry } from '../core/registries.svelte.js';
16
+ import type { CommandContext } from '../core/commands.js';
17
+ import type { ActionLogEntry } from './action-log.svelte.js';
18
+ import { actionLog } from './action-log.svelte.js';
19
+
20
+ // --- Types ---
21
+
22
+ export interface ReplayCommand {
23
+ type: string;
24
+ params: Record<string, unknown>;
25
+ }
26
+
27
+ // --- Core replay ---
28
+
29
+ /**
30
+ * Replay a sequence of commands on a fresh pixel buffer.
31
+ *
32
+ * Creates a new PixelBuffer and a temporary CommandContext, then executes
33
+ * each command in order. Commands that are not registered are skipped with
34
+ * a warning rather than throwing, to be resilient to missing plugins.
35
+ */
36
+ export function replayCommands(
37
+ commands: ReplayCommand[],
38
+ width: number,
39
+ height: number,
40
+ layerId: string,
41
+ ): PixelBuffer {
42
+ const buffer = new PixelBuffer(width, height);
43
+
44
+ // Build a temporary context with the fresh buffer
45
+ const ctx: CommandContext = {
46
+ pixelBuffer: buffer,
47
+ layerId,
48
+ width,
49
+ height,
50
+ };
51
+
52
+ for (const cmd of commands) {
53
+ const def = commandRegistry.get(cmd.type);
54
+ if (!def) {
55
+ console.warn(`[replay-engine] Skipping unknown command type "${cmd.type}"`);
56
+ continue;
57
+ }
58
+ // Clone params to avoid mutation of the source data during execute
59
+ const params = { ...cmd.params };
60
+ def.execute(params, ctx);
61
+ }
62
+
63
+ return buffer;
64
+ }
65
+
66
+ // --- Preview / apply operations ---
67
+
68
+ /**
69
+ * Preview a reorder: replay entries in a proposed new order and return the
70
+ * resulting buffer. Does NOT modify the action log state.
71
+ *
72
+ * Only enabled entries are replayed; disabled entries are skipped.
73
+ */
74
+ export function previewReorder(
75
+ entries: ActionLogEntry[],
76
+ newOrder: string[],
77
+ width: number,
78
+ height: number,
79
+ layerId: string,
80
+ ): PixelBuffer {
81
+ const entryMap = new Map(entries.map((e) => [e.commandId, e]));
82
+
83
+ const commands: ReplayCommand[] = [];
84
+ for (const id of newOrder) {
85
+ const entry = entryMap.get(id);
86
+ if (!entry || !entry.enabled) continue;
87
+ commands.push({ type: entry.type, params: entry.params });
88
+ }
89
+
90
+ return replayCommands(commands, width, height, layerId);
91
+ }
92
+
93
+ /**
94
+ * Apply a reorder: update the action log entries to the new order and
95
+ * return the replayed buffer. The caller is responsible for applying the
96
+ * buffer to the canvas.
97
+ */
98
+ export function applyReorder(
99
+ newOrder: string[],
100
+ width: number,
101
+ height: number,
102
+ layerId: string,
103
+ ): PixelBuffer {
104
+ const currentEntries = actionLog.getEntries();
105
+ const entryMap = new Map(currentEntries.map((e) => [e.commandId, e]));
106
+
107
+ // Reorder entries according to newOrder
108
+ const reordered: ActionLogEntry[] = [];
109
+ for (const id of newOrder) {
110
+ const entry = entryMap.get(id);
111
+ if (entry) reordered.push(entry);
112
+ }
113
+
114
+ // Update the action log
115
+ actionLog.setEntries(reordered);
116
+
117
+ // Replay enabled entries in the new order
118
+ const commands: ReplayCommand[] = reordered
119
+ .filter((e) => e.enabled)
120
+ .map((e) => ({ type: e.type, params: e.params }));
121
+
122
+ return replayCommands(commands, width, height, layerId);
123
+ }
124
+
125
+ /**
126
+ * Preview a parameter edit: replay the full action log with one entry's
127
+ * params replaced. Does NOT modify the action log state.
128
+ */
129
+ export function previewParamEdit(
130
+ entryId: string,
131
+ newParams: Record<string, unknown>,
132
+ width: number,
133
+ height: number,
134
+ layerId: string,
135
+ ): PixelBuffer {
136
+ const entries = actionLog.getEntries();
137
+
138
+ const commands: ReplayCommand[] = [];
139
+ for (const entry of entries) {
140
+ if (!entry.enabled) continue;
141
+ if (entry.commandId === entryId) {
142
+ commands.push({ type: entry.type, params: newParams });
143
+ } else {
144
+ commands.push({ type: entry.type, params: entry.params });
145
+ }
146
+ }
147
+
148
+ return replayCommands(commands, width, height, layerId);
149
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Tests for the Spec Export/Import module.
3
+ *
4
+ * Exercises export, import, roundtrip, and diff operations on spec documents.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import type { ActionLogEntry } from './action-log.svelte.js';
9
+ import type { SpecDocument, SpecCommand } from './spec-format.js';
10
+ import { exportSpec, importSpec, diffSpecs } from './spec-format.js';
11
+
12
+ // --- Helpers ---
13
+
14
+ function makeEntry(
15
+ type: string,
16
+ params: Record<string, unknown>,
17
+ description: string,
18
+ enabled = true,
19
+ ): ActionLogEntry {
20
+ return {
21
+ commandId: crypto.randomUUID(),
22
+ type,
23
+ plugin: 'test/plugin',
24
+ description,
25
+ timestamp: Date.now(),
26
+ enabled,
27
+ params,
28
+ };
29
+ }
30
+
31
+ function makeSpec(commands: SpecCommand[], width = 32, height = 32): SpecDocument {
32
+ return {
33
+ version: '1.0.0',
34
+ canvasWidth: width,
35
+ canvasHeight: height,
36
+ commands,
37
+ };
38
+ }
39
+
40
+ // --- Tests ---
41
+
42
+ describe('Spec Format', () => {
43
+ describe('Export', () => {
44
+ it('should produce a valid spec document from action log entries', () => {
45
+ const entries = [
46
+ makeEntry('draw_pixel', { x: 5, y: 10, color: '#ff0000' }, 'Drew pixel at (5, 10)'),
47
+ makeEntry('fill_rect', { x: 0, y: 0, w: 8, h: 8 }, 'Filled 8x8 rect'),
48
+ ];
49
+
50
+ const spec = exportSpec(entries, 32, 32);
51
+
52
+ expect(spec.version).toBe('1.0.0');
53
+ expect(spec.canvasWidth).toBe(32);
54
+ expect(spec.canvasHeight).toBe(32);
55
+ expect(spec.commands).toHaveLength(2);
56
+ expect(spec.commands[0]?.type).toBe('draw_pixel');
57
+ expect(spec.commands[0]?.description).toBe('Drew pixel at (5, 10)');
58
+ expect(spec.commands[0]?.params["x"]).toBe(5);
59
+ expect(spec.commands[1]?.type).toBe('fill_rect');
60
+ });
61
+
62
+ it('should exclude disabled entries from the export', () => {
63
+ const entries = [
64
+ makeEntry('draw_pixel', { x: 0, y: 0 }, 'Enabled'),
65
+ makeEntry('draw_pixel', { x: 1, y: 1 }, 'Disabled', false),
66
+ makeEntry('draw_pixel', { x: 2, y: 2 }, 'Also enabled'),
67
+ ];
68
+
69
+ const spec = exportSpec(entries, 16, 16);
70
+
71
+ expect(spec.commands).toHaveLength(2);
72
+ expect(spec.commands[0]?.description).toBe('Enabled');
73
+ expect(spec.commands[1]?.description).toBe('Also enabled');
74
+ });
75
+
76
+ it('should strip internal params (underscore-prefixed) from export', () => {
77
+ const entries = [
78
+ makeEntry('draw_pixel', {
79
+ x: 5,
80
+ y: 10,
81
+ _oldPixel: [0, 0, 0, 0],
82
+ _snapshot: { data: 'internal' },
83
+ }, 'Drew pixel'),
84
+ ];
85
+
86
+ const spec = exportSpec(entries, 32, 32);
87
+
88
+ expect(spec.commands[0]?.params).toEqual({ x: 5, y: 10 });
89
+ expect(spec.commands[0]?.params["_oldPixel"]).toBeUndefined();
90
+ expect(spec.commands[0]?.params["_snapshot"]).toBeUndefined();
91
+ });
92
+ });
93
+
94
+ describe('Import', () => {
95
+ it('should parse a spec document into a command sequence', () => {
96
+ const spec = makeSpec([
97
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
98
+ { type: 'fill_rect', description: 'Filled rect', params: { w: 8, h: 8 } },
99
+ ]);
100
+
101
+ const commands = importSpec(spec);
102
+
103
+ expect(commands).toHaveLength(2);
104
+ expect(commands[0]?.type).toBe('draw_pixel');
105
+ expect(commands[0]?.params["x"]).toBe(0);
106
+ expect(commands[1]?.type).toBe('fill_rect');
107
+ expect(commands[1]?.params["w"]).toBe(8);
108
+ });
109
+
110
+ it('should throw on invalid spec document', () => {
111
+ expect(() => importSpec(null as unknown as SpecDocument)).toThrow('Invalid spec document');
112
+ expect(() => importSpec({} as SpecDocument)).toThrow('Invalid spec document');
113
+ });
114
+ });
115
+
116
+ describe('Roundtrip', () => {
117
+ it('should produce identical spec after export -> import -> export', () => {
118
+ const entries = [
119
+ makeEntry('draw_pixel', { x: 5, y: 10, color: '#ff0000' }, 'Drew red pixel'),
120
+ makeEntry('fill_rect', { x: 0, y: 0, w: 16, h: 16, color: '#00ff00' }, 'Filled green rect'),
121
+ ];
122
+
123
+ const spec1 = exportSpec(entries, 64, 64);
124
+ const commands = importSpec(spec1);
125
+
126
+ // Recreate entries from imported commands
127
+ const reimportedEntries = commands.map((cmd, i) => ({
128
+ commandId: crypto.randomUUID(),
129
+ type: cmd.type,
130
+ plugin: 'test/plugin',
131
+ description: spec1.commands[i]?.description ?? '',
132
+ timestamp: Date.now(),
133
+ enabled: true,
134
+ params: cmd.params,
135
+ }));
136
+
137
+ const spec2 = exportSpec(reimportedEntries, 64, 64);
138
+
139
+ // Compare the command data (ignoring version/canvas metadata which are set by exportSpec)
140
+ expect(spec2.commands).toHaveLength(spec1.commands.length);
141
+ for (let i = 0; i < spec1.commands.length; i++) {
142
+ expect(spec2.commands[i]?.type).toBe(spec1.commands[i]?.type);
143
+ expect(spec2.commands[i]?.description).toBe(spec1.commands[i]?.description);
144
+ expect(spec2.commands[i]?.params).toEqual(spec1.commands[i]?.params);
145
+ }
146
+ });
147
+ });
148
+
149
+ describe('Diff', () => {
150
+ it('should return no differences for identical specs', () => {
151
+ const commands = [
152
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
153
+ ];
154
+ const spec1 = makeSpec(commands);
155
+ const spec2 = makeSpec([...commands]);
156
+
157
+ const diffs = diffSpecs(spec1, spec2);
158
+ expect(diffs).toHaveLength(0);
159
+ });
160
+
161
+ it('should detect an added command', () => {
162
+ const spec1 = makeSpec([
163
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
164
+ ]);
165
+ const spec2 = makeSpec([
166
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
167
+ { type: 'fill_rect', description: 'Filled rect', params: { w: 8, h: 8 } },
168
+ ]);
169
+
170
+ const diffs = diffSpecs(spec1, spec2);
171
+
172
+ expect(diffs).toHaveLength(1);
173
+ expect(diffs[0]?.type).toBe('added');
174
+ expect(diffs[0]?.index).toBe(1);
175
+ expect(diffs[0]?.command?.type).toBe('fill_rect');
176
+ });
177
+
178
+ it('should detect a removed command', () => {
179
+ const spec1 = makeSpec([
180
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
181
+ { type: 'fill_rect', description: 'Filled rect', params: { w: 8, h: 8 } },
182
+ ]);
183
+ const spec2 = makeSpec([
184
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
185
+ ]);
186
+
187
+ const diffs = diffSpecs(spec1, spec2);
188
+
189
+ expect(diffs).toHaveLength(1);
190
+ expect(diffs[0]?.type).toBe('removed');
191
+ expect(diffs[0]?.index).toBe(1);
192
+ expect(diffs[0]?.oldCommand?.type).toBe('fill_rect');
193
+ });
194
+
195
+ it('should detect modified params', () => {
196
+ const spec1 = makeSpec([
197
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0, color: '#ff0000' } },
198
+ ]);
199
+ const spec2 = makeSpec([
200
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 5, y: 0, color: '#ff0000' } },
201
+ ]);
202
+
203
+ const diffs = diffSpecs(spec1, spec2);
204
+
205
+ expect(diffs).toHaveLength(1);
206
+ expect(diffs[0]?.type).toBe('modified');
207
+ expect(diffs[0]?.changes).toContain('x');
208
+ expect(diffs[0]?.changes).not.toContain('color');
209
+ expect(diffs[0]?.changes).not.toContain('y');
210
+ });
211
+
212
+ it('should detect type change as a modification', () => {
213
+ const spec1 = makeSpec([
214
+ { type: 'draw_pixel', description: 'Drew pixel', params: { x: 0, y: 0 } },
215
+ ]);
216
+ const spec2 = makeSpec([
217
+ { type: 'fill_rect', description: 'Filled rect', params: { x: 0, y: 0 } },
218
+ ]);
219
+
220
+ const diffs = diffSpecs(spec1, spec2);
221
+
222
+ expect(diffs).toHaveLength(1);
223
+ expect(diffs[0]?.type).toBe('modified');
224
+ expect(diffs[0]?.changes).toContain('type');
225
+ });
226
+
227
+ it('should handle complex diffs with multiple changes', () => {
228
+ const spec1 = makeSpec([
229
+ { type: 'cmd_a', description: 'A', params: { v: 1 } },
230
+ { type: 'cmd_b', description: 'B', params: { v: 2 } },
231
+ { type: 'cmd_c', description: 'C', params: { v: 3 } },
232
+ ]);
233
+ const spec2 = makeSpec([
234
+ { type: 'cmd_a', description: 'A', params: { v: 1 } }, // same
235
+ { type: 'cmd_b', description: 'B', params: { v: 99 } }, // modified
236
+ { type: 'cmd_c', description: 'C', params: { v: 3 } }, // same
237
+ { type: 'cmd_d', description: 'D', params: { v: 4 } }, // added
238
+ ]);
239
+
240
+ const diffs = diffSpecs(spec1, spec2);
241
+
242
+ expect(diffs).toHaveLength(2);
243
+ expect(diffs[0]?.type).toBe('modified');
244
+ expect(diffs[0]?.index).toBe(1);
245
+ expect(diffs[0]?.changes).toContain('v');
246
+ expect(diffs[1]?.type).toBe('added');
247
+ expect(diffs[1]?.index).toBe(3);
248
+ });
249
+ });
250
+ });