@1agh/maude 0.42.0 → 0.44.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 (332) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/acp/bridge.ts +44 -10
  4. package/apps/studio/acp/env.ts +23 -0
  5. package/apps/studio/acp/login-state.ts +263 -0
  6. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  7. package/apps/studio/acp/probe.ts +152 -1
  8. package/apps/studio/annotations-layer.tsx +1451 -324
  9. package/apps/studio/annotations-model.ts +58 -0
  10. package/apps/studio/api.ts +568 -24
  11. package/apps/studio/bin/_audio-search.mjs +140 -0
  12. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  13. package/apps/studio/bin/_import-asset.mjs +815 -0
  14. package/apps/studio/bin/_import-brand.mjs +635 -0
  15. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  16. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  17. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  18. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  19. package/apps/studio/bin/_transcribe.mjs +419 -0
  20. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  21. package/apps/studio/bin/_video-playwright.mjs +141 -71
  22. package/apps/studio/bin/audio-search.sh +28 -0
  23. package/apps/studio/bin/generate.sh +154 -0
  24. package/apps/studio/bin/import-asset.sh +34 -0
  25. package/apps/studio/bin/import-brand.sh +29 -0
  26. package/apps/studio/bin/import-tokens.sh +33 -0
  27. package/apps/studio/bin/ingest-footage.sh +27 -0
  28. package/apps/studio/bin/photo-adjust.sh +163 -0
  29. package/apps/studio/bin/photo-bg-remove.sh +258 -0
  30. package/apps/studio/bin/probe-footage.sh +28 -0
  31. package/apps/studio/bin/read-annotations.mjs +125 -2
  32. package/apps/studio/bin/transcribe.sh +39 -0
  33. package/apps/studio/canvas-edit.ts +656 -12
  34. package/apps/studio/canvas-lib.tsx +637 -10
  35. package/apps/studio/canvas-pipeline.ts +65 -7
  36. package/apps/studio/canvas-shell.tsx +156 -16
  37. package/apps/studio/client/app.jsx +2385 -699
  38. package/apps/studio/client/export-center.jsx +35 -1
  39. package/apps/studio/client/generate-dialog.jsx +352 -0
  40. package/apps/studio/client/github.js +18 -0
  41. package/apps/studio/client/inspector-controls.jsx +781 -0
  42. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  43. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  44. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  45. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  46. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  47. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  48. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  49. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  50. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  51. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  52. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  53. package/apps/studio/client/photo-knobs.jsx +432 -0
  54. package/apps/studio/client/styles/3-shell-maude.css +376 -28
  55. package/apps/studio/client/styles/4-components.css +223 -0
  56. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  57. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  58. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  59. package/apps/studio/commands/edit-source-command.ts +31 -4
  60. package/apps/studio/config.schema.json +70 -0
  61. package/apps/studio/context-menu.tsx +42 -8
  62. package/apps/studio/context.ts +16 -0
  63. package/apps/studio/design-setup-readiness.ts +115 -0
  64. package/apps/studio/dist/client.bundle.js +5572 -21
  65. package/apps/studio/dist/comment-mount.js +2 -2
  66. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  67. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  68. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  69. package/apps/studio/dist/styles.css +1 -1
  70. package/apps/studio/dom-selection.ts +25 -0
  71. package/apps/studio/draw/palette.ts +34 -0
  72. package/apps/studio/exporters/jobs.ts +47 -5
  73. package/apps/studio/exporters/video.ts +24 -4
  74. package/apps/studio/footage/schema.test.ts +287 -0
  75. package/apps/studio/footage/schema.ts +662 -0
  76. package/apps/studio/footage-store.ts +235 -0
  77. package/apps/studio/fs-watch.ts +10 -0
  78. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  79. package/apps/studio/generation/adapters/gemini.ts +542 -0
  80. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  81. package/apps/studio/generation/adapters/groq.ts +247 -0
  82. package/apps/studio/generation/audio-library.test.ts +95 -0
  83. package/apps/studio/generation/audio-library.ts +156 -0
  84. package/apps/studio/generation/captions.test.ts +78 -0
  85. package/apps/studio/generation/captions.ts +132 -0
  86. package/apps/studio/generation/download.ts +76 -0
  87. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  88. package/apps/studio/generation/gemini.test.ts +350 -0
  89. package/apps/studio/generation/jobs.test.ts +128 -0
  90. package/apps/studio/generation/jobs.ts +243 -0
  91. package/apps/studio/generation/keys.test.ts +75 -0
  92. package/apps/studio/generation/keys.ts +162 -0
  93. package/apps/studio/generation/prefs.test.ts +95 -0
  94. package/apps/studio/generation/prefs.ts +82 -0
  95. package/apps/studio/generation/registry.ts +88 -0
  96. package/apps/studio/generation/types.test.ts +123 -0
  97. package/apps/studio/generation/types.ts +266 -0
  98. package/apps/studio/generation/whisper-models.test.ts +152 -0
  99. package/apps/studio/generation/whisper-models.ts +257 -0
  100. package/apps/studio/generation/whisper-spike-results.md +61 -0
  101. package/apps/studio/git/endpoints.ts +68 -7
  102. package/apps/studio/git/service.ts +199 -21
  103. package/apps/studio/github/service.ts +67 -0
  104. package/apps/studio/handoff.ts +3 -2
  105. package/apps/studio/http.ts +1039 -22
  106. package/apps/studio/input-router.tsx +10 -0
  107. package/apps/studio/inspect.ts +53 -1
  108. package/apps/studio/media/intro.mp4 +0 -0
  109. package/apps/studio/media-commit-chain.ts +119 -0
  110. package/apps/studio/paths.ts +20 -0
  111. package/apps/studio/photo/filters.ts +285 -0
  112. package/apps/studio/photo/pipeline.ts +607 -0
  113. package/apps/studio/photo/schema.ts +367 -0
  114. package/apps/studio/photo-store.ts +154 -0
  115. package/apps/studio/readiness.ts +108 -20
  116. package/apps/studio/runtime-bundle.ts +29 -6
  117. package/apps/studio/scaffold-design.ts +1145 -0
  118. package/apps/studio/server.ts +18 -2
  119. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  120. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  121. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  122. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  123. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  124. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  125. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  126. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  127. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  128. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  129. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  130. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  131. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  132. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  133. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  134. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  135. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  136. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  137. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  138. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  139. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  140. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  141. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  142. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  143. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  144. package/apps/studio/stickers/life-style/best.png +0 -0
  145. package/apps/studio/stickers/life-style/come-on.png +0 -0
  146. package/apps/studio/stickers/life-style/cut.png +0 -0
  147. package/apps/studio/stickers/life-style/detox.png +0 -0
  148. package/apps/studio/stickers/life-style/just.png +0 -0
  149. package/apps/studio/stickers/life-style/manifest.json +68 -0
  150. package/apps/studio/stickers/life-style/mirror.png +0 -0
  151. package/apps/studio/stickers/life-style/nice.png +0 -0
  152. package/apps/studio/stickers/life-style/objects.png +0 -0
  153. package/apps/studio/stickers/life-style/ok.png +0 -0
  154. package/apps/studio/stickers/life-style/queen.png +0 -0
  155. package/apps/studio/stickers/life-style/star.png +0 -0
  156. package/apps/studio/stickers/life-style/sun.png +0 -0
  157. package/apps/studio/stickers/life-style/water.png +0 -0
  158. package/apps/studio/stickers/life-style/yeah.png +0 -0
  159. package/apps/studio/stickers/life-style/you-can.png +0 -0
  160. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  161. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  162. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  163. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  164. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  165. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  166. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  167. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  168. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  169. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  170. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  171. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  172. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  173. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  174. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  175. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  176. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  177. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  178. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  179. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  180. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  181. package/apps/studio/stickers/project-status/group-100.png +0 -0
  182. package/apps/studio/stickers/project-status/group-101.png +0 -0
  183. package/apps/studio/stickers/project-status/group-102.png +0 -0
  184. package/apps/studio/stickers/project-status/group-103.png +0 -0
  185. package/apps/studio/stickers/project-status/group-104.png +0 -0
  186. package/apps/studio/stickers/project-status/group-105.png +0 -0
  187. package/apps/studio/stickers/project-status/group-106.png +0 -0
  188. package/apps/studio/stickers/project-status/group-107.png +0 -0
  189. package/apps/studio/stickers/project-status/group-108.png +0 -0
  190. package/apps/studio/stickers/project-status/group-109.png +0 -0
  191. package/apps/studio/stickers/project-status/group-110.png +0 -0
  192. package/apps/studio/stickers/project-status/group-111.png +0 -0
  193. package/apps/studio/stickers/project-status/group-112.png +0 -0
  194. package/apps/studio/stickers/project-status/group-113.png +0 -0
  195. package/apps/studio/stickers/project-status/group-114.png +0 -0
  196. package/apps/studio/stickers/project-status/group-115.png +0 -0
  197. package/apps/studio/stickers/project-status/group-117.png +0 -0
  198. package/apps/studio/stickers/project-status/group-118.png +0 -0
  199. package/apps/studio/stickers/project-status/group-119.png +0 -0
  200. package/apps/studio/stickers/project-status/group-120.png +0 -0
  201. package/apps/studio/stickers/project-status/group-121.png +0 -0
  202. package/apps/studio/stickers/project-status/group-122.png +0 -0
  203. package/apps/studio/stickers/project-status/group-41.png +0 -0
  204. package/apps/studio/stickers/project-status/group-42.png +0 -0
  205. package/apps/studio/stickers/project-status/group-43.png +0 -0
  206. package/apps/studio/stickers/project-status/group-44.png +0 -0
  207. package/apps/studio/stickers/project-status/group-45.png +0 -0
  208. package/apps/studio/stickers/project-status/group-46.png +0 -0
  209. package/apps/studio/stickers/project-status/group-47.png +0 -0
  210. package/apps/studio/stickers/project-status/group-48.png +0 -0
  211. package/apps/studio/stickers/project-status/group-49.png +0 -0
  212. package/apps/studio/stickers/project-status/group-50.png +0 -0
  213. package/apps/studio/stickers/project-status/group-51.png +0 -0
  214. package/apps/studio/stickers/project-status/group-52.png +0 -0
  215. package/apps/studio/stickers/project-status/group-53.png +0 -0
  216. package/apps/studio/stickers/project-status/group-54.png +0 -0
  217. package/apps/studio/stickers/project-status/group-55.png +0 -0
  218. package/apps/studio/stickers/project-status/group-56.png +0 -0
  219. package/apps/studio/stickers/project-status/group-57.png +0 -0
  220. package/apps/studio/stickers/project-status/group-58.png +0 -0
  221. package/apps/studio/stickers/project-status/group-59.png +0 -0
  222. package/apps/studio/stickers/project-status/group-60.png +0 -0
  223. package/apps/studio/stickers/project-status/group-61.png +0 -0
  224. package/apps/studio/stickers/project-status/group-62.png +0 -0
  225. package/apps/studio/stickers/project-status/group-63.png +0 -0
  226. package/apps/studio/stickers/project-status/group-64.png +0 -0
  227. package/apps/studio/stickers/project-status/group-65.png +0 -0
  228. package/apps/studio/stickers/project-status/group-66.png +0 -0
  229. package/apps/studio/stickers/project-status/group-67.png +0 -0
  230. package/apps/studio/stickers/project-status/group-68.png +0 -0
  231. package/apps/studio/stickers/project-status/group-69.png +0 -0
  232. package/apps/studio/stickers/project-status/group-70.png +0 -0
  233. package/apps/studio/stickers/project-status/group-71.png +0 -0
  234. package/apps/studio/stickers/project-status/group-72.png +0 -0
  235. package/apps/studio/stickers/project-status/group-73.png +0 -0
  236. package/apps/studio/stickers/project-status/group-74.png +0 -0
  237. package/apps/studio/stickers/project-status/group-75.png +0 -0
  238. package/apps/studio/stickers/project-status/group-76.png +0 -0
  239. package/apps/studio/stickers/project-status/group-77.png +0 -0
  240. package/apps/studio/stickers/project-status/group-78.png +0 -0
  241. package/apps/studio/stickers/project-status/group-79.png +0 -0
  242. package/apps/studio/stickers/project-status/group-80.png +0 -0
  243. package/apps/studio/stickers/project-status/group-81.png +0 -0
  244. package/apps/studio/stickers/project-status/group-82.png +0 -0
  245. package/apps/studio/stickers/project-status/group-83.png +0 -0
  246. package/apps/studio/stickers/project-status/group-84.png +0 -0
  247. package/apps/studio/stickers/project-status/group-85.png +0 -0
  248. package/apps/studio/stickers/project-status/group-86.png +0 -0
  249. package/apps/studio/stickers/project-status/group-87.png +0 -0
  250. package/apps/studio/stickers/project-status/group-88.png +0 -0
  251. package/apps/studio/stickers/project-status/group-89.png +0 -0
  252. package/apps/studio/stickers/project-status/group-90.png +0 -0
  253. package/apps/studio/stickers/project-status/group-91.png +0 -0
  254. package/apps/studio/stickers/project-status/group-92.png +0 -0
  255. package/apps/studio/stickers/project-status/group-93.png +0 -0
  256. package/apps/studio/stickers/project-status/group-94.png +0 -0
  257. package/apps/studio/stickers/project-status/group-96.png +0 -0
  258. package/apps/studio/stickers/project-status/group-97.png +0 -0
  259. package/apps/studio/stickers/project-status/group-98.png +0 -0
  260. package/apps/studio/stickers/project-status/group-99.png +0 -0
  261. package/apps/studio/stickers/project-status/manifest.json +328 -0
  262. package/apps/studio/test/_helpers.ts +15 -2
  263. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  264. package/apps/studio/test/acp-bridge.test.ts +34 -1
  265. package/apps/studio/test/acp-commands.test.ts +2 -1
  266. package/apps/studio/test/acp-env.test.ts +30 -0
  267. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  268. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  269. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  270. package/apps/studio/test/annotations-layer.test.ts +174 -0
  271. package/apps/studio/test/asset-api.test.ts +3 -3
  272. package/apps/studio/test/canvas-edit.test.ts +17 -6
  273. package/apps/studio/test/canvas-origin-gate.test.ts +50 -0
  274. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  275. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  276. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  277. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  278. package/apps/studio/test/edit-source-command.test.ts +23 -0
  279. package/apps/studio/test/element-structural-edit.test.ts +154 -0
  280. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  281. package/apps/studio/test/footage-store.test.ts +100 -0
  282. package/apps/studio/test/generate-route.test.ts +106 -0
  283. package/apps/studio/test/git-branches.test.ts +97 -0
  284. package/apps/studio/test/github-api.test.ts +56 -1
  285. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  286. package/apps/studio/test/import-asset-route.test.ts +71 -0
  287. package/apps/studio/test/import-asset.test.ts +399 -0
  288. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  289. package/apps/studio/test/import-brand-route.test.ts +120 -0
  290. package/apps/studio/test/import-brand.test.ts +206 -0
  291. package/apps/studio/test/import-tokens.test.ts +722 -0
  292. package/apps/studio/test/input-router.test.ts +33 -0
  293. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  294. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  295. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  296. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  297. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  298. package/apps/studio/test/photo-filters.test.ts +149 -0
  299. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  300. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  301. package/apps/studio/test/read-annotations.test.ts +164 -0
  302. package/apps/studio/test/readiness.test.ts +13 -11
  303. package/apps/studio/test/scaffold-design.test.ts +122 -0
  304. package/apps/studio/test/shell-importmap.test.ts +49 -0
  305. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  306. package/apps/studio/test/timeline-parse.test.ts +57 -0
  307. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  308. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  309. package/apps/studio/test/undo-stack.test.ts +33 -0
  310. package/apps/studio/test/video-asset.test.ts +5 -5
  311. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  312. package/apps/studio/text-caret.ts +239 -0
  313. package/apps/studio/tool-palette.tsx +49 -21
  314. package/apps/studio/ui-prefs.ts +130 -0
  315. package/apps/studio/undo-stack.ts +94 -2
  316. package/apps/studio/use-annotation-resize.tsx +4 -4
  317. package/apps/studio/use-canvas-media-drop.tsx +40 -1
  318. package/apps/studio/use-chrome-visibility.tsx +8 -2
  319. package/apps/studio/use-selection-set.tsx +21 -0
  320. package/apps/studio/video-comp.tsx +48 -7
  321. package/apps/studio/whats-new.json +149 -0
  322. package/apps/studio/ws.ts +5 -0
  323. package/cli/bin/maude.mjs +6 -6
  324. package/cli/commands/design.mjs +67 -3
  325. package/cli/lib/gitignore-block.mjs +2 -0
  326. package/cli/lib/pkg-root.mjs +107 -0
  327. package/cli/lib/pkg-root.test.mjs +79 -0
  328. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  329. package/cli/lib/update-check.mjs +8 -0
  330. package/package.json +13 -11
  331. package/plugins/design/dependencies.json +18 -0
  332. package/plugins/design/templates/_shell.html +1 -0
@@ -20,18 +20,61 @@ import GitPanel from './panels/GitPanel.jsx';
20
20
  import IdentityBar from './panels/IdentityBar.jsx';
21
21
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
22
22
  import { ReadinessDialog } from './panels/ReadinessList.jsx';
23
+ import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
24
+ import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
25
+ import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
23
26
  import TimelinePanel from './panels/TimelinePanel.jsx';
24
27
  import { parseCompTimeline } from './panels/timeline-parse.js';
28
+ import GenerateDialog from './generate-dialog.jsx';
25
29
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
30
+ import SettingsPanel from './panels/SettingsPanel.jsx';
31
+ import StickerPicker from './panels/StickerPicker.jsx';
32
+ import { AlignPad, AngleDial, ColorField, IconButtonGroup, IconToggleGroup, makeScrubHandler, NumberField, RadiusControl, Segmented, SliderField, Toggle, UnitSelect, ValueTokenField } from './inspector-controls.jsx';
33
+ import {
34
+ ALargeSmall as LuALargeSmall,
35
+ AlignCenter as LuAlignCenter,
36
+ AlignHorizontalJustifyCenter as LuJustifyCenter,
37
+ AlignHorizontalJustifyEnd as LuJustifyEnd,
38
+ AlignHorizontalJustifyStart as LuJustifyStart,
39
+ AlignHorizontalSpaceBetween as LuSpaceBetween,
40
+ AlignJustify as LuAlignJustify,
41
+ AlignLeft as LuAlignLeft,
42
+ AlignRight as LuAlignRight,
43
+ AlignVerticalJustifyCenter as LuVJustifyCenter,
44
+ AlignVerticalJustifyEnd as LuVJustifyEnd,
45
+ AlignVerticalJustifyStart as LuVJustifyStart,
46
+ Baseline as LuBaseline,
47
+ Bold as LuBold,
48
+ Columns3 as LuColumns3,
49
+ Eye as LuEye,
50
+ Italic as LuItalic,
51
+ Minus as LuMinus,
52
+ MoveHorizontal as LuMoveH,
53
+ RotateCw as LuRotateCw,
54
+ Rows3 as LuRows3,
55
+ Scissors as LuScissors,
56
+ ScrollText as LuScrollText,
57
+ Spline as LuSpline,
58
+ StretchHorizontal as LuStretch,
59
+ Underline as LuUnderline,
60
+ Braces as LuBraces,
61
+ Wand2 as LuWand2,
62
+ } from 'lucide-react';
63
+
64
+ // lucide wrapper — hairline stroke to match the shell's icon weight (handoff).
65
+ const Lu = ({ as: C, size = 14 }) => <C size={size} strokeWidth={1.75} style={{ display: 'block' }} />;
66
+ import { PhotoKnobs } from './photo-knobs.jsx';
26
67
  import {
27
68
  appIsFirstRun,
28
69
  isNativeApp,
29
70
  onUpdateReady,
30
71
  pickMediaFile,
72
+ pickMediaFiles,
31
73
  restartToUpdate,
32
74
  } from './github.js';
33
75
  import { COLLAB_TOUR } from './tour/collab-tour.js';
34
76
  import { TourOverlay } from './tour/overlay.jsx';
77
+ import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
35
78
  import { USAGE_TOUR } from './tour/usage-tour.js';
36
79
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
37
80
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
@@ -44,7 +87,83 @@ const SYSTEM_TAB = '__system__';
44
87
  const THEME_STORE = 'mdcc-theme';
45
88
  const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
46
89
  const SECTIONS_STORE = 'mdcc-sections-expanded';
90
+ // DDR-171 — CSS panel vocabulary mode ('advanced' | 'designer'), read inside
91
+ // CssKnobs.
92
+ const CP_MODE_STORE = 'maude-cp-mode';
47
93
  const SIDEBAR_STORE = 'mdcc-sidebar-open';
94
+ const MINIMAP_STORE = 'mdcc-minimap-visible';
95
+ const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
96
+ const ANNOT_STORE = 'mdcc-annotations-visible';
97
+ const AUTOOPEN_STORE = 'maude-auto-open-inspector';
98
+
99
+ // feature-unified-settings-modal — the Settings modal's view prefs are ALSO
100
+ // persisted to disk (~/.config/maude/prefs.json via /_api/ui-prefs), so they
101
+ // survive a restart even if localStorage is cleared (the native WKWebView
102
+ // case). localStorage stays the synchronous init source (no boot flash); disk
103
+ // is the durable, cross-restart source of truth reconciled on mount.
104
+ function persistUiPrefs(patch) {
105
+ try {
106
+ fetch('/_api/ui-prefs', {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'text/plain' },
109
+ body: JSON.stringify(patch),
110
+ }).catch(() => {});
111
+ } catch {}
112
+ }
113
+
114
+ // feature-configurable-panel-docking — the dockable shell panels. Each can live
115
+ // in the LEFT or RIGHT slot (persisted in UiPrefs.panelSides); each slot is a
116
+ // single-panel-at-a-time surface with a tab strip over the panels assigned to
117
+ // it. Order here = tab order within a slot. `assistant` is native-only.
118
+ const DOCK_PANELS = [
119
+ { id: 'tree', label: 'Files' },
120
+ { id: 'layers', label: 'Layers' },
121
+ { id: 'inspector', label: 'Inspector' },
122
+ { id: 'comments', label: 'Comments' },
123
+ { id: 'changes', label: 'Changes' },
124
+ { id: 'assistant', label: 'Assistant' },
125
+ ];
126
+ const PANEL_SIDES_DEFAULTS = {
127
+ tree: 'left',
128
+ layers: 'left',
129
+ inspector: 'right',
130
+ comments: 'right',
131
+ changes: 'right',
132
+ assistant: 'right',
133
+ };
134
+ const PANEL_SIDES_STORE = 'mdcc-panel-sides';
135
+ const LAYERS_MODE_STORE = 'mdcc-layers-mode';
136
+
137
+ // feature-configurable-panel-docking — one dock slot (left or right). Owns the
138
+ // resizable width + a tab strip over the panels assigned to the slot; renders
139
+ // the active panel (passed as children). Collapses to 0 width when nothing is
140
+ // visibly open (it may still host an always-mounted hidden ChatPanel).
141
+ function DockSlot({ side, width, open, ids, activeId, onPick, children }) {
142
+ return (
143
+ <div
144
+ className={'st-dockslot st-dockslot--' + side + (open ? '' : ' is-collapsed')}
145
+ style={{ width: open ? width : 0, flexBasis: open ? width : 0 }}
146
+ >
147
+ {open && ids.length > 1 && (
148
+ <div className="st-docktabs" role="tablist" aria-label={side + ' panels'}>
149
+ {ids.map((id) => (
150
+ <button
151
+ key={id}
152
+ type="button"
153
+ role="tab"
154
+ aria-selected={activeId === id}
155
+ className={'st-docktab' + (activeId === id ? ' is-active' : '')}
156
+ onClick={() => onPick(id)}
157
+ >
158
+ {(DOCK_PANELS.find((p) => p.id === id) || {}).label || id}
159
+ </button>
160
+ ))}
161
+ </div>
162
+ )}
163
+ {children}
164
+ </div>
165
+ );
166
+ }
48
167
  const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
49
168
  // Bun's `define` substitutes this at build time (see build.ts); falls back when
50
169
  // the bundle is consumed in a context that hasn't run the build.
@@ -841,6 +960,23 @@ const PNG_SCALES = [
841
960
  { value: 3, label: '3× (max)' },
842
961
  ];
843
962
 
963
+ // feature-bulk-media-insert — best-effort MIME guess from a filename extension.
964
+ // Only used to classify a natively-picked file (Rust returns `{name, bytes}`,
965
+ // no type) for the destination-toggle's image/video/audio split; the actual
966
+ // upload is always magic-byte-sniffed server-side, so a wrong guess here
967
+ // can't misrepresent what gets stored, only which picker UI state it shows.
968
+ const EXT_MIME = {
969
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
970
+ webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
971
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', m4v: 'video/mp4', ogg: 'video/ogg',
972
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', aac: 'audio/aac',
973
+ flac: 'audio/flac', oga: 'audio/ogg', opus: 'audio/opus',
974
+ };
975
+ function mimeFromExt(name) {
976
+ const ext = String(name || '').split('.').pop()?.toLowerCase();
977
+ return EXT_MIME[ext] || 'application/octet-stream';
978
+ }
979
+
844
980
  // feature-element-editing-robustness Stage F1 — AssetPicker. A shell modal that
845
981
  // lists the versioned content-addressed media under <designRoot>/assets/ (via the
846
982
  // main-origin-only GET /_api/assets) and lets the user pick one — or upload a new
@@ -848,16 +984,38 @@ const PNG_SCALES = [
848
984
  // Insert. Thumbnails load from the main origin's designRoot static serve
849
985
  // (/${designRel}/${asset.path}); never a remote hotlink (the CSP split origin
850
986
  // blocks those — memory reference_canvas_images_download_first).
851
- function AssetPicker({ designRel, onPick, onClose }) {
987
+ function AssetPicker({
988
+ designRel,
989
+ onPick,
990
+ onClose,
991
+ multiple = false,
992
+ hasArtboardAnchor = false,
993
+ onPickMany,
994
+ }) {
852
995
  const [assets, setAssets] = useState(null);
853
996
  const [busy, setBusy] = useState(false);
854
997
  const [err, setErr] = useState(null);
998
+ // feature-bulk-media-insert — multi-select state. `kindByPath` merges the
999
+ // fetched listing with freshly uploaded assets (the upload response carries
1000
+ // no `kind`, so a just-uploaded file's kind comes from its own File.type).
1001
+ const [selected, setSelected] = useState(() => new Set());
1002
+ const [kindByPath, setKindByPath] = useState({});
1003
+ const [destination, setDestination] = useState(hasArtboardAnchor ? 'artboard' : 'annotation');
855
1004
 
856
1005
  useEffect(() => {
857
1006
  let alive = true;
858
1007
  fetch('/_api/assets')
859
1008
  .then((r) => r.json())
860
- .then((j) => alive && setAssets(j.ok ? j.assets : []))
1009
+ .then((j) => {
1010
+ if (!alive) return;
1011
+ const list = j.ok ? j.assets : [];
1012
+ setAssets(list);
1013
+ setKindByPath((prev) => {
1014
+ const next = { ...prev };
1015
+ for (const a of list) next[a.path] = a.kind;
1016
+ return next;
1017
+ });
1018
+ })
861
1019
  .catch(() => alive && setAssets([]));
862
1020
  return () => {
863
1021
  alive = false;
@@ -875,28 +1033,84 @@ function AssetPicker({ designRel, onPick, onClose }) {
875
1033
  return () => window.removeEventListener('keydown', onKey, true);
876
1034
  }, [onClose]);
877
1035
 
1036
+ const hasNonImageSelected = Array.from(selected).some((p) => (kindByPath[p] || 'image') !== 'image');
1037
+ const artboardDisabled = !hasArtboardAnchor || hasNonImageSelected;
1038
+ const artboardDisabledReason = !hasArtboardAnchor
1039
+ ? 'No artboard on this canvas'
1040
+ : hasNonImageSelected
1041
+ ? 'Video/audio can only be added as annotations'
1042
+ : '';
1043
+ // Force off "Add to artboard" the moment it becomes unavailable (e.g. a
1044
+ // video gets added to an until-now all-image selection).
1045
+ useEffect(() => {
1046
+ if (artboardDisabled && destination === 'artboard') setDestination('annotation');
1047
+ }, [artboardDisabled, destination]);
1048
+
1049
+ const toggleSelected = (path) => {
1050
+ setSelected((prev) => {
1051
+ const next = new Set(prev);
1052
+ if (next.has(path)) next.delete(path);
1053
+ else next.add(path);
1054
+ return next;
1055
+ });
1056
+ };
1057
+
1058
+ // Shared upload core — both the single-pick `doUpload` and the multi-file
1059
+ // `doUploadMany` post through here, so neither duplicates the request shape.
1060
+ const uploadOne = async (f) => {
1061
+ const res = await fetch('/_api/asset', {
1062
+ method: 'POST',
1063
+ headers: { 'content-type': f.type || 'application/octet-stream' },
1064
+ body: f,
1065
+ });
1066
+ const j = await res.json().catch(() => ({}));
1067
+ // /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
1068
+ // `j.ok` check always failed → "upload failed" even on a good upload).
1069
+ if (res.ok && j.path) return { ok: true, path: j.path };
1070
+ return { ok: false, error: j.error || `upload failed (HTTP ${res.status})` };
1071
+ };
1072
+
878
1073
  const doUpload = async (f) => {
879
1074
  if (!f) return;
880
1075
  setBusy(true);
881
1076
  setErr(null);
882
- try {
883
- const res = await fetch('/_api/asset', {
884
- method: 'POST',
885
- headers: { 'content-type': f.type || 'application/octet-stream' },
886
- body: f,
1077
+ const res = await uploadOne(f);
1078
+ setBusy(false);
1079
+ if (res.ok) onPick(res.path);
1080
+ else setErr(res.error);
1081
+ };
1082
+
1083
+ const kindOfMime = (mime) => {
1084
+ if (typeof mime === 'string' && mime.startsWith('video/')) return 'video';
1085
+ if (typeof mime === 'string' && mime.startsWith('audio/')) return 'audio';
1086
+ return 'image';
1087
+ };
1088
+
1089
+ // Multi-file upload — each file uploads independently/concurrently (the
1090
+ // route is idempotent + content-addressed, so no ordering concern); every
1091
+ // one that succeeds joins the selection as it resolves.
1092
+ const doUploadMany = async (files) => {
1093
+ if (!files.length) return;
1094
+ setBusy(true);
1095
+ setErr(null);
1096
+ const results = await Promise.all(
1097
+ files.map((f) => uploadOne(f).then((r) => ({ ...r, file: f })))
1098
+ );
1099
+ setBusy(false);
1100
+ const ok = results.filter((r) => r.ok);
1101
+ const failed = results.length - ok.length;
1102
+ if (failed > 0) setErr(`${failed} of ${results.length} uploads failed`);
1103
+ if (ok.length) {
1104
+ setKindByPath((prev) => {
1105
+ const next = { ...prev };
1106
+ for (const r of ok) next[r.path] = kindOfMime(r.file.type);
1107
+ return next;
1108
+ });
1109
+ setSelected((prev) => {
1110
+ const next = new Set(prev);
1111
+ for (const r of ok) next.add(r.path);
1112
+ return next;
887
1113
  });
888
- const j = await res.json().catch(() => ({}));
889
- // /_api/asset returns 201 { path } on success — NO `ok` field (the bug: a
890
- // `j.ok` check always failed → "upload failed" even on a good upload).
891
- if (res.ok && j.path) {
892
- onPick(j.path);
893
- return;
894
- }
895
- setErr(j.error || `upload failed (HTTP ${res.status})`);
896
- } catch {
897
- setErr('upload failed');
898
- } finally {
899
- setBusy(false);
900
1114
  }
901
1115
  };
902
1116
 
@@ -909,6 +1123,15 @@ function AssetPicker({ designRel, onPick, onClose }) {
909
1123
  setBusy(true);
910
1124
  setErr(null);
911
1125
  try {
1126
+ if (multiple) {
1127
+ const picked = await pickMediaFiles();
1128
+ const files = (picked || []).map(
1129
+ (p) => new File([new Uint8Array(p.bytes)], p.name, { type: mimeFromExt(p.name) })
1130
+ );
1131
+ setBusy(false);
1132
+ if (files.length) await doUploadMany(files);
1133
+ return;
1134
+ }
912
1135
  const picked = await pickMediaFile();
913
1136
  if (picked?.bytes) {
914
1137
  // Blob from the byte array; the server sniffs the type, so no content-type
@@ -919,7 +1142,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
919
1142
  } catch (e) {
920
1143
  setErr(e?.message || 'open failed');
921
1144
  }
922
- setBusy(false); // only reached on cancel / error (doUpload owns the success path)
1145
+ setBusy(false); // only reached on cancel / error (doUpload/doUploadMany own the success path)
923
1146
  };
924
1147
 
925
1148
  // Browser — imperative <input>, mirrors the proven replaceMediaViaPicker
@@ -933,6 +1156,7 @@ function AssetPicker({ designRel, onPick, onClose }) {
933
1156
  const input = document.createElement('input');
934
1157
  input.type = 'file';
935
1158
  input.accept = 'image/*,video/*';
1159
+ if (multiple) input.multiple = true;
936
1160
  input.style.cssText =
937
1161
  'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
938
1162
  document.body.appendChild(input);
@@ -941,9 +1165,11 @@ function AssetPicker({ designRel, onPick, onClose }) {
941
1165
  };
942
1166
  window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
943
1167
  input.addEventListener('change', () => {
944
- const file = input.files?.[0];
1168
+ const files = input.files ? Array.from(input.files) : [];
945
1169
  cleanup();
946
- if (file) doUpload(file);
1170
+ if (!files.length) return;
1171
+ if (multiple) doUploadMany(files);
1172
+ else doUpload(files[0]);
947
1173
  });
948
1174
  input.click();
949
1175
  };
@@ -978,25 +1204,71 @@ function AssetPicker({ designRel, onPick, onClose }) {
978
1204
  ) : assets.length === 0 ? (
979
1205
  <div className="st-ap-empty">No assets yet — upload one.</div>
980
1206
  ) : (
981
- assets.map((a) => (
982
- <button
983
- type="button"
984
- key={a.path}
985
- className="st-ap-cell"
986
- title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
987
- onClick={() => onPick(a.path)}
988
- >
989
- {a.kind === 'video' ? (
990
- <video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
991
- ) : (
992
- <img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
993
- )}
994
- <span className="st-ap-name">{a.name}</span>
995
- </button>
996
- ))
1207
+ assets.map((a) => {
1208
+ const isSelected = multiple && selected.has(a.path);
1209
+ return (
1210
+ <button
1211
+ type="button"
1212
+ key={a.path}
1213
+ className="st-ap-cell"
1214
+ data-selected={isSelected ? 'true' : undefined}
1215
+ aria-pressed={multiple ? isSelected : undefined}
1216
+ title={`${a.name} · ${Math.max(1, Math.round(a.size / 1024))} KB`}
1217
+ onClick={() => (multiple ? toggleSelected(a.path) : onPick(a.path))}
1218
+ >
1219
+ {multiple && (
1220
+ <span className="st-ap-check" aria-hidden="true">
1221
+ {isSelected ? <StIcon name="check" size={12} /> : null}
1222
+ </span>
1223
+ )}
1224
+ {a.kind === 'video' ? (
1225
+ <video className="st-ap-thumb" src={assetUrl(a.path)} muted playsInline />
1226
+ ) : (
1227
+ <img className="st-ap-thumb" src={assetUrl(a.path)} alt={a.name} loading="lazy" />
1228
+ )}
1229
+ <span className="st-ap-name">{a.name}</span>
1230
+ </button>
1231
+ );
1232
+ })
997
1233
  )}
998
1234
  </div>
999
1235
  </div>
1236
+ {multiple && selected.size > 0 && (
1237
+ <div className="st-dialog-ft st-ap-confirm">
1238
+ <span className="st-ap-count">{selected.size} selected</span>
1239
+ <div className="st-ap-seg" role="radiogroup" aria-label="Insert as">
1240
+ <button
1241
+ type="button"
1242
+ className="st-ap-seg-btn"
1243
+ aria-pressed={destination === 'artboard'}
1244
+ disabled={artboardDisabled}
1245
+ onClick={() => setDestination('artboard')}
1246
+ >
1247
+ Add to artboard
1248
+ </button>
1249
+ <button
1250
+ type="button"
1251
+ className="st-ap-seg-btn"
1252
+ aria-pressed={destination === 'annotation'}
1253
+ onClick={() => setDestination('annotation')}
1254
+ >
1255
+ Add as annotation
1256
+ </button>
1257
+ </div>
1258
+ {artboardDisabled && <span className="st-ap-dest-note">{artboardDisabledReason}</span>}
1259
+ <span className="st-ap-confirm-spacer" />
1260
+ <button type="button" className="btn btn--ghost" onClick={onClose}>
1261
+ Cancel
1262
+ </button>
1263
+ <button
1264
+ type="button"
1265
+ className="btn btn--primary"
1266
+ onClick={() => onPickMany?.(Array.from(selected), destination)}
1267
+ >
1268
+ Insert
1269
+ </button>
1270
+ </div>
1271
+ )}
1000
1272
  </div>
1001
1273
  </div>
1002
1274
  );
@@ -1324,7 +1596,18 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
1324
1596
  );
1325
1597
  }
1326
1598
 
1327
- function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar, dirty }) {
1599
+ function FileRow({
1600
+ file,
1601
+ activePath,
1602
+ onOpen,
1603
+ onDelete,
1604
+ openCount: oc,
1605
+ depth,
1606
+ kind,
1607
+ sidecar,
1608
+ dirty,
1609
+ experimentalKind,
1610
+ }) {
1328
1611
  const isSel = file.path === activePath;
1329
1612
  const isCanvas = CANVAS_EXT_RE.test(file.name);
1330
1613
  // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
@@ -1368,6 +1651,15 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
1368
1651
  <StIcon name="file" size={13} />
1369
1652
  </span>
1370
1653
  <span className="st-row-name">{label}</span>
1654
+ {experimentalKind === 'reconstructed-experimental' && (
1655
+ <span
1656
+ className="st-row-exp-badge"
1657
+ title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
1658
+ aria-label="Reconstructed, experimental"
1659
+ >
1660
+ exp
1661
+ </span>
1662
+ )}
1371
1663
  {dirty && (
1372
1664
  <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1373
1665
  {dirty}
@@ -1410,6 +1702,7 @@ function CanvasRow({
1410
1702
  showHidden,
1411
1703
  forceOpen,
1412
1704
  dirtyByPath,
1705
+ experimentalKind,
1413
1706
  }) {
1414
1707
  const dirty = dirtyByPath?.get(primary.path);
1415
1708
  const hasSidecars = sidecars.length > 0;
@@ -1431,6 +1724,7 @@ function CanvasRow({
1431
1724
  depth={depth}
1432
1725
  kind={kind}
1433
1726
  dirty={dirty}
1727
+ experimentalKind={experimentalKind}
1434
1728
  />
1435
1729
  );
1436
1730
  }
@@ -1464,6 +1758,15 @@ function CanvasRow({
1464
1758
  <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
1465
1759
  </span>
1466
1760
  <span className="st-row-name">{displayName(primary.name)}</span>
1761
+ {experimentalKind === 'reconstructed-experimental' && (
1762
+ <span
1763
+ className="st-row-exp-badge"
1764
+ title="Reconstructed from an image via /design:import --reconstruct — experimental, lossy, review before trusting (DDR-174)"
1765
+ aria-label="Reconstructed, experimental"
1766
+ >
1767
+ exp
1768
+ </span>
1769
+ )}
1467
1770
  {dirty && (
1468
1771
  <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1469
1772
  {dirty}
@@ -1502,6 +1805,7 @@ function Tree({
1502
1805
  onOpenSystem,
1503
1806
  onDelete,
1504
1807
  dirtyByPath,
1808
+ canvasKinds,
1505
1809
  }) {
1506
1810
  const dirs = Object.keys(node)
1507
1811
  .filter((k) => k !== '_files')
@@ -1544,6 +1848,7 @@ function Tree({
1544
1848
  showHidden={showHidden}
1545
1849
  forceOpen={forceOpen}
1546
1850
  dirtyByPath={dirtyByPath}
1851
+ experimentalKind={canvasKinds?.[entry.primary.path]}
1547
1852
  />
1548
1853
  );
1549
1854
  })}
@@ -1576,6 +1881,7 @@ function Tree({
1576
1881
  onOpenSystem={onOpenSystem}
1577
1882
  onDelete={onDelete}
1578
1883
  dirtyByPath={dirtyByPath}
1884
+ canvasKinds={canvasKinds}
1579
1885
  />
1580
1886
  );
1581
1887
  if (dsMatch && onOpenSystem) {
@@ -1648,6 +1954,9 @@ function Sidebar({
1648
1954
  dirtyByPath,
1649
1955
  project,
1650
1956
  gitBranch,
1957
+ remoteSync,
1958
+ onGetLatest,
1959
+ canvasKinds,
1651
1960
  }) {
1652
1961
  const filteredGroups = useMemo(() => {
1653
1962
  if (!search) return groups;
@@ -1878,6 +2187,7 @@ function Sidebar({
1878
2187
  onOpenSystem={isDs ? onOpenSystem : undefined}
1879
2188
  onDelete={isDs ? undefined : onDeleteBoard}
1880
2189
  dirtyByPath={dirtyByPath}
2190
+ canvasKinds={canvasKinds}
1881
2191
  />
1882
2192
  ) : (
1883
2193
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -1889,7 +2199,7 @@ function Sidebar({
1889
2199
  {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
1890
2200
  opens UPWARD, sitting directly above the GitHub identity avatar so the two
1891
2201
  form one bottom dock. Renders nothing until the project is a git repo. */}
1892
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} />
2202
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
1893
2203
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
1894
2204
  sign in, connected account + New/Pull/Share, sign out. Self-contained
1895
2205
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -2427,12 +2737,9 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
2427
2737
  <span className="st-dd-lead">
2428
2738
  <span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
2429
2739
  <span>{p.label}</span>
2740
+ {p.phase ? <span className="st-dd-phase">{p.phase}</span> : null}
2430
2741
  </span>
2431
- {p.phase ? (
2432
- <span className="st-dd-phase">{p.phase}</span>
2433
- ) : p.shortcut ? (
2434
- <Kbd>{p.shortcut}</Kbd>
2435
- ) : null}
2742
+ {p.shortcut ? <Kbd>{p.shortcut}</Kbd> : null}
2436
2743
  </button>
2437
2744
  ))}
2438
2745
  <div className="st-dd-sep" />
@@ -2517,10 +2824,15 @@ function HelpDropdown({ onAction, onClose }) {
2517
2824
  { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
2518
2825
  { sep: true },
2519
2826
  { id: 'tour', label: 'Take the tour' },
2827
+ { id: 'watch-intro', label: 'Watch the intro' },
2520
2828
  // The collab "how sharing works" course teaches the plain-words Save →
2521
2829
  // Publish → Pull cycle — a non-technical, native-app concern. A web-studio
2522
2830
  // dev already knows git, so it's hidden there (DDR-119).
2523
2831
  ...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
2832
+ // DDR-166 plan, Phase 2 (T7) — the quick-setup journey (design system →
2833
+ // first canvas → first AI edit) is a native, no-terminal concern same as
2834
+ // the two tours above.
2835
+ ...(isNativeApp() ? [{ id: 'quick-setup', label: 'Quick setup' }] : []),
2524
2836
  ...(isNativeApp() ? [{ id: 'readiness', label: 'Check AI editing readiness…' }] : []),
2525
2837
  { id: 'whatsnew', label: "What's new" },
2526
2838
  ]}
@@ -2588,6 +2900,10 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
2588
2900
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
2589
2901
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
2590
2902
  { sep: true },
2903
+ // feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
2904
+ { id: 'generate', label: 'Generate with AI…' },
2905
+ { id: 'settings', label: 'Settings…', shortcut: '⌘,' },
2906
+ { sep: true },
2591
2907
  { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
2592
2908
  { id: 'close', label: 'Close canvas', disabled: !hasCanvas },
2593
2909
  ]}
@@ -2655,6 +2971,8 @@ function Menubar({
2655
2971
  postToActiveCanvas,
2656
2972
  onOpenWhatsNew,
2657
2973
  onOpenReadiness,
2974
+ onOpenQuickSetup,
2975
+ onWatchIntro,
2658
2976
  whatsNewCount,
2659
2977
  exportCenter,
2660
2978
  artboardCount = 0,
@@ -2675,6 +2993,8 @@ function Menubar({
2675
2993
  onNewCanvas,
2676
2994
  onAssembleVideo,
2677
2995
  onOpenExport,
2996
+ onOpenSettings,
2997
+ onOpenGenerate,
2678
2998
  onReload,
2679
2999
  onCloseCanvas,
2680
3000
  onInsertArtboard,
@@ -2740,6 +3060,7 @@ function Menubar({
2740
3060
  {
2741
3061
  id: 'timeline',
2742
3062
  label: 'Timeline',
3063
+ shortcut: '⌘ ⇧ T',
2743
3064
  phase: hasComps ? 'video' : undefined,
2744
3065
  checked: timelineOpen,
2745
3066
  disabled: false,
@@ -2881,6 +3202,8 @@ function Menubar({
2881
3202
  else if (id === 'assemble') onAssembleVideo?.();
2882
3203
  else if (id === 'export') onOpenExport?.('export');
2883
3204
  else if (id === 'handoff') onOpenExport?.('handoff');
3205
+ else if (id === 'generate') onOpenGenerate?.();
3206
+ else if (id === 'settings') onOpenSettings?.();
2884
3207
  else if (id === 'reload') onReload?.();
2885
3208
  else if (id === 'close') onCloseCanvas?.();
2886
3209
  }}
@@ -2948,8 +3271,10 @@ function Menubar({
2948
3271
  else if (id === 'help') onOpenHelp?.();
2949
3272
  else if (id === 'tour') onStartTour?.();
2950
3273
  else if (id === 'collab-tour') onStartCollabTour?.();
3274
+ else if (id === 'quick-setup') onOpenQuickSetup?.();
2951
3275
  else if (id === 'readiness') onOpenReadiness?.();
2952
3276
  else if (id === 'whatsnew') onOpenWhatsNew?.();
3277
+ else if (id === 'watch-intro') onWatchIntro?.();
2953
3278
  }}
2954
3279
  onClose={() => setOpenMenu(null)}
2955
3280
  />
@@ -2960,6 +3285,7 @@ function Menubar({
2960
3285
  <button
2961
3286
  type="button"
2962
3287
  className="st-assistant"
3288
+ data-testid="assistant-toggle"
2963
3289
  data-active={assistantOpen ? 'true' : 'false'}
2964
3290
  data-busy={assistantBusy ? 'true' : 'false'}
2965
3291
  data-unseen={assistantUnseen ? 'true' : 'false'}
@@ -3009,6 +3335,8 @@ function Viewport({
3009
3335
  cfg,
3010
3336
  loadingPath,
3011
3337
  onIframeLoad,
3338
+ showQuickSetup,
3339
+ onStartQuickSetup,
3012
3340
  }) {
3013
3341
  return (
3014
3342
  <div className="viewport st-stage" data-tour="viewport">
@@ -3024,24 +3352,34 @@ function Viewport({
3024
3352
  localhost:{typeof window !== 'undefined' ? window.location.port : '4399'}
3025
3353
  </span>
3026
3354
  </div>
3027
- <div className="st-empty-title">No canvas open</div>
3355
+ <div className="st-empty-title">Nothing open yet</div>
3028
3356
  <div className="st-empty-body">
3029
- Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the{' '}
3030
- <strong>Design system</strong> view above it.
3357
+ Pick a screen from the list on the left, or open <strong>Design system</strong> above
3358
+ it to see your colors, type, and components.
3031
3359
  <br />
3032
3360
  <br />
3033
- Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
3034
- canvas clears the stage.
3035
- <br />
3036
- <br />
3037
- <strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
3038
- preview, click to select. <Kbd>⌘⇧</Kbd>+click adds to a multi-selection. <Kbd>V</Kbd>
3039
- /<Kbd>H</Kbd>/<Kbd>C</Kbd> swap tool; right-click opens the context menu.
3040
- <br />
3041
- <br />
3042
- Active file, selection, and comments are tracked in <code>_active.json</code> +{' '}
3043
- <code>_comments/</code> Claude reads them when you run <code>/design</code>.
3361
+ <strong>To select something on the canvas:</strong> hold <Kbd>⌘</Kbd> and hover to
3362
+ preview an element, click to select it — <Kbd>⌘⇧</Kbd>+click selects more than one.
3363
+ Right-click for more options.
3364
+ {isNativeApp() ? (
3365
+ <>
3366
+ <br />
3367
+ <br />
3368
+ Claude can see whatever's selected when you ask for a change in the Assistant
3369
+ panel, so pointing is often faster than describing it.
3370
+ </>
3371
+ ) : null}
3044
3372
  </div>
3373
+ {showQuickSetup && (
3374
+ <button
3375
+ type="button"
3376
+ data-testid="st-empty-start-quick-setup"
3377
+ className="btn btn--primary st-empty-quick-setup"
3378
+ onClick={onStartQuickSetup}
3379
+ >
3380
+ Start quick setup
3381
+ </button>
3382
+ )}
3045
3383
  </div>
3046
3384
  )}
3047
3385
  {tabs.map((t) => {
@@ -3832,16 +4170,15 @@ const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index'
3832
4170
  // #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
3833
4171
  // a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
3834
4172
  const PROP_LEAD = {
3835
- 'font-size': { icon: 'p-size' },
3836
- 'line-height': { icon: 'p-lineheight' },
3837
- 'letter-spacing': { icon: 'p-letterspacing' },
3838
- gap: { icon: 'p-gap' },
4173
+ 'font-size': { node: <Lu as={LuALargeSmall} size={12} /> },
4174
+ 'line-height': { node: <Lu as={LuBaseline} size={12} /> },
4175
+ 'letter-spacing': { node: <Lu as={LuMoveH} size={12} /> },
4176
+ gap: { node: <Lu as={LuSpaceBetween} size={12} /> },
3839
4177
  width: { t: 'W' },
3840
4178
  height: { t: 'H' },
3841
4179
  'max-width': { t: 'W' },
3842
- 'border-radius': { icon: 'p-corner' },
3843
- 'border-width': { icon: 'p-border' },
3844
- opacity: { icon: 'p-opacity' },
4180
+ 'border-radius': { node: <Lu as={LuSpline} size={12} /> },
4181
+ 'border-width': { node: <Lu as={LuMinus} size={12} /> },
3845
4182
  };
3846
4183
  const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
3847
4184
  // feature-element-editing-robustness Stage B — enum option lists for the promoted
@@ -3853,6 +4190,26 @@ const CSS_TEXT_DECORATION = ['none', 'underline', 'line-through', 'overline'];
3853
4190
  const CSS_WHITE_SPACE = ['normal', 'nowrap', 'pre', 'pre-wrap', 'pre-line', 'break-spaces'];
3854
4191
  const CSS_OBJECT_FIT = ['fill', 'contain', 'cover', 'none', 'scale-down'];
3855
4192
  const CSS_OVERFLOW = ['visible', 'hidden', 'auto', 'scroll'];
4193
+ // DDR-171 — Designer mode "Effects" cluster (blend) + the matching Advanced-mode
4194
+ // Appearance row. Standard CSS `mix-blend-mode` keyword list.
4195
+ const CSS_BLEND_MODES = [
4196
+ 'normal',
4197
+ 'multiply',
4198
+ 'screen',
4199
+ 'overlay',
4200
+ 'darken',
4201
+ 'lighten',
4202
+ 'color-dodge',
4203
+ 'color-burn',
4204
+ 'hard-light',
4205
+ 'soft-light',
4206
+ 'difference',
4207
+ 'exclusion',
4208
+ 'hue',
4209
+ 'saturation',
4210
+ 'color',
4211
+ 'luminosity',
4212
+ ];
3856
4213
  // Common aspect ratios for the Media dropdown (dogfood request — a select, not a
3857
4214
  // free-text field). Canonical spaced form so a dropdown-set value round-trips.
3858
4215
  const CSS_ASPECT_RATIO = ['auto', '1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9', '3 / 4', '2 / 3', '9 / 16'];
@@ -3959,6 +4316,12 @@ function cssSplitUnit(v) {
3959
4316
  // is for the SAME element we already hold locally, preserve those fields instead
3960
4317
  // of clobbering them to empty (else the server round-trip wipes the custom-CSS /
3961
4318
  // custom-attr rows + computed readout right after selection).
4319
+ // feature-photo-editor — `photoKind`/`photoAsset` (dom-selection.ts) are the SAME
4320
+ // class of client-only DOM-derived field and belong in this list for the same
4321
+ // reason: without it, every server-pushed `selected`/`snapshot` restore (a canvas
4322
+ // switch, a reconnect, the persisted `_active.json` on boot — none of which carry
4323
+ // these fields) silently drops the Inspector's Photo tab until the next fresh
4324
+ // click re-derives it live — the "tab is there, then it's gone" flicker.
3962
4325
  function mergeSelClientFields(incoming, prev) {
3963
4326
  if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
3964
4327
  if (!incoming.id || incoming.id !== prev.id) return incoming;
@@ -3968,6 +4331,8 @@ function mergeSelClientFields(incoming, prev) {
3968
4331
  computed: incoming.computed ?? prev.computed,
3969
4332
  customStyles: incoming.customStyles ?? prev.customStyles,
3970
4333
  attrs: incoming.attrs ?? prev.attrs,
4334
+ photoKind: incoming.photoKind ?? prev.photoKind,
4335
+ photoAsset: incoming.photoAsset ?? prev.photoAsset,
3971
4336
  };
3972
4337
  }
3973
4338
 
@@ -4068,7 +4433,7 @@ function useAllDsTokens(cfg, designRel, activeName) {
4068
4433
  // `seed` is the resolved current colour (hex). Drag updates the picker UI live;
4069
4434
  // commits on pointer-up (one source write per drag); the hex field commits on
4070
4435
  // blur/Enter.
4071
- function ColorPicker({ seed, onApply }) {
4436
+ function ColorPicker({ seed, label, onApply }) {
4072
4437
  const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
4073
4438
  const hsvRef = useRef(hsv);
4074
4439
  hsvRef.current = hsv;
@@ -4123,6 +4488,31 @@ function ColorPicker({ seed, onApply }) {
4123
4488
  document.addEventListener('pointermove', move);
4124
4489
  document.addEventListener('pointerup', up);
4125
4490
  };
4491
+ // Keyboard equivalents for dragSV/dragHue (feature-photo-editor follow-up
4492
+ // debt, Task 18) — the pad + hue bar were pointer-only. Arrow keys nudge
4493
+ // the same `setHsv`/`onApply` path the drag handlers use; shift = a bigger
4494
+ // step (mirrors makeScrub's shift=×10 modifier convention elsewhere).
4495
+ const nudgeHsv = (patch) => {
4496
+ const next = { ...hsvRef.current, ...patch };
4497
+ setHsv(next);
4498
+ onApply(rgbToHex(hsvToRgb(next)));
4499
+ };
4500
+ const onSvKeyDown = (e) => {
4501
+ const step = e.shiftKey ? 0.1 : 0.02;
4502
+ const deltas = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
4503
+ const d = deltas[e.key];
4504
+ if (!d) return;
4505
+ e.preventDefault();
4506
+ nudgeHsv({ s: clamp01(hsvRef.current.s + d[0]), v: clamp01(hsvRef.current.v + d[1]) });
4507
+ };
4508
+ const onHueKeyDown = (e) => {
4509
+ const step = e.shiftKey ? 20 : 2;
4510
+ const deltas = { ArrowLeft: -step, ArrowRight: step };
4511
+ const d = deltas[e.key];
4512
+ if (d == null) return;
4513
+ e.preventDefault();
4514
+ nudgeHsv({ h: Math.min(360, Math.max(0, hsvRef.current.h + d)) });
4515
+ };
4126
4516
  const eyedrop = async () => {
4127
4517
  try {
4128
4518
  // EyeDropper is Chromium-only; guarded.
@@ -4138,15 +4528,43 @@ function ColorPicker({ seed, onApply }) {
4138
4528
  }
4139
4529
  };
4140
4530
 
4531
+ // handoff — RGB numeric fields (design parity). Editing one re-derives hsv.
4532
+ const rgb = hsvToRgb(hsv);
4533
+ const setRgb = (patch) => {
4534
+ const next = { ...rgb, ...patch };
4535
+ const h = rgbToHsv({ r: clamp01(next.r / 255) * 255, g: clamp01(next.g / 255) * 255, b: clamp01(next.b / 255) * 255 });
4536
+ setHsv(h);
4537
+ onApply(rgbToHex(hsvToRgb(h)));
4538
+ };
4141
4539
  return (
4142
4540
  <div className="st-cp-cpick">
4541
+ {/* hex + swatch on top (design), then the SV pad, controls, RGB */}
4542
+ <div className="st-cp-cpick-hexrow">
4543
+ <span className="st-cp-cpick-hexsw" style={{ background: hex }} />
4544
+ <input
4545
+ className="st-cp-cpick-hex"
4546
+ type="text"
4547
+ value={hex}
4548
+ aria-label={label ? `${label} hex value` : 'hex value'}
4549
+ onChange={(e) => {
4550
+ const v = e.target.value;
4551
+ if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
4552
+ }}
4553
+ onKeyDown={(e) => {
4554
+ if (e.key === 'Enter') onApply(e.currentTarget.value);
4555
+ }}
4556
+ onBlur={(e) => onApply(e.currentTarget.value)}
4557
+ onFocus={(e) => e.currentTarget.select()}
4558
+ />
4559
+ </div>
4143
4560
  <button
4144
4561
  type="button"
4145
4562
  ref={svRef}
4146
4563
  className="st-cp-cpick-sv"
4147
- aria-label="saturation and value"
4564
+ aria-label={label ? `${label} saturation and value` : 'saturation and value'}
4148
4565
  style={{ background: `hsl(${hsv.h} 100% 50%)` }}
4149
4566
  onPointerDown={dragSV}
4567
+ onKeyDown={onSvKeyDown}
4150
4568
  >
4151
4569
  <span className="st-cp-cpick-svwhite" />
4152
4570
  <span className="st-cp-cpick-svblack" />
@@ -4167,30 +4585,31 @@ function ColorPicker({ seed, onApply }) {
4167
4585
  <StIcon name="eyedropper" size={14} />
4168
4586
  </button>
4169
4587
  ) : null}
4588
+ <span className="st-cp-cpick-preview" style={{ background: hex }} aria-hidden="true" />
4170
4589
  <button
4171
4590
  type="button"
4172
4591
  ref={hueRef}
4173
4592
  className="st-cp-cpick-hue"
4174
- aria-label="hue"
4593
+ aria-label={label ? `${label} hue` : 'hue'}
4175
4594
  onPointerDown={dragHue}
4595
+ onKeyDown={onHueKeyDown}
4176
4596
  >
4177
4597
  <span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
4178
4598
  </button>
4179
4599
  </div>
4180
- <input
4181
- className="st-cp-fin"
4182
- type="text"
4183
- value={hex}
4184
- aria-label="hex value"
4185
- onChange={(e) => {
4186
- const v = e.target.value;
4187
- if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
4188
- }}
4189
- onKeyDown={(e) => {
4190
- if (e.key === 'Enter') onApply(e.currentTarget.value);
4191
- }}
4192
- onBlur={(e) => onApply(e.currentTarget.value)}
4193
- />
4600
+ <div className="st-cp-cpick-rgb">
4601
+ {['r', 'g', 'b'].map((k) => (
4602
+ <label key={k} className="st-cp-cpick-rgbf">
4603
+ <input
4604
+ aria-label={k.toUpperCase()}
4605
+ value={Math.round(rgb[k])}
4606
+ onChange={(e) => setRgb({ [k]: clamp01((Number.parseFloat(e.target.value) || 0) / 255) * 255 })}
4607
+ onFocus={(e) => e.currentTarget.select()}
4608
+ />
4609
+ <span>{k.toUpperCase()}</span>
4610
+ </label>
4611
+ ))}
4612
+ </div>
4194
4613
  </div>
4195
4614
  );
4196
4615
  }
@@ -4200,7 +4619,7 @@ function ColorPicker({ seed, onApply }) {
4200
4619
  // `kind='value'` a variable list (pretty name + resolved value, à la Figma's
4201
4620
  // variable picker). Picking commits `var(--token)`. Portals to <body> +
4202
4621
  // fixed-positions from the trigger rect so the panel's overflow never clips it.
4203
- function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
4622
+ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs, swatchClassName }) {
4204
4623
  const [open, setOpen] = useState(false);
4205
4624
  const [pos, setPos] = useState(null);
4206
4625
  // Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
@@ -4256,26 +4675,48 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4256
4675
  const onKey = (e) => {
4257
4676
  if (e.key === 'Escape') setOpen(false);
4258
4677
  };
4259
- const dismiss = () => setOpen(false);
4260
- // The popover is fixed-positioned from the trigger rect, so a scroll of the
4261
- // PANEL detaches it dismiss. But scrolling INSIDE the popover (its own
4262
- // overflow list) must NOT close it (the user couldn't scroll the variables).
4678
+ // Task 6 (feature-inspector-controls-redesign) RE-ANCHOR on scroll/resize
4679
+ // instead of dismissing. `place()` computes viewport-relative coordinates
4680
+ // from `getBoundingClientRect()`, but this fixed-positioned popover's actual
4681
+ // containing block is whichever ANCESTOR (if any) has a transform/filter/
4682
+ // will-change — e.g. the right panel's mount-in `st-panel-in` transform —
4683
+ // not necessarily the viewport. Re-running place() keeps it glued to the
4684
+ // trigger through any layout change instead of vanishing on the first
4685
+ // scroll (the old dismiss-on-scroll workaround for the same root cause).
4263
4686
  const onScroll = (e) => {
4264
4687
  if (popRef.current?.contains(e.target)) return;
4265
- setOpen(false);
4688
+ place();
4266
4689
  };
4267
4690
  document.addEventListener('pointerdown', onDoc, true);
4268
4691
  document.addEventListener('keydown', onKey);
4269
- window.addEventListener('resize', dismiss);
4692
+ window.addEventListener('resize', place);
4270
4693
  document.addEventListener('scroll', onScroll, true);
4271
4694
  return () => {
4272
4695
  document.removeEventListener('pointerdown', onDoc, true);
4273
4696
  document.removeEventListener('keydown', onKey);
4274
- window.removeEventListener('resize', dismiss);
4697
+ window.removeEventListener('resize', place);
4275
4698
  document.removeEventListener('scroll', onScroll, true);
4276
4699
  };
4277
4700
  }, [open]);
4278
4701
 
4702
+ // Transform-ancestor compensation — regardless of WHICH ancestor ends up
4703
+ // establishing this fixed popover's containing block, measure where it
4704
+ // actually landed vs where `place()` intended (viewport-relative) and cancel
4705
+ // out the delta. This is correct independent of the cause, so it doesn't
4706
+ // require hunting down every transform/filter/will-change that could ever
4707
+ // apply between `document.body` (the portal target) and this element.
4708
+ // Converges in at most one correction: once the delta is compensated, the
4709
+ // measured rect matches the intended position and the effect is a no-op.
4710
+ useEffect(() => {
4711
+ if (!open || !pos || !popRef.current) return;
4712
+ const r = popRef.current.getBoundingClientRect();
4713
+ const dx = pos.left - r.left;
4714
+ const dy = pos.top - r.top;
4715
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
4716
+ setPos((p) => (p ? { ...p, left: p.left + dx, top: p.top + dy } : p));
4717
+ }
4718
+ }, [open, pos]);
4719
+
4279
4720
  // Pick a token. #3 — apply CORRECTLY across design systems: a token from the
4280
4721
  // canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
4281
4722
  // a token from ANOTHER DS commits its RESOLVED value (literal), because
@@ -4373,7 +4814,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4373
4814
  <button
4374
4815
  type="button"
4375
4816
  ref={btnRef}
4376
- className={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
4817
+ className={`${swatchClassName || 'st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger'}${bound && !swatchClassName ? ' is-bound' : ''}`}
4377
4818
  aria-haspopup="dialog"
4378
4819
  aria-expanded={open}
4379
4820
  aria-label={label || 'pick a colour'}
@@ -4467,7 +4908,7 @@ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex,
4467
4908
  );
4468
4909
  }
4469
4910
 
4470
- function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo }) {
4911
+ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoRedo, mode, onSetMode }) {
4471
4912
  const editable = !!el.id;
4472
4913
  const computed = el.computed || {};
4473
4914
  // Phase 12.3 — optimistic local overlay over the selection's authored / custom
@@ -4519,7 +4960,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4519
4960
  Appearance: true,
4520
4961
  Advanced: false,
4521
4962
  });
4522
- const [split, setSplit] = useState(false);
4523
4963
 
4524
4964
  // Phase 12.3 — auto-expand Advanced when the selected element carries custom
4525
4965
  // CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
@@ -4530,6 +4970,28 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4530
4970
  useEffect(() => {
4531
4971
  if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
4532
4972
  }, [el.id, hasCustom]);
4973
+ // DDR-171 — Designer mode's Position cluster mirrors the same auto-expand
4974
+ // precedent: collapsed by default (position is the rare case), auto-opens
4975
+ // the moment the element actually has a non-static position so the inset
4976
+ // fields the user just set (or that came from the source) aren't hidden.
4977
+ const hasCustomPosition = (authored.position || cssHint(computed.position) || 'static') !== 'static';
4978
+ useEffect(() => {
4979
+ if (hasCustomPosition) setOpen((o) => (o['d:Position'] ? o : { ...o, 'd:Position': true }));
4980
+ }, [el.id, hasCustomPosition]);
4981
+
4982
+ // DDR-171 — the panel's vocabulary mode: 'advanced' (today's raw-CSS panel,
4983
+ // unchanged, still the default — zero behavior change for existing users) or
4984
+ // 'designer' (the Figma-vocabulary regroup). Lifted to App state (DDR-171
4985
+ // follow-up) so the same preference is controllable from BOTH the in-panel
4986
+ // corner toggle AND Settings → Appearance, and the two never diverge — the
4987
+ // exact single-source-of-truth pattern `theme` uses. App owns the
4988
+ // `maude-cp-mode` localStorage persistence; here it's a controlled prop.
4989
+ const setMode = onSetMode;
4990
+ // Designer-mode per-cluster "···" disclosure state (Auto layout/Wrap,
4991
+ // Size/Min-Max, Position/z-index, Text/extras) — keyed by cluster name, kept
4992
+ // in-memory only (unlike `open`, not persisted; matches the disclosure being
4993
+ // a "peek", not a durable preference).
4994
+ const [designerMore, setDesignerMore] = useState({});
4533
4995
 
4534
4996
  async function post(url, payload, key) {
4535
4997
  setStatus((s) => ({ ...s, [key]: 'saving' }));
@@ -4647,30 +5109,50 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4647
5109
  const sizeModeSeg = (axis) => {
4648
5110
  const cur = sizingModeOf(axis, authored, computed, parentLayout);
4649
5111
  return (
4650
- <div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
4651
- <span className="st-cp-modeax" aria-hidden="true">
4652
- {axis === 'width' ? 'W' : 'H'}
4653
- </span>
4654
- {[
4655
- ['fixed', 'Fixed'],
4656
- ['hug', 'Hug'],
4657
- ['fill', 'Fill'],
4658
- ].map(([m, label]) => (
4659
- <button
4660
- key={m}
4661
- type="button"
4662
- className={`st-cp-modebtn${cur === m ? ' is-active' : ''}`}
4663
- aria-pressed={cur === m}
4664
- disabled={!editable}
4665
- onClick={() => applySizing(axis, m)}
4666
- title={`${label} ${axis}`}
4667
- >
4668
- {label}
4669
- </button>
4670
- ))}
5112
+ // handoff — a proper inspector row: "Width sizing" / "Height sizing" label
5113
+ // in the label column, the shared Segmented right-aligned in the control
5114
+ // column (aligned with the number inputs below), input-matching height.
5115
+ <div className="st-cp-moderow" key={`mode-${axis}`}>
5116
+ <span className="st-cp-modelabel">{axis === 'width' ? 'Width sizing' : 'Height sizing'}</span>
5117
+ <div className="st-cp-modeseg" role="group" aria-label={`${axis} sizing mode`}>
5118
+ <Segmented
5119
+ value={cur}
5120
+ ariaLabel={`${axis} sizing`}
5121
+ options={[{ value: 'fixed', label: 'fixed' }, { value: 'hug', label: 'hug' }, { value: 'fill', label: 'fill' }]}
5122
+ onChange={(m) => applySizing(axis, m)}
5123
+ />
5124
+ </div>
4671
5125
  </div>
4672
5126
  );
4673
5127
  };
5128
+ // DDR-171 — Designer mode's Auto-layout alignment: `AlignPad`'s 9-cell grid
5129
+ // maps to the TWO real CSS axes (`justify-content` = main axis,
5130
+ // `align-items` = cross axis), which axis is "horizontal" vs "vertical"
5131
+ // depending on `flex-direction`. Only the 3 positional align-items values
5132
+ // (start/center/end) round-trip through the pad — `stretch` reads as the
5133
+ // pad's center cell (closest visual analog) but the pad never WRITES
5134
+ // `stretch`; that stays an Advanced-mode-only value, same as Figma's own
5135
+ // alignment pad (no "stretch" cell — it's a separate control there too).
5136
+ const AP_JC = ['flex-start', 'center', 'flex-end'];
5137
+ const AP_AI = ['flex-start', 'center', 'flex-end'];
5138
+ const AP_ROWS = ['t', 'c', 'b'];
5139
+ const AP_COLS = ['l', 'c', 'r'];
5140
+ const alignPadCell = () => {
5141
+ const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
5142
+ const jcPos = Math.max(0, AP_JC.indexOf(authored['justify-content'] || cssHint(computed['justify-content']) || 'flex-start'));
5143
+ const aiRaw = authored['align-items'] || cssHint(computed['align-items']) || 'stretch';
5144
+ const aiPos = aiRaw === 'stretch' ? 1 : Math.max(0, AP_AI.indexOf(aiRaw));
5145
+ const [h, v] = isRow ? [jcPos, aiPos] : [aiPos, jcPos];
5146
+ return AP_ROWS[v] + AP_COLS[h];
5147
+ };
5148
+ const setAlignPadCell = (cell) => {
5149
+ const v = AP_ROWS.indexOf(cell[0]);
5150
+ const h = AP_COLS.indexOf(cell[1]);
5151
+ const isRow = !(authored['flex-direction'] || cssHint(computed['flex-direction']) || 'row').startsWith('column');
5152
+ const [jcPos, aiPos] = isRow ? [h, v] : [v, h];
5153
+ commit('justify-content', AP_JC[jcPos]);
5154
+ commit('align-items', AP_AI[aiPos]);
5155
+ };
4674
5156
  // Cmd+Z / Cmd+Shift+Z (or Cmd+Y) inside the inspector forwards to the canvas
4675
5157
  // undo stack — Figma-parity: a property field reverts the last DOCUMENT edit,
4676
5158
  // not field text. Without this, an edit committed with focus still in the
@@ -4686,69 +5168,6 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4686
5168
  onUndoRedo?.('redo');
4687
5169
  }
4688
5170
  };
4689
- // Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
4690
- // change its value. Live preview via optimistic apply on every move (no source
4691
- // write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
4692
- // is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
4693
- // ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
4694
- // alt+shift = all four (else just this side). `opts.min` clamps (default 0).
4695
- const makeScrub = (prop, opts = {}) => (e) => {
4696
- if (e.button !== 0) return;
4697
- const input = e.currentTarget;
4698
- const startX = e.clientX;
4699
- const baseN =
4700
- Number.parseFloat(
4701
- cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
4702
- ) || 0;
4703
- const unit = opts.unitless
4704
- ? ''
4705
- : opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
4706
- const min = opts.min ?? 0;
4707
- const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
4708
- const sidesFor = (ev) => {
4709
- if (!opts.sides) return [prop];
4710
- if (ev.altKey && ev.shiftKey) return opts.sides.all;
4711
- if (ev.altKey) return opts.sides.pair;
4712
- return [prop];
4713
- };
4714
- let scrubbing = false;
4715
- let last = baseN;
4716
- const move = (ev) => {
4717
- const dx = ev.clientX - startX;
4718
- if (!scrubbing && Math.abs(dx) < 3) return;
4719
- if (!scrubbing) {
4720
- scrubbing = true;
4721
- document.body.classList.add('st-scrubbing');
4722
- }
4723
- ev.preventDefault();
4724
- const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
4725
- last = Math.round((baseN + dx * granular) * 100) / 100;
4726
- if (last < min) last = min;
4727
- const sides = sidesFor(ev);
4728
- // Live-update the dragged field AND, for a box-model multi-side scrub, the
4729
- // sibling box inputs so the whole pair / four-up move shows in the panel —
4730
- // not just the one being dragged (W2.2 feedback).
4731
- if (input) input.value = String(last);
4732
- if (opts.sides && sides.length > 1) {
4733
- const box = input?.closest('.st-cp-box');
4734
- for (const p of sides) {
4735
- if (p === prop) continue;
4736
- const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
4737
- if (sib) sib.value = String(last);
4738
- }
4739
- }
4740
- for (const p of sides) optimistic(p, fmt(last));
4741
- };
4742
- const up = (ev) => {
4743
- document.removeEventListener('pointermove', move);
4744
- document.removeEventListener('pointerup', up);
4745
- if (!scrubbing) return;
4746
- document.body.classList.remove('st-scrubbing');
4747
- for (const p of sidesFor(ev)) commit(p, fmt(last));
4748
- };
4749
- document.addEventListener('pointermove', move);
4750
- document.addEventListener('pointerup', up);
4751
- };
4752
5171
  const provOf = (prop) => {
4753
5172
  const v = authored[prop];
4754
5173
  if (!v) return 'inherit';
@@ -4811,16 +5230,21 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4811
5230
  );
4812
5231
  };
4813
5232
 
4814
- const row = (prop, control, provKind) => {
5233
+ // `labelOverride` (DDR-171 Designer mode) swaps BOTH the visible label text
5234
+ // AND the title tooltip together, so they never drift out of sync. Optional
5235
+ // and additive — every Advanced-mode call site omits it and renders exactly
5236
+ // as before (label = title = the raw CSS property name).
5237
+ const row = (prop, control, provKind, labelOverride) => {
4815
5238
  // #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
4816
5239
  // the handful of overridden rows pop (Webflow/Framer model). Composite rows
4817
5240
  // (border — they pass an explicit provKind) are never dimmed.
4818
5241
  const unset = provKind === undefined && !authored[prop];
5242
+ const label = labelOverride ?? prop;
4819
5243
  return (
4820
5244
  <div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
4821
5245
  {provDot(prop, provKind)}
4822
- <label className="st-cp-label" title={prop}>
4823
- {prop}
5246
+ <label className="st-cp-label" title={label}>
5247
+ {label}
4824
5248
  </label>
4825
5249
  <div className="st-cp-ctl">{control}</div>
4826
5250
  </div>
@@ -4882,6 +5306,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4882
5306
  'border-style',
4883
5307
  'border-color',
4884
5308
  'box-shadow',
5309
+ 'filter',
5310
+ 'mix-blend-mode',
4885
5311
  'opacity',
4886
5312
  'transform',
4887
5313
  'transform-origin',
@@ -4909,22 +5335,86 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4909
5335
  </span>
4910
5336
  {name}
4911
5337
  </button>
4912
- {dirty ? (
4913
- <button
4914
- type="button"
4915
- className="st-cp-secreset"
4916
- aria-label={`reset ${name} section to original`}
4917
- title={`reset ${name}`}
4918
- onClick={() => resetSection(name)}
4919
- >
4920
-
4921
- </button>
4922
- ) : null}
5338
+ {/* handoff — reset always present (design), dimmed when nothing to reset. */}
5339
+ <button
5340
+ type="button"
5341
+ className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
5342
+ aria-label={`reset ${name} section to original`}
5343
+ title={`reset ${name}`}
5344
+ disabled={!dirty}
5345
+ onClick={() => resetSection(name)}
5346
+ >
5347
+ <Lu as={LuRotateCw} size={12} />
5348
+ </button>
5349
+ </div>
5350
+ {/* animated collapse — grid-rows 0fr→1fr keeps the DOM + interpolates
5351
+ height (feature-inspector-controls-redesign handoff). */}
5352
+ <div className={`st-cp-sec-anim${open[name] ? ' is-open' : ''}`}>
5353
+ <div className="st-cp-sec-inner">{body}</div>
5354
+ </div>
5355
+ </section>
5356
+ );
5357
+ };
5358
+
5359
+ // DDR-171 — Designer mode's cluster wrapper. Mirrors `sec()`'s exact chrome
5360
+ // (caret, reset, animated collapse — reuses `.st-cp-sec`/`.st-cp-sechd`/
5361
+ // `.st-cp-sec-anim` verbatim, no new CSS shape) but takes an explicit
5362
+ // `props` reset-list instead of looking one up in `SECTION_PROPS`, because
5363
+ // Designer clusters cut across Advanced-mode section boundaries and don't
5364
+ // map 1:1 onto it (e.g. "Auto layout" pulls from both Layout and Size).
5365
+ // Open-state lives in the SAME `open` object under a `d:`-prefixed key so no
5366
+ // second piece of state is needed; `defaultOpen=false` is how the Position
5367
+ // cluster starts collapsed (see `hasCustomPosition` auto-expand above).
5368
+ const dsec = (name, props, body, defaultOpen = true) => {
5369
+ const key = `d:${name}`;
5370
+ const isOpen = open[key] === undefined ? defaultOpen : open[key];
5371
+ const dirty = props.some((p) => authored[p]);
5372
+ return (
5373
+ <section className="st-cp-sec" key={key}>
5374
+ <div className="st-cp-sechd-row">
5375
+ <button
5376
+ type="button"
5377
+ className="st-cp-sechd"
5378
+ aria-expanded={isOpen}
5379
+ onClick={() => setOpen((o) => ({ ...o, [key]: !isOpen }))}
5380
+ >
5381
+ <span className="st-cp-caret" aria-hidden="true">
5382
+ {isOpen ? '▾' : '▸'}
5383
+ </span>
5384
+ {name}
5385
+ </button>
5386
+ <button
5387
+ type="button"
5388
+ className={`st-cp-secreset${dirty ? '' : ' is-quiet'}`}
5389
+ aria-label={`reset ${name} to original`}
5390
+ title={`reset ${name}`}
5391
+ disabled={!dirty}
5392
+ onClick={() => props.forEach((p) => { if (authored[p]) reset(p); })}
5393
+ >
5394
+ <Lu as={LuRotateCw} size={12} />
5395
+ </button>
5396
+ </div>
5397
+ <div className={`st-cp-sec-anim${isOpen ? ' is-open' : ''}`}>
5398
+ <div className="st-cp-sec-inner">{body}</div>
4923
5399
  </div>
4924
- {open[name] ? body : null}
4925
5400
  </section>
4926
5401
  );
4927
5402
  };
5403
+ // A cluster's "···" disclosure toggle for its less-common rows (Figma's own
5404
+ // per-panel overflow affordance) — distinct from the cluster's own
5405
+ // open/collapse caret above. `designerMore[key]` gates the extra rows.
5406
+ const moreBtn = (key) => (
5407
+ <button
5408
+ type="button"
5409
+ className="st-cp-clustermore"
5410
+ aria-expanded={!!designerMore[key]}
5411
+ aria-label={designerMore[key] ? `${key} — fewer options` : `${key} — more options`}
5412
+ title={designerMore[key] ? 'fewer' : 'more'}
5413
+ onClick={() => setDesignerMore((m) => ({ ...m, [key]: !m[key] }))}
5414
+ >
5415
+ {designerMore[key] ? '▴' : '···'}
5416
+ </button>
5417
+ );
4928
5418
 
4929
5419
  // native <select> committing a CSS value directly
4930
5420
  const csel = (prop, list) => (
@@ -4945,6 +5435,165 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4945
5435
  </select>
4946
5436
  );
4947
5437
 
5438
+ // ── feature-inspector-controls-redesign handoff — the design's control set,
5439
+ // wired to CssKnobs' CSS-string commit lane. ────────────────────────────────
5440
+ const flatTokens = (familyKey) => tokenGroups(familyKey).flatMap((g) => (g.names || []).map((n) => ({ name: n, value: g.vals?.[n] || '' })));
5441
+ // enum → lucide icon button group (commits the raw CSS keyword)
5442
+ const iconseg = (prop, options) => (
5443
+ <IconButtonGroup value={authored[prop] || cssHint(computed[prop]) || options[0].value} ariaLabel={prop} options={options} onChange={(v) => commit(prop, v)} />
5444
+ );
5445
+ // number + unit + ◇ design-token binding (space / radius / type families)
5446
+ const vtok = (prop, familyKey, opts = {}) => {
5447
+ const cur = cssSplitUnit(authored[prop] ?? '');
5448
+ const unitless = CSS_UNITLESS.has(prop);
5449
+ const av = authored[prop] ?? '';
5450
+ const bound = typeof av === 'string' && /var\(\s*--/.test(av);
5451
+ const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
5452
+ const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
5453
+ const numVal = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
5454
+ const lead = PROP_LEAD[prop];
5455
+ return (
5456
+ <ValueTokenField
5457
+ value={bound ? av : numVal}
5458
+ tokens={flatTokens(familyKey)}
5459
+ ariaLabel={prop}
5460
+ min={opts.min ?? 0}
5461
+ lead={lead ? (lead.node ?? lead.t) : undefined}
5462
+ unitSlot={unitless ? null : <UnitSelect units={CSS_UNITS} value={cur.unit || 'px'} ariaLabel={`${prop} unit`} onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)} />}
5463
+ onChange={(v) => commit(prop, typeof v === 'string' ? v : unitless ? `${v}` : `${v}${unit}`)}
5464
+ />
5465
+ );
5466
+ };
5467
+ // border-radius → uniform field + ▢ detach → 2×2 corner quad
5468
+ const radiusControl = () => {
5469
+ const parse = (p) => Number.parseFloat(cssSplitUnit(authored[p] ?? authored['border-radius'] ?? cssHint(computed[p]) ?? cssHint(computed['border-radius']) ?? '0').n) || 0;
5470
+ const corners = { tl: parse('border-top-left-radius'), tr: parse('border-top-right-radius'), bl: parse('border-bottom-left-radius'), br: parse('border-bottom-right-radius') };
5471
+ const onCorners = (c) => {
5472
+ const uniform = c.tl === c.tr && c.tr === c.bl && c.bl === c.br;
5473
+ if (uniform) {
5474
+ ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'].forEach((p) => { if (authored[p]) reset(p); });
5475
+ commit('border-radius', `${c.tl}px`);
5476
+ } else {
5477
+ commit('border-top-left-radius', `${c.tl}px`);
5478
+ commit('border-top-right-radius', `${c.tr}px`);
5479
+ commit('border-bottom-left-radius', `${c.bl}px`);
5480
+ commit('border-bottom-right-radius', `${c.br}px`);
5481
+ }
5482
+ };
5483
+ const lead = PROP_LEAD['border-radius'];
5484
+ return <RadiusControl corners={corners} lead={lead ? (lead.node ?? lead.t) : undefined} onCorners={onCorners} />;
5485
+ };
5486
+ // border composite (width + style + colour) — DDR-171 pulled this out of the
5487
+ // Advanced-mode Appearance row inline JSX so Designer mode's "Stroke"
5488
+ // cluster (Task 6) can reuse the exact same control, not a re-implementation.
5489
+ const borderControl = () => (
5490
+ <div className="st-cp-border">
5491
+ {num('border-width', null, { fixedUnit: 'px' })}
5492
+ <select
5493
+ className="st-cp-nsel st-cp-nsel--mini"
5494
+ aria-label="border-style"
5495
+ value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
5496
+ onChange={(e) => commit('border-style', e.target.value)}
5497
+ >
5498
+ <option value="" disabled>
5499
+ style
5500
+ </option>
5501
+ {CSS_BORDER_STYLES.map((s) => (
5502
+ <option key={s} value={s}>
5503
+ {s}
5504
+ </option>
5505
+ ))}
5506
+ </select>
5507
+ <TokenPopover
5508
+ kind="color"
5509
+ groups={tokenGroups('color')}
5510
+ current={authored['border-color']}
5511
+ activeDs={_activeDs}
5512
+ swatchBg={computed['border-color'] || authored['border-color'] || ''}
5513
+ seedHex={cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'}
5514
+ onPick={(v) => commit('border-color', v)}
5515
+ label="border colour"
5516
+ />
5517
+ </div>
5518
+ );
5519
+ // rotation dial, reading/writing the rotate() term of `transform`
5520
+ const rotationControl = () => {
5521
+ const t = authored.transform || cssHint(computed.transform) || '';
5522
+ const m = /rotate\(\s*(-?\d+(?:\.\d+)?)deg\s*\)/.exec(t);
5523
+ const deg = (((m ? Number.parseFloat(m[1]) : 0) % 360) + 360) % 360;
5524
+ const setDeg = (d) => {
5525
+ const norm = ((d % 360) + 360) % 360;
5526
+ const base = (authored.transform || '').replace(/\s*rotate\([^)]*\)\s*/g, ' ').trim();
5527
+ commit('transform', `${base ? `${base} ` : ''}rotate(${norm}deg)`.trim());
5528
+ };
5529
+ return (
5530
+ <div className="st-cp-num" style={{ border: 0, background: 'transparent', gap: 'var(--space-2)' }}>
5531
+ <AngleDial value={deg} onChange={setDeg} />
5532
+ <NumberField value={deg} min={0} max={360} ariaLabel="rotation" lead={<Lu as={LuRotateCw} />} steppers={false} unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">°</span>} onCommit={setDeg} />
5533
+ </div>
5534
+ );
5535
+ };
5536
+ // DDR-171 — `filter: blur(Npx)`, scoped to blur-only for v1 (not a full
5537
+ // filter-function editor). Local parse/serialize (unlike most rows this
5538
+ // doesn't pass the raw string straight to `commit`) — kept as a closure here,
5539
+ // not a top-level utility, so it stays governed by the "no new primitives"
5540
+ // constraint. A non-blur `filter` value (set via Advanced's raw-CSS hatch)
5541
+ // reads as 0 here rather than being clobbered — only written back once the
5542
+ // user actually commits a blur amount.
5543
+ const blurControl = () => {
5544
+ const f = authored.filter || cssHint(computed.filter) || '';
5545
+ const m = /blur\(\s*(-?\d+(?:\.\d+)?)px\s*\)/.exec(f);
5546
+ const px = m ? Number.parseFloat(m[1]) : 0;
5547
+ const setBlur = (n) => {
5548
+ const base = (authored.filter || '').replace(/\s*blur\([^)]*\)\s*/g, ' ').trim();
5549
+ commit('filter', n > 0 ? `${base ? `${base} ` : ''}blur(${n}px)`.trim() : base || 'none');
5550
+ };
5551
+ return <NumberField value={px} min={0} ariaLabel="filter blur" unitSlot={<span className="st-cp-numsuffix" aria-hidden="true">px</span>} onCommit={setBlur} />;
5552
+ };
5553
+ // handoff — shown as 0–100 % (design), stored as the CSS 0–1 value. Pulled
5554
+ // out (DDR-171) so Designer mode's "Opacity" cluster reuses it verbatim.
5555
+ const opacityControl = () => {
5556
+ const a = authored.opacity;
5557
+ const raw = a != null && a !== '' ? Number.parseFloat(a) : Number.parseFloat(cssHint(computed.opacity)) || 1;
5558
+ const pct = Math.round((Number.isNaN(raw) ? 1 : raw) * 100);
5559
+ return (
5560
+ <SliderField
5561
+ key={`opacity:${a ?? ''}`}
5562
+ value={pct}
5563
+ min={0}
5564
+ max={100}
5565
+ step={1}
5566
+ unit="%"
5567
+ ariaLabel="opacity"
5568
+ onInput={(n) => optimistic('opacity', String(n / 100))}
5569
+ onCommit={(n) => commit('opacity', String(n / 100))}
5570
+ />
5571
+ );
5572
+ };
5573
+ // B / I / U quick-style toggle group → font-weight / font-style / text-decoration
5574
+ const textStyleToggle = () => {
5575
+ const isBold = Number.parseInt(authored['font-weight'] || cssHint(computed['font-weight']) || '400', 10) >= 600;
5576
+ const isItalic = (authored['font-style'] || cssHint(computed['font-style'])) === 'italic';
5577
+ const isUnder = /underline/.test(authored['text-decoration'] || cssHint(computed['text-decoration']) || '');
5578
+ return (
5579
+ <IconToggleGroup
5580
+ value={{ b: isBold, i: isItalic, u: isUnder }}
5581
+ ariaLabel="text style"
5582
+ options={[{ value: 'b', node: <Lu as={LuBold} />, label: 'Bold' }, { value: 'i', node: <Lu as={LuItalic} />, label: 'Italic' }, { value: 'u', node: <Lu as={LuUnderline} />, label: 'Underline' }]}
5583
+ onToggle={(k) => {
5584
+ if (k === 'b') commit('font-weight', isBold ? '400' : '700');
5585
+ else if (k === 'i') commit('font-style', isItalic ? 'normal' : 'italic');
5586
+ else commit('text-decoration', isUnder ? 'none' : 'underline');
5587
+ }}
5588
+ />
5589
+ );
5590
+ };
5591
+ const DIR_OPTS = [{ value: 'row', node: <Lu as={LuColumns3} />, label: 'Row' }, { value: 'column', node: <Lu as={LuRows3} />, label: 'Column' }];
5592
+ const JUSTIFY_OPTS = [{ value: 'flex-start', node: <Lu as={LuJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuJustifyEnd} />, label: 'End' }, { value: 'space-between', node: <Lu as={LuSpaceBetween} />, label: 'Space between' }];
5593
+ const ALIGNITEMS_OPTS = [{ value: 'flex-start', node: <Lu as={LuVJustifyStart} />, label: 'Start' }, { value: 'center', node: <Lu as={LuVJustifyCenter} />, label: 'Center' }, { value: 'flex-end', node: <Lu as={LuVJustifyEnd} />, label: 'End' }, { value: 'stretch', node: <Lu as={LuStretch} />, label: 'Stretch' }];
5594
+ const TEXTALIGN_OPTS = [{ value: 'left', node: <Lu as={LuAlignLeft} />, label: 'Left' }, { value: 'center', node: <Lu as={LuAlignCenter} />, label: 'Center' }, { value: 'right', node: <Lu as={LuAlignRight} />, label: 'Right' }, { value: 'justify', node: <Lu as={LuAlignJustify} />, label: 'Justify' }];
5595
+ const OVERFLOW_OPTS = [{ value: 'visible', node: <Lu as={LuEye} />, label: 'Visible' }, { value: 'hidden', node: <Lu as={LuScissors} />, label: 'Hidden' }, { value: 'scroll', node: <Lu as={LuScrollText} />, label: 'Scroll' }];
5596
+
4948
5597
  // token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
4949
5598
  // this property (name + resolved value), grouped per design system (W3);
4950
5599
  // picking writes var(--token). `familyKey` selects the token family.
@@ -4962,6 +5611,31 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4962
5611
  ) : null;
4963
5612
  };
4964
5613
 
5614
+ // Select-all-on-focus (deferred past the browser's own click-caret placement
5615
+ // so a SECOND click, already focused, places the caret instead) — the
5616
+ // interaction-model rule applied to the CssKnobs fields that stay bespoke
5617
+ // <input>s (free text, box-model cells) rather than the shared NumberField.
5618
+ const selectAllOnFocus = (e) => {
5619
+ const el = e.currentTarget;
5620
+ requestAnimationFrame(() => {
5621
+ if (document.activeElement === el) el.select();
5622
+ });
5623
+ };
5624
+ // Arrow-key stepping (±1, Shift ×10) for the bespoke box-model cells — same
5625
+ // keyboard model NumberField gives the main fields, hand-rolled here because
5626
+ // these stay compact <input>s (no room for NumberField's handle+stepper
5627
+ // chrome in a 36×24 box-model cell). Returns true if it handled the key.
5628
+ const stepBoxInput = (e, commitFn) => {
5629
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return false;
5630
+ e.preventDefault();
5631
+ const mult = e.shiftKey ? 10 : 1;
5632
+ const dir = e.key === 'ArrowUp' ? 1 : -1;
5633
+ const n = (Number.parseFloat(e.currentTarget.value) || 0) + dir * mult;
5634
+ e.currentTarget.value = String(n);
5635
+ commitFn(n);
5636
+ return true;
5637
+ };
5638
+
4965
5639
  // free text input — raw value or var(--token), commits on blur/Enter
4966
5640
  const text = (prop) => (
4967
5641
  <input
@@ -4970,6 +5644,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4970
5644
  aria-label={prop}
4971
5645
  defaultValue={authored[prop] ?? ''}
4972
5646
  placeholder={cssHint(computed[prop]) || '—'}
5647
+ onFocus={selectAllOnFocus}
4973
5648
  onKeyDown={(e) => {
4974
5649
  if (e.key === 'Enter') e.currentTarget.blur();
4975
5650
  }}
@@ -4977,79 +5652,46 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
4977
5652
  />
4978
5653
  );
4979
5654
 
4980
- // number + steppers + unit-select (+ optional token quick-pick after)
5655
+ // number + steppers + unit-select (+ optional token quick-pick after) — built
5656
+ // on the shared NumberField (feature-inspector-controls-redesign): the drag
5657
+ // handle moves to the leading icon/grip (never the input body, so click-to-
5658
+ // type + select-all-on-focus work), and arrow-key stepping comes for free.
4981
5659
  const num = (prop, tokenList, opts = {}) => {
4982
5660
  const cur = cssSplitUnit(authored[prop] ?? '');
4983
5661
  // Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
4984
5662
  // (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
4985
5663
  const unitless = CSS_UNITLESS.has(prop);
4986
- const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
4987
- const bump = (d) => {
4988
- const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
4989
- commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
4990
- };
5664
+ // `opts.fixedUnit` a px-only field (border-width) skips the unit <select>
5665
+ // entirely so the compact border-cluster row (width + style + swatch) has
5666
+ // room to fit at the panel's 260-304px widths (Task 5 overflow fix).
5667
+ const unit = unitless ? '' : opts.fixedUnit || (cur.unit && cur.unit !== 'auto' ? cur.unit : 'px');
4991
5668
  const lead = PROP_LEAD[prop];
5669
+ // Unset (no authored value) shows the computed/inherited value as the
5670
+ // starting number — same value the old placeholder hinted at; the row's own
5671
+ // dimming (`row()`'s `is-unset`) is what signals "inherited", not this field.
5672
+ const hintN = Number.parseFloat(cssSplitUnit(cssHint(computed[prop]) ?? '').n) || 0;
5673
+ const shownN = cur.n !== '' && cur.n != null ? Number.parseFloat(cur.n) || 0 : hintN;
4992
5674
  return (
4993
5675
  <>
4994
- <div className="st-cp-num">
4995
- {lead ? (
4996
- <span className="st-cp-numlead" aria-hidden="true">
4997
- {lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
4998
- </span>
4999
- ) : null}
5000
- <input
5001
- className="st-cp-numin st-cp-scrub"
5002
- key={`${prop}:${authored[prop] ?? ''}`}
5003
- aria-label={prop}
5004
- defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
5005
- placeholder={cssHint(computed[prop]) || '—'}
5006
- onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
5007
- onKeyDown={(e) => {
5008
- if (e.key === 'Enter') e.currentTarget.blur();
5009
- }}
5010
- onBlur={(e) => {
5011
- const raw = e.currentTarget.value.trim();
5012
- if (!raw) return;
5013
- commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
5014
- }}
5015
- />
5016
- <span className="st-cp-step">
5017
- <button
5018
- type="button"
5019
- className="st-cp-stepb"
5020
- tabIndex={-1}
5021
- aria-label={`increase ${prop}`}
5022
- onClick={() => bump(1)}
5023
- >
5024
-
5025
- </button>
5026
- <button
5027
- type="button"
5028
- className="st-cp-stepb"
5029
- tabIndex={-1}
5030
- aria-label={`decrease ${prop}`}
5031
- onClick={() => bump(-1)}
5032
- >
5033
-
5034
- </button>
5035
- </span>
5036
- {unitless ? null : (
5037
- <select
5038
- className="st-cp-unitsel"
5039
- aria-label={`${prop} unit`}
5040
- value={cur.unit || 'px'}
5041
- onChange={(e) =>
5042
- commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
5043
- }
5044
- >
5045
- {CSS_UNITS.map((u) => (
5046
- <option key={u} value={u}>
5047
- {u}
5048
- </option>
5049
- ))}
5050
- </select>
5051
- )}
5052
- </div>
5676
+ <NumberField
5677
+ key={`${prop}:${authored[prop] ?? ''}`}
5678
+ value={shownN}
5679
+ min={opts.min ?? 0}
5680
+ step={1}
5681
+ ariaLabel={prop}
5682
+ lead={lead ? (lead.node ?? lead.t) : undefined}
5683
+ onCommit={(n) => commit(prop, unitless ? `${n}` : `${n}${unit}`)}
5684
+ unitSlot={
5685
+ unitless || opts.fixedUnit ? null : (
5686
+ <UnitSelect
5687
+ units={CSS_UNITS}
5688
+ value={cur.unit || 'px'}
5689
+ ariaLabel={`${prop} unit`}
5690
+ onChange={(u) => commit(prop, u === 'auto' ? 'auto' : `${cur.n || '0'}${u}`)}
5691
+ />
5692
+ )
5693
+ }
5694
+ />
5053
5695
  {tok(prop, tokenList)}
5054
5696
  </>
5055
5697
  );
@@ -5057,24 +5699,34 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5057
5699
 
5058
5700
  // color swatch (native picker → hex) + raw text + token quick-pick
5059
5701
  const color = (prop) => {
5060
- // ONE colour control: the swatch is the trigger for a single popover with a
5061
- // full HSV picker (Custom) + the DS swatches (Variables). No separate native
5062
- // OS picker (#6was two popovers doing the same thing).
5702
+ // ONE compact colour field (feature-inspector-controls-redesign handoff): the
5703
+ // TokenPopover swatch is the FLUSH prefix (divider, no gap) inside ColorField,
5704
+ // then the value input swatch + value read as one field. The popover keeps
5705
+ // its full HSV picker (Custom) + DS swatches (Variables) + cross-DS/security.
5063
5706
  const resolved = computed[prop] || authored[prop] || '';
5707
+ const av = authored[prop] ?? '';
5708
+ const bound = typeof av === 'string' && /var\(\s*--/.test(av);
5709
+ const display = bound ? av.replace(/^var\(\s*|\s*\)$/g, '').replace(/^--/, '').replace(/-/g, ' ') : av;
5064
5710
  return (
5065
- <>
5066
- <TokenPopover
5067
- kind="color"
5068
- groups={tokenGroups('color')}
5069
- current={authored[prop]}
5070
- activeDs={_activeDs}
5071
- swatchBg={resolved}
5072
- seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
5073
- onPick={(v) => commit(prop, v)}
5074
- label={`${prop} colour`}
5075
- />
5076
- {text(prop)}
5077
- </>
5711
+ <ColorField
5712
+ swatch={
5713
+ <TokenPopover
5714
+ kind="color"
5715
+ swatchClassName="st-cp-cf-sw"
5716
+ groups={tokenGroups('color')}
5717
+ current={authored[prop]}
5718
+ activeDs={_activeDs}
5719
+ swatchBg={resolved}
5720
+ seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
5721
+ onPick={(v) => commit(prop, v)}
5722
+ label={`${prop} colour`}
5723
+ />
5724
+ }
5725
+ displayValue={display}
5726
+ bound={bound}
5727
+ ariaLabel={prop}
5728
+ onValue={(v) => commit(prop, v)}
5729
+ />
5078
5730
  );
5079
5731
  };
5080
5732
 
@@ -5082,6 +5734,49 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5082
5734
  // always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
5083
5735
  // styling for an unset/zero side. Edits the single side (the old "link all
5084
5736
  // sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
5737
+ // Built on the shared `makeScrubHandler` engine (feature-inspector-controls-
5738
+ // redesign) — a plain (non-hook) factory, since `side`/`inset` are helper
5739
+ // closures invoked during render, not components (can't call a hook there).
5740
+ // These stay compact <input>s with whole-cell scrub (no separate drag handle
5741
+ // — the Figma/Webflow convention for tiny box-model cells with no room for
5742
+ // one; the 3px dead-zone already lets a plain click through to focus). A
5743
+ // multi-side drag (alt = pair, alt+shift = all four) live-updates the sibling
5744
+ // box inputs too, so the whole move shows in the panel, not just the dragged
5745
+ // cell — `node` closes over the actual input DOM element via onPointerDown.
5746
+ const boxScrub = (prop, opts) => {
5747
+ let node = null;
5748
+ const unit = opts.unitless ? '' : 'px';
5749
+ const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
5750
+ const applyToSides = (n, activeSides, fn) => {
5751
+ for (const p of activeSides ?? [prop]) fn(p, fmt(n));
5752
+ };
5753
+ const scrub = makeScrubHandler({
5754
+ getBase: () => node?.value ?? '0',
5755
+ min: opts.min ?? 0,
5756
+ step: 1,
5757
+ sides: opts.sides,
5758
+ onInput: (n, activeSides) => {
5759
+ if (node) node.value = String(n);
5760
+ if (activeSides) {
5761
+ const box = node?.closest('.st-cp-box');
5762
+ for (const p of activeSides) {
5763
+ if (p === prop) continue;
5764
+ const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
5765
+ if (sib) sib.value = String(n);
5766
+ }
5767
+ }
5768
+ applyToSides(n, activeSides, optimistic);
5769
+ },
5770
+ onCommit: (n, activeSides) => applyToSides(n, activeSides, commit),
5771
+ });
5772
+ return {
5773
+ onPointerDown: (e) => {
5774
+ node = e.currentTarget;
5775
+ scrub(e);
5776
+ },
5777
+ };
5778
+ };
5779
+
5085
5780
  const side = (prop, group) => {
5086
5781
  const a = authored[prop];
5087
5782
  const shown =
@@ -5106,8 +5801,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5106
5801
  aria-label={prop}
5107
5802
  defaultValue={shown}
5108
5803
  title="drag to scrub · alt = symmetric · alt+shift = all sides"
5109
- onPointerDown={makeScrub(prop, { sides: { pair, all } })}
5804
+ {...boxScrub(prop, { sides: { pair, all } })}
5805
+ onFocus={selectAllOnFocus}
5110
5806
  onKeyDown={(e) => {
5807
+ if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
5111
5808
  if (e.key === 'Enter') e.currentTarget.blur();
5112
5809
  }}
5113
5810
  onBlur={(e) => {
@@ -5141,8 +5838,10 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5141
5838
  defaultValue={shown}
5142
5839
  placeholder="auto"
5143
5840
  title="drag to scrub · alt = axis pair · alt+shift = all sides · type auto"
5144
- onPointerDown={makeScrub(prop, { sides: { pair, all }, min: -Infinity })}
5841
+ {...boxScrub(prop, { sides: { pair, all }, min: -Infinity })}
5842
+ onFocus={selectAllOnFocus}
5145
5843
  onKeyDown={(e) => {
5844
+ if (stepBoxInput(e, (n) => commit(prop, `${n}px`))) return;
5146
5845
  if (e.key === 'Enter') e.currentTarget.blur();
5147
5846
  }}
5148
5847
  onBlur={(e) => {
@@ -5155,40 +5854,261 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5155
5854
  );
5156
5855
  };
5157
5856
 
5158
- const corner = (label, prop) => (
5159
- <label className="st-cp-cornerf">
5160
- <span>{label}</span>
5161
- <input
5162
- key={`${prop}:${authored[prop] ?? ''}`}
5163
- aria-label={prop}
5164
- defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
5165
- placeholder={cssHint(computed[prop]) || '0'}
5166
- onKeyDown={(e) => {
5167
- if (e.key === 'Enter') e.currentTarget.blur();
5168
- }}
5169
- onBlur={(e) => {
5170
- const raw = e.currentTarget.value.trim();
5171
- if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
5172
- }}
5173
- />
5174
- </label>
5175
- );
5176
-
5177
5857
  // Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
5178
5858
  // surfaced in Advanced so the user can see/edit/remove what they added.
5179
5859
  const customStyleRows = Object.entries(customStyles);
5180
5860
  const attrRows = Object.entries(attrs);
5181
5861
 
5862
+ // Stage B (Task B5) — Media framing gate + body, pulled out (DDR-171) so
5863
+ // Designer mode's "Media" cluster renders the identical content (just a
5864
+ // relabeled wrapper) instead of re-deriving `showMedia`/`canReplace`.
5865
+ // Rendered only for a media element (img / video / picture / svg / canvas)
5866
+ // or a selection that already carries a framing prop, so a plain <div>
5867
+ // doesn't grow object-fit rows. Media = box/framing/source (this plan); the
5868
+ // photo-editor plan's "Photo" tab owns pixels/look — separate DOM slots by
5869
+ // design.
5870
+ const mediaGate = () => {
5871
+ const t = (el.tag || '').toLowerCase();
5872
+ const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
5873
+ const showMedia =
5874
+ isMediaEl || !!authored['object-fit'] || !!authored['object-position'] || !!authored['aspect-ratio'];
5875
+ // Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
5876
+ // <img>/<video> only; a template-expression src can't be string-swapped,
5877
+ // so gate on a real src attr being present).
5878
+ const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
5879
+ return { showMedia, canReplace };
5880
+ };
5881
+ const mediaBody = (canReplace) => (
5882
+ <>
5883
+ {canReplace && (
5884
+ <div className="st-cp-mediabtn">
5885
+ <button type="button" className="st-btn st-cp-replace" onClick={() => onReplaceMedia(el)}>
5886
+ Replace…
5887
+ </button>
5888
+ </div>
5889
+ )}
5890
+ {row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
5891
+ {row('object-position', text('object-position'))}
5892
+ {row(
5893
+ 'aspect-ratio',
5894
+ <select
5895
+ className="st-cp-nsel"
5896
+ aria-label="aspect-ratio"
5897
+ value={CSS_ASPECT_RATIO.includes(authored['aspect-ratio']) ? authored['aspect-ratio'] : ''}
5898
+ onChange={(e) => {
5899
+ const v = e.target.value;
5900
+ commit('aspect-ratio', v);
5901
+ // A fixed height overrides aspect-ratio (CSS: explicit width+height
5902
+ // win). When applying a real ratio, release the height so the ratio
5903
+ // actually reshapes the box (dogfood: "nastavil jsem 16/9 a nic se
5904
+ // nestalo").
5905
+ if (v && v !== 'auto' && authored.height) reset('height');
5906
+ }}
5907
+ >
5908
+ <option value="" disabled>
5909
+ {cssHint(computed['aspect-ratio']) || '—'}
5910
+ </option>
5911
+ {CSS_ASPECT_RATIO.map((v) => (
5912
+ <option key={v} value={v}>
5913
+ {v}
5914
+ </option>
5915
+ ))}
5916
+ </select>
5917
+ )}
5918
+ </>
5919
+ );
5920
+
5182
5921
  return (
5183
- <div className="st-cp" key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
5922
+ <div className={`st-cp${mode === 'designer' ? ' st-cp--designer' : ''}`} key={el.id} data-tour="css-panel" onKeyDown={onKnobKeyDown}>
5184
5923
  <div className="st-cp-id">
5185
5924
  <span className="st-cp-idtag">
5186
5925
  {el.tag || 'element'}
5187
5926
  {el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
5188
5927
  </span>
5189
- <span className="st-cp-idmeta">inline style</span>
5928
+ {/* DDR-171 — vocabulary mode toggle, tucked into the id row's corner
5929
+ slot (was a full-width Segmented row — read as too heavy; a
5930
+ two-icon IconButtonGroup matches every other compact toggle in
5931
+ this panel). 'advanced' is the default (today's panel, byte-
5932
+ identical below); 'designer' swaps in the Figma-vocabulary regroup.
5933
+ Named "Advanced" (not "Simple"/"Basic") so neither mode reads as
5934
+ the lesser fallback — see DDR-171 for the naming-collision call
5935
+ against the nested Advanced *section* below. */}
5936
+ <span className="st-cp-idmode" data-tour="cp-mode">
5937
+ <IconButtonGroup
5938
+ value={mode}
5939
+ ariaLabel="panel vocabulary mode"
5940
+ options={[
5941
+ { value: 'advanced', node: <Lu as={LuBraces} size={12} />, label: 'Advanced — raw CSS' },
5942
+ { value: 'designer', node: <Lu as={LuWand2} size={12} />, label: 'Designer — Figma vocabulary' },
5943
+ ]}
5944
+ onChange={setMode}
5945
+ />
5946
+ </span>
5190
5947
  </div>
5191
5948
 
5949
+ {mode === 'designer' ? (
5950
+ <>
5951
+ {dsec(
5952
+ 'Auto layout',
5953
+ ['display', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left'],
5954
+ (() => {
5955
+ const disp = (authored.display || cssHint(computed.display) || '').trim();
5956
+ const isFlex = disp === 'flex' || disp === 'inline-flex';
5957
+ return isFlex ? (
5958
+ <>
5959
+ {row('flex-direction', iconseg('flex-direction', DIR_OPTS), undefined, 'Direction')}
5960
+ {row(
5961
+ 'align-items',
5962
+ <AlignPad value={alignPadCell()} onChange={setAlignPadCell} ariaLabel="auto-layout alignment" />,
5963
+ provOf('align-items'),
5964
+ 'Alignment'
5965
+ )}
5966
+ {row('gap', vtok('gap', 'space'), undefined, 'Gap')}
5967
+ <div className="st-cp-modes">
5968
+ {sizeModeSeg('width')}
5969
+ {sizeModeSeg('height')}
5970
+ </div>
5971
+ <div className="st-cp-box" aria-label="padding">
5972
+ <span className="st-cp-boxtag st-cp-boxtag--p">
5973
+ {prov(provOf('padding-top'))}padding
5974
+ </span>
5975
+ {side('padding-top', 'padding')}
5976
+ {side('padding-right', 'padding')}
5977
+ {side('padding-bottom', 'padding')}
5978
+ {side('padding-left', 'padding')}
5979
+ </div>
5980
+ <div className="st-cp-clustermore-row">{moreBtn('Auto layout')}</div>
5981
+ {designerMore['Auto layout']
5982
+ ? row(
5983
+ 'flex-wrap',
5984
+ <Toggle
5985
+ checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')}
5986
+ label="wrap items"
5987
+ ariaLabel="flex-wrap"
5988
+ onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')}
5989
+ />,
5990
+ provOf('flex-wrap'),
5991
+ 'Wrap'
5992
+ )
5993
+ : null}
5994
+ </>
5995
+ ) : (
5996
+ <button type="button" className="st-cp-makeflex" disabled={!editable} onClick={() => commit('display', 'flex')}>
5997
+ + Auto layout (flex)
5998
+ </button>
5999
+ );
6000
+ })()
6001
+ )}
6002
+
6003
+ {dsec(
6004
+ 'Size',
6005
+ ['width', 'height', 'min-width', 'max-width', 'min-height', 'max-height'],
6006
+ <>
6007
+ {row('width', num('width'), undefined, 'Width')}
6008
+ {row('height', num('height'), undefined, 'Height')}
6009
+ <div className="st-cp-clustermore-row">{moreBtn('Size')}</div>
6010
+ {designerMore.Size ? (
6011
+ <>
6012
+ {row('min-width', num('min-width'), undefined, 'Min width')}
6013
+ {row('max-width', num('max-width'), undefined, 'Max width')}
6014
+ {row('min-height', num('min-height'), undefined, 'Min height')}
6015
+ {row('max-height', num('max-height'), undefined, 'Max height')}
6016
+ </>
6017
+ ) : null}
6018
+ </>
6019
+ )}
6020
+
6021
+ {dsec(
6022
+ 'Position',
6023
+ ['position', 'top', 'right', 'bottom', 'left', 'z-index'],
6024
+ <>
6025
+ {row('position', csel('position', CSS_POSITION), undefined, 'Position')}
6026
+ <div className="st-cp-box st-cp-box--inset" aria-label="position inset (top / right / bottom / left)">
6027
+ <span className="st-cp-boxtag st-cp-boxtag--i">{prov(provOf('top'))}inset</span>
6028
+ {inset('top')}
6029
+ {inset('right')}
6030
+ {inset('bottom')}
6031
+ {inset('left')}
6032
+ <div className="st-cp-boxcore st-cp-boxcore--pos">
6033
+ {authored.position || cssHint(computed.position) || 'static'}
6034
+ </div>
6035
+ </div>
6036
+ {(authored.position || cssHint(computed.position) || 'static') === 'static' ? (
6037
+ <div className="st-cp-note">
6038
+ top / right / bottom / left apply once position is relative, absolute, fixed, or sticky
6039
+ </div>
6040
+ ) : null}
6041
+ <div className="st-cp-clustermore-row">{moreBtn('Position')}</div>
6042
+ {designerMore.Position ? row('z-index', num('z-index'), undefined, 'Layer order') : null}
6043
+ </>,
6044
+ false
6045
+ )}
6046
+
6047
+ {dsec('Fill', ['background-color'], row('background-color', color('background-color'), undefined, 'Fill'))}
6048
+
6049
+ {dsec('Stroke', ['border-width', 'border-style', 'border-color'], row('border', borderControl(), provOf('border-width'), 'Stroke'))}
6050
+
6051
+ {dsec(
6052
+ 'Corner radius',
6053
+ ['border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-left-radius', 'border-bottom-right-radius'],
6054
+ row('border-radius', radiusControl(), provOf('border-radius'), 'Corner radius')
6055
+ )}
6056
+
6057
+ {dsec(
6058
+ 'Effects',
6059
+ ['box-shadow', 'filter', 'mix-blend-mode'],
6060
+ <>
6061
+ {row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'), undefined, 'Shadow')}
6062
+ {row('filter', blurControl(), provOf('filter'), 'Blur')}
6063
+ {row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES), undefined, 'Blend')}
6064
+ </>
6065
+ )}
6066
+
6067
+ {dsec('Opacity', ['opacity'], row('opacity', opacityControl(), undefined, 'Opacity'))}
6068
+
6069
+ {dsec(
6070
+ 'Text',
6071
+ ['font-family', 'color', 'font-size', 'font-weight', 'line-height', 'text-align', 'letter-spacing', 'font-style', 'text-transform', 'white-space'],
6072
+ <>
6073
+ {row('font-family', csel('font-family', CSS_FONTS), undefined, 'Font')}
6074
+ {row('color', color('color'), undefined, 'Color')}
6075
+ {row('font-size', vtok('font-size', 'type'), undefined, 'Size')}
6076
+ {row('font-weight', csel('font-weight', CSS_WEIGHTS), undefined, 'Weight')}
6077
+ {row('line-height', num('line-height', 'lh'), undefined, 'Line height')}
6078
+ {row('text-align', iconseg('text-align', TEXTALIGN_OPTS), undefined, 'Align')}
6079
+ <div className="st-cp-clustermore-row">{moreBtn('Text')}</div>
6080
+ {designerMore.Text ? (
6081
+ <>
6082
+ {row('letter-spacing', num('letter-spacing', null, { min: -Infinity }), undefined, 'Letter spacing')}
6083
+ {row('font-style', csel('font-style', CSS_FONT_STYLE), undefined, 'Style')}
6084
+ {row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM), undefined, 'Case')}
6085
+ {row('white-space', csel('white-space', CSS_WHITE_SPACE), undefined, 'Whitespace')}
6086
+ </>
6087
+ ) : null}
6088
+ </>
6089
+ )}
6090
+
6091
+ {dsec(
6092
+ 'Spacing',
6093
+ ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'],
6094
+ <div className="st-cp-box" aria-label="margin">
6095
+ <span className="st-cp-boxtag st-cp-boxtag--m">{prov(provOf('margin-top'))}margin</span>
6096
+ {side('margin-top', 'margin')}
6097
+ {side('margin-right', 'margin')}
6098
+ {side('margin-bottom', 'margin')}
6099
+ {side('margin-left', 'margin')}
6100
+ </div>
6101
+ )}
6102
+
6103
+ {(() => {
6104
+ const { showMedia, canReplace } = mediaGate();
6105
+ return showMedia
6106
+ ? dsec('Media', ['object-fit', 'object-position', 'aspect-ratio'], mediaBody(canReplace))
6107
+ : null;
6108
+ })()}
6109
+ </>
6110
+ ) : (
6111
+ <>
5192
6112
  {sec(
5193
6113
  'Layout',
5194
6114
  (() => {
@@ -5206,15 +6126,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5206
6126
  {row('display', csel('display', CSS_DISPLAYS))}
5207
6127
  {isFlex ? (
5208
6128
  <>
5209
- {row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
5210
- {row('flex-wrap', csel('flex-wrap', CSS_FLEX_WRAP))}
6129
+ {row('flex-direction', iconseg('flex-direction', DIR_OPTS))}
6130
+ {row('flex-wrap', <Toggle checked={/^wrap/.test(authored['flex-wrap'] || cssHint(computed['flex-wrap']) || '')} label="wrap items" ariaLabel="flex-wrap" onChange={(w) => commit('flex-wrap', w ? 'wrap' : 'nowrap')} />, provOf('flex-wrap'))}
5211
6131
  </>
5212
6132
  ) : null}
5213
6133
  {isFlex || isGrid ? (
5214
6134
  <>
5215
- {row('align-items', csel('align-items', CSS_ALIGN))}
5216
- {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
5217
- {row('gap', num('gap', 'space'))}
6135
+ {row('align-items', iconseg('align-items', ALIGNITEMS_OPTS))}
6136
+ {row('justify-content', iconseg('justify-content', JUSTIFY_OPTS))}
6137
+ {row('gap', vtok('gap', 'space'))}
5218
6138
  </>
5219
6139
  ) : (
5220
6140
  <button
@@ -5259,35 +6179,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5259
6179
  <>
5260
6180
  {row('font-family', csel('font-family', CSS_FONTS))}
5261
6181
  {row('color', color('color'))}
5262
- {row('font-size', num('font-size', 'type'))}
6182
+ {row('font-size', vtok('font-size', 'type'))}
5263
6183
  {row('font-weight', csel('font-weight', CSS_WEIGHTS))}
5264
6184
  {row('line-height', num('line-height', 'lh'))}
5265
6185
  {row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
5266
- {row(
5267
- 'text-align',
5268
- <div className="st-cp-seg" role="group" aria-label="text-align">
5269
- {CSS_ALIGN_OPTS.map((a) => (
5270
- <button
5271
- key={a}
5272
- type="button"
5273
- className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
5274
- aria-label={`align ${a}`}
5275
- aria-pressed={(authored['text-align'] || computed['text-align']) === a}
5276
- onClick={() => commit('text-align', a)}
5277
- >
5278
- <span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
5279
- <i />
5280
- <i />
5281
- <i />
5282
- </span>
5283
- </button>
5284
- ))}
5285
- </div>
5286
- )}
6186
+ {/* handoff — text-align as a lucide icon button group; B/I/U as a toggle
6187
+ group mapped to font-weight / font-style / text-decoration. */}
6188
+ {row('text-align', iconseg('text-align', TEXTALIGN_OPTS))}
6189
+ {row('font-style', textStyleToggle(), provOf('font-style'))}
5287
6190
  {/* Stage B (Task B4) — promoted typography knobs (was DDR-104 OUT-list). */}
5288
- {row('font-style', csel('font-style', CSS_FONT_STYLE))}
5289
6191
  {row('text-transform', csel('text-transform', CSS_TEXT_TRANSFORM))}
5290
- {row('text-decoration', csel('text-decoration', CSS_TEXT_DECORATION))}
5291
6192
  {row('white-space', csel('white-space', CSS_WHITE_SPACE))}
5292
6193
  </>
5293
6194
  )}
@@ -5312,7 +6213,9 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5312
6213
  {side('padding-bottom', 'padding')}
5313
6214
  {side('padding-left', 'padding')}
5314
6215
  <div className="st-cp-boxcore">
5315
- {Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
6216
+ {/* handoff prefer the AUTHORED size (updates live as you edit
6217
+ width/height); el.bounds is stale until the canvas re-measures. */}
6218
+ {Math.round(Number.parseFloat(authored.width) || el.bounds?.w || 0)} × {Math.round(Number.parseFloat(authored.height) || el.bounds?.h || 0)}
5316
6219
  </div>
5317
6220
  </div>
5318
6221
  </div>
@@ -5334,7 +6237,7 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5334
6237
  {row('max-width', num('max-width'))}
5335
6238
  {row('min-height', num('min-height'))}
5336
6239
  {row('max-height', num('max-height'))}
5337
- {row('overflow', csel('overflow', CSS_OVERFLOW))}
6240
+ {row('overflow', iconseg('overflow', OVERFLOW_OPTS))}
5338
6241
  {/* Stage M1 — flex-CHILD controls, only meaningful when the parent is a
5339
6242
  flex container. align-self is the cross-axis override; flex-grow/shrink/
5340
6243
  basis are the main-axis behavior the Fill mode writes for you. */}
@@ -5350,159 +6253,28 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5350
6253
  </>
5351
6254
  )}
5352
6255
 
5353
- {/* Stage B (Task B5) — Media framing. Rendered only for a media element
5354
- (img / video / picture / svg / canvas) or a selection that already
5355
- carries a framing prop, so a plain <div> doesn't grow object-fit rows.
5356
- Media = box/framing/source (this plan); the photo-editor plan's "Photo"
5357
- tab owns pixels/look — they are separate DOM slots by design. */}
6256
+ {/* Stage B (Task B5) — Media framing (gate/body pulled into mediaGate()/
6257
+ mediaBody() above, DDR-171, so Designer mode's "Media" cluster
6258
+ reuses the identical content). */}
5358
6259
  {(() => {
5359
- const t = (el.tag || '').toLowerCase();
5360
- const isMediaEl = t === 'img' || t === 'video' || t === 'picture' || t === 'svg' || t === 'canvas';
5361
- const showMedia =
5362
- isMediaEl ||
5363
- !!authored['object-fit'] ||
5364
- !!authored['object-position'] ||
5365
- !!authored['aspect-ratio'];
5366
- // Stage F2 — "Replace…" opens the AssetPicker to re-point src (authored
5367
- // <img>/<video> only; a template-expression src can't be string-swapped,
5368
- // so gate on a real src attr being present).
5369
- const canReplace = (t === 'img' || t === 'video') && !!el.attrs?.src && !!onReplaceMedia;
5370
- return showMedia
5371
- ? sec(
5372
- 'Media',
5373
- <>
5374
- {canReplace && (
5375
- <div className="st-cp-mediabtn">
5376
- <button
5377
- type="button"
5378
- className="st-btn st-cp-replace"
5379
- onClick={() => onReplaceMedia(el)}
5380
- >
5381
- Replace…
5382
- </button>
5383
- </div>
5384
- )}
5385
- {row('object-fit', csel('object-fit', CSS_OBJECT_FIT))}
5386
- {row('object-position', text('object-position'))}
5387
- {row(
5388
- 'aspect-ratio',
5389
- <select
5390
- className="st-cp-nsel"
5391
- aria-label="aspect-ratio"
5392
- value={
5393
- CSS_ASPECT_RATIO.includes(authored['aspect-ratio'])
5394
- ? authored['aspect-ratio']
5395
- : ''
5396
- }
5397
- onChange={(e) => {
5398
- const v = e.target.value;
5399
- commit('aspect-ratio', v);
5400
- // A fixed height overrides aspect-ratio (CSS: explicit
5401
- // width+height win). When applying a real ratio, release the
5402
- // height so the ratio actually reshapes the box (dogfood:
5403
- // "nastavil jsem 16/9 a nic se nestalo").
5404
- if (v && v !== 'auto' && authored.height) reset('height');
5405
- }}
5406
- >
5407
- <option value="" disabled>
5408
- {cssHint(computed['aspect-ratio']) || '—'}
5409
- </option>
5410
- {CSS_ASPECT_RATIO.map((v) => (
5411
- <option key={v} value={v}>
5412
- {v}
5413
- </option>
5414
- ))}
5415
- </select>
5416
- )}
5417
- </>
5418
- )
5419
- : null;
6260
+ const { showMedia, canReplace } = mediaGate();
6261
+ return showMedia ? sec('Media', mediaBody(canReplace)) : null;
5420
6262
  })()}
5421
6263
 
5422
6264
  {sec(
5423
6265
  'Appearance',
5424
6266
  <>
5425
6267
  {row('background-color', color('background-color'))}
5426
- <div className="st-cp-row">
5427
- {prov(provOf('border-radius'))}
5428
- <label className="st-cp-label" title="border-radius">
5429
- border-radius
5430
- </label>
5431
- <div className="st-cp-ctl">
5432
- {num('border-radius', 'radius')}
5433
- <button
5434
- type="button"
5435
- className={`st-cp-split${split ? ' is-on' : ''}`}
5436
- aria-pressed={split}
5437
- aria-label="set each corner separately"
5438
- title="set each corner separately"
5439
- onClick={() => setSplit((v) => !v)}
5440
- />
5441
- </div>
5442
- </div>
5443
- {split ? (
5444
- <div className="st-cp-corners" aria-label="per-corner radius">
5445
- {corner('TL', 'border-top-left-radius')}
5446
- {corner('TR', 'border-top-right-radius')}
5447
- {corner('BL', 'border-bottom-left-radius')}
5448
- {corner('BR', 'border-bottom-right-radius')}
5449
- </div>
5450
- ) : null}
5451
- {row(
5452
- 'border',
5453
- <div className="st-cp-border">
5454
- {num('border-width')}
5455
- <select
5456
- className="st-cp-nsel st-cp-nsel--mini"
5457
- aria-label="border-style"
5458
- value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
5459
- onChange={(e) => commit('border-style', e.target.value)}
5460
- >
5461
- <option value="" disabled>
5462
- style
5463
- </option>
5464
- {CSS_BORDER_STYLES.map((s) => (
5465
- <option key={s} value={s}>
5466
- {s}
5467
- </option>
5468
- ))}
5469
- </select>
5470
- <TokenPopover
5471
- kind="color"
5472
- groups={tokenGroups('color')}
5473
- current={authored['border-color']}
5474
- activeDs={_activeDs}
5475
- swatchBg={computed['border-color'] || authored['border-color'] || ''}
5476
- seedHex={
5477
- cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
5478
- }
5479
- onPick={(v) => commit('border-color', v)}
5480
- label="border colour"
5481
- />
5482
- </div>,
5483
- provOf('border-width')
5484
- )}
6268
+ {row('border-radius', radiusControl(), provOf('border-radius'))}
6269
+ {row('border', borderControl(), provOf('border-width'))}
5485
6270
  {row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
5486
- {row(
5487
- 'opacity',
5488
- <div className="st-cp-num">
5489
- <span className="st-cp-numlead" aria-hidden="true">
5490
- <StIcon name="p-opacity" size={12} />
5491
- </span>
5492
- <input
5493
- className="st-cp-numin"
5494
- key={`opacity:${authored.opacity ?? ''}`}
5495
- aria-label="opacity"
5496
- defaultValue={authored.opacity ?? ''}
5497
- placeholder={cssHint(computed.opacity) || '1'}
5498
- onKeyDown={(e) => {
5499
- if (e.key === 'Enter') e.currentTarget.blur();
5500
- }}
5501
- onBlur={(e) => commit('opacity', e.currentTarget.value)}
5502
- />
5503
- </div>
5504
- )}
6271
+ {/* DDR-171 — blur + blend, real Advanced-mode rows (not Designer-exclusive);
6272
+ also power Designer mode's "Effects" cluster. */}
6273
+ {row('filter', blurControl(), provOf('filter'))}
6274
+ {row('mix-blend-mode', csel('mix-blend-mode', CSS_BLEND_MODES))}
6275
+ {row('opacity', opacityControl())}
5505
6276
  {/* Stage B (Task B4) — transform as a free-value row (mirrors box-shadow). */}
6277
+ {row('rotation', rotationControl(), provOf('transform'))}
5506
6278
  {row('transform', text('transform'))}
5507
6279
  {row('transform-origin', text('transform-origin'))}
5508
6280
  </>
@@ -5601,6 +6373,8 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
5601
6373
  <AttrKnob commit={commitAttr} />
5602
6374
  </div>
5603
6375
  )}
6376
+ </>
6377
+ )}
5604
6378
 
5605
6379
  <div className="st-cp-legend">
5606
6380
  <span>
@@ -5979,7 +6753,14 @@ function resolveArtboardIdFromSelection(el) {
5979
6753
  // /_api/resize-artboard, NOT edit-css — DDR-027 numeric JSX props) + the SAME
5980
6754
  // SCREEN_PRESETS the "+ Artboard" menu uses, so picking "Tablet" resizes the
5981
6755
  // CURRENT artboard to 834×1194 in one click instead of typing both fields.
5982
- function ArtboardKnobs({ el, onResizeArtboard }) {
6756
+ const ARTBOARD_LAYOUT_OPTIONS = [
6757
+ ['', 'Block (default)'],
6758
+ ['flex-col', 'Flex ↓ (column)'],
6759
+ ['flex-row', 'Flex → (row)'],
6760
+ ['grid', 'Grid'],
6761
+ ];
6762
+
6763
+ function ArtboardKnobs({ el, onResizeArtboard, onSetArtboardHug, onSetArtboardStyle }) {
5983
6764
  const artboardId = resolveArtboardIdFromSelection(el);
5984
6765
  // Dogfood 2026-07-07 (round 2) — `worldW`/`worldH` (zoom-independent) are
5985
6766
  // undefined for a selection that reached here via a code path predating
@@ -6007,6 +6788,25 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
6007
6788
  const activePreset = Object.entries(SCREEN_PRESETS).find(
6008
6789
  ([, p]) => p.width === w && p.height === h
6009
6790
  )?.[0];
6791
+ // Hug default (artboard "hug height") — current mode + the "more settings"
6792
+ // (background/padding/layout/gap) read off the SAME generic `attrs` escape
6793
+ // hatch dom-selection.ts already scrapes for every selection (the `data-dc-*`
6794
+ // attributes DCArtboard stamps on the frame purely for this panel to read
6795
+ // its own resolved props back — see canvas-lib.tsx readBackAttrs).
6796
+ const fixed = el.attrs?.['data-dc-fixed'] === 'true';
6797
+ const bg = el.attrs?.['data-dc-bg'] ?? '';
6798
+ const padding = el.attrs?.['data-dc-padding'] ?? '';
6799
+ const layoutMode = el.attrs?.['data-dc-layout'] ?? '';
6800
+ const gap = el.attrs?.['data-dc-gap'] ?? '';
6801
+ const setHug = (nextFixed) => {
6802
+ if (!artboardId) return;
6803
+ const freezeHeight = nextFixed && Number.isFinite(h) ? Math.round(h) : undefined;
6804
+ onSetArtboardHug?.(artboardId, nextFixed, freezeHeight);
6805
+ };
6806
+ const commitStyle = (patch) => {
6807
+ if (!artboardId) return;
6808
+ onSetArtboardStyle?.(artboardId, patch);
6809
+ };
6010
6810
  return (
6011
6811
  <section className="st-cp-sec">
6012
6812
  <div className="st-cp-sechd-row">
@@ -6039,12 +6839,17 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
6039
6839
  type="number"
6040
6840
  min="1"
6041
6841
  aria-label="artboard height"
6042
- key={`h:${h ?? ''}`}
6842
+ readOnly={!fixed}
6843
+ title={fixed ? undefined : 'Hug — grows to fit content. Switch to Fixed to set an exact height.'}
6844
+ key={`h:${h ?? ''}:${fixed}`}
6043
6845
  defaultValue={h ?? ''}
6044
6846
  onKeyDown={(e) => {
6045
6847
  if (e.key === 'Enter') e.currentTarget.blur();
6046
6848
  }}
6047
- onBlur={(e) => commitSize(null, Number.parseFloat(e.currentTarget.value))}
6849
+ onBlur={(e) => {
6850
+ if (!fixed) return;
6851
+ commitSize(null, Number.parseFloat(e.currentTarget.value));
6852
+ }}
6048
6853
  />
6049
6854
  </div>
6050
6855
  </div>
@@ -6068,10 +6873,133 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
6068
6873
  ))}
6069
6874
  </select>
6070
6875
  </div>
6876
+ <div style={{ padding: '0 12px 8px' }}>
6877
+ <div className="st-cp-modeseg" role="group" aria-label="artboard height sizing mode">
6878
+ <span className="st-cp-modeax" aria-hidden="true">
6879
+ H
6880
+ </span>
6881
+ {[
6882
+ [false, 'Hug'],
6883
+ [true, 'Fixed'],
6884
+ ].map(([val, label]) => (
6885
+ <button
6886
+ key={label}
6887
+ type="button"
6888
+ className={`st-cp-modebtn${fixed === val ? ' is-active' : ''}`}
6889
+ aria-pressed={fixed === val}
6890
+ onClick={() => setHug(val)}
6891
+ title={`${label} height`}
6892
+ >
6893
+ {label}
6894
+ </button>
6895
+ ))}
6896
+ </div>
6897
+ </div>
6898
+ <div className="st-cp-sechd-row">
6899
+ <span className="st-cp-sechd">Style</span>
6900
+ </div>
6901
+ <div style={{ padding: '4px 12px 8px', display: 'flex', flexDirection: 'column', gap: 8 }}>
6902
+ <div className="st-cp-num" style={{ width: '100%' }}>
6903
+ <span className="st-cp-numlead" aria-hidden="true">
6904
+ Bg
6905
+ </span>
6906
+ <input
6907
+ className="st-cp-numin"
6908
+ type="text"
6909
+ aria-label="artboard background"
6910
+ placeholder="var(--bg-1)"
6911
+ key={`bg:${bg}`}
6912
+ defaultValue={bg}
6913
+ onKeyDown={(e) => {
6914
+ if (e.key === 'Enter') e.currentTarget.blur();
6915
+ }}
6916
+ onBlur={(e) => commitStyle({ background: e.currentTarget.value.trim() || null })}
6917
+ />
6918
+ </div>
6919
+ <select
6920
+ className="st-cp-nsel"
6921
+ aria-label="artboard body layout"
6922
+ value={layoutMode}
6923
+ onChange={(e) => commitStyle({ layout: e.currentTarget.value || null })}
6924
+ >
6925
+ {ARTBOARD_LAYOUT_OPTIONS.map(([val, label]) => (
6926
+ <option key={val} value={val}>
6927
+ {label}
6928
+ </option>
6929
+ ))}
6930
+ </select>
6931
+ <div style={{ display: 'flex', gap: 8 }}>
6932
+ <div className="st-cp-num">
6933
+ <span className="st-cp-numlead" aria-hidden="true">
6934
+ Pad
6935
+ </span>
6936
+ <input
6937
+ className="st-cp-numin"
6938
+ type="number"
6939
+ min="0"
6940
+ aria-label="artboard padding"
6941
+ key={`pad:${padding}`}
6942
+ defaultValue={padding}
6943
+ onKeyDown={(e) => {
6944
+ if (e.key === 'Enter') e.currentTarget.blur();
6945
+ }}
6946
+ onBlur={(e) => {
6947
+ const v = e.currentTarget.value;
6948
+ commitStyle({ padding: v === '' ? null : Number.parseFloat(v) });
6949
+ }}
6950
+ />
6951
+ </div>
6952
+ <div className="st-cp-num">
6953
+ <span className="st-cp-numlead" aria-hidden="true">
6954
+ Gap
6955
+ </span>
6956
+ <input
6957
+ className="st-cp-numin"
6958
+ type="number"
6959
+ min="0"
6960
+ aria-label="artboard gap"
6961
+ key={`gap:${gap}`}
6962
+ defaultValue={gap}
6963
+ onKeyDown={(e) => {
6964
+ if (e.key === 'Enter') e.currentTarget.blur();
6965
+ }}
6966
+ onBlur={(e) => {
6967
+ const v = e.currentTarget.value;
6968
+ commitStyle({ gap: v === '' ? null : Number.parseFloat(v) });
6969
+ }}
6970
+ />
6971
+ </div>
6972
+ </div>
6973
+ </div>
6071
6974
  </section>
6072
6975
  );
6073
6976
  }
6074
6977
 
6978
+ // feature-photo-editor (Task 13/14) — derive the content-addressed photo asset a
6979
+ // DOM selection points at (an artboard `<img src="assets/<sha8>.<ext>">`). Prefers
6980
+ // the `photoAsset` the resolver stamped (dom-selection.ts), falling back to a scan
6981
+ // of the selection's src/html for a selection that reached the panel via a code
6982
+ // path predating that field. Returns null for a non-photo element.
6983
+ const PHOTO_ASSET_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
6984
+ function photoAssetOfSelection(el) {
6985
+ if (!el || Array.isArray(el)) return null;
6986
+ if (el.photoAsset) return el.photoAsset;
6987
+ if ((el.tag || '').toLowerCase() !== 'img') return null;
6988
+ const html = el.html || '';
6989
+ // Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
6990
+ // PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
6991
+ // so a REPLAYED/serialized selection (a WS resync, or the persisted
6992
+ // `_active.json` restored on boot) that arrives without the dedicated
6993
+ // `photoAsset` field would otherwise permanently lose the Photo tab for any
6994
+ // already-edited photo. The element still carries the `data-photo-asset` tag
6995
+ // the bridge stamped, and that DOES survive into an outerHTML snapshot — check
6996
+ // it before falling back to the plain assets/<sha8> src scan.
6997
+ const tagged = /data-photo-asset="([^"]+)"/.exec(html);
6998
+ if (tagged) return tagged[1];
6999
+ const m = PHOTO_ASSET_RE.exec(`${el.attrs?.src || ''} ${html}`);
7000
+ return m ? m[0] : null;
7001
+ }
7002
+
6075
7003
  function InspectorPanel({
6076
7004
  selected,
6077
7005
  onClose,
@@ -6086,19 +7014,41 @@ function InspectorPanel({
6086
7014
  onRecordEdit,
6087
7015
  onReplaceMedia,
6088
7016
  onResizeArtboard,
7017
+ onSetArtboardHug,
7018
+ onSetArtboardStyle,
6089
7019
  onUndoRedo,
6090
7020
  editScope,
6091
7021
  tab: tabProp,
6092
7022
  onTabChange,
6093
7023
  width,
6094
7024
  resizing,
7025
+ // feature-photo-editor (Task 13/15) — the Photo tab + its live-preview /
7026
+ // bg-removal / undo channels. `photoSel` is the annotation-image target
7027
+ // (threaded up separately since the annotation model has no DOM selection);
7028
+ // an artboard `<img>` target is derived from `selected` instead.
7029
+ photoSel,
7030
+ photoRev,
7031
+ onPhotoEdit,
7032
+ onPhotoRemoveBackground,
7033
+ onPhotoRecordEdit,
7034
+ onPhotoUndoRedo,
7035
+ // feature-configurable-panel-docking — when true this instance IS the standalone
7036
+ // Layers panel: it forces the Layers view and hides the tab bar (the tab strip
7037
+ // lives on the dock slot instead). `hideLayersTab` drops the inline Layers tab
7038
+ // when Layers has been split into its own panel.
7039
+ layersOnly = false,
7040
+ hideLayersTab = false,
7041
+ // DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), owned by App
7042
+ // (single source of truth shared with Settings → Appearance).
7043
+ cpMode,
7044
+ onSetCpMode,
6095
7045
  }) {
6096
7046
  // Tab is controllable from the parent (the guided tour drives it to 'css' /
6097
7047
  // 'layers' so a spotlight step lands on a real row) but falls back to local
6098
7048
  // state for normal use. A user click both updates local state and notifies the
6099
7049
  // parent, so the two stay in lockstep whichever owns it.
6100
7050
  const [tabState, setTabState] = useState('inspect');
6101
- const tab = tabProp ?? tabState;
7051
+ const tab = layersOnly ? 'layers' : (tabProp ?? tabState);
6102
7052
  const setTab = (t) => {
6103
7053
  setTabState(t);
6104
7054
  onTabChange?.(t);
@@ -6390,10 +7340,41 @@ function InspectorPanel({
6390
7340
  };
6391
7341
  // `selected` may be a single element, an array (multi-select), or null.
6392
7342
  const el = Array.isArray(selected) ? selected[0] : selected;
7343
+ // feature-photo-editor (Task 13) — resolve the Photo-tab target. Priority: an
7344
+ // annotation-image threaded up (`photoSel`, no DOM selection) → a Photo-ONLY
7345
+ // panel; else a content-addressed artboard `<img>` selection → Photo alongside
7346
+ // the normal tabs.
7347
+ const photoTarget = photoSel
7348
+ ? { asset: photoSel.asset, kind: 'annotation-image' }
7349
+ : (() => {
7350
+ const a = photoAssetOfSelection(el);
7351
+ return a ? { asset: a, kind: 'artboard-img' } : null;
7352
+ })();
7353
+ const photoOnly = photoTarget?.kind === 'annotation-image';
7354
+ // Cmd+Z / Cmd+Shift+Z (or Cmd+Y) while focus is still inside a Photo-tab
7355
+ // slider — mirrors `onKnobKeyDown` above for the same reason: the window-
7356
+ // level shortcut listener bails out whenever `document.activeElement` is an
7357
+ // `<input>` (native text-undo would otherwise win), so a commit-then-Cmd+Z
7358
+ // with the slider still focused needs its own forwarder here.
7359
+ const onPhotoKnobKeyDown = (e) => {
7360
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
7361
+ const k = e.key.toLowerCase();
7362
+ if (k === 'z') {
7363
+ e.preventDefault();
7364
+ onPhotoUndoRedo?.(e.shiftKey ? 'redo' : 'undo');
7365
+ } else if (k === 'y') {
7366
+ e.preventDefault();
7367
+ onPhotoUndoRedo?.('redo');
7368
+ }
7369
+ };
7370
+ // An annotation-image has no element tabs; force Photo. Otherwise honor the
7371
+ // requested tab, but drop off a stale 'photo' tab when the new selection isn't
7372
+ // photo-eligible (so switching from a photo to a normal element lands sanely).
7373
+ const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
6393
7374
  const tabBtn = (id, label, icon) => (
6394
7375
  <button
6395
7376
  type="button"
6396
- className={'st-rp-tab' + (tab === id ? ' is-active' : '')}
7377
+ className={'st-rp-tab' + (effTab === id ? ' is-active' : '')}
6397
7378
  onClick={() => setTab(id)}
6398
7379
  >
6399
7380
  <StIcon name={icon} size={14} />
@@ -6408,10 +7389,21 @@ function InspectorPanel({
6408
7389
  aria-label="Inspector"
6409
7390
  data-tour="inspector"
6410
7391
  >
6411
- <div className="st-rp-tabs" data-tour="inspector-tabs">
6412
- {tabBtn('inspect', 'Inspect', 'sliders')}
6413
- {tabBtn('layers', 'Layers', 'layers')}
6414
- {tabBtn('css', 'CSS', 'code')}
7392
+ <div
7393
+ className="st-rp-tabs"
7394
+ data-tour="inspector-tabs"
7395
+ style={layersOnly ? { display: 'none' } : undefined}
7396
+ >
7397
+ {photoOnly ? (
7398
+ tabBtn('photo', 'Photo', 'image')
7399
+ ) : (
7400
+ <>
7401
+ {tabBtn('inspect', 'Inspect', 'sliders')}
7402
+ {!hideLayersTab && tabBtn('layers', 'Layers', 'layers')}
7403
+ {tabBtn('css', 'CSS', 'code')}
7404
+ {photoTarget ? tabBtn('photo', 'Photo', 'image') : null}
7405
+ </>
7406
+ )}
6415
7407
  <button
6416
7408
  type="button"
6417
7409
  className="st-iconbtn"
@@ -6447,7 +7439,19 @@ function InspectorPanel({
6447
7439
  </div>
6448
7440
  ) : null}
6449
7441
  <div className="st-rp-body">
6450
- {!el ? (
7442
+ {effTab === 'photo' && photoTarget ? (
7443
+ <div onKeyDown={onPhotoKnobKeyDown}>
7444
+ <PhotoKnobs
7445
+ key={`${photoTarget.asset}:${photoRev ?? 0}`}
7446
+ asset={photoTarget.asset}
7447
+ ColorPicker={ColorPicker}
7448
+ StIcon={StIcon}
7449
+ onEdit={(edit) => onPhotoEdit?.(photoTarget.asset, edit)}
7450
+ onRemoveBackground={onPhotoRemoveBackground}
7451
+ onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
7452
+ />
7453
+ </div>
7454
+ ) : !el ? (
6451
7455
  <div className="st-rp-empty">
6452
7456
  {/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
6453
7457
  kbd would stack as stretched flex items. */}
@@ -6455,7 +7459,7 @@ function InspectorPanel({
6455
7459
  Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
6456
7460
  </p>
6457
7461
  </div>
6458
- ) : tab === 'inspect' ? (
7462
+ ) : effTab === 'inspect' ? (
6459
7463
  <>
6460
7464
  <div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
6461
7465
  <div className="st-insp-row">
@@ -6504,7 +7508,7 @@ function InspectorPanel({
6504
7508
  ) : null}
6505
7509
  <InspectComputed el={el} />
6506
7510
  </>
6507
- ) : tab === 'layers' ? (
7511
+ ) : effTab === 'layers' ? (
6508
7512
  <>
6509
7513
  <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
6510
7514
  {layersTree?.nodes?.length ? (
@@ -6578,7 +7582,12 @@ function InspectorPanel({
6578
7582
  )}
6579
7583
  </>
6580
7584
  ) : !el.id && resolveArtboardIdFromSelection(el) ? (
6581
- <ArtboardKnobs el={el} onResizeArtboard={onResizeArtboard} />
7585
+ <ArtboardKnobs
7586
+ el={el}
7587
+ onResizeArtboard={onResizeArtboard}
7588
+ onSetArtboardHug={onSetArtboardHug}
7589
+ onSetArtboardStyle={onSetArtboardStyle}
7590
+ />
6582
7591
  ) : (
6583
7592
  <CssKnobs
6584
7593
  el={el}
@@ -6587,6 +7596,8 @@ function InspectorPanel({
6587
7596
  onRecordEdit={onRecordEdit}
6588
7597
  onReplaceMedia={onReplaceMedia}
6589
7598
  onUndoRedo={onUndoRedo}
7599
+ mode={cpMode}
7600
+ onSetMode={onSetCpMode}
6590
7601
  />
6591
7602
  )}
6592
7603
  </div>
@@ -6834,12 +7845,31 @@ function App() {
6834
7845
  const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
6835
7846
  const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
6836
7847
  const [theme, setTheme] = useState(readInitialTheme);
7848
+ // DDR-171 — CSS-panel vocabulary mode ('advanced' | 'designer'), App-owned so
7849
+ // the in-panel corner toggle (CssKnobs) and Settings → Appearance share one
7850
+ // source of truth; persisted to `maude-cp-mode` (same pattern as `theme`).
7851
+ const [cpMode, setCpMode] = useState(() => {
7852
+ const m = readJsonStore(CP_MODE_STORE, 'advanced');
7853
+ return m === 'designer' ? 'designer' : 'advanced';
7854
+ });
7855
+ useEffect(() => {
7856
+ try {
7857
+ localStorage.setItem(CP_MODE_STORE, JSON.stringify(cpMode));
7858
+ } catch {}
7859
+ }, [cpMode]);
6837
7860
  const [openMenu, setOpenMenu] = useState(null);
6838
7861
  const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
6839
7862
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
6840
7863
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
6841
7864
  const [helpOpen, setHelpOpen] = useState(false);
6842
7865
  const [readinessOpen, setReadinessOpen] = useState(false);
7866
+ const [introOpen, setIntroOpen] = useState(false);
7867
+ const [quickSetupOpen, setQuickSetupOpen] = useState(false);
7868
+ const [brandUploadOpen, setBrandUploadOpen] = useState(false);
7869
+ // DDR-166 plan, Phase 2 (T7) — the persistent "Setup" affordance in the empty
7870
+ // canvas state (below) only renders while the project's own setup (design
7871
+ // system / first canvas / brand assets) is incomplete; native-only concern.
7872
+ const { report: setupReadiness } = useSetupReadiness(isNativeApp());
6843
7873
  // ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
6844
7874
  // Help modal (F1), which keeps commands & flows.
6845
7875
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -6847,7 +7877,26 @@ function App() {
6847
7877
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
6848
7878
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
6849
7879
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
7880
+ // feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
7881
+ // the AI generate action.
7882
+ const [settingsOpen, setSettingsOpen] = useState(false);
7883
+ const [generateOpen, setGenerateOpen] = useState(false);
6850
7884
  const [inspectorOpen, setInspectorOpen] = useState(false);
7885
+ // feature-configurable-panel-docking — Layers as its own openable panel (when
7886
+ // layersMode==='separate'), plus the per-panel side map + layers mode. All
7887
+ // three hydrate from disk (/_api/ui-prefs) on mount and persist on change,
7888
+ // mirroring the view-pref pattern.
7889
+ const [layersOpen, setLayersOpen] = useState(false);
7890
+ const [panelSide, setPanelSide] = useState(() =>
7891
+ readJsonStore(PANEL_SIDES_STORE, PANEL_SIDES_DEFAULTS)
7892
+ );
7893
+ const [layersMode, setLayersMode] = useState(() => {
7894
+ try {
7895
+ const v = localStorage.getItem(LAYERS_MODE_STORE);
7896
+ if (v === 'separate' || v === 'in-inspector') return v;
7897
+ } catch {}
7898
+ return 'separate';
7899
+ });
6851
7900
  // DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
6852
7901
  // is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
6853
7902
  // its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
@@ -6855,7 +7904,9 @@ function App() {
6855
7904
  const [activeComps, setActiveComps] = useState([]);
6856
7905
  const [timelineFrame, setTimelineFrame] = useState(0);
6857
7906
  const [timelinePlaying, setTimelinePlaying] = useState(false);
6858
- const [timelineLoop, setTimelineLoop] = useState(true);
7907
+ // Default off — looping is an explicit opt-in per session, not a surprise
7908
+ // once the user presses Play (rca/issue-video-artboard-loop-defaults-on).
7909
+ const [timelineLoop, setTimelineLoop] = useState(false);
6859
7910
  const [timelineMuted, setTimelineMuted] = useState(false);
6860
7911
  const [timelineVolume, setTimelineVolume] = useState(1);
6861
7912
  const [timelineHeight, setTimelineHeight] = useState(216);
@@ -6878,6 +7929,15 @@ function App() {
6878
7929
  useEffect(() => {
6879
7930
  timelineCompIdRef.current = timelineCompId;
6880
7931
  }, [timelineCompId]);
7932
+ // rca/issue-video-artboard-frame-reset-on-edit — last known playhead, read
7933
+ // from the `timeline-comps` handler below (which fires on every comp
7934
+ // (re)mount, incl. a ⌘R hard iframe reload) to re-seed the fresh Player
7935
+ // instance. A ref, not state, so the handler always sees the latest value
7936
+ // without pulling `timelineFrame` into that big message-listener's deps.
7937
+ const timelineFrameRef = useRef(0);
7938
+ useEffect(() => {
7939
+ timelineFrameRef.current = timelineFrame;
7940
+ }, [timelineFrame]);
6881
7941
  // The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
6882
7942
  // /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
6883
7943
  // rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
@@ -6925,6 +7985,17 @@ function App() {
6925
7985
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
6926
7986
  // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
6927
7987
  const [inspectorTab, setInspectorTab] = useState('inspect');
7988
+ // feature-photo-editor (Task 13) — the annotation-image Photo-tab target,
7989
+ // threaded up from the annotation layer's "Edit Photo…" (the annotation model
7990
+ // has no DOM selection to ride, unlike an artboard `<img>`). `{ asset, strokeId }`
7991
+ // or null. Cleared whenever a normal DOM selection arrives so the two never
7992
+ // fight over the panel. The shell-side photo undo stack lives alongside it.
7993
+ const [photoSel, setPhotoSel] = useState(null);
7994
+ const photoUndoRef = useRef({ undo: [], redo: [] });
7995
+ // Bumped by the Cmd+Z/Cmd+Shift+Z photo-undo handler below so the mounted
7996
+ // PhotoKnobs (keyed on `asset:photoRev`) remounts and re-fetches the sidecar
7997
+ // it just PUT, instead of drifting from its own stale local `edit` state.
7998
+ const [photoRev, setPhotoRev] = useState(0);
6928
7999
  // feature-element-editing-robustness Stage C — auto-open the Inspector on the
6929
8000
  // CSS tab when a fresh single selection arrives AND no right panel is already
6930
8001
  // open. Preference-backed (default ON); disable it in the View menu. Refs keep
@@ -6959,14 +8030,57 @@ function App() {
6959
8030
  // ~13 call sites and only some closed their siblings, so a panel opened via a
6960
8031
  // path that left a sibling `true` rendered *behind* it under the fixed
6961
8032
  // precedence — looking like the new panel "overlapped" the old one.)
6962
- const openRightPanel = useCallback((which) => {
6963
- setChangesOpen(which === 'changes');
6964
- setInspectorOpen(which === 'inspector');
6965
- setCommentsPanelOpen(which === 'comments');
6966
- setAssistantOpen(which === 'assistant');
6967
- // NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
6968
- // mutual-exclusion it stays open when a right panel opens.
8033
+ // feature-configurable-panel-docking generic, SIDE-AWARE panel management.
8034
+ // A slot (left/right) shows one panel at a time, so opening a panel closes any
8035
+ // OTHER open panel ON THE SAME SIDE (panels on the other side are independent).
8036
+ // A live ref carries the current open + side maps so the callbacks stay stable
8037
+ // yet never act on stale state. (The Timeline is a BOTTOM dock, DDR-148, and is
8038
+ // NOT part of either slot's mutual-exclusion.)
8039
+ const panelStateRef = useRef({ open: {}, side: PANEL_SIDES_DEFAULTS });
8040
+ panelStateRef.current = {
8041
+ open: {
8042
+ tree: sidebarOpen,
8043
+ layers: layersOpen,
8044
+ inspector: inspectorOpen,
8045
+ comments: commentsPanelOpen,
8046
+ changes: changesOpen,
8047
+ assistant: assistantOpen,
8048
+ },
8049
+ side: panelSide,
8050
+ };
8051
+ const setPanelOpen = useCallback((id, val) => {
8052
+ if (id === 'tree') setSidebarOpen(val);
8053
+ else if (id === 'layers') setLayersOpen(val);
8054
+ else if (id === 'inspector') setInspectorOpen(val);
8055
+ else if (id === 'comments') setCommentsPanelOpen(val);
8056
+ else if (id === 'changes') setChangesOpen(val);
8057
+ else if (id === 'assistant') setAssistantOpen(val);
6969
8058
  }, []);
8059
+ const sideOf = useCallback(
8060
+ (id) => panelStateRef.current.side?.[id] || PANEL_SIDES_DEFAULTS[id],
8061
+ []
8062
+ );
8063
+ const openPanelExclusive = useCallback(
8064
+ (id) => {
8065
+ const side = sideOf(id);
8066
+ setPanelOpen(id, true);
8067
+ for (const p of DOCK_PANELS) {
8068
+ if (p.id !== id && sideOf(p.id) === side) setPanelOpen(p.id, false);
8069
+ }
8070
+ },
8071
+ [setPanelOpen, sideOf]
8072
+ );
8073
+ const togglePanel = useCallback(
8074
+ (id) => {
8075
+ if (panelStateRef.current.open?.[id]) setPanelOpen(id, false);
8076
+ else openPanelExclusive(id);
8077
+ },
8078
+ [openPanelExclusive, setPanelOpen]
8079
+ );
8080
+ // Legacy name kept for the many call sites; now side-aware (opens `which` and
8081
+ // closes only same-side siblings, so a panel moved to the left is unaffected).
8082
+ const openRightPanel = openPanelExclusive;
8083
+ const toggleRightPanel = togglePanel;
6970
8084
  // feature-element-editing-robustness Stage C (Task C1) — open the Inspector on
6971
8085
  // the CSS tab for a fresh SINGLE selection, but only when nothing is already
6972
8086
  // docked on the right (never override an open Layers/Inspect/Comments panel)
@@ -6982,47 +8096,6 @@ function App() {
6982
8096
  },
6983
8097
  [openRightPanel]
6984
8098
  );
6985
- // Functional updates so this is stale-closure-safe inside the keydown /
6986
- // postMessage listeners; opening always clears the sibling panels.
6987
- const toggleRightPanel = useCallback((which) => {
6988
- if (which === 'inspector') {
6989
- setInspectorOpen((v) => {
6990
- if (!v) {
6991
- setChangesOpen(false);
6992
- setCommentsPanelOpen(false);
6993
- setAssistantOpen(false);
6994
- }
6995
- return !v;
6996
- });
6997
- } else if (which === 'comments') {
6998
- setCommentsPanelOpen((v) => {
6999
- if (!v) {
7000
- setChangesOpen(false);
7001
- setInspectorOpen(false);
7002
- setAssistantOpen(false);
7003
- }
7004
- return !v;
7005
- });
7006
- } else if (which === 'changes') {
7007
- setChangesOpen((v) => {
7008
- if (!v) {
7009
- setInspectorOpen(false);
7010
- setCommentsPanelOpen(false);
7011
- setAssistantOpen(false);
7012
- }
7013
- return !v;
7014
- });
7015
- } else if (which === 'assistant') {
7016
- setAssistantOpen((v) => {
7017
- if (!v) {
7018
- setChangesOpen(false);
7019
- setInspectorOpen(false);
7020
- setCommentsPanelOpen(false);
7021
- }
7022
- return !v;
7023
- });
7024
- }
7025
- }, []);
7026
8099
  // DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
7027
8100
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
7028
8101
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
@@ -7088,7 +8161,7 @@ function App() {
7088
8161
  setup: (step) => {
7089
8162
  if (!step) return;
7090
8163
  if ((step.canvas || step.requireSelection) && tabs.length === 0) {
7091
- setSidebarOpen(true);
8164
+ openPanelExclusive('tree');
7092
8165
  setTimeout(() => {
7093
8166
  try {
7094
8167
  document.querySelector('.st-sidebar [role="treeitem"]')?.click();
@@ -7108,13 +8181,19 @@ function App() {
7108
8181
  localStorage.setItem(USAGE_TOUR_STORE, '1');
7109
8182
  } catch {}
7110
8183
  }, []);
7111
- const [annotationsVisible, setAnnotationsVisible] = useState(true);
8184
+ const [annotationsVisible, setAnnotationsVisible] = useState(() =>
8185
+ readBoolStore(ANNOT_STORE, true)
8186
+ );
8187
+ // feature-unified-settings-modal — gate the disk-write effect until the boot
8188
+ // GET /_api/ui-prefs has reconciled (disk-wins), so we never clobber a stored
8189
+ // pref with the localStorage/default value on the first render.
8190
+ const [uiPrefsHydrated, setUiPrefsHydrated] = useState(false);
7112
8191
  // Canvas-chrome visibility (View menu). minimap + zoom-controls are
7113
8192
  // persistent prefs broadcast to every open canvas iframe; presentMode is a
7114
8193
  // non-destructive "hide ALL chrome + shell, artboards only" overlay with an
7115
8194
  // Esc / floating-pill escape hatch back to the chrome.
7116
- const [minimapVisible, setMinimapVisible] = useState(true);
7117
- const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
8195
+ const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
8196
+ const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
7118
8197
  const [presentMode, setPresentMode] = useState(false);
7119
8198
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
7120
8199
  // so the top bar no longer mirrors it.)
@@ -7145,6 +8224,96 @@ function App() {
7145
8224
  [activePath]
7146
8225
  );
7147
8226
 
8227
+ // ── feature-photo-editor — the Photo tab's three channels ─────────────────
8228
+ // (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
8229
+ // canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
8230
+ // into the matching `<img>`/`<image>` element's src/href (iteration 2 — see
8231
+ // the bridge's own header comment for why it's a direct swap, not an
8232
+ // overlay). (2) undo: a shell-side stack (photo edits are sidecar writes,
8233
+ // not the canvas-side source-edit stack). (3) background removal: the
8234
+ // client-side @imgly ML flow (Task 12).
8235
+ const onPhotoEdit = useCallback(
8236
+ (asset, edit) => postToActiveCanvas({ dgn: 'photo-preview', asset, edit }),
8237
+ [postToActiveCanvas]
8238
+ );
8239
+ const onPhotoRecordEdit = useCallback((asset, before, after) => {
8240
+ photoUndoRef.current.undo.push({ asset, before, after });
8241
+ photoUndoRef.current.redo.length = 0;
8242
+ }, []);
8243
+ // Pops the shell-side photo-undo stack, re-persists the reverted edit through
8244
+ // the same PUT route PhotoKnobs itself uses, re-broadcasts it to the live
8245
+ // preview, and bumps `photoRev` so the mounted PhotoKnobs (keyed on
8246
+ // `asset:photoRev`) remounts and re-fetches instead of drifting from its own
8247
+ // stale local state. Returns false (does nothing) when the requested stack is
8248
+ // empty, so callers can fall through to the canvas's own undo stack.
8249
+ const performPhotoUndo = useCallback(
8250
+ (redo) => {
8251
+ const stack = redo ? photoUndoRef.current.redo : photoUndoRef.current.undo;
8252
+ if (!stack.length) return false;
8253
+ const entry = stack.pop();
8254
+ (redo ? photoUndoRef.current.undo : photoUndoRef.current.redo).push(entry);
8255
+ const edit = (redo ? entry.after : entry.before) || {};
8256
+ onPhotoEdit(entry.asset, edit);
8257
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(entry.asset)}`, {
8258
+ method: 'PUT',
8259
+ headers: { 'Content-Type': 'application/json' },
8260
+ body: JSON.stringify(edit),
8261
+ }).catch(() => {});
8262
+ setPhotoRev((v) => v + 1);
8263
+ return true;
8264
+ },
8265
+ [onPhotoEdit]
8266
+ );
8267
+ // Task 12 — magic background removal, entirely client-side (WASM/WebGPU via
8268
+ // @imgly/background-removal — NEVER the -node native-addon variant, DDR-070).
8269
+ // The lib is DYNAMICALLY imported so a session that never removes a background
8270
+ // pays zero bundle cost (the lazy-bundle guarantee). Fetches the source bytes,
8271
+ // runs the matte, uploads it content-addressed through the SAME /_api/asset lane
8272
+ // drag-drop uses, and returns the new `assets/<sha8>.png` for PhotoEdit.
8273
+ const onPhotoRemoveBackground = useCallback(
8274
+ async (asset) => {
8275
+ // Scan/shimmer reveal on the live photo while the ML pass runs, the same
8276
+ // "something is actively happening here" language as the agent-edit
8277
+ // artboard rim (artboard-activity-overlay.tsx) — but applied directly to
8278
+ // the photo element itself (a `data-photo-busy` attribute + injected CSS
8279
+ // mask sweep, inspect.ts) rather than a floating tracked overlay. A
8280
+ // floating decoy is exactly the architecture DDR-161's addendum tore out
8281
+ // for the live-edit preview (z-index/resize/hit-testing bugs); an
8282
+ // attribute toggle on the real element sidesteps that whole class.
8283
+ postToActiveCanvas({ dgn: 'photo-busy', asset, busy: true });
8284
+ try {
8285
+ const srcRes = await fetch(`/${asset.replace(/^\/+/, '')}`);
8286
+ if (!srcRes.ok) return null;
8287
+ const srcBlob = await srcRes.blob();
8288
+ const { removeBackground } = await import('@imgly/background-removal');
8289
+ // Inference is 100% CLIENT-SIDE (WASM/WebGPU) — the user's pixels NEVER
8290
+ // leave the browser. Only the public model weights (~40 MB, identical for
8291
+ // everyone) are fetched from IMG.LY's default host on first use; they're
8292
+ // too large to bundle in the npm tarball (`resources.json` ships empty).
8293
+ // Self-hosting those weights off the dev server for offline / air-gapped
8294
+ // parity is the flagged Task-11 follow-up (a one-time download step) — until
8295
+ // then the default host provides them, with no pixel-privacy exposure.
8296
+ const matte = await removeBackground(srcBlob);
8297
+ const up = await fetch('/_api/asset', {
8298
+ method: 'POST',
8299
+ headers: { 'content-type': matte.type || 'image/png' },
8300
+ body: matte,
8301
+ });
8302
+ const j = await up.json().catch(() => ({}));
8303
+ if (up.ok && j.path) return { maskAsset: j.path };
8304
+ return null;
8305
+ } catch (err) {
8306
+ console.error('[photo] background removal failed', err);
8307
+ return null;
8308
+ } finally {
8309
+ // Always clears, success or failure — an errored pass must not leave
8310
+ // the shimmer stuck on the photo forever.
8311
+ postToActiveCanvas({ dgn: 'photo-busy', asset, busy: false });
8312
+ }
8313
+ },
8314
+ [postToActiveCanvas]
8315
+ );
8316
+
7148
8317
  // DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
7149
8318
  // with no drop target used to make the browser NAVIGATE AWAY to the file
7150
8319
  // (file:// replaces the app). Document-level guard: always cancel the default
@@ -7533,6 +8702,87 @@ function App() {
7533
8702
  localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
7534
8703
  } catch {}
7535
8704
  }, [sectionsExpanded]);
8705
+ useEffect(() => {
8706
+ try {
8707
+ localStorage.setItem(MINIMAP_STORE, minimapVisible ? '1' : '0');
8708
+ } catch {}
8709
+ }, [minimapVisible]);
8710
+ useEffect(() => {
8711
+ try {
8712
+ localStorage.setItem(ZOOMCTL_STORE, zoomCtlVisible ? '1' : '0');
8713
+ } catch {}
8714
+ }, [zoomCtlVisible]);
8715
+ useEffect(() => {
8716
+ try {
8717
+ localStorage.setItem(ANNOT_STORE, annotationsVisible ? '1' : '0');
8718
+ } catch {}
8719
+ }, [annotationsVisible]);
8720
+
8721
+ // feature-unified-settings-modal — reconcile view prefs with the on-disk store
8722
+ // once on mount (disk wins over the localStorage/default init: it survives a
8723
+ // cleared localStorage). Applying a value that already matches is a no-op, so
8724
+ // there's no flash in the common case. On failure we still mark hydrated so
8725
+ // writes resume best-effort.
8726
+ useEffect(() => {
8727
+ let cancelled = false;
8728
+ fetch('/_api/ui-prefs')
8729
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
8730
+ .then((p) => {
8731
+ if (cancelled || !p || typeof p !== 'object') return;
8732
+ if (p.theme === 'light' || p.theme === 'dark') setTheme(p.theme);
8733
+ if (typeof p.minimap === 'boolean') setMinimapVisible(p.minimap);
8734
+ if (typeof p.zoom === 'boolean') setZoomCtlVisible(p.zoom);
8735
+ if (typeof p.annotations === 'boolean') setAnnotationsVisible(p.annotations);
8736
+ if (typeof p.autoOpenInspector === 'boolean') setAutoOpenInspector(p.autoOpenInspector);
8737
+ if (p.panelSides && typeof p.panelSides === 'object')
8738
+ setPanelSide({ ...PANEL_SIDES_DEFAULTS, ...p.panelSides });
8739
+ if (p.layersMode === 'separate' || p.layersMode === 'in-inspector')
8740
+ setLayersMode(p.layersMode);
8741
+ })
8742
+ .catch(() => {})
8743
+ .finally(() => {
8744
+ if (!cancelled) setUiPrefsHydrated(true);
8745
+ });
8746
+ return () => {
8747
+ cancelled = true;
8748
+ };
8749
+ }, []);
8750
+
8751
+ // Mirror every view pref to disk once hydrated. The barrier stops the initial
8752
+ // render (localStorage/default values) from overwriting the disk store before
8753
+ // the boot GET has reconciled.
8754
+ useEffect(() => {
8755
+ if (!uiPrefsHydrated) return;
8756
+ persistUiPrefs({
8757
+ theme,
8758
+ minimap: minimapVisible,
8759
+ zoom: zoomCtlVisible,
8760
+ annotations: annotationsVisible,
8761
+ autoOpenInspector,
8762
+ panelSides: panelSide,
8763
+ layersMode,
8764
+ });
8765
+ }, [
8766
+ uiPrefsHydrated,
8767
+ theme,
8768
+ minimapVisible,
8769
+ zoomCtlVisible,
8770
+ annotationsVisible,
8771
+ autoOpenInspector,
8772
+ panelSide,
8773
+ layersMode,
8774
+ ]);
8775
+ // localStorage mirror for panel sides + layers mode (synchronous no-flash init).
8776
+ useEffect(() => {
8777
+ try {
8778
+ localStorage.setItem(PANEL_SIDES_STORE, JSON.stringify(panelSide));
8779
+ } catch {}
8780
+ }, [panelSide]);
8781
+ useEffect(() => {
8782
+ try {
8783
+ localStorage.setItem(LAYERS_MODE_STORE, layersMode);
8784
+ } catch {}
8785
+ }, [layersMode]);
7536
8786
 
7537
8787
  const toggleSection = useCallback((label, defaultOpen) => {
7538
8788
  setSectionsExpanded((prev) => {
@@ -7563,7 +8813,14 @@ function App() {
7563
8813
  // fetch (either may land first). `?? {}` keeps older servers (no map) on
7564
8814
  // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
7565
8815
  // canvas refreshes the map.
7566
- setCfg((prev) => ({ ...prev, canvasDesignSystems: data.canvasDesignSystems ?? {} }));
8816
+ setCfg((prev) => ({
8817
+ ...prev,
8818
+ canvasDesignSystems: data.canvasDesignSystems ?? {},
8819
+ // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
8820
+ // only `reconstructed-experimental`), folded in the same way + for the
8821
+ // same reason as canvasDesignSystems above.
8822
+ canvasKinds: data.canvasKinds ?? {},
8823
+ }));
7567
8824
  } catch (e) {
7568
8825
  console.error('failed to load tree', e);
7569
8826
  }
@@ -7863,8 +9120,11 @@ function App() {
7863
9120
  useEffect(() => {
7864
9121
  if (gitStatus?.repo === false) return; // solo / non-git project — no remote
7865
9122
  refreshRemoteSync();
7866
- if (!changesOpen) return; // only keep polling while the panel is visible
7867
- const id = setInterval(refreshRemoteSync, 60000);
9123
+ // Poll in the background too not only while Changes is open — so the dock's
9124
+ // "Get latest" nudge surfaces a teammate's publish proactively. Slower cadence
9125
+ // when the panel is closed to stay network-polite (the ahead/behind probe is
9126
+ // TTL-cached server-side, so a missed tick is cheap to re-issue).
9127
+ const id = setInterval(refreshRemoteSync, changesOpen ? 60000 : 120000);
7868
9128
  return () => clearInterval(id);
7869
9129
  }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
7870
9130
 
@@ -8245,7 +9505,22 @@ function App() {
8245
9505
  // canvas must not be able to plant a selection with no user gesture. The
8246
9506
  // user can only click the visible active canvas, so a non-active source is
8247
9507
  // never a legitimate selection.
8248
- if (m.dgn === 'select' || m.dgn === 'select-set' || m.dgn === 'clear-select') {
9508
+ //
9509
+ // The SAME gate now covers the privileged SOURCE-WRITE relays — `edit-text`
9510
+ // (inline text commit) and `apply-edit` (undo/redo re-application). Both
9511
+ // ride into a main-origin `.tsx` rewrite; a write path is strictly more
9512
+ // dangerous than a selection, yet was previously ungated (ethical-hacker
9513
+ // F-B, DDR-160 follow-up). A background/synced untrusted canvas (DDR-054)
9514
+ // must not drive a gestureless write into another canvas — and inline edits
9515
+ // + Cmd+Z always originate in the ACTIVE canvas the user is looking at, so
9516
+ // a non-active source is never legitimate here either.
9517
+ if (
9518
+ m.dgn === 'select' ||
9519
+ m.dgn === 'select-set' ||
9520
+ m.dgn === 'clear-select' ||
9521
+ m.dgn === 'edit-text' ||
9522
+ m.dgn === 'apply-edit'
9523
+ ) {
8249
9524
  const activeWin =
8250
9525
  activePath && activePath !== SYSTEM_TAB
8251
9526
  ? iframesRef.current.get(activePath)?.contentWindow
@@ -8253,10 +9528,12 @@ function App() {
8253
9528
  if (e.source !== activeWin) return;
8254
9529
  }
8255
9530
  if (m.dgn === 'select' && m.selection) {
9531
+ setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
8256
9532
  wsSend({ type: 'select', selection: m.selection });
8257
9533
  setSelected(m.selection);
8258
9534
  maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
8259
9535
  } else if (m.dgn === 'select-set') {
9536
+ setPhotoSel(null);
8260
9537
  // Canvas multi-select. Payload shape:
8261
9538
  // null → empty selection
8262
9539
  // Selection → length-1 (back-compat with legacy single-element shape)
@@ -8280,6 +9557,7 @@ function App() {
8280
9557
  maybeAutoOpenInspectorOnSelect(payload); // Stage C
8281
9558
  }
8282
9559
  } else if (m.dgn === 'clear-select') {
9560
+ setPhotoSel(null);
8283
9561
  wsSend({ type: 'clear-select' });
8284
9562
  setSelected(null);
8285
9563
  } else if (m.dgn === 'edit-text' && m.id) {
@@ -8301,7 +9579,15 @@ function App() {
8301
9579
  fetch('/_api/edit-text', {
8302
9580
  method: 'POST',
8303
9581
  headers: { 'content-type': 'application/json' },
8304
- body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
9582
+ body: JSON.stringify({
9583
+ canvas: m.file,
9584
+ id: m.id,
9585
+ text: m.text ?? '',
9586
+ // Context for editing `{variable}` text — which rendered instance +
9587
+ // its pre-edit text, so the engine targets the right source string.
9588
+ ...(typeof m.occurrence === 'number' ? { occurrence: m.occurrence } : {}),
9589
+ ...(typeof m.before === 'string' ? { before: m.before } : {}),
9590
+ }),
8305
9591
  })
8306
9592
  .then((r) => r.json().catch(() => ({})))
8307
9593
  .then((j) => {
@@ -8334,6 +9620,11 @@ function App() {
8334
9620
  } else {
8335
9621
  url = '/_api/edit-text';
8336
9622
  body = { canvas: m.canvas, id: m.id, text: value ?? '' };
9623
+ // Undo/redo of a `{variable}` text edit needs to re-target the source
9624
+ // string: the occurrence + the value currently on disk (`from` — the
9625
+ // side we're replacing FROM). Harmless for literal text.
9626
+ if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
9627
+ if (typeof m.from === 'string') body.before = m.from;
8337
9628
  }
8338
9629
  editApplyChainRef.current = editApplyChainRef.current
8339
9630
  .catch(() => {})
@@ -8510,40 +9801,66 @@ function App() {
8510
9801
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8511
9802
  if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
8512
9803
  } else if (m.dgn === 'insert-request') {
8513
- // Stage I3 — insert a synthesized div/text/image relative to `refId`.
9804
+ // Stage I3 — insert a synthesized div/text/image relative to `refId`,
9805
+ // OR — empty-artboard fallback (tool-palette "+ Element" on a fresh
9806
+ // artboard with no elements yet) — as a child of `artboardId`. Exactly
9807
+ // one of the two must be present; the artboardId variant only makes
9808
+ // sense inside-start/inside-end (no sibling to be before/after).
8514
9809
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9810
+ const hasRefId = typeof m.refId === 'string';
9811
+ const hasArtboardId = typeof m.artboardId === 'string';
9812
+ const okPosition =
9813
+ m.position === 'before' ||
9814
+ m.position === 'after' ||
9815
+ m.position === 'inside-start' ||
9816
+ m.position === 'inside-end';
8515
9817
  const okShape =
8516
- typeof m.refId === 'string' &&
9818
+ hasRefId !== hasArtboardId &&
8517
9819
  (m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
8518
- (m.position === 'before' ||
8519
- m.position === 'after' ||
8520
- m.position === 'inside-start' ||
8521
- m.position === 'inside-end');
9820
+ okPosition &&
9821
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8522
9822
  if (e.source === activeWin && okShape) {
8523
9823
  insertElementShellRef.current?.(m.refId, m.position, m.kind, {
9824
+ artboardId: hasArtboardId ? m.artboardId : undefined,
8524
9825
  src: typeof m.src === 'string' ? m.src : undefined,
8525
9826
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8526
9827
  });
8527
9828
  }
8528
9829
  } else if (m.dgn === 'insert-image-request') {
8529
- // Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
8530
- // needs a contained asset src, so the shell opens the AssetPicker; the
8531
- // pick then drives insertElementShell(kind:'image'). Confused-deputy
8532
- // gated + pinned to the active canvas like the other request verbs.
9830
+ // Stage F/I3 — "Insert ▸ Image" from the canvas context menu, or the
9831
+ // tool-palette's Image tool (`artboardId` in place of `refId` for its
9832
+ // empty-artboard fallback, or NEITHER when the canvas has no artboard
9833
+ // at all feature-bulk-media-insert). An image needs a contained
9834
+ // asset src, so the shell opens the AssetPicker in bulk mode; the
9835
+ // confirm then drives insertElementShell(kind:'image') per path (Task
9836
+ // 10) or a single batched annotation insert. Confused-deputy gated +
9837
+ // pinned to the active canvas like the other request verbs.
8533
9838
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9839
+ const hasRefId = typeof m.refId === 'string';
9840
+ const hasArtboardId = typeof m.artboardId === 'string';
9841
+ const noAnchor = !hasRefId && !hasArtboardId;
8534
9842
  const okShape =
8535
- typeof m.refId === 'string' &&
8536
- (m.position === 'before' ||
9843
+ (hasRefId !== hasArtboardId || noAnchor) &&
9844
+ (noAnchor ||
9845
+ m.position === 'before' ||
8537
9846
  m.position === 'after' ||
8538
9847
  m.position === 'inside-start' ||
8539
- m.position === 'inside-end');
9848
+ m.position === 'inside-end') &&
9849
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8540
9850
  if (e.source === activeWin && okShape) {
8541
9851
  openAssetPickerRef.current?.({
8542
9852
  purpose: 'insert-image',
8543
9853
  canvas: activePath,
8544
9854
  refId: m.refId,
8545
- position: m.position,
9855
+ artboardId: hasArtboardId ? m.artboardId : undefined,
9856
+ position: noAnchor ? undefined : m.position,
8546
9857
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
9858
+ multiple: true,
9859
+ // Artboard destination is possible whenever there's ANY resolvable
9860
+ // anchor — a refId (context-menu, or the tool-palette's normal
9861
+ // last-element case) inserts inside the SAME artboard that
9862
+ // element already lives in, not just the artboardId fallback.
9863
+ hasArtboardAnchor: !noAnchor,
8547
9864
  });
8548
9865
  }
8549
9866
  } else if (m.dgn === 'replace-media-request') {
@@ -8573,6 +9890,27 @@ function App() {
8573
9890
  before: typeof m.before === 'string' ? m.before : null,
8574
9891
  });
8575
9892
  }
9893
+ } else if (m.dgn === 'edit-annotation-photo-request') {
9894
+ // feature-photo-editor (Task 17) — "Edit Photo…" on an annotation
9895
+ // ImageStroke. The annotation model has no data-cd-id / DOM selection, so
9896
+ // it can't ride the normal select path; instead the canvas posts its
9897
+ // `assets/<sha8>.<ext>` href up and the shell opens the Photo-only tab on
9898
+ // it. Same confused-deputy gate (DDR-054) + active-canvas pin as F3.
9899
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9900
+ const asset = PHOTO_ASSET_RE.exec(typeof m.asset === 'string' ? m.asset : '')?.[0] || null;
9901
+ if (e.source === activeWin && typeof m.id === 'string' && asset) {
9902
+ setPhotoSel({ asset, strokeId: m.id });
9903
+ openRightPanel('inspector');
9904
+ setInspectorTab('photo');
9905
+ }
9906
+ } else if (m.dgn === 'open-sticker-picker') {
9907
+ // Phase 4 (whiteboard-improvements) — the toolbar's Stickers button.
9908
+ // Confused-deputy gated + pinned to the active canvas like the other
9909
+ // request verbs above.
9910
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9911
+ if (e.source === activeWin) {
9912
+ openStickerPickerRef.current?.({ canvas: activePath });
9913
+ }
8576
9914
  } else if (m.dgn === 'insert-artboard-request') {
8577
9915
  // Stage I4 — insert a new empty artboard from a screen-size preset.
8578
9916
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
@@ -8609,7 +9947,12 @@ function App() {
8609
9947
  }
8610
9948
  } else if (m.dgn === 'open-inspector') {
8611
9949
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
9950
+ // feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
9951
+ // specific tab (the element "Edit Photo…" entry passes `tab: 'photo'`; the
9952
+ // img is already selected via select-set, so the Photo tab derives its
9953
+ // target from that selection's `photoAsset`).
8612
9954
  openRightPanel('inspector');
9955
+ if (typeof m.tab === 'string') setInspectorTab(m.tab);
8613
9956
  } else if (m.dgn === 'present-enter') {
8614
9957
  // Canvas tool-palette "Presentation mode" button — Present Mode is a
8615
9958
  // shell-level state (hides the menubar / sidebar / panels), so the
@@ -8703,6 +10046,23 @@ function App() {
8703
10046
  height: Math.max(1, Math.round(Number(c.height) || 0)),
8704
10047
  }));
8705
10048
  setActiveComps(safe);
10049
+ // rca/issue-video-artboard-frame-reset-on-edit — the SAME comp
10050
+ // re-announcing means its Player just remounted (an edit-triggered
10051
+ // HMR remount, or a ⌘R hard iframe reload that wiped video-comp.tsx's
10052
+ // own module-scope frame mirror too). The shell's `timelineFrame`
10053
+ // survives either way (this component doesn't remount) — push it back
10054
+ // down so the fresh Player opens where the Timeline left off, instead
10055
+ // of the poster-frame default. Skip on a genuinely new comp/canvas
10056
+ // (no prior id, or frame still at its post-switch 0).
10057
+ const prevCompId = timelineCompIdRef.current;
10058
+ const stillPresent = prevCompId && safe.some((c) => c.id === prevCompId);
10059
+ if (stillPresent && timelineFrameRef.current > 0) {
10060
+ postToActiveCanvas({
10061
+ dgn: 'timeline-seek',
10062
+ frame: timelineFrameRef.current,
10063
+ id: prevCompId,
10064
+ });
10065
+ }
8706
10066
  } else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
8707
10067
  // Live playhead mirror from the Player (preview scrub/playback).
8708
10068
  const activeWin =
@@ -8714,6 +10074,17 @@ function App() {
8714
10074
  // artboard's Player must not fight it for the readout (multi-comp canvas).
8715
10075
  if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
8716
10076
  if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
10077
+ } else if (m.dgn === 'timeline-ended') {
10078
+ // The Player stopped itself at the last frame (loop off) — resync the
10079
+ // shell's Play/Pause button, which otherwise has no way to learn
10080
+ // playback ended on its own (rca/issue-video-artboard-loop-defaults-on).
10081
+ const activeWin =
10082
+ activePath && activePath !== SYSTEM_TAB
10083
+ ? iframesRef.current.get(activePath)?.contentWindow
10084
+ : null;
10085
+ if (e.source !== activeWin) return;
10086
+ if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
10087
+ setTimelinePlaying(false);
8717
10088
  } else if (m.dgn === 'toggle-palette') {
8718
10089
  // ⌘K pressed while focus was inside the canvas iframe — the injected
8719
10090
  // inspector forwards the chord here since the iframe's keydown never
@@ -8726,6 +10097,8 @@ function App() {
8726
10097
  else if (m.id === 'inspector') toggleRightPanel('inspector');
8727
10098
  else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
8728
10099
  else if (m.id === 'comments') toggleRightPanel('comments');
10100
+ else if (m.id === 'changes') toggleRightPanel('changes');
10101
+ else if (m.id === 'timeline') toggleTimeline();
8729
10102
  else if (m.id === 'export') setExportDialog({ mode: 'export' });
8730
10103
  else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
8731
10104
  } else if (m.dgn === 'open-export') {
@@ -8736,6 +10109,11 @@ function App() {
8736
10109
  mode: 'export',
8737
10110
  scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
8738
10111
  });
10112
+ } else if (m.dgn === 'open-timeline-request') {
10113
+ // Artboard-chrome context menu's "Open Timeline" (video-comp artboards
10114
+ // only). Scope the Timeline to the right-clicked artboard, then open it.
10115
+ if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
10116
+ setTimelineOpen(true);
8739
10117
  } else if (m.dgn === 'loaded' && m.file) {
8740
10118
  // iframe finished loading — drop the compile skeleton, push current
8741
10119
  // comments + carry over focused pin if any
@@ -8885,6 +10263,7 @@ function App() {
8885
10263
  zoomCtlVisible,
8886
10264
  broadcastChrome,
8887
10265
  activePath,
10266
+ postToActiveCanvas,
8888
10267
  ]);
8889
10268
 
8890
10269
  // Tell the active canvas iframe to drop any persistent selection (canvas
@@ -9269,6 +10648,7 @@ function App() {
9269
10648
  '/_api/insert-element',
9270
10649
  {
9271
10650
  refId,
10651
+ artboardId: typeof opts.artboardId === 'string' ? opts.artboardId : undefined,
9272
10652
  position,
9273
10653
  kind,
9274
10654
  src: typeof opts.src === 'string' ? opts.src : undefined,
@@ -9287,6 +10667,32 @@ function App() {
9287
10667
  [structuralWrite]
9288
10668
  );
9289
10669
 
10670
+ // feature-ai-media-generation Phase 1 (Task 1.1) — auto-insert a generated
10671
+ // image onto the canvas where the user is looking, so generation is never a
10672
+ // dead-end modal. The image landed in the content-addressed asset store
10673
+ // (assets/<sha8>.png) via the privileged /_api/generate-jobs route; splice it
10674
+ // into the active artboard through the SAME main-origin source-write lane the
10675
+ // AssetPicker uses (insertElementShell → /_api/insert-element). Returns true
10676
+ // when it placed the image, false when there's no target artboard (no canvas
10677
+ // open / no active artboard) so the caller can fall back to a manual affordance.
10678
+ const insertGeneratedImage = useCallback(
10679
+ (assetPath) => {
10680
+ if (typeof assetPath !== 'string' || !assetPath) return false;
10681
+ if (!activePath) return false;
10682
+ // Respect the active-canvas + selected-artboard signals (_active.json): an
10683
+ // explicit selection wins, else the viewport-active artboard canvas-lib
10684
+ // reports on pan. Without a target artboard we can't source-write.
10685
+ const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
10686
+ if (!artboardId) return false;
10687
+ // Insert as the last child of the artboard (empty or not — the engine's
10688
+ // insertElementIntoArtboard handles both). Content-addressed src only; the
10689
+ // route contains an `image` src to assets/ (no remote hotlink / scheme).
10690
+ insertElementShell(undefined, 'inside-end', 'image', { artboardId, src: assetPath });
10691
+ return true;
10692
+ },
10693
+ [activePath, canvasActiveArtboard, insertElementShell]
10694
+ );
10695
+
9290
10696
  const duplicateElementShell = useCallback(
9291
10697
  (id, idIndex) => {
9292
10698
  structuralWrite(
@@ -9424,6 +10830,28 @@ function App() {
9424
10830
  },
9425
10831
  [structuralWrite]
9426
10832
  );
10833
+
10834
+ // Artboard "hug height" default — Hug ⇄ Fixed toggle from ArtboardKnobs
10835
+ // (CSS panel). Direct shell-side action (no canvas postMessage round trip,
10836
+ // unlike resizeArtboardShell's drag-overlay caller), so no *ShellRef needed.
10837
+ const setArtboardHugShell = useCallback(
10838
+ (artboardId, fixed, freezeHeight) => {
10839
+ structuralWrite(
10840
+ '/_api/set-artboard-hug',
10841
+ { artboardId, fixed, freezeHeight },
10842
+ { label: fixed ? 'pin artboard height' : 'hug artboard height' }
10843
+ );
10844
+ },
10845
+ [structuralWrite]
10846
+ );
10847
+
10848
+ // Artboard "more settings" (background/padding/layout/gap) from ArtboardKnobs.
10849
+ const setArtboardStyleShell = useCallback(
10850
+ (artboardId, patch) => {
10851
+ structuralWrite('/_api/set-artboard-style', { artboardId, ...patch }, { label: 'artboard style' });
10852
+ },
10853
+ [structuralWrite]
10854
+ );
9427
10855
  // Refs the (stale-closure) onMessage handlers below read.
9428
10856
  const deleteElementShellRef = useRef(null);
9429
10857
  const insertElementShellRef = useRef(null);
@@ -9474,8 +10902,11 @@ function App() {
9474
10902
  t.tagName === 'SELECT' ||
9475
10903
  t.isContentEditable);
9476
10904
  if (editable) return;
9477
- if (!activePath || activePath === SYSTEM_TAB) return;
10905
+ // Suppress the WKWebView back-nav unconditionally once focus isn't editable —
10906
+ // the activePath/SYSTEM_TAB check below is app logic, not default-action gating,
10907
+ // so it must not gate preventDefault (that gap caused the desktop "Starting…" hang).
9478
10908
  e.preventDefault();
10909
+ if (!activePath || activePath === SYSTEM_TAB) return;
9479
10910
  const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
9480
10911
  if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
9481
10912
  };
@@ -9491,6 +10922,16 @@ function App() {
9491
10922
  useEffect(() => {
9492
10923
  openAssetPickerRef.current = (req) => setAssetPickerReq(req);
9493
10924
  }, []);
10925
+
10926
+ // Phase 4 (whiteboard-improvements) — { canvas: activePath } when open, null
10927
+ // when closed. Mirrors assetPickerReq's ref-indirection (the message
10928
+ // listener below is set up with a minimal dep array, so it reaches a FRESH
10929
+ // opener via a ref rather than needing setStickerPickerReq in its own deps).
10930
+ const [stickerPickerReq, setStickerPickerReq] = useState(null);
10931
+ const openStickerPickerRef = useRef(null);
10932
+ useEffect(() => {
10933
+ openStickerPickerRef.current = (req) => setStickerPickerReq(req);
10934
+ }, []);
9494
10935
  const onAssetPicked = useCallback(
9495
10936
  (pickedPath) => {
9496
10937
  const req = assetPickerReq;
@@ -9506,6 +10947,7 @@ function App() {
9506
10947
  }
9507
10948
  if (req.purpose === 'insert-image') {
9508
10949
  insertElementShell(req.refId, req.position || 'after', 'image', {
10950
+ artboardId: req.artboardId,
9509
10951
  src: pickedPath,
9510
10952
  refIndex: req.refIndex,
9511
10953
  });
@@ -9551,6 +10993,77 @@ function App() {
9551
10993
  },
9552
10994
  [assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
9553
10995
  );
10996
+
10997
+ // feature-bulk-media-insert Task 10 — the picker's multi-select confirm.
10998
+ // `destination === 'artboard'`: loop insertElementShell once per path
10999
+ // (already race-safe via editApplyChainRef, no new serialization needed);
11000
+ // always appended `inside-end` of whichever artboard is currently active —
11001
+ // NOT the single-pick request's own refId/position, which only made sense
11002
+ // for inserting exactly one image relative to one anchor. `destination ===
11003
+ // 'annotation'`: one batched message so the canvas-side handler (Task 11)
11004
+ // performs a single atomic commit instead of N racing ones.
11005
+ const onPickMany = useCallback(
11006
+ (paths, destination) => {
11007
+ const req = assetPickerReq;
11008
+ setAssetPickerReq(null);
11009
+ if (!req || !Array.isArray(paths) || !paths.length) return;
11010
+ if (req.canvas && req.canvas !== activePath) {
11011
+ console.warn('[asset-picker] active canvas changed since request — aborting');
11012
+ return;
11013
+ }
11014
+ if (destination === 'artboard') {
11015
+ const artboardId = selectedRef.current?.artboardId ?? canvasActiveArtboard ?? null;
11016
+ if (!artboardId) return;
11017
+ for (const src of paths) {
11018
+ insertElementShell(undefined, 'inside-end', 'image', { artboardId, src });
11019
+ }
11020
+ return;
11021
+ }
11022
+ postToActiveCanvas({ dgn: 'insert-annotation-media', paths });
11023
+ },
11024
+ [assetPickerReq, activePath, insertElementShell, canvasActiveArtboard, postToActiveCanvas]
11025
+ );
11026
+
11027
+ // Phase 4 (whiteboard-improvements) — a bundled sticker has no project asset
11028
+ // path yet (it lives in MAUDE's own STICKERS_DIR, main-origin-only per
11029
+ // DDR-054), so re-upload its bytes through the SAME /_api/asset lane every
11030
+ // other image source uses (content-addressed, canvas-origin-allowlisted) —
11031
+ // then relay the resulting project-relative path down to the canvas exactly
11032
+ // like replace-annotation-media above (the annotation model owns its own
11033
+ // strokes; the shell performs no stroke write of its own).
11034
+ const onStickerPicked = useCallback(
11035
+ async (sticker) => {
11036
+ const req = stickerPickerReq;
11037
+ setStickerPickerReq(null);
11038
+ if (!req || !sticker?.url) return;
11039
+ // G3 security (DDR-152) — same re-check as onAssetPicked: the request
11040
+ // captured the active canvas when the picker opened; abort rather than
11041
+ // insert into whatever canvas happens to be active now.
11042
+ if (req.canvas && req.canvas !== activePath) {
11043
+ console.warn('[sticker-picker] active canvas changed since request — aborting');
11044
+ return;
11045
+ }
11046
+ try {
11047
+ const blob = await fetch(sticker.url).then((r) =>
11048
+ r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))
11049
+ );
11050
+ const res = await fetch('/_api/asset', {
11051
+ method: 'POST',
11052
+ headers: { 'content-type': blob.type || 'image/png' },
11053
+ body: blob,
11054
+ });
11055
+ const j = await res.json().catch(() => ({}));
11056
+ if (!res.ok || !j.path) {
11057
+ console.warn('[sticker-picker]', j.error || `upload failed (HTTP ${res.status})`);
11058
+ return;
11059
+ }
11060
+ postToActiveCanvas({ dgn: 'insert-sticker', path: j.path });
11061
+ } catch {
11062
+ console.warn('[sticker-picker] could not load or upload that sticker');
11063
+ }
11064
+ },
11065
+ [stickerPickerReq, activePath, postToActiveCanvas]
11066
+ );
9554
11067
  // Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
9555
11068
  // the element's current src as the undo before-value (captured from the
9556
11069
  // Selection's `attrs.src`, not the resolved URL).
@@ -9654,8 +11167,19 @@ function App() {
9654
11167
  ) {
9655
11168
  return; // the timeline shortcuts effect claims it
9656
11169
  }
9657
- e.preventDefault();
9658
11170
  const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
11171
+ // feature-photo-editor — photo edits are sidecar writes (`/_api/photo-edit`),
11172
+ // invisible to the canvas's own source-edit undo stack, so they need
11173
+ // their own owner here. Claims the keypress only while the Photo tab
11174
+ // is the one on screen AND it actually has something to undo/redo —
11175
+ // otherwise falls through to the canvas stack as before. (Focus still
11176
+ // inside a Photo-tab slider is handled separately by
11177
+ // `onPhotoKnobKeyDown`, mirroring `onKnobKeyDown` for CSS knobs.)
11178
+ if (inspectorTab === 'photo' && performPhotoUndo(redo)) {
11179
+ e.preventDefault();
11180
+ return;
11181
+ }
11182
+ e.preventDefault();
9659
11183
  postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
9660
11184
  return;
9661
11185
  }
@@ -9713,6 +11237,19 @@ function App() {
9713
11237
  setExportDialog({ mode: 'handoff' });
9714
11238
  return;
9715
11239
  }
11240
+ // Cmd+, — open Settings (AI generation keys). The platform convention for
11241
+ // a settings/preferences surface (feature-ai-media-generation, DDR-16x).
11242
+ if (meta && !e.shiftKey && !e.altKey && e.key === ',') {
11243
+ e.preventDefault();
11244
+ setSettingsOpen(true);
11245
+ return;
11246
+ }
11247
+ // Cmd+Shift+T — toggle the Timeline (video-comp scrub) dock.
11248
+ if (meta && e.shiftKey && (e.key === 't' || e.key === 'T')) {
11249
+ e.preventDefault();
11250
+ toggleTimeline();
11251
+ return;
11252
+ }
9716
11253
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
9717
11254
  // Canvas comment-drop is the `C` tool letter (press C in the canvas,
9718
11255
  // then click the element) or right-click "Add comment". Cmd+C now
@@ -9743,7 +11280,7 @@ function App() {
9743
11280
  }
9744
11281
  if (meta && (e.key === 'f' || e.key === 'F')) {
9745
11282
  e.preventDefault();
9746
- if (!sidebarOpen) setSidebarOpen(true);
11283
+ if (!sidebarOpen) openPanelExclusive('tree');
9747
11284
  setTimeout(() => {
9748
11285
  const inp = document.querySelector('.st-search input');
9749
11286
  if (inp) inp.focus();
@@ -9762,7 +11299,7 @@ function App() {
9762
11299
  if (e.key === 't' || e.key === 'T') {
9763
11300
  if (e.shiftKey || meta) return;
9764
11301
  e.preventDefault();
9765
- setSidebarOpen((v) => !v);
11302
+ togglePanel('tree');
9766
11303
  return;
9767
11304
  }
9768
11305
  // H — toggle show-hidden (sidecars + project/runtime orphans)
@@ -9786,7 +11323,7 @@ function App() {
9786
11323
  // which the browser reserves for New Window and never delivers).
9787
11324
  if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
9788
11325
  e.preventDefault();
9789
- setSidebarOpen(true);
11326
+ openPanelExclusive('tree');
9790
11327
  setTimeout(
9791
11328
  () => document.querySelector('[aria-label="New blank brief board"]')?.click(),
9792
11329
  60
@@ -9828,6 +11365,9 @@ function App() {
9828
11365
  clearActiveCanvasSelection,
9829
11366
  presentMode,
9830
11367
  exitPresent,
11368
+ toggleTimeline,
11369
+ inspectorTab,
11370
+ performPhotoUndo,
9831
11371
  ]);
9832
11372
 
9833
11373
  const registerIframe = useCallback((path, el) => {
@@ -9864,7 +11404,7 @@ function App() {
9864
11404
  icon: 'plus',
9865
11405
  kbd: 'N',
9866
11406
  run: () => {
9867
- setSidebarOpen(true);
11407
+ openPanelExclusive('tree');
9868
11408
  setTimeout(
9869
11409
  () => document.querySelector('[aria-label="New blank brief board"]')?.click(),
9870
11410
  60
@@ -9887,6 +11427,21 @@ function App() {
9887
11427
  kbd: '⇧⌘H',
9888
11428
  run: () => setExportDialog({ mode: 'handoff' }),
9889
11429
  },
11430
+ {
11431
+ id: 'generate',
11432
+ group: 'Canvas',
11433
+ label: 'Generate with AI…',
11434
+ icon: 'sparkle',
11435
+ run: () => setGenerateOpen(true),
11436
+ },
11437
+ {
11438
+ id: 'settings',
11439
+ group: 'Canvas',
11440
+ label: 'Settings…',
11441
+ icon: 'sliders',
11442
+ kbd: '⌘,',
11443
+ run: () => setSettingsOpen(true),
11444
+ },
9890
11445
  // ── View ────────────────────────────────────────────────────────────
9891
11446
  {
9892
11447
  id: 'system',
@@ -9969,6 +11524,155 @@ function App() {
9969
11524
  [openSystem, toggleTheme, reloadActive, whatsNew]
9970
11525
  );
9971
11526
 
11527
+ // feature-configurable-panel-docking — resolve, for each slot, the panels
11528
+ // assigned to it and which one is active (the open one). Layers is only a
11529
+ // dockable panel in `separate` mode; Assistant is native-only.
11530
+ const panelAvailable = (id) =>
11531
+ id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
11532
+ const idsForSide = (side) =>
11533
+ DOCK_PANELS.filter(
11534
+ (p) => panelAvailable(p.id) && (panelSide[p.id] || PANEL_SIDES_DEFAULTS[p.id]) === side
11535
+ ).map((p) => p.id);
11536
+ const panelIsOpen = {
11537
+ tree: sidebarOpen,
11538
+ layers: layersOpen,
11539
+ inspector: inspectorOpen,
11540
+ comments: commentsPanelOpen,
11541
+ changes: changesOpen,
11542
+ assistant: assistantOpen,
11543
+ };
11544
+ const leftIds = idsForSide('left');
11545
+ const rightIds = idsForSide('right');
11546
+ const leftActive = leftIds.find((id) => panelIsOpen[id]) || null;
11547
+ const rightActive = rightIds.find((id) => panelIsOpen[id]) || null;
11548
+ const leftHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'left';
11549
+ const rightHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'right';
11550
+ const resizingFor = (id) =>
11551
+ (panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
11552
+ const activeCanvasFile =
11553
+ activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath) ? activePath : null;
11554
+
11555
+ // Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
11556
+ // which owns the resizable width). Assistant is handled separately below as an
11557
+ // always-mounted ChatPanel so its stream survives a tab switch.
11558
+ const renderPanelBody = (id) => {
11559
+ if (id === 'tree')
11560
+ return (
11561
+ <Sidebar
11562
+ groups={groups}
11563
+ activePath={activePath}
11564
+ activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
11565
+ onOpen={openTab}
11566
+ onOpenSystem={openSystem}
11567
+ wsConnected={wsConnected}
11568
+ search={search}
11569
+ setSearch={setSearch}
11570
+ commentsByFile={commentsByFile}
11571
+ showHidden={showHidden}
11572
+ sectionsExpanded={sectionsExpanded}
11573
+ onToggleSection={toggleSection}
11574
+ onNewBoard={createBoard}
11575
+ onDeleteBoard={deleteBoard}
11576
+ onRefresh={refreshTree}
11577
+ refreshing={treeRefreshing}
11578
+ collapsed={false}
11579
+ onCollapse={() => togglePanel('tree')}
11580
+ resizing={resizingFor('tree')}
11581
+ dirtyByPath={dirtyByPath}
11582
+ project={project}
11583
+ gitBranch={gitStatus?.branch}
11584
+ remoteSync={remoteSync}
11585
+ onGetLatest={gitGetLatest}
11586
+ canvasKinds={cfg?.canvasKinds}
11587
+ />
11588
+ );
11589
+ if (id === 'changes')
11590
+ return (
11591
+ <GitPanel
11592
+ status={gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus}
11593
+ project={project}
11594
+ readOnly={!isNativeApp()}
11595
+ resizing={resizingFor('changes')}
11596
+ onClose={() => setChangesOpen(false)}
11597
+ onCommit={gitCommit}
11598
+ onDiscard={gitDiscard}
11599
+ onPublish={gitPublish}
11600
+ onGetLatest={gitGetLatest}
11601
+ loadLog={gitLoadLog}
11602
+ onOpenCanvas={(p) => openTab(p)}
11603
+ onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
11604
+ activeCanvas={activeCanvasFile}
11605
+ onPreviewVersion={(sha) => setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })}
11606
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
11607
+ />
11608
+ );
11609
+ if (id === 'inspector' || id === 'layers')
11610
+ return (
11611
+ <InspectorPanel
11612
+ layersOnly={id === 'layers'}
11613
+ hideLayersTab={layersMode === 'separate'}
11614
+ cpMode={cpMode}
11615
+ onSetCpMode={setCpMode}
11616
+ selected={selected}
11617
+ cfg={cfg}
11618
+ tab={id === 'layers' ? 'layers' : inspectorTab}
11619
+ onTabChange={setInspectorTab}
11620
+ onClose={() => (id === 'layers' ? setLayersOpen(false) : setInspectorOpen(false))}
11621
+ onOptimistic={applyOptimisticStyle}
11622
+ onRecordEdit={recordSourceEdit}
11623
+ onReplaceMedia={onReplaceMedia}
11624
+ onResizeArtboard={resizeArtboardShell}
11625
+ onSetArtboardHug={setArtboardHugShell}
11626
+ onSetArtboardStyle={setArtboardStyleShell}
11627
+ editScope={editScope}
11628
+ onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
11629
+ photoSel={photoSel}
11630
+ photoRev={photoRev}
11631
+ onPhotoEdit={onPhotoEdit}
11632
+ onPhotoRemoveBackground={onPhotoRemoveBackground}
11633
+ onPhotoRecordEdit={onPhotoRecordEdit}
11634
+ onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
11635
+ layersTree={layersTree}
11636
+ canvasFile={activePath}
11637
+ onSelectLayer={(n) =>
11638
+ postToActiveCanvas({
11639
+ dgn: 'select-by-id',
11640
+ id: n.id,
11641
+ artboardId: layersTree?.artboardId,
11642
+ index: n.index,
11643
+ })
11644
+ }
11645
+ onHoverLayer={(n) =>
11646
+ postToActiveCanvas({
11647
+ dgn: 'highlight',
11648
+ id: n ? n.id : null,
11649
+ artboardId: layersTree?.artboardId,
11650
+ index: n ? n.index : 0,
11651
+ })
11652
+ }
11653
+ onReorderLayer={reorderLayer}
11654
+ layersBusyRef={layersBusyRef}
11655
+ resizing={resizingFor(id)}
11656
+ />
11657
+ );
11658
+ if (id === 'comments')
11659
+ return (
11660
+ <CommentsPanel
11661
+ commentsByFile={commentsByFile}
11662
+ filter={commentsFilter}
11663
+ setFilter={setCommentsFilter}
11664
+ activePath={activePath}
11665
+ focusedId={focusedCommentId}
11666
+ onJump={jumpToComment}
11667
+ onResolve={resolveComment}
11668
+ onReopen={reopenComment}
11669
+ onDelete={deleteComment}
11670
+ resizing={resizingFor('comments')}
11671
+ />
11672
+ );
11673
+ return null;
11674
+ };
11675
+
9972
11676
  return (
9973
11677
  <div
9974
11678
  className={'maude' + (presentMode ? ' is-present' : '')}
@@ -10014,7 +11718,7 @@ function App() {
10014
11718
  onToggleChanges={() => toggleRightPanel('changes')}
10015
11719
  onOpenSystem={openSystem}
10016
11720
  sidebarOpen={sidebarOpen}
10017
- onToggleSidebar={() => setSidebarOpen((v) => !v)}
11721
+ onToggleSidebar={() => togglePanel('tree')}
10018
11722
  showHidden={showHidden}
10019
11723
  onToggleShowHidden={() => setShowHidden((v) => !v)}
10020
11724
  onOpenHelp={() => setHelpOpen(true)}
@@ -10031,6 +11735,8 @@ function App() {
10031
11735
  onTogglePresent={togglePresent}
10032
11736
  postToActiveCanvas={postToActiveCanvas}
10033
11737
  onOpenReadiness={() => setReadinessOpen(true)}
11738
+ onOpenQuickSetup={() => setQuickSetupOpen(true)}
11739
+ onWatchIntro={() => setIntroOpen(true)}
10034
11740
  onOpenWhatsNew={whatsNew.openPanel}
10035
11741
  whatsNewCount={whatsNew.unseen.length}
10036
11742
  exportCenter={exportCenter}
@@ -10049,17 +11755,20 @@ function App() {
10049
11755
  assistantBusy={assistantBusy}
10050
11756
  assistantUnseen={assistantUnseen}
10051
11757
  onOpenLayers={() => {
10052
- // Toggle: already open on Layers close; otherwise open on Layers
10053
- // (clearing the sibling panels one dock slot).
10054
- if (inspectorOpen && inspectorTab === 'layers') {
11758
+ // feature-configurable-panel-docking Layers is its own dockable
11759
+ // panel when layersMode==='separate' (toggle it), else it's the
11760
+ // Inspector's Layers tab (open the inspector on that tab).
11761
+ if (layersMode === 'separate') {
11762
+ togglePanel('layers');
11763
+ } else if (inspectorOpen && inspectorTab === 'layers') {
10055
11764
  setInspectorOpen(false);
10056
11765
  } else {
10057
11766
  setInspectorTab('layers');
10058
- openRightPanel('inspector');
11767
+ openPanelExclusive('inspector');
10059
11768
  }
10060
11769
  }}
10061
11770
  onNewCanvas={() => {
10062
- setSidebarOpen(true);
11771
+ openPanelExclusive('tree');
10063
11772
  setTimeout(
10064
11773
  () => document.querySelector('[aria-label="New blank brief board"]')?.click(),
10065
11774
  60
@@ -10067,6 +11776,8 @@ function App() {
10067
11776
  }}
10068
11777
  onAssembleVideo={assembleVideo}
10069
11778
  onOpenExport={(mode) => setExportDialog({ mode })}
11779
+ onOpenSettings={() => setSettingsOpen(true)}
11780
+ onOpenGenerate={() => setGenerateOpen(true)}
10070
11781
  onReload={reloadActive}
10071
11782
  onCloseCanvas={() => activePath && closeTab(activePath)}
10072
11783
  presence={
@@ -10088,42 +11799,44 @@ function App() {
10088
11799
  }
10089
11800
  />
10090
11801
  <div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
11802
+ {/* LEFT dock slot (feature-configurable-panel-docking) — the collapsed
11803
+ rail shows when the left slot is empty; the tree's expand hooks open
11804
+ it on the tree tab. */}
10091
11805
  <CollapsedRail
10092
- shown={!sidebarOpen}
10093
- onExpand={() => setSidebarOpen(true)}
11806
+ shown={!leftActive}
11807
+ onExpand={() => openPanelExclusive('tree')}
10094
11808
  onSearch={() => {
10095
- setSidebarOpen(true);
11809
+ openPanelExclusive('tree');
10096
11810
  setTimeout(() => document.querySelector('.st-search input')?.focus(), 60);
10097
11811
  }}
10098
11812
  />
10099
- <Sidebar
10100
- groups={groups}
10101
- activePath={activePath}
10102
- activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
10103
- onOpen={openTab}
10104
- onOpenSystem={openSystem}
10105
- wsConnected={wsConnected}
10106
- search={search}
10107
- setSearch={setSearch}
10108
- commentsByFile={commentsByFile}
10109
- showHidden={showHidden}
10110
- sectionsExpanded={sectionsExpanded}
10111
- onToggleSection={toggleSection}
10112
- onNewBoard={createBoard}
10113
- onDeleteBoard={deleteBoard}
10114
- onRefresh={refreshTree}
10115
- refreshing={treeRefreshing}
10116
- collapsed={!sidebarOpen}
10117
- onCollapse={() => setSidebarOpen(false)}
10118
- width={sbSize.w}
10119
- resizing={dragSide === 'sb'}
10120
- dirtyByPath={dirtyByPath}
10121
- project={project}
10122
- gitBranch={gitStatus?.branch}
10123
- />
10124
- {sidebarOpen && (
11813
+ {(leftActive || leftHostsAssistant) && (
11814
+ <DockSlot
11815
+ side="left"
11816
+ width={sbSize.w}
11817
+ open={!!leftActive}
11818
+ ids={leftIds}
11819
+ activeId={leftActive}
11820
+ onPick={togglePanel}
11821
+ >
11822
+ {leftHostsAssistant && (
11823
+ <ChatPanel
11824
+ hidden={leftActive !== 'assistant'}
11825
+ activeCanvas={activeCanvasFile}
11826
+ selected={selected}
11827
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
11828
+ resizing={resizingFor('assistant')}
11829
+ onClose={() => setAssistantOpen(false)}
11830
+ onBusyChange={setAssistantBusy}
11831
+ onFinished={handleAssistantFinished}
11832
+ />
11833
+ )}
11834
+ {leftActive && leftActive !== 'assistant' && renderPanelBody(leftActive)}
11835
+ </DockSlot>
11836
+ )}
11837
+ {leftActive && (
10125
11838
  <PanelGrip
10126
- label="Resize files panel"
11839
+ label="Resize left panel"
10127
11840
  size={sbSize}
10128
11841
  active={dragSide === 'sb'}
10129
11842
  onPointerDown={(e) => {
@@ -10145,11 +11858,13 @@ function App() {
10145
11858
  cfg={cfg}
10146
11859
  loadingPath={loadingPath}
10147
11860
  onIframeLoad={onIframeLoad}
11861
+ showQuickSetup={isNativeApp() && !!setupReadiness && !setupReadiness.ready}
11862
+ onStartQuickSetup={() => setQuickSetupOpen(true)}
10148
11863
  />
10149
11864
  </div>
10150
- {(inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen) && (
11865
+ {rightActive && (
10151
11866
  <PanelGrip
10152
- label="Resize side panel"
11867
+ label="Resize right panel"
10153
11868
  dir="rtl"
10154
11869
  size={rpSize}
10155
11870
  active={dragSide === 'rp'}
@@ -10160,105 +11875,32 @@ function App() {
10160
11875
  }}
10161
11876
  />
10162
11877
  )}
10163
- {/* Right dock one panel at a time. Changes (E2) takes precedence,
10164
- then Inspector (T6), then the comments panel. */}
10165
- {changesOpen ? (
10166
- <GitPanel
10167
- status={
10168
- gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus
10169
- }
10170
- project={project}
10171
- readOnly={!isNativeApp()}
10172
- width={rpSize.w}
10173
- resizing={dragSide === 'rp'}
10174
- onClose={() => setChangesOpen(false)}
10175
- onCommit={gitCommit}
10176
- onDiscard={gitDiscard}
10177
- onPublish={gitPublish}
10178
- onGetLatest={gitGetLatest}
10179
- loadLog={gitLoadLog}
10180
- onOpenCanvas={(p) => openTab(p)}
10181
- onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
10182
- activeCanvas={
10183
- activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
10184
- ? activePath
10185
- : null
10186
- }
10187
- onPreviewVersion={(sha) =>
10188
- setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
10189
- }
10190
- designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
10191
- />
10192
- ) : inspectorOpen ? (
10193
- <InspectorPanel
10194
- selected={selected}
10195
- cfg={cfg}
10196
- tab={inspectorTab}
10197
- onTabChange={setInspectorTab}
10198
- onClose={() => setInspectorOpen(false)}
10199
- onOptimistic={applyOptimisticStyle}
10200
- onRecordEdit={recordSourceEdit}
10201
- onReplaceMedia={onReplaceMedia}
10202
- onResizeArtboard={resizeArtboardShell}
10203
- editScope={editScope}
10204
- onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
10205
- layersTree={layersTree}
10206
- canvasFile={activePath}
10207
- onSelectLayer={(n) =>
10208
- postToActiveCanvas({
10209
- dgn: 'select-by-id',
10210
- id: n.id,
10211
- artboardId: layersTree?.artboardId,
10212
- index: n.index,
10213
- })
10214
- }
10215
- onHoverLayer={(n) =>
10216
- postToActiveCanvas({
10217
- dgn: 'highlight',
10218
- id: n ? n.id : null,
10219
- artboardId: layersTree?.artboardId,
10220
- index: n ? n.index : 0,
10221
- })
10222
- }
10223
- onReorderLayer={reorderLayer}
10224
- layersBusyRef={layersBusyRef}
11878
+ {/* RIGHT dock slot (feature-configurable-panel-docking). The Assistant
11879
+ (ACP) chat stays MOUNTED (display:none when inactive) so its stream
11880
+ survives a tab switch — DDR-123. Native-only. */}
11881
+ {(rightActive || rightHostsAssistant) && (
11882
+ <DockSlot
11883
+ side="right"
10225
11884
  width={rpSize.w}
10226
- resizing={dragSide === 'rp'}
10227
- />
10228
- ) : commentsPanelOpen ? (
10229
- <CommentsPanel
10230
- commentsByFile={commentsByFile}
10231
- filter={commentsFilter}
10232
- setFilter={setCommentsFilter}
10233
- activePath={activePath}
10234
- focusedId={focusedCommentId}
10235
- onJump={jumpToComment}
10236
- onResolve={resolveComment}
10237
- onReopen={reopenComment}
10238
- onDelete={deleteComment}
10239
- width={rpSize.w}
10240
- resizing={dragSide === 'rp'}
10241
- />
10242
- ) : null}
10243
- {/* Phase 31 (DDR-123) the ACP chat panel stays MOUNTED (display:none
10244
- when inactive) so the chat keeps streaming + its history survives a
10245
- switch to Changes/Inspector/Comments. Native-only. */}
10246
- {isNativeApp() && (
10247
- <ChatPanel
10248
- hidden={!assistantOpen}
10249
- activeCanvas={
10250
- activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
10251
- ? activePath
10252
- : null
10253
- }
10254
- selected={selected}
10255
- designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
10256
- width={rpSize.w}
10257
- resizing={dragSide === 'rp'}
10258
- onClose={() => setAssistantOpen(false)}
10259
- onBusyChange={setAssistantBusy}
10260
- onFinished={handleAssistantFinished}
10261
- />
11885
+ open={!!rightActive}
11886
+ ids={rightIds}
11887
+ activeId={rightActive}
11888
+ onPick={togglePanel}
11889
+ >
11890
+ {rightHostsAssistant && (
11891
+ <ChatPanel
11892
+ hidden={rightActive !== 'assistant'}
11893
+ activeCanvas={activeCanvasFile}
11894
+ selected={selected}
11895
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
11896
+ resizing={resizingFor('assistant')}
11897
+ onClose={() => setAssistantOpen(false)}
11898
+ onBusyChange={setAssistantBusy}
11899
+ onFinished={handleAssistantFinished}
11900
+ />
11901
+ )}
11902
+ {rightActive && rightActive !== 'assistant' && renderPanelBody(rightActive)}
11903
+ </DockSlot>
10262
11904
  )}
10263
11905
  </div>
10264
11906
  {/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
@@ -10658,13 +12300,49 @@ function App() {
10658
12300
  onClose={() => setExportDialog(null)}
10659
12301
  />
10660
12302
  )}
12303
+ {settingsOpen && (
12304
+ <SettingsPanel
12305
+ onClose={() => setSettingsOpen(false)}
12306
+ initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
12307
+ theme={theme}
12308
+ onSetTheme={setTheme}
12309
+ cpMode={cpMode}
12310
+ onSetCpMode={setCpMode}
12311
+ minimapVisible={minimapVisible}
12312
+ onToggleMinimap={toggleMinimap}
12313
+ zoomCtlVisible={zoomCtlVisible}
12314
+ onToggleZoomCtl={toggleZoomCtl}
12315
+ annotationsVisible={annotationsVisible}
12316
+ onToggleAnnotations={toggleAnnotations}
12317
+ autoOpenInspector={autoOpenInspector}
12318
+ onToggleAutoOpenInspector={() => setAutoOpenInspector((v) => !v)}
12319
+ hasCanvas={!!activePath && activePath !== SYSTEM_TAB}
12320
+ panelSide={panelSide}
12321
+ onSetPanelSide={(id, side) => setPanelSide((prev) => ({ ...prev, [id]: side }))}
12322
+ layersMode={layersMode}
12323
+ onSetLayersMode={(m) => {
12324
+ setLayersMode(m);
12325
+ // Leaving separate mode retires the standalone Layers panel.
12326
+ if (m !== 'separate') setLayersOpen(false);
12327
+ }}
12328
+ />
12329
+ )}
12330
+ {generateOpen && (
12331
+ <GenerateDialog onClose={() => setGenerateOpen(false)} onInsert={insertGeneratedImage} />
12332
+ )}
10661
12333
  {assetPickerReq && (
10662
12334
  <AssetPicker
10663
12335
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
10664
12336
  onPick={onAssetPicked}
10665
12337
  onClose={() => setAssetPickerReq(null)}
12338
+ multiple={!!assetPickerReq.multiple}
12339
+ hasArtboardAnchor={!!assetPickerReq.hasArtboardAnchor}
12340
+ onPickMany={onPickMany}
10666
12341
  />
10667
12342
  )}
12343
+ {stickerPickerReq && (
12344
+ <StickerPicker onPick={onStickerPicked} onClose={() => setStickerPickerReq(null)} />
12345
+ )}
10668
12346
  {diffTarget && (
10669
12347
  <DiffView
10670
12348
  target={diffTarget}
@@ -10702,6 +12380,14 @@ function App() {
10702
12380
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
10703
12381
  <ExportPanel center={exportCenter} />
10704
12382
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
12383
+ <IntroVideoDialog open={introOpen} onClose={() => setIntroOpen(false)} />
12384
+ <SetupChecklistDialog
12385
+ open={quickSetupOpen}
12386
+ onClose={() => setQuickSetupOpen(false)}
12387
+ onStartTour={() => startTour(QUICK_SETUP_TOUR)}
12388
+ onBringBrand={() => setBrandUploadOpen(true)}
12389
+ />
12390
+ <BrandUploadPanel open={brandUploadOpen} onClose={() => setBrandUploadOpen(false)} />
10705
12391
  {usageNudge && !tourSteps && !collabNudge && (
10706
12392
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
10707
12393
  <div className="mdcc-tour-nudge__body">