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,822 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VariantPanel -- sidebar panel for managing variant presets.
4
+ *
5
+ * Shows a grid of variant thumbnails rendered by compositing the current
6
+ * frame with each preset's per-group palette swaps. Clicking a thumbnail
7
+ * dispatches `apply_variant`. Header buttons dispatch the corresponding
8
+ * batch commands so every action lands in the undo stack / action log and
9
+ * can be bound to shortcuts and the command palette:
10
+ * - Generate: `generate_variants` (undoable)
11
+ * - Extract: `extract_palette_from_layer` (read-only, not undoable)
12
+ * - Export: `export_bisection_atlas` (side effect, not undoable)
13
+ * - Remove: `remove_variant_preset` (undoable)
14
+ *
15
+ * Visual conventions match LayerPanel.svelte; all colors via design tokens.
16
+ */
17
+
18
+ import { composite } from '../layers/compositor.js';
19
+ import { getLayers } from '../layers/layer-tree.svelte.js';
20
+ import {
21
+ getCurrentFrame,
22
+ getFrames,
23
+ } from '../animation/frame-model.svelte.js';
24
+ import { applyGroupPaletteSwap } from './palette-swap.js';
25
+ import {
26
+ getPresets,
27
+ getActivePresetId,
28
+ getActivePreset,
29
+ } from './variant-state.svelte.js';
30
+ import type { VariantPreset } from './variant-state.svelte.js';
31
+ import { canvasState } from '../canvas/canvas-state.svelte.js';
32
+ import type { CommandType, ParamsOf } from '../core/command-params.js';
33
+ import { dispatch } from '../core/dispatcher.js';
34
+ import type { PixelBuffer } from '../canvas/pixel-buffer.js';
35
+
36
+ import { notificationState } from '../core/notification-state.svelte.js';
37
+
38
+ import WandIcon from '~icons/lucide/wand-2';
39
+ import PaletteIcon from '~icons/lucide/palette';
40
+ import DownloadIcon from '~icons/lucide/download';
41
+ import FolderDownIcon from '~icons/lucide/folder-down';
42
+ import TrashIcon from '~icons/lucide/trash-2';
43
+ import ShuffleIcon from '~icons/lucide/shuffle';
44
+ import BlendIcon from '~icons/lucide/blend';
45
+ import ArrowRightIcon from '~icons/lucide/arrow-right';
46
+ import XIcon from '~icons/lucide/x';
47
+
48
+ // --- Command dispatch helper ---
49
+ // All mutations flow through the dispatcher so they appear in the action
50
+ // log, land in the undo stack where applicable, and can be intercepted by
51
+ // shortcut / palette entry points.
52
+ // Typed overload: compile-time param validation for known commands
53
+ function dispatchCmd<T extends CommandType>(type: T, params: ParamsOf<T>): void;
54
+ // String fallback: for dynamic dispatch where command type is a variable
55
+ function dispatchCmd(type: string, params?: Record<string, unknown>): void;
56
+ function dispatchCmd(type: string, params: Record<string, unknown> = {}) {
57
+ dispatch({
58
+ type,
59
+ plugin: 'ui/variants',
60
+ version: '1.0.0',
61
+ params,
62
+ id: crypto.randomUUID(),
63
+ timestamp: Date.now(),
64
+ });
65
+ }
66
+
67
+ // --- Generation mode toggle ---
68
+ // 'random' generates HSV-shifted variants; 'blend' interpolates between
69
+ // the first two existing presets.
70
+ type GenerateMode = 'random' | 'blend';
71
+ let generateMode: GenerateMode = $state('random');
72
+
73
+ /** Push a notification for in-panel feedback (e.g. insufficient presets). */
74
+ function panelNotify(message: string, type: 'info' | 'warning' = 'warning'): void {
75
+ notificationState.push({
76
+ id: `variant-panel-${crypto.randomUUID()}`,
77
+ message,
78
+ type,
79
+ autoDismissMs: 4000,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Composite the current frame with a preset's palette swaps applied.
85
+ * Returns null if no frame is available. Thumbnails are purely read-only
86
+ * rendering so we still call the swap/composite library directly here --
87
+ * there is no state mutation to route through a command.
88
+ */
89
+ function compositeWithPreset(preset: VariantPreset | null): PixelBuffer | null {
90
+ const frames = getFrames();
91
+ if (frames.length === 0) return null;
92
+
93
+ const frame = getCurrentFrame();
94
+ const tree = getLayers();
95
+ const width = canvasState.canvasWidth;
96
+ const height = canvasState.canvasHeight;
97
+
98
+ // Start from the frame's pixel data, then overlay swapped buffers per group
99
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- transient compositing buffer
100
+ const pixelData = new Map(frame.pixelData);
101
+
102
+ if (preset) {
103
+ for (const [groupId, colorMap] of preset.groupOverrides) {
104
+ if (colorMap.size === 0) continue;
105
+ const swapped = applyGroupPaletteSwap(groupId, colorMap, tree, [frame]);
106
+ const frameSwapped = swapped.get(frame.id);
107
+ if (!frameSwapped) continue;
108
+ for (const [layerId, buffer] of frameSwapped) {
109
+ pixelData.set(layerId, buffer);
110
+ }
111
+ }
112
+ }
113
+
114
+ return composite(tree, pixelData, width, height);
115
+ }
116
+
117
+ // --- Reactive derived state ---
118
+ // Reads of variant state inside $derived create subscriptions.
119
+
120
+ let presets = $derived(getPresets());
121
+ let activeId = $derived(getActivePresetId());
122
+ let canvasW = $derived(canvasState.canvasWidth);
123
+ let canvasH = $derived(canvasState.canvasHeight);
124
+
125
+ // --- Active preset color mappings ---
126
+ // When a variant is selected, derive its per-group color overrides as a
127
+ // flat list of { groupId, original, replacement } entries for the editor UI.
128
+
129
+ let activePreset = $derived(getActivePreset());
130
+
131
+ interface ColorMapping {
132
+ groupId: string;
133
+ original: string;
134
+ replacement: string;
135
+ }
136
+
137
+ /**
138
+ * Derive the color mapping rows for the active preset. For each group in
139
+ * the preset's overrides, list every original->replacement pair. This
140
+ * drives the inline color editor below the thumbnail grid.
141
+ */
142
+ let colorMappings = $derived.by<ColorMapping[]>(() => {
143
+ const preset = activePreset;
144
+ if (!preset) return [];
145
+ const rows: ColorMapping[] = [];
146
+ for (const [groupId, colorMap] of preset.groupOverrides) {
147
+ for (const [original, replacement] of colorMap) {
148
+ rows.push({ groupId, original, replacement });
149
+ }
150
+ }
151
+ return rows;
152
+ });
153
+
154
+ /** Handle a color override change from the inline <input type="color">. */
155
+ function handleColorChange(
156
+ presetId: string,
157
+ groupId: string,
158
+ originalColor: string,
159
+ newColor: string,
160
+ ): void {
161
+ dispatchCmd('set_color_override', {
162
+ presetId,
163
+ groupId,
164
+ originalColor,
165
+ newColor: newColor.toUpperCase(),
166
+ });
167
+ }
168
+
169
+ /** Remove a single color override (revert to original). */
170
+ function handleRemoveOverride(
171
+ e: MouseEvent,
172
+ presetId: string,
173
+ groupId: string,
174
+ originalColor: string,
175
+ ): void {
176
+ e.stopPropagation();
177
+ dispatchCmd('remove_color_override', {
178
+ presetId,
179
+ groupId,
180
+ originalColor,
181
+ });
182
+ }
183
+
184
+ // --- Thumbnail rendering ---
185
+ // One <canvas> per preset. We collect refs via {@attach} -- Svelte 5's
186
+ // attachment primitive runs on mount and returns a cleanup on unmount.
187
+
188
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- attachment refs, bumped via thumbVersion
189
+ const thumbEls = new Map<string, HTMLCanvasElement>();
190
+
191
+ // Bump this counter whenever the thumbEls map changes so the redraw
192
+ // effect re-runs after new canvases mount / old ones unmount.
193
+ let thumbVersion = $state(0);
194
+
195
+ /**
196
+ * Returns an attachment function for `{@attach thumbAttach(preset.id)}`.
197
+ * Registers the canvas element with our local map and cleans up on unmount.
198
+ */
199
+ function thumbAttach(id: string) {
200
+ return (node: Element) => {
201
+ thumbEls.set(id, node as HTMLCanvasElement);
202
+ thumbVersion += 1;
203
+ return () => {
204
+ thumbEls.delete(id);
205
+ thumbVersion += 1;
206
+ };
207
+ };
208
+ }
209
+
210
+ // Redraw thumbnails on any state change that affects what they show.
211
+ $effect(() => {
212
+ // Dependency touches: presets list, active id (for highlight), canvas
213
+ // size, thumbnail element set, and the current frame id.
214
+ void presets;
215
+ void activeId;
216
+ void canvasW;
217
+ void canvasH;
218
+ void thumbVersion;
219
+ const frames = getFrames();
220
+ if (frames.length === 0) return;
221
+ void getCurrentFrame().id;
222
+
223
+ for (const preset of presets) {
224
+ const el = thumbEls.get(preset.id);
225
+ if (!el) continue;
226
+ const buffer = compositeWithPreset(preset);
227
+ if (!buffer) continue;
228
+ el.width = buffer.width;
229
+ el.height = buffer.height;
230
+ const ctx = el.getContext('2d');
231
+ if (!ctx) continue;
232
+ ctx.imageSmoothingEnabled = false;
233
+ ctx.putImageData(buffer.toImageData(), 0, 0);
234
+ }
235
+ });
236
+
237
+ // --- Actions ---
238
+ // Every handler dispatches a command; the variant-commands plugin is
239
+ // responsible for targeting groups, emitting notifications, and managing
240
+ // undo snapshots. Keeping this panel dumb means shortcuts/palette hooks
241
+ // trigger identical behaviour to button clicks.
242
+
243
+ function handleGenerate(): void {
244
+ if (generateMode === 'blend') {
245
+ if (presets.length < 2) {
246
+ panelNotify('Need at least 2 variants to blend');
247
+ return;
248
+ }
249
+ dispatchCmd('generate_variants', { count: 3, mode: 'interpolate' });
250
+ } else {
251
+ dispatchCmd('generate_variants', { count: 1, mode: 'random' });
252
+ }
253
+ }
254
+
255
+ function handleExtractPalette(): void {
256
+ dispatchCmd('extract_palette_from_layer', {});
257
+ }
258
+
259
+ function handleExportBisection(): void {
260
+ dispatchCmd('export_bisection_atlas', {});
261
+ }
262
+
263
+ function handleExportBatch(): void {
264
+ dispatchCmd('export_variants_batch', {});
265
+ }
266
+
267
+ function handleApply(preset: VariantPreset): void {
268
+ // Toggle: clicking the active preset clears it (restores original).
269
+ const nextId = activeId === preset.id ? null : preset.id;
270
+ dispatchCmd('apply_variant', { presetId: nextId });
271
+ }
272
+
273
+ function handleRemove(e: MouseEvent, preset: VariantPreset): void {
274
+ e.stopPropagation();
275
+ dispatchCmd('remove_variant_preset', { id: preset.id });
276
+ }
277
+
278
+ function handleKeydown(e: KeyboardEvent, preset: VariantPreset): void {
279
+ if (e.key === 'Enter' || e.key === ' ') {
280
+ e.preventDefault();
281
+ handleApply(preset);
282
+ }
283
+ }
284
+ </script>
285
+
286
+ <div class="variant-panel">
287
+ <!-- Header -->
288
+ <div class="variant-header">
289
+ <span class="variant-title">Variants</span>
290
+ <div class="variant-actions">
291
+ <!-- Mode toggle: Random vs Blend -->
292
+ <div class="mode-toggle" role="radiogroup" aria-label="Generation mode">
293
+ <button
294
+ class="mode-seg"
295
+ class:mode-seg--active={generateMode === 'random'}
296
+ role="radio"
297
+ aria-checked={generateMode === 'random'}
298
+ title="Random mode"
299
+ aria-label="Random mode"
300
+ onclick={() => (generateMode = 'random')}
301
+ ><ShuffleIcon /></button>
302
+ <button
303
+ class="mode-seg"
304
+ class:mode-seg--active={generateMode === 'blend'}
305
+ role="radio"
306
+ aria-checked={generateMode === 'blend'}
307
+ title="Blend mode"
308
+ aria-label="Blend mode"
309
+ onclick={() => (generateMode = 'blend')}
310
+ ><BlendIcon /></button>
311
+ </div>
312
+ <button
313
+ class="action-btn"
314
+ title={generateMode === 'blend' ? 'Generate blended variants' : 'Generate random variant'}
315
+ aria-label={generateMode === 'blend' ? 'Generate blended variants' : 'Generate random variant'}
316
+ onclick={handleGenerate}
317
+ ><WandIcon /></button>
318
+ <button
319
+ class="action-btn"
320
+ title="Extract palette from active group"
321
+ aria-label="Extract palette from active group"
322
+ onclick={handleExtractPalette}
323
+ ><PaletteIcon /></button>
324
+ <button
325
+ class="action-btn"
326
+ title="Export bisection atlas"
327
+ aria-label="Export bisection atlas"
328
+ onclick={handleExportBisection}
329
+ disabled={presets.length === 0}
330
+ ><DownloadIcon /></button>
331
+ <button
332
+ class="action-btn"
333
+ title="Export variants individually"
334
+ aria-label="Export variants individually"
335
+ onclick={handleExportBatch}
336
+ disabled={presets.length === 0}
337
+ ><FolderDownIcon /></button>
338
+ </div>
339
+ </div>
340
+
341
+ <!-- Grid or empty state -->
342
+ {#if presets.length === 0}
343
+ <div class="empty-state">
344
+ <p class="empty-title">No variants yet</p>
345
+ <p class="empty-hint">
346
+ Use the wand button to generate a random variant for the active layer group.
347
+ </p>
348
+ </div>
349
+ {:else}
350
+ <div class="variant-grid">
351
+ {#each presets as preset (preset.id)}
352
+ <div
353
+ class="variant-cell"
354
+ class:variant-cell--active={preset.id === activeId}
355
+ role="button"
356
+ tabindex="0"
357
+ aria-pressed={preset.id === activeId}
358
+ aria-label={`Apply variant ${preset.name}`}
359
+ title={preset.name}
360
+ onclick={() => { handleApply(preset); }}
361
+ onkeydown={(e) => { handleKeydown(e, preset); }}
362
+ >
363
+ <div class="thumb-wrap">
364
+ <canvas
365
+ class="thumb"
366
+ {@attach thumbAttach(preset.id)}
367
+ ></canvas>
368
+ <button
369
+ class="remove-btn"
370
+ title="Remove variant"
371
+ aria-label={`Remove variant ${preset.name}`}
372
+ onclick={(e) => { handleRemove(e, preset); }}
373
+ ><TrashIcon /></button>
374
+ </div>
375
+ <span class="variant-name">{preset.name}</span>
376
+ </div>
377
+ {/each}
378
+ </div>
379
+ {/if}
380
+
381
+ <!-- Per-color editor for the active variant -->
382
+ <div class="color-editor">
383
+ {#if activePreset && colorMappings.length > 0}
384
+ <div class="color-editor-header">
385
+ <span class="color-editor-title">Color Overrides</span>
386
+ </div>
387
+ <div class="color-rows">
388
+ {#each colorMappings as mapping (mapping.groupId + ':' + mapping.original)}
389
+ <div class="color-row">
390
+ <span
391
+ class="color-swatch"
392
+ style:background-color={mapping.original}
393
+ title={mapping.original}
394
+ ></span>
395
+ <span class="color-arrow"><ArrowRightIcon /></span>
396
+ <label class="color-input-wrap" title="Click to change">
397
+ <span
398
+ class="color-swatch color-swatch--editable"
399
+ style:background-color={mapping.replacement}
400
+ ></span>
401
+ <input
402
+ type="color"
403
+ class="color-input-hidden"
404
+ value={mapping.replacement}
405
+ onchange={(e) => {
406
+ // activePreset is a `$derived` value -- its declared type
407
+ // ignores the surrounding `{#if activePreset}` narrowing
408
+ // by the time this callback actually runs, so we keep
409
+ // the runtime guard.
410
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
411
+ if (!activePreset) return;
412
+ handleColorChange(
413
+ activePreset.id,
414
+ mapping.groupId,
415
+ mapping.original,
416
+ (e.target as HTMLInputElement).value,
417
+ );
418
+ }}
419
+ />
420
+ </label>
421
+ <button
422
+ class="color-remove-btn"
423
+ title="Revert to original"
424
+ aria-label={`Remove override for ${mapping.original}`}
425
+ onclick={(e) => {
426
+ // See onchange above: $derived narrowing does not
427
+ // survive the nested callback boundary.
428
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
429
+ if (!activePreset) return;
430
+ handleRemoveOverride(e, activePreset.id, mapping.groupId, mapping.original);
431
+ }}
432
+ ><XIcon /></button>
433
+ </div>
434
+ {/each}
435
+ </div>
436
+ {:else if activePreset}
437
+ <div class="color-editor-hint">No color overrides in this variant.</div>
438
+ {:else}
439
+ <div class="color-editor-hint">Select a variant to edit colors</div>
440
+ {/if}
441
+ </div>
442
+ </div>
443
+
444
+ <style>
445
+ .variant-panel {
446
+ display: flex;
447
+ flex-direction: column;
448
+ width: 100%;
449
+ height: 100%;
450
+ background: var(--bg-panel);
451
+ color: var(--text-primary);
452
+ font-size: var(--text-base);
453
+ user-select: none;
454
+ }
455
+
456
+ .variant-header {
457
+ display: flex;
458
+ align-items: center;
459
+ justify-content: space-between;
460
+ padding: 6px var(--space-3);
461
+ border-bottom: 1px solid var(--border);
462
+ background: var(--bg-toolbar);
463
+ flex-shrink: 0;
464
+ }
465
+
466
+ .variant-title {
467
+ font-weight: 600;
468
+ font-size: var(--text-sm);
469
+ text-transform: uppercase;
470
+ letter-spacing: 0.5px;
471
+ color: var(--text-secondary);
472
+ }
473
+
474
+ .variant-actions {
475
+ display: flex;
476
+ gap: var(--space-1);
477
+ }
478
+
479
+ .action-btn {
480
+ background: none;
481
+ border: 1px solid transparent;
482
+ border-radius: var(--radius-sm);
483
+ color: var(--text-secondary);
484
+ cursor: pointer;
485
+ width: 24px;
486
+ height: 24px;
487
+ display: flex;
488
+ align-items: center;
489
+ justify-content: center;
490
+ font-size: var(--text-xl);
491
+ padding: 0;
492
+ line-height: 1;
493
+ }
494
+
495
+ .action-btn:hover:not(:disabled) {
496
+ background: var(--bg-primary);
497
+ color: var(--text-primary);
498
+ border-color: var(--border);
499
+ }
500
+
501
+ .action-btn:disabled {
502
+ opacity: 0.3;
503
+ cursor: default;
504
+ }
505
+
506
+ .action-btn :global(svg) {
507
+ width: 14px;
508
+ height: 14px;
509
+ }
510
+
511
+ /* Mode toggle -- compact two-segment radio group */
512
+ .mode-toggle {
513
+ display: flex;
514
+ border: 1px solid var(--border);
515
+ border-radius: var(--radius-sm);
516
+ overflow: hidden;
517
+ margin-right: var(--space-1);
518
+ }
519
+
520
+ .mode-seg {
521
+ background: none;
522
+ border: none;
523
+ color: var(--text-secondary);
524
+ cursor: pointer;
525
+ width: 22px;
526
+ height: 22px;
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: center;
530
+ padding: 0;
531
+ line-height: 1;
532
+ transition: background var(--transition-fast), color var(--transition-fast);
533
+ }
534
+
535
+ .mode-seg + .mode-seg {
536
+ border-left: 1px solid var(--border);
537
+ }
538
+
539
+ .mode-seg:hover:not(.mode-seg--active) {
540
+ background: var(--bg-primary);
541
+ color: var(--text-primary);
542
+ }
543
+
544
+ .mode-seg--active {
545
+ background: var(--accent);
546
+ color: var(--bg-panel);
547
+ }
548
+
549
+ .mode-seg :global(svg) {
550
+ width: 12px;
551
+ height: 12px;
552
+ }
553
+
554
+ /* Empty state */
555
+ .empty-state {
556
+ flex: 1;
557
+ display: flex;
558
+ flex-direction: column;
559
+ align-items: center;
560
+ justify-content: center;
561
+ padding: var(--space-4);
562
+ text-align: center;
563
+ color: var(--text-secondary);
564
+ gap: var(--space-2);
565
+ }
566
+
567
+ .empty-title {
568
+ font-size: var(--text-base);
569
+ font-weight: 600;
570
+ margin: 0;
571
+ }
572
+
573
+ .empty-hint {
574
+ font-size: var(--text-sm);
575
+ margin: 0;
576
+ line-height: 1.4;
577
+ max-width: 220px;
578
+ }
579
+
580
+ /* Grid container */
581
+ .variant-grid {
582
+ flex: 1;
583
+ min-height: 0;
584
+ overflow-y: auto;
585
+ overflow-x: hidden;
586
+ display: grid;
587
+ grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
588
+ gap: var(--space-2);
589
+ padding: var(--space-2);
590
+ align-content: start;
591
+ }
592
+
593
+ /* Individual cell */
594
+ .variant-cell {
595
+ display: flex;
596
+ flex-direction: column;
597
+ align-items: stretch;
598
+ gap: var(--space-1);
599
+ cursor: pointer;
600
+ padding: var(--space-1);
601
+ border: 1px solid transparent;
602
+ border-radius: var(--radius-sm);
603
+ transition: background var(--transition-fast), border-color var(--transition-fast);
604
+ min-width: 0;
605
+ }
606
+
607
+ .variant-cell:hover {
608
+ background: rgba(255, 255, 255, 0.04);
609
+ }
610
+
611
+ :global([data-theme="light"]) .variant-cell:hover {
612
+ background: rgba(0, 0, 0, 0.04);
613
+ }
614
+
615
+ .variant-cell:focus-visible {
616
+ outline: 2px solid var(--accent);
617
+ outline-offset: 1px;
618
+ }
619
+
620
+ .variant-cell--active {
621
+ border-color: var(--accent);
622
+ background: rgba(255, 255, 255, 0.06);
623
+ }
624
+
625
+ :global([data-theme="light"]) .variant-cell--active {
626
+ background: rgba(0, 0, 0, 0.06);
627
+ }
628
+
629
+ /* Thumbnail wrapper (square) */
630
+ .thumb-wrap {
631
+ position: relative;
632
+ width: 100%;
633
+ aspect-ratio: 1 / 1;
634
+ background: var(--bg-primary);
635
+ border: 1px solid var(--border);
636
+ border-radius: var(--radius-sm);
637
+ overflow: hidden;
638
+ }
639
+
640
+ .thumb {
641
+ width: 100%;
642
+ height: 100%;
643
+ display: block;
644
+ /* Pixelated scaling keeps thumbnails crisp at any size. */
645
+ image-rendering: pixelated;
646
+ image-rendering: crisp-edges;
647
+ }
648
+
649
+ /* Remove button -- top-right over the thumbnail */
650
+ .remove-btn {
651
+ position: absolute;
652
+ top: 2px;
653
+ right: 2px;
654
+ background: var(--bg-panel);
655
+ border: 1px solid var(--border);
656
+ border-radius: var(--radius-sm);
657
+ color: var(--text-secondary);
658
+ cursor: pointer;
659
+ width: 18px;
660
+ height: 18px;
661
+ display: none;
662
+ align-items: center;
663
+ justify-content: center;
664
+ padding: 0;
665
+ }
666
+
667
+ .variant-cell:hover .remove-btn,
668
+ .variant-cell:focus-within .remove-btn {
669
+ display: flex;
670
+ }
671
+
672
+ .remove-btn:hover {
673
+ color: var(--text-primary);
674
+ border-color: var(--accent);
675
+ }
676
+
677
+ .remove-btn :global(svg) {
678
+ width: 10px;
679
+ height: 10px;
680
+ }
681
+
682
+ /* Variant name label */
683
+ .variant-name {
684
+ font-size: var(--text-xs);
685
+ color: var(--text-secondary);
686
+ overflow: hidden;
687
+ text-overflow: ellipsis;
688
+ white-space: nowrap;
689
+ text-align: center;
690
+ }
691
+
692
+ .variant-cell--active .variant-name {
693
+ color: var(--text-primary);
694
+ font-weight: 600;
695
+ }
696
+
697
+ /* --- Color editor section --- */
698
+
699
+ .color-editor {
700
+ flex-shrink: 0;
701
+ border-top: 1px solid var(--border);
702
+ max-height: 40%;
703
+ display: flex;
704
+ flex-direction: column;
705
+ }
706
+
707
+ .color-editor-header {
708
+ display: flex;
709
+ align-items: center;
710
+ padding: 4px var(--space-3);
711
+ background: var(--bg-toolbar);
712
+ flex-shrink: 0;
713
+ }
714
+
715
+ .color-editor-title {
716
+ font-weight: 600;
717
+ font-size: var(--text-xs);
718
+ text-transform: uppercase;
719
+ letter-spacing: 0.5px;
720
+ color: var(--text-secondary);
721
+ }
722
+
723
+ .color-rows {
724
+ overflow-y: auto;
725
+ padding: var(--space-1) var(--space-2);
726
+ }
727
+
728
+ .color-row {
729
+ display: flex;
730
+ align-items: center;
731
+ gap: 4px;
732
+ height: 22px;
733
+ padding: 1px 0;
734
+ }
735
+
736
+ .color-swatch {
737
+ width: 16px;
738
+ height: 16px;
739
+ border-radius: 2px;
740
+ border: 1px solid var(--border);
741
+ flex-shrink: 0;
742
+ }
743
+
744
+ .color-swatch--editable {
745
+ cursor: pointer;
746
+ }
747
+
748
+ .color-swatch--editable:hover {
749
+ border-color: var(--accent);
750
+ }
751
+
752
+ .color-arrow {
753
+ display: flex;
754
+ align-items: center;
755
+ color: var(--text-secondary);
756
+ flex-shrink: 0;
757
+ }
758
+
759
+ .color-arrow :global(svg) {
760
+ width: 10px;
761
+ height: 10px;
762
+ }
763
+
764
+ /* Wrap the editable swatch + hidden native color input in a label so
765
+ clicking the swatch opens the picker. The native input is visually
766
+ hidden but remains accessible for keyboard / screen-reader use. */
767
+ .color-input-wrap {
768
+ position: relative;
769
+ display: flex;
770
+ align-items: center;
771
+ cursor: pointer;
772
+ }
773
+
774
+ .color-input-hidden {
775
+ position: absolute;
776
+ width: 1px;
777
+ height: 1px;
778
+ overflow: hidden;
779
+ clip: rect(0, 0, 0, 0);
780
+ border: 0;
781
+ padding: 0;
782
+ margin: 0;
783
+ }
784
+
785
+ .color-remove-btn {
786
+ background: none;
787
+ border: none;
788
+ color: var(--text-secondary);
789
+ cursor: pointer;
790
+ width: 16px;
791
+ height: 16px;
792
+ display: flex;
793
+ align-items: center;
794
+ justify-content: center;
795
+ padding: 0;
796
+ margin-left: auto;
797
+ border-radius: 2px;
798
+ opacity: 0;
799
+ transition: opacity var(--transition-fast);
800
+ }
801
+
802
+ .color-row:hover .color-remove-btn {
803
+ opacity: 1;
804
+ }
805
+
806
+ .color-remove-btn:hover {
807
+ color: var(--text-primary);
808
+ background: var(--bg-primary);
809
+ }
810
+
811
+ .color-remove-btn :global(svg) {
812
+ width: 10px;
813
+ height: 10px;
814
+ }
815
+
816
+ .color-editor-hint {
817
+ padding: var(--space-2) var(--space-3);
818
+ font-size: var(--text-xs);
819
+ color: var(--text-secondary);
820
+ text-align: center;
821
+ }
822
+ </style>