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,574 @@
1
+ /**
2
+ * WebSocket Client for the PixelWeaver server-authoritative protocol.
3
+ *
4
+ * Handles connection lifecycle, message routing, auto-reconnect with
5
+ * exponential backoff, and request/response correlation via message IDs.
6
+ *
7
+ * Works in both browser and desktop environments (uses globalThis.WebSocket).
8
+ */
9
+
10
+ import type { Command } from '../core/commands.js';
11
+ import type { PatchMessage, StatePatch } from './patch-types.js';
12
+
13
+ // --- Types for the server protocol ---
14
+
15
+ /** The state snapshot returned by the server on sync_request. */
16
+ export interface StateSnapshot {
17
+ project: { name: string; width: number; height: number } | null;
18
+ canvases: Record<string, {
19
+ name: string;
20
+ width: number;
21
+ height: number;
22
+ layers: Array<Record<string, unknown>>;
23
+ frames?: Array<{ id: string; duration_ms: number | null }>;
24
+ current_frame_index?: number;
25
+ global_fps?: number;
26
+ }>;
27
+ layers: Record<string, Array<Record<string, unknown>>>;
28
+ history: Array<Record<string, unknown>>;
29
+ }
30
+
31
+ /** A broadcast command from another client. */
32
+ export interface BroadcastedCommand {
33
+ type: string;
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ // --- Callback types ---
38
+
39
+ type StateSyncCallback = (state: StateSnapshot) => void;
40
+ type CommandBroadcastCallback = (command: BroadcastedCommand) => void;
41
+ type StatePatchCallback = (message: PatchMessage) => void;
42
+ type ConnectionChangeCallback = (connected: boolean) => void;
43
+ type ErrorCallback = (error: string) => void;
44
+ type ForwardRejectCallback = (commandId: string, reason: string) => void;
45
+
46
+ // --- Pending message tracking ---
47
+
48
+ interface PendingMessage {
49
+ resolve: (value: unknown) => void;
50
+ reject: (reason: Error) => void;
51
+ timer: ReturnType<typeof setTimeout>;
52
+ }
53
+
54
+ // --- Constants ---
55
+
56
+ const PENDING_TIMEOUT_MS = 10_000;
57
+ const MAX_BACKOFF_MS = 30_000;
58
+ const BASE_BACKOFF_MS = 1_000;
59
+
60
+ // --- Exported helpers (unit-testable) ---
61
+
62
+ /** Generate a unique message ID using crypto.randomUUID. */
63
+ export function generateMessageId(): string {
64
+ return crypto.randomUUID();
65
+ }
66
+
67
+ /**
68
+ * Calculate exponential backoff delay in milliseconds.
69
+ * Formula: min(base * 2^attempt, maxBackoff)
70
+ */
71
+ export function calculateBackoff(attempt: number): number {
72
+ return Math.min(BASE_BACKOFF_MS * Math.pow(2, attempt), MAX_BACKOFF_MS);
73
+ }
74
+
75
+ // --- WebSocket Client ---
76
+
77
+ export class WebSocketClient {
78
+ private ws: WebSocket | null = null;
79
+ private url: string;
80
+ private reconnectAttempts = 0;
81
+ private maxReconnectAttempts = 10;
82
+ private pendingMessages: Map<string, PendingMessage> = new Map();
83
+ /** Message IDs of fire-and-forget forwarded commands awaiting server ack. */
84
+ private forwardedIds: Set<string> = new Set();
85
+ private forwardRejectCallback: ForwardRejectCallback | null = null;
86
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
87
+ private intentionalDisconnect = false;
88
+
89
+ // Event callback arrays
90
+ private stateSyncCallbacks: StateSyncCallback[] = [];
91
+ private commandBroadcastCallbacks: CommandBroadcastCallback[] = [];
92
+ private statePatchCallbacks: StatePatchCallback[] = [];
93
+ private connectionChangeCallbacks: ConnectionChangeCallback[] = [];
94
+ private errorCallbacks: ErrorCallback[] = [];
95
+
96
+ constructor(url: string) {
97
+ this.url = url;
98
+ }
99
+
100
+ // --- Connection lifecycle ---
101
+
102
+ /** Connect to the WebSocket server. Resolves when the connection is open. */
103
+ connect(): Promise<void> {
104
+ this.intentionalDisconnect = false;
105
+
106
+ // If an existing socket is open or still connecting, tear it down
107
+ // cleanly before constructing a new one. Without this, the old
108
+ // socket's onclose would fire AFTER this.ws is reassigned and
109
+ // trigger scheduleReconnect() -- a phantom reconnect on top of
110
+ // the fresh connection.
111
+ if (
112
+ this.ws &&
113
+ (this.ws.readyState === globalThis.WebSocket.OPEN ||
114
+ this.ws.readyState === globalThis.WebSocket.CONNECTING)
115
+ ) {
116
+ this.ws.onopen = null;
117
+ this.ws.onmessage = null;
118
+ this.ws.onerror = null;
119
+ this.ws.onclose = null;
120
+ this.ws.close();
121
+ this.ws = null;
122
+ }
123
+
124
+ // Cancel any pending backoff reconnect -- a manual connect()
125
+ // supersedes the scheduled one so we don't race two connections.
126
+ if (this.reconnectTimer !== null) {
127
+ clearTimeout(this.reconnectTimer);
128
+ this.reconnectTimer = null;
129
+ }
130
+
131
+ return new Promise<void>((resolve, reject) => {
132
+ // Guards the connect-promise against multi-fire events. onerror can
133
+ // fire multiple times on the same socket, and both onopen and onerror
134
+ // race onclose. Without this flag, we would call resolve/reject (and
135
+ // spam notifyError on rejection) more than once per connect attempt.
136
+ let settled = false;
137
+ const settleResolve = () => {
138
+ if (settled) return;
139
+ settled = true;
140
+ resolve();
141
+ };
142
+ const settleReject = (err: Error) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ reject(err);
146
+ };
147
+
148
+ try {
149
+ this.ws = new globalThis.WebSocket(this.url);
150
+ } catch (err) {
151
+ // Synchronous constructor throw: no socket exists, so onclose will
152
+ // never fire. If we're in an auto-reconnecting state, schedule the
153
+ // next attempt explicitly -- otherwise the retry chain dies silently.
154
+ if (
155
+ !this.intentionalDisconnect &&
156
+ this.reconnectAttempts < this.maxReconnectAttempts
157
+ ) {
158
+ this.scheduleReconnect();
159
+ }
160
+ settleReject(new Error(`Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`));
161
+ return;
162
+ }
163
+
164
+ this.ws.onopen = () => {
165
+ this.reconnectAttempts = 0;
166
+ this.notifyConnectionChange(true);
167
+ settleResolve();
168
+ };
169
+
170
+ this.ws.onclose = () => {
171
+ this.notifyConnectionChange(false);
172
+ this.rejectAllPending('WebSocket connection closed');
173
+ if (!this.intentionalDisconnect) {
174
+ this.scheduleReconnect();
175
+ }
176
+ };
177
+
178
+ this.ws.onerror = (event) => {
179
+ // onerror can fire multiple times for a single socket in the
180
+ // pre-settle window, and also post-open as a runtime signal. The
181
+ // `settled` guard stops us from rejecting repeatedly AND from
182
+ // re-notifying subscribers for each duplicate error. Post-settle
183
+ // onerror events are swallowed here; the following onclose still
184
+ // notifies via connection change, so subscribers still learn the
185
+ // socket died.
186
+ if (settled) return;
187
+ const msg = `WebSocket error${event instanceof ErrorEvent ? `: ${event.message}` : ''}`;
188
+ this.notifyError(msg);
189
+ // If we haven't connected yet, reject the promise. settleReject
190
+ // flips `settled` so duplicate onerror events become no-ops above.
191
+ if (this.ws?.readyState !== globalThis.WebSocket.OPEN) {
192
+ settleReject(new Error(msg));
193
+ }
194
+ };
195
+
196
+ this.ws.onmessage = (event) => {
197
+ // event.data is typed `any` by lib.dom; narrow it to what we accept.
198
+ const payload: unknown = event.data;
199
+ if (typeof payload === 'string' || payload instanceof ArrayBuffer) {
200
+ this.handleMessage(payload);
201
+ }
202
+ };
203
+ });
204
+ }
205
+
206
+ /** Disconnect from the server. Stops auto-reconnect. */
207
+ disconnect(): void {
208
+ this.intentionalDisconnect = true;
209
+ if (this.reconnectTimer !== null) {
210
+ clearTimeout(this.reconnectTimer);
211
+ this.reconnectTimer = null;
212
+ }
213
+ this.rejectAllPending('Client disconnected');
214
+ if (this.ws) {
215
+ // Null ALL four handlers symmetrically. Nulling only onclose (the
216
+ // previous behavior) still let the closing socket fire stale
217
+ // onopen/onmessage/onerror events, which would call handleMessage
218
+ // or notifyError after the caller has logically disconnected. This
219
+ // matches the reconnect-path cleanup in connect().
220
+ this.ws.onopen = null;
221
+ this.ws.onmessage = null;
222
+ this.ws.onerror = null;
223
+ this.ws.onclose = null;
224
+ this.ws.close();
225
+ this.ws = null;
226
+ this.notifyConnectionChange(false);
227
+ }
228
+ }
229
+
230
+ /** Update the server URL. Does NOT reconnect automatically. */
231
+ setUrl(url: string): void {
232
+ this.url = url;
233
+ }
234
+
235
+ // --- Message sending ---
236
+
237
+ /**
238
+ * Send a command to the server and wait for ack/reject.
239
+ * The caller provides the command without id/timestamp -- those are filled in.
240
+ */
241
+ async sendCommand(commandFields: Omit<Command, 'id' | 'timestamp'>): Promise<void> {
242
+ const command: Command = {
243
+ ...commandFields,
244
+ id: generateMessageId(),
245
+ timestamp: Date.now(),
246
+ };
247
+
248
+ const msgId = generateMessageId();
249
+ const message = {
250
+ type: 'command' as const,
251
+ id: msgId,
252
+ command,
253
+ };
254
+
255
+ await this.sendAndWait(msgId, message);
256
+ }
257
+
258
+ /**
259
+ * Forward an already-dispatched command to the server. Unlike sendCommand(),
260
+ * this preserves the command's existing id and timestamp. Used by the
261
+ * dispatcher's sync hook to relay locally-executed commands to the server.
262
+ *
263
+ * Best-effort: send failures are silently ignored so local dispatch is never
264
+ * blocked by network issues. However, if the server later rejects the
265
+ * command, the forwardRejectCallback (if set) is invoked.
266
+ */
267
+ forwardCommand(command: Command): void {
268
+ if (!this.isConnected()) return;
269
+
270
+ const msgId = generateMessageId();
271
+ const message = {
272
+ type: 'command' as const,
273
+ id: msgId,
274
+ command,
275
+ };
276
+
277
+ try {
278
+ // isConnected() narrowed above -- ws is non-null while connected,
279
+ // but TS can't follow the guard through a method call.
280
+ this.ws?.send(JSON.stringify(message));
281
+ // Track this ID so we can detect server rejection asynchronously.
282
+ this.forwardedIds.add(msgId);
283
+ } catch {
284
+ // Best-effort -- don't block the caller on send failures.
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Register a callback invoked when the server rejects a forwarded command.
290
+ * Pass null to clear. Only one callback is active at a time.
291
+ */
292
+ setOnForwardReject(cb: ForwardRejectCallback | null): void {
293
+ this.forwardRejectCallback = cb;
294
+ }
295
+
296
+ /** Request a full state sync from the server. */
297
+ async requestSync(): Promise<StateSnapshot> {
298
+ const msgId = generateMessageId();
299
+ const message = {
300
+ type: 'sync_request' as const,
301
+ id: msgId,
302
+ };
303
+
304
+ const result = await this.sendAndWait(msgId, message);
305
+ return result as StateSnapshot;
306
+ }
307
+
308
+ /** Send an undo request and wait for ack. */
309
+ async sendUndo(): Promise<void> {
310
+ const msgId = generateMessageId();
311
+ const message = {
312
+ type: 'undo' as const,
313
+ id: msgId,
314
+ };
315
+
316
+ await this.sendAndWait(msgId, message);
317
+ }
318
+
319
+ /** Send a redo request and wait for ack. */
320
+ async sendRedo(): Promise<void> {
321
+ const msgId = generateMessageId();
322
+ const message = {
323
+ type: 'redo' as const,
324
+ id: msgId,
325
+ };
326
+
327
+ await this.sendAndWait(msgId, message);
328
+ }
329
+
330
+ // --- Event subscription ---
331
+
332
+ /** Register a callback for state_sync messages. Returns an unsubscribe function. */
333
+ onStateSync(callback: StateSyncCallback): () => void {
334
+ this.stateSyncCallbacks.push(callback);
335
+ return () => {
336
+ const idx = this.stateSyncCallbacks.indexOf(callback);
337
+ if (idx !== -1) this.stateSyncCallbacks.splice(idx, 1);
338
+ };
339
+ }
340
+
341
+ /** Register a callback for command_broadcast messages. Returns an unsubscribe function. */
342
+ onCommandBroadcast(callback: CommandBroadcastCallback): () => void {
343
+ this.commandBroadcastCallbacks.push(callback);
344
+ return () => {
345
+ const idx = this.commandBroadcastCallbacks.indexOf(callback);
346
+ if (idx !== -1) this.commandBroadcastCallbacks.splice(idx, 1);
347
+ };
348
+ }
349
+
350
+ /** Register a callback for state_patch messages. Returns an unsubscribe function. */
351
+ onStatePatch(callback: StatePatchCallback): () => void {
352
+ this.statePatchCallbacks.push(callback);
353
+ return () => {
354
+ const idx = this.statePatchCallbacks.indexOf(callback);
355
+ if (idx !== -1) this.statePatchCallbacks.splice(idx, 1);
356
+ };
357
+ }
358
+
359
+ /** Register a callback for connection state changes. Returns an unsubscribe function. */
360
+ onConnectionChange(callback: ConnectionChangeCallback): () => void {
361
+ this.connectionChangeCallbacks.push(callback);
362
+ return () => {
363
+ const idx = this.connectionChangeCallbacks.indexOf(callback);
364
+ if (idx !== -1) this.connectionChangeCallbacks.splice(idx, 1);
365
+ };
366
+ }
367
+
368
+ /** Register a callback for error messages. Returns an unsubscribe function. */
369
+ onError(callback: ErrorCallback): () => void {
370
+ this.errorCallbacks.push(callback);
371
+ return () => {
372
+ const idx = this.errorCallbacks.indexOf(callback);
373
+ if (idx !== -1) this.errorCallbacks.splice(idx, 1);
374
+ };
375
+ }
376
+
377
+ // --- Connection state query ---
378
+
379
+ /** Whether the WebSocket is currently open. */
380
+ isConnected(): boolean {
381
+ return this.ws?.readyState === globalThis.WebSocket.OPEN;
382
+ }
383
+
384
+ // --- Internal: message handling ---
385
+
386
+ private handleMessage(raw: string | ArrayBuffer): void {
387
+ let data: Record<string, unknown>;
388
+ try {
389
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
390
+ data = JSON.parse(text) as Record<string, unknown>;
391
+ } catch {
392
+ this.notifyError('Failed to parse server message');
393
+ return;
394
+ }
395
+
396
+ switch (data["type"]) {
397
+ case 'command_ack':
398
+ // Clear forwarded tracking if this ack is for a forwarded command.
399
+ this.forwardedIds.delete(data["id"] as string);
400
+ this.resolvePending(data["id"] as string, undefined);
401
+ break;
402
+
403
+ case 'command_reject': {
404
+ const rejectId = data["id"] as string;
405
+ const errVal = data["error"];
406
+ const reason = typeof errVal === 'string' ? errVal : 'unknown reason';
407
+ if (this.forwardedIds.delete(rejectId)) {
408
+ // This was a fire-and-forget forward -- notify via callback.
409
+ this.forwardRejectCallback?.(rejectId, reason);
410
+ }
411
+ this.rejectPending(rejectId, new Error(`Command rejected: ${reason}`));
412
+ break;
413
+ }
414
+
415
+ case 'state_sync':
416
+ // Resolve pending sync_request if there is one
417
+ this.resolvePending(data["id"] as string, data["state"] as StateSnapshot);
418
+ // Also notify subscribers (for reconnect-triggered syncs, etc.)
419
+ for (const cb of this.stateSyncCallbacks) {
420
+ cb(data["state"] as StateSnapshot);
421
+ }
422
+ break;
423
+
424
+ case 'command_broadcast':
425
+ for (const cb of this.commandBroadcastCallbacks) {
426
+ cb(data["command"] as BroadcastedCommand);
427
+ }
428
+ break;
429
+
430
+ case 'state_patch': {
431
+ // Server sends snake_case keys; map to the camelCase PatchMessage type.
432
+ // Spread-conditional on commandId: exactOptionalPropertyTypes distinguishes
433
+ // "missing" from "explicitly undefined", and PatchMessage.commandId is
434
+ // declared as optional (not `string | undefined`).
435
+ const commandId = data["command_id"] as string | undefined;
436
+ const patchMessage: PatchMessage = {
437
+ type: 'state_patch',
438
+ projectName: data["project_name"] as string,
439
+ patches: data["patches"] as StatePatch[],
440
+ ...(commandId !== undefined && { commandId }),
441
+ };
442
+ for (const cb of this.statePatchCallbacks) {
443
+ cb(patchMessage);
444
+ }
445
+ break;
446
+ }
447
+
448
+ case 'error': {
449
+ // Reject the pending message if there is one
450
+ const errVal = data["error"];
451
+ const errStr = typeof errVal === 'string' ? errVal : 'unknown';
452
+ this.rejectPending(
453
+ data["id"] as string,
454
+ new Error(`Server error: ${errStr}`),
455
+ );
456
+ this.notifyError(errStr);
457
+ break;
458
+ }
459
+
460
+ default:
461
+ this.notifyError(`Unknown message type: ${String(data["type"])}`);
462
+ }
463
+ }
464
+
465
+ // --- Internal: send-and-wait pattern ---
466
+
467
+ /**
468
+ * Send a JSON message and return a promise that resolves when the server
469
+ * responds with a matching message ID (ack/state_sync) or rejects on
470
+ * error/timeout.
471
+ */
472
+ private sendAndWait(msgId: string, message: Record<string, unknown>): Promise<unknown> {
473
+ return new Promise((resolve, reject) => {
474
+ if (!this.isConnected()) {
475
+ reject(new Error('Not connected to server'));
476
+ return;
477
+ }
478
+
479
+ const timer = setTimeout(() => {
480
+ this.pendingMessages.delete(msgId);
481
+ reject(new Error(`Request timed out after ${String(PENDING_TIMEOUT_MS)}ms`));
482
+ }, PENDING_TIMEOUT_MS);
483
+
484
+ this.pendingMessages.set(msgId, { resolve, reject, timer });
485
+
486
+ try {
487
+ // isConnected() guarded above -- ws is non-null, but TS loses the
488
+ // narrowing through the method call.
489
+ this.ws?.send(JSON.stringify(message));
490
+ } catch (err) {
491
+ clearTimeout(timer);
492
+ this.pendingMessages.delete(msgId);
493
+ reject(new Error(`Failed to send message: ${err instanceof Error ? err.message : String(err)}`));
494
+ }
495
+ });
496
+ }
497
+
498
+ private resolvePending(msgId: string, value: unknown): void {
499
+ const pending = this.pendingMessages.get(msgId);
500
+ if (pending) {
501
+ clearTimeout(pending.timer);
502
+ this.pendingMessages.delete(msgId);
503
+ pending.resolve(value);
504
+ }
505
+ }
506
+
507
+ private rejectPending(msgId: string, error: Error): void {
508
+ const pending = this.pendingMessages.get(msgId);
509
+ if (pending) {
510
+ clearTimeout(pending.timer);
511
+ this.pendingMessages.delete(msgId);
512
+ pending.reject(error);
513
+ }
514
+ }
515
+
516
+ private rejectAllPending(reason: string): void {
517
+ for (const [id, pending] of this.pendingMessages) {
518
+ clearTimeout(pending.timer);
519
+ pending.reject(new Error(reason));
520
+ this.pendingMessages.delete(id);
521
+ }
522
+ // Forwarded commands will never receive ack/reject after disconnect.
523
+ this.forwardedIds.clear();
524
+ }
525
+
526
+ // --- Internal: reconnect logic ---
527
+
528
+ private scheduleReconnect(): void {
529
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
530
+ this.notifyError(
531
+ `Max reconnect attempts (${String(this.maxReconnectAttempts)}) reached. Giving up.`,
532
+ );
533
+ return;
534
+ }
535
+
536
+ const delay = calculateBackoff(this.reconnectAttempts);
537
+ this.reconnectAttempts++;
538
+
539
+ this.reconnectTimer = setTimeout(() => {
540
+ this.reconnectTimer = null;
541
+ // Fire-and-forget: setTimeout expects () => void, so wrap the async
542
+ // body in an IIFE and discard the promise.
543
+ void (async () => {
544
+ try {
545
+ await this.connect();
546
+ // On successful reconnect, request full state sync
547
+ this.requestSync().catch((err: unknown) => {
548
+ const msg = err instanceof Error ? err.message : String(err);
549
+ this.notifyError(`Sync after reconnect failed: ${msg}`);
550
+ });
551
+ } catch {
552
+ // connect() rejects on failure. If the socket was constructed and
553
+ // later errored/closed, its onclose will trigger another reconnect.
554
+ // If the constructor threw synchronously, connect()'s catch branch
555
+ // already called scheduleReconnect() so the chain survives.
556
+ }
557
+ })();
558
+ }, delay);
559
+ }
560
+
561
+ // --- Internal: notification helpers ---
562
+
563
+ private notifyConnectionChange(connected: boolean): void {
564
+ for (const cb of this.connectionChangeCallbacks) {
565
+ cb(connected);
566
+ }
567
+ }
568
+
569
+ private notifyError(error: string): void {
570
+ for (const cb of this.errorCallbacks) {
571
+ cb(error);
572
+ }
573
+ }
574
+ }
File without changes
File without changes
@@ -0,0 +1,113 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ onClose: () => void;
4
+ }
5
+ let { onClose }: Props = $props();
6
+
7
+ let dialogEl: HTMLDialogElement | undefined = $state();
8
+
9
+ // Open the native <dialog> as soon as it's bound. This runs once on mount
10
+ // because dialogEl transitions from undefined to the element.
11
+ $effect(() => {
12
+ if (dialogEl && !dialogEl.open) {
13
+ dialogEl.showModal();
14
+ }
15
+ });
16
+
17
+ // Backdrop click: clicks on the <dialog> element itself target the dialog
18
+ // (not children), so we can use that to detect outside clicks. We call
19
+ // dialogEl.close() rather than onClose() directly so the `close` event
20
+ // fires once through a single code path (onclose={onClose}).
21
+ function handleDialogClick(e: MouseEvent) {
22
+ if (e.target === dialogEl) {
23
+ // e.target === dialogEl narrows dialogEl to non-undefined here.
24
+ dialogEl.close();
25
+ }
26
+ }
27
+
28
+ function handleOk() {
29
+ dialogEl?.close();
30
+ }
31
+ </script>
32
+
33
+ <!--
34
+ Native <dialog> gives us focus trap, Escape-to-close (via cancel event),
35
+ and backdrop rendering for free. onclose fires on both user and programmatic
36
+ close and is where we forward the parent's onClose callback.
37
+ -->
38
+ <dialog
39
+ bind:this={dialogEl}
40
+ class="about-dialog-el"
41
+ aria-label="About PixelWeaver"
42
+ onclick={handleDialogClick}
43
+ onclose={onClose}
44
+ >
45
+ <div class="about-dialog">
46
+ <h2>PixelWeaver</h2>
47
+ <p>Pixel Art Editor</p>
48
+ <p class="version">Version 1.0.0</p>
49
+ <button class="close-btn" onclick={handleOk}>OK</button>
50
+ </div>
51
+ </dialog>
52
+
53
+ <style>
54
+ /* Reset UA styles and use flexbox to center the inner content. */
55
+ .about-dialog-el {
56
+ margin: 0;
57
+ padding: 0;
58
+ border: none;
59
+ background: transparent;
60
+ max-width: none;
61
+ max-height: none;
62
+ width: 100vw;
63
+ height: 100vh;
64
+ color: inherit;
65
+ }
66
+
67
+ .about-dialog-el[open] {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ }
72
+
73
+ .about-dialog-el::backdrop {
74
+ background: rgba(0, 0, 0, 0.5);
75
+ }
76
+
77
+ .about-dialog {
78
+ background: var(--bg-panel);
79
+ border: 1px solid var(--border);
80
+ border-radius: var(--radius-md);
81
+ padding: var(--space-6) var(--space-8);
82
+ text-align: center;
83
+ min-width: 280px;
84
+ box-shadow: var(--shadow-md);
85
+ }
86
+ h2 {
87
+ margin: 0 0 var(--space-2);
88
+ font-family: var(--font-ui);
89
+ font-size: var(--text-xl);
90
+ color: var(--accent);
91
+ }
92
+ p {
93
+ margin: 0 0 var(--space-2);
94
+ color: var(--text-secondary);
95
+ font-size: var(--text-base);
96
+ }
97
+ .version {
98
+ color: var(--text-muted);
99
+ font-size: var(--text-sm);
100
+ margin-bottom: var(--space-4);
101
+ }
102
+ .close-btn {
103
+ background: var(--accent);
104
+ color: var(--text-on-accent, #fff);
105
+ border: none;
106
+ border-radius: var(--radius-sm);
107
+ padding: var(--space-2) var(--space-6);
108
+ font-size: var(--text-base);
109
+ cursor: pointer;
110
+ transition: opacity var(--transition-fast);
111
+ }
112
+ .close-btn:hover { opacity: 0.9; }
113
+ </style>