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,1808 @@
1
+ <!--
2
+ LevelEditorViewport -- interactive canvas for the tilemap / level editor.
3
+
4
+ Renders the current map-state on an HTML <canvas> with pan and zoom,
5
+ shows a hover highlight on the cell under the pointer, and paints a
6
+ placeholder tile on click into the active tile layer. Ships with a
7
+ header button that exports the map to Tiled JSON (.tmj).
8
+
9
+ Scope: this is just enough UI to render and paint. A real tile picker,
10
+ multi-tool support, layer controls, and entity/collision editing are
11
+ future work.
12
+ -->
13
+ <script lang="ts">
14
+ import { onMount } from 'svelte';
15
+ import DownloadIcon from '~icons/lucide/download';
16
+ import PlusIcon from '~icons/lucide/plus';
17
+ import TrashIcon from '~icons/lucide/trash-2';
18
+ import ChevronDownIcon from '~icons/lucide/chevron-down';
19
+ import ChevronUpIcon from '~icons/lucide/chevron-up';
20
+ import LayersIcon from '~icons/lucide/layers';
21
+ import MousePointerClickIcon from '~icons/lucide/mouse-pointer-click';
22
+ import BoxSelectIcon from '~icons/lucide/box-select';
23
+ import PentagonIcon from '~icons/lucide/pentagon';
24
+ import PaintbrushIcon from '~icons/lucide/paintbrush';
25
+ import { mapState, type TilePlacement, type EntityPlacement, type MapLayer } from './map-state.svelte.js';
26
+ import { exportTiledMap, buildTilesetSpritesheet, pixelBufferToPngBlob } from './tiled-export.js';
27
+ import { depthSort, type RenderItem } from './depth-sort.js';
28
+ import { screenToIso } from '../iso/iso-math.js';
29
+ import { ZOOM_STEPS } from '../canvas/zoom-utils.js';
30
+ import { tileSourceRegistry, tileRefId } from './tile-source-registry.svelte.js';
31
+ import { notificationState } from '../core/notification-state.svelte.js';
32
+ import { downloadCompanionFiles, type DownloadFileOptions } from '../export/download.js';
33
+ import { dispatch, undoLast, redoLast } from '../core/dispatcher.js';
34
+
35
+ // --- Local viewport state (not shared with the pixel canvas) ---
36
+
37
+ // Panning offset in screen pixels.
38
+ let panX = $state(0);
39
+ let panY = $state(0);
40
+ // Integer zoom (reuses the ZOOM_STEPS set used by the pixel canvas).
41
+ let zoom = $state(2);
42
+
43
+ // Cell currently under the cursor (null when pointer leaves the canvas).
44
+ let hoverCol = $state<number | null>(null);
45
+ let hoverRow = $state<number | null>(null);
46
+
47
+ // Whether the grid overlay is drawn. Toggled with the G key.
48
+ let showGrid = $state(true);
49
+
50
+ // --- Layer panel state ---
51
+
52
+ // Whether the layer list overlay is expanded in the viewport.
53
+ let layerPanelOpen = $state(true);
54
+ // Whether the "add layer" type dropdown is visible.
55
+ let addLayerDropdownOpen = $state(false);
56
+
57
+ // --- Layer drag-to-reorder state ---
58
+
59
+ let layerDragFromIndex = $state<number | null>(null);
60
+ let layerDragOverIndex = $state<number | null>(null);
61
+ // 'above' or 'below' relative to the hovered row (vertical list)
62
+ let layerDragInsertSide = $state<'above' | 'below' | null>(null);
63
+
64
+ // --- Canvas element ref and rAF loop ---
65
+
66
+ let canvasEl: HTMLCanvasElement;
67
+ let animFrameId: number;
68
+
69
+ // --- Active tool selection ---
70
+
71
+ // Active tool in the level editor
72
+ type LevelEditorTool = 'paint' | 'entity' | 'collision-rect' | 'collision-polygon';
73
+ let activeTool = $state<LevelEditorTool>('paint');
74
+
75
+ // Entity dragging state
76
+ let draggingEntity: { layerId: string; entityId: string; offsetX: number; offsetY: number; startX: number; startY: number } | null = null;
77
+
78
+ // Collision rect drawing state
79
+ let collisionDrawing = $state(false);
80
+ let collisionStartX = 0;
81
+ let collisionStartY = 0;
82
+ let collisionEndX = $state(0);
83
+ let collisionEndY = $state(0);
84
+
85
+ // Polygon drawing state (click-to-add-vertex, double-click to close)
86
+ let polygonPoints = $state<{ x: number; y: number }[]>([]);
87
+ let polygonPreviewX = $state(0);
88
+ let polygonPreviewY = $state(0);
89
+
90
+ // --- Pan / paint state ---
91
+
92
+ let isPanning = false;
93
+ let isErasing = false;
94
+ let panStartX = 0;
95
+ let panStartY = 0;
96
+ let panStartPanX = 0;
97
+ let panStartPanY = 0;
98
+
99
+ // Pan step in screen pixels per arrow-key press.
100
+ const PAN_STEP = 32;
101
+
102
+ // Cache of ImageData by tile-ref id, refreshed each frame so source edits
103
+ // propagate. Re-creating ImageData per cell each frame would be wasteful;
104
+ // re-creating once per ref keeps the per-tile draw call cheap.
105
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- render cache, not reactive
106
+ const imageDataCache = new Map<string, ImageData>();
107
+
108
+ // --- Derived state ---
109
+
110
+ // First tile layer (the one we paint into). We auto-create one on first
111
+ // paint if the user hasn't added any, so the viewport is usable from a
112
+ // fresh map. Using a getter keeps this reactive through mapState.
113
+ function firstTileLayerId(): string | null {
114
+ const layer = mapState.layers.find((l) => l.type === 'tile');
115
+ return layer ? layer.id : null;
116
+ }
117
+
118
+ // Count placed tiles across all tile layers -- used to drive the empty
119
+ // state overlay. Reading mapState.layers inside a $derived makes this
120
+ // reactive.
121
+ let placedTileCount = $derived.by(() => {
122
+ let n = 0;
123
+ for (const l of mapState.layers) {
124
+ if (l.type === 'tile' && l.tiles) n += l.tiles.size;
125
+ }
126
+ return n;
127
+ });
128
+
129
+ // Dismiss the persistent "no active tile" notification once a tile is selected.
130
+ $effect(() => {
131
+ if (mapState.activeTile) {
132
+ notificationState.dismiss('level-editor/no-active-tile');
133
+ }
134
+ });
135
+
136
+ // --- Zoom steps ---
137
+
138
+ function stepUp(current: number): number {
139
+ for (const step of ZOOM_STEPS) if (step > current) return step;
140
+ // ZOOM_STEPS is non-empty so the last element is always defined.
141
+ return ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? current;
142
+ }
143
+
144
+ function stepDown(current: number): number {
145
+ for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
146
+ const step = ZOOM_STEPS[i];
147
+ if (step !== undefined && step < current) return step;
148
+ }
149
+ return ZOOM_STEPS[0];
150
+ }
151
+
152
+ // --- Coordinate helpers ---
153
+
154
+ /**
155
+ * Convert a canvas-local screen position to a grid cell.
156
+ * Accounts for the current pan and zoom and the viewport's center origin
157
+ * (column 0, row 0 is anchored at the horizontal middle of the viewport).
158
+ */
159
+ function screenToCell(screenX: number, screenY: number): { col: number; row: number } {
160
+ const rect = canvasEl.getBoundingClientRect();
161
+ // World-space: undo centering, pan, and zoom.
162
+ const worldX = (screenX - rect.width / 2 - panX) / zoom;
163
+ const worldY = (screenY - panY) / zoom;
164
+ if (mapState.gridMode === 'ortho') {
165
+ return {
166
+ col: Math.floor(worldX / mapState.tileWidth),
167
+ row: Math.floor(worldY / mapState.tileHeight),
168
+ };
169
+ }
170
+ const { col, row } = screenToIso(worldX, worldY, mapState.tileWidth, mapState.tileHeight);
171
+ return { col: Math.floor(col), row: Math.floor(row) };
172
+ }
173
+
174
+ /**
175
+ * Convert a canvas-local screen position to world coordinates (not grid-snapped).
176
+ * Used for entity placement and collision shape drawing where free positioning
177
+ * is needed.
178
+ */
179
+ function screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
180
+ const rect = canvasEl.getBoundingClientRect();
181
+ const x = (screenX - rect.width / 2 - panX) / zoom;
182
+ const y = (screenY - panY) / zoom;
183
+ return { x, y };
184
+ }
185
+
186
+ /** Find the entity under a world-space point (simple bounding box test). */
187
+ function hitTestEntity(worldX: number, worldY: number): { layerId: string; entity: EntityPlacement } | null {
188
+ // Check entity layers in reverse (top-most first)
189
+ for (let i = mapState.layers.length - 1; i >= 0; i--) {
190
+ const layer = mapState.layers[i];
191
+ if (!layer || !layer.visible || layer.type !== 'entity' || !layer.entities) continue;
192
+ for (let j = layer.entities.length - 1; j >= 0; j--) {
193
+ const entity = layer.entities[j];
194
+ if (!entity) continue;
195
+ const ew = mapState.tileWidth;
196
+ const eh = mapState.tileHeight;
197
+ if (worldX >= entity.x && worldX < entity.x + ew &&
198
+ worldY >= entity.y && worldY < entity.y + eh) {
199
+ return { layerId: layer.id, entity };
200
+ }
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ // --- Rendering ---
207
+
208
+ /** Resize the canvas backing store to match its CSS size (retina-aware). */
209
+ function resizeCanvas(): void {
210
+ const rect = canvasEl.getBoundingClientRect();
211
+ const dpr = window.devicePixelRatio || 1;
212
+ const w = Math.round(rect.width * dpr);
213
+ const h = Math.round(rect.height * dpr);
214
+ if (canvasEl.width !== w || canvasEl.height !== h) {
215
+ canvasEl.width = w;
216
+ canvasEl.height = h;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Draw a single iso tile diamond outline at the given (col, row).
222
+ * `screenX` / `screenY` is the top anchor of the diamond in viewport pixels.
223
+ */
224
+ function drawDiamond(
225
+ ctx: CanvasRenderingContext2D,
226
+ cx: number,
227
+ cy: number,
228
+ tw: number,
229
+ th: number,
230
+ ): void {
231
+ ctx.beginPath();
232
+ ctx.moveTo(cx, cy); // top
233
+ ctx.lineTo(cx + tw / 2, cy + th / 2); // right
234
+ ctx.lineTo(cx, cy + th); // bottom
235
+ ctx.lineTo(cx - tw / 2, cy + th / 2); // left
236
+ ctx.closePath();
237
+ }
238
+
239
+ /**
240
+ * Draw an axis-aligned rectangle path at (cx, cy) -- the top-left of the
241
+ * cell -- with size (tw, th). Used for the orthogonal grid mode.
242
+ */
243
+ function drawCellRect(
244
+ ctx: CanvasRenderingContext2D,
245
+ cx: number,
246
+ cy: number,
247
+ tw: number,
248
+ th: number,
249
+ ): void {
250
+ ctx.beginPath();
251
+ ctx.rect(cx, cy, tw, th);
252
+ }
253
+
254
+ /**
255
+ * Compute the scaled screen-space anchor for a given (col,row), already
256
+ * multiplied by the current zoom (i.e. matching the tw/th the render loop
257
+ * uses). Branches on gridMode -- iso returns the diamond top, ortho
258
+ * returns the cell's top-left.
259
+ */
260
+ function cellScreenOffset(col: number, row: number, tw: number, th: number): { x: number; y: number } {
261
+ if (mapState.gridMode === 'ortho') {
262
+ return { x: col * tw, y: row * th };
263
+ }
264
+ return { x: (col - row) * (tw / 2), y: (col + row) * (th / 2) };
265
+ }
266
+
267
+ /**
268
+ * Path a single grid cell outline using the projection appropriate for
269
+ * the current grid mode. Caller is responsible for stroke/fill style.
270
+ */
271
+ function pathCell(
272
+ ctx: CanvasRenderingContext2D,
273
+ col: number,
274
+ row: number,
275
+ originX: number,
276
+ originY: number,
277
+ tw: number,
278
+ th: number,
279
+ ): void {
280
+ const { x, y } = cellScreenOffset(col, row, tw, th);
281
+ if (mapState.gridMode === 'ortho') {
282
+ drawCellRect(ctx, originX + x, originY + y, tw, th);
283
+ } else {
284
+ drawDiamond(ctx, originX + x, originY + y, tw, th);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Read a CSS custom property from :root at render time so that theme
290
+ * changes are picked up live without rebuilding the component.
291
+ */
292
+ function cssVar(name: string, fallback: string): string {
293
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
294
+ return v.length > 0 ? v : fallback;
295
+ }
296
+
297
+ /** Main render pass. */
298
+ function renderLoop(): void {
299
+ resizeCanvas();
300
+
301
+ const ctx = canvasEl.getContext('2d');
302
+ if (!ctx) {
303
+ animFrameId = requestAnimationFrame(renderLoop);
304
+ return;
305
+ }
306
+
307
+ const dpr = window.devicePixelRatio || 1;
308
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
309
+
310
+ const cssW = canvasEl.clientWidth;
311
+ const cssH = canvasEl.clientHeight;
312
+
313
+ // Background clear using the workspace canvas color.
314
+ ctx.fillStyle = cssVar('--bg-canvas', '#121212');
315
+ ctx.fillRect(0, 0, cssW, cssH);
316
+
317
+ // Origin: horizontally centered, vertically offset by pan.
318
+ const originX = cssW / 2 + panX;
319
+ const originY = panY;
320
+
321
+ const tw = mapState.tileWidth * zoom;
322
+ const th = mapState.tileHeight * zoom;
323
+
324
+ // Grid outline for all cells (when enabled). Cheap enough for the
325
+ // 20x20 default and gives the user a sense of map extents even when empty.
326
+ if (showGrid) {
327
+ const gridColor = cssVar('--border', '#3a3a3a');
328
+ ctx.strokeStyle = gridColor;
329
+ ctx.lineWidth = 1;
330
+
331
+ for (let row = 0; row < mapState.gridRows; row++) {
332
+ for (let col = 0; col < mapState.gridCols; col++) {
333
+ pathCell(ctx, col, row, originX, originY, tw, th);
334
+ ctx.stroke();
335
+ }
336
+ }
337
+ }
338
+
339
+ // Placed tiles: collect, depth-sort, draw. We try the tile source
340
+ // registry first; if a placement has a registered PixelBuffer we render
341
+ // its actual pixels, otherwise we fall back to a solid-color diamond
342
+ // (degraded render) so the user still sees where they painted.
343
+ const items: RenderItem[] = [];
344
+ let layerIndex = 0;
345
+ for (const layer of mapState.layers) {
346
+ if (layer.visible && layer.type === 'tile' && layer.tiles) {
347
+ for (const [key, placement] of layer.tiles.entries()) {
348
+ const [colStr, rowStr] = key.split(',') as [string, string];
349
+ const col = parseInt(colStr, 10);
350
+ const row = parseInt(rowStr, 10);
351
+ if (Number.isNaN(col) || Number.isNaN(row)) continue;
352
+ items.push({
353
+ type: 'tile',
354
+ col,
355
+ row,
356
+ height: placement.height,
357
+ layerIndex,
358
+ data: placement,
359
+ });
360
+ }
361
+ }
362
+ layerIndex++;
363
+ }
364
+
365
+ const sorted = depthSort(items);
366
+ const tileFill = cssVar('--accent', '#4a9eff');
367
+ const tileStroke = cssVar('--accent-hover', '#6ab4ff');
368
+
369
+ // Refresh the per-frame ImageData cache: re-fetch each unique buffer
370
+ // from the registry once so subsequent same-id placements reuse it.
371
+ imageDataCache.clear();
372
+
373
+ for (const item of sorted) {
374
+ const placement = item.data as TilePlacement;
375
+ const id = tileRefId(placement);
376
+ const buffer = tileSourceRegistry.get(id);
377
+ const { x, y } = cellScreenOffset(item.col, item.row, tw, th);
378
+
379
+ if (buffer) {
380
+ // Real tile render -- blit the PixelBuffer scaled by zoom. We use
381
+ // an offscreen canvas because putImageData ignores the ctx
382
+ // transform; drawImage respects it and lets us scale by zoom.
383
+ let imageData = imageDataCache.get(id);
384
+ if (!imageData) {
385
+ imageData = buffer.toImageData();
386
+ imageDataCache.set(id, imageData);
387
+ }
388
+ // Anchor: align tile center horizontally with cell anchor (iso top
389
+ // of diamond is centered horizontally; ortho cell anchor is the
390
+ // top-left, so we shift right by half a tile to keep the tile
391
+ // visually centered with its cell width).
392
+ const anchorX = mapState.gridMode === 'ortho'
393
+ ? originX + x
394
+ : originX + x - (buffer.width * zoom) / 2;
395
+ const anchorY = mapState.gridMode === 'ortho'
396
+ ? originY + y
397
+ : originY + y;
398
+ // Use a temporary canvas to apply nearest-neighbor scaling.
399
+ const tmp = document.createElement('canvas');
400
+ tmp.width = buffer.width;
401
+ tmp.height = buffer.height;
402
+ const tmpCtx = tmp.getContext('2d');
403
+ if (tmpCtx) {
404
+ tmpCtx.putImageData(imageData, 0, 0);
405
+ ctx.imageSmoothingEnabled = false;
406
+ ctx.drawImage(
407
+ tmp,
408
+ anchorX,
409
+ anchorY,
410
+ buffer.width * zoom,
411
+ buffer.height * zoom,
412
+ );
413
+ }
414
+ } else {
415
+ // Fallback diamond / cell -- the registry has nothing for this id.
416
+ ctx.fillStyle = tileFill;
417
+ ctx.strokeStyle = tileStroke;
418
+ ctx.lineWidth = 1.5;
419
+ if (mapState.gridMode === 'ortho') {
420
+ drawCellRect(ctx, originX + x, originY + y, tw, th);
421
+ } else {
422
+ drawDiamond(ctx, originX + x, originY + y, tw, th);
423
+ }
424
+ ctx.fill();
425
+ ctx.stroke();
426
+ }
427
+ }
428
+
429
+ // Entity rendering: draw sprites from entity layers
430
+ for (const layer of mapState.layers) {
431
+ if (!layer.visible || layer.type !== 'entity' || !layer.entities) continue;
432
+ for (const entity of layer.entities) {
433
+ // Try to get the sprite from the tile source registry
434
+ const id = tileRefId({ projectRef: entity.projectRef, canvasRef: entity.canvasRef, frameIndex: 0 });
435
+ const buffer = tileSourceRegistry.get(id);
436
+ // World to screen conversion
437
+ const sx = originX + entity.x * zoom;
438
+ const sy = originY + entity.y * zoom;
439
+ if (buffer) {
440
+ let imageData = imageDataCache.get(id);
441
+ if (!imageData) {
442
+ imageData = buffer.toImageData();
443
+ imageDataCache.set(id, imageData);
444
+ }
445
+ const tmp = document.createElement('canvas');
446
+ tmp.width = buffer.width;
447
+ tmp.height = buffer.height;
448
+ const tmpCtx = tmp.getContext('2d');
449
+ if (tmpCtx) {
450
+ tmpCtx.putImageData(imageData, 0, 0);
451
+ ctx.imageSmoothingEnabled = false;
452
+ ctx.drawImage(tmp, sx, sy, buffer.width * zoom, buffer.height * zoom);
453
+ }
454
+ } else {
455
+ // Fallback: draw a colored rectangle placeholder
456
+ ctx.fillStyle = 'rgba(255, 165, 0, 0.5)';
457
+ ctx.strokeStyle = 'rgba(255, 165, 0, 0.8)';
458
+ ctx.lineWidth = 2;
459
+ ctx.fillRect(sx, sy, mapState.tileWidth * zoom, mapState.tileHeight * zoom);
460
+ ctx.strokeRect(sx, sy, mapState.tileWidth * zoom, mapState.tileHeight * zoom);
461
+ }
462
+ }
463
+ }
464
+
465
+ // Collision shape rendering: draw as semi-transparent overlays
466
+ for (const layer of mapState.layers) {
467
+ if (!layer.visible || layer.type !== 'collision' || !layer.collisionShapes) continue;
468
+ ctx.fillStyle = 'rgba(0, 255, 100, 0.2)';
469
+ ctx.strokeStyle = 'rgba(0, 255, 100, 0.8)';
470
+ ctx.lineWidth = 2;
471
+ for (const shape of layer.collisionShapes) {
472
+ if (shape.type === 'rect' && shape.points.length >= 2) {
473
+ const p1 = shape.points[0];
474
+ const p2 = shape.points[1];
475
+ if (!p1 || !p2) continue;
476
+ const sx = originX + p1.x * zoom;
477
+ const sy = originY + p1.y * zoom;
478
+ const sw = (p2.x - p1.x) * zoom;
479
+ const sh = (p2.y - p1.y) * zoom;
480
+ ctx.fillRect(sx, sy, sw, sh);
481
+ ctx.strokeRect(sx, sy, sw, sh);
482
+ } else if (shape.type === 'polygon' && shape.points.length >= 3) {
483
+ ctx.beginPath();
484
+ const first = shape.points[0];
485
+ if (!first) continue;
486
+ ctx.moveTo(originX + first.x * zoom, originY + first.y * zoom);
487
+ for (let i = 1; i < shape.points.length; i++) {
488
+ const p = shape.points[i];
489
+ if (!p) continue;
490
+ ctx.lineTo(originX + p.x * zoom, originY + p.y * zoom);
491
+ }
492
+ ctx.closePath();
493
+ ctx.fill();
494
+ ctx.stroke();
495
+ }
496
+ }
497
+ }
498
+
499
+ // Collision drawing preview (rect in progress)
500
+ if (collisionDrawing) {
501
+ ctx.fillStyle = 'rgba(0, 255, 100, 0.15)';
502
+ ctx.strokeStyle = 'rgba(0, 255, 100, 0.6)';
503
+ ctx.lineWidth = 1;
504
+ ctx.setLineDash([6, 3]);
505
+ const sx = originX + collisionStartX * zoom;
506
+ const sy = originY + collisionStartY * zoom;
507
+ const sw = (collisionEndX - collisionStartX) * zoom;
508
+ const sh = (collisionEndY - collisionStartY) * zoom;
509
+ ctx.fillRect(sx, sy, sw, sh);
510
+ ctx.strokeRect(sx, sy, sw, sh);
511
+ ctx.setLineDash([]);
512
+ }
513
+
514
+ // Polygon drawing preview (vertices placed so far + line to cursor)
515
+ if (polygonPoints.length > 0) {
516
+ ctx.strokeStyle = 'rgba(100, 150, 255, 0.8)';
517
+ ctx.fillStyle = 'rgba(100, 150, 255, 0.2)';
518
+ ctx.lineWidth = 2;
519
+ ctx.beginPath();
520
+ const first = polygonPoints[0];
521
+ if (first) {
522
+ ctx.moveTo(originX + first.x * zoom, originY + first.y * zoom);
523
+ }
524
+ for (let i = 1; i < polygonPoints.length; i++) {
525
+ const p = polygonPoints[i];
526
+ if (!p) continue;
527
+ ctx.lineTo(originX + p.x * zoom, originY + p.y * zoom);
528
+ }
529
+ // Line to cursor position
530
+ ctx.lineTo(originX + polygonPreviewX * zoom, originY + polygonPreviewY * zoom);
531
+ ctx.stroke();
532
+ // Draw vertices as dots
533
+ for (const p of polygonPoints) {
534
+ ctx.fillStyle = 'rgba(100, 150, 255, 0.9)';
535
+ ctx.beginPath();
536
+ ctx.arc(originX + p.x * zoom, originY + p.y * zoom, 4, 0, Math.PI * 2);
537
+ ctx.fill();
538
+ }
539
+ }
540
+
541
+ // Hover highlight on top of everything.
542
+ if (
543
+ hoverCol !== null &&
544
+ hoverRow !== null &&
545
+ hoverCol >= 0 &&
546
+ hoverCol < mapState.gridCols &&
547
+ hoverRow >= 0 &&
548
+ hoverRow < mapState.gridRows
549
+ ) {
550
+ ctx.strokeStyle = cssVar('--text-primary', '#e0e0e0');
551
+ ctx.lineWidth = 2;
552
+ pathCell(ctx, hoverCol, hoverRow, originX, originY, tw, th);
553
+ ctx.stroke();
554
+ }
555
+
556
+ animFrameId = requestAnimationFrame(renderLoop);
557
+ }
558
+
559
+ // --- Keyboard handler ---
560
+
561
+ /**
562
+ * Handle keyboard shortcuts when the canvas is focused.
563
+ * Arrow keys pan, +/- zoom, G toggles grid, Escape clears active tile.
564
+ */
565
+ function onKeyDown(e: KeyboardEvent): void {
566
+ // Undo/redo: intercept before the switch so modifier combos are handled first.
567
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
568
+ undoLast();
569
+ e.preventDefault();
570
+ return;
571
+ }
572
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
573
+ redoLast();
574
+ e.preventDefault();
575
+ return;
576
+ }
577
+ // Redo alternative: Ctrl+Y (common on Windows/Linux).
578
+ if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
579
+ redoLast();
580
+ e.preventDefault();
581
+ return;
582
+ }
583
+
584
+ switch (e.key) {
585
+ case 'ArrowUp':
586
+ panY += PAN_STEP;
587
+ break;
588
+ case 'ArrowDown':
589
+ panY -= PAN_STEP;
590
+ break;
591
+ case 'ArrowLeft':
592
+ panX += PAN_STEP;
593
+ break;
594
+ case 'ArrowRight':
595
+ panX -= PAN_STEP;
596
+ break;
597
+ case '+':
598
+ case '=':
599
+ // Zoom in, pivoted around the viewport center.
600
+ zoom = stepUp(zoom);
601
+ break;
602
+ case '-':
603
+ // Zoom out, pivoted around the viewport center.
604
+ zoom = stepDown(zoom);
605
+ break;
606
+ case 'g':
607
+ case 'G':
608
+ showGrid = !showGrid;
609
+ break;
610
+ case 'Escape':
611
+ mapState.activeTile = null;
612
+ // Cancel in-progress collision drawing
613
+ polygonPoints = [];
614
+ collisionDrawing = false;
615
+ break;
616
+ // Number keys switch the active level-editor tool.
617
+ case '1':
618
+ activeTool = 'paint';
619
+ break;
620
+ case '2':
621
+ activeTool = 'entity';
622
+ break;
623
+ case '3':
624
+ activeTool = 'collision-rect';
625
+ break;
626
+ case '4':
627
+ activeTool = 'collision-polygon';
628
+ polygonPoints = [];
629
+ break;
630
+ default:
631
+ // Not a handled key -- let the event propagate normally.
632
+ return;
633
+ }
634
+ e.preventDefault();
635
+ }
636
+
637
+ // --- Pointer handlers ---
638
+
639
+ function onPointerDown(e: PointerEvent): void {
640
+ canvasEl.setPointerCapture(e.pointerId);
641
+
642
+ // Middle-click pans regardless of active tool.
643
+ if (e.button === 1) {
644
+ isPanning = true;
645
+ panStartX = e.clientX;
646
+ panStartY = e.clientY;
647
+ panStartPanX = panX;
648
+ panStartPanY = panY;
649
+ e.preventDefault();
650
+ return;
651
+ }
652
+
653
+ const rect = canvasEl.getBoundingClientRect();
654
+ const localX = e.clientX - rect.left;
655
+ const localY = e.clientY - rect.top;
656
+
657
+ if (activeTool === 'paint') {
658
+ if (e.button === 2) {
659
+ isErasing = true;
660
+ eraseAtEvent(e);
661
+ e.preventDefault();
662
+ return;
663
+ }
664
+ if (e.button === 0) {
665
+ paintAtEvent(e);
666
+ }
667
+ } else if (activeTool === 'entity') {
668
+ const world = screenToWorld(localX, localY);
669
+ if (e.button === 2) {
670
+ // Right-click: remove entity under cursor
671
+ const hit = hitTestEntity(world.x, world.y);
672
+ if (hit) {
673
+ dispatch({
674
+ type: 'remove_entity',
675
+ plugin: 'leveleditor/commands',
676
+ version: '1.0.0',
677
+ params: { layerId: hit.layerId, entityId: hit.entity.id },
678
+ id: crypto.randomUUID(),
679
+ timestamp: Date.now(),
680
+ });
681
+ }
682
+ e.preventDefault();
683
+ return;
684
+ }
685
+ if (e.button === 0) {
686
+ const hit = hitTestEntity(world.x, world.y);
687
+ if (hit) {
688
+ // Start dragging existing entity
689
+ draggingEntity = {
690
+ layerId: hit.layerId,
691
+ entityId: hit.entity.id,
692
+ offsetX: world.x - hit.entity.x,
693
+ offsetY: world.y - hit.entity.y,
694
+ startX: hit.entity.x,
695
+ startY: hit.entity.y,
696
+ };
697
+ } else {
698
+ // Place a new entity using activeTile as sprite source
699
+ if (!mapState.activeTile) {
700
+ notificationState.push({
701
+ id: 'level-editor/no-active-tile',
702
+ message: 'No active tile selected -- select a tile to place as entity.',
703
+ type: 'info',
704
+ });
705
+ return;
706
+ }
707
+ // Find the active entity layer or auto-create one
708
+ let layerId = mapState.layers.find(
709
+ (l) => l.id === mapState.activeLayerId && l.type === 'entity',
710
+ )?.id;
711
+ if (!layerId) {
712
+ layerId = mapState.layers.find((l) => l.type === 'entity')?.id;
713
+ }
714
+ if (!layerId) {
715
+ layerId = mapState.addLayer('Entities', 'entity').id;
716
+ }
717
+ dispatch({
718
+ type: 'place_entity',
719
+ plugin: 'leveleditor/commands',
720
+ version: '1.0.0',
721
+ params: {
722
+ layerId,
723
+ projectRef: mapState.activeTile.projectRef,
724
+ canvasRef: mapState.activeTile.canvasRef,
725
+ x: world.x,
726
+ y: world.y,
727
+ height: 0,
728
+ properties: {},
729
+ },
730
+ id: crypto.randomUUID(),
731
+ timestamp: Date.now(),
732
+ });
733
+ }
734
+ }
735
+ } else if (activeTool === 'collision-rect') {
736
+ if (e.button === 0) {
737
+ const world = screenToWorld(localX, localY);
738
+ collisionDrawing = true;
739
+ collisionStartX = world.x;
740
+ collisionStartY = world.y;
741
+ collisionEndX = world.x;
742
+ collisionEndY = world.y;
743
+ }
744
+ } else {
745
+ // activeTool === 'collision-polygon' -- the only remaining case
746
+ if (e.button === 0) {
747
+ const world = screenToWorld(localX, localY);
748
+ polygonPoints = [...polygonPoints, { x: world.x, y: world.y }];
749
+ }
750
+ }
751
+ }
752
+
753
+ /** Handle double-click to close polygon shapes. */
754
+ function onDblClick(_e: MouseEvent): void {
755
+ if (activeTool !== 'collision-polygon') return;
756
+ // The preceding pointerdown added a spurious vertex at the double-click
757
+ // location -- remove it before checking the minimum vertex count.
758
+ if (polygonPoints.length > 0) {
759
+ polygonPoints = polygonPoints.slice(0, -1);
760
+ }
761
+ if (polygonPoints.length < 3) return;
762
+ // Find the active collision layer or auto-create one
763
+ let layerId = mapState.layers.find(
764
+ (l) => l.id === mapState.activeLayerId && l.type === 'collision',
765
+ )?.id;
766
+ if (!layerId) {
767
+ layerId = mapState.layers.find((l) => l.type === 'collision')?.id;
768
+ }
769
+ if (!layerId) {
770
+ layerId = mapState.addLayer('Collision', 'collision').id;
771
+ }
772
+ dispatch({
773
+ type: 'add_collision_shape',
774
+ plugin: 'leveleditor/commands',
775
+ version: '1.0.0',
776
+ params: {
777
+ layerId,
778
+ type: 'polygon',
779
+ points: polygonPoints.map((p) => ({ x: p.x, y: p.y })),
780
+ },
781
+ id: crypto.randomUUID(),
782
+ timestamp: Date.now(),
783
+ });
784
+ polygonPoints = [];
785
+ }
786
+
787
+ function onPointerMove(e: PointerEvent): void {
788
+ if (isPanning) {
789
+ panX = panStartPanX + (e.clientX - panStartX);
790
+ panY = panStartPanY + (e.clientY - panStartY);
791
+ return;
792
+ }
793
+
794
+ const rect = canvasEl.getBoundingClientRect();
795
+ const localX = e.clientX - rect.left;
796
+ const localY = e.clientY - rect.top;
797
+ const { col, row } = screenToCell(localX, localY);
798
+ hoverCol = col;
799
+ hoverRow = row;
800
+
801
+ if (activeTool === 'paint') {
802
+ // Drag-to-erase while right button is held.
803
+ if (isErasing) {
804
+ eraseAtEvent(e);
805
+ return;
806
+ }
807
+ // Drag-to-paint while left button is held.
808
+ if (e.buttons & 1) {
809
+ paintAtEvent(e);
810
+ }
811
+ } else if (activeTool === 'entity') {
812
+ // Update visual position while dragging (actual move_entity dispatched on release)
813
+ if (draggingEntity && (e.buttons & 1)) {
814
+ const world = screenToWorld(localX, localY);
815
+ const newX = world.x - draggingEntity.offsetX;
816
+ const newY = world.y - draggingEntity.offsetY;
817
+ // Direct mutation for smooth visual feedback; undoable command on release.
818
+ mapState.moveEntity(draggingEntity.layerId, draggingEntity.entityId, newX, newY);
819
+ }
820
+ } else if (activeTool === 'collision-rect') {
821
+ if (collisionDrawing) {
822
+ const world = screenToWorld(localX, localY);
823
+ collisionEndX = world.x;
824
+ collisionEndY = world.y;
825
+ }
826
+ } else {
827
+ // activeTool === 'collision-polygon' -- the only remaining case.
828
+ // Update preview line endpoint.
829
+ const world = screenToWorld(localX, localY);
830
+ polygonPreviewX = world.x;
831
+ polygonPreviewY = world.y;
832
+ }
833
+ }
834
+
835
+ function onPointerUp(e: PointerEvent): void {
836
+ canvasEl.releasePointerCapture(e.pointerId);
837
+
838
+ if (activeTool === 'entity' && draggingEntity) {
839
+ // Dispatch undoable move_entity with final position
840
+ const entity = mapState.getEntity(draggingEntity.layerId, draggingEntity.entityId);
841
+ if (entity) {
842
+ dispatch({
843
+ type: 'move_entity',
844
+ plugin: 'leveleditor/commands',
845
+ version: '1.0.0',
846
+ params: {
847
+ layerId: draggingEntity.layerId,
848
+ entityId: draggingEntity.entityId,
849
+ x: entity.x,
850
+ y: entity.y,
851
+ oldX: draggingEntity.startX,
852
+ oldY: draggingEntity.startY,
853
+ },
854
+ id: crypto.randomUUID(),
855
+ timestamp: Date.now(),
856
+ });
857
+ }
858
+ draggingEntity = null;
859
+ }
860
+
861
+ if (activeTool === 'collision-rect' && collisionDrawing) {
862
+ collisionDrawing = false;
863
+ // Only create a shape if the rect has meaningful size
864
+ const dx = Math.abs(collisionEndX - collisionStartX);
865
+ const dy = Math.abs(collisionEndY - collisionStartY);
866
+ if (dx > 1 || dy > 1) {
867
+ // Normalize so topLeft < bottomRight
868
+ const x1 = Math.min(collisionStartX, collisionEndX);
869
+ const y1 = Math.min(collisionStartY, collisionEndY);
870
+ const x2 = Math.max(collisionStartX, collisionEndX);
871
+ const y2 = Math.max(collisionStartY, collisionEndY);
872
+ // Find the active collision layer or auto-create one
873
+ let layerId = mapState.layers.find(
874
+ (l) => l.id === mapState.activeLayerId && l.type === 'collision',
875
+ )?.id;
876
+ if (!layerId) {
877
+ layerId = mapState.layers.find((l) => l.type === 'collision')?.id;
878
+ }
879
+ if (!layerId) {
880
+ layerId = mapState.addLayer('Collision', 'collision').id;
881
+ }
882
+ dispatch({
883
+ type: 'add_collision_shape',
884
+ plugin: 'leveleditor/commands',
885
+ version: '1.0.0',
886
+ params: {
887
+ layerId,
888
+ type: 'rect',
889
+ points: [{ x: x1, y: y1 }, { x: x2, y: y2 }],
890
+ },
891
+ id: crypto.randomUUID(),
892
+ timestamp: Date.now(),
893
+ });
894
+ }
895
+ }
896
+
897
+ isPanning = false;
898
+ isErasing = false;
899
+ }
900
+
901
+ function onPointerLeave(): void {
902
+ hoverCol = null;
903
+ hoverRow = null;
904
+ }
905
+
906
+ function onWheel(e: WheelEvent): void {
907
+ e.preventDefault();
908
+
909
+ // Zoom pivoted around the cursor: keep the world point under the cursor
910
+ // stable while zoom changes. Computing in centered coordinates (origin
911
+ // at horizontal midpoint) matches how render() positions the grid.
912
+ const rect = canvasEl.getBoundingClientRect();
913
+ const cx = e.clientX - rect.left - rect.width / 2;
914
+ const cy = e.clientY - rect.top;
915
+
916
+ const oldZoom = zoom;
917
+ const newZoom = e.deltaY < 0 ? stepUp(oldZoom) : stepDown(oldZoom);
918
+ if (newZoom === oldZoom) return;
919
+
920
+ // worldX = (cx - panX) / oldZoom; want worldX * newZoom + newPanX = cx
921
+ panX = cx - ((cx - panX) / oldZoom) * newZoom;
922
+ panY = cy - ((cy - panY) / oldZoom) * newZoom;
923
+ zoom = newZoom;
924
+ }
925
+
926
+ // --- Paint action ---
927
+
928
+ /**
929
+ * Paint mapState.activeTile at the cell under the pointer. If no active
930
+ * tile is selected the click is a no-op (with a one-shot notification),
931
+ * matching the design where the active tile is selected via a future
932
+ * tile picker UI.
933
+ */
934
+ function paintAtEvent(e: PointerEvent): void {
935
+ if (!mapState.activeTile) {
936
+ notificationState.push({
937
+ id: 'level-editor/no-active-tile',
938
+ message: 'No active tile selected.',
939
+ type: 'info',
940
+ });
941
+ return;
942
+ }
943
+
944
+ const rect = canvasEl.getBoundingClientRect();
945
+ const { col, row } = screenToCell(e.clientX - rect.left, e.clientY - rect.top);
946
+ if (col < 0 || col >= mapState.gridCols) return;
947
+ if (row < 0 || row >= mapState.gridRows) return;
948
+
949
+ // Use the active tile layer when available, otherwise fall back to the
950
+ // first tile layer. Auto-create one if the map has none at all.
951
+ let layerId = paintTargetLayerId();
952
+ if (!layerId) {
953
+ layerId = mapState.addLayer('Tiles', 'tile').id;
954
+ }
955
+ // Dispatch through the command system so the placement is undoable.
956
+ dispatch({
957
+ type: 'place_tile',
958
+ plugin: 'leveleditor/commands',
959
+ version: '1.0.0',
960
+ params: { layerId, col, row, tile: { ...mapState.activeTile } },
961
+ id: crypto.randomUUID(),
962
+ timestamp: Date.now(),
963
+ });
964
+ }
965
+
966
+ /**
967
+ * Erase the tile at the cell under the pointer. Operates on the first
968
+ * tile layer if one exists; no-op on an empty map.
969
+ */
970
+ function eraseAtEvent(e: PointerEvent): void {
971
+ const layerId = paintTargetLayerId();
972
+ if (!layerId) return;
973
+
974
+ const rect = canvasEl.getBoundingClientRect();
975
+ const { col, row } = screenToCell(e.clientX - rect.left, e.clientY - rect.top);
976
+ if (col < 0 || col >= mapState.gridCols) return;
977
+ if (row < 0 || row >= mapState.gridRows) return;
978
+
979
+ // Dispatch through the command system so the erasure is undoable.
980
+ dispatch({
981
+ type: 'remove_tile',
982
+ plugin: 'leveleditor/commands',
983
+ version: '1.0.0',
984
+ params: { layerId, col, row },
985
+ id: crypto.randomUUID(),
986
+ timestamp: Date.now(),
987
+ });
988
+ }
989
+
990
+ // --- Map property controls ---
991
+
992
+ // --- Layer management ---
993
+
994
+ /** Resolve the layer id to paint/erase on: active layer if it is a tile
995
+ * layer, otherwise fall back to the first tile layer. */
996
+ function paintTargetLayerId(): string | null {
997
+ const active = mapState.layers.find((l) => l.id === mapState.activeLayerId);
998
+ if (active && active.type === 'tile') return active.id;
999
+ return firstTileLayerId();
1000
+ }
1001
+
1002
+ function handleToggleLayerVisibility(e: MouseEvent, layer: MapLayer): void {
1003
+ e.stopPropagation();
1004
+ // Mutate through mapState -- the layer object is from the reactive array.
1005
+ layer.visible = !layer.visible;
1006
+ }
1007
+
1008
+ function handleSelectLayer(layer: MapLayer): void {
1009
+ mapState.activeLayerId = layer.id;
1010
+ }
1011
+
1012
+ function handleAddLayer(type: MapLayer['type']): void {
1013
+ const namePrefix = type === 'tile' ? 'Tiles' : type === 'entity' ? 'Entities' : 'Collision';
1014
+ const count = mapState.layers.filter((l) => l.type === type).length;
1015
+ const name = count === 0 ? namePrefix : `${namePrefix} ${String(count + 1)}`;
1016
+ mapState.addLayer(name, type);
1017
+ addLayerDropdownOpen = false;
1018
+ }
1019
+
1020
+ function handleDeleteActiveLayer(): void {
1021
+ if (mapState.layers.length <= 1) return;
1022
+ mapState.removeLayer(mapState.activeLayerId);
1023
+ }
1024
+
1025
+ /** Dispatch an undoable move_layer command via the level editor plugin. */
1026
+ function dispatchMoveLayer(fromIndex: number, toIndex: number): void {
1027
+ if (fromIndex === toIndex) return;
1028
+ if (fromIndex < 0 || fromIndex >= mapState.layers.length) return;
1029
+ if (toIndex < 0 || toIndex >= mapState.layers.length) return;
1030
+ dispatch({
1031
+ type: 'move_layer',
1032
+ plugin: 'leveleditor/commands',
1033
+ version: '1.0.0',
1034
+ params: { fromIndex, toIndex },
1035
+ id: crypto.randomUUID(),
1036
+ timestamp: Date.now(),
1037
+ });
1038
+ }
1039
+
1040
+ function handleMoveLayerUp(e: MouseEvent, index: number): void {
1041
+ e.stopPropagation();
1042
+ if (index <= 0) return;
1043
+ dispatchMoveLayer(index, index - 1);
1044
+ }
1045
+
1046
+ function handleMoveLayerDown(e: MouseEvent, index: number): void {
1047
+ e.stopPropagation();
1048
+ if (index >= mapState.layers.length - 1) return;
1049
+ dispatchMoveLayer(index, index + 1);
1050
+ }
1051
+
1052
+ // --- Layer drag-to-reorder handlers ---
1053
+
1054
+ function handleLayerDragPointerDown(e: PointerEvent, index: number): void {
1055
+ if (e.button !== 0) return;
1056
+ layerDragFromIndex = index;
1057
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1058
+ }
1059
+
1060
+ function handleLayerDragPointerMove(e: PointerEvent, index: number): void {
1061
+ if (layerDragFromIndex === null) return;
1062
+ if (layerDragFromIndex === index) {
1063
+ // Check if the cursor left the element vertically
1064
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1065
+ if (e.clientY < rect.top || e.clientY > rect.bottom) {
1066
+ layerDragOverIndex = index;
1067
+ }
1068
+ return;
1069
+ }
1070
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1071
+ const midY = rect.top + rect.height / 2;
1072
+ layerDragOverIndex = index;
1073
+ layerDragInsertSide = e.clientY < midY ? 'above' : 'below';
1074
+ }
1075
+
1076
+ function handleLayerDragPointerUp(): void {
1077
+ if (layerDragFromIndex !== null && layerDragOverIndex !== null && layerDragInsertSide !== null) {
1078
+ let toIndex = layerDragOverIndex;
1079
+ if (layerDragInsertSide === 'below') {
1080
+ toIndex = layerDragOverIndex + 1;
1081
+ }
1082
+ // Adjust for removal shift
1083
+ if (layerDragFromIndex < toIndex) {
1084
+ toIndex -= 1;
1085
+ }
1086
+ if (layerDragFromIndex !== toIndex && toIndex >= 0 && toIndex < mapState.layers.length) {
1087
+ dispatchMoveLayer(layerDragFromIndex, toIndex);
1088
+ }
1089
+ }
1090
+ layerDragFromIndex = null;
1091
+ layerDragOverIndex = null;
1092
+ layerDragInsertSide = null;
1093
+ }
1094
+
1095
+ /** Badge label for layer types. */
1096
+ function layerTypeBadge(type: MapLayer['type']): string {
1097
+ switch (type) {
1098
+ case 'tile': return 'T';
1099
+ case 'entity': return 'E';
1100
+ case 'collision': return 'C';
1101
+ }
1102
+ }
1103
+
1104
+ // --- Tiled export ---
1105
+
1106
+ async function handleExportTiled(): Promise<void> {
1107
+ // Build the tileset spritesheet from all unique tiles in the map.
1108
+ // When present, the .tmj references "tileset.png" so both files must
1109
+ // be saved to the same directory for the Tiled editor to load them.
1110
+ const spritesheetResult = buildTilesetSpritesheet(mapState);
1111
+ const tiled = exportTiledMap(mapState, spritesheetResult);
1112
+ const json = JSON.stringify(tiled, null, 2);
1113
+
1114
+ const files: DownloadFileOptions[] = [
1115
+ {
1116
+ blob: new Blob([json], { type: 'application/json' }),
1117
+ filename: 'level.tmj',
1118
+ filterName: 'Tiled Map',
1119
+ filterExtensions: ['tmj'],
1120
+ },
1121
+ ];
1122
+
1123
+ // Include the tileset spritesheet PNG as a companion file (if tiles exist)
1124
+ if (spritesheetResult) {
1125
+ const pngBlob = await pixelBufferToPngBlob(spritesheetResult.spritesheet);
1126
+ files.push({
1127
+ blob: pngBlob,
1128
+ filename: 'tileset.png',
1129
+ filterName: 'PNG Image',
1130
+ filterExtensions: ['png'],
1131
+ });
1132
+ }
1133
+
1134
+ await downloadCompanionFiles(files);
1135
+ }
1136
+
1137
+ // --- Mount ---
1138
+
1139
+ onMount(() => {
1140
+ // Center the grid horizontally on first layout. The grid is already
1141
+ // centered by rendering (originX = width/2), so we only need to nudge
1142
+ // it down to a comfortable offset from the top.
1143
+ const rect = canvasEl.getBoundingClientRect();
1144
+ if (rect.height > 0) {
1145
+ panY = Math.round(rect.height * 0.15);
1146
+ }
1147
+
1148
+ // Focus the canvas so keyboard shortcuts work immediately.
1149
+ canvasEl.focus();
1150
+
1151
+ animFrameId = requestAnimationFrame(renderLoop);
1152
+
1153
+ return () => {
1154
+ cancelAnimationFrame(animFrameId);
1155
+ };
1156
+ });
1157
+ </script>
1158
+
1159
+ <div class="level-editor">
1160
+ <div class="level-editor__header">
1161
+ <span class="level-editor__title">Level Editor</span>
1162
+ <div class="level-editor__tools">
1163
+ <button
1164
+ class="tool-btn"
1165
+ class:tool-btn--active={activeTool === 'paint'}
1166
+ title="Paint Tiles"
1167
+ onclick={() => { activeTool = 'paint'; }}
1168
+ ><PaintbrushIcon /></button>
1169
+ <button
1170
+ class="tool-btn"
1171
+ class:tool-btn--active={activeTool === 'entity'}
1172
+ title="Place Entities"
1173
+ onclick={() => { activeTool = 'entity'; }}
1174
+ ><MousePointerClickIcon /></button>
1175
+ <button
1176
+ class="tool-btn"
1177
+ class:tool-btn--active={activeTool === 'collision-rect'}
1178
+ title="Draw Collision Rect"
1179
+ onclick={() => { activeTool = 'collision-rect'; }}
1180
+ ><BoxSelectIcon /></button>
1181
+ <button
1182
+ class="tool-btn"
1183
+ class:tool-btn--active={activeTool === 'collision-polygon'}
1184
+ title="Draw Collision Polygon"
1185
+ onclick={() => { activeTool = 'collision-polygon'; polygonPoints = []; }}
1186
+ ><PentagonIcon /></button>
1187
+ </div>
1188
+ <div class="level-editor__actions">
1189
+ <button
1190
+ class="action-btn"
1191
+ title="Export to Tiled (.tmj)"
1192
+ aria-label="Export to Tiled"
1193
+ onclick={handleExportTiled}
1194
+ ><DownloadIcon /></button>
1195
+ </div>
1196
+ </div>
1197
+
1198
+ <div class="level-editor__viewport">
1199
+ <canvas
1200
+ bind:this={canvasEl}
1201
+ class="level-editor__canvas"
1202
+ tabindex="0"
1203
+ role="application"
1204
+ aria-label={`Level editor, ${String(mapState.gridCols)} by ${String(mapState.gridRows)} tile grid`}
1205
+ onpointerdown={onPointerDown}
1206
+ onpointermove={onPointerMove}
1207
+ onpointerup={onPointerUp}
1208
+ onpointerleave={onPointerLeave}
1209
+ onwheel={onWheel}
1210
+ onkeydown={onKeyDown}
1211
+ ondblclick={onDblClick}
1212
+ oncontextmenu={(e) => { e.preventDefault(); }}
1213
+ ></canvas>
1214
+
1215
+ <!-- Layer management panel -- overlaid in the bottom-left of the viewport -->
1216
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1217
+ <div
1218
+ class="layer-overlay"
1219
+ onpointerdown={(e) => { e.stopPropagation(); }}
1220
+ onpointermove={(e) => { e.stopPropagation(); }}
1221
+ onpointerup={(e) => { e.stopPropagation(); }}
1222
+ >
1223
+ <button
1224
+ class="layer-overlay__toggle"
1225
+ title={layerPanelOpen ? 'Collapse layers' : 'Expand layers'}
1226
+ aria-label={layerPanelOpen ? 'Collapse layers' : 'Expand layers'}
1227
+ onclick={() => { layerPanelOpen = !layerPanelOpen; }}
1228
+ >
1229
+ <LayersIcon />
1230
+ <span class="layer-overlay__toggle-label">Layers ({mapState.layers.length})</span>
1231
+ <span class="layer-overlay__chevron" class:layer-overlay__chevron--collapsed={!layerPanelOpen}>
1232
+ <ChevronDownIcon />
1233
+ </span>
1234
+ </button>
1235
+
1236
+ {#if layerPanelOpen}
1237
+ <div class="layer-overlay__list" role="listbox" aria-label="Map layers">
1238
+ {#each mapState.layers as layer, i (layer.id)}
1239
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1240
+ <div
1241
+ class="layer-overlay__row"
1242
+ class:layer-overlay__row--active={layer.id === mapState.activeLayerId}
1243
+ class:layer-overlay__row--dragging={layerDragFromIndex === i}
1244
+ class:layer-overlay__row--drag-above={layerDragOverIndex === i && layerDragInsertSide === 'above'}
1245
+ class:layer-overlay__row--drag-below={layerDragOverIndex === i && layerDragInsertSide === 'below'}
1246
+ role="option"
1247
+ aria-selected={layer.id === mapState.activeLayerId}
1248
+ tabindex="0"
1249
+ onclick={() => { handleSelectLayer(layer); }}
1250
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSelectLayer(layer); } }}
1251
+ onpointerdown={(e) => { handleLayerDragPointerDown(e, i); }}
1252
+ onpointermove={(e) => { handleLayerDragPointerMove(e, i); }}
1253
+ onpointerup={handleLayerDragPointerUp}
1254
+ >
1255
+ <!-- Visibility toggle (eye icon, matches sprite layer panel) -->
1256
+ <button
1257
+ class="layer-overlay__vis-btn"
1258
+ title={layer.visible ? 'Hide' : 'Show'}
1259
+ aria-label={layer.visible ? `Hide ${layer.name}` : `Show ${layer.name}`}
1260
+ aria-pressed={layer.visible}
1261
+ onclick={(e) => { handleToggleLayerVisibility(e, layer); }}
1262
+ >
1263
+ {#if layer.visible}
1264
+ <svg class="layer-overlay__icon" viewBox="0 0 16 16" width="14" height="14">
1265
+ <path d="M8 3C4 3 1 8 1 8s3 5 7 5 7-5 7-5-3-5-7-5z" fill="none" stroke="currentColor" stroke-width="1.5"/>
1266
+ <circle cx="8" cy="8" r="2" fill="currentColor"/>
1267
+ </svg>
1268
+ {:else}
1269
+ <svg class="layer-overlay__icon" viewBox="0 0 16 16" width="14" height="14">
1270
+ <path d="M8 3C4 3 1 8 1 8s3 5 7 5 7-5 7-5-3-5-7-5z" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.3"/>
1271
+ <line x1="2" y1="2" x2="14" y2="14" stroke="currentColor" stroke-width="1.5"/>
1272
+ </svg>
1273
+ {/if}
1274
+ </button>
1275
+
1276
+ <span class="layer-overlay__name">{layer.name}</span>
1277
+
1278
+ <!-- Move up/down buttons -->
1279
+ <button
1280
+ class="layer-overlay__move-btn"
1281
+ title="Move up"
1282
+ aria-label={`Move ${layer.name} up`}
1283
+ disabled={i === 0}
1284
+ onclick={(e) => { handleMoveLayerUp(e, i); }}
1285
+ ><ChevronUpIcon /></button>
1286
+ <button
1287
+ class="layer-overlay__move-btn"
1288
+ title="Move down"
1289
+ aria-label={`Move ${layer.name} down`}
1290
+ disabled={i === mapState.layers.length - 1}
1291
+ onclick={(e) => { handleMoveLayerDown(e, i); }}
1292
+ ><ChevronDownIcon /></button>
1293
+
1294
+ <!-- Type badge -->
1295
+ <span
1296
+ class="layer-overlay__badge"
1297
+ class:layer-overlay__badge--tile={layer.type === 'tile'}
1298
+ class:layer-overlay__badge--entity={layer.type === 'entity'}
1299
+ class:layer-overlay__badge--collision={layer.type === 'collision'}
1300
+ title={layer.type}
1301
+ >{layerTypeBadge(layer.type)}</span>
1302
+ </div>
1303
+ {/each}
1304
+ </div>
1305
+
1306
+ <!-- Controls: add + delete -->
1307
+ <div class="layer-overlay__controls">
1308
+ <div class="layer-overlay__add-wrap">
1309
+ <button
1310
+ class="layer-overlay__ctrl-btn"
1311
+ title="Add layer"
1312
+ aria-label="Add layer"
1313
+ onclick={() => { addLayerDropdownOpen = !addLayerDropdownOpen; }}
1314
+ ><PlusIcon /></button>
1315
+ {#if addLayerDropdownOpen}
1316
+ <div class="layer-overlay__add-menu">
1317
+ <button class="layer-overlay__add-option" onclick={() => { handleAddLayer('tile'); }}>Tile layer</button>
1318
+ <button class="layer-overlay__add-option" onclick={() => { handleAddLayer('entity'); }}>Entity layer</button>
1319
+ <button class="layer-overlay__add-option" onclick={() => { handleAddLayer('collision'); }}>Collision layer</button>
1320
+ </div>
1321
+ {/if}
1322
+ </div>
1323
+ <button
1324
+ class="layer-overlay__ctrl-btn"
1325
+ title="Delete active layer"
1326
+ aria-label="Delete active layer"
1327
+ disabled={mapState.layers.length <= 1}
1328
+ onclick={handleDeleteActiveLayer}
1329
+ ><TrashIcon /></button>
1330
+ </div>
1331
+ {/if}
1332
+ </div>
1333
+
1334
+ {#if placedTileCount === 0}
1335
+ <div class="level-editor__empty">
1336
+ <p class="level-editor__empty-title">Empty map</p>
1337
+ <p class="level-editor__empty-hint">
1338
+ Click a cell to paint a tile, right-click to erase. Middle-click drag to pan, scroll to zoom.
1339
+ </p>
1340
+ </div>
1341
+ {/if}
1342
+ </div>
1343
+ </div>
1344
+
1345
+ <style>
1346
+ .level-editor {
1347
+ display: flex;
1348
+ flex-direction: column;
1349
+ width: 100%;
1350
+ height: 100%;
1351
+ background: var(--bg-panel);
1352
+ color: var(--text-primary);
1353
+ font-size: var(--text-base);
1354
+ user-select: none;
1355
+ }
1356
+
1357
+ .level-editor__header {
1358
+ display: flex;
1359
+ align-items: center;
1360
+ justify-content: space-between;
1361
+ padding: 6px var(--space-3);
1362
+ border-bottom: 1px solid var(--border);
1363
+ background: var(--bg-toolbar);
1364
+ flex-shrink: 0;
1365
+ }
1366
+
1367
+ .level-editor__title {
1368
+ font-weight: 600;
1369
+ font-size: var(--text-sm);
1370
+ text-transform: uppercase;
1371
+ letter-spacing: 0.5px;
1372
+ color: var(--text-secondary);
1373
+ }
1374
+
1375
+ .level-editor__actions {
1376
+ display: flex;
1377
+ gap: var(--space-1);
1378
+ }
1379
+
1380
+ .action-btn {
1381
+ background: none;
1382
+ border: 1px solid transparent;
1383
+ border-radius: var(--radius-sm);
1384
+ color: var(--text-secondary);
1385
+ cursor: pointer;
1386
+ width: 24px;
1387
+ height: 24px;
1388
+ display: flex;
1389
+ align-items: center;
1390
+ justify-content: center;
1391
+ font-size: var(--text-xl);
1392
+ padding: 0;
1393
+ line-height: 1;
1394
+ }
1395
+
1396
+ .action-btn:hover:not(:disabled) {
1397
+ background: var(--bg-primary);
1398
+ color: var(--text-primary);
1399
+ border-color: var(--border);
1400
+ }
1401
+
1402
+ .action-btn:disabled {
1403
+ opacity: 0.3;
1404
+ cursor: default;
1405
+ }
1406
+
1407
+ .action-btn :global(svg) {
1408
+ width: 14px;
1409
+ height: 14px;
1410
+ }
1411
+
1412
+ /* Tool selector buttons in the header */
1413
+ .level-editor__tools {
1414
+ display: flex;
1415
+ gap: 2px;
1416
+ margin-left: 8px;
1417
+ }
1418
+
1419
+ .tool-btn {
1420
+ background: transparent;
1421
+ border: 1px solid transparent;
1422
+ color: var(--text-secondary);
1423
+ padding: 4px 6px;
1424
+ border-radius: 4px;
1425
+ cursor: pointer;
1426
+ display: flex;
1427
+ align-items: center;
1428
+ }
1429
+
1430
+ .tool-btn:hover {
1431
+ background: var(--bg-hover);
1432
+ color: var(--text-primary);
1433
+ }
1434
+
1435
+ .tool-btn--active {
1436
+ background: var(--bg-active);
1437
+ color: var(--accent);
1438
+ border-color: var(--accent);
1439
+ }
1440
+
1441
+ .tool-btn :global(svg) {
1442
+ width: 14px;
1443
+ height: 14px;
1444
+ }
1445
+
1446
+ /* Viewport is the canvas host. Position relative so the empty-state
1447
+ overlay can be absolutely positioned on top of the canvas. */
1448
+ .level-editor__viewport {
1449
+ position: relative;
1450
+ flex: 1;
1451
+ min-height: 0;
1452
+ background: var(--bg-canvas);
1453
+ }
1454
+
1455
+ .level-editor__canvas {
1456
+ display: block;
1457
+ width: 100%;
1458
+ height: 100%;
1459
+ user-select: none;
1460
+ touch-action: none;
1461
+ outline: none;
1462
+ cursor: crosshair;
1463
+ }
1464
+
1465
+ /* Empty state sits on top of the canvas, non-interactive so clicks still
1466
+ paint into the grid behind it. */
1467
+ .level-editor__empty {
1468
+ position: absolute;
1469
+ inset: 0;
1470
+ display: flex;
1471
+ flex-direction: column;
1472
+ align-items: center;
1473
+ justify-content: center;
1474
+ gap: var(--space-2);
1475
+ pointer-events: none;
1476
+ text-align: center;
1477
+ padding: var(--space-5);
1478
+ }
1479
+
1480
+ .level-editor__empty-title {
1481
+ margin: 0;
1482
+ font-size: var(--text-lg);
1483
+ font-weight: 600;
1484
+ color: var(--text-secondary);
1485
+ }
1486
+
1487
+ .level-editor__empty-hint {
1488
+ margin: 0;
1489
+ max-width: 320px;
1490
+ font-size: var(--text-sm);
1491
+ color: var(--text-muted);
1492
+ line-height: 1.4;
1493
+ }
1494
+
1495
+ /* --- Layer overlay panel (bottom-left corner of the viewport) --- */
1496
+
1497
+ .layer-overlay {
1498
+ position: absolute;
1499
+ bottom: var(--space-3, 12px);
1500
+ left: var(--space-3, 12px);
1501
+ width: 180px;
1502
+ background: var(--bg-panel);
1503
+ border: 1px solid var(--border);
1504
+ border-radius: var(--radius-sm);
1505
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
1506
+ z-index: 10;
1507
+ display: flex;
1508
+ flex-direction: column;
1509
+ overflow: hidden;
1510
+ font-size: var(--text-sm);
1511
+ }
1512
+
1513
+ .layer-overlay__toggle {
1514
+ display: flex;
1515
+ align-items: center;
1516
+ gap: var(--space-1, 4px);
1517
+ width: 100%;
1518
+ padding: 5px 8px;
1519
+ background: var(--bg-toolbar);
1520
+ border: none;
1521
+ border-bottom: 1px solid var(--border);
1522
+ color: var(--text-secondary);
1523
+ cursor: pointer;
1524
+ font-size: var(--text-sm);
1525
+ font-weight: 600;
1526
+ text-transform: uppercase;
1527
+ letter-spacing: 0.5px;
1528
+ }
1529
+
1530
+ .layer-overlay__toggle:hover {
1531
+ color: var(--text-primary);
1532
+ }
1533
+
1534
+ .layer-overlay__toggle :global(svg) {
1535
+ width: 14px;
1536
+ height: 14px;
1537
+ flex-shrink: 0;
1538
+ }
1539
+
1540
+ .layer-overlay__toggle-label {
1541
+ flex: 1;
1542
+ text-align: left;
1543
+ }
1544
+
1545
+ .layer-overlay__chevron {
1546
+ display: flex;
1547
+ align-items: center;
1548
+ transition: transform 150ms ease;
1549
+ }
1550
+
1551
+ .layer-overlay__chevron--collapsed {
1552
+ transform: rotate(-90deg);
1553
+ }
1554
+
1555
+ .layer-overlay__chevron :global(svg) {
1556
+ width: 12px;
1557
+ height: 12px;
1558
+ }
1559
+
1560
+ /* Scrollable layer list */
1561
+ .layer-overlay__list {
1562
+ max-height: 160px;
1563
+ overflow-y: auto;
1564
+ overflow-x: hidden;
1565
+ }
1566
+
1567
+ .layer-overlay__row {
1568
+ position: relative;
1569
+ display: flex;
1570
+ align-items: center;
1571
+ gap: var(--space-1, 4px);
1572
+ padding: 3px 6px;
1573
+ cursor: pointer;
1574
+ border-bottom: 1px solid transparent;
1575
+ transition: background 100ms ease;
1576
+ }
1577
+
1578
+ .layer-overlay__row:hover {
1579
+ background: rgba(255, 255, 255, 0.04);
1580
+ }
1581
+
1582
+ :global([data-theme="light"]) .layer-overlay__row:hover {
1583
+ background: rgba(0, 0, 0, 0.04);
1584
+ }
1585
+
1586
+ .layer-overlay__row--active {
1587
+ background: var(--accent);
1588
+ color: #ffffff;
1589
+ }
1590
+
1591
+ .layer-overlay__row--active:hover {
1592
+ background: var(--accent-hover);
1593
+ }
1594
+
1595
+ .layer-overlay__vis-btn {
1596
+ background: none;
1597
+ border: none;
1598
+ color: inherit;
1599
+ cursor: pointer;
1600
+ width: 18px;
1601
+ height: 18px;
1602
+ display: flex;
1603
+ align-items: center;
1604
+ justify-content: center;
1605
+ padding: 0;
1606
+ flex-shrink: 0;
1607
+ }
1608
+
1609
+ .layer-overlay__vis-btn:hover {
1610
+ color: var(--accent);
1611
+ }
1612
+
1613
+ .layer-overlay__row--active .layer-overlay__vis-btn:hover {
1614
+ color: #ffffff;
1615
+ }
1616
+
1617
+ .layer-overlay__icon {
1618
+ display: block;
1619
+ }
1620
+
1621
+ .layer-overlay__name {
1622
+ flex: 1;
1623
+ min-width: 0;
1624
+ overflow: hidden;
1625
+ text-overflow: ellipsis;
1626
+ white-space: nowrap;
1627
+ font-size: var(--text-xs);
1628
+ }
1629
+
1630
+ .layer-overlay__badge {
1631
+ flex-shrink: 0;
1632
+ font-size: 9px;
1633
+ font-weight: 700;
1634
+ line-height: 1;
1635
+ padding: 1px 4px;
1636
+ border-radius: 3px;
1637
+ text-transform: uppercase;
1638
+ letter-spacing: 0.3px;
1639
+ }
1640
+
1641
+ .layer-overlay__badge--tile {
1642
+ background: rgba(74, 158, 255, 0.2);
1643
+ color: #6ab4ff;
1644
+ }
1645
+
1646
+ .layer-overlay__badge--entity {
1647
+ background: rgba(255, 170, 50, 0.2);
1648
+ color: #ffbe6a;
1649
+ }
1650
+
1651
+ .layer-overlay__badge--collision {
1652
+ background: rgba(255, 80, 80, 0.2);
1653
+ color: #ff8080;
1654
+ }
1655
+
1656
+ .layer-overlay__row--active .layer-overlay__badge {
1657
+ background: rgba(255, 255, 255, 0.2);
1658
+ color: #ffffff;
1659
+ }
1660
+
1661
+ /* Move up/down buttons */
1662
+ .layer-overlay__move-btn {
1663
+ background: none;
1664
+ border: none;
1665
+ color: inherit;
1666
+ cursor: pointer;
1667
+ width: 16px;
1668
+ height: 16px;
1669
+ display: flex;
1670
+ align-items: center;
1671
+ justify-content: center;
1672
+ padding: 0;
1673
+ flex-shrink: 0;
1674
+ opacity: 0;
1675
+ transition: opacity 100ms ease;
1676
+ }
1677
+
1678
+ .layer-overlay__row:hover .layer-overlay__move-btn {
1679
+ opacity: 0.6;
1680
+ }
1681
+
1682
+ .layer-overlay__move-btn:hover:not(:disabled) {
1683
+ opacity: 1 !important;
1684
+ color: var(--accent);
1685
+ }
1686
+
1687
+ .layer-overlay__row--active .layer-overlay__move-btn:hover:not(:disabled) {
1688
+ color: #ffffff;
1689
+ }
1690
+
1691
+ .layer-overlay__move-btn:disabled {
1692
+ opacity: 0 !important;
1693
+ cursor: default;
1694
+ }
1695
+
1696
+ .layer-overlay__row:hover .layer-overlay__move-btn:disabled {
1697
+ opacity: 0.15 !important;
1698
+ }
1699
+
1700
+ .layer-overlay__move-btn :global(svg) {
1701
+ width: 10px;
1702
+ height: 10px;
1703
+ }
1704
+
1705
+ /* Drag-to-reorder: dim the source row */
1706
+ .layer-overlay__row--dragging {
1707
+ opacity: 0.35;
1708
+ }
1709
+
1710
+ /* Insertion indicator: horizontal accent line shown above or below a row
1711
+ during drag-to-reorder. Uses pseudo-elements positioned in the gap. */
1712
+ .layer-overlay__row--drag-above::before,
1713
+ .layer-overlay__row--drag-below::after {
1714
+ content: '';
1715
+ position: absolute;
1716
+ left: 0;
1717
+ right: 0;
1718
+ height: 2px;
1719
+ background: var(--accent);
1720
+ border-radius: 1px;
1721
+ pointer-events: none;
1722
+ z-index: 1;
1723
+ }
1724
+
1725
+ .layer-overlay__row--drag-above::before {
1726
+ top: -1px;
1727
+ }
1728
+
1729
+ .layer-overlay__row--drag-below::after {
1730
+ bottom: -1px;
1731
+ }
1732
+
1733
+ /* Add/delete controls bar */
1734
+ .layer-overlay__controls {
1735
+ display: flex;
1736
+ align-items: center;
1737
+ gap: var(--space-1, 4px);
1738
+ padding: 4px 6px;
1739
+ border-top: 1px solid var(--border);
1740
+ background: var(--bg-toolbar);
1741
+ }
1742
+
1743
+ .layer-overlay__ctrl-btn {
1744
+ background: none;
1745
+ border: 1px solid transparent;
1746
+ border-radius: var(--radius-sm);
1747
+ color: var(--text-secondary);
1748
+ cursor: pointer;
1749
+ width: 22px;
1750
+ height: 22px;
1751
+ display: flex;
1752
+ align-items: center;
1753
+ justify-content: center;
1754
+ padding: 0;
1755
+ }
1756
+
1757
+ .layer-overlay__ctrl-btn:hover:not(:disabled) {
1758
+ background: var(--bg-primary);
1759
+ color: var(--text-primary);
1760
+ border-color: var(--border);
1761
+ }
1762
+
1763
+ .layer-overlay__ctrl-btn:disabled {
1764
+ opacity: 0.3;
1765
+ cursor: default;
1766
+ }
1767
+
1768
+ .layer-overlay__ctrl-btn :global(svg) {
1769
+ width: 12px;
1770
+ height: 12px;
1771
+ }
1772
+
1773
+ /* "Add layer" dropdown positioned above the button */
1774
+ .layer-overlay__add-wrap {
1775
+ position: relative;
1776
+ }
1777
+
1778
+ .layer-overlay__add-menu {
1779
+ position: absolute;
1780
+ bottom: 100%;
1781
+ left: 0;
1782
+ margin-bottom: 4px;
1783
+ background: var(--bg-panel);
1784
+ border: 1px solid var(--border);
1785
+ border-radius: var(--radius-sm);
1786
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
1787
+ overflow: hidden;
1788
+ white-space: nowrap;
1789
+ z-index: 20;
1790
+ }
1791
+
1792
+ .layer-overlay__add-option {
1793
+ display: block;
1794
+ width: 100%;
1795
+ padding: 4px 12px;
1796
+ background: none;
1797
+ border: none;
1798
+ color: var(--text-primary);
1799
+ font-size: var(--text-xs);
1800
+ text-align: left;
1801
+ cursor: pointer;
1802
+ }
1803
+
1804
+ .layer-overlay__add-option:hover {
1805
+ background: var(--accent);
1806
+ color: #ffffff;
1807
+ }
1808
+ </style>