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,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
3
+ import { exportGroupSpritesheet, exportVariantAtlas } from './bisection-export.js';
4
+ import type { Layer } from '../layers/layer-types.js';
5
+ import type { Frame } from '../animation/frame-model.svelte.js';
6
+ import type { VariantPreset } from './variant-state.svelte.js';
7
+
8
+ /** Build a simple test group with one pixel layer. */
9
+ function makeTestScene() {
10
+ const layer1: Layer = { id: 'l1', name: 'Body', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
11
+ const layer2: Layer = { id: 'l2', name: 'Outside', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
12
+ const group: Layer = {
13
+ id: 'g1', name: 'Character', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
14
+ children: [layer1], expanded: true,
15
+ };
16
+
17
+ // l1 has a red pixel; l2 (outside group) has a green pixel
18
+ const buf1 = new PixelBuffer(4, 4);
19
+ buf1.setPixel(1, 1, 255, 0, 0, 255);
20
+
21
+ const buf2 = new PixelBuffer(4, 4);
22
+ buf2.setPixel(2, 2, 0, 255, 0, 255);
23
+
24
+ const frame: Frame = {
25
+ id: 'f1', index: 0, durationMs: null,
26
+ pixelData: new Map([['l1', buf1], ['l2', buf2]]),
27
+ };
28
+
29
+ return { group, layer2, frame, layerTree: [layer2, group] };
30
+ }
31
+
32
+ describe('exportGroupSpritesheet', () => {
33
+ it('should only include pixels from the group\'s layers', () => {
34
+ const { frame, layerTree } = makeTestScene();
35
+ const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4);
36
+
37
+ expect(frames).toHaveLength(1);
38
+ const buf = frames[0];
39
+ if (!buf) throw new Error("missing buffer");
40
+ // Red pixel from l1 (inside group) should be present
41
+ expect(buf.getPixel(1, 1)).toEqual([255, 0, 0, 255]);
42
+ // Green pixel from l2 (outside group) should NOT be present
43
+ expect(buf.getPixel(2, 2)).toEqual([0, 0, 0, 0]);
44
+ });
45
+
46
+ it('should leave non-group pixels transparent', () => {
47
+ const { frame, layerTree } = makeTestScene();
48
+ const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4);
49
+ const buf = frames[0];
50
+ if (!buf) throw new Error("missing buffer");
51
+
52
+ // Most pixels should be transparent
53
+ expect(buf.getPixel(0, 0)).toEqual([0, 0, 0, 0]);
54
+ expect(buf.getPixel(3, 3)).toEqual([0, 0, 0, 0]);
55
+ });
56
+
57
+ it('should apply variant during export', () => {
58
+ const { frame, layerTree } = makeTestScene();
59
+ const variant: VariantPreset = {
60
+ id: 'v1', name: 'Blue',
61
+ groupOverrides: new Map([
62
+ ['g1', new Map([['#FF0000', '#0000FF']])], // red -> blue
63
+ ]),
64
+ };
65
+
66
+ const frames = exportGroupSpritesheet('g1', layerTree, [frame], 4, 4, { variant });
67
+ const buf = frames[0];
68
+ if (!buf) throw new Error("missing buffer");
69
+ // Red pixel should now be blue
70
+ expect(buf.getPixel(1, 1)).toEqual([0, 0, 255, 255]);
71
+ });
72
+
73
+ it('should throw for non-existent group', () => {
74
+ expect(() => exportGroupSpritesheet('bad', [], [], 4, 4)).toThrow();
75
+ });
76
+ });
77
+
78
+ describe('exportVariantAtlas', () => {
79
+ it('should create atlas with correct dimensions (rows=variants, cols=frames)', () => {
80
+ const layer: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
81
+ const group: Layer = {
82
+ id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
83
+ children: [layer], expanded: true,
84
+ };
85
+
86
+ const buf = new PixelBuffer(4, 4);
87
+ buf.setPixel(0, 0, 255, 0, 0, 255);
88
+
89
+ const frame1: Frame = { id: 'f1', index: 0, durationMs: null, pixelData: new Map([['l1', buf]]) };
90
+ const frame2: Frame = { id: 'f2', index: 1, durationMs: null, pixelData: new Map([['l1', buf.clone()]]) };
91
+
92
+ const v1: VariantPreset = { id: 'v1', name: 'Original', groupOverrides: new Map() };
93
+ const v2: VariantPreset = {
94
+ id: 'v2', name: 'Blue',
95
+ groupOverrides: new Map([['g1', new Map([['#FF0000', '#0000FF']])]]),
96
+ };
97
+
98
+ const atlas = exportVariantAtlas('g1', [group], [frame1, frame2], [v1, v2], 4, 4);
99
+ // 2 frames x 4px = 8 wide, 2 variants x 4px = 8 tall
100
+ expect(atlas.width).toBe(8);
101
+ expect(atlas.height).toBe(8);
102
+
103
+ // Row 0 (v1/Original): frame 1 at (0,0), red pixel at (0,0)
104
+ expect(atlas.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
105
+ // Row 0, frame 2 at (4,0), red pixel at (4,0)
106
+ expect(atlas.getPixel(4, 0)).toEqual([255, 0, 0, 255]);
107
+
108
+ // Row 1 (v2/Blue): frame 1 at (0,4), should be blue
109
+ expect(atlas.getPixel(0, 4)).toEqual([0, 0, 255, 255]);
110
+ // Row 1, frame 2 at (4,4), should be blue
111
+ expect(atlas.getPixel(4, 4)).toEqual([0, 0, 255, 255]);
112
+ });
113
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Bisection Export -- exports individual layer groups as spritesheets or variant atlases.
3
+ *
4
+ * "Bisection" refers to isolating a group from the rest of the layer tree,
5
+ * exporting only its pixels on a transparent background.
6
+ */
7
+
8
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
9
+ import { composite } from '../layers/compositor.js';
10
+ import type { Layer } from '../layers/layer-types.js';
11
+ import type { Frame } from '../animation/frame-model.svelte.js';
12
+ import { applyPaletteSwap } from './palette-swap.js';
13
+ import type { VariantPreset } from './variant-state.svelte.js';
14
+
15
+ /**
16
+ * Find a layer by ID in the tree (depth-first search).
17
+ */
18
+ function findLayer(id: string, tree: Layer[]): Layer | undefined {
19
+ for (const layer of tree) {
20
+ if (layer.id === id) return layer;
21
+ if (layer.type === 'group' && layer.children) {
22
+ const found = findLayer(id, layer.children);
23
+ if (found) return found;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ /**
30
+ * Recursively collect IDs of all pixel layers within a layer.
31
+ */
32
+ function collectPixelLayerIds(layer: Layer): string[] {
33
+ if (layer.type === 'pixel') return [layer.id];
34
+ if (layer.children) {
35
+ return layer.children.flatMap(collectPixelLayerIds);
36
+ }
37
+ return [];
38
+ }
39
+
40
+ /**
41
+ * Build a minimal layer tree containing only the target group,
42
+ * preserving its internal structure for correct compositing.
43
+ */
44
+ function isolateGroupTree(group: Layer): Layer[] {
45
+ return [group];
46
+ }
47
+
48
+ /**
49
+ * Composite a group's layers for a single frame, optionally applying a variant.
50
+ */
51
+ function compositeGroupFrame(
52
+ group: Layer,
53
+ frame: Frame,
54
+ width: number,
55
+ height: number,
56
+ variant?: VariantPreset,
57
+ ): PixelBuffer {
58
+ const pixelLayerIds = collectPixelLayerIds(group);
59
+ const colorMap = variant?.groupOverrides.get(group.id);
60
+
61
+ // Build pixel data map containing only this group's layers
62
+ const pixelData = new Map<string, PixelBuffer>();
63
+ for (const layerId of pixelLayerIds) {
64
+ const buffer = frame.pixelData.get(layerId);
65
+ if (!buffer) continue;
66
+ if (colorMap && colorMap.size > 0) {
67
+ pixelData.set(layerId, applyPaletteSwap(buffer, colorMap));
68
+ } else {
69
+ pixelData.set(layerId, buffer);
70
+ }
71
+ }
72
+
73
+ // Composite using the group's own layer tree (preserves blend modes and opacity)
74
+ return composite(isolateGroupTree(group), pixelData, width, height);
75
+ }
76
+
77
+ /**
78
+ * Export a layer group as a separate spritesheet.
79
+ * Only includes pixels from layers within the group, on transparent background.
80
+ *
81
+ * @param groupId - ID of the group layer to export
82
+ * @param layerTree - the full layer tree
83
+ * @param frames - all animation frames
84
+ * @param width - canvas width in pixels
85
+ * @param height - canvas height in pixels
86
+ * @param options - optionally apply a variant preset
87
+ * @returns one PixelBuffer per frame
88
+ */
89
+ export function exportGroupSpritesheet(
90
+ groupId: string,
91
+ layerTree: Layer[],
92
+ frames: Frame[],
93
+ width: number,
94
+ height: number,
95
+ options?: { variant?: VariantPreset },
96
+ ): PixelBuffer[] {
97
+ const group = findLayer(groupId, layerTree);
98
+ if (!group || group.type !== 'group') {
99
+ throw new Error(`Group "${groupId}" not found or is not a group.`);
100
+ }
101
+
102
+ return frames.map((frame) =>
103
+ compositeGroupFrame(group, frame, width, height, options?.variant),
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Export all variants of a group as a variant atlas.
109
+ * Layout: rows = variants, columns = frames.
110
+ *
111
+ * @param groupId - ID of the group layer
112
+ * @param layerTree - the full layer tree
113
+ * @param frames - all animation frames
114
+ * @param variants - array of variant presets to render
115
+ * @param width - per-frame width in pixels
116
+ * @param height - per-frame height in pixels
117
+ * @returns a single PixelBuffer atlas image
118
+ */
119
+ export function exportVariantAtlas(
120
+ groupId: string,
121
+ layerTree: Layer[],
122
+ frames: Frame[],
123
+ variants: VariantPreset[],
124
+ width: number,
125
+ height: number,
126
+ ): PixelBuffer {
127
+ const group = findLayer(groupId, layerTree);
128
+ if (!group || group.type !== 'group') {
129
+ throw new Error(`Group "${groupId}" not found or is not a group.`);
130
+ }
131
+
132
+ const atlasWidth = frames.length * width;
133
+ const atlasHeight = variants.length * height;
134
+ const atlas = new PixelBuffer(atlasWidth, atlasHeight);
135
+
136
+ for (let row = 0; row < variants.length; row++) {
137
+ const variant = variants[row];
138
+ if (!variant) continue;
139
+ for (let col = 0; col < frames.length; col++) {
140
+ const frame = frames[col];
141
+ if (!frame) continue;
142
+ const frameBuffer = compositeGroupFrame(group, frame, width, height, variant);
143
+ atlas.paste(frameBuffer, col * width, row * height);
144
+ }
145
+ }
146
+
147
+ return atlas;
148
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
3
+ import { extractBufferPalette, extractGroupPalette } from './palette-extraction.js';
4
+ import type { Layer } from '../layers/layer-types.js';
5
+ import type { Frame } from '../animation/frame-model.svelte.js';
6
+
7
+ describe('extractBufferPalette', () => {
8
+ it('should extract unique colors from a buffer', () => {
9
+ const buf = new PixelBuffer(3, 1);
10
+ buf.setPixel(0, 0, 255, 0, 0, 255); // red
11
+ buf.setPixel(1, 0, 0, 255, 0, 255); // green
12
+ buf.setPixel(2, 0, 0, 0, 255, 255); // blue
13
+
14
+ const palette = extractBufferPalette(buf);
15
+ expect(palette).toHaveLength(3);
16
+ expect(palette).toContain('#FF0000');
17
+ expect(palette).toContain('#00FF00');
18
+ expect(palette).toContain('#0000FF');
19
+ });
20
+
21
+ it('should deduplicate identical colors', () => {
22
+ const buf = new PixelBuffer(3, 1);
23
+ buf.setPixel(0, 0, 255, 0, 0, 255);
24
+ buf.setPixel(1, 0, 255, 0, 0, 255);
25
+ buf.setPixel(2, 0, 255, 0, 0, 255);
26
+
27
+ const palette = extractBufferPalette(buf);
28
+ expect(palette).toHaveLength(1);
29
+ expect(palette[0]).toBe('#FF0000');
30
+ });
31
+
32
+ it('should skip transparent pixels', () => {
33
+ const buf = new PixelBuffer(2, 1);
34
+ buf.setPixel(0, 0, 255, 0, 0, 255);
35
+ // pixel (1,0) is transparent (default)
36
+
37
+ const palette = extractBufferPalette(buf);
38
+ expect(palette).toHaveLength(1);
39
+ expect(palette[0]).toBe('#FF0000');
40
+ });
41
+
42
+ it('should return empty array for fully transparent buffer', () => {
43
+ const buf = new PixelBuffer(4, 4);
44
+ expect(extractBufferPalette(buf)).toEqual([]);
45
+ });
46
+
47
+ it('should include semi-transparent pixels (alpha > 0)', () => {
48
+ const buf = new PixelBuffer(1, 1);
49
+ buf.setPixel(0, 0, 128, 64, 32, 1); // alpha=1, nearly transparent but not zero
50
+
51
+ const palette = extractBufferPalette(buf);
52
+ expect(palette).toHaveLength(1);
53
+ });
54
+ });
55
+
56
+ describe('extractGroupPalette', () => {
57
+ it('should extract palette across multiple layers in a group', () => {
58
+ const layer1: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
59
+ const layer2: Layer = { id: 'l2', name: 'L2', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
60
+ const group: Layer = {
61
+ id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
62
+ children: [layer1, layer2], expanded: true,
63
+ };
64
+
65
+ const buf1 = new PixelBuffer(2, 1);
66
+ buf1.setPixel(0, 0, 255, 0, 0, 255);
67
+ buf1.setPixel(1, 0, 0, 255, 0, 255);
68
+
69
+ const buf2 = new PixelBuffer(2, 1);
70
+ buf2.setPixel(0, 0, 0, 0, 255, 255);
71
+ buf2.setPixel(1, 0, 255, 0, 0, 255); // duplicate of buf1's red
72
+
73
+ const frame: Frame = {
74
+ id: 'f1', index: 0, durationMs: null,
75
+ pixelData: new Map([['l1', buf1], ['l2', buf2]]),
76
+ };
77
+
78
+ const palette = extractGroupPalette('g1', [group], [frame]);
79
+ // Should have 3 unique colors: red, green, blue
80
+ expect(palette).toHaveLength(3);
81
+ expect(palette).toContain('#FF0000');
82
+ expect(palette).toContain('#00FF00');
83
+ expect(palette).toContain('#0000FF');
84
+ });
85
+
86
+ it('should return empty array for non-existent group', () => {
87
+ const palette = extractGroupPalette('nonexistent', [], []);
88
+ expect(palette).toEqual([]);
89
+ });
90
+
91
+ it('should extract across multiple frames', () => {
92
+ const layer: Layer = { id: 'l1', name: 'L1', type: 'pixel', visible: true, opacity: 100, blendMode: 'normal', locked: false };
93
+ const group: Layer = {
94
+ id: 'g1', name: 'Group', type: 'group', visible: true, opacity: 100, blendMode: 'normal', locked: false,
95
+ children: [layer], expanded: true,
96
+ };
97
+
98
+ const buf1 = new PixelBuffer(1, 1);
99
+ buf1.setPixel(0, 0, 255, 0, 0, 255);
100
+ const frame1: Frame = { id: 'f1', index: 0, durationMs: null, pixelData: new Map([['l1', buf1]]) };
101
+
102
+ const buf2 = new PixelBuffer(1, 1);
103
+ buf2.setPixel(0, 0, 0, 0, 255, 255);
104
+ const frame2: Frame = { id: 'f2', index: 1, durationMs: null, pixelData: new Map([['l1', buf2]]) };
105
+
106
+ const palette = extractGroupPalette('g1', [group], [frame1, frame2]);
107
+ expect(palette).toHaveLength(2);
108
+ expect(palette).toContain('#FF0000');
109
+ expect(palette).toContain('#0000FF');
110
+ });
111
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Palette Extraction -- extracts unique colors from pixel layers and groups.
3
+ *
4
+ * Scans PixelBuffer data to collect all distinct opaque colors as uppercase
5
+ * 6-digit hex strings. Transparent pixels (alpha === 0) are excluded.
6
+ */
7
+
8
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
9
+ import { rgbToHex } from '../color/color-utils.js';
10
+ import type { Layer } from '../layers/layer-types.js';
11
+ import type { Frame } from '../animation/frame-model.svelte.js';
12
+
13
+ /**
14
+ * Extract unique colors from a single PixelBuffer.
15
+ * Skips fully transparent pixels (alpha === 0).
16
+ * Returns uppercase 6-digit hex strings, deduplicated.
17
+ */
18
+ export function extractBufferPalette(buffer: PixelBuffer): string[] {
19
+ const colors = new Set<string>();
20
+ const { data } = buffer;
21
+
22
+ for (let i = 0; i < data.length; i += 4) {
23
+ if (data[i + 3] === 0) continue; // skip transparent pixels
24
+ colors.add(rgbToHex(data[i] ?? 0, data[i + 1] ?? 0, data[i + 2] ?? 0));
25
+ }
26
+
27
+ return Array.from(colors);
28
+ }
29
+
30
+ /**
31
+ * Recursively collect IDs of all pixel layers within a group.
32
+ */
33
+ function collectPixelLayerIds(layer: Layer): string[] {
34
+ if (layer.type === 'pixel') return [layer.id];
35
+ if (layer.children) {
36
+ return layer.children.flatMap(collectPixelLayerIds);
37
+ }
38
+ return [];
39
+ }
40
+
41
+ /**
42
+ * Find a layer by ID in the tree (depth-first search).
43
+ */
44
+ function findLayer(id: string, tree: Layer[]): Layer | undefined {
45
+ for (const layer of tree) {
46
+ if (layer.id === id) return layer;
47
+ if (layer.type === 'group' && layer.children) {
48
+ const found = findLayer(id, layer.children);
49
+ if (found) return found;
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Extract unique colors from all pixel layers within a layer group,
57
+ * across all frames.
58
+ *
59
+ * Returns uppercase 6-digit hex strings, deduplicated and sorted for
60
+ * deterministic output.
61
+ */
62
+ export function extractGroupPalette(
63
+ groupId: string,
64
+ layerTree: Layer[],
65
+ frames: Frame[],
66
+ ): string[] {
67
+ const group = findLayer(groupId, layerTree);
68
+ if (!group || group.type !== 'group') return [];
69
+
70
+ const pixelLayerIds = collectPixelLayerIds(group);
71
+ const colors = new Set<string>();
72
+
73
+ for (const frame of frames) {
74
+ for (const layerId of pixelLayerIds) {
75
+ const buffer = frame.pixelData.get(layerId);
76
+ if (!buffer) continue;
77
+ for (const hex of extractBufferPalette(buffer)) {
78
+ colors.add(hex);
79
+ }
80
+ }
81
+ }
82
+
83
+ return Array.from(colors).sort();
84
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { interpolatePresets, generateInterpolatedSeries } from './palette-interpolation.js';
3
+ import type { VariantPreset } from './variant-state.svelte.js';
4
+
5
+ /** Helper to create a preset with a single group's color overrides. */
6
+ function makePreset(
7
+ name: string,
8
+ groupId: string,
9
+ overrides: Record<string, string>,
10
+ ): VariantPreset {
11
+ return {
12
+ id: `test-${name}`,
13
+ name,
14
+ groupOverrides: new Map([[groupId, new Map(Object.entries(overrides))]]),
15
+ };
16
+ }
17
+
18
+ describe('interpolatePresets', () => {
19
+ it('should return preset A colors at t=0', () => {
20
+ const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
21
+ const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
22
+
23
+ const result = interpolatePresets(a, b, 0);
24
+ const color = result.groupOverrides.get('g1')?.get('#FF0000');
25
+ expect(color).toBe('#00FF00');
26
+ });
27
+
28
+ it('should return preset B colors at t=1', () => {
29
+ const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
30
+ const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
31
+
32
+ const result = interpolatePresets(a, b, 1);
33
+ const color = result.groupOverrides.get('g1')?.get('#FF0000');
34
+ expect(color).toBe('#0000FF');
35
+ });
36
+
37
+ it('should return an intermediate color at t=0.5', () => {
38
+ const a = makePreset('A', 'g1', { '#000000': '#000000' });
39
+ const b = makePreset('B', 'g1', { '#000000': '#FFFFFF' });
40
+
41
+ const result = interpolatePresets(a, b, 0.5);
42
+ const color = result.groupOverrides.get('g1')?.get('#000000');
43
+ // OKLab midpoint of black and white is a medium gray; exact value depends on OKLab
44
+ // but it should not be black or white
45
+ expect(color).not.toBe('#000000');
46
+ expect(color).not.toBe('#FFFFFF');
47
+ });
48
+
49
+ it('should handle overrides present in only one preset', () => {
50
+ // Preset A overrides red, preset B does not
51
+ const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
52
+ const b: VariantPreset = { id: 'b', name: 'B', groupOverrides: new Map() };
53
+
54
+ // At t=0, should be fully A's color
55
+ const r0 = interpolatePresets(a, b, 0);
56
+ expect(r0.groupOverrides.get('g1')?.get('#FF0000')).toBe('#00FF00');
57
+
58
+ // At t=1, B has no override so endpoint is the original color (#FF0000)
59
+ const r1 = interpolatePresets(a, b, 1);
60
+ expect(r1.groupOverrides.get('g1')?.get('#FF0000')).toBe('#FF0000');
61
+ });
62
+
63
+ it('should clamp t to [0, 1]', () => {
64
+ const a = makePreset('A', 'g1', { '#FF0000': '#00FF00' });
65
+ const b = makePreset('B', 'g1', { '#FF0000': '#0000FF' });
66
+
67
+ const rNeg = interpolatePresets(a, b, -0.5);
68
+ const r0 = interpolatePresets(a, b, 0);
69
+ expect(rNeg.groupOverrides.get('g1')?.get('#FF0000')).toBe(
70
+ r0.groupOverrides.get('g1')?.get('#FF0000'),
71
+ );
72
+
73
+ const rOver = interpolatePresets(a, b, 1.5);
74
+ const r1 = interpolatePresets(a, b, 1);
75
+ expect(rOver.groupOverrides.get('g1')?.get('#FF0000')).toBe(
76
+ r1.groupOverrides.get('g1')?.get('#FF0000'),
77
+ );
78
+ });
79
+ });
80
+
81
+ describe('generateInterpolatedSeries', () => {
82
+ it('should produce the correct number of presets', () => {
83
+ const a = makePreset('A', 'g1', { '#FF0000': '#000000' });
84
+ const b = makePreset('B', 'g1', { '#FF0000': '#FFFFFF' });
85
+
86
+ expect(generateInterpolatedSeries(a, b, 5)).toHaveLength(5);
87
+ expect(generateInterpolatedSeries(a, b, 1)).toHaveLength(1);
88
+ expect(generateInterpolatedSeries(a, b, 0)).toHaveLength(0);
89
+ });
90
+
91
+ it('should have first = A and last = B for series of N >= 2', () => {
92
+ const a = makePreset('A', 'g1', { '#FF0000': '#000000' });
93
+ const b = makePreset('B', 'g1', { '#FF0000': '#FFFFFF' });
94
+
95
+ const series = generateInterpolatedSeries(a, b, 3);
96
+ const firstColor = series[0]?.groupOverrides.get('g1')?.get('#FF0000');
97
+ const lastColor = series[2]?.groupOverrides.get('g1')?.get('#FF0000');
98
+ expect(firstColor).toBe('#000000');
99
+ expect(lastColor).toBe('#FFFFFF');
100
+ });
101
+
102
+ it('should produce evenly spaced results', () => {
103
+ const a = makePreset('A', 'g1', { '#000000': '#000000' });
104
+ const b = makePreset('B', 'g1', { '#000000': '#FFFFFF' });
105
+
106
+ const series = generateInterpolatedSeries(a, b, 3);
107
+ // Middle value should be the same as interpolation at t=0.5
108
+ const mid = interpolatePresets(a, b, 0.5);
109
+ const midColor = mid.groupOverrides.get('g1')?.get('#000000');
110
+ const seriesMidColor = series[1]?.groupOverrides.get('g1')?.get('#000000');
111
+ expect(seriesMidColor).toBe(midColor);
112
+ });
113
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Palette Interpolation -- blends between variant presets using OKLab color space.
3
+ *
4
+ * Uses lerpColor from color-utils.ts for perceptually uniform interpolation.
5
+ * Missing overrides in one preset are treated as identity (original color).
6
+ */
7
+
8
+ import { lerpColor } from '../color/color-utils.js';
9
+ import type { VariantPreset } from './variant-state.svelte.js';
10
+
11
+ /**
12
+ * Interpolate between two variant presets.
13
+ * Uses OKLab color space (via lerpColor) for perceptual uniformity.
14
+ *
15
+ * When a color override exists in only one preset, the other endpoint
16
+ * is treated as the original color (the key itself), so interpolation
17
+ * smoothly transitions from/to the unmodified palette.
18
+ *
19
+ * @param presetA - first preset (t=0)
20
+ * @param presetB - second preset (t=1)
21
+ * @param t - interpolation factor: 0.0 = preset A, 1.0 = preset B
22
+ * @returns a new preset with interpolated colors
23
+ */
24
+ export function interpolatePresets(
25
+ presetA: VariantPreset,
26
+ presetB: VariantPreset,
27
+ t: number,
28
+ ): VariantPreset {
29
+ const clampedT = Math.max(0, Math.min(1, t));
30
+ const groupOverrides = new Map<string, Map<string, string>>();
31
+
32
+ // Collect all group IDs from both presets
33
+ const allGroupIds = new Set<string>();
34
+ for (const groupId of presetA.groupOverrides.keys()) allGroupIds.add(groupId);
35
+ for (const groupId of presetB.groupOverrides.keys()) allGroupIds.add(groupId);
36
+
37
+ for (const groupId of allGroupIds) {
38
+ const mapA = presetA.groupOverrides.get(groupId);
39
+ const mapB = presetB.groupOverrides.get(groupId);
40
+
41
+ // Collect all original colors from both maps
42
+ const allOriginals = new Set<string>();
43
+ if (mapA) for (const key of mapA.keys()) allOriginals.add(key);
44
+ if (mapB) for (const key of mapB.keys()) allOriginals.add(key);
45
+
46
+ const interpolatedMap = new Map<string, string>();
47
+
48
+ for (const originalColor of allOriginals) {
49
+ // If a preset doesn't override this color, use the original as the endpoint
50
+ const colorA = mapA?.get(originalColor) ?? originalColor;
51
+ const colorB = mapB?.get(originalColor) ?? originalColor;
52
+ interpolatedMap.set(originalColor, lerpColor(colorA, colorB, clampedT));
53
+ }
54
+
55
+ if (interpolatedMap.size > 0) {
56
+ groupOverrides.set(groupId, interpolatedMap);
57
+ }
58
+ }
59
+
60
+ return {
61
+ id: crypto.randomUUID(),
62
+ name: `Interpolated (t=${clampedT.toFixed(2)})`,
63
+ groupOverrides,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Generate N evenly-spaced interpolated presets between A and B.
69
+ *
70
+ * For count=3, produces presets at t = 0.0, 0.5, 1.0.
71
+ * For count=1, produces a preset at t = 0.5.
72
+ */
73
+ export function generateInterpolatedSeries(
74
+ presetA: VariantPreset,
75
+ presetB: VariantPreset,
76
+ count: number,
77
+ ): VariantPreset[] {
78
+ if (count <= 0) return [];
79
+ if (count === 1) return [interpolatePresets(presetA, presetB, 0.5)];
80
+
81
+ const results: VariantPreset[] = [];
82
+ for (let i = 0; i < count; i++) {
83
+ const t = i / (count - 1);
84
+ results.push(interpolatePresets(presetA, presetB, t));
85
+ }
86
+ return results;
87
+ }