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,222 @@
1
+ /**
2
+ * Piskel Importer Plugin -- imports .piskel files (Piskel's native JSON format).
3
+ *
4
+ * Piskel files contain layers, each with chunked base64 PNG sprite sheets.
5
+ * Each chunk tiles all frames horizontally into a single PNG. This plugin
6
+ * decodes those PNGs, splits them into per-frame PixelBuffers, and populates
7
+ * the PixelWeaver layer/frame state.
8
+ *
9
+ * Registers:
10
+ * - Importer: `piskel` (extensions: ['.piskel'])
11
+ */
12
+
13
+ import type { PluginModule } from '../../../src/lib/core/plugin-loader.js';
14
+ import type { PluginAPI } from '../../../src/lib/core/plugin-types.js';
15
+ import type { PixelBuffer } from '../../../src/lib/canvas/pixel-buffer.js';
16
+ import FileImage from '~icons/lucide/file-image';
17
+
18
+ // --- Piskel file types (subset relevant to import) ---
19
+ //
20
+ // Fields are optional because these shapes describe what JSON.parse()
21
+ // may yield from an untrusted .piskel file. The importer validates and
22
+ // supplies fallbacks for missing values; declaring everything required
23
+ // here would mislead callers into skipping the runtime guards.
24
+
25
+ interface PiskelChunk {
26
+ layout?: number[][];
27
+ base64PNG?: string;
28
+ }
29
+
30
+ interface PiskelLayer {
31
+ name: string;
32
+ opacity?: number;
33
+ frameCount?: number;
34
+ chunks?: PiskelChunk[];
35
+ }
36
+
37
+ interface PiskelFile {
38
+ modelVersion?: number;
39
+ piskel?: {
40
+ name?: string;
41
+ description?: string;
42
+ fps?: number;
43
+ height: number;
44
+ width: number;
45
+ layers?: PiskelLayer[];
46
+ hiddenFrames?: number[];
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Decode a base64 data-URL PNG to ImageData using Image + canvas.
52
+ * Works in browser environments where Image and canvas are available.
53
+ */
54
+ async function decodeBase64Png(dataUrl: string): Promise<ImageData> {
55
+ const img = new Image();
56
+ img.src = dataUrl;
57
+ await new Promise<void>((resolve, reject) => {
58
+ img.onload = () => { resolve(); };
59
+ img.onerror = () => { reject(new Error('Failed to decode base64 PNG from Piskel chunk')); };
60
+ });
61
+
62
+ // Prefer OffscreenCanvas when available (e.g. in workers)
63
+ if (typeof OffscreenCanvas !== 'undefined') {
64
+ const canvas = new OffscreenCanvas(img.width, img.height);
65
+ const ctx = canvas.getContext('2d');
66
+ if (!ctx) throw new Error('Failed to get 2d context');
67
+ ctx.drawImage(img, 0, 0);
68
+ return ctx.getImageData(0, 0, img.width, img.height);
69
+ }
70
+
71
+ const canvas = document.createElement('canvas');
72
+ canvas.width = img.width;
73
+ canvas.height = img.height;
74
+ const ctx = canvas.getContext('2d');
75
+ if (!ctx) throw new Error('Failed to get 2d context');
76
+ ctx.drawImage(img, 0, 0);
77
+ return ctx.getImageData(0, 0, img.width, img.height);
78
+ }
79
+
80
+ /**
81
+ * Extract a single frame's pixels from a horizontally-tiled ImageData.
82
+ * Frame N occupies columns [N*frameWidth .. (N+1)*frameWidth).
83
+ */
84
+ function extractFrameBuffer(
85
+ api: PluginAPI,
86
+ imageData: ImageData,
87
+ frameIndex: number,
88
+ frameWidth: number,
89
+ frameHeight: number,
90
+ ): PixelBuffer {
91
+ const buf = api.createPixelBuffer(frameWidth, frameHeight);
92
+ const srcStride = imageData.width * 4;
93
+ const xOffset = frameIndex * frameWidth;
94
+
95
+ for (let y = 0; y < frameHeight; y++) {
96
+ for (let x = 0; x < frameWidth; x++) {
97
+ const srcIdx = y * srcStride + (xOffset + x) * 4;
98
+ buf.setPixel(
99
+ x,
100
+ y,
101
+ imageData.data[srcIdx] ?? 0,
102
+ imageData.data[srcIdx + 1] ?? 0,
103
+ imageData.data[srcIdx + 2] ?? 0,
104
+ imageData.data[srcIdx + 3] ?? 0,
105
+ );
106
+ }
107
+ }
108
+
109
+ return buf;
110
+ }
111
+
112
+ export const piskelImporterPlugin: PluginModule = {
113
+ name: 'builtin/import-piskel',
114
+ version: '1.0.0',
115
+ description: 'Import Piskel .piskel files',
116
+ register(api) {
117
+ api.addCommand('import_piskel', {
118
+ tier: 'project',
119
+ execute() { /* Will be wired to file picker */ },
120
+ undo() {},
121
+ describe() { return 'Import Piskel file'; },
122
+ label: 'Piskel (.piskel)',
123
+ category: 'File',
124
+ icon: FileImage,
125
+ });
126
+ api.addMenuItem('menu:file:import:piskel', {
127
+ commandId: 'import_piskel',
128
+ menuPath: 'file/import',
129
+ group: 'import',
130
+ order: 20,
131
+ label: 'Piskel (.piskel)',
132
+ });
133
+
134
+ api.addImporter('piskel', {
135
+ label: 'Piskel File',
136
+ extensions: ['piskel'],
137
+
138
+ async import(data) {
139
+ const text = data instanceof File ? await data.text() : new TextDecoder().decode(data);
140
+ // PiskelFile's fields are all declared optional because JSON.parse
141
+ // yields untrusted data; the structural check below is what
142
+ // actually proves the minimal shape we need.
143
+ const json = JSON.parse(text) as PiskelFile;
144
+ const piskel = json.piskel;
145
+
146
+ // Validate minimal structure
147
+ if (!piskel || typeof piskel.width !== 'number' || typeof piskel.height !== 'number') {
148
+ throw new Error('Invalid Piskel file: missing piskel object or dimensions.');
149
+ }
150
+
151
+ const width = piskel.width;
152
+ const height = piskel.height;
153
+ const fps = Math.max(1, Math.min(120, piskel.fps || 12));
154
+ const piskelLayers = piskel.layers ?? [];
155
+
156
+ // Determine total frame count from the first layer (all layers share the same count)
157
+ const frameCount = piskelLayers[0]?.frameCount || 1;
158
+
159
+ // Handle edge case: no layers at all
160
+ if (piskelLayers.length === 0) {
161
+ api.setCanvasSize(width, height);
162
+ api.setGlobalFps(fps);
163
+ return;
164
+ }
165
+
166
+ // 1. Set canvas dimensions
167
+ api.setCanvasSize(width, height);
168
+
169
+ // 2. Reset existing layers and frames to a clean slate
170
+ api.deserializeLayers({ layers: [], activeLayerId: '' });
171
+ api.deserializeFrames({
172
+ frames: [],
173
+ currentFrameIndex: 0,
174
+ globalFps: fps,
175
+ originX: 0,
176
+ originY: 0,
177
+ });
178
+
179
+ // 3. Create the required number of frames
180
+ for (let i = 0; i < frameCount; i++) {
181
+ api.addFrame();
182
+ }
183
+
184
+ // 4. Process each Piskel layer
185
+ let firstLayerId: string | undefined;
186
+
187
+ for (const piskelLayer of piskelLayers) {
188
+ const layerId = api.addLayer(piskelLayer.name);
189
+ if (!firstLayerId) firstLayerId = layerId;
190
+
191
+ const layerFrameCount = piskelLayer.frameCount || 1;
192
+
193
+ for (const chunk of piskelLayer.chunks ?? []) {
194
+ if (!chunk.base64PNG) continue;
195
+
196
+ const imageData = await decodeBase64Png(chunk.base64PNG);
197
+
198
+ // The layout array maps logical frame indices to chunk tile positions.
199
+ // Typically layout is [[0],[1],[2],...] meaning frame i is at tile i.
200
+ // We iterate the layout to support non-trivial mappings.
201
+ const layout = chunk.layout ?? [];
202
+ const framesToProcess = layout.length > 0 ? layout.length : layerFrameCount;
203
+
204
+ for (let frameIdx = 0; frameIdx < framesToProcess; frameIdx++) {
205
+ // Determine the tile index within the PNG strip
206
+ // layout[frameIdx] is an array (usually single-element) pointing to the tile
207
+ const tileIndex = layout[frameIdx]?.[0] ?? frameIdx;
208
+
209
+ // Ensure enough frames exist (in case layers disagree on frame count)
210
+ while (api.getFrames().length <= frameIdx) {
211
+ api.addFrame();
212
+ }
213
+
214
+ const buf = extractFrameBuffer(api, imageData, tileIndex, width, height);
215
+ api.setFramePixelData(frameIdx, layerId, buf);
216
+ }
217
+ }
218
+ }
219
+ },
220
+ });
221
+ },
222
+ };
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Sky Spec Importer Plugin -- imports JSON spec files from the Sky art pipeline.
3
+ *
4
+ * Sky specs define sprites as sequences of drawing commands. This plugin
5
+ * maps each Sky command to a PixelWeaver command and dispatches them through
6
+ * the command system so they appear in the action log and support undo/redo.
7
+ *
8
+ * Handles both single specs and batch specs ({ "batch": [...] }).
9
+ * For batch specs, imports the first spec and warns about the rest.
10
+ *
11
+ * Registers:
12
+ * - Importer: `sky-spec` (extensions: ['json'])
13
+ */
14
+
15
+ import type { PluginModule } from '../../../src/lib/core/plugin-loader.js';
16
+ import FileJson from '~icons/lucide/file-json';
17
+ import { dispatch } from '../../../src/lib/core/dispatcher.js';
18
+ import type { Command } from '../../../src/lib/core/commands.js';
19
+ import { hexToRgba } from '../drawing-utils.js';
20
+
21
+ // --- Sky spec types ---
22
+
23
+ interface SkyCommand {
24
+ type: string;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ interface SkyLayer {
29
+ // Optional because the JSON is untrusted: a layer object may omit
30
+ // `commands` and the importer falls back to `[]` at the use site.
31
+ commands?: SkyCommand[];
32
+ }
33
+
34
+ interface SkySpec {
35
+ name?: string;
36
+ category?: string;
37
+ width: number;
38
+ height: number;
39
+ background?: string;
40
+ layers?: SkyLayer[];
41
+ }
42
+
43
+ interface SkyBatchSpec {
44
+ batch: SkySpec[];
45
+ }
46
+
47
+ // --- Command mapping ---
48
+
49
+ /**
50
+ * Maps a Sky command type to a PixelWeaver command type and a parameter
51
+ * transform function. The transform receives the raw Sky command object
52
+ * plus the spec's canvas dimensions (for commands like `fill` that need them).
53
+ */
54
+ const COMMAND_MAP: Record<
55
+ string,
56
+ { type: string; transform: (cmd: SkyCommand, w: number, h: number) => Record<string, unknown> }
57
+ > = {
58
+ pixel: {
59
+ type: 'draw_pixels',
60
+ transform: (cmd) => ({
61
+ pixels: [{ x: cmd["x"], y: cmd["y"], ...hexToRgba(cmd["color"] as string) }],
62
+ }),
63
+ },
64
+
65
+ fill: {
66
+ type: 'draw_rect',
67
+ transform: (cmd, w, h) => ({
68
+ x: 0, y: 0, w, h,
69
+ filled: true,
70
+ color: cmd["color"],
71
+ }),
72
+ },
73
+
74
+ rect: {
75
+ type: 'draw_rect',
76
+ transform: (cmd) => ({
77
+ x: cmd["x"], y: cmd["y"], w: cmd["w"], h: cmd["h"],
78
+ filled: true,
79
+ color: cmd["color"],
80
+ }),
81
+ },
82
+
83
+ rect_outline: {
84
+ type: 'draw_rect',
85
+ transform: (cmd) => ({
86
+ x: cmd["x"], y: cmd["y"], w: cmd["w"], h: cmd["h"],
87
+ filled: false,
88
+ color: cmd["color"],
89
+ }),
90
+ },
91
+
92
+ line: {
93
+ type: 'draw_line',
94
+ transform: (cmd) => ({
95
+ x0: cmd["x1"], y0: cmd["y1"], x1: cmd["x2"], y1: cmd["y2"],
96
+ color: cmd["color"],
97
+ algorithm: 'standard',
98
+ }),
99
+ },
100
+
101
+ hline: {
102
+ type: 'draw_line',
103
+ transform: (cmd) => ({
104
+ x0: cmd["x"], y0: cmd["y"],
105
+ x1: (cmd["x"] as number) + (cmd["length"] as number) - 1, y1: cmd["y"],
106
+ color: cmd["color"],
107
+ algorithm: 'standard',
108
+ }),
109
+ },
110
+
111
+ vline: {
112
+ type: 'draw_line',
113
+ transform: (cmd) => ({
114
+ x0: cmd["x"], y0: cmd["y"],
115
+ x1: cmd["x"], y1: (cmd["y"] as number) + (cmd["length"] as number) - 1,
116
+ color: cmd["color"],
117
+ algorithm: 'standard',
118
+ }),
119
+ },
120
+
121
+ diamond_fill: {
122
+ type: 'draw_diamond',
123
+ transform: (cmd) => ({
124
+ cx: cmd["cx"], cy: cmd["cy"],
125
+ radiusX: cmd["rx"], radiusY: cmd["ry"],
126
+ filled: true,
127
+ color: cmd["color"],
128
+ }),
129
+ },
130
+
131
+ diamond: {
132
+ type: 'draw_diamond',
133
+ transform: (cmd) => ({
134
+ cx: cmd["cx"], cy: cmd["cy"],
135
+ radiusX: cmd["rx"], radiusY: cmd["ry"],
136
+ filled: false,
137
+ color: cmd["color"],
138
+ }),
139
+ },
140
+
141
+ circle_fill: {
142
+ type: 'draw_ellipse',
143
+ transform: (cmd) => ({
144
+ cx: cmd["cx"], cy: cmd["cy"],
145
+ rx: cmd["r"], ry: cmd["r"],
146
+ filled: true,
147
+ color: cmd["color"],
148
+ }),
149
+ },
150
+
151
+ circle: {
152
+ type: 'draw_ellipse',
153
+ transform: (cmd) => ({
154
+ cx: cmd["cx"], cy: cmd["cy"],
155
+ rx: cmd["r"], ry: cmd["r"],
156
+ filled: false,
157
+ color: cmd["color"],
158
+ }),
159
+ },
160
+
161
+ gradient_v: {
162
+ type: 'draw_gradient',
163
+ transform: (cmd) => ({
164
+ x0: (cmd["x"] as number) + (cmd["w"] as number) / 2, y0: cmd["y"],
165
+ x1: (cmd["x"] as number) + (cmd["w"] as number) / 2, y1: (cmd["y"] as number) + (cmd["h"] as number),
166
+ color0: cmd["color_top"],
167
+ color1: cmd["color_bottom"],
168
+ mode: 'smooth',
169
+ }),
170
+ },
171
+
172
+ gradient_h: {
173
+ type: 'draw_gradient',
174
+ transform: (cmd) => ({
175
+ x0: cmd["x"], y0: (cmd["y"] as number) + (cmd["h"] as number) / 2,
176
+ x1: (cmd["x"] as number) + (cmd["w"] as number), y1: (cmd["y"] as number) + (cmd["h"] as number) / 2,
177
+ color0: cmd["color_left"],
178
+ color1: cmd["color_right"],
179
+ mode: 'smooth',
180
+ }),
181
+ },
182
+
183
+ noise: {
184
+ type: 'draw_noise',
185
+ transform: (cmd) => ({
186
+ x: cmd["x"], y: cmd["y"], w: cmd["w"], h: cmd["h"],
187
+ colors: cmd["colors"],
188
+ density: (cmd["density"] ?? 0.3) as number,
189
+ seed: Math.floor(Math.random() * 0xFFFFFFFF),
190
+ }),
191
+ },
192
+
193
+ dither: {
194
+ type: 'draw_dither',
195
+ transform: (cmd) => ({
196
+ x: cmd["x"], y: cmd["y"], w: cmd["w"], h: cmd["h"],
197
+ color0: cmd["color1"],
198
+ color1: cmd["color2"],
199
+ matrixSize: 2,
200
+ }),
201
+ },
202
+
203
+ mirror_h: {
204
+ type: 'flip',
205
+ transform: () => ({
206
+ direction: 'h',
207
+ }),
208
+ },
209
+
210
+ mirror_v: {
211
+ type: 'flip',
212
+ transform: () => ({
213
+ direction: 'v',
214
+ }),
215
+ },
216
+
217
+ // Primitives from drawing-primitives-plugin.ts
218
+ scatter: {
219
+ type: 'scatter',
220
+ transform: (cmd) => ({
221
+ points: cmd["points"],
222
+ color: cmd["color"],
223
+ }),
224
+ },
225
+
226
+ copy: {
227
+ type: 'copy_region',
228
+ transform: (cmd) => ({
229
+ srcX: cmd["src_x"], srcY: cmd["src_y"],
230
+ w: cmd["w"], h: cmd["h"],
231
+ dstX: cmd["dst_x"], dstY: cmd["dst_y"],
232
+ }),
233
+ },
234
+
235
+ replace_color: {
236
+ type: 'replace_color',
237
+ transform: (cmd) => ({
238
+ oldColor: cmd["old"],
239
+ newColor: cmd["new"],
240
+ }),
241
+ },
242
+
243
+ checkerboard: {
244
+ type: 'checkerboard',
245
+ transform: (cmd, w, h) => ({
246
+ x: (cmd["x"] ?? 0) as number,
247
+ y: (cmd["y"] ?? 0) as number,
248
+ w: (cmd["w"] ?? w) as number,
249
+ h: (cmd["h"] ?? h) as number,
250
+ color1: cmd["color1"],
251
+ color2: cmd["color2"],
252
+ size: (cmd["size"] ?? 1) as number,
253
+ }),
254
+ },
255
+
256
+ polygon_fill: {
257
+ type: 'draw_polygon',
258
+ transform: (cmd) => ({
259
+ points: cmd["points"],
260
+ filled: true,
261
+ color: cmd["color"],
262
+ }),
263
+ },
264
+
265
+ polygon: {
266
+ type: 'draw_polygon',
267
+ transform: (cmd) => ({
268
+ points: cmd["points"],
269
+ filled: false,
270
+ color: cmd["color"],
271
+ }),
272
+ },
273
+
274
+ iso_outline: {
275
+ type: 'iso_outline',
276
+ transform: (cmd) => ({
277
+ color: cmd["color"],
278
+ }),
279
+ },
280
+ };
281
+
282
+ // --- Dispatch helper ---
283
+
284
+ const PLUGIN_NAME = 'builtin/sky-spec';
285
+ const PLUGIN_VERSION = '1.0.0';
286
+
287
+ /**
288
+ * Build a Command object and dispatch it through the PixelWeaver command system.
289
+ * This ensures every imported command appears in the action log and supports undo.
290
+ */
291
+ function dispatchCmd(type: string, params: Record<string, unknown>): void {
292
+ const command: Command = {
293
+ type,
294
+ plugin: PLUGIN_NAME,
295
+ version: PLUGIN_VERSION,
296
+ params,
297
+ id: crypto.randomUUID(),
298
+ timestamp: Date.now(),
299
+ };
300
+ dispatch(command);
301
+ }
302
+
303
+ // --- Plugin module ---
304
+
305
+ export const skySpecPlugin: PluginModule = {
306
+ name: PLUGIN_NAME,
307
+ version: PLUGIN_VERSION,
308
+ description: 'Import Sky art pipeline JSON specs',
309
+ dependencies: ['builtin/layers', 'builtin/canvas-init'],
310
+
311
+ register(api) {
312
+ api.addCommand('import_sky_spec', {
313
+ tier: 'project',
314
+ execute() { /* Will be wired to file picker */ },
315
+ undo() {},
316
+ describe() { return 'Import Sky Spec file'; },
317
+ label: 'Sky Spec (.json)',
318
+ category: 'File',
319
+ icon: FileJson,
320
+ });
321
+ api.addMenuItem('menu:file:import:sky-spec', {
322
+ commandId: 'import_sky_spec',
323
+ menuPath: 'file/import',
324
+ group: 'import',
325
+ order: 30,
326
+ label: 'Sky Spec (.json)',
327
+ });
328
+
329
+ api.addImporter('sky-spec', {
330
+ label: 'Sky Spec File',
331
+ extensions: ['json'],
332
+
333
+ async import(data) {
334
+ const text = data instanceof File ? await data.text() : new TextDecoder().decode(data);
335
+ const json = JSON.parse(text) as SkySpec | SkyBatchSpec;
336
+
337
+ // Handle batch or single spec
338
+ const specs: SkySpec[] = 'batch' in json ? json.batch : [json];
339
+
340
+ if (specs.length === 0) {
341
+ throw new Error('Sky spec file contains no specs.');
342
+ }
343
+
344
+ const spec = specs[0];
345
+ if (!spec) throw new Error('Sky spec file contains no specs.');
346
+
347
+ if (specs.length > 1) {
348
+ console.warn(
349
+ `[sky-spec] Batch contains ${String(specs.length)} specs; importing only the first ("${spec.name ?? 'unnamed'}"). ` +
350
+ `Remaining ${String(specs.length - 1)} specs were skipped.`,
351
+ );
352
+ }
353
+
354
+ // Validate minimal structure
355
+ if (typeof spec.width !== 'number' || typeof spec.height !== 'number') {
356
+ throw new Error('Invalid Sky spec: missing width or height.');
357
+ }
358
+
359
+ // 1. Set canvas dimensions
360
+ api.setCanvasSize(spec.width, spec.height);
361
+
362
+ // 2. Clear existing layers and frames
363
+ api.deserializeLayers({ layers: [], activeLayerId: '' });
364
+ api.deserializeFrames({
365
+ frames: [],
366
+ currentFrameIndex: 0,
367
+ globalFps: 12,
368
+ originX: 0,
369
+ originY: 0,
370
+ });
371
+
372
+ // 3. Create a fresh layer and frame
373
+ const layerId = api.addLayer(spec.name || 'Layer 1');
374
+ if (api.getFrames().length === 0) api.addFrame();
375
+
376
+ // 4. Ensure the (single, freshly created) frame has a blank buffer
377
+ // for the newly added layer. After the deserialize+addFrame above,
378
+ // the only frame lives at index 0.
379
+ api.setFramePixelData(
380
+ 0,
381
+ layerId,
382
+ api.createPixelBuffer(spec.width, spec.height),
383
+ );
384
+
385
+ // 5. If background color is set, dispatch a fill command
386
+ if (spec.background) {
387
+ dispatchCmd('draw_rect', {
388
+ x: 0, y: 0, w: spec.width, h: spec.height,
389
+ filled: true,
390
+ color: spec.background,
391
+ });
392
+ }
393
+
394
+ // 6. Iterate layers and commands, map and dispatch each one
395
+ for (const skyLayer of spec.layers ?? []) {
396
+ for (const cmd of skyLayer.commands ?? []) {
397
+ const mapping = COMMAND_MAP[cmd.type];
398
+ if (!mapping) {
399
+ console.warn(`[sky-spec] Unknown command type: "${cmd.type}" -- skipped.`);
400
+ continue;
401
+ }
402
+ const params = mapping.transform(cmd, spec.width, spec.height);
403
+ dispatchCmd(mapping.type, params);
404
+ }
405
+ }
406
+ },
407
+ });
408
+ },
409
+ };