@agent-native/core 0.79.27 → 0.80.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 (346) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +26 -0
  3. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +41 -0
  4. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +51 -0
  5. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +50 -0
  6. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +49 -0
  7. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +44 -0
  8. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +46 -0
  9. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +44 -0
  10. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +48 -0
  11. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +36 -0
  12. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +38 -0
  13. package/corpus/core/docs/content/template-design.mdx +45 -0
  14. package/corpus/core/package.json +2 -1
  15. package/corpus/core/src/agent/engine/index.ts +1 -0
  16. package/corpus/core/src/agent/engine/registry.ts +40 -0
  17. package/corpus/core/src/agent/production-agent.ts +6 -2
  18. package/corpus/core/src/cli/design-connect.ts +490 -0
  19. package/corpus/core/src/cli/index.ts +16 -0
  20. package/corpus/core/src/cli/skills.ts +126 -3
  21. package/corpus/core/src/client/AssistantChat.tsx +43 -7
  22. package/corpus/core/src/client/agent-chat.ts +27 -3
  23. package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
  24. package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
  25. package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
  26. package/corpus/core/src/client/chat/message-components.tsx +1 -1
  27. package/corpus/core/src/client/chat-model-groups.ts +1 -2
  28. package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
  29. package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
  30. package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
  31. package/corpus/core/src/client/frame-protocol.ts +17 -2
  32. package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
  33. package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
  34. package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
  35. package/corpus/core/src/server/transcribe-voice.ts +57 -22
  36. package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
  37. package/corpus/core/src/styles/agent-native.css +56 -7
  38. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  39. package/corpus/core/src/vite/client.ts +2 -0
  40. package/corpus/core/src/voice/index.ts +18 -0
  41. package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
  42. package/corpus/core/src/voice/voice-context.ts +217 -0
  43. package/corpus/core/src/voice/voice-replacements.ts +75 -0
  44. package/corpus/templates/analytics/AGENTS.md +4 -3
  45. package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
  46. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
  47. package/corpus/templates/analytics/app/global.css +0 -4
  48. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  49. package/corpus/templates/analytics/app/i18n-data.ts +20 -0
  50. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +33 -8
  51. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
  52. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +7 -0
  53. package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
  54. package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
  55. package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  56. package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
  57. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
  58. package/corpus/templates/analytics/server/lib/dashboard-report.ts +29 -7
  59. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
  60. package/corpus/templates/analytics/server/lib/session-replay.ts +24 -4
  61. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
  62. package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  63. package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
  64. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
  65. package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
  66. package/corpus/templates/brain/app/i18n-data.ts +20 -0
  67. package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  68. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
  69. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
  70. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  71. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  72. package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  73. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
  74. package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  75. package/corpus/templates/clips/AGENTS.md +4 -0
  76. package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
  77. package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
  78. package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
  79. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
  80. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
  81. package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
  82. package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  83. package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
  84. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
  85. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
  86. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
  87. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
  88. package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  89. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +137 -0
  90. package/corpus/templates/design/AGENTS.md +95 -5
  91. package/corpus/templates/design/DESIGN.md +82 -0
  92. package/corpus/templates/design/DEVELOPING.md +43 -0
  93. package/corpus/templates/design/actions/add-localhost-screens.ts +426 -0
  94. package/corpus/templates/design/actions/apply-shader.ts +277 -0
  95. package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
  96. package/corpus/templates/design/actions/connect-localhost.ts +183 -0
  97. package/corpus/templates/design/actions/edit-design.ts +195 -42
  98. package/corpus/templates/design/actions/generate-design.ts +71 -1
  99. package/corpus/templates/design/actions/generate-screens.ts +193 -0
  100. package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
  101. package/corpus/templates/design/actions/get-shader.ts +119 -0
  102. package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
  103. package/corpus/templates/design/actions/navigate.ts +55 -2
  104. package/corpus/templates/design/actions/view-screen.ts +68 -1
  105. package/corpus/templates/design/agent-native.app-skill.json +10 -0
  106. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +761 -0
  107. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2057 -103
  108. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
  109. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +399 -0
  110. package/corpus/templates/design/app/components/design/EditPanel.tsx +3185 -439
  111. package/corpus/templates/design/app/components/design/LayersPanel.tsx +1124 -0
  112. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3116 -147
  113. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
  114. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +107 -63
  115. package/corpus/templates/design/app/components/design/index.ts +0 -5
  116. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +286 -0
  117. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +699 -0
  118. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +461 -0
  119. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +166 -0
  120. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +1420 -0
  121. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
  122. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +215 -0
  123. package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
  124. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +518 -0
  125. package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
  126. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +361 -0
  127. package/corpus/templates/design/app/components/design/inspector/index.ts +60 -0
  128. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
  129. package/corpus/templates/design/app/components/design/types.ts +23 -0
  130. package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
  131. package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
  132. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
  133. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
  134. package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
  135. package/corpus/templates/design/app/global.css +121 -3
  136. package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
  137. package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
  138. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
  139. package/corpus/templates/design/app/i18n/zh-TW.ts +264 -0
  140. package/corpus/templates/design/app/i18n-data.ts +2790 -1
  141. package/corpus/templates/design/app/pages/DesignEditor.tsx +5108 -851
  142. package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
  143. package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
  144. package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
  145. package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
  146. package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
  147. package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
  148. package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
  149. package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  150. package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
  151. package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
  152. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
  153. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
  154. package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
  155. package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
  156. package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
  157. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
  158. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
  159. package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
  160. package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
  161. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
  162. package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
  163. package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
  164. package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
  165. package/corpus/templates/design/package.json +1 -0
  166. package/corpus/templates/design/server/db/schema.ts +26 -0
  167. package/corpus/templates/design/server/lib/design-snapshot.ts +1 -1
  168. package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
  169. package/corpus/templates/design/shared/api.ts +27 -0
  170. package/corpus/templates/design/shared/canvas-frames.ts +103 -0
  171. package/corpus/templates/design/shared/canvas-math.ts +1058 -0
  172. package/corpus/templates/design/shared/code-layer.ts +2212 -0
  173. package/corpus/templates/design/shared/color-utils.ts +180 -0
  174. package/corpus/templates/design/shared/generation-session.ts +61 -0
  175. package/corpus/templates/design/shared/shader-presets.ts +782 -0
  176. package/corpus/templates/design/shared/shader-safety.ts +137 -0
  177. package/corpus/templates/design/shared/source-mode.ts +237 -0
  178. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
  179. package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
  180. package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
  181. package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
  182. package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
  183. package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
  184. package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
  185. package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
  186. package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
  187. package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
  188. package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
  189. package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
  190. package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  191. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
  192. package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  193. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
  194. package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
  195. package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
  196. package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
  197. package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
  198. package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
  199. package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
  200. package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
  201. package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
  202. package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
  203. package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
  204. package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
  205. package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  206. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
  207. package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
  208. package/corpus/templates/plan/app/global.css +61 -78
  209. package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
  210. package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  211. package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
  212. package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
  213. package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
  214. package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  215. package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
  216. package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
  217. package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
  218. package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
  219. package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
  220. package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
  221. package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
  222. package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
  223. package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
  224. package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
  225. package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
  226. package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
  227. package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
  228. package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
  229. package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  230. package/dist/agent/engine/index.d.ts +1 -1
  231. package/dist/agent/engine/index.d.ts.map +1 -1
  232. package/dist/agent/engine/index.js +1 -1
  233. package/dist/agent/engine/index.js.map +1 -1
  234. package/dist/agent/engine/registry.d.ts +9 -0
  235. package/dist/agent/engine/registry.d.ts.map +1 -1
  236. package/dist/agent/engine/registry.js +37 -0
  237. package/dist/agent/engine/registry.js.map +1 -1
  238. package/dist/agent/production-agent.d.ts.map +1 -1
  239. package/dist/agent/production-agent.js +4 -3
  240. package/dist/agent/production-agent.js.map +1 -1
  241. package/dist/cli/design-connect.d.ts +52 -0
  242. package/dist/cli/design-connect.d.ts.map +1 -0
  243. package/dist/cli/design-connect.js +409 -0
  244. package/dist/cli/design-connect.js.map +1 -0
  245. package/dist/cli/index.js +15 -0
  246. package/dist/cli/index.js.map +1 -1
  247. package/dist/cli/skills.d.ts +3 -0
  248. package/dist/cli/skills.d.ts.map +1 -1
  249. package/dist/cli/skills.js +121 -3
  250. package/dist/cli/skills.js.map +1 -1
  251. package/dist/client/AssistantChat.d.ts.map +1 -1
  252. package/dist/client/AssistantChat.js +38 -8
  253. package/dist/client/AssistantChat.js.map +1 -1
  254. package/dist/client/agent-chat.d.ts +6 -0
  255. package/dist/client/agent-chat.d.ts.map +1 -1
  256. package/dist/client/agent-chat.js +17 -3
  257. package/dist/client/agent-chat.js.map +1 -1
  258. package/dist/client/blocks/library/MermaidBlock.js +1 -1
  259. package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
  260. package/dist/client/blocks/library/diagram.js +1 -1
  261. package/dist/client/blocks/library/diagram.js.map +1 -1
  262. package/dist/client/blocks/library/wireframe.js +1 -1
  263. package/dist/client/blocks/library/wireframe.js.map +1 -1
  264. package/dist/client/chat/message-components.js +1 -1
  265. package/dist/client/chat/message-components.js.map +1 -1
  266. package/dist/client/chat-model-groups.d.ts.map +1 -1
  267. package/dist/client/chat-model-groups.js +1 -2
  268. package/dist/client/chat-model-groups.js.map +1 -1
  269. package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
  270. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
  271. package/dist/client/components/LiveCursorOverlay.js +19 -40
  272. package/dist/client/components/LiveCursorOverlay.js.map +1 -1
  273. package/dist/client/composer/TiptapComposer.d.ts +3 -1
  274. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  275. package/dist/client/composer/TiptapComposer.js +86 -24
  276. package/dist/client/composer/TiptapComposer.js.map +1 -1
  277. package/dist/client/composer/useVoiceDictation.d.ts +9 -0
  278. package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
  279. package/dist/client/composer/useVoiceDictation.js +154 -32
  280. package/dist/client/composer/useVoiceDictation.js.map +1 -1
  281. package/dist/client/frame-protocol.d.ts +17 -2
  282. package/dist/client/frame-protocol.d.ts.map +1 -1
  283. package/dist/client/frame-protocol.js.map +1 -1
  284. package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
  285. package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
  286. package/dist/client/sharing/ShareButton.d.ts +2 -0
  287. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  288. package/dist/client/sharing/ShareButton.js +7 -6
  289. package/dist/client/sharing/ShareButton.js.map +1 -1
  290. package/dist/client/use-agent-engine-configured.d.ts +5 -0
  291. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  292. package/dist/client/use-agent-engine-configured.js +51 -25
  293. package/dist/client/use-agent-engine-configured.js.map +1 -1
  294. package/dist/collab/awareness.d.ts +2 -2
  295. package/dist/collab/awareness.d.ts.map +1 -1
  296. package/dist/collab/routes.d.ts +1 -1
  297. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  298. package/dist/notifications/routes.d.ts +3 -3
  299. package/dist/observability/routes.d.ts +8 -8
  300. package/dist/progress/routes.d.ts +1 -1
  301. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  302. package/dist/server/transcribe-voice.d.ts.map +1 -1
  303. package/dist/server/transcribe-voice.js +40 -24
  304. package/dist/server/transcribe-voice.js.map +1 -1
  305. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  306. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  307. package/dist/shared/mcp-embed-headers.js +20 -1
  308. package/dist/shared/mcp-embed-headers.js.map +1 -1
  309. package/dist/styles/agent-native.css +56 -7
  310. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  311. package/dist/vite/client.d.ts.map +1 -1
  312. package/dist/vite/client.js +2 -0
  313. package/dist/vite/client.js.map +1 -1
  314. package/dist/voice/index.d.ts +5 -0
  315. package/dist/voice/index.d.ts.map +1 -0
  316. package/dist/voice/index.js +4 -0
  317. package/dist/voice/index.js.map +1 -0
  318. package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
  319. package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
  320. package/dist/voice/voice-cleanup-prompt.js +26 -0
  321. package/dist/voice/voice-cleanup-prompt.js.map +1 -0
  322. package/dist/voice/voice-context.d.ts +25 -0
  323. package/dist/voice/voice-context.d.ts.map +1 -0
  324. package/dist/voice/voice-context.js +156 -0
  325. package/dist/voice/voice-context.js.map +1 -0
  326. package/dist/voice/voice-replacements.d.ts +4 -0
  327. package/dist/voice/voice-replacements.d.ts.map +1 -0
  328. package/dist/voice/voice-replacements.js +52 -0
  329. package/dist/voice/voice-replacements.js.map +1 -0
  330. package/docs/content/locales/ar-SA/template-design.mdx +41 -0
  331. package/docs/content/locales/de-DE/template-design.mdx +51 -0
  332. package/docs/content/locales/es-ES/template-design.mdx +50 -0
  333. package/docs/content/locales/fr-FR/template-design.mdx +49 -0
  334. package/docs/content/locales/hi-IN/template-design.mdx +44 -0
  335. package/docs/content/locales/ja-JP/template-design.mdx +46 -0
  336. package/docs/content/locales/ko-KR/template-design.mdx +44 -0
  337. package/docs/content/locales/pt-BR/template-design.mdx +48 -0
  338. package/docs/content/locales/zh-CN/template-design.mdx +36 -0
  339. package/docs/content/locales/zh-TW/template-design.mdx +38 -0
  340. package/docs/content/template-design.mdx +45 -0
  341. package/package.json +2 -1
  342. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  343. package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
  344. package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
  345. package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
  346. package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
@@ -1,9 +1,57 @@
1
1
  import { useT } from "@agent-native/core/client";
2
- import { IconPointer } from "@tabler/icons-react";
3
- import { useCallback, useEffect, useState } from "react";
2
+ import type { TweakDefinition } from "@shared/api";
3
+ import {
4
+ alphaToOpacity,
5
+ parseCssColor,
6
+ rgbaToCss,
7
+ withColorOpacity,
8
+ } from "@shared/color-utils";
9
+ import {
10
+ IconAlignCenter,
11
+ IconAlignJustified,
12
+ IconAlignLeft,
13
+ IconAlignRight,
14
+ IconBorderStyle,
15
+ IconBrush,
16
+ IconChevronDown,
17
+ IconChevronRight,
18
+ IconCode,
19
+ IconComponents,
20
+ IconEye,
21
+ IconEyeOff,
22
+ IconFlipHorizontal,
23
+ IconFlipVertical,
24
+ IconFrame,
25
+ IconLayoutGrid,
26
+ IconLayoutAlignBottom,
27
+ IconLayoutAlignCenter,
28
+ IconLayoutAlignLeft,
29
+ IconLayoutAlignMiddle,
30
+ IconLayoutAlignRight,
31
+ IconLayoutAlignTop,
32
+ IconLetterCase,
33
+ IconLetterSpacing,
34
+ IconLineHeight,
35
+ IconLink,
36
+ IconMaximize,
37
+ IconMinus,
38
+ IconPalette,
39
+ IconPhoto,
40
+ IconPlus,
41
+ IconTypography,
42
+ IconUnlink,
43
+ IconVector,
44
+ } from "@tabler/icons-react";
45
+ import { useCallback, useEffect, useState, type ReactNode } from "react";
4
46
 
47
+ import { Button } from "@/components/ui/button";
5
48
  import { Input } from "@/components/ui/input";
6
49
  import { Label } from "@/components/ui/label";
50
+ import {
51
+ Popover,
52
+ PopoverContent,
53
+ PopoverTrigger,
54
+ } from "@/components/ui/popover";
7
55
  import {
8
56
  Select,
9
57
  SelectContent,
@@ -11,16 +59,59 @@ import {
11
59
  SelectTrigger,
12
60
  SelectValue,
13
61
  } from "@/components/ui/select";
14
- import { Separator } from "@/components/ui/separator";
15
62
  import { Slider } from "@/components/ui/slider";
63
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
64
+ import {
65
+ Tooltip,
66
+ TooltipContent,
67
+ TooltipTrigger,
68
+ } from "@/components/ui/tooltip";
16
69
  import { cn } from "@/lib/utils";
17
70
 
71
+ import {
72
+ DesignExtensionsPanel,
73
+ type DesignExtensionSlotContext,
74
+ } from "./DesignExtensionsPanel";
75
+ import {
76
+ AutoLayoutMatrix,
77
+ ConstraintsWidget,
78
+ ExportSettingsPanel,
79
+ FigmaColorPicker,
80
+ ScrubInput,
81
+ type AlignmentMatrixValue,
82
+ type AutoLayoutMatrixValue,
83
+ type AutoLayoutSizing,
84
+ type AutoLayoutSizingAxis,
85
+ type ConstraintsValue,
86
+ type ExportSettingsValue,
87
+ type FigmaFillRow,
88
+ type FigmaFillRowPatch,
89
+ type FigmaGradientStop,
90
+ type FigmaGradientStopPatch,
91
+ type FigmaGradientType,
92
+ } from "./inspector";
93
+ import type { FigmaPaintType } from "./inspector/FigmaColorPicker";
94
+ import { TweaksPanelContent } from "./TweaksPanel";
18
95
  import type { ElementInfo } from "./types";
19
96
 
97
+ export type InspectorTab = "design" | "tweaks" | "extensions";
98
+
20
99
  interface EditPanelProps {
21
100
  selectedElement: ElementInfo | null;
22
101
  pageStyles?: Record<string, string>;
102
+ zoom?: number;
103
+ width?: number;
104
+ activeTab?: InspectorTab;
105
+ onActiveTabChange?: (tab: InspectorTab) => void;
106
+ tweaks?: TweakDefinition[];
107
+ tweakValues?: Record<string, string | number | boolean>;
108
+ extensionContext?: DesignExtensionSlotContext;
109
+ onTweakChange?: (id: string, value: string | number | boolean) => void;
110
+ onRequestTweaks?: (anchor: HTMLElement) => void;
23
111
  onStyleChange: (property: string, value: string) => void;
112
+ onStylesChange?: (styles: Record<string, string>) => void;
113
+ onExport?: (settings: ExportSettingsValue) => void;
114
+ exporting?: boolean;
24
115
  }
25
116
 
26
117
  /**
@@ -74,10 +165,8 @@ function PropInput({
74
165
  };
75
166
 
76
167
  return (
77
- <div className="flex items-center gap-2">
78
- <Label className="text-xs text-muted-foreground w-20 shrink-0">
79
- {label}
80
- </Label>
168
+ <div className="flex items-center gap-1.5">
169
+ <FieldLabel>{label}</FieldLabel>
81
170
  <Input
82
171
  type={type}
83
172
  value={draft}
@@ -98,82 +187,551 @@ function PropInput({
98
187
  }
99
188
  }}
100
189
  placeholder={placeholder}
101
- className="h-7 text-xs"
190
+ className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
102
191
  />
103
192
  </div>
104
193
  );
105
194
  }
106
195
 
107
- /** Compact color input: label + color swatch + text input */
196
+ /** Compact color input: label + Figma-style picker popover. */
108
197
  function ColorInput({
109
198
  label,
110
199
  value,
111
200
  onChange,
201
+ backgroundImage,
202
+ onBackgroundImageChange,
203
+ blendMode,
204
+ onBlendModeChange,
205
+ supportsLayeredFills = false,
112
206
  }: {
113
207
  label: string;
114
208
  value: string;
115
209
  onChange: (value: string) => void;
210
+ backgroundImage?: string;
211
+ onBackgroundImageChange?: (value: string) => void;
212
+ blendMode?: string;
213
+ onBlendModeChange?: (value: string) => void;
214
+ supportsLayeredFills?: boolean;
116
215
  }) {
117
- const t = useT();
118
216
  const [draft, setDraft] = useState(value);
217
+ const [selectedFillId, setSelectedFillId] = useState(SOLID_FILL_ID);
218
+ const [selectedStopId, setSelectedStopId] = useState<string | undefined>();
119
219
 
120
220
  useEffect(() => {
121
221
  setDraft(value);
122
222
  }, [value]);
123
223
 
224
+ const backgroundLayers = splitCssLayers(backgroundImage || "");
225
+ const selectedLayerIndex = fillLayerIndex(selectedFillId);
226
+ const selectedGradient =
227
+ selectedLayerIndex !== null
228
+ ? parseGradientLayer(backgroundLayers[selectedLayerIndex] || "")
229
+ : null;
230
+ const fallbackGradientIndex = backgroundLayers.findIndex((layer) =>
231
+ Boolean(parseGradientLayer(layer)),
232
+ );
233
+ const activeGradientIndex =
234
+ selectedGradient && selectedLayerIndex !== null
235
+ ? selectedLayerIndex
236
+ : fallbackGradientIndex >= 0
237
+ ? fallbackGradientIndex
238
+ : null;
239
+ const activeGradient =
240
+ activeGradientIndex !== null
241
+ ? parseGradientLayer(backgroundLayers[activeGradientIndex] || "")
242
+ : null;
243
+ const activeStopIds =
244
+ activeGradient?.stops.map((stop) => stop.id).join("|") ?? "";
245
+
246
+ useEffect(() => {
247
+ if (
248
+ selectedFillId !== SOLID_FILL_ID &&
249
+ (selectedLayerIndex === null ||
250
+ selectedLayerIndex >= backgroundLayers.length)
251
+ ) {
252
+ setSelectedFillId(SOLID_FILL_ID);
253
+ }
254
+ }, [backgroundLayers.length, selectedFillId, selectedLayerIndex]);
255
+
256
+ useEffect(() => {
257
+ if (!activeStopIds) {
258
+ if (selectedStopId) setSelectedStopId(undefined);
259
+ return;
260
+ }
261
+ const stopIds = activeStopIds.split("|").filter(Boolean);
262
+ if (!selectedStopId || !stopIds.includes(selectedStopId)) {
263
+ setSelectedStopId(stopIds[0]);
264
+ }
265
+ }, [activeStopIds, selectedStopId]);
266
+
124
267
  const setNext = (next: string) => {
125
268
  setDraft(next);
126
269
  onChange(next);
127
270
  };
128
271
 
272
+ const replaceBackgroundLayer = (index: number, nextLayer: string) => {
273
+ if (!onBackgroundImageChange) return;
274
+ const nextLayers = [...backgroundLayers];
275
+ nextLayers[index] = nextLayer;
276
+ onBackgroundImageChange(joinCssLayers(nextLayers));
277
+ };
278
+
279
+ const removeBackgroundLayer = (index: number) => {
280
+ if (!onBackgroundImageChange) return;
281
+ const nextLayers = backgroundLayers.filter(
282
+ (_, layerIndex) => layerIndex !== index,
283
+ );
284
+ onBackgroundImageChange(joinCssLayers(nextLayers));
285
+ setSelectedFillId(SOLID_FILL_ID);
286
+ };
287
+
288
+ const fillRows = supportsLayeredFills
289
+ ? buildFillRows(
290
+ draft || value || "#000000",
291
+ backgroundLayers,
292
+ selectedFillId,
293
+ )
294
+ : undefined;
295
+
296
+ const handleFillChange = (id: string, patch: FigmaFillRowPatch) => {
297
+ if (id === SOLID_FILL_ID) {
298
+ if (patch.value !== undefined) setNext(patch.value);
299
+ if (patch.opacity !== undefined) {
300
+ const parsed = parseCssColor(patch.value ?? draft);
301
+ if (parsed) setNext(rgbaToCss(withColorOpacity(parsed, patch.opacity)));
302
+ }
303
+ return;
304
+ }
305
+
306
+ const index = fillLayerIndex(id);
307
+ if (index === null || !onBackgroundImageChange) return;
308
+ const currentLayer = backgroundLayers[index] || "";
309
+ if (patch.value !== undefined) {
310
+ replaceBackgroundLayer(index, patch.value);
311
+ return;
312
+ }
313
+ if (patch.opacity === undefined) return;
314
+ const gradient = parseGradientLayer(currentLayer);
315
+ if (!gradient) return;
316
+ replaceBackgroundLayer(
317
+ index,
318
+ buildGradientLayer(
319
+ gradient.type,
320
+ gradient.stops.map((stop) => ({
321
+ ...stop,
322
+ opacity: patch.opacity,
323
+ })),
324
+ ),
325
+ );
326
+ };
327
+
328
+ const handleAddFill = onBackgroundImageChange
329
+ ? () => {
330
+ const nextLayers = [
331
+ defaultGradientLayer("linear", draft || value || "#000000"),
332
+ ...backgroundLayers,
333
+ ];
334
+ onBackgroundImageChange(joinCssLayers(nextLayers));
335
+ setSelectedFillId(fillLayerId(0));
336
+ setSelectedStopId("stop-0");
337
+ }
338
+ : undefined;
339
+
340
+ const handleRemoveFill = onBackgroundImageChange
341
+ ? (id: string) => {
342
+ const index = fillLayerIndex(id);
343
+ if (index === null) return;
344
+ removeBackgroundLayer(index);
345
+ }
346
+ : undefined;
347
+
348
+ const handleGradientTypeChange =
349
+ activeGradient && activeGradientIndex !== null
350
+ ? (type: FigmaGradientType) => {
351
+ replaceBackgroundLayer(
352
+ activeGradientIndex,
353
+ buildGradientLayer(type, activeGradient.stops),
354
+ );
355
+ }
356
+ : undefined;
357
+
358
+ const handleGradientStopChange =
359
+ activeGradient && activeGradientIndex !== null
360
+ ? (id: string, patch: FigmaGradientStopPatch) => {
361
+ const nextStops = activeGradient.stops.map((stop) =>
362
+ stop.id === id ? { ...stop, ...patch } : stop,
363
+ );
364
+ replaceBackgroundLayer(
365
+ activeGradientIndex,
366
+ buildGradientLayer(activeGradient.type, nextStops),
367
+ );
368
+ }
369
+ : undefined;
370
+
371
+ const handleAddGradientStop = onBackgroundImageChange
372
+ ? () => {
373
+ if (activeGradient && activeGradientIndex !== null) {
374
+ const nextStop: FigmaGradientStop = {
375
+ id: `stop-${activeGradient.stops.length}`,
376
+ color: draft || "#000000",
377
+ position: 50,
378
+ opacity: 100,
379
+ };
380
+ replaceBackgroundLayer(
381
+ activeGradientIndex,
382
+ buildGradientLayer(activeGradient.type, [
383
+ ...activeGradient.stops,
384
+ nextStop,
385
+ ]),
386
+ );
387
+ setSelectedStopId(nextStop.id);
388
+ return;
389
+ }
390
+
391
+ onBackgroundImageChange(
392
+ joinCssLayers([
393
+ defaultGradientLayer("linear", draft || value || "#000000"),
394
+ ...backgroundLayers,
395
+ ]),
396
+ );
397
+ setSelectedFillId(fillLayerId(0));
398
+ setSelectedStopId("stop-0");
399
+ }
400
+ : undefined;
401
+
402
+ const handleRemoveGradientStop =
403
+ activeGradient && activeGradientIndex !== null
404
+ ? (id: string) => {
405
+ if (activeGradient.stops.length <= 2) return;
406
+ const nextStops = activeGradient.stops.filter(
407
+ (stop) => stop.id !== id,
408
+ );
409
+ replaceBackgroundLayer(
410
+ activeGradientIndex,
411
+ buildGradientLayer(activeGradient.type, nextStops),
412
+ );
413
+ setSelectedStopId(nextStops[0]?.id);
414
+ }
415
+ : undefined;
416
+
417
+ const selectedPaintType: FigmaPaintType =
418
+ selectedFillId !== SOLID_FILL_ID
419
+ ? selectedGradient
420
+ ? selectedGradient.type
421
+ : "image"
422
+ : colorHasVisibleAlpha(draft || value)
423
+ ? "solid"
424
+ : "none";
425
+
426
+ const handlePaintTypeChange = (type: FigmaPaintType) => {
427
+ const selectedLayer = fillLayerIndex(selectedFillId);
428
+ if (type === "solid") {
429
+ if (selectedLayer !== null) removeBackgroundLayer(selectedLayer);
430
+ setSelectedFillId(SOLID_FILL_ID);
431
+ setNext(cssColorOrFallback(draft || value, "#000000"));
432
+ return;
433
+ }
434
+ if (type === "none") {
435
+ if (selectedLayer !== null) {
436
+ removeBackgroundLayer(selectedLayer);
437
+ return;
438
+ }
439
+ setNext("transparent");
440
+ return;
441
+ }
442
+ if (!onBackgroundImageChange) return;
443
+
444
+ if (
445
+ type !== "linear" &&
446
+ type !== "radial" &&
447
+ type !== "angular" &&
448
+ type !== "diamond"
449
+ ) {
450
+ return;
451
+ }
452
+ const nextType: FigmaGradientType = type;
453
+ const layerIndex = selectedLayer ?? activeGradientIndex;
454
+ if (layerIndex !== null) {
455
+ const currentGradient = parseGradientLayer(
456
+ backgroundLayers[layerIndex] || "",
457
+ );
458
+ const stops =
459
+ currentGradient?.stops ?? defaultGradientStops(draft || value);
460
+ replaceBackgroundLayer(layerIndex, buildGradientLayer(nextType, stops));
461
+ setSelectedFillId(fillLayerId(layerIndex));
462
+ setSelectedStopId(stops[0]?.id);
463
+ return;
464
+ }
465
+
466
+ onBackgroundImageChange(
467
+ joinCssLayers([
468
+ defaultGradientLayer(nextType, draft || value || "#000000"),
469
+ ...backgroundLayers,
470
+ ]),
471
+ );
472
+ setSelectedFillId(fillLayerId(0));
473
+ setSelectedStopId("stop-0");
474
+ };
475
+
129
476
  return (
130
- <div className="flex items-center gap-2">
131
- <Label className="text-xs text-muted-foreground w-20 shrink-0">
132
- {label}
133
- </Label>
134
- <div className="flex items-center gap-1.5 flex-1">
135
- <input
136
- type="color"
137
- aria-label={`${label} ${t("editPanel.colorInputLabel")}`}
138
- value={toColorInputValue(draft)}
139
- onChange={(e) => setNext(e.target.value)}
140
- className="h-6 w-6 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0"
141
- />
142
- <Input
143
- value={draft}
144
- onChange={(e) => setNext(e.target.value)}
145
- placeholder="#000000"
146
- className="h-7 text-xs"
147
- />
148
- </div>
149
- </div>
477
+ <FigmaColorPicker
478
+ label={label}
479
+ value={draft || "#000000"}
480
+ onChange={setNext}
481
+ blendMode={blendMode}
482
+ onBlendModeChange={onBlendModeChange}
483
+ showBlendMode={supportsLayeredFills}
484
+ fillRows={fillRows}
485
+ selectedFillId={selectedFillId}
486
+ onFillSelect={supportsLayeredFills ? setSelectedFillId : undefined}
487
+ onFillChange={supportsLayeredFills ? handleFillChange : undefined}
488
+ onAddFill={supportsLayeredFills ? handleAddFill : undefined}
489
+ onRemoveFill={supportsLayeredFills ? handleRemoveFill : undefined}
490
+ paintType={selectedPaintType}
491
+ onPaintTypeChange={handlePaintTypeChange}
492
+ gradientType={activeGradient?.type}
493
+ onGradientTypeChange={handleGradientTypeChange}
494
+ gradientStops={activeGradient?.stops}
495
+ selectedStopId={selectedStopId}
496
+ onGradientStopSelect={setSelectedStopId}
497
+ onGradientStopChange={handleGradientStopChange}
498
+ onAddGradientStop={
499
+ supportsLayeredFills ? handleAddGradientStop : undefined
500
+ }
501
+ onRemoveGradientStop={handleRemoveGradientStop}
502
+ />
150
503
  );
151
504
  }
152
505
 
153
- function toColorInputValue(value: string): string {
506
+ const SOLID_FILL_ID = "solid";
507
+ const FILL_LAYER_PREFIX = "layer:";
508
+
509
+ interface ParsedGradientLayer {
510
+ type: FigmaGradientType;
511
+ stops: FigmaGradientStop[];
512
+ }
513
+
514
+ function fillLayerId(index: number): string {
515
+ return `${FILL_LAYER_PREFIX}${index}`;
516
+ }
517
+
518
+ function fillLayerIndex(id: string): number | null {
519
+ if (!id.startsWith(FILL_LAYER_PREFIX)) return null;
520
+ const index = Number(id.slice(FILL_LAYER_PREFIX.length));
521
+ return Number.isInteger(index) && index >= 0 ? index : null;
522
+ }
523
+
524
+ function buildFillRows(
525
+ colorValue: string,
526
+ backgroundLayers: string[],
527
+ selectedFillId: string,
528
+ ): FigmaFillRow[] {
529
+ const solid = parseCssColor(colorValue);
530
+ const rows: FigmaFillRow[] = [
531
+ {
532
+ id: SOLID_FILL_ID,
533
+ label: "Solid", // i18n-ignore inspector fallback label
534
+ type: "solid",
535
+ value: colorValue,
536
+ swatch: colorValue,
537
+ opacity: solid ? alphaToOpacity(solid.a) : 100,
538
+ selected: selectedFillId === SOLID_FILL_ID,
539
+ },
540
+ ];
541
+
542
+ backgroundLayers.forEach((layer, index) => {
543
+ const gradient = parseGradientLayer(layer);
544
+ rows.push({
545
+ id: fillLayerId(index),
546
+ label: gradient
547
+ ? `Gradient ${index + 1}` // i18n-ignore inspector fallback label
548
+ : `Image ${index + 1}`, // i18n-ignore inspector fallback label
549
+ type: gradient ? "gradient" : "image",
550
+ value: layer,
551
+ swatch: layer,
552
+ opacity: gradient ? averageGradientOpacity(gradient.stops) : 100,
553
+ selected: selectedFillId === fillLayerId(index),
554
+ });
555
+ });
556
+
557
+ return rows;
558
+ }
559
+
560
+ function averageGradientOpacity(stops: FigmaGradientStop[]): number {
561
+ if (!stops.length) return 100;
562
+ const total = stops.reduce((sum, stop) => {
563
+ const parsed = parseCssColor(stop.color);
564
+ return sum + (stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100));
565
+ }, 0);
566
+ return Math.round(total / stops.length);
567
+ }
568
+
569
+ function splitCssLayers(value: string): string[] {
154
570
  const trimmed = value.trim();
155
- if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed;
156
- if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
157
- return `#${trimmed
158
- .slice(1)
159
- .split("")
160
- .map((char) => char + char)
161
- .join("")}`;
571
+ if (!trimmed || trimmed === "none") return [];
572
+ const layers: string[] = [];
573
+ let depth = 0;
574
+ let start = 0;
575
+
576
+ for (let index = 0; index < trimmed.length; index += 1) {
577
+ const char = trimmed[index];
578
+ if (char === "(") depth += 1;
579
+ if (char === ")") depth = Math.max(0, depth - 1);
580
+ if (char === "," && depth === 0) {
581
+ const layer = trimmed.slice(start, index).trim();
582
+ if (layer) layers.push(layer);
583
+ start = index + 1;
584
+ }
162
585
  }
163
- const rgb = trimmed.match(
164
- /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i,
165
- );
166
- if (rgb) {
167
- return `#${rgb
168
- .slice(1, 4)
169
- .map((part) =>
170
- Math.max(0, Math.min(255, Number(part)))
171
- .toString(16)
172
- .padStart(2, "0"),
173
- )
174
- .join("")}`;
586
+
587
+ const finalLayer = trimmed.slice(start).trim();
588
+ if (finalLayer) layers.push(finalLayer);
589
+ return layers;
590
+ }
591
+
592
+ function joinCssLayers(layers: string[]): string {
593
+ const cleaned = layers.map((layer) => layer.trim()).filter(Boolean);
594
+ return cleaned.length ? cleaned.join(", ") : "none";
595
+ }
596
+
597
+ function parseGradientLayer(layer: string): ParsedGradientLayer | null {
598
+ const match = layer.trim().match(/^(linear|radial|conic)-gradient\((.*)\)$/i);
599
+ if (!match) return null;
600
+
601
+ const parts = splitCssLayers(match[2] || "");
602
+ const type = gradientTypeFromCss(match[1] || "", layer);
603
+ const firstStop = parseGradientStop(parts[0] || "", 0, parts.length);
604
+ const stopParts = firstStop ? parts : parts.slice(1);
605
+ const stops = stopParts
606
+ .map((part, index) => parseGradientStop(part, index, stopParts.length))
607
+ .filter((stop): stop is FigmaGradientStop => Boolean(stop));
608
+
609
+ if (!stops.length) return null;
610
+ return { type, stops };
611
+ }
612
+
613
+ function parseGradientStop(
614
+ part: string,
615
+ index: number,
616
+ total: number,
617
+ ): FigmaGradientStop | null {
618
+ const color = readLeadingColor(part);
619
+ if (!color) return null;
620
+ const parsed = parseCssColor(color.value);
621
+ const remaining = part.slice(color.raw.length);
622
+ const positionMatch = remaining.match(/(-?\d+(?:\.\d+)?)%/);
623
+ const position = positionMatch
624
+ ? clampNumber(Number(positionMatch[1]), 0, 100)
625
+ : total <= 1
626
+ ? 0
627
+ : Math.round((index / (total - 1)) * 100);
628
+
629
+ return {
630
+ id: `stop-${index}`,
631
+ color: parsed ? rgbaToCss(parsed) : color.value,
632
+ position,
633
+ opacity: parsed ? alphaToOpacity(parsed.a) : 100,
634
+ };
635
+ }
636
+
637
+ function readLeadingColor(part: string): { raw: string; value: string } | null {
638
+ const trimmed = part.trim();
639
+ const hex = trimmed.match(/^#[0-9a-f]{3,8}\b/i);
640
+ if (hex) return { raw: hex[0], value: hex[0] };
641
+ const transparent = trimmed.match(/^transparent\b/i);
642
+ if (transparent) {
643
+ return { raw: transparent[0], value: "rgba(0, 0, 0, 0)" };
644
+ }
645
+ const functionName = trimmed.match(/^(rgb|rgba|hsl|hsla)\(/i);
646
+ if (!functionName) return null;
647
+ let depth = 0;
648
+ for (let index = 0; index < trimmed.length; index += 1) {
649
+ const char = trimmed[index];
650
+ if (char === "(") depth += 1;
651
+ if (char === ")") {
652
+ depth -= 1;
653
+ if (depth === 0) {
654
+ const raw = trimmed.slice(0, index + 1);
655
+ return { raw, value: raw };
656
+ }
657
+ }
658
+ }
659
+ return null;
660
+ }
661
+
662
+ function gradientTypeFromCss(
663
+ functionName: string,
664
+ layer: string,
665
+ ): FigmaGradientType {
666
+ if (functionName.toLowerCase() === "conic") return "angular";
667
+ if (/closest-corner/i.test(layer)) return "diamond";
668
+ if (functionName.toLowerCase() === "radial") return "radial";
669
+ return "linear";
670
+ }
671
+
672
+ function gradientLabel(type: FigmaGradientType): string {
673
+ if (type === "radial") {
674
+ return "Radial gradient"; // i18n-ignore Figma inspector paint row
675
+ }
676
+ if (type === "angular") {
677
+ return "Angular gradient"; // i18n-ignore Figma inspector paint row
175
678
  }
176
- return "#000000";
679
+ if (type === "diamond") {
680
+ return "Diamond gradient"; // i18n-ignore Figma inspector paint row
681
+ }
682
+ return "Linear gradient"; // i18n-ignore Figma inspector paint row
683
+ }
684
+
685
+ function buildGradientLayer(
686
+ type: FigmaGradientType,
687
+ stops: FigmaGradientStop[],
688
+ ): string {
689
+ const stopList = [...stops]
690
+ .sort((a, b) => a.position - b.position)
691
+ .map((stop) => {
692
+ const parsed = parseCssColor(stop.color);
693
+ const opacity = stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100);
694
+ const color = parsed
695
+ ? rgbaToCss(withColorOpacity(parsed, opacity))
696
+ : stop.color;
697
+ return `${color} ${clampNumber(stop.position, 0, 100)}%`;
698
+ })
699
+ .join(", ");
700
+
701
+ if (type === "radial") {
702
+ return `radial-gradient(circle at 50% 50%, ${stopList})`;
703
+ }
704
+ if (type === "angular") {
705
+ return `conic-gradient(from 0deg at 50% 50%, ${stopList})`;
706
+ }
707
+ if (type === "diamond") {
708
+ return `radial-gradient(closest-corner at 50% 50%, ${stopList})`;
709
+ }
710
+ return `linear-gradient(90deg, ${stopList})`;
711
+ }
712
+
713
+ function defaultGradientStops(colorValue: string): FigmaGradientStop[] {
714
+ const parsed =
715
+ parseCssColor(cssColorOrFallback(colorValue, "#000000")) ??
716
+ parseCssColor("#000000");
717
+ const start = parsed ? rgbaToCss(withColorOpacity(parsed, 100)) : "#000000";
718
+ const end = parsed
719
+ ? rgbaToCss(withColorOpacity(parsed, 0))
720
+ : "rgba(0, 0, 0, 0)";
721
+
722
+ return [
723
+ { id: "stop-0", color: start, position: 0, opacity: 100 },
724
+ { id: "stop-1", color: end, position: 100, opacity: 0 },
725
+ ];
726
+ }
727
+
728
+ function defaultGradientLayer(type: FigmaGradientType, colorValue: string) {
729
+ return buildGradientLayer(type, defaultGradientStops(colorValue));
730
+ }
731
+
732
+ function clampNumber(value: number, min: number, max: number): number {
733
+ if (!Number.isFinite(value)) return min;
734
+ return Math.max(min, Math.min(max, Math.round(value)));
177
735
  }
178
736
 
179
737
  /** Select dropdown */
@@ -189,17 +747,19 @@ function PropSelect({
189
747
  options: { value: string; label: string }[];
190
748
  }) {
191
749
  return (
192
- <div className="flex items-center gap-2">
193
- <Label className="text-xs text-muted-foreground w-20 shrink-0">
194
- {label}
195
- </Label>
750
+ <div className="flex items-center gap-1.5">
751
+ <FieldLabel>{label}</FieldLabel>
196
752
  <Select value={value} onValueChange={onChange}>
197
- <SelectTrigger className="h-7 text-xs">
753
+ <SelectTrigger className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
198
754
  <SelectValue />
199
755
  </SelectTrigger>
200
756
  <SelectContent>
201
757
  {options.map((opt) => (
202
- <SelectItem key={opt.value} value={opt.value} className="text-xs">
758
+ <SelectItem
759
+ key={opt.value}
760
+ value={opt.value}
761
+ className="text-[11px]"
762
+ >
203
763
  {opt.label}
204
764
  </SelectItem>
205
765
  ))}
@@ -228,10 +788,8 @@ function PropSlider({
228
788
  unit?: string;
229
789
  }) {
230
790
  return (
231
- <div className="flex items-center gap-2">
232
- <Label className="text-xs text-muted-foreground w-20 shrink-0">
233
- {label}
234
- </Label>
791
+ <div className="flex items-center gap-1.5">
792
+ <FieldLabel>{label}</FieldLabel>
235
793
  <Slider
236
794
  value={[value]}
237
795
  onValueChange={([v]) => onChange(v)}
@@ -240,7 +798,7 @@ function PropSlider({
240
798
  step={step}
241
799
  className="flex-1"
242
800
  />
243
- <span className="text-xs text-muted-foreground w-12 text-right tabular-nums">
801
+ <span className="w-12 text-right text-[11px] tabular-nums text-muted-foreground">
244
802
  {value}
245
803
  {unit}
246
804
  </span>
@@ -248,55 +806,65 @@ function PropSlider({
248
806
  );
249
807
  }
250
808
 
251
- function SectionTitle({ children }: { children: React.ReactNode }) {
252
- return (
253
- <h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">
254
- {children}
255
- </h3>
256
- );
257
- }
809
+ const EmptyScrubIcon = () => null;
258
810
 
259
- function FourSideCell({
260
- side,
261
- placeholder,
262
- value,
263
- onChange,
811
+ function PanelSection({
812
+ title,
813
+ actions,
814
+ children,
264
815
  }: {
265
- side: string;
266
- placeholder: string;
267
- value: string;
268
- onChange: (side: string, value: string) => void;
816
+ title: string;
817
+ actions?: ReactNode;
818
+ children?: ReactNode;
269
819
  }) {
270
- const [draft, setDraft] = useState(value);
271
- useEffect(() => {
272
- setDraft(value);
273
- }, [value]);
820
+ const [collapsed, setCollapsed] = useState(false);
274
821
 
275
- const commit = () => {
276
- const next = normalizeLengthValue(draft, "px");
277
- if (next !== draft) setDraft(next);
278
- if (next !== value) onChange(side, next);
279
- };
822
+ return (
823
+ <section className="shrink-0 border-b border-border/90">
824
+ <div className="flex min-h-8 items-center gap-2 px-3 pt-1.5">
825
+ <button
826
+ type="button"
827
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 truncate bg-transparent text-left"
828
+ onClick={() => setCollapsed((c) => !c)}
829
+ aria-expanded={!collapsed}
830
+ >
831
+ {collapsed ? (
832
+ <IconChevronRight className="size-3 shrink-0 text-muted-foreground" />
833
+ ) : (
834
+ <IconChevronDown className="size-3 shrink-0 text-muted-foreground" />
835
+ )}
836
+ <h3 className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground">
837
+ {title}
838
+ </h3>
839
+ </button>
840
+ {actions ? (
841
+ <div className="flex items-center gap-0.5">{actions}</div>
842
+ ) : null}
843
+ </div>
844
+ {!collapsed && children ? (
845
+ <div className="space-y-1.5 px-3 pb-2 pt-1.5 text-[11px]">
846
+ {children}
847
+ </div>
848
+ ) : null}
849
+ </section>
850
+ );
851
+ }
280
852
 
853
+ function FieldLabel({ children }: { children: ReactNode }) {
281
854
  return (
282
- <Input
283
- value={draft}
284
- onChange={(e) => setDraft(e.target.value)}
285
- onBlur={commit}
286
- onKeyDown={(e) => {
287
- if (e.key === "Enter") {
288
- e.preventDefault();
289
- commit();
290
- (e.currentTarget as HTMLInputElement).blur();
291
- }
292
- }}
293
- placeholder={placeholder}
294
- className="h-7 text-xs text-center"
295
- />
855
+ <Label className="w-[64px] shrink-0 text-[11px] font-medium text-muted-foreground">
856
+ {children}
857
+ </Label>
858
+ );
859
+ }
860
+
861
+ function SubsectionLabel({ children }: { children: ReactNode }) {
862
+ return (
863
+ <p className="text-[11px] font-medium text-muted-foreground">{children}</p>
296
864
  );
297
865
  }
298
866
 
299
- function FourSideInput({
867
+ function FigmaSpacingControl({
300
868
  label,
301
869
  values,
302
870
  onChange,
@@ -306,39 +874,128 @@ function FourSideInput({
306
874
  onChange: (side: string, value: string) => void;
307
875
  }) {
308
876
  const t = useT();
877
+ const [linked, setLinked] = useState(() => sidesAreLinked(values));
878
+ const numeric = {
879
+ top: parseNumericValue(values.top || "0"),
880
+ right: parseNumericValue(values.right || "0"),
881
+ bottom: parseNumericValue(values.bottom || "0"),
882
+ left: parseNumericValue(values.left || "0"),
883
+ };
884
+ const linkedValue = Math.round(
885
+ (numeric.top + numeric.right + numeric.bottom + numeric.left) / 4,
886
+ );
887
+ const setSide = (
888
+ side: "Top" | "Right" | "Bottom" | "Left",
889
+ value: number,
890
+ ) => {
891
+ onChange(side, `${Math.round(value)}px`);
892
+ };
893
+ const setAll = (value: number) => {
894
+ (["Top", "Right", "Bottom", "Left"] as const).forEach((side) =>
895
+ setSide(side, value),
896
+ );
897
+ };
898
+ const linkedLabel = linked
899
+ ? t("editPanel.labels.unlinkSides")
900
+ : t("editPanel.labels.linkSides");
901
+
309
902
  return (
310
903
  <div className="space-y-1.5">
311
- <Label className="text-xs text-muted-foreground">{label}</Label>
312
- <div className="grid grid-cols-4 gap-1">
313
- <FourSideCell
314
- side="Top"
315
- placeholder={t("editPanel.sidePlaceholders.top")}
316
- value={values.top}
317
- onChange={onChange}
318
- />
319
- <FourSideCell
320
- side="Right"
321
- placeholder={t("editPanel.sidePlaceholders.right")}
322
- value={values.right}
323
- onChange={onChange}
324
- />
325
- <FourSideCell
326
- side="Bottom"
327
- placeholder={t("editPanel.sidePlaceholders.bottom")}
328
- value={values.bottom}
329
- onChange={onChange}
330
- />
331
- <FourSideCell
332
- side="Left"
333
- placeholder={t("editPanel.sidePlaceholders.left")}
334
- value={values.left}
335
- onChange={onChange}
336
- />
904
+ <div className="flex items-center justify-between gap-1.5">
905
+ <Label className="text-[11px] font-medium text-muted-foreground">
906
+ {label}
907
+ </Label>
908
+ <Tooltip>
909
+ <TooltipTrigger asChild>
910
+ <Button
911
+ type="button"
912
+ variant="ghost"
913
+ size="icon"
914
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
915
+ onClick={() => setLinked((current) => !current)}
916
+ aria-label={linkedLabel}
917
+ >
918
+ {linked ? (
919
+ <IconLink className="size-3.5" />
920
+ ) : (
921
+ <IconUnlink className="size-3.5" />
922
+ )}
923
+ </Button>
924
+ </TooltipTrigger>
925
+ <TooltipContent>{linkedLabel}</TooltipContent>
926
+ </Tooltip>
337
927
  </div>
928
+ {linked ? (
929
+ <ScrubInput
930
+ label={t("editPanel.labels.allSides")}
931
+ value={linkedValue}
932
+ onChange={setAll}
933
+ unit="px"
934
+ min={0}
935
+ precision={1}
936
+ labelClassName="w-16"
937
+ inputClassName="h-6"
938
+ />
939
+ ) : (
940
+ <div className="grid grid-cols-2 gap-1.5">
941
+ <ScrubInput
942
+ label={t("editPanel.sidePlaceholders.top")}
943
+ value={numeric.top}
944
+ onChange={(value) => setSide("Top", value)}
945
+ unit="px"
946
+ min={0}
947
+ precision={1}
948
+ inputClassName="h-6"
949
+ />
950
+ <ScrubInput
951
+ label={t("editPanel.sidePlaceholders.right")}
952
+ value={numeric.right}
953
+ onChange={(value) => setSide("Right", value)}
954
+ unit="px"
955
+ min={0}
956
+ precision={1}
957
+ inputClassName="h-6"
958
+ />
959
+ <ScrubInput
960
+ label={t("editPanel.sidePlaceholders.bottom")}
961
+ value={numeric.bottom}
962
+ onChange={(value) => setSide("Bottom", value)}
963
+ unit="px"
964
+ min={0}
965
+ precision={1}
966
+ inputClassName="h-6"
967
+ />
968
+ <ScrubInput
969
+ label={t("editPanel.sidePlaceholders.left")}
970
+ value={numeric.left}
971
+ onChange={(value) => setSide("Left", value)}
972
+ unit="px"
973
+ min={0}
974
+ precision={1}
975
+ inputClassName="h-6"
976
+ />
977
+ </div>
978
+ )}
338
979
  </div>
339
980
  );
340
981
  }
341
982
 
983
+ function sidesAreLinked(values: {
984
+ top: string;
985
+ right: string;
986
+ bottom: string;
987
+ left: string;
988
+ }) {
989
+ return (
990
+ parseNumericValue(values.top || "0") ===
991
+ parseNumericValue(values.right || "0") &&
992
+ parseNumericValue(values.top || "0") ===
993
+ parseNumericValue(values.bottom || "0") &&
994
+ parseNumericValue(values.top || "0") ===
995
+ parseNumericValue(values.left || "0")
996
+ );
997
+ }
998
+
342
999
  const FONT_FAMILY_OPTIONS = [
343
1000
  { value: "inherit", key: "inherit" },
344
1001
  { value: "sans-serif", key: "sansSerif" },
@@ -362,224 +1019,1041 @@ const FONT_WEIGHT_OPTIONS = [
362
1019
  { value: "900", key: "black" },
363
1020
  ] as const;
364
1021
 
365
- const TEXT_ALIGN_OPTIONS = [
366
- { value: "left", key: "left" },
367
- { value: "center", key: "center" },
368
- { value: "right", key: "right" },
369
- { value: "justify", key: "justify" },
370
- ] as const;
371
- const FLEX_DIRECTION_OPTIONS = [
372
- { value: "row", key: "row" },
373
- { value: "column", key: "column" },
374
- { value: "row-reverse", key: "rowReverse" },
375
- { value: "column-reverse", key: "columnReverse" },
376
- ] as const;
377
- const JUSTIFY_OPTIONS = [
378
- { value: "flex-start", key: "start" },
379
- { value: "center", key: "center" },
380
- { value: "flex-end", key: "end" },
381
- { value: "space-between", key: "between" },
382
- { value: "space-around", key: "around" },
383
- { value: "space-evenly", key: "evenly" },
384
- ] as const;
385
- const ALIGN_OPTIONS = [
1022
+ const ALIGN_SELF_OPTIONS = [
1023
+ { value: "auto", key: "auto" },
386
1024
  { value: "flex-start", key: "start" },
387
1025
  { value: "center", key: "center" },
388
1026
  { value: "flex-end", key: "end" },
389
1027
  { value: "stretch", key: "stretch" },
390
1028
  { value: "baseline", key: "baseline" },
391
1029
  ] as const;
1030
+ const STROKE_POSITION_OPTIONS = [
1031
+ { value: "inside", key: "inside" },
1032
+ { value: "center", key: "center" },
1033
+ { value: "outside", key: "outside" },
1034
+ ] as const;
1035
+ const BLEND_MODE_OPTIONS = [
1036
+ { value: "normal", label: "Normal" },
1037
+ { value: "multiply", label: "Multiply" },
1038
+ { value: "screen", label: "Screen" },
1039
+ { value: "overlay", label: "Overlay" },
1040
+ { value: "darken", label: "Darken" },
1041
+ { value: "lighten", label: "Lighten" },
1042
+ { value: "color-dodge", label: "Color dodge" }, // i18n-ignore Figma blend mode label
1043
+ { value: "color-burn", label: "Color burn" }, // i18n-ignore Figma blend mode label
1044
+ { value: "hard-light", label: "Hard light" }, // i18n-ignore Figma blend mode label
1045
+ { value: "soft-light", label: "Soft light" }, // i18n-ignore Figma blend mode label
1046
+ { value: "difference", label: "Difference" },
1047
+ { value: "exclusion", label: "Exclusion" },
1048
+ { value: "hue", label: "Hue" },
1049
+ { value: "saturation", label: "Saturation" },
1050
+ { value: "color", label: "Color" },
1051
+ { value: "luminosity", label: "Luminosity" },
1052
+ ] as const;
392
1053
 
393
1054
  function parseNumericValue(value: string): number {
394
1055
  return parseFloat(value) || 0;
395
1056
  }
396
1057
 
397
- /** Page-level properties when nothing is selected */
398
- function PageProperties({
399
- styles,
400
- onStyleChange,
401
- }: {
402
- styles: Record<string, string>;
403
- onStyleChange: (property: string, value: string) => void;
404
- }) {
405
- const t = useT();
406
- const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
407
- value: option.value,
408
- label: t(`editPanel.fontFamilies.${option.key}`),
409
- }));
410
- const fontFamily = FONT_FAMILY_OPTIONS.some(
411
- (option) => option.value === styles.fontFamily,
412
- )
413
- ? styles.fontFamily
414
- : "sans-serif";
415
-
416
- return (
417
- <div className="space-y-4">
418
- {/* Lead with a clear CTA so users discover the much richer per-element
419
- panel. Without this it's easy to mistake the 3 page-level fields for
420
- "the entire editor" — the cause of the "controls too limited"
421
- feedback. */}
422
- <div className="rounded-lg border border-border/70 bg-accent/30 p-3 text-xs text-muted-foreground/90 leading-relaxed">
423
- <p className="font-medium text-foreground/85 mb-1 flex items-center gap-1.5">
424
- <IconPointer className="w-3.5 h-3.5" />
425
- {t("editPanel.pageHelpTitle")}
426
- </p>
427
- <p>{t("editPanel.pageHelpDescription")}</p>
428
- </div>
1058
+ function parseRotationValue(transform: string | undefined): number {
1059
+ const match = transform?.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
1060
+ return match ? Number(match[1]) : 0;
1061
+ }
429
1062
 
430
- <SectionTitle>{t("editPanel.sections.page")}</SectionTitle>
431
- <ColorInput
432
- label={t("editPanel.labels.background")}
433
- value={styles.backgroundColor || ""}
434
- onChange={(v) => onStyleChange("backgroundColor", v)}
435
- />
436
- <PropSelect
437
- label={t("editPanel.labels.font")}
438
- value={fontFamily}
439
- onChange={(v) => onStyleChange("fontFamily", v)}
440
- options={fontFamilyOptions}
441
- />
442
- <PropInput
443
- label={t("editPanel.labels.baseSize")}
444
- value={styles.fontSize || "16px"}
445
- onChange={(v) => onStyleChange("fontSize", v)}
446
- placeholder="16px"
447
- defaultUnit="px"
448
- />
449
- </div>
450
- );
1063
+ function mergeRotationValue(transform: string | undefined, degrees: number) {
1064
+ const nextRotate = `rotate(${Math.round(degrees * 10) / 10}deg)`;
1065
+ if (!transform || transform === "none") return nextRotate;
1066
+ if (/rotate\((-?\d+(?:\.\d+)?)deg\)/.test(transform)) {
1067
+ return transform.replace(/rotate\((-?\d+(?:\.\d+)?)deg\)/, nextRotate);
1068
+ }
1069
+ return `${transform} ${nextRotate}`;
451
1070
  }
452
1071
 
453
- /** Text element properties */
454
- function TextProperties({
455
- element,
456
- onStyleChange,
1072
+ function ScrubStyleInput({
1073
+ label,
1074
+ value,
1075
+ placeholder,
1076
+ onChange,
1077
+ unit = "px",
1078
+ min,
1079
+ max,
1080
+ step = 1,
1081
+ labelClassName,
1082
+ inputClassName,
1083
+ hideIcon = true,
457
1084
  }: {
458
- element: ElementInfo;
459
- onStyleChange: (property: string, value: string) => void;
1085
+ label: string;
1086
+ value: string;
1087
+ placeholder?: number;
1088
+ onChange: (value: number) => void;
1089
+ unit?: string;
1090
+ min?: number;
1091
+ max?: number;
1092
+ step?: number;
1093
+ labelClassName?: string;
1094
+ inputClassName?: string;
1095
+ hideIcon?: boolean;
460
1096
  }) {
461
- const t = useT();
462
- const styles = element.computedStyles;
463
- const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
464
- value: option.value,
465
- label: t(`editPanel.fontFamilies.${option.key}`),
466
- }));
467
- const fontWeightOptions = FONT_WEIGHT_OPTIONS.map((option) => ({
468
- value: option.value,
469
- label: t(`editPanel.fontWeights.${option.key}`),
470
- }));
471
- const textAlignOptions = TEXT_ALIGN_OPTIONS.map((option) => ({
472
- value: option.value,
473
- label: t(`editPanel.textAligns.${option.key}`),
474
- }));
475
-
476
1097
  return (
477
- <div className="space-y-4">
478
- <SectionTitle>{t("editPanel.sections.typography")}</SectionTitle>
479
- <PropSelect
480
- label={t("editPanel.labels.font")}
481
- value={styles.fontFamily || "sans-serif"}
482
- onChange={(v) => onStyleChange("fontFamily", v)}
483
- options={fontFamilyOptions}
484
- />
485
- <PropInput
486
- label={t("editPanel.labels.size")}
487
- value={styles.fontSize || ""}
488
- onChange={(v) => onStyleChange("fontSize", v)}
489
- placeholder="16px"
490
- defaultUnit="px"
491
- />
492
- <PropSelect
493
- label={t("editPanel.labels.weight")}
494
- value={styles.fontWeight || "400"}
495
- onChange={(v) => onStyleChange("fontWeight", v)}
496
- options={fontWeightOptions}
497
- />
498
- <ColorInput
499
- label={t("editPanel.labels.color")}
500
- value={styles.color || ""}
501
- onChange={(v) => onStyleChange("color", v)}
502
- />
503
- <PropSelect
504
- label={t("editPanel.labels.align")}
505
- value={styles.textAlign || "left"}
506
- onChange={(v) => onStyleChange("textAlign", v)}
507
- options={textAlignOptions}
508
- />
509
- <PropInput
510
- label={t("editPanel.labels.lineHeight")}
511
- value={styles.lineHeight || ""}
512
- onChange={(v) => onStyleChange("lineHeight", v)}
513
- placeholder="1.5"
514
- />
515
- <PropInput
516
- label={t("editPanel.labels.tracking")}
517
- value={styles.letterSpacing || ""}
518
- onChange={(v) => onStyleChange("letterSpacing", v)}
519
- placeholder="0px"
520
- defaultUnit="px"
521
- />
522
- </div>
1098
+ <ScrubInput
1099
+ label={label}
1100
+ icon={hideIcon ? EmptyScrubIcon : undefined}
1101
+ value={value ? parseNumericValue(value) : (placeholder ?? 0)}
1102
+ onChange={onChange}
1103
+ unit={unit}
1104
+ min={min}
1105
+ max={max}
1106
+ step={step}
1107
+ precision={1}
1108
+ className="gap-0"
1109
+ labelClassName={cn(
1110
+ "h-6 w-7 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] tabular-nums",
1111
+ labelClassName,
1112
+ )}
1113
+ inputClassName={cn(
1114
+ "h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]",
1115
+ inputClassName,
1116
+ )}
1117
+ />
523
1118
  );
524
1119
  }
525
1120
 
526
- /** Flex container/child properties */
527
- function FlexProperties({
528
- element,
529
- onStyleChange,
530
- }: {
531
- element: ElementInfo;
532
- onStyleChange: (property: string, value: string) => void;
533
- }) {
534
- const t = useT();
535
- const styles = element.computedStyles;
536
- const flexDirectionOptions = FLEX_DIRECTION_OPTIONS.map((option) => ({
537
- value: option.value,
538
- label: t(`editPanel.flexDirections.${option.key}`),
539
- }));
540
- const justifyOptions = JUSTIFY_OPTIONS.map((option) => ({
541
- value: option.value,
542
- label: t(`editPanel.justifyOptions.${option.key}`),
543
- }));
544
- const alignOptions = ALIGN_OPTIONS.map((option) => ({
545
- value: option.value,
546
- label: t(`editPanel.alignOptions.${option.key}`),
547
- }));
1121
+ function commitStylePatch(
1122
+ styles: Record<string, string>,
1123
+ onStyleChange: (property: string, value: string) => void,
1124
+ onStylesChange?: (styles: Record<string, string>) => void,
1125
+ ) {
1126
+ if (onStylesChange) {
1127
+ onStylesChange(styles);
1128
+ return;
1129
+ }
1130
+ Object.entries(styles).forEach(([property, value]) => {
1131
+ onStyleChange(property, value);
1132
+ });
1133
+ }
548
1134
 
549
- return (
550
- <div className="space-y-4">
551
- <SectionTitle>{t("editPanel.sections.flexLayout")}</SectionTitle>
552
- <PropSelect
553
- label={t("editPanel.labels.direction")}
554
- value={styles.flexDirection || "row"}
555
- onChange={(v) => onStyleChange("flexDirection", v)}
556
- options={flexDirectionOptions}
557
- />
558
- <PropSelect
559
- label={t("editPanel.labels.justify")}
560
- value={styles.justifyContent || "flex-start"}
561
- onChange={(v) => onStyleChange("justifyContent", v)}
562
- options={justifyOptions}
563
- />
564
- <PropSelect
565
- label={t("editPanel.labels.align")}
566
- value={styles.alignItems || "stretch"}
567
- onChange={(v) => onStyleChange("alignItems", v)}
568
- options={alignOptions}
569
- />
570
- <PropInput
571
- label={t("editPanel.labels.gap")}
572
- value={styles.gap || ""}
573
- onChange={(v) => onStyleChange("gap", v)}
574
- placeholder="0px"
575
- defaultUnit="px"
1135
+ function optionValue<T extends readonly { value: string }[]>(
1136
+ options: T,
1137
+ value: string | undefined,
1138
+ fallback: T[number]["value"],
1139
+ ) {
1140
+ return options.some((option) => option.value === value) ? value! : fallback;
1141
+ }
1142
+
1143
+ function cssLengthNumber(value: string | undefined, fallback = 0): number {
1144
+ const parsed = parseFloat(value || "");
1145
+ return Number.isFinite(parsed) ? parsed : fallback;
1146
+ }
1147
+
1148
+ function cssColorOrFallback(value: string | undefined, fallback: string) {
1149
+ const normalized = value?.trim();
1150
+ if (
1151
+ !normalized ||
1152
+ normalized === "transparent" ||
1153
+ normalized === "rgba(0, 0, 0, 0)"
1154
+ ) {
1155
+ return fallback;
1156
+ }
1157
+ return normalized;
1158
+ }
1159
+
1160
+ function strokeIsVisible(width: string | undefined, style: string | undefined) {
1161
+ return cssLengthNumber(width) > 0 && style !== "none";
1162
+ }
1163
+
1164
+ function swatchStyle(value: string | undefined) {
1165
+ return {
1166
+ background:
1167
+ value && value !== "none"
1168
+ ? value
1169
+ : "linear-gradient(135deg, hsl(var(--muted)) 0 45%, hsl(var(--border)) 45% 55%, hsl(var(--muted)) 55% 100%)",
1170
+ };
1171
+ }
1172
+
1173
+ function compactCssValue(value: string | undefined, fallback: string) {
1174
+ const normalized = value?.trim();
1175
+ if (!normalized || normalized === "none") return fallback;
1176
+ return normalized;
1177
+ }
1178
+
1179
+ function colorHasVisibleAlpha(value: string | undefined): boolean {
1180
+ const parsed = parseCssColor(value || "");
1181
+ if (!parsed) return Boolean(value && value !== "transparent");
1182
+ return parsed.a > 0;
1183
+ }
1184
+
1185
+ function inspectorObjectTitle(element: ElementInfo): string {
1186
+ const tag = element.tagName || "element";
1187
+ if (TEXT_TAGS.has(tag)) return "Text";
1188
+ if (tag === "section") return "Section";
1189
+ if (tag === "img" || tag === "video" || tag === "picture") return "Image";
1190
+ if (tag === "button" || tag === "a") return "Button";
1191
+ if (tag === "svg" || tag === "path") return "Vector";
1192
+ if (element.isFlexContainer || tag === "div" || tag === "main") {
1193
+ return "Frame";
1194
+ }
1195
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
1196
+ }
1197
+
1198
+ function displayLabel(value: string | undefined): string {
1199
+ const normalized = value?.trim();
1200
+ if (!normalized || normalized === "normal") return "flow";
1201
+ return normalized;
1202
+ }
1203
+
1204
+ function justifyToHorizontal(
1205
+ value: string | undefined,
1206
+ ): AlignmentMatrixValue["horizontal"] {
1207
+ if (value === "center") return "center";
1208
+ if (value === "flex-end" || value === "end" || value === "right") {
1209
+ return "right";
1210
+ }
1211
+ return "left";
1212
+ }
1213
+
1214
+ function alignToVertical(
1215
+ value: string | undefined,
1216
+ ): AlignmentMatrixValue["vertical"] {
1217
+ if (value === "center") return "middle";
1218
+ if (value === "flex-end" || value === "end" || value === "bottom") {
1219
+ return "bottom";
1220
+ }
1221
+ return "top";
1222
+ }
1223
+
1224
+ function horizontalToJustify(
1225
+ value: AlignmentMatrixValue["horizontal"],
1226
+ ): string {
1227
+ if (value === "center") return "center";
1228
+ if (value === "right") return "flex-end";
1229
+ return "flex-start";
1230
+ }
1231
+
1232
+ function verticalToAlign(value: AlignmentMatrixValue["vertical"]): string {
1233
+ if (value === "middle") return "center";
1234
+ if (value === "bottom") return "flex-end";
1235
+ return "flex-start";
1236
+ }
1237
+
1238
+ function autoLayoutAlignmentFromStyles(
1239
+ styles: Record<string, string>,
1240
+ direction: AutoLayoutMatrixValue["direction"],
1241
+ ): AlignmentMatrixValue {
1242
+ if (direction === "vertical") {
1243
+ return {
1244
+ horizontal: justifyToHorizontal(styles.alignItems),
1245
+ vertical: alignToVertical(styles.justifyContent),
1246
+ };
1247
+ }
1248
+ return {
1249
+ horizontal: justifyToHorizontal(styles.justifyContent),
1250
+ vertical: alignToVertical(styles.alignItems),
1251
+ };
1252
+ }
1253
+
1254
+ function isParentFlex(element: ElementInfo): boolean {
1255
+ return (
1256
+ element.isFlexChild ||
1257
+ Boolean(element.parentDisplay?.toLowerCase().includes("flex"))
1258
+ );
1259
+ }
1260
+
1261
+ function isParentGrid(element: ElementInfo): boolean {
1262
+ return Boolean(element.parentDisplay?.toLowerCase().includes("grid"));
1263
+ }
1264
+
1265
+ function parentFlexDirection(element: ElementInfo): AutoLayoutSizingAxis {
1266
+ return element.parentLayout?.flexDirection?.includes("column")
1267
+ ? "vertical"
1268
+ : "horizontal";
1269
+ }
1270
+
1271
+ function availableSizingForElement(
1272
+ element: ElementInfo,
1273
+ ): Partial<Record<AutoLayoutSizingAxis, AutoLayoutSizing[]>> {
1274
+ const options: AutoLayoutSizing[] =
1275
+ isParentFlex(element) || isParentGrid(element)
1276
+ ? ["hug", "fill", "fixed"]
1277
+ : ["hug", "fixed"];
1278
+ return {
1279
+ horizontal: options,
1280
+ vertical: options,
1281
+ };
1282
+ }
1283
+
1284
+ function inferElementSizing(
1285
+ element: ElementInfo,
1286
+ axis: AutoLayoutSizingAxis,
1287
+ ): AutoLayoutSizing {
1288
+ const styles = element.computedStyles;
1289
+ const size = axis === "horizontal" ? styles.width : styles.height;
1290
+ const parentDirection = parentFlexDirection(element);
1291
+ const isMainFlexAxis = isParentFlex(element) && parentDirection === axis;
1292
+
1293
+ if (
1294
+ size === "100%" ||
1295
+ (isMainFlexAxis && Number.parseFloat(styles.flexGrow || "0") > 0)
1296
+ ) {
1297
+ return "fill";
1298
+ }
1299
+ if (size === "auto" || size === "fit-content" || size === "max-content") {
1300
+ return "hug";
1301
+ }
1302
+ return "fixed";
1303
+ }
1304
+
1305
+ function commitElementSizing(
1306
+ element: ElementInfo,
1307
+ axis: AutoLayoutSizingAxis,
1308
+ sizing: AutoLayoutSizing,
1309
+ onStyleChange: (property: string, value: string) => void,
1310
+ onStylesChange?: (styles: Record<string, string>) => void,
1311
+ ) {
1312
+ const isHorizontal = axis === "horizontal";
1313
+ const sizeProperty = isHorizontal ? "width" : "height";
1314
+ const resolvedSize = Math.max(
1315
+ 1,
1316
+ Math.round(
1317
+ isHorizontal ? element.boundingRect.width : element.boundingRect.height,
1318
+ ),
1319
+ );
1320
+ const parentDirection = parentFlexDirection(element);
1321
+ const isFlex = isParentFlex(element);
1322
+ const isGrid = isParentGrid(element);
1323
+ const isMainFlexAxis = isFlex && parentDirection === axis;
1324
+ const patch: Record<string, string> = {};
1325
+
1326
+ if (sizing === "fixed") {
1327
+ patch[sizeProperty] = `${resolvedSize}px`;
1328
+ if (isMainFlexAxis) {
1329
+ patch.flexGrow = "0";
1330
+ patch.flexShrink = "0";
1331
+ patch.flexBasis = "auto";
1332
+ }
1333
+ } else if (sizing === "fill") {
1334
+ if (isMainFlexAxis) {
1335
+ patch[sizeProperty] = "auto";
1336
+ patch.flexGrow = "1";
1337
+ patch.flexShrink = "1";
1338
+ patch.flexBasis = "0px";
1339
+ } else {
1340
+ patch[sizeProperty] = "100%";
1341
+ if (isFlex) patch.alignSelf = "stretch";
1342
+ if (isGrid) patch[isHorizontal ? "justifySelf" : "alignSelf"] = "stretch";
1343
+ }
1344
+ } else {
1345
+ patch[sizeProperty] = "auto";
1346
+ if (isMainFlexAxis) {
1347
+ patch.flexGrow = "0";
1348
+ patch.flexShrink = "1";
1349
+ patch.flexBasis = "auto";
1350
+ }
1351
+ }
1352
+
1353
+ commitStylePatch(patch, onStyleChange, onStylesChange);
1354
+ }
1355
+
1356
+ function elementTypeIcon(element: ElementInfo) {
1357
+ const tag = element.tagName || "element";
1358
+ if (TEXT_TAGS.has(tag)) return IconTypography;
1359
+ if (tag === "img" || tag === "video" || tag === "picture") return IconPhoto;
1360
+ if (tag === "svg" || tag === "path") return IconVector;
1361
+ if (tag === "button" || tag === "a") return IconComponents;
1362
+ return IconFrame;
1363
+ }
1364
+
1365
+ function SelectionHeader({ element }: { element: ElementInfo | null }) {
1366
+ if (!element) return null;
1367
+
1368
+ const title = inspectorObjectTitle(element);
1369
+ const TypeIcon = elementTypeIcon(element);
1370
+
1371
+ return (
1372
+ <div className="flex min-h-8 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
1373
+ <div className="flex min-w-0 items-center gap-1.5 text-left text-[13px] font-semibold text-foreground">
1374
+ <TypeIcon className="size-3.5 shrink-0 text-muted-foreground" />
1375
+ <span className="truncate">{title}</span>
1376
+ </div>
1377
+ </div>
1378
+ );
1379
+ }
1380
+
1381
+ function InspectorTabsHeader({
1382
+ activeTab,
1383
+ onActiveTabChange,
1384
+ }: {
1385
+ activeTab: InspectorTab;
1386
+ onActiveTabChange: (tab: InspectorTab) => void;
1387
+ }) {
1388
+ const t = useT();
1389
+
1390
+ return (
1391
+ <div className="flex min-h-8 shrink-0 items-center justify-between border-b border-border/90 px-3">
1392
+ <Tabs
1393
+ value={activeTab}
1394
+ onValueChange={(value) => onActiveTabChange(value as InspectorTab)}
1395
+ >
1396
+ <TabsList className="h-7 justify-start gap-1 rounded-none bg-transparent p-0">
1397
+ <TabsTrigger
1398
+ value="design"
1399
+ className="h-6 rounded-md px-2.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
1400
+ >
1401
+ {"Design" /* i18n-ignore Figma inspector tab */}
1402
+ </TabsTrigger>
1403
+ <TabsTrigger
1404
+ value="tweaks"
1405
+ className="h-6 rounded-md px-2.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
1406
+ >
1407
+ {t("designEditor.tweaks")}
1408
+ </TabsTrigger>
1409
+ <TabsTrigger
1410
+ value="extensions"
1411
+ className="h-6 rounded-md px-2.5 text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
1412
+ >
1413
+ {t("designEditor.extensions")}
1414
+ </TabsTrigger>
1415
+ </TabsList>
1416
+ </Tabs>
1417
+ </div>
1418
+ );
1419
+ }
1420
+
1421
+ function SectionIconButton({
1422
+ label,
1423
+ onClick,
1424
+ children,
1425
+ disabled = false,
1426
+ }: {
1427
+ label: string;
1428
+ onClick?: () => void;
1429
+ children: ReactNode;
1430
+ disabled?: boolean;
1431
+ }) {
1432
+ return (
1433
+ <Tooltip>
1434
+ <TooltipTrigger asChild>
1435
+ <Button
1436
+ type="button"
1437
+ variant="ghost"
1438
+ size="icon"
1439
+ className="size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed"
1440
+ disabled={disabled}
1441
+ onClick={onClick}
1442
+ aria-label={label}
1443
+ >
1444
+ {children}
1445
+ </Button>
1446
+ </TooltipTrigger>
1447
+ <TooltipContent>{label}</TooltipContent>
1448
+ </Tooltip>
1449
+ );
1450
+ }
1451
+
1452
+ function InspectorIconButton({
1453
+ label,
1454
+ active,
1455
+ onClick,
1456
+ children,
1457
+ }: {
1458
+ label: string;
1459
+ active?: boolean;
1460
+ onClick?: () => void;
1461
+ children: ReactNode;
1462
+ }) {
1463
+ return (
1464
+ <Tooltip>
1465
+ <TooltipTrigger asChild>
1466
+ <Button
1467
+ type="button"
1468
+ variant="ghost"
1469
+ size="icon"
1470
+ className={cn(
1471
+ "h-6 min-w-6 cursor-pointer rounded-none border-r border-border/50 text-muted-foreground first:rounded-l-md last:rounded-r-md last:border-r-0 hover:bg-[var(--design-editor-panel-raised-bg)] hover:text-foreground disabled:cursor-not-allowed",
1472
+ active &&
1473
+ "bg-[var(--design-editor-panel-bg)] text-[var(--design-editor-accent-color)] shadow-[inset_0_0_0_1px_var(--design-editor-control-border)]",
1474
+ )}
1475
+ onClick={onClick}
1476
+ aria-label={label}
1477
+ >
1478
+ {children}
1479
+ </Button>
1480
+ </TooltipTrigger>
1481
+ <TooltipContent>{label}</TooltipContent>
1482
+ </Tooltip>
1483
+ );
1484
+ }
1485
+
1486
+ function InspectorSegment({ children }: { children: ReactNode }) {
1487
+ return (
1488
+ <div className="flex min-w-0 overflow-hidden rounded-md bg-[var(--design-editor-control-bg)]">
1489
+ {children}
1490
+ </div>
1491
+ );
1492
+ }
1493
+
1494
+ function sizingModeLabel(mode: AutoLayoutSizing): string {
1495
+ if (mode === "hug") return "Hug"; // i18n-ignore Figma inspector sizing mode
1496
+ if (mode === "fill") return "Fill"; // i18n-ignore Figma inspector sizing mode
1497
+ return "Fixed"; // i18n-ignore Figma inspector sizing mode
1498
+ }
1499
+
1500
+ function SizingModeButton({
1501
+ axis,
1502
+ value,
1503
+ resolvedSize,
1504
+ options,
1505
+ onChange,
1506
+ }: {
1507
+ axis: "W" | "H";
1508
+ value: AutoLayoutSizing;
1509
+ resolvedSize: number;
1510
+ options: AutoLayoutSizing[];
1511
+ onChange: (value: AutoLayoutSizing) => void;
1512
+ }) {
1513
+ const validOptions = options.includes(value) ? options : [...options, value];
1514
+ const currentIndex = validOptions.indexOf(value);
1515
+ const nextValue =
1516
+ validOptions[(currentIndex + 1) % validOptions.length] ?? "fixed";
1517
+ const label = `${axis} ${sizingModeLabel(value)}`;
1518
+
1519
+ return (
1520
+ <Tooltip>
1521
+ <TooltipTrigger asChild>
1522
+ <Button
1523
+ type="button"
1524
+ variant="ghost"
1525
+ aria-label={label}
1526
+ onClick={() => onChange(nextValue)}
1527
+ className="h-6 justify-start rounded-md bg-[var(--design-editor-control-bg)] px-0 text-[11px] font-normal text-foreground hover:bg-[var(--design-editor-panel-raised-bg)]"
1528
+ >
1529
+ <span className="flex h-full w-6 shrink-0 items-center justify-center rounded-l-md border-r border-border/60 text-muted-foreground">
1530
+ {axis}
1531
+ </span>
1532
+ <span className="min-w-0 flex-1 truncate px-1 text-left tabular-nums text-muted-foreground">
1533
+ {Math.round(resolvedSize)}
1534
+ </span>
1535
+ <span className="flex h-full min-w-8 items-center justify-center border-l border-border/60 px-1.5 font-medium text-foreground">
1536
+ {sizingModeLabel(value)}
1537
+ </span>
1538
+ </Button>
1539
+ </TooltipTrigger>
1540
+ <TooltipContent>{label}</TooltipContent>
1541
+ </Tooltip>
1542
+ );
1543
+ }
1544
+
1545
+ function CornerRadiusControl({
1546
+ styles,
1547
+ onStyleChange,
1548
+ }: {
1549
+ styles: Record<string, string>;
1550
+ onStyleChange: (property: string, value: string) => void;
1551
+ }) {
1552
+ const t = useT();
1553
+ const independentCornersLabel = t("editPanel.labels.independentCorners");
1554
+ const radius = cssLengthNumber(styles.borderRadius || "0");
1555
+ const corners = {
1556
+ topLeft: cssLengthNumber(styles.borderTopLeftRadius || styles.borderRadius),
1557
+ topRight: cssLengthNumber(
1558
+ styles.borderTopRightRadius || styles.borderRadius,
1559
+ ),
1560
+ bottomRight: cssLengthNumber(
1561
+ styles.borderBottomRightRadius || styles.borderRadius,
1562
+ ),
1563
+ bottomLeft: cssLengthNumber(
1564
+ styles.borderBottomLeftRadius || styles.borderRadius,
1565
+ ),
1566
+ };
1567
+
1568
+ return (
1569
+ <div className="grid grid-cols-[1fr_auto] items-center gap-1.5">
1570
+ <ScrubInput
1571
+ label={t("editPanel.labels.cornerRadius")}
1572
+ value={radius}
1573
+ onChange={(value) =>
1574
+ onStyleChange("borderRadius", `${Math.max(0, Math.round(value))}px`)
1575
+ }
1576
+ unit="px"
1577
+ min={0}
1578
+ precision={1}
1579
+ labelClassName="w-24"
1580
+ inputClassName="h-6"
576
1581
  />
1582
+ <Popover>
1583
+ <Tooltip>
1584
+ <TooltipTrigger asChild>
1585
+ <PopoverTrigger asChild>
1586
+ <Button
1587
+ type="button"
1588
+ variant="outline"
1589
+ size="icon"
1590
+ className="size-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]"
1591
+ aria-label={independentCornersLabel}
1592
+ >
1593
+ <IconMaximize className="size-3.5" />
1594
+ </Button>
1595
+ </PopoverTrigger>
1596
+ </TooltipTrigger>
1597
+ <TooltipContent>{independentCornersLabel}</TooltipContent>
1598
+ </Tooltip>
1599
+ <PopoverContent
1600
+ side="left"
1601
+ align="start"
1602
+ sideOffset={8}
1603
+ className="w-64 p-3"
1604
+ >
1605
+ <div className="space-y-2">
1606
+ <p className="text-xs font-semibold text-foreground">
1607
+ {independentCornersLabel}
1608
+ </p>
1609
+ <div className="grid grid-cols-2 gap-2">
1610
+ <ScrubInput
1611
+ label={t("editPanel.labels.topLeft")}
1612
+ value={corners.topLeft}
1613
+ onChange={(value) =>
1614
+ onStyleChange(
1615
+ "borderTopLeftRadius",
1616
+ `${Math.max(0, Math.round(value))}px`,
1617
+ )
1618
+ }
1619
+ unit="px"
1620
+ min={0}
1621
+ precision={1}
1622
+ inputClassName="h-6"
1623
+ />
1624
+ <ScrubInput
1625
+ label={t("editPanel.labels.topRight")}
1626
+ value={corners.topRight}
1627
+ onChange={(value) =>
1628
+ onStyleChange(
1629
+ "borderTopRightRadius",
1630
+ `${Math.max(0, Math.round(value))}px`,
1631
+ )
1632
+ }
1633
+ unit="px"
1634
+ min={0}
1635
+ precision={1}
1636
+ inputClassName="h-6"
1637
+ />
1638
+ <ScrubInput
1639
+ label={t("editPanel.labels.bottomLeft")}
1640
+ value={corners.bottomLeft}
1641
+ onChange={(value) =>
1642
+ onStyleChange(
1643
+ "borderBottomLeftRadius",
1644
+ `${Math.max(0, Math.round(value))}px`,
1645
+ )
1646
+ }
1647
+ unit="px"
1648
+ min={0}
1649
+ precision={1}
1650
+ inputClassName="h-6"
1651
+ />
1652
+ <ScrubInput
1653
+ label={t("editPanel.labels.bottomRight")}
1654
+ value={corners.bottomRight}
1655
+ onChange={(value) =>
1656
+ onStyleChange(
1657
+ "borderBottomRightRadius",
1658
+ `${Math.max(0, Math.round(value))}px`,
1659
+ )
1660
+ }
1661
+ unit="px"
1662
+ min={0}
1663
+ precision={1}
1664
+ inputClassName="h-6"
1665
+ />
1666
+ </div>
1667
+ </div>
1668
+ </PopoverContent>
1669
+ </Popover>
1670
+ </div>
1671
+ );
1672
+ }
1673
+
1674
+ type StrokeLayerKind = "border" | "outline";
1675
+
1676
+ function StrokeLayerControl({
1677
+ kind,
1678
+ visible,
1679
+ color,
1680
+ width,
1681
+ styleValue,
1682
+ onStyleChange,
1683
+ onRemove,
1684
+ }: {
1685
+ kind: StrokeLayerKind;
1686
+ visible: boolean;
1687
+ color: string;
1688
+ width: string;
1689
+ styleValue: string;
1690
+ onStyleChange: (property: string, value: string) => void;
1691
+ onRemove: () => void;
1692
+ }) {
1693
+ const t = useT();
1694
+ const strokePositionOptions = STROKE_POSITION_OPTIONS.map((option) => ({
1695
+ value: option.value,
1696
+ label:
1697
+ option.key === "center"
1698
+ ? t("editPanel.textAligns.center")
1699
+ : t(`editPanel.labels.${option.key}`),
1700
+ }));
1701
+ const prefix = kind === "border" ? "border" : "outline";
1702
+ const position = kind === "border" ? "inside" : "outside";
1703
+
1704
+ const movePosition = (next: string) => {
1705
+ if (next === position) return;
1706
+ const nextPrefix = next === "outside" ? "outline" : "border";
1707
+ onStyleChange(`${nextPrefix}Color`, color);
1708
+ onStyleChange(`${nextPrefix}Width`, width || "1px");
1709
+ onStyleChange(
1710
+ `${nextPrefix}Style`,
1711
+ styleValue === "none" ? "solid" : styleValue,
1712
+ );
1713
+ onRemove();
1714
+ };
1715
+
1716
+ return (
1717
+ <div className="space-y-1.5">
1718
+ {/* Figma stroke row: [swatch+hex trigger (flex-1)] [eye] [remove] */}
1719
+ <div className="flex items-center gap-1.5">
1720
+ <div className="min-w-0 flex-1">
1721
+ <ColorInput
1722
+ label=""
1723
+ value={cssColorOrFallback(color, "#000000")}
1724
+ onChange={(value) => onStyleChange(`${prefix}Color`, value)}
1725
+ />
1726
+ </div>
1727
+ <SectionIconButton
1728
+ label={
1729
+ visible
1730
+ ? t("editPanel.labels.hideLayer")
1731
+ : t("editPanel.labels.showLayer")
1732
+ }
1733
+ onClick={() => {
1734
+ if (visible) {
1735
+ onStyleChange(`${prefix}Style`, "none");
1736
+ return;
1737
+ }
1738
+ onStyleChange(`${prefix}Style`, "solid");
1739
+ onStyleChange(
1740
+ `${prefix}Width`,
1741
+ width === "0px" ? "1px" : width || "1px",
1742
+ );
1743
+ }}
1744
+ >
1745
+ {visible ? (
1746
+ <IconEye className="size-3.5" />
1747
+ ) : (
1748
+ <IconEyeOff className="size-3.5" />
1749
+ )}
1750
+ </SectionIconButton>
1751
+ <SectionIconButton
1752
+ label={t("editPanel.labels.removeLayer")}
1753
+ onClick={onRemove}
1754
+ >
1755
+ <IconMinus className="size-3.5" />
1756
+ </SectionIconButton>
1757
+ </div>
1758
+ {/* Figma stroke geometry: position + weight side by side */}
1759
+ <div className="grid grid-cols-2 gap-1.5">
1760
+ <Select value={position} onValueChange={movePosition}>
1761
+ <SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
1762
+ <SelectValue />
1763
+ </SelectTrigger>
1764
+ <SelectContent>
1765
+ {strokePositionOptions.map((option) => (
1766
+ <SelectItem
1767
+ key={option.value}
1768
+ value={option.value}
1769
+ className="text-[11px]"
1770
+ >
1771
+ {option.label}
1772
+ </SelectItem>
1773
+ ))}
1774
+ </SelectContent>
1775
+ </Select>
1776
+ <ScrubInput
1777
+ label={t("editPanel.labels.weight")}
1778
+ ariaLabel={t("editPanel.labels.weight")}
1779
+ icon={IconBorderStyle}
1780
+ value={cssLengthNumber(width)}
1781
+ onChange={(value) =>
1782
+ onStyleChange(
1783
+ `${prefix}Width`,
1784
+ `${Math.max(0, Math.round(value))}px`,
1785
+ )
1786
+ }
1787
+ unit="px"
1788
+ min={0}
1789
+ precision={1}
1790
+ className="gap-0"
1791
+ labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
1792
+ inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
1793
+ />
1794
+ </div>
1795
+ </div>
1796
+ );
1797
+ }
1798
+
1799
+ interface ShadowLayer {
1800
+ id: string;
1801
+ x: number;
1802
+ y: number;
1803
+ blur: number;
1804
+ spread: number;
1805
+ color: string;
1806
+ inset: boolean;
1807
+ }
1808
+
1809
+ function defaultDropShadowLayer(index: number): ShadowLayer {
1810
+ return {
1811
+ id: `shadow-${index}`,
1812
+ x: 0,
1813
+ y: 4,
1814
+ blur: 12,
1815
+ spread: 0,
1816
+ color: "rgba(0, 0, 0, 0.25)",
1817
+ inset: false,
1818
+ };
1819
+ }
1820
+
1821
+ function parseShadowLayers(value: string | undefined): ShadowLayer[] {
1822
+ return splitCssLayers(value || "")
1823
+ .filter((layer) => layer && layer !== "none")
1824
+ .map((layer, index) => parseShadowLayer(layer, index));
1825
+ }
1826
+
1827
+ function parseShadowLayer(layer: string, index: number): ShadowLayer {
1828
+ const tokens = splitCssTokens(layer);
1829
+ const inset = tokens.includes("inset");
1830
+ const colorToken =
1831
+ tokens.find((token) => parseCssColor(token) || token === "transparent") ||
1832
+ "rgba(0, 0, 0, 0.25)";
1833
+ const numericTokens = tokens
1834
+ .filter((token) => token !== "inset" && token !== colorToken)
1835
+ .map((token) => parseFloat(token))
1836
+ .filter((value) => Number.isFinite(value));
1837
+
1838
+ return {
1839
+ id: `shadow-${index}`,
1840
+ x: numericTokens[0] ?? 0,
1841
+ y: numericTokens[1] ?? 4,
1842
+ blur: numericTokens[2] ?? 12,
1843
+ spread: numericTokens[3] ?? 0,
1844
+ color: colorToken,
1845
+ inset,
1846
+ };
1847
+ }
1848
+
1849
+ function splitCssTokens(value: string): string[] {
1850
+ const tokens: string[] = [];
1851
+ let start = 0;
1852
+ let depth = 0;
1853
+ for (let index = 0; index < value.length; index += 1) {
1854
+ const char = value[index];
1855
+ if (char === "(") depth += 1;
1856
+ if (char === ")") depth = Math.max(0, depth - 1);
1857
+ if (/\s/.test(char) && depth === 0) {
1858
+ const token = value.slice(start, index).trim();
1859
+ if (token) tokens.push(token);
1860
+ start = index + 1;
1861
+ }
1862
+ }
1863
+ const finalToken = value.slice(start).trim();
1864
+ if (finalToken) tokens.push(finalToken);
1865
+ return tokens;
1866
+ }
1867
+
1868
+ function serializeShadowLayers(layers: ShadowLayer[]) {
1869
+ if (!layers.length) return "none";
1870
+ return layers
1871
+ .map((layer) =>
1872
+ [
1873
+ layer.inset ? "inset" : "",
1874
+ `${Math.round(layer.x)}px`,
1875
+ `${Math.round(layer.y)}px`,
1876
+ `${Math.max(0, Math.round(layer.blur))}px`,
1877
+ `${Math.max(0, Math.round(layer.spread))}px`,
1878
+ layer.color,
1879
+ ]
1880
+ .filter(Boolean)
1881
+ .join(" "),
1882
+ )
1883
+ .join(", ");
1884
+ }
1885
+
1886
+ function readBlurFilter(value: string | undefined): number {
1887
+ const match = value?.match(/blur\((-?\d+(?:\.\d+)?)px\)/);
1888
+ return match ? Math.max(0, Number(match[1])) : 0;
1889
+ }
1890
+
1891
+ function ShadowEffectRow({
1892
+ layer,
1893
+ index,
1894
+ onChange,
1895
+ onRemove,
1896
+ }: {
1897
+ layer: ShadowLayer;
1898
+ index: number;
1899
+ onChange: (patch: Partial<ShadowLayer>) => void;
1900
+ onRemove: () => void;
1901
+ }) {
1902
+ const t = useT();
1903
+ return (
1904
+ <Popover>
1905
+ {/* Figma effect row: [swatch+label+x,y,blur trigger (flex-1)] [remove] */}
1906
+ <div className="flex items-center gap-1.5">
1907
+ <PopoverTrigger asChild>
1908
+ <button
1909
+ type="button"
1910
+ className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
1911
+ >
1912
+ <span
1913
+ className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
1914
+ style={swatchStyle(layer.color)}
1915
+ />
1916
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
1917
+ {index === 0
1918
+ ? t("editPanel.labels.dropShadow")
1919
+ : `${t("editPanel.labels.dropShadow")} ${index + 1}`}
1920
+ </span>
1921
+ <span className="shrink-0 tabular-nums text-muted-foreground">
1922
+ {Math.round(layer.x)}, {Math.round(layer.y)},{" "}
1923
+ {Math.round(layer.blur)}
1924
+ </span>
1925
+ </button>
1926
+ </PopoverTrigger>
1927
+ <SectionIconButton
1928
+ label={t("editPanel.labels.removeLayer")}
1929
+ onClick={onRemove}
1930
+ >
1931
+ <IconMinus className="size-3.5" />
1932
+ </SectionIconButton>
1933
+ </div>
1934
+ <PopoverContent
1935
+ side="left"
1936
+ align="start"
1937
+ sideOffset={8}
1938
+ className="w-72 p-3"
1939
+ >
1940
+ <div className="space-y-3">
1941
+ <div className="flex items-center justify-between gap-2">
1942
+ <p className="text-xs font-semibold text-foreground">
1943
+ {t("editPanel.labels.dropShadow")}
1944
+ </p>
1945
+ <button
1946
+ type="button"
1947
+ className={cn(
1948
+ "rounded border px-2 py-1 text-[11px]",
1949
+ layer.inset
1950
+ ? "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)] text-foreground"
1951
+ : "border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-muted-foreground",
1952
+ )}
1953
+ onClick={() => onChange({ inset: !layer.inset })}
1954
+ >
1955
+ {t("editPanel.labels.innerShadow")}
1956
+ </button>
1957
+ </div>
1958
+ <div className="grid grid-cols-2 gap-2">
1959
+ <ScrubInput
1960
+ label="X"
1961
+ value={layer.x}
1962
+ onChange={(value) => onChange({ x: value })}
1963
+ unit="px"
1964
+ precision={1}
1965
+ inputClassName="h-6"
1966
+ />
1967
+ <ScrubInput
1968
+ label="Y"
1969
+ value={layer.y}
1970
+ onChange={(value) => onChange({ y: value })}
1971
+ unit="px"
1972
+ precision={1}
1973
+ inputClassName="h-6"
1974
+ />
1975
+ <ScrubInput
1976
+ label={t("editPanel.labels.blur")}
1977
+ value={layer.blur}
1978
+ onChange={(value) => onChange({ blur: Math.max(0, value) })}
1979
+ unit="px"
1980
+ min={0}
1981
+ precision={1}
1982
+ inputClassName="h-6"
1983
+ />
1984
+ <ScrubInput
1985
+ label={t("editPanel.labels.spread")}
1986
+ value={layer.spread}
1987
+ onChange={(value) => onChange({ spread: Math.max(0, value) })}
1988
+ unit="px"
1989
+ min={0}
1990
+ precision={1}
1991
+ inputClassName="h-6"
1992
+ />
1993
+ </div>
1994
+ <ColorInput
1995
+ label={t("editPanel.labels.color")}
1996
+ value={cssColorOrFallback(layer.color, "rgba(0, 0, 0, 0.25)")}
1997
+ onChange={(value) => onChange({ color: value })}
1998
+ />
1999
+ </div>
2000
+ </PopoverContent>
2001
+ </Popover>
2002
+ );
2003
+ }
2004
+
2005
+ /** Page-level properties when nothing is selected */
2006
+ function PageProperties({
2007
+ styles,
2008
+ onStyleChange,
2009
+ }: {
2010
+ styles: Record<string, string>;
2011
+ onStyleChange: (property: string, value: string) => void;
2012
+ }) {
2013
+ const t = useT();
2014
+ const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
2015
+ value: option.value,
2016
+ label: t(`editPanel.fontFamilies.${option.key}`),
2017
+ }));
2018
+ const fontFamily = FONT_FAMILY_OPTIONS.some(
2019
+ (option) => option.value === styles.fontFamily,
2020
+ )
2021
+ ? styles.fontFamily
2022
+ : "sans-serif";
2023
+
2024
+ return (
2025
+ <div>
2026
+ <PanelSection title={t("editPanel.sections.page")}>
2027
+ <ColorInput
2028
+ label={t("editPanel.labels.background")}
2029
+ value={styles.backgroundColor || ""}
2030
+ onChange={(v) => onStyleChange("backgroundColor", v)}
2031
+ backgroundImage={styles.backgroundImage || ""}
2032
+ onBackgroundImageChange={(v) => onStyleChange("backgroundImage", v)}
2033
+ blendMode={styles.backgroundBlendMode || "normal"}
2034
+ onBlendModeChange={(v) => onStyleChange("backgroundBlendMode", v)}
2035
+ supportsLayeredFills
2036
+ />
2037
+ <PropSelect
2038
+ label={t("editPanel.labels.font")}
2039
+ value={fontFamily}
2040
+ onChange={(v) => onStyleChange("fontFamily", v)}
2041
+ options={fontFamilyOptions}
2042
+ />
2043
+ <PropInput
2044
+ label={t("editPanel.labels.baseSize")}
2045
+ value={styles.fontSize || "16px"}
2046
+ onChange={(v) => onStyleChange("fontSize", v)}
2047
+ placeholder="16px"
2048
+ defaultUnit="px"
2049
+ />
2050
+ </PanelSection>
577
2051
  </div>
578
2052
  );
579
2053
  }
580
2054
 
581
- /** Universal element properties (size, opacity, spacing, border, background) */
582
- function ElementProperties({
2055
+ /** Text element properties */
2056
+ function TypographyProperties({
583
2057
  element,
584
2058
  onStyleChange,
585
2059
  }: {
@@ -588,104 +2062,1284 @@ function ElementProperties({
588
2062
  }) {
589
2063
  const t = useT();
590
2064
  const styles = element.computedStyles;
2065
+ const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
2066
+ value: option.value,
2067
+ label: t(`editPanel.fontFamilies.${option.key}`),
2068
+ }));
2069
+ const fontWeightOptions = FONT_WEIGHT_OPTIONS.map((option) => ({
2070
+ value: option.value,
2071
+ label: t(`editPanel.fontWeights.${option.key}`),
2072
+ }));
2073
+ const textAlign = styles.textAlign || "left";
591
2074
 
592
- const handlePaddingChange = useCallback(
593
- (side: string, value: string) => {
594
- onStyleChange(`padding${side}`, value);
595
- },
596
- [onStyleChange],
2075
+ return (
2076
+ <PanelSection title={t("editPanel.sections.typography")}>
2077
+ {/* Row 1: font family full-width */}
2078
+ <Select
2079
+ value={styles.fontFamily || "sans-serif"}
2080
+ onValueChange={(v) => onStyleChange("fontFamily", v)}
2081
+ >
2082
+ <SelectTrigger className="h-6 w-full rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
2083
+ <SelectValue />
2084
+ </SelectTrigger>
2085
+ <SelectContent>
2086
+ {fontFamilyOptions.map((opt) => (
2087
+ <SelectItem
2088
+ key={opt.value}
2089
+ value={opt.value}
2090
+ className="text-[11px]"
2091
+ >
2092
+ {opt.label}
2093
+ </SelectItem>
2094
+ ))}
2095
+ </SelectContent>
2096
+ </Select>
2097
+
2098
+ {/* Row 2: weight + size side by side */}
2099
+ <div className="grid grid-cols-2 gap-1.5">
2100
+ <Select
2101
+ value={styles.fontWeight || "400"}
2102
+ onValueChange={(v) => onStyleChange("fontWeight", v)}
2103
+ >
2104
+ <SelectTrigger className="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
2105
+ <SelectValue />
2106
+ </SelectTrigger>
2107
+ <SelectContent>
2108
+ {fontWeightOptions.map((opt) => (
2109
+ <SelectItem
2110
+ key={opt.value}
2111
+ value={opt.value}
2112
+ className="text-[11px]"
2113
+ >
2114
+ {opt.label}
2115
+ </SelectItem>
2116
+ ))}
2117
+ </SelectContent>
2118
+ </Select>
2119
+ <ScrubInput
2120
+ label={t("editPanel.labels.size")}
2121
+ ariaLabel={t("editPanel.labels.size")}
2122
+ icon={IconLetterCase}
2123
+ value={styles.fontSize ? parseNumericValue(styles.fontSize) : 16}
2124
+ onChange={(value) =>
2125
+ onStyleChange("fontSize", `${Math.max(1, Math.round(value))}px`)
2126
+ }
2127
+ unit="px"
2128
+ min={1}
2129
+ precision={1}
2130
+ className="gap-0"
2131
+ labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
2132
+ inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
2133
+ />
2134
+ </div>
2135
+
2136
+ {/* Row 3: line-height + letter-spacing with Figma-style leading icons */}
2137
+ <div className="grid grid-cols-2 gap-1.5">
2138
+ <ScrubInput
2139
+ label={t("editPanel.labels.lineHeight")}
2140
+ ariaLabel={t("editPanel.labels.lineHeight")}
2141
+ icon={IconLineHeight}
2142
+ value={parseNumericValue(styles.lineHeight || "1.2")}
2143
+ onChange={(value) =>
2144
+ onStyleChange("lineHeight", String(Math.max(0.1, value)))
2145
+ }
2146
+ min={0.1}
2147
+ step={0.1}
2148
+ precision={2}
2149
+ className="gap-0"
2150
+ labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
2151
+ inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
2152
+ />
2153
+ <ScrubInput
2154
+ label={t("editPanel.labels.tracking")}
2155
+ ariaLabel={t("editPanel.labels.tracking")}
2156
+ icon={IconLetterSpacing}
2157
+ value={
2158
+ styles.letterSpacing ? parseNumericValue(styles.letterSpacing) : 0
2159
+ }
2160
+ onChange={(value) => onStyleChange("letterSpacing", `${value}px`)}
2161
+ unit="px"
2162
+ precision={1}
2163
+ className="gap-0"
2164
+ labelClassName="h-6 w-6 justify-center gap-0 rounded-l-md border border-r-0 border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-[11px] [&>span]:hidden"
2165
+ inputClassName="h-6 rounded-l-none rounded-r-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
2166
+ />
2167
+ </div>
2168
+
2169
+ {/* Row 4: text alignment */}
2170
+ <InspectorSegment>
2171
+ <InspectorIconButton
2172
+ label={t("editPanel.textAligns.left")}
2173
+ active={textAlign === "left" || textAlign === "start"}
2174
+ onClick={() => onStyleChange("textAlign", "left")}
2175
+ >
2176
+ <IconAlignLeft className="size-3.5" />
2177
+ </InspectorIconButton>
2178
+ <InspectorIconButton
2179
+ label={t("editPanel.textAligns.center")}
2180
+ active={textAlign === "center"}
2181
+ onClick={() => onStyleChange("textAlign", "center")}
2182
+ >
2183
+ <IconAlignCenter className="size-3.5" />
2184
+ </InspectorIconButton>
2185
+ <InspectorIconButton
2186
+ label={t("editPanel.textAligns.right")}
2187
+ active={textAlign === "right" || textAlign === "end"}
2188
+ onClick={() => onStyleChange("textAlign", "right")}
2189
+ >
2190
+ <IconAlignRight className="size-3.5" />
2191
+ </InspectorIconButton>
2192
+ <InspectorIconButton
2193
+ label={t("editPanel.textAligns.justify")}
2194
+ active={textAlign === "justify"}
2195
+ onClick={() => onStyleChange("textAlign", "justify")}
2196
+ >
2197
+ <IconAlignJustified className="size-3.5" />
2198
+ </InspectorIconButton>
2199
+ </InspectorSegment>
2200
+ </PanelSection>
597
2201
  );
2202
+ }
598
2203
 
599
- const handleMarginChange = useCallback(
600
- (side: string, value: string) => {
601
- onStyleChange(`margin${side}`, value);
2204
+ /** Flex container properties */
2205
+ function FlexContainerControls({
2206
+ element,
2207
+ onStyleChange,
2208
+ onStylesChange,
2209
+ }: {
2210
+ element: ElementInfo;
2211
+ onStyleChange: (property: string, value: string) => void;
2212
+ onStylesChange?: (styles: Record<string, string>) => void;
2213
+ }) {
2214
+ const t = useT();
2215
+ const styles = element.computedStyles;
2216
+ const flexDirection: AutoLayoutMatrixValue["direction"] =
2217
+ styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
2218
+ const padding = {
2219
+ top: parseNumericValue(styles.paddingTop || "0"),
2220
+ right: parseNumericValue(styles.paddingRight || "0"),
2221
+ bottom: parseNumericValue(styles.paddingBottom || "0"),
2222
+ left: parseNumericValue(styles.paddingLeft || "0"),
2223
+ };
2224
+ const allPaddingEqual =
2225
+ padding.top === padding.right &&
2226
+ padding.top === padding.bottom &&
2227
+ padding.top === padding.left;
2228
+ const [paddingLinked, setPaddingLinked] = useState(allPaddingEqual);
2229
+
2230
+ useEffect(() => {
2231
+ if (!allPaddingEqual && paddingLinked) setPaddingLinked(false);
2232
+ }, [allPaddingEqual, paddingLinked]);
2233
+
2234
+ const autoLayoutValue: AutoLayoutMatrixValue = {
2235
+ direction: flexDirection,
2236
+ wrap: styles.flexWrap === "wrap" ? "wrap" : "nowrap",
2237
+ alignment: autoLayoutAlignmentFromStyles(styles, flexDirection),
2238
+ gap: parseNumericValue(styles.gap || "0"),
2239
+ padding,
2240
+ paddingLinked,
2241
+ childSizing: {
2242
+ horizontal: inferElementSizing(element, "horizontal"),
2243
+ vertical: inferElementSizing(element, "vertical"),
602
2244
  },
603
- [onStyleChange],
2245
+ clipContent: styles.overflow === "hidden",
2246
+ resolvedSize: {
2247
+ horizontal: element.boundingRect.width,
2248
+ vertical: element.boundingRect.height,
2249
+ },
2250
+ };
2251
+
2252
+ return (
2253
+ <div className="space-y-2">
2254
+ <AutoLayoutMatrix
2255
+ value={autoLayoutValue}
2256
+ onDirectionChange={(direction) =>
2257
+ onStyleChange(
2258
+ "flexDirection",
2259
+ direction === "vertical" ? "column" : "row",
2260
+ )
2261
+ }
2262
+ onWrapChange={(wrap) => onStyleChange("flexWrap", wrap)}
2263
+ onAlignmentChange={(alignment) => {
2264
+ if (autoLayoutValue.direction === "vertical") {
2265
+ onStyleChange(
2266
+ "alignItems",
2267
+ horizontalToJustify(alignment.horizontal),
2268
+ );
2269
+ onStyleChange(
2270
+ "justifyContent",
2271
+ verticalToAlign(alignment.vertical),
2272
+ );
2273
+ return;
2274
+ }
2275
+ onStyleChange(
2276
+ "justifyContent",
2277
+ horizontalToJustify(alignment.horizontal),
2278
+ );
2279
+ onStyleChange("alignItems", verticalToAlign(alignment.vertical));
2280
+ }}
2281
+ onGapChange={(gap) => onStyleChange("gap", `${gap}px`)}
2282
+ onPaddingChange={(nextPadding) => {
2283
+ onStyleChange("paddingTop", `${nextPadding.top}px`);
2284
+ onStyleChange("paddingRight", `${nextPadding.right}px`);
2285
+ onStyleChange("paddingBottom", `${nextPadding.bottom}px`);
2286
+ onStyleChange("paddingLeft", `${nextPadding.left}px`);
2287
+ }}
2288
+ onPaddingLinkedChange={(linked) => {
2289
+ setPaddingLinked(linked);
2290
+ if (!linked) return;
2291
+ onStyleChange("paddingTop", `${padding.top}px`);
2292
+ onStyleChange("paddingRight", `${padding.top}px`);
2293
+ onStyleChange("paddingBottom", `${padding.top}px`);
2294
+ onStyleChange("paddingLeft", `${padding.top}px`);
2295
+ }}
2296
+ onClipContentChange={(clipContent) =>
2297
+ onStyleChange("overflow", clipContent ? "hidden" : "visible")
2298
+ }
2299
+ onDistribute={(axis) => {
2300
+ const mainAxis =
2301
+ autoLayoutValue.direction === "horizontal"
2302
+ ? "horizontal"
2303
+ : "vertical";
2304
+ if (axis === mainAxis) {
2305
+ onStyleChange("justifyContent", "space-between");
2306
+ } else if (autoLayoutValue.wrap === "wrap") {
2307
+ onStyleChange("alignContent", "space-between");
2308
+ }
2309
+ }}
2310
+ availableChildSizing={availableSizingForElement(element)}
2311
+ onChildSizingChange={(axis, sizing) => {
2312
+ commitElementSizing(
2313
+ element,
2314
+ axis,
2315
+ sizing,
2316
+ onStyleChange,
2317
+ onStylesChange,
2318
+ );
2319
+ }}
2320
+ />
2321
+ </div>
604
2322
  );
2323
+ }
2324
+
2325
+ function FlexChildControls({
2326
+ element,
2327
+ onStyleChange,
2328
+ }: {
2329
+ element: ElementInfo;
2330
+ onStyleChange: (property: string, value: string) => void;
2331
+ }) {
2332
+ const t = useT();
2333
+ const styles = element.computedStyles;
2334
+ const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
2335
+ value: option.value,
2336
+ label: t(`editPanel.alignSelfOptions.${option.key}`),
2337
+ }));
605
2338
 
606
2339
  return (
607
- <div className="space-y-4">
608
- <SectionTitle>{t("editPanel.sections.layout")}</SectionTitle>
2340
+ <div className="space-y-2">
2341
+ <SubsectionLabel>{t("editPanel.layoutContext.child")}</SubsectionLabel>
609
2342
  <PropInput
610
- label={t("editPanel.labels.width")}
611
- value={styles.width || ""}
612
- onChange={(v) => onStyleChange("width", v)}
613
- placeholder="auto"
614
- defaultUnit="px"
2343
+ label={t("editPanel.labels.flexGrow")}
2344
+ value={styles.flexGrow || ""}
2345
+ onChange={(v) => onStyleChange("flexGrow", v)}
2346
+ placeholder="0"
615
2347
  />
616
2348
  <PropInput
617
- label={t("editPanel.labels.height")}
618
- value={styles.height || ""}
619
- onChange={(v) => onStyleChange("height", v)}
2349
+ label={t("editPanel.labels.flexShrink")}
2350
+ value={styles.flexShrink || ""}
2351
+ onChange={(v) => onStyleChange("flexShrink", v)}
2352
+ placeholder="1"
2353
+ />
2354
+ <PropInput
2355
+ label={t("editPanel.labels.flexBasis")}
2356
+ value={styles.flexBasis || ""}
2357
+ onChange={(v) => onStyleChange("flexBasis", v)}
620
2358
  placeholder="auto"
621
2359
  defaultUnit="px"
622
2360
  />
623
- <PropSlider
624
- label={t("editPanel.labels.opacity")}
625
- value={parseNumericValue(styles.opacity || "1") * 100}
626
- onChange={(v) => onStyleChange("opacity", String(v / 100))}
627
- min={0}
628
- max={100}
629
- step={1}
630
- unit="%"
631
- />
632
-
633
- <Separator />
634
-
635
- <SectionTitle>{t("editPanel.sections.spacing")}</SectionTitle>
636
- <FourSideInput
637
- label={t("editPanel.labels.padding")}
638
- values={{
639
- top: styles.paddingTop || "0",
640
- right: styles.paddingRight || "0",
641
- bottom: styles.paddingBottom || "0",
642
- left: styles.paddingLeft || "0",
643
- }}
644
- onChange={handlePaddingChange}
2361
+ <PropInput
2362
+ label={t("editPanel.labels.order")}
2363
+ value={styles.order || ""}
2364
+ onChange={(v) => onStyleChange("order", v)}
2365
+ placeholder="0"
645
2366
  />
646
- <FourSideInput
647
- label={t("editPanel.labels.margin")}
648
- values={{
649
- top: styles.marginTop || "0",
650
- right: styles.marginRight || "0",
651
- bottom: styles.marginBottom || "0",
652
- left: styles.marginLeft || "0",
653
- }}
654
- onChange={handleMarginChange}
2367
+ <PropSelect
2368
+ label={t("editPanel.labels.alignSelf")}
2369
+ value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
2370
+ onChange={(v) => onStyleChange("alignSelf", v)}
2371
+ options={alignSelfOptions}
655
2372
  />
2373
+ </div>
2374
+ );
2375
+ }
656
2376
 
657
- <Separator />
2377
+ function GridChildControls({
2378
+ element,
2379
+ onStyleChange,
2380
+ }: {
2381
+ element: ElementInfo;
2382
+ onStyleChange: (property: string, value: string) => void;
2383
+ }) {
2384
+ const t = useT();
2385
+ const styles = element.computedStyles;
2386
+ const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
2387
+ value: option.value,
2388
+ label: t(`editPanel.alignSelfOptions.${option.key}`),
2389
+ }));
658
2390
 
659
- <SectionTitle>{t("editPanel.sections.border")}</SectionTitle>
2391
+ return (
2392
+ <div className="space-y-2">
2393
+ <SubsectionLabel>
2394
+ {t("editPanel.layoutContext.gridChild")}
2395
+ </SubsectionLabel>
660
2396
  <PropInput
661
- label={t("editPanel.labels.width")}
662
- value={styles.borderWidth || "0"}
663
- onChange={(v) => onStyleChange("borderWidth", v)}
664
- placeholder="0px"
665
- defaultUnit="px"
666
- />
667
- <ColorInput
668
- label={t("editPanel.labels.color")}
669
- value={styles.borderColor || ""}
670
- onChange={(v) => onStyleChange("borderColor", v)}
2397
+ label={t("editPanel.labels.gridColumn")}
2398
+ value={styles.gridColumn || ""}
2399
+ onChange={(v) => onStyleChange("gridColumn", v)}
2400
+ placeholder="auto"
671
2401
  />
672
2402
  <PropInput
673
- label={t("editPanel.labels.radius")}
674
- value={styles.borderRadius || "0"}
675
- onChange={(v) => onStyleChange("borderRadius", v)}
676
- placeholder="0px"
677
- defaultUnit="px"
2403
+ label={t("editPanel.labels.gridRow")}
2404
+ value={styles.gridRow || ""}
2405
+ onChange={(v) => onStyleChange("gridRow", v)}
2406
+ placeholder="auto"
678
2407
  />
2408
+ <PropSelect
2409
+ label={t("editPanel.labels.alignSelf")}
2410
+ value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
2411
+ onChange={(v) => onStyleChange("alignSelf", v)}
2412
+ options={alignSelfOptions}
2413
+ />
2414
+ </div>
2415
+ );
2416
+ }
2417
+
2418
+ function LayoutContextProperties({
2419
+ element,
2420
+ onStyleChange,
2421
+ onStylesChange,
2422
+ }: {
2423
+ element: ElementInfo;
2424
+ onStyleChange: (property: string, value: string) => void;
2425
+ onStylesChange?: (styles: Record<string, string>) => void;
2426
+ }) {
2427
+ const t = useT();
2428
+ const flexChild = isParentFlex(element);
2429
+ const gridChild = isParentGrid(element);
2430
+ const availableSizing = availableSizingForElement(element);
679
2431
 
680
- <Separator />
2432
+ if (!element.isFlexContainer) {
2433
+ return (
2434
+ <PanelSection title={t("editPanel.sections.layout")}>
2435
+ {/* Figma-style single-row-per-axis: [W | value | Fixed/Hug ▾] */}
2436
+ <div className="grid grid-cols-2 gap-1.5">
2437
+ <SizingModeButton
2438
+ axis="W"
2439
+ value={inferElementSizing(element, "horizontal")}
2440
+ resolvedSize={element.boundingRect.width}
2441
+ options={availableSizing.horizontal ?? ["hug", "fixed"]}
2442
+ onChange={(mode) =>
2443
+ commitElementSizing(
2444
+ element,
2445
+ "horizontal",
2446
+ mode,
2447
+ onStyleChange,
2448
+ onStylesChange,
2449
+ )
2450
+ }
2451
+ />
2452
+ <SizingModeButton
2453
+ axis="H"
2454
+ value={inferElementSizing(element, "vertical")}
2455
+ resolvedSize={element.boundingRect.height}
2456
+ options={availableSizing.vertical ?? ["hug", "fixed"]}
2457
+ onChange={(mode) =>
2458
+ commitElementSizing(
2459
+ element,
2460
+ "vertical",
2461
+ mode,
2462
+ onStyleChange,
2463
+ onStylesChange,
2464
+ )
2465
+ }
2466
+ />
2467
+ </div>
2468
+ {flexChild ? (
2469
+ <FlexChildControls element={element} onStyleChange={onStyleChange} />
2470
+ ) : null}
2471
+ {gridChild ? (
2472
+ <GridChildControls element={element} onStyleChange={onStyleChange} />
2473
+ ) : null}
2474
+ </PanelSection>
2475
+ );
2476
+ }
681
2477
 
682
- <SectionTitle>{t("editPanel.sections.fill")}</SectionTitle>
683
- <ColorInput
684
- label={t("editPanel.labels.background")}
685
- value={styles.backgroundColor || ""}
686
- onChange={(v) => onStyleChange("backgroundColor", v)}
2478
+ return (
2479
+ <PanelSection
2480
+ title={t("editPanel.sections.autoLayout")}
2481
+ actions={
2482
+ <span className="flex size-7 items-center justify-center rounded-md bg-[var(--design-editor-accent-color)]/15 text-[var(--design-editor-accent-color)]">
2483
+ <IconLayoutGrid className="size-4" />
2484
+ </span>
2485
+ }
2486
+ >
2487
+ <FlexContainerControls
2488
+ element={element}
2489
+ onStyleChange={onStyleChange}
2490
+ onStylesChange={onStylesChange}
687
2491
  />
688
- </div>
2492
+
2493
+ {flexChild ? (
2494
+ <div className="border-t border-border/70 pt-2">
2495
+ <FlexChildControls element={element} onStyleChange={onStyleChange} />
2496
+ </div>
2497
+ ) : null}
2498
+
2499
+ {gridChild ? (
2500
+ <div className="border-t border-border/70 pt-2">
2501
+ <GridChildControls element={element} onStyleChange={onStyleChange} />
2502
+ </div>
2503
+ ) : null}
2504
+ </PanelSection>
2505
+ );
2506
+ }
2507
+
2508
+ /** Position, size, and spacing properties */
2509
+ function PositionLayoutProperties({
2510
+ element,
2511
+ onStyleChange,
2512
+ }: {
2513
+ element: ElementInfo;
2514
+ onStyleChange: (property: string, value: string) => void;
2515
+ }) {
2516
+ const t = useT();
2517
+ const styles = element.computedStyles;
2518
+ const constrainedPosition =
2519
+ styles.position === "absolute" || styles.position === "fixed";
2520
+ const constraintsValue: ConstraintsValue = {
2521
+ horizontal:
2522
+ styles.left && styles.right
2523
+ ? "left-right"
2524
+ : styles.right
2525
+ ? "right"
2526
+ : styles.transform?.includes("translateX(-50%)")
2527
+ ? "center"
2528
+ : styles.width === "100%"
2529
+ ? "scale"
2530
+ : "left",
2531
+ vertical:
2532
+ styles.top && styles.bottom
2533
+ ? "top-bottom"
2534
+ : styles.bottom
2535
+ ? "bottom"
2536
+ : styles.transform?.includes("translateY(-50%)")
2537
+ ? "center"
2538
+ : styles.height === "100%"
2539
+ ? "scale"
2540
+ : "top",
2541
+ };
2542
+
2543
+ const handleConstraintsChange = useCallback(
2544
+ (value: ConstraintsValue) => {
2545
+ onStyleChange("position", "absolute");
2546
+ if (value.horizontal === "left") {
2547
+ onStyleChange(
2548
+ "left",
2549
+ styles.left || `${Math.round(element.boundingRect.x)}px`,
2550
+ );
2551
+ onStyleChange("right", "auto");
2552
+ } else if (value.horizontal === "right") {
2553
+ onStyleChange("right", "0px");
2554
+ onStyleChange("left", "auto");
2555
+ } else if (value.horizontal === "left-right") {
2556
+ onStyleChange(
2557
+ "left",
2558
+ styles.left || `${Math.round(element.boundingRect.x)}px`,
2559
+ );
2560
+ onStyleChange("right", "0px");
2561
+ } else if (value.horizontal === "center") {
2562
+ onStyleChange("left", "50%");
2563
+ onStyleChange("right", "auto");
2564
+ onStyleChange("transform", "translateX(-50%)");
2565
+ } else {
2566
+ onStyleChange("left", "0px");
2567
+ onStyleChange("right", "0px");
2568
+ onStyleChange("width", "100%");
2569
+ }
2570
+
2571
+ if (value.vertical === "top") {
2572
+ onStyleChange(
2573
+ "top",
2574
+ styles.top || `${Math.round(element.boundingRect.y)}px`,
2575
+ );
2576
+ onStyleChange("bottom", "auto");
2577
+ } else if (value.vertical === "bottom") {
2578
+ onStyleChange("bottom", "0px");
2579
+ onStyleChange("top", "auto");
2580
+ } else if (value.vertical === "top-bottom") {
2581
+ onStyleChange(
2582
+ "top",
2583
+ styles.top || `${Math.round(element.boundingRect.y)}px`,
2584
+ );
2585
+ onStyleChange("bottom", "0px");
2586
+ } else if (value.vertical === "center") {
2587
+ onStyleChange("top", "50%");
2588
+ onStyleChange("bottom", "auto");
2589
+ onStyleChange("transform", "translateY(-50%)");
2590
+ } else {
2591
+ onStyleChange("top", "0px");
2592
+ onStyleChange("bottom", "0px");
2593
+ onStyleChange("height", "100%");
2594
+ }
2595
+ },
2596
+ [
2597
+ element.boundingRect.x,
2598
+ element.boundingRect.y,
2599
+ onStyleChange,
2600
+ styles.left,
2601
+ styles.top,
2602
+ ],
2603
+ );
2604
+
2605
+ return (
2606
+ <PanelSection title={t("editPanel.sections.positionLayout")}>
2607
+ <div className="space-y-1.5">
2608
+ <SubsectionLabel>
2609
+ {"Alignment" /* i18n-ignore Figma inspector label */}
2610
+ </SubsectionLabel>
2611
+ <div className="flex items-center gap-3">
2612
+ <InspectorSegment>
2613
+ <InspectorIconButton
2614
+ label={t("editPanel.textAligns.left")}
2615
+ onClick={() => onStyleChange("justifyContent", "flex-start")}
2616
+ >
2617
+ <IconLayoutAlignLeft className="size-4" />
2618
+ </InspectorIconButton>
2619
+ <InspectorIconButton
2620
+ label={t("editPanel.textAligns.center")}
2621
+ onClick={() => onStyleChange("justifyContent", "center")}
2622
+ >
2623
+ <IconLayoutAlignCenter className="size-4" />
2624
+ </InspectorIconButton>
2625
+ <InspectorIconButton
2626
+ label={t("editPanel.textAligns.right")}
2627
+ onClick={() => onStyleChange("justifyContent", "flex-end")}
2628
+ >
2629
+ <IconLayoutAlignRight className="size-4" />
2630
+ </InspectorIconButton>
2631
+ </InspectorSegment>
2632
+ <InspectorSegment>
2633
+ <InspectorIconButton
2634
+ label={t("editPanel.alignSelfOptions.start")}
2635
+ onClick={() => onStyleChange("alignItems", "flex-start")}
2636
+ >
2637
+ <IconLayoutAlignTop className="size-4" />
2638
+ </InspectorIconButton>
2639
+ <InspectorIconButton
2640
+ label={t("editPanel.alignSelfOptions.center")}
2641
+ onClick={() => onStyleChange("alignItems", "center")}
2642
+ >
2643
+ <IconLayoutAlignMiddle className="size-4" />
2644
+ </InspectorIconButton>
2645
+ <InspectorIconButton
2646
+ label={t("editPanel.alignSelfOptions.end")}
2647
+ onClick={() => onStyleChange("alignItems", "flex-end")}
2648
+ >
2649
+ <IconLayoutAlignBottom className="size-4" />
2650
+ </InspectorIconButton>
2651
+ </InspectorSegment>
2652
+ </div>
2653
+ </div>
2654
+
2655
+ <div className="space-y-1.5">
2656
+ <SubsectionLabel>{t("editPanel.labels.position")}</SubsectionLabel>
2657
+ <div className="grid grid-cols-2 gap-2">
2658
+ <ScrubStyleInput
2659
+ label="X"
2660
+ value={styles.left || ""}
2661
+ placeholder={element.boundingRect.x}
2662
+ inputClassName="h-6"
2663
+ onChange={(v) => onStyleChange("left", `${Math.round(v)}px`)}
2664
+ />
2665
+ <ScrubStyleInput
2666
+ label="Y"
2667
+ value={styles.top || ""}
2668
+ placeholder={element.boundingRect.y}
2669
+ inputClassName="h-6"
2670
+ onChange={(v) => onStyleChange("top", `${Math.round(v)}px`)}
2671
+ />
2672
+ </div>
2673
+ </div>
2674
+
2675
+ <div className="space-y-1.5">
2676
+ <SubsectionLabel>{t("editPanel.labels.rotation")}</SubsectionLabel>
2677
+ <div className="flex items-center gap-2">
2678
+ <div className="min-w-0 flex-1">
2679
+ <ScrubStyleInput
2680
+ label="R"
2681
+ value={`${parseRotationValue(styles.transform)}deg`}
2682
+ unit="deg"
2683
+ inputClassName="h-6"
2684
+ onChange={(v) =>
2685
+ onStyleChange(
2686
+ "transform",
2687
+ mergeRotationValue(styles.transform, v),
2688
+ )
2689
+ }
2690
+ />
2691
+ </div>
2692
+ <InspectorSegment>
2693
+ <InspectorIconButton
2694
+ label={t("editPanel.labels.flipHorizontal")}
2695
+ onClick={() => onStyleChange("scale", "-1 1")}
2696
+ >
2697
+ <IconFlipHorizontal className="size-4" />
2698
+ </InspectorIconButton>
2699
+ <InspectorIconButton
2700
+ label={t("editPanel.labels.flipVertical")}
2701
+ onClick={() => onStyleChange("scale", "1 -1")}
2702
+ >
2703
+ <IconFlipVertical className="size-4" />
2704
+ </InspectorIconButton>
2705
+ </InspectorSegment>
2706
+ </div>
2707
+ </div>
2708
+
2709
+ {constrainedPosition ? (
2710
+ <ConstraintsWidget
2711
+ value={constraintsValue}
2712
+ onChange={handleConstraintsChange}
2713
+ />
2714
+ ) : null}
2715
+ </PanelSection>
2716
+ );
2717
+ }
2718
+
2719
+ function FillProperties({
2720
+ element,
2721
+ onStyleChange,
2722
+ onStylesChange,
2723
+ }: {
2724
+ element: ElementInfo;
2725
+ onStyleChange: (property: string, value: string) => void;
2726
+ onStylesChange?: (styles: Record<string, string>) => void;
2727
+ }) {
2728
+ const t = useT();
2729
+ const styles = element.computedStyles;
2730
+ const isTextElement = TEXT_TAGS.has(element.tagName);
2731
+ const fillProperty = isTextElement ? "color" : "backgroundColor";
2732
+ const fillValue = isTextElement
2733
+ ? styles.color || ""
2734
+ : styles.backgroundColor || "";
2735
+ const backgroundLayers = isTextElement
2736
+ ? []
2737
+ : splitCssLayers(styles.backgroundImage || "");
2738
+ const hasBackgroundLayer = !isTextElement && backgroundLayers.length > 0;
2739
+ const hasVisibleFill =
2740
+ isTextElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
2741
+ const fallbackValue = isTextElement
2742
+ ? cssColorOrFallback(styles.color, "#000000")
2743
+ : cssColorOrFallback(styles.backgroundColor, "#ffffff");
2744
+
2745
+ return (
2746
+ <PanelSection
2747
+ title={t("editPanel.sections.fill")}
2748
+ actions={
2749
+ <SectionIconButton
2750
+ label={t("editPanel.labels.addLayer")}
2751
+ onClick={() => {
2752
+ if (isTextElement) {
2753
+ onStyleChange(
2754
+ "color",
2755
+ cssColorOrFallback(styles.color, "#000000"),
2756
+ );
2757
+ return;
2758
+ }
2759
+ if (!colorHasVisibleAlpha(styles.backgroundColor)) {
2760
+ onStyleChange(
2761
+ "backgroundColor",
2762
+ cssColorOrFallback(styles.backgroundColor, "#ffffff"),
2763
+ );
2764
+ return;
2765
+ }
2766
+ const current = compactCssValue(styles.backgroundImage, "");
2767
+ const nextLayer = defaultGradientLayer(
2768
+ "linear",
2769
+ styles.backgroundColor || "#ffffff",
2770
+ );
2771
+ onStyleChange(
2772
+ "backgroundImage",
2773
+ current ? `${nextLayer}, ${current}` : nextLayer,
2774
+ );
2775
+ }}
2776
+ >
2777
+ <IconPlus className="size-3.5" />
2778
+ </SectionIconButton>
2779
+ }
2780
+ >
2781
+ {hasVisibleFill ? (
2782
+ <div className="space-y-1.5">
2783
+ {isTextElement || colorHasVisibleAlpha(fillValue) ? (
2784
+ /* Figma row: [swatch+hex trigger (flex-1)] [eye] [remove] */
2785
+ <div className="flex items-center gap-1.5">
2786
+ <div className="min-w-0 flex-1">
2787
+ <ColorInput
2788
+ label=""
2789
+ value={fillValue}
2790
+ onChange={(v) => onStyleChange(fillProperty, v)}
2791
+ backgroundImage=""
2792
+ blendMode={
2793
+ isTextElement
2794
+ ? undefined
2795
+ : styles.backgroundBlendMode || "normal"
2796
+ }
2797
+ onBlendModeChange={
2798
+ isTextElement
2799
+ ? undefined
2800
+ : (v) => onStyleChange("backgroundBlendMode", v)
2801
+ }
2802
+ />
2803
+ </div>
2804
+ <SectionIconButton
2805
+ label={
2806
+ fillValue === "transparent"
2807
+ ? t("editPanel.labels.showLayer")
2808
+ : t("editPanel.labels.hideLayer")
2809
+ }
2810
+ onClick={() =>
2811
+ onStyleChange(
2812
+ fillProperty,
2813
+ fillValue === "transparent" ? fallbackValue : "transparent",
2814
+ )
2815
+ }
2816
+ >
2817
+ {fillValue === "transparent" ? (
2818
+ <IconEyeOff className="size-3.5" />
2819
+ ) : (
2820
+ <IconEye className="size-3.5" />
2821
+ )}
2822
+ </SectionIconButton>
2823
+ <SectionIconButton
2824
+ label={t("editPanel.labels.removeLayer")}
2825
+ onClick={() => {
2826
+ if (isTextElement) {
2827
+ onStyleChange(fillProperty, "transparent");
2828
+ return;
2829
+ }
2830
+ if (onStylesChange) {
2831
+ onStylesChange({
2832
+ backgroundColor: "transparent",
2833
+ backgroundImage: "none",
2834
+ });
2835
+ } else {
2836
+ onStyleChange(fillProperty, "transparent");
2837
+ }
2838
+ }}
2839
+ >
2840
+ <IconMinus className="size-3.5" />
2841
+ </SectionIconButton>
2842
+ </div>
2843
+ ) : null}
2844
+ {!isTextElement
2845
+ ? backgroundLayers.map((layer, index) => {
2846
+ const gradient = parseGradientLayer(layer);
2847
+ const opacity = gradient
2848
+ ? averageGradientOpacity(gradient.stops)
2849
+ : 100;
2850
+ const label = gradient
2851
+ ? `${gradientLabel(gradient.type)} ${index + 1}`
2852
+ : `${"Image" /* i18n-ignore Figma inspector paint row */} ${
2853
+ index + 1
2854
+ }`;
2855
+ const replaceLayer = (nextLayer: string) => {
2856
+ const nextLayers = [...backgroundLayers];
2857
+ nextLayers[index] = nextLayer;
2858
+ onStyleChange("backgroundImage", joinCssLayers(nextLayers));
2859
+ };
2860
+ const removeLayer = () => {
2861
+ onStyleChange(
2862
+ "backgroundImage",
2863
+ joinCssLayers(
2864
+ backgroundLayers.filter(
2865
+ (_, layerIndex) => layerIndex !== index,
2866
+ ),
2867
+ ),
2868
+ );
2869
+ };
2870
+
2871
+ return (
2872
+ /* Figma row: [swatch+label+opacity% trigger (flex-1)] [eye] [remove] */
2873
+ <div
2874
+ key={`${layer}-${index}`}
2875
+ className="flex items-center gap-1.5"
2876
+ >
2877
+ <Popover>
2878
+ <PopoverTrigger asChild>
2879
+ <button
2880
+ type="button"
2881
+ className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
2882
+ >
2883
+ <span
2884
+ className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
2885
+ style={swatchStyle(layer)}
2886
+ />
2887
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
2888
+ {label}
2889
+ </span>
2890
+ <span className="shrink-0 tabular-nums text-muted-foreground">
2891
+ {opacity}%
2892
+ </span>
2893
+ </button>
2894
+ </PopoverTrigger>
2895
+ <PopoverContent
2896
+ side="left"
2897
+ align="start"
2898
+ sideOffset={8}
2899
+ className="w-80 p-0"
2900
+ >
2901
+ <FigmaColorPicker
2902
+ value={gradient?.stops[0]?.color ?? "#000000"}
2903
+ onChange={(nextColor) => {
2904
+ if (!gradient) return;
2905
+ const firstStop = gradient.stops[0];
2906
+ if (!firstStop) return;
2907
+ replaceLayer(
2908
+ buildGradientLayer(gradient.type, [
2909
+ { ...firstStop, color: nextColor },
2910
+ ...gradient.stops.slice(1),
2911
+ ]),
2912
+ );
2913
+ }}
2914
+ paintType={gradient?.type ?? "image"}
2915
+ gradientType={gradient?.type}
2916
+ onGradientTypeChange={(type) => {
2917
+ if (!gradient) return;
2918
+ replaceLayer(
2919
+ buildGradientLayer(type, gradient.stops),
2920
+ );
2921
+ }}
2922
+ fillRows={[
2923
+ {
2924
+ id: `layer-${index}`,
2925
+ label,
2926
+ value: layer,
2927
+ type: gradient ? "gradient" : "image",
2928
+ selected: true,
2929
+ swatch: layer,
2930
+ },
2931
+ ]}
2932
+ selectedFillId={`layer-${index}`}
2933
+ />
2934
+ </PopoverContent>
2935
+ </Popover>
2936
+ <SectionIconButton
2937
+ label={
2938
+ opacity <= 0
2939
+ ? t("editPanel.labels.showLayer")
2940
+ : t("editPanel.labels.hideLayer")
2941
+ }
2942
+ onClick={() => {
2943
+ if (!gradient) return;
2944
+ replaceLayer(
2945
+ buildGradientLayer(
2946
+ gradient.type,
2947
+ gradient.stops.map((stop) => ({
2948
+ ...stop,
2949
+ opacity: opacity <= 0 ? 100 : 0,
2950
+ })),
2951
+ ),
2952
+ );
2953
+ }}
2954
+ >
2955
+ {opacity <= 0 ? (
2956
+ <IconEyeOff className="size-3.5" />
2957
+ ) : (
2958
+ <IconEye className="size-3.5" />
2959
+ )}
2960
+ </SectionIconButton>
2961
+ <SectionIconButton
2962
+ label={t("editPanel.labels.removeLayer")}
2963
+ onClick={removeLayer}
2964
+ >
2965
+ <IconMinus className="size-3.5" />
2966
+ </SectionIconButton>
2967
+ </div>
2968
+ );
2969
+ })
2970
+ : null}
2971
+ </div>
2972
+ ) : null}
2973
+ </PanelSection>
2974
+ );
2975
+ }
2976
+
2977
+ function StrokeProperties({
2978
+ element,
2979
+ onStyleChange,
2980
+ onStylesChange,
2981
+ }: {
2982
+ element: ElementInfo;
2983
+ onStyleChange: (property: string, value: string) => void;
2984
+ onStylesChange?: (styles: Record<string, string>) => void;
2985
+ }) {
2986
+ const t = useT();
2987
+ const styles = element.computedStyles;
2988
+ const borderVisible = strokeIsVisible(styles.borderWidth, styles.borderStyle);
2989
+ const outlineVisible = strokeIsVisible(
2990
+ styles.outlineWidth,
2991
+ styles.outlineStyle,
2992
+ );
2993
+
2994
+ return (
2995
+ <PanelSection
2996
+ title={t("editPanel.sections.stroke")}
2997
+ actions={
2998
+ <SectionIconButton
2999
+ label={t("editPanel.labels.addLayer")}
3000
+ onClick={() => {
3001
+ if (!borderVisible) {
3002
+ const borderColor = cssColorOrFallback(
3003
+ styles.borderColor || styles.color,
3004
+ "#000000",
3005
+ );
3006
+ commitStylePatch(
3007
+ {
3008
+ borderWidth: "1px",
3009
+ borderStyle: "solid",
3010
+ borderColor,
3011
+ },
3012
+ onStyleChange,
3013
+ onStylesChange,
3014
+ );
3015
+ return;
3016
+ }
3017
+ if (outlineVisible) {
3018
+ const outlineWidth = `${
3019
+ Math.max(1, cssLengthNumber(styles.outlineWidth, 1)) + 1
3020
+ }px`;
3021
+ const outlineStyle =
3022
+ styles.outlineStyle === "none"
3023
+ ? "solid"
3024
+ : styles.outlineStyle || "solid";
3025
+ const outlineColor = cssColorOrFallback(
3026
+ styles.outlineColor || styles.borderColor,
3027
+ "#000000",
3028
+ );
3029
+ commitStylePatch(
3030
+ {
3031
+ outlineWidth,
3032
+ outlineStyle,
3033
+ outlineColor,
3034
+ outlineOffset: styles.outlineOffset || "0px",
3035
+ },
3036
+ onStyleChange,
3037
+ onStylesChange,
3038
+ );
3039
+ return;
3040
+ }
3041
+ commitStylePatch(
3042
+ {
3043
+ outlineWidth: "1px",
3044
+ outlineStyle: "solid",
3045
+ outlineColor: cssColorOrFallback(styles.borderColor, "#000000"),
3046
+ outlineOffset: "0px",
3047
+ },
3048
+ onStyleChange,
3049
+ onStylesChange,
3050
+ );
3051
+ }}
3052
+ >
3053
+ <IconPlus className="size-3.5" />
3054
+ </SectionIconButton>
3055
+ }
3056
+ >
3057
+ {borderVisible ? (
3058
+ <StrokeLayerControl
3059
+ kind="border"
3060
+ visible={borderVisible}
3061
+ color={styles.borderColor || "#000000"}
3062
+ width={styles.borderWidth || "0px"}
3063
+ styleValue={styles.borderStyle || "none"}
3064
+ onStyleChange={onStyleChange}
3065
+ onRemove={() => {
3066
+ if (onStylesChange) {
3067
+ onStylesChange({ borderWidth: "0px", borderStyle: "none" });
3068
+ } else {
3069
+ onStyleChange("borderWidth", "0px");
3070
+ }
3071
+ }}
3072
+ />
3073
+ ) : null}
3074
+ {outlineVisible ? (
3075
+ <StrokeLayerControl
3076
+ kind="outline"
3077
+ visible={outlineVisible}
3078
+ color={styles.outlineColor || styles.borderColor || "#000000"}
3079
+ width={styles.outlineWidth || "0px"}
3080
+ styleValue={styles.outlineStyle || "solid"}
3081
+ onStyleChange={onStyleChange}
3082
+ onRemove={() => {
3083
+ if (onStylesChange) {
3084
+ onStylesChange({ outlineWidth: "0px", outlineStyle: "none" });
3085
+ } else {
3086
+ onStyleChange("outlineWidth", "0px");
3087
+ }
3088
+ }}
3089
+ />
3090
+ ) : null}
3091
+ </PanelSection>
3092
+ );
3093
+ }
3094
+
3095
+ function AppearanceProperties({
3096
+ element,
3097
+ onStyleChange,
3098
+ }: {
3099
+ element: ElementInfo;
3100
+ onStyleChange: (property: string, value: string) => void;
3101
+ }) {
3102
+ const t = useT();
3103
+ const styles = element.computedStyles;
3104
+ return (
3105
+ <PanelSection title={t("root.commandAppearance")}>
3106
+ <div className="grid grid-cols-2 gap-2">
3107
+ <ScrubInput
3108
+ label={t("editPanel.labels.opacity")}
3109
+ value={parseNumericValue(styles.opacity || "1") * 100}
3110
+ onChange={(v) => onStyleChange("opacity", String(v / 100))}
3111
+ min={0}
3112
+ max={100}
3113
+ step={1}
3114
+ unit="%"
3115
+ precision={1}
3116
+ labelClassName="w-0 overflow-hidden"
3117
+ inputClassName="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none"
3118
+ />
3119
+ {/* M9: blend mode compact — inline select next to opacity, no separate labeled row */}
3120
+ <Select
3121
+ value={optionValue(
3122
+ BLEND_MODE_OPTIONS,
3123
+ styles.mixBlendMode || "normal",
3124
+ "normal",
3125
+ )}
3126
+ onValueChange={(value) => onStyleChange("mixBlendMode", value)}
3127
+ >
3128
+ <SelectTrigger className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
3129
+ <SelectValue />
3130
+ </SelectTrigger>
3131
+ <SelectContent>
3132
+ {BLEND_MODE_OPTIONS.map((opt) => (
3133
+ <SelectItem
3134
+ key={opt.value}
3135
+ value={opt.value}
3136
+ className="text-[11px]"
3137
+ >
3138
+ {opt.label}
3139
+ </SelectItem>
3140
+ ))}
3141
+ </SelectContent>
3142
+ </Select>
3143
+ </div>
3144
+ {/* M7: use CornerRadiusControl (with independent-corners toggle) instead of bare ScrubInput */}
3145
+ <CornerRadiusControl styles={styles} onStyleChange={onStyleChange} />
3146
+ </PanelSection>
3147
+ );
3148
+ }
3149
+
3150
+ function EffectsProperties({
3151
+ element,
3152
+ onStyleChange,
3153
+ onStylesChange,
3154
+ }: {
3155
+ element: ElementInfo;
3156
+ onStyleChange: (property: string, value: string) => void;
3157
+ onStylesChange?: (styles: Record<string, string>) => void;
3158
+ }) {
3159
+ const t = useT();
3160
+ const styles = element.computedStyles;
3161
+ const blurValue = readBlurFilter(styles.filter);
3162
+ const shadowLayers = parseShadowLayers(styles.boxShadow);
3163
+ const setShadowLayers = (layers: ShadowLayer[]) => {
3164
+ const boxShadow = serializeShadowLayers(layers);
3165
+ if (onStylesChange) onStylesChange({ boxShadow });
3166
+ else onStyleChange("boxShadow", boxShadow);
3167
+ };
3168
+
3169
+ return (
3170
+ <PanelSection
3171
+ title={t("editPanel.sections.effects")}
3172
+ actions={
3173
+ <SectionIconButton
3174
+ label={t("editPanel.labels.addLayer")}
3175
+ onClick={() =>
3176
+ setShadowLayers([
3177
+ ...shadowLayers,
3178
+ defaultDropShadowLayer(shadowLayers.length),
3179
+ ])
3180
+ }
3181
+ >
3182
+ <IconPlus className="size-3.5" />
3183
+ </SectionIconButton>
3184
+ }
3185
+ >
3186
+ {shadowLayers.length ? (
3187
+ <div className="space-y-1.5">
3188
+ {shadowLayers.map((layer, index) => (
3189
+ <ShadowEffectRow
3190
+ key={layer.id}
3191
+ layer={layer}
3192
+ index={index}
3193
+ onChange={(patch) => {
3194
+ const next = shadowLayers.map((candidate) =>
3195
+ candidate.id === layer.id
3196
+ ? { ...candidate, ...patch }
3197
+ : candidate,
3198
+ );
3199
+ setShadowLayers(next);
3200
+ }}
3201
+ onRemove={() =>
3202
+ setShadowLayers(
3203
+ shadowLayers.filter((candidate) => candidate.id !== layer.id),
3204
+ )
3205
+ }
3206
+ />
3207
+ ))}
3208
+ </div>
3209
+ ) : null}
3210
+ {blurValue > 0 ? (
3211
+ /* Figma effect row for layer blur: flat row matching shadow rows */
3212
+ <Popover>
3213
+ <div className="flex items-center gap-1.5">
3214
+ <PopoverTrigger asChild>
3215
+ <button
3216
+ type="button"
3217
+ className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-left text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)]"
3218
+ >
3219
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
3220
+ {t("editPanel.labels.layerBlur")}
3221
+ </span>
3222
+ <span className="shrink-0 tabular-nums text-muted-foreground">
3223
+ {Math.round(blurValue)}px
3224
+ </span>
3225
+ </button>
3226
+ </PopoverTrigger>
3227
+ <SectionIconButton
3228
+ label={t("editPanel.labels.removeLayer")}
3229
+ onClick={() => onStyleChange("filter", "none")}
3230
+ disabled={!styles.filter || styles.filter === "none"}
3231
+ >
3232
+ <IconMinus className="size-3.5" />
3233
+ </SectionIconButton>
3234
+ </div>
3235
+ <PopoverContent
3236
+ side="left"
3237
+ align="start"
3238
+ sideOffset={8}
3239
+ className="w-56 p-3"
3240
+ >
3241
+ <ScrubInput
3242
+ label={t("editPanel.labels.blur")}
3243
+ value={blurValue}
3244
+ onChange={(value) =>
3245
+ onStyleChange(
3246
+ "filter",
3247
+ `blur(${Math.max(0, Math.round(value))}px)`,
3248
+ )
3249
+ }
3250
+ unit="px"
3251
+ min={0}
3252
+ precision={1}
3253
+ labelClassName="w-16"
3254
+ inputClassName="h-6"
3255
+ />
3256
+ </PopoverContent>
3257
+ </Popover>
3258
+ ) : null}
3259
+ </PanelSection>
3260
+ );
3261
+ }
3262
+
3263
+ interface SelectionColorValue {
3264
+ property: string;
3265
+ value: string;
3266
+ }
3267
+
3268
+ function selectionColorValues(element: ElementInfo): SelectionColorValue[] {
3269
+ const styles = element.computedStyles;
3270
+ const rawValues: SelectionColorValue[] = [
3271
+ { property: "color", value: styles.color },
3272
+ { property: "backgroundColor", value: styles.backgroundColor },
3273
+ { property: "borderColor", value: styles.borderColor },
3274
+ { property: "outlineColor", value: styles.outlineColor },
3275
+ ];
3276
+ const seen = new Set<string>();
3277
+ return rawValues
3278
+ .map((color) => ({ ...color, value: color.value?.trim() }))
3279
+ .filter((color): color is SelectionColorValue => Boolean(color.value))
3280
+ .filter(
3281
+ (color) =>
3282
+ color.value !== "transparent" && color.value !== "rgba(0, 0, 0, 0)",
3283
+ )
3284
+ .filter((color) => {
3285
+ const key = color.value.toLowerCase();
3286
+ if (seen.has(key)) return false;
3287
+ seen.add(key);
3288
+ return true;
3289
+ });
3290
+ }
3291
+
3292
+ function SelectionColorsProperties({
3293
+ element,
3294
+ onStyleChange,
3295
+ }: {
3296
+ element: ElementInfo;
3297
+ onStyleChange: (property: string, value: string) => void;
3298
+ }) {
3299
+ const colors = selectionColorValues(element);
3300
+ const overflowCount = Math.max(0, colors.length - 3);
3301
+ if (!colors.length) return null;
3302
+
3303
+ return (
3304
+ <PanelSection
3305
+ title={"Selection colors" /* i18n-ignore Figma inspector label */}
3306
+ >
3307
+ <div className="flex min-w-0 items-center gap-1.5">
3308
+ {colors.slice(0, 3).map((color, index) => (
3309
+ <Popover key={`${color.value}-${index}`}>
3310
+ <Tooltip>
3311
+ <TooltipTrigger asChild>
3312
+ <PopoverTrigger asChild>
3313
+ <button
3314
+ type="button"
3315
+ className="size-5 rounded-sm border border-[var(--design-editor-control-border)] transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
3316
+ style={swatchStyle(color.value)}
3317
+ aria-label={color.value}
3318
+ />
3319
+ </PopoverTrigger>
3320
+ </TooltipTrigger>
3321
+ <TooltipContent>{color.value}</TooltipContent>
3322
+ </Tooltip>
3323
+ <PopoverContent
3324
+ side="left"
3325
+ align="start"
3326
+ sideOffset={8}
3327
+ className="w-80 p-0"
3328
+ >
3329
+ <FigmaColorPicker
3330
+ value={cssColorOrFallback(color.value, "#000000")}
3331
+ onChange={(value) => onStyleChange(color.property, value)}
3332
+ />
3333
+ </PopoverContent>
3334
+ </Popover>
3335
+ ))}
3336
+ {overflowCount > 0 ? (
3337
+ <span className="pl-0.5 text-[11px] font-medium text-muted-foreground">
3338
+ +{overflowCount}
3339
+ </span>
3340
+ ) : null}
3341
+ </div>
3342
+ </PanelSection>
689
3343
  );
690
3344
  }
691
3345
 
@@ -708,66 +3362,158 @@ const TEXT_TAGS = new Set([
708
3362
  export function EditPanel({
709
3363
  selectedElement,
710
3364
  pageStyles = {},
3365
+ width = 256,
3366
+ activeTab = "design",
3367
+ onActiveTabChange,
3368
+ tweaks = [],
3369
+ tweakValues = {},
3370
+ extensionContext,
3371
+ onTweakChange,
3372
+ onRequestTweaks,
711
3373
  onStyleChange,
3374
+ onStylesChange,
3375
+ onExport,
3376
+ exporting = false,
712
3377
  }: EditPanelProps) {
713
3378
  const t = useT();
3379
+ const [exportSettings, setExportSettings] = useState<ExportSettingsValue>({
3380
+ scale: 1,
3381
+ format: "png",
3382
+ suffix: "",
3383
+ });
714
3384
  const isTextElement = selectedElement
715
3385
  ? TEXT_TAGS.has(selectedElement.tagName)
716
3386
  : false;
717
- const isFlexContainer = selectedElement?.isFlexContainer ?? false;
3387
+ const handleActiveTabChange = useCallback(
3388
+ (tab: InspectorTab) => onActiveTabChange?.(tab),
3389
+ [onActiveTabChange],
3390
+ );
3391
+ const handleTweakChange = useCallback(
3392
+ (tweakId: string, value: string | number | boolean) => {
3393
+ onTweakChange?.(tweakId, value);
3394
+ },
3395
+ [onTweakChange],
3396
+ );
718
3397
 
719
3398
  return (
720
3399
  <div
721
3400
  className={cn(
722
- "w-64 border-l border-border bg-background overflow-y-auto",
723
- "flex flex-col",
3401
+ "shrink-0 bg-[var(--design-editor-panel-bg)]",
3402
+ "flex h-full min-h-0 flex-col overflow-hidden",
724
3403
  )}
3404
+ style={{ width }}
725
3405
  >
726
- <div className="p-3 border-b border-border">
727
- <h2 className="text-xs font-semibold text-foreground">
728
- {selectedElement
729
- ? `<${selectedElement.tagName}>${selectedElement.id ? ` #${selectedElement.id}` : ""}`
730
- : t("editPanel.properties")}
731
- </h2>
732
- {selectedElement?.classes.length ? (
733
- <p className="text-[10px] text-muted-foreground mt-0.5 truncate">
734
- .{selectedElement.classes.join(".")}
735
- </p>
736
- ) : null}
737
- </div>
3406
+ <InspectorTabsHeader
3407
+ activeTab={activeTab}
3408
+ onActiveTabChange={handleActiveTabChange}
3409
+ />
738
3410
 
739
- <div className="flex-1 p-3 space-y-4 overflow-y-auto">
740
- {!selectedElement && (
741
- <PageProperties styles={pageStyles} onStyleChange={onStyleChange} />
742
- )}
3411
+ {activeTab === "design" ? (
3412
+ <>
3413
+ <SelectionHeader element={selectedElement} />
743
3414
 
744
- {selectedElement && isTextElement && (
745
- <>
746
- <TextProperties
747
- element={selectedElement}
748
- onStyleChange={onStyleChange}
749
- />
750
- <Separator />
751
- </>
752
- )}
753
-
754
- {selectedElement && isFlexContainer && (
755
- <>
756
- <FlexProperties
757
- element={selectedElement}
758
- onStyleChange={onStyleChange}
3415
+ <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
3416
+ {!selectedElement && (
3417
+ <PageProperties
3418
+ styles={pageStyles}
3419
+ onStyleChange={onStyleChange}
3420
+ />
3421
+ )}
3422
+
3423
+ {selectedElement && (
3424
+ <>
3425
+ <PositionLayoutProperties
3426
+ element={selectedElement}
3427
+ onStyleChange={onStyleChange}
3428
+ />
3429
+ <LayoutContextProperties
3430
+ element={selectedElement}
3431
+ onStyleChange={onStyleChange}
3432
+ onStylesChange={onStylesChange}
3433
+ />
3434
+ <AppearanceProperties
3435
+ element={selectedElement}
3436
+ onStyleChange={onStyleChange}
3437
+ />
3438
+ {isTextElement ? (
3439
+ <TypographyProperties
3440
+ element={selectedElement}
3441
+ onStyleChange={onStyleChange}
3442
+ />
3443
+ ) : null}
3444
+ <FillProperties
3445
+ element={selectedElement}
3446
+ onStyleChange={onStyleChange}
3447
+ onStylesChange={onStylesChange}
3448
+ />
3449
+ <StrokeProperties
3450
+ element={selectedElement}
3451
+ onStyleChange={onStyleChange}
3452
+ onStylesChange={onStylesChange}
3453
+ />
3454
+ <EffectsProperties
3455
+ element={selectedElement}
3456
+ onStyleChange={onStyleChange}
3457
+ onStylesChange={onStylesChange}
3458
+ />
3459
+ <SelectionColorsProperties
3460
+ element={selectedElement}
3461
+ onStyleChange={onStyleChange}
3462
+ />
3463
+ </>
3464
+ )}
3465
+ {onExport ? (
3466
+ <PanelSection title={t("editPanel.sections.export")}>
3467
+ <ExportSettingsPanel
3468
+ value={exportSettings}
3469
+ formats={["png", "svg"]}
3470
+ exporting={exporting}
3471
+ onChange={(patch) =>
3472
+ setExportSettings((current) => ({ ...current, ...patch }))
3473
+ }
3474
+ onExport={onExport}
3475
+ />
3476
+ </PanelSection>
3477
+ ) : null}
3478
+ </div>
3479
+ </>
3480
+ ) : activeTab === "tweaks" ? (
3481
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
3482
+ <div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
3483
+ <h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
3484
+ {t("designEditor.tweaks")}
3485
+ </h3>
3486
+ {onRequestTweaks ? (
3487
+ <Tooltip>
3488
+ <TooltipTrigger asChild>
3489
+ <Button
3490
+ type="button"
3491
+ variant="ghost"
3492
+ size="icon"
3493
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
3494
+ aria-label={t("designEditor.addTweaks")}
3495
+ onClick={(event) => onRequestTweaks(event.currentTarget)}
3496
+ >
3497
+ <IconPlus className="size-3.5" />
3498
+ </Button>
3499
+ </TooltipTrigger>
3500
+ <TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
3501
+ </Tooltip>
3502
+ ) : null}
3503
+ </div>
3504
+ <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
3505
+ <TweaksPanelContent
3506
+ tweaks={tweaks}
3507
+ values={tweakValues}
3508
+ onChange={handleTweakChange}
3509
+ onRequestTweaks={onRequestTweaks}
3510
+ className="px-3 py-3"
759
3511
  />
760
- <Separator />
761
- </>
762
- )}
763
-
764
- {selectedElement && (
765
- <ElementProperties
766
- element={selectedElement}
767
- onStyleChange={onStyleChange}
768
- />
769
- )}
770
- </div>
3512
+ </div>
3513
+ </div>
3514
+ ) : extensionContext ? (
3515
+ <DesignExtensionsPanel context={extensionContext} />
3516
+ ) : null}
771
3517
  </div>
772
3518
  );
773
3519
  }