@agent-native/core 0.79.27 → 0.80.1

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 (497) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +34 -0
  3. package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  4. package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +55 -1
  5. package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  6. package/corpus/core/docs/content/locales/de-DE/template-design.mdx +66 -1
  7. package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  8. package/corpus/core/docs/content/locales/es-ES/template-design.mdx +65 -1
  9. package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  10. package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +64 -1
  11. package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  12. package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +59 -1
  13. package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  14. package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +62 -1
  15. package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  16. package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +58 -1
  17. package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  18. package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +62 -1
  19. package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  20. package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +50 -1
  21. package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  22. package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +52 -1
  23. package/corpus/core/docs/content/skills-guide.mdx +5 -0
  24. package/corpus/core/docs/content/template-design.mdx +59 -1
  25. package/corpus/core/package.json +2 -1
  26. package/corpus/core/src/agent/engine/index.ts +1 -0
  27. package/corpus/core/src/agent/engine/registry.ts +40 -0
  28. package/corpus/core/src/agent/production-agent.ts +6 -2
  29. package/corpus/core/src/application-state/handlers.ts +1 -1
  30. package/corpus/core/src/brand-kit/index.ts +1 -1
  31. package/corpus/core/src/cli/design-connect.ts +490 -0
  32. package/corpus/core/src/cli/index.ts +16 -0
  33. package/corpus/core/src/cli/skills.ts +144 -3
  34. package/corpus/core/src/client/AgentPanel.tsx +1 -1
  35. package/corpus/core/src/client/AssistantChat.tsx +43 -7
  36. package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
  37. package/corpus/core/src/client/agent-chat.ts +27 -3
  38. package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
  39. package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
  40. package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
  41. package/corpus/core/src/client/chat/message-components.tsx +1 -1
  42. package/corpus/core/src/client/chat-model-groups.ts +1 -2
  43. package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
  44. package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
  45. package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
  46. package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
  47. package/corpus/core/src/client/frame-protocol.ts +17 -2
  48. package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
  49. package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
  50. package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
  51. package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
  52. package/corpus/core/src/extensions/path.ts +1 -4
  53. package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
  54. package/corpus/core/src/mcp/build-server.ts +1 -1
  55. package/corpus/core/src/org/handlers.ts +5 -5
  56. package/corpus/core/src/org/plugin.ts +2 -2
  57. package/corpus/core/src/scripts/call-agent.ts +1 -1
  58. package/corpus/core/src/scripts/db/safety.ts +1 -1
  59. package/corpus/core/src/server/auth.ts +3 -0
  60. package/corpus/core/src/server/builder-design-systems.ts +308 -0
  61. package/corpus/core/src/server/core-routes-plugin.ts +29 -3
  62. package/corpus/core/src/server/index.ts +8 -0
  63. package/corpus/core/src/server/transcribe-voice.ts +57 -22
  64. package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
  65. package/corpus/core/src/styles/agent-native.css +60 -4
  66. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  67. package/corpus/core/src/usage/store.ts +2 -2
  68. package/corpus/core/src/vite/client.ts +2 -0
  69. package/corpus/core/src/voice/index.ts +18 -0
  70. package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
  71. package/corpus/core/src/voice/voice-context.ts +217 -0
  72. package/corpus/core/src/voice/voice-replacements.ts +75 -0
  73. package/corpus/templates/analytics/AGENTS.md +4 -3
  74. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
  75. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
  76. package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
  77. package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
  78. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
  79. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
  80. package/corpus/templates/analytics/app/global.css +0 -4
  81. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
  82. package/corpus/templates/analytics/app/i18n/zh-TW.ts +39 -0
  83. package/corpus/templates/analytics/app/i18n-data.ts +424 -0
  84. package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
  85. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
  86. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
  87. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +82 -34
  88. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
  89. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +144 -36
  90. package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
  91. package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
  92. package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  93. package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
  94. package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
  95. package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
  96. package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
  97. package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
  98. package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
  99. package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
  100. package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
  101. package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
  102. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
  103. package/corpus/templates/analytics/server/lib/dashboard-report.ts +75 -30
  104. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
  105. package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
  106. package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
  107. package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
  108. package/corpus/templates/analytics/server/lib/session-replay.ts +53 -12
  109. package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
  110. package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
  111. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
  112. package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  113. package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
  114. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
  115. package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
  116. package/corpus/templates/brain/app/i18n-data.ts +20 -0
  117. package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  118. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
  119. package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
  120. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  121. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  122. package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  123. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
  124. package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  125. package/corpus/templates/clips/AGENTS.md +4 -0
  126. package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
  127. package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
  128. package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
  129. package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
  130. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
  131. package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
  132. package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  133. package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
  134. package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
  135. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
  136. package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
  137. package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
  138. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
  139. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
  140. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
  141. package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  142. package/corpus/templates/content/shared/document-export.ts +1 -1
  143. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
  144. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +154 -0
  145. package/corpus/templates/design/AGENTS.md +95 -5
  146. package/corpus/templates/design/DESIGN.md +82 -0
  147. package/corpus/templates/design/DEVELOPING.md +43 -0
  148. package/corpus/templates/design/actions/add-localhost-screens.ts +453 -0
  149. package/corpus/templates/design/actions/apply-shader.ts +277 -0
  150. package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
  151. package/corpus/templates/design/actions/connect-localhost.ts +183 -0
  152. package/corpus/templates/design/actions/edit-design.ts +195 -42
  153. package/corpus/templates/design/actions/generate-design.ts +71 -1
  154. package/corpus/templates/design/actions/generate-screens.ts +193 -0
  155. package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
  156. package/corpus/templates/design/actions/get-shader.ts +119 -0
  157. package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
  158. package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
  159. package/corpus/templates/design/actions/navigate.ts +55 -2
  160. package/corpus/templates/design/actions/update-file.ts +9 -2
  161. package/corpus/templates/design/actions/view-screen.ts +68 -1
  162. package/corpus/templates/design/agent-native.app-skill.json +10 -0
  163. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +719 -0
  164. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2120 -103
  165. package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
  166. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +401 -0
  167. package/corpus/templates/design/app/components/design/EditPanel.tsx +3966 -440
  168. package/corpus/templates/design/app/components/design/LayersPanel.tsx +1473 -0
  169. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3940 -147
  170. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
  171. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +105 -63
  172. package/corpus/templates/design/app/components/design/index.ts +0 -5
  173. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +353 -0
  174. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1589 -0
  175. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +511 -0
  176. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +395 -0
  177. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +2010 -0
  178. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
  179. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
  180. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
  181. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +238 -0
  182. package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
  183. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +575 -0
  184. package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
  185. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
  186. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +741 -0
  187. package/corpus/templates/design/app/components/design/inspector/index.ts +87 -0
  188. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
  189. package/corpus/templates/design/app/components/design/types.ts +23 -0
  190. package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
  191. package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
  192. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
  193. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
  194. package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
  195. package/corpus/templates/design/app/global.css +149 -5
  196. package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
  197. package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
  198. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
  199. package/corpus/templates/design/app/i18n/zh-TW.ts +277 -0
  200. package/corpus/templates/design/app/i18n-data.ts +3028 -1
  201. package/corpus/templates/design/app/pages/DesignEditor.tsx +5831 -904
  202. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
  203. package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
  204. package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
  205. package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
  206. package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
  207. package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
  208. package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
  209. package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
  210. package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  211. package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
  212. package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
  213. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
  214. package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
  215. package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
  216. package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
  217. package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
  218. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
  219. package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
  220. package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
  221. package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
  222. package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
  223. package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
  224. package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
  225. package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
  226. package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
  227. package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
  228. package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
  229. package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
  230. package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
  231. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
  232. package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
  233. package/corpus/templates/design/package.json +1 -0
  234. package/corpus/templates/design/server/db/schema.ts +26 -0
  235. package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
  236. package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
  237. package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
  238. package/corpus/templates/design/shared/api.ts +27 -0
  239. package/corpus/templates/design/shared/canvas-frames.ts +103 -0
  240. package/corpus/templates/design/shared/canvas-math.ts +1058 -0
  241. package/corpus/templates/design/shared/code-layer.ts +2412 -0
  242. package/corpus/templates/design/shared/color-utils.ts +180 -0
  243. package/corpus/templates/design/shared/generation-session.ts +61 -0
  244. package/corpus/templates/design/shared/html-content.ts +21 -0
  245. package/corpus/templates/design/shared/pen-path.ts +207 -0
  246. package/corpus/templates/design/shared/shader-presets.ts +782 -0
  247. package/corpus/templates/design/shared/shader-safety.ts +137 -0
  248. package/corpus/templates/design/shared/source-mode.ts +237 -0
  249. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
  250. package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
  251. package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
  252. package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
  253. package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
  254. package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
  255. package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
  256. package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
  257. package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
  258. package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
  259. package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
  260. package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
  261. package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  262. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
  263. package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
  264. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
  265. package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
  266. package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
  267. package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
  268. package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
  269. package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
  270. package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
  271. package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
  272. package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
  273. package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
  274. package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
  275. package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
  276. package/corpus/templates/mail/app/lib/utils.ts +2 -2
  277. package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  278. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
  279. package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
  280. package/corpus/templates/plan/app/global.css +61 -78
  281. package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
  282. package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  283. package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
  284. package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
  285. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
  286. package/corpus/templates/slides/AGENTS.md +3 -2
  287. package/corpus/templates/slides/actions/import-file.ts +26 -17
  288. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
  289. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
  290. package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
  291. package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
  292. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
  293. package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
  294. package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
  295. package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
  296. package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
  297. package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
  298. package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
  299. package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
  300. package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
  301. package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
  302. package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
  303. package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
  304. package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
  305. package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
  306. package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
  307. package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
  308. package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
  309. package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
  310. package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
  311. package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
  312. package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
  313. package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
  314. package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
  315. package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
  316. package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
  317. package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
  318. package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
  319. package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
  320. package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
  321. package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
  322. package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
  323. package/dist/agent/engine/index.d.ts +1 -1
  324. package/dist/agent/engine/index.d.ts.map +1 -1
  325. package/dist/agent/engine/index.js +1 -1
  326. package/dist/agent/engine/index.js.map +1 -1
  327. package/dist/agent/engine/registry.d.ts +9 -0
  328. package/dist/agent/engine/registry.d.ts.map +1 -1
  329. package/dist/agent/engine/registry.js +37 -0
  330. package/dist/agent/engine/registry.js.map +1 -1
  331. package/dist/agent/production-agent.d.ts.map +1 -1
  332. package/dist/agent/production-agent.js +4 -3
  333. package/dist/agent/production-agent.js.map +1 -1
  334. package/dist/application-state/handlers.js +1 -1
  335. package/dist/application-state/handlers.js.map +1 -1
  336. package/dist/brand-kit/index.d.ts +1 -1
  337. package/dist/brand-kit/index.js +1 -1
  338. package/dist/brand-kit/index.js.map +1 -1
  339. package/dist/cli/design-connect.d.ts +52 -0
  340. package/dist/cli/design-connect.d.ts.map +1 -0
  341. package/dist/cli/design-connect.js +409 -0
  342. package/dist/cli/design-connect.js.map +1 -0
  343. package/dist/cli/index.js +15 -0
  344. package/dist/cli/index.js.map +1 -1
  345. package/dist/cli/skills.d.ts +3 -0
  346. package/dist/cli/skills.d.ts.map +1 -1
  347. package/dist/cli/skills.js +139 -3
  348. package/dist/cli/skills.js.map +1 -1
  349. package/dist/client/AgentPanel.js +1 -1
  350. package/dist/client/AgentPanel.js.map +1 -1
  351. package/dist/client/AssistantChat.d.ts.map +1 -1
  352. package/dist/client/AssistantChat.js +38 -8
  353. package/dist/client/AssistantChat.js.map +1 -1
  354. package/dist/client/MultiTabAssistantChat.js +1 -1
  355. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  356. package/dist/client/agent-chat.d.ts +6 -0
  357. package/dist/client/agent-chat.d.ts.map +1 -1
  358. package/dist/client/agent-chat.js +17 -3
  359. package/dist/client/agent-chat.js.map +1 -1
  360. package/dist/client/blocks/library/MermaidBlock.js +1 -1
  361. package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
  362. package/dist/client/blocks/library/diagram.js +1 -1
  363. package/dist/client/blocks/library/diagram.js.map +1 -1
  364. package/dist/client/blocks/library/wireframe.js +1 -1
  365. package/dist/client/blocks/library/wireframe.js.map +1 -1
  366. package/dist/client/chat/message-components.js +1 -1
  367. package/dist/client/chat/message-components.js.map +1 -1
  368. package/dist/client/chat-model-groups.d.ts.map +1 -1
  369. package/dist/client/chat-model-groups.js +1 -2
  370. package/dist/client/chat-model-groups.js.map +1 -1
  371. package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
  372. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
  373. package/dist/client/components/LiveCursorOverlay.js +19 -40
  374. package/dist/client/components/LiveCursorOverlay.js.map +1 -1
  375. package/dist/client/composer/TiptapComposer.d.ts +3 -1
  376. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  377. package/dist/client/composer/TiptapComposer.js +86 -24
  378. package/dist/client/composer/TiptapComposer.js.map +1 -1
  379. package/dist/client/composer/useVoiceDictation.d.ts +9 -0
  380. package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
  381. package/dist/client/composer/useVoiceDictation.js +154 -32
  382. package/dist/client/composer/useVoiceDictation.js.map +1 -1
  383. package/dist/client/extensions/portable-extension.d.ts +1 -1
  384. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  385. package/dist/client/extensions/portable-extension.js.map +1 -1
  386. package/dist/client/frame-protocol.d.ts +17 -2
  387. package/dist/client/frame-protocol.d.ts.map +1 -1
  388. package/dist/client/frame-protocol.js.map +1 -1
  389. package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
  390. package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
  391. package/dist/client/sharing/ShareButton.d.ts +2 -0
  392. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  393. package/dist/client/sharing/ShareButton.js +7 -6
  394. package/dist/client/sharing/ShareButton.js.map +1 -1
  395. package/dist/client/use-agent-engine-configured.d.ts +5 -0
  396. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  397. package/dist/client/use-agent-engine-configured.js +51 -25
  398. package/dist/client/use-agent-engine-configured.js.map +1 -1
  399. package/dist/code-agents/transcript-normalizer.js +1 -1
  400. package/dist/code-agents/transcript-normalizer.js.map +1 -1
  401. package/dist/collab/routes.d.ts +1 -1
  402. package/dist/extensions/path.d.ts +1 -1
  403. package/dist/extensions/path.d.ts.map +1 -1
  404. package/dist/extensions/path.js.map +1 -1
  405. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  406. package/dist/integrations/a2a-continuation-processor.js +1 -1
  407. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  408. package/dist/mcp/build-server.d.ts +1 -1
  409. package/dist/mcp/build-server.d.ts.map +1 -1
  410. package/dist/mcp/build-server.js.map +1 -1
  411. package/dist/notifications/routes.d.ts +3 -3
  412. package/dist/observability/routes.d.ts +5 -5
  413. package/dist/org/handlers.js +5 -5
  414. package/dist/org/handlers.js.map +1 -1
  415. package/dist/org/plugin.js +2 -2
  416. package/dist/org/plugin.js.map +1 -1
  417. package/dist/scripts/call-agent.js +1 -1
  418. package/dist/scripts/call-agent.js.map +1 -1
  419. package/dist/scripts/db/safety.js +1 -1
  420. package/dist/scripts/db/safety.js.map +1 -1
  421. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  422. package/dist/server/auth.d.ts.map +1 -1
  423. package/dist/server/auth.js +3 -0
  424. package/dist/server/auth.js.map +1 -1
  425. package/dist/server/builder-design-systems.d.ts +28 -0
  426. package/dist/server/builder-design-systems.d.ts.map +1 -0
  427. package/dist/server/builder-design-systems.js +217 -0
  428. package/dist/server/builder-design-systems.js.map +1 -0
  429. package/dist/server/core-routes-plugin.d.ts +1 -0
  430. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  431. package/dist/server/core-routes-plugin.js +20 -3
  432. package/dist/server/core-routes-plugin.js.map +1 -1
  433. package/dist/server/index.d.ts +1 -0
  434. package/dist/server/index.d.ts.map +1 -1
  435. package/dist/server/index.js +1 -0
  436. package/dist/server/index.js.map +1 -1
  437. package/dist/server/transcribe-voice.d.ts +1 -1
  438. package/dist/server/transcribe-voice.d.ts.map +1 -1
  439. package/dist/server/transcribe-voice.js +40 -24
  440. package/dist/server/transcribe-voice.js.map +1 -1
  441. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  442. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  443. package/dist/shared/mcp-embed-headers.js +20 -1
  444. package/dist/shared/mcp-embed-headers.js.map +1 -1
  445. package/dist/styles/agent-native.css +60 -4
  446. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  447. package/dist/usage/store.js +2 -2
  448. package/dist/usage/store.js.map +1 -1
  449. package/dist/vite/client.d.ts.map +1 -1
  450. package/dist/vite/client.js +2 -0
  451. package/dist/vite/client.js.map +1 -1
  452. package/dist/voice/index.d.ts +5 -0
  453. package/dist/voice/index.d.ts.map +1 -0
  454. package/dist/voice/index.js +4 -0
  455. package/dist/voice/index.js.map +1 -0
  456. package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
  457. package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
  458. package/dist/voice/voice-cleanup-prompt.js +26 -0
  459. package/dist/voice/voice-cleanup-prompt.js.map +1 -0
  460. package/dist/voice/voice-context.d.ts +25 -0
  461. package/dist/voice/voice-context.d.ts.map +1 -0
  462. package/dist/voice/voice-context.js +156 -0
  463. package/dist/voice/voice-context.js.map +1 -0
  464. package/dist/voice/voice-replacements.d.ts +4 -0
  465. package/dist/voice/voice-replacements.d.ts.map +1 -0
  466. package/dist/voice/voice-replacements.js +52 -0
  467. package/dist/voice/voice-replacements.js.map +1 -0
  468. package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  469. package/docs/content/locales/ar-SA/template-design.mdx +55 -1
  470. package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  471. package/docs/content/locales/de-DE/template-design.mdx +66 -1
  472. package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  473. package/docs/content/locales/es-ES/template-design.mdx +65 -1
  474. package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  475. package/docs/content/locales/fr-FR/template-design.mdx +64 -1
  476. package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  477. package/docs/content/locales/hi-IN/template-design.mdx +59 -1
  478. package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  479. package/docs/content/locales/ja-JP/template-design.mdx +62 -1
  480. package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  481. package/docs/content/locales/ko-KR/template-design.mdx +58 -1
  482. package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  483. package/docs/content/locales/pt-BR/template-design.mdx +62 -1
  484. package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  485. package/docs/content/locales/zh-CN/template-design.mdx +50 -1
  486. package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  487. package/docs/content/locales/zh-TW/template-design.mdx +52 -1
  488. package/docs/content/skills-guide.mdx +5 -0
  489. package/docs/content/template-design.mdx +59 -1
  490. package/package.json +2 -1
  491. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
  492. package/corpus/templates/design/actions/import-figma-file.ts +0 -166
  493. package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
  494. package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
  495. package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
  496. package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
  497. package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
@@ -1,9 +1,72 @@
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
+ rgbaToHex,
8
+ withColorOpacity,
9
+ } from "@shared/color-utils";
10
+ import {
11
+ IconAlignCenter,
12
+ IconAlignJustified,
13
+ IconAlignLeft,
14
+ IconAlignRight,
15
+ IconArrowAutofitHeight,
16
+ IconArrowAutofitWidth,
17
+ IconBackground,
18
+ IconBlur,
19
+ IconBorderStyle,
20
+ IconBrush,
21
+ IconChevronDown,
22
+ IconCode,
23
+ IconComponents,
24
+ IconDroplet,
25
+ IconEye,
26
+ IconEyeOff,
27
+ IconFlipHorizontal,
28
+ IconFlipVertical,
29
+ IconFrame,
30
+ IconLayoutDistributeHorizontal,
31
+ IconLayoutGrid,
32
+ IconSlice,
33
+ IconLayoutAlignBottom,
34
+ IconLayoutAlignCenter,
35
+ IconLayoutAlignLeft,
36
+ IconLayoutAlignMiddle,
37
+ IconLayoutAlignRight,
38
+ IconLayoutAlignTop,
39
+ IconLetterCase,
40
+ IconLetterSpacing,
41
+ IconLineHeight,
42
+ IconLink,
43
+ IconMaximize,
44
+ IconMinus,
45
+ IconPalette,
46
+ IconPhoto,
47
+ IconPlus,
48
+ IconShadow,
49
+ IconSquare,
50
+ IconTypography,
51
+ IconUnlink,
52
+ IconVector,
53
+ } from "@tabler/icons-react";
54
+ import { useCallback, useEffect, useState, type ReactNode } from "react";
4
55
 
56
+ import { Button } from "@/components/ui/button";
57
+ import {
58
+ DropdownMenu,
59
+ DropdownMenuContent,
60
+ DropdownMenuItem,
61
+ DropdownMenuTrigger,
62
+ } from "@/components/ui/dropdown-menu";
5
63
  import { Input } from "@/components/ui/input";
6
64
  import { Label } from "@/components/ui/label";
65
+ import {
66
+ Popover,
67
+ PopoverContent,
68
+ PopoverTrigger,
69
+ } from "@/components/ui/popover";
7
70
  import {
8
71
  Select,
9
72
  SelectContent,
@@ -11,16 +74,61 @@ import {
11
74
  SelectTrigger,
12
75
  SelectValue,
13
76
  } from "@/components/ui/select";
14
- import { Separator } from "@/components/ui/separator";
15
77
  import { Slider } from "@/components/ui/slider";
78
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
79
+ import {
80
+ Tooltip,
81
+ TooltipContent,
82
+ TooltipTrigger,
83
+ } from "@/components/ui/tooltip";
16
84
  import { cn } from "@/lib/utils";
17
85
 
86
+ import {
87
+ DesignExtensionsPanel,
88
+ type DesignExtensionSlotContext,
89
+ } from "./DesignExtensionsPanel";
90
+ import {
91
+ AutoLayoutMatrix,
92
+ ConstraintsWidget,
93
+ ExportSettingsPanel,
94
+ FigmaColorPicker,
95
+ ScrubInput,
96
+ SizingField,
97
+ type AlignmentMatrixValue,
98
+ type AutoLayoutMatrixValue,
99
+ type AutoLayoutSizing,
100
+ type AutoLayoutSizingAxis,
101
+ type ConstraintsValue,
102
+ type ExportSettingsValue,
103
+ type FigmaFillRow,
104
+ type FigmaFillRowPatch,
105
+ type FigmaGradientStop,
106
+ type FigmaGradientStopPatch,
107
+ type FigmaGradientType,
108
+ } from "./inspector";
109
+ import type { FigmaPaintType } from "./inspector/FigmaColorPicker";
110
+ import { TweaksPanelContent } from "./TweaksPanel";
18
111
  import type { ElementInfo } from "./types";
19
112
 
113
+ export type InspectorTab = "design" | "tweaks" | "extensions";
114
+
20
115
  interface EditPanelProps {
21
116
  selectedElement: ElementInfo | null;
22
117
  pageStyles?: Record<string, string>;
118
+ zoom?: number;
119
+ headerTrailing?: ReactNode;
120
+ width?: number;
121
+ activeTab?: InspectorTab;
122
+ onActiveTabChange?: (tab: InspectorTab) => void;
123
+ tweaks?: TweakDefinition[];
124
+ tweakValues?: Record<string, string | number | boolean>;
125
+ extensionContext?: DesignExtensionSlotContext;
126
+ onTweakChange?: (id: string, value: string | number | boolean) => void;
127
+ onRequestTweaks?: (anchor: HTMLElement) => void;
23
128
  onStyleChange: (property: string, value: string) => void;
129
+ onStylesChange?: (styles: Record<string, string>) => void;
130
+ onExport?: (settings: ExportSettingsValue) => void;
131
+ exporting?: boolean;
24
132
  }
25
133
 
26
134
  /**
@@ -74,10 +182,8 @@ function PropInput({
74
182
  };
75
183
 
76
184
  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>
185
+ <div className="flex items-center gap-1.5">
186
+ <FieldLabel>{label}</FieldLabel>
81
187
  <Input
82
188
  type={type}
83
189
  value={draft}
@@ -98,82 +204,582 @@ function PropInput({
98
204
  }
99
205
  }}
100
206
  placeholder={placeholder}
101
- className="h-7 text-xs"
207
+ 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
208
  />
103
209
  </div>
104
210
  );
105
211
  }
106
212
 
107
- /** Compact color input: label + color swatch + text input */
213
+ /** Compact color input: label + Figma-style picker popover. */
108
214
  function ColorInput({
109
215
  label,
110
216
  value,
111
217
  onChange,
218
+ backgroundImage,
219
+ onBackgroundImageChange,
220
+ blendMode,
221
+ onBlendModeChange,
222
+ supportsLayeredFills = false,
223
+ documentColors,
112
224
  }: {
113
225
  label: string;
114
226
  value: string;
115
227
  onChange: (value: string) => void;
228
+ backgroundImage?: string;
229
+ onBackgroundImageChange?: (value: string) => void;
230
+ blendMode?: string;
231
+ onBlendModeChange?: (value: string) => void;
232
+ supportsLayeredFills?: boolean;
233
+ /** Hex strings already in use on the page — forwarded to the color picker swatch grid. */
234
+ documentColors?: string[];
116
235
  }) {
117
- const t = useT();
118
236
  const [draft, setDraft] = useState(value);
237
+ const [selectedFillId, setSelectedFillId] = useState(SOLID_FILL_ID);
238
+ const [selectedStopId, setSelectedStopId] = useState<string | undefined>();
119
239
 
120
240
  useEffect(() => {
121
241
  setDraft(value);
122
242
  }, [value]);
123
243
 
244
+ const backgroundLayers = splitCssLayers(backgroundImage || "");
245
+ const selectedLayerIndex = fillLayerIndex(selectedFillId);
246
+ const selectedGradient =
247
+ selectedLayerIndex !== null
248
+ ? parseGradientLayer(backgroundLayers[selectedLayerIndex] || "")
249
+ : null;
250
+ const fallbackGradientIndex = backgroundLayers.findIndex((layer) =>
251
+ Boolean(parseGradientLayer(layer)),
252
+ );
253
+ const activeGradientIndex =
254
+ selectedGradient && selectedLayerIndex !== null
255
+ ? selectedLayerIndex
256
+ : fallbackGradientIndex >= 0
257
+ ? fallbackGradientIndex
258
+ : null;
259
+ const activeGradient =
260
+ activeGradientIndex !== null
261
+ ? parseGradientLayer(backgroundLayers[activeGradientIndex] || "")
262
+ : null;
263
+ const activeStopIds =
264
+ activeGradient?.stops.map((stop) => stop.id).join("|") ?? "";
265
+
266
+ useEffect(() => {
267
+ if (
268
+ selectedFillId !== SOLID_FILL_ID &&
269
+ (selectedLayerIndex === null ||
270
+ selectedLayerIndex >= backgroundLayers.length)
271
+ ) {
272
+ setSelectedFillId(SOLID_FILL_ID);
273
+ }
274
+ }, [backgroundLayers.length, selectedFillId, selectedLayerIndex]);
275
+
276
+ useEffect(() => {
277
+ if (!activeStopIds) {
278
+ if (selectedStopId) setSelectedStopId(undefined);
279
+ return;
280
+ }
281
+ const stopIds = activeStopIds.split("|").filter(Boolean);
282
+ if (!selectedStopId || !stopIds.includes(selectedStopId)) {
283
+ setSelectedStopId(stopIds[0]);
284
+ }
285
+ }, [activeStopIds, selectedStopId]);
286
+
124
287
  const setNext = (next: string) => {
125
288
  setDraft(next);
126
289
  onChange(next);
127
290
  };
128
291
 
292
+ const replaceBackgroundLayer = (index: number, nextLayer: string) => {
293
+ if (!onBackgroundImageChange) return;
294
+ const nextLayers = [...backgroundLayers];
295
+ nextLayers[index] = nextLayer;
296
+ onBackgroundImageChange(joinCssLayers(nextLayers));
297
+ };
298
+
299
+ const removeBackgroundLayer = (index: number) => {
300
+ if (!onBackgroundImageChange) return;
301
+ const nextLayers = backgroundLayers.filter(
302
+ (_, layerIndex) => layerIndex !== index,
303
+ );
304
+ onBackgroundImageChange(joinCssLayers(nextLayers));
305
+ setSelectedFillId(SOLID_FILL_ID);
306
+ };
307
+
308
+ const handlePaintValueChange = (nextValue: string) => {
309
+ if (!supportsLayeredFills || !onBackgroundImageChange) {
310
+ setNext(nextValue);
311
+ return;
312
+ }
313
+
314
+ const selectedLayer = fillLayerIndex(selectedFillId);
315
+ if (selectedLayer !== null) {
316
+ replaceBackgroundLayer(selectedLayer, nextValue);
317
+ const gradient = parseGradientLayer(nextValue);
318
+ if (gradient) setSelectedStopId(gradient.stops[0]?.id);
319
+ return;
320
+ }
321
+
322
+ onBackgroundImageChange(joinCssLayers([nextValue, ...backgroundLayers]));
323
+ setSelectedFillId(fillLayerId(0));
324
+ const gradient = parseGradientLayer(nextValue);
325
+ setSelectedStopId(gradient?.stops[0]?.id);
326
+ };
327
+
328
+ const fillRows = supportsLayeredFills
329
+ ? buildFillRows(
330
+ draft || value || "#000000",
331
+ backgroundLayers,
332
+ selectedFillId,
333
+ )
334
+ : undefined;
335
+
336
+ const handleFillChange = (id: string, patch: FigmaFillRowPatch) => {
337
+ if (id === SOLID_FILL_ID) {
338
+ if (patch.value !== undefined) setNext(patch.value);
339
+ if (patch.opacity !== undefined) {
340
+ const parsed = parseCssColor(patch.value ?? draft);
341
+ if (parsed) setNext(rgbaToCss(withColorOpacity(parsed, patch.opacity)));
342
+ }
343
+ return;
344
+ }
345
+
346
+ const index = fillLayerIndex(id);
347
+ if (index === null || !onBackgroundImageChange) return;
348
+ const currentLayer = backgroundLayers[index] || "";
349
+ if (patch.value !== undefined) {
350
+ replaceBackgroundLayer(index, patch.value);
351
+ return;
352
+ }
353
+ if (patch.opacity === undefined) return;
354
+ const gradient = parseGradientLayer(currentLayer);
355
+ if (!gradient) return;
356
+ replaceBackgroundLayer(
357
+ index,
358
+ buildGradientLayer(
359
+ gradient.type,
360
+ gradient.stops.map((stop) => ({
361
+ ...stop,
362
+ opacity: patch.opacity,
363
+ })),
364
+ ),
365
+ );
366
+ };
367
+
368
+ const handleAddFill = onBackgroundImageChange
369
+ ? () => {
370
+ const nextLayers = [
371
+ defaultGradientLayer("linear", draft || value || "#000000"),
372
+ ...backgroundLayers,
373
+ ];
374
+ onBackgroundImageChange(joinCssLayers(nextLayers));
375
+ setSelectedFillId(fillLayerId(0));
376
+ setSelectedStopId("stop-0");
377
+ }
378
+ : undefined;
379
+
380
+ const handleRemoveFill = onBackgroundImageChange
381
+ ? (id: string) => {
382
+ const index = fillLayerIndex(id);
383
+ if (index === null) return;
384
+ removeBackgroundLayer(index);
385
+ }
386
+ : undefined;
387
+
388
+ const handleGradientTypeChange =
389
+ activeGradient && activeGradientIndex !== null
390
+ ? (type: FigmaGradientType) => {
391
+ replaceBackgroundLayer(
392
+ activeGradientIndex,
393
+ buildGradientLayer(type, activeGradient.stops),
394
+ );
395
+ }
396
+ : undefined;
397
+
398
+ const handleGradientStopChange =
399
+ activeGradient && activeGradientIndex !== null
400
+ ? (id: string, patch: FigmaGradientStopPatch) => {
401
+ const nextStops = activeGradient.stops.map((stop) =>
402
+ stop.id === id ? { ...stop, ...patch } : stop,
403
+ );
404
+ replaceBackgroundLayer(
405
+ activeGradientIndex,
406
+ buildGradientLayer(activeGradient.type, nextStops),
407
+ );
408
+ }
409
+ : undefined;
410
+
411
+ const handleAddGradientStop = onBackgroundImageChange
412
+ ? () => {
413
+ if (activeGradient && activeGradientIndex !== null) {
414
+ const nextStop: FigmaGradientStop = {
415
+ id: `stop-${activeGradient.stops.length}`,
416
+ color: draft || "#000000",
417
+ position: 50,
418
+ opacity: 100,
419
+ };
420
+ replaceBackgroundLayer(
421
+ activeGradientIndex,
422
+ buildGradientLayer(activeGradient.type, [
423
+ ...activeGradient.stops,
424
+ nextStop,
425
+ ]),
426
+ );
427
+ setSelectedStopId(nextStop.id);
428
+ return;
429
+ }
430
+
431
+ onBackgroundImageChange(
432
+ joinCssLayers([
433
+ defaultGradientLayer("linear", draft || value || "#000000"),
434
+ ...backgroundLayers,
435
+ ]),
436
+ );
437
+ setSelectedFillId(fillLayerId(0));
438
+ setSelectedStopId("stop-0");
439
+ }
440
+ : undefined;
441
+
442
+ const handleRemoveGradientStop =
443
+ activeGradient && activeGradientIndex !== null
444
+ ? (id: string) => {
445
+ if (activeGradient.stops.length <= 2) return;
446
+ const nextStops = activeGradient.stops.filter(
447
+ (stop) => stop.id !== id,
448
+ );
449
+ replaceBackgroundLayer(
450
+ activeGradientIndex,
451
+ buildGradientLayer(activeGradient.type, nextStops),
452
+ );
453
+ setSelectedStopId(nextStops[0]?.id);
454
+ }
455
+ : undefined;
456
+
457
+ const selectedPaintType: FigmaPaintType =
458
+ selectedFillId !== SOLID_FILL_ID
459
+ ? selectedGradient
460
+ ? selectedGradient.type
461
+ : "image"
462
+ : colorHasVisibleAlpha(draft || value)
463
+ ? "solid"
464
+ : "none";
465
+ const pickerValue =
466
+ selectedLayerIndex !== null
467
+ ? (backgroundLayers[selectedLayerIndex] ?? draft ?? value ?? "#000000")
468
+ : draft || "#000000";
469
+
470
+ const handlePaintTypeChange = (type: FigmaPaintType) => {
471
+ const selectedLayer = fillLayerIndex(selectedFillId);
472
+ if (type === "solid") {
473
+ if (selectedLayer !== null) removeBackgroundLayer(selectedLayer);
474
+ setSelectedFillId(SOLID_FILL_ID);
475
+ setNext(cssColorOrFallback(draft || value, "#000000"));
476
+ return;
477
+ }
478
+ if (type === "none") {
479
+ if (selectedLayer !== null) {
480
+ removeBackgroundLayer(selectedLayer);
481
+ return;
482
+ }
483
+ setNext("transparent");
484
+ return;
485
+ }
486
+ if (!onBackgroundImageChange) return;
487
+
488
+ if (
489
+ type !== "linear" &&
490
+ type !== "radial" &&
491
+ type !== "angular" &&
492
+ type !== "diamond"
493
+ ) {
494
+ return;
495
+ }
496
+ const nextType: FigmaGradientType = type;
497
+ const layerIndex = selectedLayer ?? activeGradientIndex;
498
+ if (layerIndex !== null) {
499
+ const currentGradient = parseGradientLayer(
500
+ backgroundLayers[layerIndex] || "",
501
+ );
502
+ const stops =
503
+ currentGradient?.stops ?? defaultGradientStops(draft || value);
504
+ replaceBackgroundLayer(layerIndex, buildGradientLayer(nextType, stops));
505
+ setSelectedFillId(fillLayerId(layerIndex));
506
+ setSelectedStopId(stops[0]?.id);
507
+ return;
508
+ }
509
+
510
+ onBackgroundImageChange(
511
+ joinCssLayers([
512
+ defaultGradientLayer(nextType, draft || value || "#000000"),
513
+ ...backgroundLayers,
514
+ ]),
515
+ );
516
+ setSelectedFillId(fillLayerId(0));
517
+ setSelectedStopId("stop-0");
518
+ };
519
+
129
520
  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>
521
+ <FigmaColorPicker
522
+ label={label}
523
+ value={pickerValue}
524
+ onChange={setNext}
525
+ onPaintValueChange={
526
+ supportsLayeredFills ? handlePaintValueChange : undefined
527
+ }
528
+ blendMode={blendMode}
529
+ onBlendModeChange={onBlendModeChange}
530
+ showBlendMode={supportsLayeredFills}
531
+ fillRows={fillRows}
532
+ selectedFillId={selectedFillId}
533
+ onFillSelect={supportsLayeredFills ? setSelectedFillId : undefined}
534
+ onFillChange={supportsLayeredFills ? handleFillChange : undefined}
535
+ onAddFill={supportsLayeredFills ? handleAddFill : undefined}
536
+ onRemoveFill={supportsLayeredFills ? handleRemoveFill : undefined}
537
+ paintType={selectedPaintType}
538
+ onPaintTypeChange={handlePaintTypeChange}
539
+ gradientType={activeGradient?.type}
540
+ onGradientTypeChange={handleGradientTypeChange}
541
+ gradientStops={activeGradient?.stops}
542
+ selectedStopId={selectedStopId}
543
+ onGradientStopSelect={setSelectedStopId}
544
+ onGradientStopChange={handleGradientStopChange}
545
+ onAddGradientStop={
546
+ supportsLayeredFills ? handleAddGradientStop : undefined
547
+ }
548
+ onRemoveGradientStop={handleRemoveGradientStop}
549
+ documentColors={documentColors}
550
+ />
150
551
  );
151
552
  }
152
553
 
153
- function toColorInputValue(value: string): string {
554
+ const SOLID_FILL_ID = "solid";
555
+ const FILL_LAYER_PREFIX = "layer:";
556
+
557
+ interface ParsedGradientLayer {
558
+ type: FigmaGradientType;
559
+ stops: FigmaGradientStop[];
560
+ }
561
+
562
+ function fillLayerId(index: number): string {
563
+ return `${FILL_LAYER_PREFIX}${index}`;
564
+ }
565
+
566
+ function fillLayerIndex(id: string): number | null {
567
+ if (!id.startsWith(FILL_LAYER_PREFIX)) return null;
568
+ const index = Number(id.slice(FILL_LAYER_PREFIX.length));
569
+ return Number.isInteger(index) && index >= 0 ? index : null;
570
+ }
571
+
572
+ function buildFillRows(
573
+ colorValue: string,
574
+ backgroundLayers: string[],
575
+ selectedFillId: string,
576
+ ): FigmaFillRow[] {
577
+ const solid = parseCssColor(colorValue);
578
+ const rows: FigmaFillRow[] = [
579
+ {
580
+ id: SOLID_FILL_ID,
581
+ label: "Solid", // i18n-ignore inspector fallback label
582
+ type: "solid",
583
+ value: colorValue,
584
+ swatch: colorValue,
585
+ opacity: solid ? alphaToOpacity(solid.a) : 100,
586
+ selected: selectedFillId === SOLID_FILL_ID,
587
+ },
588
+ ];
589
+
590
+ backgroundLayers.forEach((layer, index) => {
591
+ const gradient = parseGradientLayer(layer);
592
+ rows.push({
593
+ id: fillLayerId(index),
594
+ label: gradient
595
+ ? `Gradient ${index + 1}` // i18n-ignore inspector fallback label
596
+ : `Image ${index + 1}`, // i18n-ignore inspector fallback label
597
+ type: gradient ? "gradient" : "image",
598
+ value: layer,
599
+ swatch: layer,
600
+ opacity: gradient ? averageGradientOpacity(gradient.stops) : 100,
601
+ selected: selectedFillId === fillLayerId(index),
602
+ });
603
+ });
604
+
605
+ return rows;
606
+ }
607
+
608
+ function averageGradientOpacity(stops: FigmaGradientStop[]): number {
609
+ if (!stops.length) return 100;
610
+ const total = stops.reduce((sum, stop) => {
611
+ const parsed = parseCssColor(stop.color);
612
+ return sum + (stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100));
613
+ }, 0);
614
+ return Math.round(total / stops.length);
615
+ }
616
+
617
+ function splitCssLayers(value: string): string[] {
154
618
  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("")}`;
619
+ if (!trimmed || trimmed === "none") return [];
620
+ const layers: string[] = [];
621
+ let depth = 0;
622
+ let start = 0;
623
+
624
+ for (let index = 0; index < trimmed.length; index += 1) {
625
+ const char = trimmed[index];
626
+ if (char === "(") depth += 1;
627
+ if (char === ")") depth = Math.max(0, depth - 1);
628
+ if (char === "," && depth === 0) {
629
+ const layer = trimmed.slice(start, index).trim();
630
+ if (layer) layers.push(layer);
631
+ start = index + 1;
632
+ }
162
633
  }
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("")}`;
634
+
635
+ const finalLayer = trimmed.slice(start).trim();
636
+ if (finalLayer) layers.push(finalLayer);
637
+ return layers;
638
+ }
639
+
640
+ function joinCssLayers(layers: string[]): string {
641
+ const cleaned = layers.map((layer) => layer.trim()).filter(Boolean);
642
+ return cleaned.length ? cleaned.join(", ") : "none";
643
+ }
644
+
645
+ function parseGradientLayer(layer: string): ParsedGradientLayer | null {
646
+ const match = layer.trim().match(/^(linear|radial|conic)-gradient\((.*)\)$/i);
647
+ if (!match) return null;
648
+
649
+ const parts = splitCssLayers(match[2] || "");
650
+ const type = gradientTypeFromCss(match[1] || "", layer);
651
+ const firstStop = parseGradientStop(parts[0] || "", 0, parts.length);
652
+ const stopParts = firstStop ? parts : parts.slice(1);
653
+ const stops = stopParts
654
+ .map((part, index) => parseGradientStop(part, index, stopParts.length))
655
+ .filter((stop): stop is FigmaGradientStop => Boolean(stop));
656
+
657
+ if (!stops.length) return null;
658
+ return { type, stops };
659
+ }
660
+
661
+ function parseGradientStop(
662
+ part: string,
663
+ index: number,
664
+ total: number,
665
+ ): FigmaGradientStop | null {
666
+ const color = readLeadingColor(part);
667
+ if (!color) return null;
668
+ const parsed = parseCssColor(color.value);
669
+ const remaining = part.slice(color.raw.length);
670
+ const positionMatch = remaining.match(/(-?\d+(?:\.\d+)?)%/);
671
+ const position = positionMatch
672
+ ? clampNumber(Number(positionMatch[1]), 0, 100)
673
+ : total <= 1
674
+ ? 0
675
+ : Math.round((index / (total - 1)) * 100);
676
+
677
+ return {
678
+ id: `stop-${index}`,
679
+ color: parsed ? rgbaToCss(parsed) : color.value,
680
+ position,
681
+ opacity: parsed ? alphaToOpacity(parsed.a) : 100,
682
+ };
683
+ }
684
+
685
+ function readLeadingColor(part: string): { raw: string; value: string } | null {
686
+ const trimmed = part.trim();
687
+ const hex = trimmed.match(/^#[0-9a-f]{3,8}\b/i);
688
+ if (hex) return { raw: hex[0], value: hex[0] };
689
+ const transparent = trimmed.match(/^transparent\b/i);
690
+ if (transparent) {
691
+ return { raw: transparent[0], value: "rgba(0, 0, 0, 0)" };
692
+ }
693
+ const functionName = trimmed.match(/^(rgb|rgba|hsl|hsla)\(/i);
694
+ if (!functionName) return null;
695
+ let depth = 0;
696
+ for (let index = 0; index < trimmed.length; index += 1) {
697
+ const char = trimmed[index];
698
+ if (char === "(") depth += 1;
699
+ if (char === ")") {
700
+ depth -= 1;
701
+ if (depth === 0) {
702
+ const raw = trimmed.slice(0, index + 1);
703
+ return { raw, value: raw };
704
+ }
705
+ }
706
+ }
707
+ return null;
708
+ }
709
+
710
+ function gradientTypeFromCss(
711
+ functionName: string,
712
+ layer: string,
713
+ ): FigmaGradientType {
714
+ if (functionName.toLowerCase() === "conic") return "angular";
715
+ if (/closest-corner/i.test(layer)) return "diamond";
716
+ if (functionName.toLowerCase() === "radial") return "radial";
717
+ return "linear";
718
+ }
719
+
720
+ function gradientLabel(type: FigmaGradientType): string {
721
+ if (type === "radial") {
722
+ return "Radial gradient"; // i18n-ignore Figma inspector paint row
723
+ }
724
+ if (type === "angular") {
725
+ return "Angular gradient"; // i18n-ignore Figma inspector paint row
726
+ }
727
+ if (type === "diamond") {
728
+ return "Diamond gradient"; // i18n-ignore Figma inspector paint row
729
+ }
730
+ return "Linear gradient"; // i18n-ignore Figma inspector paint row
731
+ }
732
+
733
+ function buildGradientLayer(
734
+ type: FigmaGradientType,
735
+ stops: FigmaGradientStop[],
736
+ ): string {
737
+ const stopList = [...stops]
738
+ .sort((a, b) => a.position - b.position)
739
+ .map((stop) => {
740
+ const parsed = parseCssColor(stop.color);
741
+ const opacity = stop.opacity ?? (parsed ? alphaToOpacity(parsed.a) : 100);
742
+ const color = parsed
743
+ ? rgbaToCss(withColorOpacity(parsed, opacity))
744
+ : stop.color;
745
+ return `${color} ${clampNumber(stop.position, 0, 100)}%`;
746
+ })
747
+ .join(", ");
748
+
749
+ if (type === "radial") {
750
+ return `radial-gradient(circle at 50% 50%, ${stopList})`;
751
+ }
752
+ if (type === "angular") {
753
+ return `conic-gradient(from 0deg at 50% 50%, ${stopList})`;
754
+ }
755
+ if (type === "diamond") {
756
+ return `radial-gradient(closest-corner at 50% 50%, ${stopList})`;
175
757
  }
176
- return "#000000";
758
+ return `linear-gradient(90deg, ${stopList})`;
759
+ }
760
+
761
+ function defaultGradientStops(colorValue: string): FigmaGradientStop[] {
762
+ const parsed =
763
+ parseCssColor(cssColorOrFallback(colorValue, "#000000")) ??
764
+ parseCssColor("#000000");
765
+ const start = parsed ? rgbaToCss(withColorOpacity(parsed, 100)) : "#000000";
766
+ const end = parsed
767
+ ? rgbaToCss(withColorOpacity(parsed, 0))
768
+ : "rgba(0, 0, 0, 0)";
769
+
770
+ return [
771
+ { id: "stop-0", color: start, position: 0, opacity: 100 },
772
+ { id: "stop-1", color: end, position: 100, opacity: 0 },
773
+ ];
774
+ }
775
+
776
+ function defaultGradientLayer(type: FigmaGradientType, colorValue: string) {
777
+ return buildGradientLayer(type, defaultGradientStops(colorValue));
778
+ }
779
+
780
+ function clampNumber(value: number, min: number, max: number): number {
781
+ if (!Number.isFinite(value)) return min;
782
+ return Math.max(min, Math.min(max, Math.round(value)));
177
783
  }
178
784
 
179
785
  /** Select dropdown */
@@ -189,17 +795,19 @@ function PropSelect({
189
795
  options: { value: string; label: string }[];
190
796
  }) {
191
797
  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>
798
+ <div className="flex items-center gap-1.5">
799
+ <FieldLabel>{label}</FieldLabel>
196
800
  <Select value={value} onValueChange={onChange}>
197
- <SelectTrigger className="h-7 text-xs">
801
+ <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
802
  <SelectValue />
199
803
  </SelectTrigger>
200
804
  <SelectContent>
201
805
  {options.map((opt) => (
202
- <SelectItem key={opt.value} value={opt.value} className="text-xs">
806
+ <SelectItem
807
+ key={opt.value}
808
+ value={opt.value}
809
+ className="text-[11px]"
810
+ >
203
811
  {opt.label}
204
812
  </SelectItem>
205
813
  ))}
@@ -228,10 +836,8 @@ function PropSlider({
228
836
  unit?: string;
229
837
  }) {
230
838
  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>
839
+ <div className="flex items-center gap-1.5">
840
+ <FieldLabel>{label}</FieldLabel>
235
841
  <Slider
236
842
  value={[value]}
237
843
  onValueChange={([v]) => onChange(v)}
@@ -240,7 +846,7 @@ function PropSlider({
240
846
  step={step}
241
847
  className="flex-1"
242
848
  />
243
- <span className="text-xs text-muted-foreground w-12 text-right tabular-nums">
849
+ <span className="w-12 text-right text-[11px] tabular-nums text-muted-foreground">
244
850
  {value}
245
851
  {unit}
246
852
  </span>
@@ -248,55 +854,72 @@ function PropSlider({
248
854
  );
249
855
  }
250
856
 
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
- }
857
+ const EmptyScrubIcon = () => null;
258
858
 
259
- function FourSideCell({
260
- side,
261
- placeholder,
262
- value,
263
- onChange,
859
+ /**
860
+ * Figma-style inspector section. Matches Figma's real "Design" panel chrome:
861
+ * - NO left collapse chevron (Figma has none).
862
+ * - A thin divider line above each section.
863
+ * - A bold left-aligned title.
864
+ * - Right-aligned action icons (add layer, toggles, styles, etc.).
865
+ *
866
+ * The title is still clickable to collapse the body (Figma collapses sections
867
+ * on title click) but renders no chevron glyph, just like Figma.
868
+ */
869
+ function PanelSection({
870
+ title,
871
+ actions,
872
+ children,
873
+ defaultCollapsed = false,
264
874
  }: {
265
- side: string;
266
- placeholder: string;
267
- value: string;
268
- onChange: (side: string, value: string) => void;
875
+ title: string;
876
+ actions?: ReactNode;
877
+ children?: ReactNode;
878
+ defaultCollapsed?: boolean;
269
879
  }) {
270
- const [draft, setDraft] = useState(value);
271
- useEffect(() => {
272
- setDraft(value);
273
- }, [value]);
880
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
274
881
 
275
- const commit = () => {
276
- const next = normalizeLengthValue(draft, "px");
277
- if (next !== draft) setDraft(next);
278
- if (next !== value) onChange(side, next);
279
- };
882
+ return (
883
+ <section className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0">
884
+ <div className="flex min-h-9 items-center gap-2 px-3">
885
+ <button
886
+ type="button"
887
+ className="flex min-w-0 flex-1 cursor-pointer items-center bg-transparent text-left"
888
+ onClick={() => setCollapsed((c) => !c)}
889
+ aria-expanded={!collapsed}
890
+ >
891
+ <h3 className="min-w-0 flex-1 truncate text-[11px] font-semibold text-foreground">
892
+ {title}
893
+ </h3>
894
+ </button>
895
+ {actions ? (
896
+ <div className="flex shrink-0 items-center gap-0.5">{actions}</div>
897
+ ) : null}
898
+ </div>
899
+ {!collapsed && children ? (
900
+ <div className="space-y-1.5 px-3 pb-3 pt-0.5 text-[11px]">
901
+ {children}
902
+ </div>
903
+ ) : null}
904
+ </section>
905
+ );
906
+ }
280
907
 
908
+ function FieldLabel({ children }: { children: ReactNode }) {
281
909
  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
- />
910
+ <Label className="w-[64px] shrink-0 text-[11px] font-medium text-muted-foreground">
911
+ {children}
912
+ </Label>
913
+ );
914
+ }
915
+
916
+ function SubsectionLabel({ children }: { children: ReactNode }) {
917
+ return (
918
+ <p className="text-[11px] font-medium text-muted-foreground">{children}</p>
296
919
  );
297
920
  }
298
921
 
299
- function FourSideInput({
922
+ function FigmaSpacingControl({
300
923
  label,
301
924
  values,
302
925
  onChange,
@@ -306,39 +929,128 @@ function FourSideInput({
306
929
  onChange: (side: string, value: string) => void;
307
930
  }) {
308
931
  const t = useT();
932
+ const [linked, setLinked] = useState(() => sidesAreLinked(values));
933
+ const numeric = {
934
+ top: parseNumericValue(values.top || "0"),
935
+ right: parseNumericValue(values.right || "0"),
936
+ bottom: parseNumericValue(values.bottom || "0"),
937
+ left: parseNumericValue(values.left || "0"),
938
+ };
939
+ const linkedValue = Math.round(
940
+ (numeric.top + numeric.right + numeric.bottom + numeric.left) / 4,
941
+ );
942
+ const setSide = (
943
+ side: "Top" | "Right" | "Bottom" | "Left",
944
+ value: number,
945
+ ) => {
946
+ onChange(side, `${Math.round(value)}px`);
947
+ };
948
+ const setAll = (value: number) => {
949
+ (["Top", "Right", "Bottom", "Left"] as const).forEach((side) =>
950
+ setSide(side, value),
951
+ );
952
+ };
953
+ const linkedLabel = linked
954
+ ? t("editPanel.labels.unlinkSides")
955
+ : t("editPanel.labels.linkSides");
956
+
309
957
  return (
310
958
  <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
- />
959
+ <div className="flex items-center justify-between gap-1.5">
960
+ <Label className="text-[11px] font-medium text-muted-foreground">
961
+ {label}
962
+ </Label>
963
+ <Tooltip>
964
+ <TooltipTrigger asChild>
965
+ <Button
966
+ type="button"
967
+ variant="ghost"
968
+ size="icon"
969
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
970
+ onClick={() => setLinked((current) => !current)}
971
+ aria-label={linkedLabel}
972
+ >
973
+ {linked ? (
974
+ <IconLink className="size-3.5" />
975
+ ) : (
976
+ <IconUnlink className="size-3.5" />
977
+ )}
978
+ </Button>
979
+ </TooltipTrigger>
980
+ <TooltipContent>{linkedLabel}</TooltipContent>
981
+ </Tooltip>
337
982
  </div>
983
+ {linked ? (
984
+ <ScrubInput
985
+ label={t("editPanel.labels.allSides")}
986
+ value={linkedValue}
987
+ onChange={setAll}
988
+ unit="px"
989
+ min={0}
990
+ precision={1}
991
+ labelClassName="w-16"
992
+ inputClassName="h-6"
993
+ />
994
+ ) : (
995
+ <div className="grid grid-cols-2 gap-1.5">
996
+ <ScrubInput
997
+ label={t("editPanel.sidePlaceholders.top")}
998
+ value={numeric.top}
999
+ onChange={(value) => setSide("Top", value)}
1000
+ unit="px"
1001
+ min={0}
1002
+ precision={1}
1003
+ inputClassName="h-6"
1004
+ />
1005
+ <ScrubInput
1006
+ label={t("editPanel.sidePlaceholders.right")}
1007
+ value={numeric.right}
1008
+ onChange={(value) => setSide("Right", value)}
1009
+ unit="px"
1010
+ min={0}
1011
+ precision={1}
1012
+ inputClassName="h-6"
1013
+ />
1014
+ <ScrubInput
1015
+ label={t("editPanel.sidePlaceholders.bottom")}
1016
+ value={numeric.bottom}
1017
+ onChange={(value) => setSide("Bottom", value)}
1018
+ unit="px"
1019
+ min={0}
1020
+ precision={1}
1021
+ inputClassName="h-6"
1022
+ />
1023
+ <ScrubInput
1024
+ label={t("editPanel.sidePlaceholders.left")}
1025
+ value={numeric.left}
1026
+ onChange={(value) => setSide("Left", value)}
1027
+ unit="px"
1028
+ min={0}
1029
+ precision={1}
1030
+ inputClassName="h-6"
1031
+ />
1032
+ </div>
1033
+ )}
338
1034
  </div>
339
1035
  );
340
1036
  }
341
1037
 
1038
+ function sidesAreLinked(values: {
1039
+ top: string;
1040
+ right: string;
1041
+ bottom: string;
1042
+ left: string;
1043
+ }) {
1044
+ return (
1045
+ parseNumericValue(values.top || "0") ===
1046
+ parseNumericValue(values.right || "0") &&
1047
+ parseNumericValue(values.top || "0") ===
1048
+ parseNumericValue(values.bottom || "0") &&
1049
+ parseNumericValue(values.top || "0") ===
1050
+ parseNumericValue(values.left || "0")
1051
+ );
1052
+ }
1053
+
342
1054
  const FONT_FAMILY_OPTIONS = [
343
1055
  { value: "inherit", key: "inherit" },
344
1056
  { value: "sans-serif", key: "sansSerif" },
@@ -362,224 +1074,1682 @@ const FONT_WEIGHT_OPTIONS = [
362
1074
  { value: "900", key: "black" },
363
1075
  ] as const;
364
1076
 
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 = [
1077
+ const ALIGN_SELF_OPTIONS = [
1078
+ { value: "auto", key: "auto" },
386
1079
  { value: "flex-start", key: "start" },
387
1080
  { value: "center", key: "center" },
388
1081
  { value: "flex-end", key: "end" },
389
1082
  { value: "stretch", key: "stretch" },
390
1083
  { value: "baseline", key: "baseline" },
391
1084
  ] as const;
1085
+ const STROKE_POSITION_OPTIONS = [
1086
+ { value: "inside", key: "inside" },
1087
+ { value: "center", key: "center" },
1088
+ { value: "outside", key: "outside" },
1089
+ ] as const;
1090
+ const BLEND_MODE_OPTIONS = [
1091
+ { value: "normal", label: "Normal" },
1092
+ { value: "multiply", label: "Multiply" },
1093
+ { value: "screen", label: "Screen" },
1094
+ { value: "overlay", label: "Overlay" },
1095
+ { value: "darken", label: "Darken" },
1096
+ { value: "lighten", label: "Lighten" },
1097
+ { value: "color-dodge", label: "Color dodge" }, // i18n-ignore Figma blend mode label
1098
+ { value: "color-burn", label: "Color burn" }, // i18n-ignore Figma blend mode label
1099
+ { value: "hard-light", label: "Hard light" }, // i18n-ignore Figma blend mode label
1100
+ { value: "soft-light", label: "Soft light" }, // i18n-ignore Figma blend mode label
1101
+ { value: "difference", label: "Difference" },
1102
+ { value: "exclusion", label: "Exclusion" },
1103
+ { value: "hue", label: "Hue" },
1104
+ { value: "saturation", label: "Saturation" },
1105
+ { value: "color", label: "Color" },
1106
+ { value: "luminosity", label: "Luminosity" },
1107
+ ] as const;
392
1108
 
393
1109
  function parseNumericValue(value: string): number {
394
1110
  return parseFloat(value) || 0;
395
1111
  }
396
1112
 
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>
1113
+ function parseRotationValue(transform: string | undefined): number {
1114
+ const match = transform?.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
1115
+ return match ? Number(match[1]) : 0;
1116
+ }
429
1117
 
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
- );
1118
+ function mergeRotationValue(transform: string | undefined, degrees: number) {
1119
+ const nextRotate = `rotate(${Math.round(degrees * 10) / 10}deg)`;
1120
+ if (!transform || transform === "none") return nextRotate;
1121
+ if (/rotate\((-?\d+(?:\.\d+)?)deg\)/.test(transform)) {
1122
+ return transform.replace(/rotate\((-?\d+(?:\.\d+)?)deg\)/, nextRotate);
1123
+ }
1124
+ return `${transform} ${nextRotate}`;
451
1125
  }
452
1126
 
453
- /** Text element properties */
454
- function TextProperties({
455
- element,
456
- onStyleChange,
1127
+ function ScrubStyleInput({
1128
+ label,
1129
+ value,
1130
+ placeholder,
1131
+ onChange,
1132
+ unit = "px",
1133
+ min,
1134
+ max,
1135
+ step = 1,
1136
+ labelClassName,
1137
+ inputClassName,
1138
+ hideIcon = true,
457
1139
  }: {
458
- element: ElementInfo;
459
- onStyleChange: (property: string, value: string) => void;
1140
+ label: string;
1141
+ value: string;
1142
+ placeholder?: number;
1143
+ onChange: (value: number) => void;
1144
+ unit?: string;
1145
+ min?: number;
1146
+ max?: number;
1147
+ step?: number;
1148
+ labelClassName?: string;
1149
+ inputClassName?: string;
1150
+ hideIcon?: boolean;
460
1151
  }) {
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
1152
  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>
1153
+ <ScrubInput
1154
+ label={label}
1155
+ icon={hideIcon ? EmptyScrubIcon : undefined}
1156
+ value={value ? parseNumericValue(value) : (placeholder ?? 0)}
1157
+ onChange={onChange}
1158
+ unit={unit}
1159
+ min={min}
1160
+ max={max}
1161
+ step={step}
1162
+ precision={1}
1163
+ className="gap-0"
1164
+ labelClassName={cn(
1165
+ "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",
1166
+ labelClassName,
1167
+ )}
1168
+ inputClassName={cn(
1169
+ "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)]",
1170
+ inputClassName,
1171
+ )}
1172
+ />
523
1173
  );
524
1174
  }
525
1175
 
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
- }));
1176
+ function commitStylePatch(
1177
+ styles: Record<string, string>,
1178
+ onStyleChange: (property: string, value: string) => void,
1179
+ onStylesChange?: (styles: Record<string, string>) => void,
1180
+ ) {
1181
+ if (onStylesChange) {
1182
+ onStylesChange(styles);
1183
+ return;
1184
+ }
1185
+ Object.entries(styles).forEach(([property, value]) => {
1186
+ onStyleChange(property, value);
1187
+ });
1188
+ }
1189
+
1190
+ function optionValue<T extends readonly { value: string }[]>(
1191
+ options: T,
1192
+ value: string | undefined,
1193
+ fallback: T[number]["value"],
1194
+ ) {
1195
+ return options.some((option) => option.value === value) ? value! : fallback;
1196
+ }
1197
+
1198
+ function cssLengthNumber(value: string | undefined, fallback = 0): number {
1199
+ const parsed = parseFloat(value || "");
1200
+ return Number.isFinite(parsed) ? parsed : fallback;
1201
+ }
1202
+
1203
+ function cssColorOrFallback(value: string | undefined, fallback: string) {
1204
+ const normalized = value?.trim();
1205
+ if (
1206
+ !normalized ||
1207
+ normalized === "transparent" ||
1208
+ normalized === "rgba(0, 0, 0, 0)"
1209
+ ) {
1210
+ return fallback;
1211
+ }
1212
+ return normalized;
1213
+ }
1214
+
1215
+ function strokeIsVisible(width: string | undefined, style: string | undefined) {
1216
+ return cssLengthNumber(width) > 0 && style !== "none";
1217
+ }
1218
+
1219
+ function swatchStyle(value: string | undefined) {
1220
+ return {
1221
+ background:
1222
+ value && value !== "none"
1223
+ ? value
1224
+ : "linear-gradient(135deg, hsl(var(--muted)) 0 45%, hsl(var(--border)) 45% 55%, hsl(var(--muted)) 55% 100%)",
1225
+ };
1226
+ }
1227
+
1228
+ function compactCssValue(value: string | undefined, fallback: string) {
1229
+ const normalized = value?.trim();
1230
+ if (!normalized || normalized === "none") return fallback;
1231
+ return normalized;
1232
+ }
1233
+
1234
+ function colorHasVisibleAlpha(value: string | undefined): boolean {
1235
+ const parsed = parseCssColor(value || "");
1236
+ if (!parsed) return Boolean(value && value !== "transparent");
1237
+ return parsed.a > 0;
1238
+ }
1239
+
1240
+ function inspectorObjectTitle(element: ElementInfo): string {
1241
+ const tag = element.tagName || "element";
1242
+ if (TEXT_TAGS.has(tag)) return "Text";
1243
+ if (tag === "section") return "Section";
1244
+ if (tag === "img" || tag === "video" || tag === "picture") return "Image";
1245
+ if (tag === "button" || tag === "a") return "Button";
1246
+ if (tag === "svg" || tag === "path") return "Vector";
1247
+ if (element.isFlexContainer || tag === "div" || tag === "main") {
1248
+ return "Frame";
1249
+ }
1250
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
1251
+ }
1252
+
1253
+ function displayLabel(value: string | undefined): string {
1254
+ const normalized = value?.trim();
1255
+ if (!normalized || normalized === "normal") return "flow";
1256
+ return normalized;
1257
+ }
1258
+
1259
+ function justifyToHorizontal(
1260
+ value: string | undefined,
1261
+ ): AlignmentMatrixValue["horizontal"] {
1262
+ if (value === "center") return "center";
1263
+ if (value === "flex-end" || value === "end" || value === "right") {
1264
+ return "right";
1265
+ }
1266
+ return "left";
1267
+ }
1268
+
1269
+ function alignToVertical(
1270
+ value: string | undefined,
1271
+ ): AlignmentMatrixValue["vertical"] {
1272
+ if (value === "center") return "middle";
1273
+ if (value === "flex-end" || value === "end" || value === "bottom") {
1274
+ return "bottom";
1275
+ }
1276
+ return "top";
1277
+ }
1278
+
1279
+ function horizontalToJustify(
1280
+ value: AlignmentMatrixValue["horizontal"],
1281
+ ): string {
1282
+ if (value === "center") return "center";
1283
+ if (value === "right") return "flex-end";
1284
+ return "flex-start";
1285
+ }
1286
+
1287
+ function verticalToAlign(value: AlignmentMatrixValue["vertical"]): string {
1288
+ if (value === "middle") return "center";
1289
+ if (value === "bottom") return "flex-end";
1290
+ return "flex-start";
1291
+ }
1292
+
1293
+ function autoLayoutAlignmentFromStyles(
1294
+ styles: Record<string, string>,
1295
+ direction: AutoLayoutMatrixValue["direction"],
1296
+ ): AlignmentMatrixValue {
1297
+ if (direction === "vertical") {
1298
+ return {
1299
+ horizontal: justifyToHorizontal(styles.alignItems),
1300
+ vertical: alignToVertical(styles.justifyContent),
1301
+ };
1302
+ }
1303
+ return {
1304
+ horizontal: justifyToHorizontal(styles.justifyContent),
1305
+ vertical: alignToVertical(styles.alignItems),
1306
+ };
1307
+ }
1308
+
1309
+ /**
1310
+ * Block-level container tags that act like Figma frames. Selecting any of
1311
+ * these shows the Auto layout section (in an "add" state when not yet flex),
1312
+ * mirroring Figma where any frame/container exposes auto-layout controls.
1313
+ */
1314
+ const CONTAINER_TAGS = new Set([
1315
+ "div",
1316
+ "section",
1317
+ "main",
1318
+ "header",
1319
+ "footer",
1320
+ "nav",
1321
+ "article",
1322
+ "aside",
1323
+ "form",
1324
+ "ul",
1325
+ "ol",
1326
+ "figure",
1327
+ "fieldset",
1328
+ "details",
1329
+ "dialog",
1330
+ "blockquote",
1331
+ "table",
1332
+ "tbody",
1333
+ "thead",
1334
+ "tr",
1335
+ ]);
1336
+
1337
+ /** Leaf tags that never get auto-layout (text, media, vectors, controls). */
1338
+ const LEAF_TAGS = new Set([
1339
+ "img",
1340
+ "video",
1341
+ "picture",
1342
+ "audio",
1343
+ "canvas",
1344
+ "svg",
1345
+ "path",
1346
+ "input",
1347
+ "textarea",
1348
+ "select",
1349
+ "br",
1350
+ "hr",
1351
+ "iframe",
1352
+ ]);
1353
+
1354
+ /**
1355
+ * Whether the element should expose the Auto layout section. True for anything
1356
+ * already laid out with flexbox, or any block-level container tag that isn't a
1357
+ * known leaf/text element. This is what makes a plain frame/container with
1358
+ * children show the full Auto layout section like Figma does.
1359
+ */
1360
+ function isContainerElement(element: ElementInfo): boolean {
1361
+ if (element.isFlexContainer) return true;
1362
+ const tag = (element.tagName || "").toLowerCase();
1363
+ if (TEXT_TAGS.has(tag) || LEAF_TAGS.has(tag)) return false;
1364
+ return CONTAINER_TAGS.has(tag);
1365
+ }
1366
+
1367
+ function isParentFlex(element: ElementInfo): boolean {
1368
+ return (
1369
+ element.isFlexChild ||
1370
+ Boolean(element.parentDisplay?.toLowerCase().includes("flex"))
1371
+ );
1372
+ }
1373
+
1374
+ function isParentGrid(element: ElementInfo): boolean {
1375
+ return Boolean(element.parentDisplay?.toLowerCase().includes("grid"));
1376
+ }
1377
+
1378
+ function parentFlexDirection(element: ElementInfo): AutoLayoutSizingAxis {
1379
+ return element.parentLayout?.flexDirection?.includes("column")
1380
+ ? "vertical"
1381
+ : "horizontal";
1382
+ }
1383
+
1384
+ function isTextElement(element: ElementInfo): boolean {
1385
+ return TEXT_TAGS.has((element.tagName || "").toLowerCase());
1386
+ }
1387
+
1388
+ /**
1389
+ * Per-axis sizing availability following Figma's contextual rules:
1390
+ * - Fixed: always.
1391
+ * - Hug contents: only CONTAINERS (flex/container frames) and TEXT can hug
1392
+ * their content. Leaves like img/svg/input cannot.
1393
+ * - Fill container: only when the element is a CHILD of a flex/grid (auto
1394
+ * layout) parent, OR a block-flow child (which fills via width:100%).
1395
+ * Hug applies to width and height independently; the same set is offered on
1396
+ * both axes here and the per-axis CSS in `commitElementSizing` resolves the
1397
+ * exact behavior (main-axis grow vs cross-axis stretch).
1398
+ */
1399
+ function availableSizingForElement(
1400
+ element: ElementInfo,
1401
+ ): Partial<Record<AutoLayoutSizingAxis, AutoLayoutSizing[]>> {
1402
+ const canHug = isContainerElement(element) || isTextElement(element);
1403
+ const isFlexChildEl = isParentFlex(element) || isParentGrid(element);
1404
+ // Block-flow children can still "fill" via width:100% on the horizontal axis.
1405
+ const isBlockChild = Boolean(element.parentDisplay) && !isFlexChildEl;
1406
+
1407
+ const buildAxis = (axis: AutoLayoutSizingAxis): AutoLayoutSizing[] => {
1408
+ const options: AutoLayoutSizing[] = ["fixed"];
1409
+ if (canHug) options.push("hug");
1410
+ // Fill: flex/grid child on either axis; block child only fills width.
1411
+ if (isFlexChildEl || (isBlockChild && axis === "horizontal")) {
1412
+ options.push("fill");
1413
+ }
1414
+ return options;
1415
+ };
1416
+
1417
+ return {
1418
+ horizontal: buildAxis("horizontal"),
1419
+ vertical: buildAxis("vertical"),
1420
+ };
1421
+ }
1422
+
1423
+ /** Read the currently-set min/max constraints (px) for a sizing axis. */
1424
+ function readElementMinMax(
1425
+ element: ElementInfo,
1426
+ axis: AutoLayoutSizingAxis,
1427
+ ): { min: number | null; max: number | null } {
1428
+ const styles = element.computedStyles;
1429
+ const minRaw = axis === "horizontal" ? styles.minWidth : styles.minHeight;
1430
+ const maxRaw = axis === "horizontal" ? styles.maxWidth : styles.maxHeight;
1431
+ return {
1432
+ min: parseConstraintLength(minRaw),
1433
+ max: parseConstraintLength(maxRaw),
1434
+ };
1435
+ }
1436
+
1437
+ /**
1438
+ * Parse a min/max CSS length into a px number, or null when unset. Browser
1439
+ * computed values are "0px"/"none" for the defaults — both read as "not set"
1440
+ * so we don't surface a constraint sub-row the user never added.
1441
+ */
1442
+ function parseConstraintLength(value: string | undefined): number | null {
1443
+ const normalized = value?.trim().toLowerCase();
1444
+ if (
1445
+ !normalized ||
1446
+ normalized === "none" ||
1447
+ normalized === "auto" ||
1448
+ normalized === "0px" ||
1449
+ normalized === "0"
1450
+ ) {
1451
+ return null;
1452
+ }
1453
+ const parsed = Number.parseFloat(normalized);
1454
+ return Number.isFinite(parsed) ? parsed : null;
1455
+ }
1456
+
1457
+ /** Commit a single min/max constraint (px) or clear it when value is null. */
1458
+ function commitElementMinMax(
1459
+ axis: AutoLayoutSizingAxis,
1460
+ kind: "min" | "max",
1461
+ value: number | null,
1462
+ onStyleChange: (property: string, value: string) => void,
1463
+ ) {
1464
+ const isHorizontal = axis === "horizontal";
1465
+ const property =
1466
+ kind === "min"
1467
+ ? isHorizontal
1468
+ ? "minWidth"
1469
+ : "minHeight"
1470
+ : isHorizontal
1471
+ ? "maxWidth"
1472
+ : "maxHeight";
1473
+ if (value == null) {
1474
+ // Clearing: min → 0 (CSS initial), max → none (CSS initial).
1475
+ onStyleChange(property, kind === "min" ? "0px" : "none");
1476
+ return;
1477
+ }
1478
+ onStyleChange(property, `${Math.max(0, Math.round(value))}px`);
1479
+ }
1480
+
1481
+ function inferElementSizing(
1482
+ element: ElementInfo,
1483
+ axis: AutoLayoutSizingAxis,
1484
+ ): AutoLayoutSizing {
1485
+ const styles = element.computedStyles;
1486
+ const size = axis === "horizontal" ? styles.width : styles.height;
1487
+ const parentDirection = parentFlexDirection(element);
1488
+ const isFlex = isParentFlex(element);
1489
+ const isMainFlexAxis = isFlex && parentDirection === axis;
1490
+ const isCrossFlexAxis = isFlex && parentDirection !== axis;
1491
+ const alignSelf = (styles.alignSelf || "").toLowerCase();
1492
+
1493
+ if (
1494
+ size === "100%" ||
1495
+ (isMainFlexAxis && Number.parseFloat(styles.flexGrow || "0") > 0) ||
1496
+ (isCrossFlexAxis && alignSelf === "stretch")
1497
+ ) {
1498
+ return "fill";
1499
+ }
1500
+ if (size === "auto" || size === "fit-content" || size === "max-content") {
1501
+ return "hug";
1502
+ }
1503
+ return "fixed";
1504
+ }
1505
+
1506
+ function commitElementSizing(
1507
+ element: ElementInfo,
1508
+ axis: AutoLayoutSizingAxis,
1509
+ sizing: AutoLayoutSizing,
1510
+ onStyleChange: (property: string, value: string) => void,
1511
+ onStylesChange?: (styles: Record<string, string>) => void,
1512
+ ) {
1513
+ const isHorizontal = axis === "horizontal";
1514
+ const sizeProperty = isHorizontal ? "width" : "height";
1515
+ const resolvedSize = Math.max(
1516
+ 1,
1517
+ Math.round(
1518
+ isHorizontal ? element.boundingRect.width : element.boundingRect.height,
1519
+ ),
1520
+ );
1521
+ const parentDirection = parentFlexDirection(element);
1522
+ const isFlex = isParentFlex(element);
1523
+ const isGrid = isParentGrid(element);
1524
+ const isMainFlexAxis = isFlex && parentDirection === axis;
1525
+ const patch: Record<string, string> = {};
1526
+
1527
+ if (sizing === "fixed") {
1528
+ // Fixed → explicit px dimension. Reset any grow/stretch on the flex
1529
+ // main-axis so the pixel value sticks.
1530
+ patch[sizeProperty] = `${resolvedSize}px`;
1531
+ if (isMainFlexAxis) {
1532
+ patch.flexGrow = "0";
1533
+ patch.flexShrink = "0";
1534
+ patch.flexBasis = "auto";
1535
+ }
1536
+ } else if (sizing === "hug") {
1537
+ // Hug contents → shrink to fit children/content.
1538
+ patch[sizeProperty] = "fit-content";
1539
+ if (isMainFlexAxis) {
1540
+ // A flex container hugging on its main axis uses flex-basis:auto + no
1541
+ // stretch (spec: "flex-basis: auto + no stretch").
1542
+ patch.flexGrow = "0";
1543
+ patch.flexShrink = "0";
1544
+ patch.flexBasis = "auto";
1545
+ }
1546
+ } else {
1547
+ // Fill container.
1548
+ if (isMainFlexAxis) {
1549
+ // Parent main axis → grow into available space: flex: 1 0 0.
1550
+ patch.flexGrow = "1";
1551
+ patch.flexShrink = "0";
1552
+ patch.flexBasis = "0";
1553
+ // Clear any explicit dimension so flex-basis governs.
1554
+ patch[sizeProperty] = "auto";
1555
+ } else if (isFlex) {
1556
+ // Parent cross axis → stretch to the parent's cross size.
1557
+ patch.alignSelf = "stretch";
1558
+ patch[sizeProperty] = "auto";
1559
+ } else if (isGrid) {
1560
+ patch[isHorizontal ? "justifySelf" : "alignSelf"] = "stretch";
1561
+ patch[sizeProperty] = "auto";
1562
+ } else {
1563
+ // Child of a non-flex (block) parent → fill width with 100%.
1564
+ patch[sizeProperty] = "100%";
1565
+ }
1566
+ }
1567
+
1568
+ commitStylePatch(patch, onStyleChange, onStylesChange);
1569
+ }
1570
+
1571
+ function elementTypeIcon(element: ElementInfo) {
1572
+ const tag = element.tagName || "element";
1573
+ if (TEXT_TAGS.has(tag)) return IconTypography;
1574
+ if (tag === "img" || tag === "video" || tag === "picture") return IconPhoto;
1575
+ if (tag === "svg" || tag === "path") return IconVector;
1576
+ if (tag === "button" || tag === "a") return IconComponents;
1577
+ return IconFrame;
1578
+ }
1579
+
1580
+ function SelectionHeader({ element }: { element: ElementInfo | null }) {
1581
+ if (!element) return null;
1582
+
1583
+ const title = inspectorObjectTitle(element);
1584
+ const TypeIcon = elementTypeIcon(element);
1585
+
1586
+ return (
1587
+ <div className="flex min-h-8 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
1588
+ {/* M3 · Node-type label + rename/type dropdown affordance (▾) */}
1589
+ <button
1590
+ type="button"
1591
+ className="flex min-w-0 items-center gap-1.5 bg-transparent text-left text-[13px] font-semibold text-foreground"
1592
+ >
1593
+ <TypeIcon className="size-3.5 shrink-0 text-muted-foreground" />
1594
+ <span className="truncate">{title}</span>
1595
+ <IconChevronDown className="size-2.5 shrink-0 text-muted-foreground" />
1596
+ </button>
1597
+ {/* M3 · Right-aligned quick actions: create-component + dev inspect (</>) */}
1598
+ <div className="flex shrink-0 items-center gap-0.5">
1599
+ <SectionIconButton
1600
+ label={"Create component" /* i18n-ignore Figma inspector action */}
1601
+ >
1602
+ <IconComponents className="size-3.5" />
1603
+ </SectionIconButton>
1604
+ <SectionIconButton
1605
+ label={"Inspect code" /* i18n-ignore Figma inspector action */}
1606
+ >
1607
+ <IconCode className="size-3.5" />
1608
+ </SectionIconButton>
1609
+ </div>
1610
+ </div>
1611
+ );
1612
+ }
1613
+
1614
+ function InspectorTabsHeader({
1615
+ activeTab,
1616
+ onActiveTabChange,
1617
+ trailing,
1618
+ }: {
1619
+ activeTab: InspectorTab;
1620
+ onActiveTabChange: (tab: InspectorTab) => void;
1621
+ trailing?: ReactNode;
1622
+ }) {
1623
+ const t = useT();
1624
+
1625
+ return (
1626
+ <div className="flex min-h-8 shrink-0 items-center justify-between gap-1 border-b border-border/90 px-2">
1627
+ <Tabs
1628
+ value={activeTab}
1629
+ onValueChange={(value) => onActiveTabChange(value as InspectorTab)}
1630
+ >
1631
+ <TabsList className="h-7 justify-start gap-0.5 rounded-none bg-transparent p-0">
1632
+ <TabsTrigger
1633
+ value="design"
1634
+ className="h-6 rounded-md px-1.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"
1635
+ >
1636
+ {"Design" /* i18n-ignore Figma inspector tab */}
1637
+ </TabsTrigger>
1638
+ <TabsTrigger
1639
+ value="tweaks"
1640
+ className="h-6 rounded-md px-1.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"
1641
+ >
1642
+ {t("designEditor.tweaks")}
1643
+ </TabsTrigger>
1644
+ <TabsTrigger
1645
+ value="extensions"
1646
+ className="h-6 rounded-md px-1.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"
1647
+ >
1648
+ {t("designEditor.extensions")}
1649
+ </TabsTrigger>
1650
+ </TabsList>
1651
+ </Tabs>
1652
+ {trailing ? <div className="shrink-0">{trailing}</div> : null}
1653
+ </div>
1654
+ );
1655
+ }
1656
+
1657
+ function SectionIconButton({
1658
+ label,
1659
+ onClick,
1660
+ children,
1661
+ disabled = false,
1662
+ className,
1663
+ }: {
1664
+ label: string;
1665
+ onClick?: () => void;
1666
+ children: ReactNode;
1667
+ disabled?: boolean;
1668
+ className?: string;
1669
+ }) {
1670
+ return (
1671
+ <Tooltip>
1672
+ <TooltipTrigger asChild>
1673
+ <Button
1674
+ type="button"
1675
+ variant="ghost"
1676
+ size="icon"
1677
+ className={cn(
1678
+ "size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed",
1679
+ className,
1680
+ )}
1681
+ disabled={disabled}
1682
+ onClick={onClick}
1683
+ aria-label={label}
1684
+ >
1685
+ {children}
1686
+ </Button>
1687
+ </TooltipTrigger>
1688
+ <TooltipContent>{label}</TooltipContent>
1689
+ </Tooltip>
1690
+ );
1691
+ }
1692
+
1693
+ /**
1694
+ * Section-header toggle icon (Figma's right-aligned section actions, e.g. the
1695
+ * auto-layout ⊞ toggle). Highlights with the accent color when active.
1696
+ */
1697
+ function SectionIconToggle({
1698
+ label,
1699
+ active = false,
1700
+ onClick,
1701
+ children,
1702
+ }: {
1703
+ label: string;
1704
+ active?: boolean;
1705
+ onClick?: () => void;
1706
+ children: ReactNode;
1707
+ }) {
1708
+ return (
1709
+ <Tooltip>
1710
+ <TooltipTrigger asChild>
1711
+ <Button
1712
+ type="button"
1713
+ variant="ghost"
1714
+ size="icon"
1715
+ aria-label={label}
1716
+ aria-pressed={active}
1717
+ onClick={onClick}
1718
+ className={cn(
1719
+ "size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground",
1720
+ active &&
1721
+ "bg-[var(--design-editor-accent-color)]/15 text-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
1722
+ )}
1723
+ >
1724
+ {children}
1725
+ </Button>
1726
+ </TooltipTrigger>
1727
+ <TooltipContent>{label}</TooltipContent>
1728
+ </Tooltip>
1729
+ );
1730
+ }
1731
+
1732
+ function InspectorIconButton({
1733
+ label,
1734
+ active,
1735
+ onClick,
1736
+ children,
1737
+ }: {
1738
+ label: string;
1739
+ active?: boolean;
1740
+ onClick?: () => void;
1741
+ children: ReactNode;
1742
+ }) {
1743
+ return (
1744
+ <Tooltip>
1745
+ <TooltipTrigger asChild>
1746
+ <Button
1747
+ type="button"
1748
+ variant="ghost"
1749
+ size="icon"
1750
+ className={cn(
1751
+ "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",
1752
+ active &&
1753
+ "bg-[var(--design-editor-panel-bg)] text-[var(--design-editor-accent-color)] shadow-[inset_0_0_0_1px_var(--design-editor-control-border)]",
1754
+ )}
1755
+ onClick={onClick}
1756
+ aria-label={label}
1757
+ >
1758
+ {children}
1759
+ </Button>
1760
+ </TooltipTrigger>
1761
+ <TooltipContent>{label}</TooltipContent>
1762
+ </Tooltip>
1763
+ );
1764
+ }
1765
+
1766
+ function InspectorSegment({ children }: { children: ReactNode }) {
1767
+ return (
1768
+ <div className="flex min-w-0 overflow-hidden rounded-md bg-[var(--design-editor-control-bg)]">
1769
+ {children}
1770
+ </div>
1771
+ );
1772
+ }
1773
+
1774
+ function CornerRadiusControl({
1775
+ styles,
1776
+ onStyleChange,
1777
+ }: {
1778
+ styles: Record<string, string>;
1779
+ onStyleChange: (property: string, value: string) => void;
1780
+ }) {
1781
+ const t = useT();
1782
+ const independentCornersLabel = t("editPanel.labels.independentCorners");
1783
+ const radius = cssLengthNumber(styles.borderRadius || "0");
1784
+ const corners = {
1785
+ topLeft: cssLengthNumber(styles.borderTopLeftRadius || styles.borderRadius),
1786
+ topRight: cssLengthNumber(
1787
+ styles.borderTopRightRadius || styles.borderRadius,
1788
+ ),
1789
+ bottomRight: cssLengthNumber(
1790
+ styles.borderBottomRightRadius || styles.borderRadius,
1791
+ ),
1792
+ bottomLeft: cssLengthNumber(
1793
+ styles.borderBottomLeftRadius || styles.borderRadius,
1794
+ ),
1795
+ };
1796
+
1797
+ return (
1798
+ <div className="grid grid-cols-[1fr_auto] items-center gap-1.5">
1799
+ <ScrubInput
1800
+ label={t("editPanel.labels.cornerRadius")}
1801
+ value={radius}
1802
+ onChange={(value) =>
1803
+ onStyleChange("borderRadius", `${Math.max(0, Math.round(value))}px`)
1804
+ }
1805
+ unit="px"
1806
+ min={0}
1807
+ precision={1}
1808
+ labelClassName="w-24"
1809
+ inputClassName="h-6"
1810
+ />
1811
+ <Popover>
1812
+ <Tooltip>
1813
+ <TooltipTrigger asChild>
1814
+ <PopoverTrigger asChild>
1815
+ <Button
1816
+ type="button"
1817
+ variant="outline"
1818
+ size="icon"
1819
+ className="size-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]"
1820
+ aria-label={independentCornersLabel}
1821
+ >
1822
+ <IconMaximize className="size-3.5" />
1823
+ </Button>
1824
+ </PopoverTrigger>
1825
+ </TooltipTrigger>
1826
+ <TooltipContent>{independentCornersLabel}</TooltipContent>
1827
+ </Tooltip>
1828
+ <PopoverContent
1829
+ side="left"
1830
+ align="start"
1831
+ sideOffset={8}
1832
+ className="w-64 p-3"
1833
+ >
1834
+ <div className="space-y-2">
1835
+ <p className="text-xs font-semibold text-foreground">
1836
+ {independentCornersLabel}
1837
+ </p>
1838
+ <div className="grid grid-cols-2 gap-2">
1839
+ <ScrubInput
1840
+ label={t("editPanel.labels.topLeft")}
1841
+ value={corners.topLeft}
1842
+ onChange={(value) =>
1843
+ onStyleChange(
1844
+ "borderTopLeftRadius",
1845
+ `${Math.max(0, Math.round(value))}px`,
1846
+ )
1847
+ }
1848
+ unit="px"
1849
+ min={0}
1850
+ precision={1}
1851
+ inputClassName="h-6"
1852
+ />
1853
+ <ScrubInput
1854
+ label={t("editPanel.labels.topRight")}
1855
+ value={corners.topRight}
1856
+ onChange={(value) =>
1857
+ onStyleChange(
1858
+ "borderTopRightRadius",
1859
+ `${Math.max(0, Math.round(value))}px`,
1860
+ )
1861
+ }
1862
+ unit="px"
1863
+ min={0}
1864
+ precision={1}
1865
+ inputClassName="h-6"
1866
+ />
1867
+ <ScrubInput
1868
+ label={t("editPanel.labels.bottomLeft")}
1869
+ value={corners.bottomLeft}
1870
+ onChange={(value) =>
1871
+ onStyleChange(
1872
+ "borderBottomLeftRadius",
1873
+ `${Math.max(0, Math.round(value))}px`,
1874
+ )
1875
+ }
1876
+ unit="px"
1877
+ min={0}
1878
+ precision={1}
1879
+ inputClassName="h-6"
1880
+ />
1881
+ <ScrubInput
1882
+ label={t("editPanel.labels.bottomRight")}
1883
+ value={corners.bottomRight}
1884
+ onChange={(value) =>
1885
+ onStyleChange(
1886
+ "borderBottomRightRadius",
1887
+ `${Math.max(0, Math.round(value))}px`,
1888
+ )
1889
+ }
1890
+ unit="px"
1891
+ min={0}
1892
+ precision={1}
1893
+ inputClassName="h-6"
1894
+ />
1895
+ </div>
1896
+ </div>
1897
+ </PopoverContent>
1898
+ </Popover>
1899
+ </div>
1900
+ );
1901
+ }
1902
+
1903
+ type StrokeLayerKind = "border" | "outline";
1904
+
1905
+ function StrokeLayerControl({
1906
+ kind,
1907
+ visible,
1908
+ color,
1909
+ width,
1910
+ styleValue,
1911
+ onStyleChange,
1912
+ onRemove,
1913
+ }: {
1914
+ kind: StrokeLayerKind;
1915
+ visible: boolean;
1916
+ color: string;
1917
+ width: string;
1918
+ styleValue: string;
1919
+ onStyleChange: (property: string, value: string) => void;
1920
+ onRemove: () => void;
1921
+ }) {
1922
+ const t = useT();
1923
+ const strokePositionOptions = STROKE_POSITION_OPTIONS.map((option) => ({
1924
+ value: option.value,
1925
+ label:
1926
+ option.key === "center"
1927
+ ? t("editPanel.textAligns.center")
1928
+ : t(`editPanel.labels.${option.key}`),
1929
+ }));
1930
+ const prefix = kind === "border" ? "border" : "outline";
1931
+ const position = kind === "border" ? "inside" : "outside";
1932
+
1933
+ const movePosition = (next: string) => {
1934
+ if (next === position) return;
1935
+ const nextPrefix = next === "outside" ? "outline" : "border";
1936
+ onStyleChange(`${nextPrefix}Color`, color);
1937
+ onStyleChange(`${nextPrefix}Width`, width || "1px");
1938
+ onStyleChange(
1939
+ `${nextPrefix}Style`,
1940
+ styleValue === "none" ? "solid" : styleValue,
1941
+ );
1942
+ onRemove();
1943
+ };
1944
+
1945
+ return (
1946
+ <div className="space-y-1.5">
1947
+ {/* Figma stroke row: [swatch+hex trigger (flex-1)] [eye] [remove] */}
1948
+ <div className="group flex items-center gap-1.5">
1949
+ <div className="min-w-0 flex-1">
1950
+ <ColorInput
1951
+ label=""
1952
+ value={cssColorOrFallback(color, "#000000")}
1953
+ onChange={(value) => onStyleChange(`${prefix}Color`, value)}
1954
+ />
1955
+ </div>
1956
+ <SectionIconButton
1957
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
1958
+ label={
1959
+ visible
1960
+ ? t("editPanel.labels.hideLayer")
1961
+ : t("editPanel.labels.showLayer")
1962
+ }
1963
+ onClick={() => {
1964
+ if (visible) {
1965
+ onStyleChange(`${prefix}Style`, "none");
1966
+ return;
1967
+ }
1968
+ onStyleChange(`${prefix}Style`, "solid");
1969
+ onStyleChange(
1970
+ `${prefix}Width`,
1971
+ width === "0px" ? "1px" : width || "1px",
1972
+ );
1973
+ }}
1974
+ >
1975
+ {visible ? (
1976
+ <IconEye className="size-3.5" />
1977
+ ) : (
1978
+ <IconEyeOff className="size-3.5" />
1979
+ )}
1980
+ </SectionIconButton>
1981
+ <SectionIconButton
1982
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
1983
+ label={t("editPanel.labels.removeLayer")}
1984
+ onClick={onRemove}
1985
+ >
1986
+ <IconMinus className="size-3.5" />
1987
+ </SectionIconButton>
1988
+ </div>
1989
+ {/* Figma stroke geometry: position + weight side by side */}
1990
+ <div className="grid grid-cols-2 gap-1.5">
1991
+ <Select value={position} onValueChange={movePosition}>
1992
+ <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)]">
1993
+ <SelectValue />
1994
+ </SelectTrigger>
1995
+ <SelectContent>
1996
+ {strokePositionOptions.map((option) => (
1997
+ <SelectItem
1998
+ key={option.value}
1999
+ value={option.value}
2000
+ className="text-[11px]"
2001
+ >
2002
+ {option.label}
2003
+ </SelectItem>
2004
+ ))}
2005
+ </SelectContent>
2006
+ </Select>
2007
+ <ScrubInput
2008
+ label={t("editPanel.labels.weight")}
2009
+ ariaLabel={t("editPanel.labels.weight")}
2010
+ icon={IconBorderStyle}
2011
+ value={cssLengthNumber(width)}
2012
+ onChange={(value) =>
2013
+ onStyleChange(
2014
+ `${prefix}Width`,
2015
+ `${Math.max(0, Math.round(value))}px`,
2016
+ )
2017
+ }
2018
+ unit="px"
2019
+ min={0}
2020
+ precision={1}
2021
+ className="gap-0"
2022
+ 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"
2023
+ 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)]"
2024
+ />
2025
+ </div>
2026
+ </div>
2027
+ );
2028
+ }
2029
+
2030
+ interface ShadowLayer {
2031
+ id: string;
2032
+ x: number;
2033
+ y: number;
2034
+ blur: number;
2035
+ spread: number;
2036
+ color: string;
2037
+ inset: boolean;
2038
+ }
2039
+
2040
+ function defaultDropShadowLayer(index: number): ShadowLayer {
2041
+ return {
2042
+ id: `shadow-${index}`,
2043
+ x: 0,
2044
+ y: 4,
2045
+ blur: 12,
2046
+ spread: 0,
2047
+ color: "rgba(0, 0, 0, 0.25)",
2048
+ inset: false,
2049
+ };
2050
+ }
2051
+
2052
+ function parseShadowLayers(value: string | undefined): ShadowLayer[] {
2053
+ return splitCssLayers(value || "")
2054
+ .filter((layer) => layer && layer !== "none")
2055
+ .map((layer, index) => parseShadowLayer(layer, index));
2056
+ }
2057
+
2058
+ function parseShadowLayer(layer: string, index: number): ShadowLayer {
2059
+ const tokens = splitCssTokens(layer);
2060
+ const inset = tokens.includes("inset");
2061
+ const colorToken =
2062
+ tokens.find((token) => parseCssColor(token) || token === "transparent") ||
2063
+ "rgba(0, 0, 0, 0.25)";
2064
+ const numericTokens = tokens
2065
+ .filter((token) => token !== "inset" && token !== colorToken)
2066
+ .map((token) => parseFloat(token))
2067
+ .filter((value) => Number.isFinite(value));
2068
+
2069
+ return {
2070
+ id: `shadow-${index}`,
2071
+ x: numericTokens[0] ?? 0,
2072
+ y: numericTokens[1] ?? 4,
2073
+ blur: numericTokens[2] ?? 12,
2074
+ spread: numericTokens[3] ?? 0,
2075
+ color: colorToken,
2076
+ inset,
2077
+ };
2078
+ }
2079
+
2080
+ function splitCssTokens(value: string): string[] {
2081
+ const tokens: string[] = [];
2082
+ let start = 0;
2083
+ let depth = 0;
2084
+ for (let index = 0; index < value.length; index += 1) {
2085
+ const char = value[index];
2086
+ if (char === "(") depth += 1;
2087
+ if (char === ")") depth = Math.max(0, depth - 1);
2088
+ if (/\s/.test(char) && depth === 0) {
2089
+ const token = value.slice(start, index).trim();
2090
+ if (token) tokens.push(token);
2091
+ start = index + 1;
2092
+ }
2093
+ }
2094
+ const finalToken = value.slice(start).trim();
2095
+ if (finalToken) tokens.push(finalToken);
2096
+ return tokens;
2097
+ }
2098
+
2099
+ function serializeShadowLayers(layers: ShadowLayer[]) {
2100
+ if (!layers.length) return "none";
2101
+ return layers
2102
+ .map((layer) =>
2103
+ [
2104
+ layer.inset ? "inset" : "",
2105
+ `${Math.round(layer.x)}px`,
2106
+ `${Math.round(layer.y)}px`,
2107
+ `${Math.max(0, Math.round(layer.blur))}px`,
2108
+ `${Math.max(0, Math.round(layer.spread))}px`,
2109
+ layer.color,
2110
+ ]
2111
+ .filter(Boolean)
2112
+ .join(" "),
2113
+ )
2114
+ .join(", ");
2115
+ }
2116
+
2117
+ function readBlurFilter(value: string | undefined): number {
2118
+ const match = value?.match(/blur\((-?\d+(?:\.\d+)?)px\)/);
2119
+ return match ? Math.max(0, Number(match[1])) : 0;
2120
+ }
2121
+
2122
+ function ShadowEffectRow({
2123
+ layer,
2124
+ index,
2125
+ onChange,
2126
+ onRemove,
2127
+ }: {
2128
+ layer: ShadowLayer;
2129
+ index: number;
2130
+ onChange: (patch: Partial<ShadowLayer>) => void;
2131
+ onRemove: () => void;
2132
+ }) {
2133
+ const t = useT();
2134
+ return (
2135
+ <Popover>
2136
+ {/* Figma effect row: [swatch+label+x,y,blur trigger (flex-1)] [remove] */}
2137
+ <div className="group flex items-center gap-1.5">
2138
+ <PopoverTrigger asChild>
2139
+ <button
2140
+ type="button"
2141
+ 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)]"
2142
+ >
2143
+ <span
2144
+ className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
2145
+ style={swatchStyle(layer.color)}
2146
+ />
2147
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
2148
+ {index === 0
2149
+ ? t("editPanel.labels.dropShadow")
2150
+ : `${t("editPanel.labels.dropShadow")} ${index + 1}`}
2151
+ </span>
2152
+ <span className="shrink-0 tabular-nums text-muted-foreground">
2153
+ {Math.round(layer.x)}, {Math.round(layer.y)},{" "}
2154
+ {Math.round(layer.blur)}
2155
+ </span>
2156
+ </button>
2157
+ </PopoverTrigger>
2158
+ <SectionIconButton
2159
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
2160
+ label={t("editPanel.labels.removeLayer")}
2161
+ onClick={onRemove}
2162
+ >
2163
+ <IconMinus className="size-3.5" />
2164
+ </SectionIconButton>
2165
+ </div>
2166
+ <PopoverContent
2167
+ side="left"
2168
+ align="start"
2169
+ sideOffset={8}
2170
+ className="w-72 p-3"
2171
+ >
2172
+ <div className="space-y-3">
2173
+ <div className="flex items-center justify-between gap-2">
2174
+ <p className="text-xs font-semibold text-foreground">
2175
+ {t("editPanel.labels.dropShadow")}
2176
+ </p>
2177
+ <button
2178
+ type="button"
2179
+ className={cn(
2180
+ "rounded border px-2 py-1 text-[11px]",
2181
+ layer.inset
2182
+ ? "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)] text-foreground"
2183
+ : "border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-muted-foreground",
2184
+ )}
2185
+ onClick={() => onChange({ inset: !layer.inset })}
2186
+ >
2187
+ {t("editPanel.labels.innerShadow")}
2188
+ </button>
2189
+ </div>
2190
+ <div className="grid grid-cols-2 gap-2">
2191
+ <ScrubInput
2192
+ label="X"
2193
+ value={layer.x}
2194
+ onChange={(value) => onChange({ x: value })}
2195
+ unit="px"
2196
+ precision={1}
2197
+ inputClassName="h-6"
2198
+ />
2199
+ <ScrubInput
2200
+ label="Y"
2201
+ value={layer.y}
2202
+ onChange={(value) => onChange({ y: value })}
2203
+ unit="px"
2204
+ precision={1}
2205
+ inputClassName="h-6"
2206
+ />
2207
+ <ScrubInput
2208
+ label={t("editPanel.labels.blur")}
2209
+ value={layer.blur}
2210
+ onChange={(value) => onChange({ blur: Math.max(0, value) })}
2211
+ unit="px"
2212
+ min={0}
2213
+ precision={1}
2214
+ inputClassName="h-6"
2215
+ />
2216
+ <ScrubInput
2217
+ label={t("editPanel.labels.spread")}
2218
+ value={layer.spread}
2219
+ onChange={(value) => onChange({ spread: Math.max(0, value) })}
2220
+ unit="px"
2221
+ min={0}
2222
+ precision={1}
2223
+ inputClassName="h-6"
2224
+ />
2225
+ </div>
2226
+ <ColorInput
2227
+ label={t("editPanel.labels.color")}
2228
+ value={cssColorOrFallback(layer.color, "rgba(0, 0, 0, 0.25)")}
2229
+ onChange={(value) => onChange({ color: value })}
2230
+ />
2231
+ </div>
2232
+ </PopoverContent>
2233
+ </Popover>
2234
+ );
2235
+ }
2236
+
2237
+ /** Page-level properties when nothing is selected */
2238
+ function PageProperties({
2239
+ styles,
2240
+ onStyleChange,
2241
+ }: {
2242
+ styles: Record<string, string>;
2243
+ onStyleChange: (property: string, value: string) => void;
2244
+ }) {
2245
+ const t = useT();
2246
+ const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
2247
+ value: option.value,
2248
+ label: t(`editPanel.fontFamilies.${option.key}`),
2249
+ }));
2250
+ const fontFamily = FONT_FAMILY_OPTIONS.some(
2251
+ (option) => option.value === styles.fontFamily,
2252
+ )
2253
+ ? styles.fontFamily
2254
+ : "sans-serif";
2255
+
2256
+ return (
2257
+ <div>
2258
+ <PanelSection title={t("editPanel.sections.page")}>
2259
+ <ColorInput
2260
+ label={t("editPanel.labels.background")}
2261
+ value={styles.backgroundColor || ""}
2262
+ onChange={(v) => onStyleChange("backgroundColor", v)}
2263
+ backgroundImage={styles.backgroundImage || ""}
2264
+ onBackgroundImageChange={(v) => onStyleChange("backgroundImage", v)}
2265
+ blendMode={styles.backgroundBlendMode || "normal"}
2266
+ onBlendModeChange={(v) => onStyleChange("backgroundBlendMode", v)}
2267
+ supportsLayeredFills
2268
+ />
2269
+ <PropSelect
2270
+ label={t("editPanel.labels.font")}
2271
+ value={fontFamily}
2272
+ onChange={(v) => onStyleChange("fontFamily", v)}
2273
+ options={fontFamilyOptions}
2274
+ />
2275
+ <PropInput
2276
+ label={t("editPanel.labels.baseSize")}
2277
+ value={styles.fontSize || "16px"}
2278
+ onChange={(v) => onStyleChange("fontSize", v)}
2279
+ placeholder="16px"
2280
+ defaultUnit="px"
2281
+ />
2282
+ </PanelSection>
2283
+ </div>
2284
+ );
2285
+ }
2286
+
2287
+ /** Text element properties */
2288
+ function TypographyProperties({
2289
+ element,
2290
+ onStyleChange,
2291
+ }: {
2292
+ element: ElementInfo;
2293
+ onStyleChange: (property: string, value: string) => void;
2294
+ }) {
2295
+ const t = useT();
2296
+ const styles = element.computedStyles;
2297
+ const fontFamilyOptions = FONT_FAMILY_OPTIONS.map((option) => ({
2298
+ value: option.value,
2299
+ label: t(`editPanel.fontFamilies.${option.key}`),
2300
+ }));
2301
+ const fontWeightOptions = FONT_WEIGHT_OPTIONS.map((option) => ({
2302
+ value: option.value,
2303
+ label: t(`editPanel.fontWeights.${option.key}`),
2304
+ }));
2305
+ const textAlign = styles.textAlign || "left";
2306
+
2307
+ // M1 · Text resizing mode (auto-width / auto-height / fixed). Figma's text
2308
+ // nodes always expose this segment. Infer the current mode from the live CSS:
2309
+ // auto-width hugs both axes (width:auto + no wrapping), auto-height hugs the
2310
+ // height only (fixed width, content wraps), fixed pins both width and height.
2311
+ const widthIsAuto =
2312
+ !styles.width || styles.width === "auto" || styles.width === "max-content";
2313
+ const heightIsAuto = !styles.height || styles.height === "auto";
2314
+ const noWrap = styles.whiteSpace === "nowrap";
2315
+ const resizeMode: "auto-width" | "auto-height" | "fixed" =
2316
+ widthIsAuto && noWrap
2317
+ ? "auto-width"
2318
+ : !heightIsAuto && !widthIsAuto
2319
+ ? "fixed"
2320
+ : "auto-height";
2321
+ const currentWidth = styles.width && !widthIsAuto ? styles.width : "200px";
2322
+ const currentHeight = styles.height && !heightIsAuto ? styles.height : "48px";
2323
+ const setResizeMode = (mode: "auto-width" | "auto-height" | "fixed") => {
2324
+ if (mode === "auto-width") {
2325
+ onStyleChange("width", "auto");
2326
+ onStyleChange("height", "auto");
2327
+ onStyleChange("whiteSpace", "nowrap");
2328
+ } else if (mode === "auto-height") {
2329
+ onStyleChange("width", currentWidth);
2330
+ onStyleChange("height", "auto");
2331
+ onStyleChange("whiteSpace", "normal");
2332
+ } else {
2333
+ onStyleChange("width", currentWidth);
2334
+ onStyleChange("height", currentHeight);
2335
+ onStyleChange("whiteSpace", "normal");
2336
+ }
2337
+ };
2338
+
2339
+ // M2 · Vertical text alignment (top / middle / bottom). For auto-layout text
2340
+ // containers (display:flex) Figma maps this to `justifyContent`; for normal
2341
+ // text we fall back to `verticalAlign`, which is what an inline/grid text box
2342
+ // honors. Read whichever the element currently expresses.
2343
+ const display = (styles.display || "").toLowerCase();
2344
+ const isFlexText = display.includes("flex");
2345
+ const verticalAlign = isFlexText
2346
+ ? styles.justifyContent === "center"
2347
+ ? "middle"
2348
+ : styles.justifyContent === "flex-end"
2349
+ ? "bottom"
2350
+ : "top"
2351
+ : styles.verticalAlign === "middle"
2352
+ ? "middle"
2353
+ : styles.verticalAlign === "bottom"
2354
+ ? "bottom"
2355
+ : "top";
2356
+ const setVerticalAlign = (mode: "top" | "middle" | "bottom") => {
2357
+ if (isFlexText) {
2358
+ onStyleChange(
2359
+ "justifyContent",
2360
+ mode === "middle"
2361
+ ? "center"
2362
+ : mode === "bottom"
2363
+ ? "flex-end"
2364
+ : "flex-start",
2365
+ );
2366
+ } else {
2367
+ onStyleChange("verticalAlign", mode);
2368
+ }
2369
+ };
2370
+
2371
+ return (
2372
+ <PanelSection title={t("editPanel.sections.typography")}>
2373
+ {/* Row 0: text resizing (auto-width / auto-height / fixed) */}
2374
+ <InspectorSegment>
2375
+ <InspectorIconButton
2376
+ label={"Auto width" /* i18n-ignore Figma text-resize mode */}
2377
+ active={resizeMode === "auto-width"}
2378
+ onClick={() => setResizeMode("auto-width")}
2379
+ >
2380
+ <IconArrowAutofitWidth className="size-3.5" />
2381
+ </InspectorIconButton>
2382
+ <InspectorIconButton
2383
+ label={"Auto height" /* i18n-ignore Figma text-resize mode */}
2384
+ active={resizeMode === "auto-height"}
2385
+ onClick={() => setResizeMode("auto-height")}
2386
+ >
2387
+ <IconArrowAutofitHeight className="size-3.5" />
2388
+ </InspectorIconButton>
2389
+ <InspectorIconButton
2390
+ label={"Fixed size" /* i18n-ignore Figma text-resize mode */}
2391
+ active={resizeMode === "fixed"}
2392
+ onClick={() => setResizeMode("fixed")}
2393
+ >
2394
+ <IconSquare className="size-3.5" />
2395
+ </InspectorIconButton>
2396
+ </InspectorSegment>
2397
+
2398
+ {/* Row 1: font family full-width */}
2399
+ <Select
2400
+ value={styles.fontFamily || "sans-serif"}
2401
+ onValueChange={(v) => onStyleChange("fontFamily", v)}
2402
+ >
2403
+ <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)]">
2404
+ <SelectValue />
2405
+ </SelectTrigger>
2406
+ <SelectContent>
2407
+ {fontFamilyOptions.map((opt) => (
2408
+ <SelectItem
2409
+ key={opt.value}
2410
+ value={opt.value}
2411
+ className="text-[11px]"
2412
+ >
2413
+ {opt.label}
2414
+ </SelectItem>
2415
+ ))}
2416
+ </SelectContent>
2417
+ </Select>
2418
+
2419
+ {/* Row 2: weight + size side by side */}
2420
+ <div className="grid grid-cols-2 gap-1.5">
2421
+ <Select
2422
+ value={styles.fontWeight || "400"}
2423
+ onValueChange={(v) => onStyleChange("fontWeight", v)}
2424
+ >
2425
+ <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)]">
2426
+ <SelectValue />
2427
+ </SelectTrigger>
2428
+ <SelectContent>
2429
+ {fontWeightOptions.map((opt) => (
2430
+ <SelectItem
2431
+ key={opt.value}
2432
+ value={opt.value}
2433
+ className="text-[11px]"
2434
+ >
2435
+ {opt.label}
2436
+ </SelectItem>
2437
+ ))}
2438
+ </SelectContent>
2439
+ </Select>
2440
+ <ScrubInput
2441
+ label={t("editPanel.labels.size")}
2442
+ ariaLabel={t("editPanel.labels.size")}
2443
+ icon={IconLetterCase}
2444
+ value={styles.fontSize ? parseNumericValue(styles.fontSize) : 16}
2445
+ onChange={(value) =>
2446
+ onStyleChange("fontSize", `${Math.max(1, Math.round(value))}px`)
2447
+ }
2448
+ unit="px"
2449
+ min={1}
2450
+ precision={1}
2451
+ className="gap-0"
2452
+ 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"
2453
+ 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)]"
2454
+ />
2455
+ </div>
2456
+
2457
+ {/* Row 3: line-height + letter-spacing with Figma-style leading icons */}
2458
+ <div className="grid grid-cols-2 gap-1.5">
2459
+ <ScrubInput
2460
+ label={t("editPanel.labels.lineHeight")}
2461
+ ariaLabel={t("editPanel.labels.lineHeight")}
2462
+ icon={IconLineHeight}
2463
+ value={parseNumericValue(styles.lineHeight || "1.2")}
2464
+ onChange={(value) =>
2465
+ onStyleChange("lineHeight", String(Math.max(0.1, value)))
2466
+ }
2467
+ min={0.1}
2468
+ step={0.1}
2469
+ precision={2}
2470
+ className="gap-0"
2471
+ 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"
2472
+ 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)]"
2473
+ />
2474
+ <ScrubInput
2475
+ label={t("editPanel.labels.tracking")}
2476
+ ariaLabel={t("editPanel.labels.tracking")}
2477
+ icon={IconLetterSpacing}
2478
+ value={
2479
+ styles.letterSpacing ? parseNumericValue(styles.letterSpacing) : 0
2480
+ }
2481
+ onChange={(value) => onStyleChange("letterSpacing", `${value}px`)}
2482
+ unit="px"
2483
+ precision={1}
2484
+ className="gap-0"
2485
+ 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"
2486
+ 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)]"
2487
+ />
2488
+ </div>
2489
+
2490
+ {/* Row 4: horizontal + vertical text alignment */}
2491
+ <div className="grid grid-cols-2 gap-1.5">
2492
+ <InspectorSegment>
2493
+ <InspectorIconButton
2494
+ label={t("editPanel.textAligns.left")}
2495
+ active={textAlign === "left" || textAlign === "start"}
2496
+ onClick={() => onStyleChange("textAlign", "left")}
2497
+ >
2498
+ <IconAlignLeft className="size-3.5" />
2499
+ </InspectorIconButton>
2500
+ <InspectorIconButton
2501
+ label={t("editPanel.textAligns.center")}
2502
+ active={textAlign === "center"}
2503
+ onClick={() => onStyleChange("textAlign", "center")}
2504
+ >
2505
+ <IconAlignCenter className="size-3.5" />
2506
+ </InspectorIconButton>
2507
+ <InspectorIconButton
2508
+ label={t("editPanel.textAligns.right")}
2509
+ active={textAlign === "right" || textAlign === "end"}
2510
+ onClick={() => onStyleChange("textAlign", "right")}
2511
+ >
2512
+ <IconAlignRight className="size-3.5" />
2513
+ </InspectorIconButton>
2514
+ <InspectorIconButton
2515
+ label={t("editPanel.textAligns.justify")}
2516
+ active={textAlign === "justify"}
2517
+ onClick={() => onStyleChange("textAlign", "justify")}
2518
+ >
2519
+ <IconAlignJustified className="size-3.5" />
2520
+ </InspectorIconButton>
2521
+ </InspectorSegment>
2522
+ <InspectorSegment>
2523
+ <InspectorIconButton
2524
+ label={"Align top" /* i18n-ignore Figma vertical text align */}
2525
+ active={verticalAlign === "top"}
2526
+ onClick={() => setVerticalAlign("top")}
2527
+ >
2528
+ <IconLayoutAlignTop className="size-3.5" />
2529
+ </InspectorIconButton>
2530
+ <InspectorIconButton
2531
+ label={"Align middle" /* i18n-ignore Figma vertical text align */}
2532
+ active={verticalAlign === "middle"}
2533
+ onClick={() => setVerticalAlign("middle")}
2534
+ >
2535
+ <IconLayoutAlignMiddle className="size-3.5" />
2536
+ </InspectorIconButton>
2537
+ <InspectorIconButton
2538
+ label={"Align bottom" /* i18n-ignore Figma vertical text align */}
2539
+ active={verticalAlign === "bottom"}
2540
+ onClick={() => setVerticalAlign("bottom")}
2541
+ >
2542
+ <IconLayoutAlignBottom className="size-3.5" />
2543
+ </InspectorIconButton>
2544
+ </InspectorSegment>
2545
+ </div>
2546
+ </PanelSection>
2547
+ );
2548
+ }
2549
+
2550
+ /** Flex container properties */
2551
+ function FlexContainerControls({
2552
+ element,
2553
+ onStyleChange,
2554
+ onStylesChange,
2555
+ }: {
2556
+ element: ElementInfo;
2557
+ onStyleChange: (property: string, value: string) => void;
2558
+ onStylesChange?: (styles: Record<string, string>) => void;
2559
+ }) {
2560
+ const t = useT();
2561
+ const styles = element.computedStyles;
2562
+ // The element's CURRENT layout flow as authored in code, read from its own
2563
+ // computed `display`: block/flow-root/grid/etc. = "normal flow",
2564
+ // flex/inline-flex = auto layout. We forward it so the AutoLayoutMatrix Flow
2565
+ // control can show the right state (normal vs horizontal/vertical/wrap)
2566
+ // instead of an empty "add" affordance.
2567
+ const display = (styles.display || "").toLowerCase();
2568
+ const isFlex = display.includes("flex");
2569
+ const flexDirection: AutoLayoutMatrixValue["direction"] =
2570
+ styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
2571
+ // When the element is in normal flow (not flex yet), picking any flow option
2572
+ // must first turn it into a flex container; otherwise setting flex-direction
2573
+ // alone is a no-op against a block element.
2574
+ const ensureFlex = () => {
2575
+ if (!isFlex) onStyleChange("display", "flex");
2576
+ };
2577
+ const padding = {
2578
+ top: parseNumericValue(styles.paddingTop || "0"),
2579
+ right: parseNumericValue(styles.paddingRight || "0"),
2580
+ bottom: parseNumericValue(styles.paddingBottom || "0"),
2581
+ left: parseNumericValue(styles.paddingLeft || "0"),
2582
+ };
2583
+ const allPaddingEqual =
2584
+ padding.top === padding.right &&
2585
+ padding.top === padding.bottom &&
2586
+ padding.top === padding.left;
2587
+ const [paddingLinked, setPaddingLinked] = useState(allPaddingEqual);
2588
+
2589
+ useEffect(() => {
2590
+ if (!allPaddingEqual && paddingLinked) setPaddingLinked(false);
2591
+ }, [allPaddingEqual, paddingLinked]);
2592
+
2593
+ const autoLayoutValue: AutoLayoutMatrixValue = {
2594
+ direction: flexDirection,
2595
+ wrap: styles.flexWrap === "wrap" ? "wrap" : "nowrap",
2596
+ alignment: autoLayoutAlignmentFromStyles(styles, flexDirection),
2597
+ gap: parseNumericValue(styles.gap || "0"),
2598
+ padding,
2599
+ paddingLinked,
2600
+ childSizing: {
2601
+ horizontal: inferElementSizing(element, "horizontal"),
2602
+ vertical: inferElementSizing(element, "vertical"),
2603
+ },
2604
+ childMinMax: {
2605
+ horizontal: readElementMinMax(element, "horizontal"),
2606
+ vertical: readElementMinMax(element, "vertical"),
2607
+ },
2608
+ clipContent: styles.overflow === "hidden",
2609
+ resolvedSize: {
2610
+ horizontal: element.boundingRect.width,
2611
+ vertical: element.boundingRect.height,
2612
+ },
2613
+ // Forward the raw CSS display so the matrix can render the correct Flow
2614
+ // state (normal flow for block/grid, flex for flex/inline-flex). Added as an
2615
+ // optional contract field consumed by AutoLayoutMatrix; harmless when the
2616
+ // matrix ignores it.
2617
+ ...({ display } as Partial<AutoLayoutMatrixValue>),
2618
+ };
548
2619
 
549
2620
  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}
2621
+ <div className="space-y-2">
2622
+ <AutoLayoutMatrix
2623
+ value={autoLayoutValue}
2624
+ onDirectionChange={(direction) => {
2625
+ ensureFlex();
2626
+ onStyleChange(
2627
+ "flexDirection",
2628
+ direction === "vertical" ? "column" : "row",
2629
+ );
2630
+ }}
2631
+ onWrapChange={(wrap) => {
2632
+ ensureFlex();
2633
+ onStyleChange("flexWrap", wrap);
2634
+ }}
2635
+ onAlignmentChange={(alignment) => {
2636
+ if (autoLayoutValue.direction === "vertical") {
2637
+ onStyleChange(
2638
+ "alignItems",
2639
+ horizontalToJustify(alignment.horizontal),
2640
+ );
2641
+ onStyleChange(
2642
+ "justifyContent",
2643
+ verticalToAlign(alignment.vertical),
2644
+ );
2645
+ return;
2646
+ }
2647
+ onStyleChange(
2648
+ "justifyContent",
2649
+ horizontalToJustify(alignment.horizontal),
2650
+ );
2651
+ onStyleChange("alignItems", verticalToAlign(alignment.vertical));
2652
+ }}
2653
+ onGapChange={(gap) => onStyleChange("gap", `${gap}px`)}
2654
+ onPaddingChange={(nextPadding) => {
2655
+ onStyleChange("paddingTop", `${nextPadding.top}px`);
2656
+ onStyleChange("paddingRight", `${nextPadding.right}px`);
2657
+ onStyleChange("paddingBottom", `${nextPadding.bottom}px`);
2658
+ onStyleChange("paddingLeft", `${nextPadding.left}px`);
2659
+ }}
2660
+ onPaddingLinkedChange={(linked) => {
2661
+ setPaddingLinked(linked);
2662
+ if (!linked) return;
2663
+ onStyleChange("paddingTop", `${padding.top}px`);
2664
+ onStyleChange("paddingRight", `${padding.top}px`);
2665
+ onStyleChange("paddingBottom", `${padding.top}px`);
2666
+ onStyleChange("paddingLeft", `${padding.top}px`);
2667
+ }}
2668
+ onClipContentChange={(clipContent) =>
2669
+ onStyleChange("overflow", clipContent ? "hidden" : "visible")
2670
+ }
2671
+ onDistribute={(axis) => {
2672
+ const mainAxis =
2673
+ autoLayoutValue.direction === "horizontal"
2674
+ ? "horizontal"
2675
+ : "vertical";
2676
+ if (axis === mainAxis) {
2677
+ onStyleChange("justifyContent", "space-between");
2678
+ } else if (autoLayoutValue.wrap === "wrap") {
2679
+ onStyleChange("alignContent", "space-between");
2680
+ }
2681
+ }}
2682
+ availableChildSizing={availableSizingForElement(element)}
2683
+ onChildSizingChange={(axis, sizing) => {
2684
+ commitElementSizing(
2685
+ element,
2686
+ axis,
2687
+ sizing,
2688
+ onStyleChange,
2689
+ onStylesChange,
2690
+ );
2691
+ }}
2692
+ onChildMinMaxChange={(axis, kind, val) =>
2693
+ commitElementMinMax(axis, kind, val, onStyleChange)
2694
+ }
557
2695
  />
558
- <PropSelect
559
- label={t("editPanel.labels.justify")}
560
- value={styles.justifyContent || "flex-start"}
561
- onChange={(v) => onStyleChange("justifyContent", v)}
562
- options={justifyOptions}
2696
+ </div>
2697
+ );
2698
+ }
2699
+
2700
+ function FlexChildControls({
2701
+ element,
2702
+ onStyleChange,
2703
+ }: {
2704
+ element: ElementInfo;
2705
+ onStyleChange: (property: string, value: string) => void;
2706
+ }) {
2707
+ const t = useT();
2708
+ const styles = element.computedStyles;
2709
+ const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
2710
+ value: option.value,
2711
+ label: t(`editPanel.alignSelfOptions.${option.key}`),
2712
+ }));
2713
+
2714
+ return (
2715
+ <div className="space-y-2">
2716
+ <SubsectionLabel>{t("editPanel.layoutContext.child")}</SubsectionLabel>
2717
+ <PropInput
2718
+ label={t("editPanel.labels.flexGrow")}
2719
+ value={styles.flexGrow || ""}
2720
+ onChange={(v) => onStyleChange("flexGrow", v)}
2721
+ placeholder="0"
563
2722
  />
564
- <PropSelect
565
- label={t("editPanel.labels.align")}
566
- value={styles.alignItems || "stretch"}
567
- onChange={(v) => onStyleChange("alignItems", v)}
568
- options={alignOptions}
2723
+ <PropInput
2724
+ label={t("editPanel.labels.flexShrink")}
2725
+ value={styles.flexShrink || ""}
2726
+ onChange={(v) => onStyleChange("flexShrink", v)}
2727
+ placeholder="1"
569
2728
  />
570
2729
  <PropInput
571
- label={t("editPanel.labels.gap")}
572
- value={styles.gap || ""}
573
- onChange={(v) => onStyleChange("gap", v)}
574
- placeholder="0px"
2730
+ label={t("editPanel.labels.flexBasis")}
2731
+ value={styles.flexBasis || ""}
2732
+ onChange={(v) => onStyleChange("flexBasis", v)}
2733
+ placeholder="auto"
575
2734
  defaultUnit="px"
576
2735
  />
2736
+ <PropInput
2737
+ label={t("editPanel.labels.order")}
2738
+ value={styles.order || ""}
2739
+ onChange={(v) => onStyleChange("order", v)}
2740
+ placeholder="0"
2741
+ />
2742
+ <PropSelect
2743
+ label={t("editPanel.labels.alignSelf")}
2744
+ value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
2745
+ onChange={(v) => onStyleChange("alignSelf", v)}
2746
+ options={alignSelfOptions}
2747
+ />
577
2748
  </div>
578
2749
  );
579
2750
  }
580
2751
 
581
- /** Universal element properties (size, opacity, spacing, border, background) */
582
- function ElementProperties({
2752
+ function GridChildControls({
583
2753
  element,
584
2754
  onStyleChange,
585
2755
  }: {
@@ -588,107 +2758,1344 @@ function ElementProperties({
588
2758
  }) {
589
2759
  const t = useT();
590
2760
  const styles = element.computedStyles;
591
-
592
- const handlePaddingChange = useCallback(
593
- (side: string, value: string) => {
594
- onStyleChange(`padding${side}`, value);
595
- },
596
- [onStyleChange],
597
- );
598
-
599
- const handleMarginChange = useCallback(
600
- (side: string, value: string) => {
601
- onStyleChange(`margin${side}`, value);
602
- },
603
- [onStyleChange],
604
- );
2761
+ const alignSelfOptions = ALIGN_SELF_OPTIONS.map((option) => ({
2762
+ value: option.value,
2763
+ label: t(`editPanel.alignSelfOptions.${option.key}`),
2764
+ }));
605
2765
 
606
2766
  return (
607
- <div className="space-y-4">
608
- <SectionTitle>{t("editPanel.sections.layout")}</SectionTitle>
2767
+ <div className="space-y-2">
2768
+ <SubsectionLabel>
2769
+ {t("editPanel.layoutContext.gridChild")}
2770
+ </SubsectionLabel>
609
2771
  <PropInput
610
- label={t("editPanel.labels.width")}
611
- value={styles.width || ""}
612
- onChange={(v) => onStyleChange("width", v)}
2772
+ label={t("editPanel.labels.gridColumn")}
2773
+ value={styles.gridColumn || ""}
2774
+ onChange={(v) => onStyleChange("gridColumn", v)}
613
2775
  placeholder="auto"
614
- defaultUnit="px"
615
2776
  />
616
2777
  <PropInput
617
- label={t("editPanel.labels.height")}
618
- value={styles.height || ""}
619
- onChange={(v) => onStyleChange("height", v)}
2778
+ label={t("editPanel.labels.gridRow")}
2779
+ value={styles.gridRow || ""}
2780
+ onChange={(v) => onStyleChange("gridRow", v)}
620
2781
  placeholder="auto"
621
- defaultUnit="px"
622
2782
  />
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="%"
2783
+ <PropSelect
2784
+ label={t("editPanel.labels.alignSelf")}
2785
+ value={optionValue(ALIGN_SELF_OPTIONS, styles.alignSelf, "auto")}
2786
+ onChange={(v) => onStyleChange("alignSelf", v)}
2787
+ options={alignSelfOptions}
631
2788
  />
2789
+ </div>
2790
+ );
2791
+ }
632
2792
 
633
- <Separator />
2793
+ function LayoutContextProperties({
2794
+ element,
2795
+ onStyleChange,
2796
+ onStylesChange,
2797
+ }: {
2798
+ element: ElementInfo;
2799
+ onStyleChange: (property: string, value: string) => void;
2800
+ onStylesChange?: (styles: Record<string, string>) => void;
2801
+ }) {
2802
+ const t = useT();
2803
+ const flexChild = isParentFlex(element);
2804
+ const gridChild = isParentGrid(element);
2805
+ const availableSizing = availableSizingForElement(element);
2806
+ const isContainer = isContainerElement(element);
634
2807
 
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}
645
- />
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}
655
- />
2808
+ const childControls = (
2809
+ <>
2810
+ {flexChild ? (
2811
+ <div className="border-t border-border/70 pt-2">
2812
+ <FlexChildControls element={element} onStyleChange={onStyleChange} />
2813
+ </div>
2814
+ ) : null}
2815
+ {gridChild ? (
2816
+ <div className="border-t border-border/70 pt-2">
2817
+ <GridChildControls element={element} onStyleChange={onStyleChange} />
2818
+ </div>
2819
+ ) : null}
2820
+ </>
2821
+ );
656
2822
 
657
- <Separator />
2823
+ // Leaf elements (text, img, svg, etc.) never get auto layout — show the plain
2824
+ // Figma W/H sizing block instead.
2825
+ if (!isContainer) {
2826
+ return (
2827
+ <PanelSection title={t("editPanel.sections.layout")}>
2828
+ {/* Figma-style single-row-per-axis: [W | value | Fixed/Hug/Fill ▾] with
2829
+ the full sizing menu (modes + min/max + variable) per axis. */}
2830
+ <div className="grid grid-cols-2 items-start gap-1.5">
2831
+ <SizingField
2832
+ axis="W"
2833
+ sizingAxis="horizontal"
2834
+ value={inferElementSizing(element, "horizontal")}
2835
+ resolvedSize={element.boundingRect.width}
2836
+ minMax={readElementMinMax(element, "horizontal")}
2837
+ options={availableSizing.horizontal ?? ["fixed"]}
2838
+ disabled={false}
2839
+ onChange={(mode) =>
2840
+ commitElementSizing(
2841
+ element,
2842
+ "horizontal",
2843
+ mode,
2844
+ onStyleChange,
2845
+ onStylesChange,
2846
+ )
2847
+ }
2848
+ onMinMaxChange={(axis, kind, val) =>
2849
+ commitElementMinMax(axis, kind, val, onStyleChange)
2850
+ }
2851
+ />
2852
+ <SizingField
2853
+ axis="H"
2854
+ sizingAxis="vertical"
2855
+ value={inferElementSizing(element, "vertical")}
2856
+ resolvedSize={element.boundingRect.height}
2857
+ minMax={readElementMinMax(element, "vertical")}
2858
+ options={availableSizing.vertical ?? ["fixed"]}
2859
+ disabled={false}
2860
+ onChange={(mode) =>
2861
+ commitElementSizing(
2862
+ element,
2863
+ "vertical",
2864
+ mode,
2865
+ onStyleChange,
2866
+ onStylesChange,
2867
+ )
2868
+ }
2869
+ onMinMaxChange={(axis, kind, val) =>
2870
+ commitElementMinMax(axis, kind, val, onStyleChange)
2871
+ }
2872
+ />
2873
+ </div>
2874
+ {childControls}
2875
+ </PanelSection>
2876
+ );
2877
+ }
658
2878
 
659
- <SectionTitle>{t("editPanel.sections.border")}</SectionTitle>
660
- <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)}
671
- />
672
- <PropInput
673
- label={t("editPanel.labels.radius")}
674
- value={styles.borderRadius || "0"}
675
- onChange={(v) => onStyleChange("borderRadius", v)}
676
- placeholder="0px"
677
- defaultUnit="px"
2879
+ // Any container element ALREADY has a layout in code — normal flow (block) by
2880
+ // default, or flex when it uses flexbox. Figma never makes you "add" auto
2881
+ // layout for a frame, so we always render the full layout controls and let
2882
+ // the Flow control reflect/switch the element's current `display`. Choosing a
2883
+ // horizontal/vertical/wrap/grid flow applies `display:flex`; choosing the
2884
+ // normal-flow option resets to `display:block`.
2885
+ return (
2886
+ <PanelSection title={t("editPanel.sections.autoLayout")}>
2887
+ <FlexContainerControls
2888
+ element={element}
2889
+ onStyleChange={onStyleChange}
2890
+ onStylesChange={onStylesChange}
678
2891
  />
2892
+ {childControls}
2893
+ </PanelSection>
2894
+ );
2895
+ }
679
2896
 
680
- <Separator />
2897
+ /**
2898
+ * Figma "Layout grid" / layout-guide section. Shown for frame/container
2899
+ * elements. Renders an overlay column/row guide by applying a non-destructive
2900
+ * `backgroundImage` repeating gradient layer tagged so it can be toggled off
2901
+ * without disturbing real fills.
2902
+ */
2903
+ const LAYOUT_GUIDE_MARKER = "/* an-layout-guide */";
681
2904
 
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)}
687
- />
2905
+ function hasLayoutGuide(styles: Record<string, string>): boolean {
2906
+ return Boolean(styles.backgroundImage?.includes("repeating-linear-gradient"));
2907
+ }
2908
+
2909
+ function LayoutGuideProperties({
2910
+ element,
2911
+ onStyleChange,
2912
+ }: {
2913
+ element: ElementInfo;
2914
+ onStyleChange: (property: string, value: string) => void;
2915
+ }) {
2916
+ const styles = element.computedStyles;
2917
+ const active = hasLayoutGuide(styles);
2918
+
2919
+ const addGuide = () => {
2920
+ // 12-column overlay guide — Figma's default columns layout grid.
2921
+ const guide =
2922
+ "repeating-linear-gradient(to right, color-mix(in srgb, var(--design-editor-accent-color) 22%, transparent) 0 1px, transparent 1px calc(100% / 12))";
2923
+ const existing = compactCssValue(styles.backgroundImage, "");
2924
+ onStyleChange(
2925
+ "backgroundImage",
2926
+ existing ? `${guide}, ${existing}` : guide,
2927
+ );
2928
+ };
2929
+
2930
+ const removeGuide = () => {
2931
+ const layers = splitCssLayers(styles.backgroundImage || "").filter(
2932
+ (layer) => !layer.includes("repeating-linear-gradient"),
2933
+ );
2934
+ onStyleChange(
2935
+ "backgroundImage",
2936
+ layers.length ? joinCssLayers(layers) : "none",
2937
+ );
2938
+ };
2939
+
2940
+ return (
2941
+ <PanelSection
2942
+ title={"Layout guide" /* i18n-ignore Figma inspector label */}
2943
+ defaultCollapsed
2944
+ actions={
2945
+ <SectionIconButton
2946
+ label={
2947
+ active
2948
+ ? "Remove layout guide" /* i18n-ignore Figma inspector action */
2949
+ : "Add layout guide" /* i18n-ignore Figma inspector action */
2950
+ }
2951
+ onClick={active ? removeGuide : addGuide}
2952
+ >
2953
+ {active ? (
2954
+ <IconMinus className="size-3.5" />
2955
+ ) : (
2956
+ <IconPlus className="size-3.5" />
2957
+ )}
2958
+ </SectionIconButton>
2959
+ }
2960
+ >
2961
+ {active ? (
2962
+ <div className="flex items-center gap-2 rounded-md bg-[var(--design-editor-control-bg)] px-2 py-1.5 text-[11px] text-muted-foreground">
2963
+ <IconLayoutGrid className="size-3.5 shrink-0" />
2964
+ <span className="min-w-0 flex-1 truncate text-foreground">
2965
+ {"Columns" /* i18n-ignore Figma inspector label */}
2966
+ </span>
2967
+ <span className="shrink-0 tabular-nums">12</span>
2968
+ </div>
2969
+ ) : (
2970
+ <p className="text-[11px] text-muted-foreground">
2971
+ {"No layout guides" /* i18n-ignore Figma inspector empty state */}
2972
+ </p>
2973
+ )}
2974
+ </PanelSection>
2975
+ );
2976
+ }
2977
+
2978
+ /**
2979
+ * Togglable export preview thumbnail (Figma shows a small preview of the export
2980
+ * frame above the export rows). Renders a proportional placeholder reflecting
2981
+ * the selected element's aspect ratio, fill, radius and dimensions.
2982
+ */
2983
+ function ExportPreview({ element }: { element: ElementInfo | null }) {
2984
+ const rect = element?.boundingRect;
2985
+ const width = rect?.width ?? 0;
2986
+ const height = rect?.height ?? 0;
2987
+ const aspect = width > 0 && height > 0 ? width / height : 1;
2988
+ const styles = element?.computedStyles ?? {};
2989
+ const fill = cssColorOrFallback(
2990
+ styles.backgroundColor || styles.color,
2991
+ "var(--design-editor-control-bg)",
2992
+ );
2993
+ const radius = Math.min(8, cssLengthNumber(styles.borderRadius || "0"));
2994
+
2995
+ return (
2996
+ <div className="mt-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] p-3">
2997
+ <div
2998
+ className="mx-auto flex max-h-28 items-center justify-center"
2999
+ style={{
3000
+ aspectRatio: aspect,
3001
+ width: aspect >= 1 ? "100%" : "auto",
3002
+ height: aspect < 1 ? "7rem" : "auto",
3003
+ }}
3004
+ >
3005
+ <div
3006
+ className="size-full border border-[var(--design-editor-control-border)] shadow-sm"
3007
+ style={{ background: fill, borderRadius: radius }}
3008
+ />
3009
+ </div>
3010
+ <p className="mt-2 text-center text-[10px] tabular-nums text-muted-foreground">
3011
+ {Math.round(width)} × {Math.round(height)}
3012
+ </p>
688
3013
  </div>
689
3014
  );
690
3015
  }
691
3016
 
3017
+ /** Position, size, and spacing properties */
3018
+ function PositionLayoutProperties({
3019
+ element,
3020
+ onStyleChange,
3021
+ }: {
3022
+ element: ElementInfo;
3023
+ onStyleChange: (property: string, value: string) => void;
3024
+ }) {
3025
+ const t = useT();
3026
+ const styles = element.computedStyles;
3027
+ const constrainedPosition =
3028
+ styles.position === "absolute" || styles.position === "fixed";
3029
+ // Reflect the active packing in the alignment segments (Figma highlights the
3030
+ // current alignment). For a flex container the main axis is justifyContent.
3031
+ const alignH = justifyToHorizontal(styles.justifyContent);
3032
+ const alignV = alignToVertical(styles.alignItems);
3033
+ const constraintsValue: ConstraintsValue = {
3034
+ horizontal:
3035
+ styles.left && styles.right
3036
+ ? "left-right"
3037
+ : styles.right
3038
+ ? "right"
3039
+ : styles.transform?.includes("translateX(-50%)")
3040
+ ? "center"
3041
+ : styles.width === "100%"
3042
+ ? "scale"
3043
+ : "left",
3044
+ vertical:
3045
+ styles.top && styles.bottom
3046
+ ? "top-bottom"
3047
+ : styles.bottom
3048
+ ? "bottom"
3049
+ : styles.transform?.includes("translateY(-50%)")
3050
+ ? "center"
3051
+ : styles.height === "100%"
3052
+ ? "scale"
3053
+ : "top",
3054
+ };
3055
+
3056
+ const handleConstraintsChange = useCallback(
3057
+ (value: ConstraintsValue) => {
3058
+ onStyleChange("position", "absolute");
3059
+ if (value.horizontal === "left") {
3060
+ onStyleChange(
3061
+ "left",
3062
+ styles.left || `${Math.round(element.boundingRect.x)}px`,
3063
+ );
3064
+ onStyleChange("right", "auto");
3065
+ } else if (value.horizontal === "right") {
3066
+ onStyleChange("right", "0px");
3067
+ onStyleChange("left", "auto");
3068
+ } else if (value.horizontal === "left-right") {
3069
+ onStyleChange(
3070
+ "left",
3071
+ styles.left || `${Math.round(element.boundingRect.x)}px`,
3072
+ );
3073
+ onStyleChange("right", "0px");
3074
+ } else if (value.horizontal === "center") {
3075
+ onStyleChange("left", "50%");
3076
+ onStyleChange("right", "auto");
3077
+ onStyleChange("transform", "translateX(-50%)");
3078
+ } else {
3079
+ onStyleChange("left", "0px");
3080
+ onStyleChange("right", "0px");
3081
+ onStyleChange("width", "100%");
3082
+ }
3083
+
3084
+ if (value.vertical === "top") {
3085
+ onStyleChange(
3086
+ "top",
3087
+ styles.top || `${Math.round(element.boundingRect.y)}px`,
3088
+ );
3089
+ onStyleChange("bottom", "auto");
3090
+ } else if (value.vertical === "bottom") {
3091
+ onStyleChange("bottom", "0px");
3092
+ onStyleChange("top", "auto");
3093
+ } else if (value.vertical === "top-bottom") {
3094
+ onStyleChange(
3095
+ "top",
3096
+ styles.top || `${Math.round(element.boundingRect.y)}px`,
3097
+ );
3098
+ onStyleChange("bottom", "0px");
3099
+ } else if (value.vertical === "center") {
3100
+ onStyleChange("top", "50%");
3101
+ onStyleChange("bottom", "auto");
3102
+ onStyleChange("transform", "translateY(-50%)");
3103
+ } else {
3104
+ onStyleChange("top", "0px");
3105
+ onStyleChange("bottom", "0px");
3106
+ onStyleChange("height", "100%");
3107
+ }
3108
+ },
3109
+ [
3110
+ element.boundingRect.x,
3111
+ element.boundingRect.y,
3112
+ onStyleChange,
3113
+ styles.left,
3114
+ styles.top,
3115
+ ],
3116
+ );
3117
+
3118
+ return (
3119
+ <PanelSection
3120
+ title={t("editPanel.sections.positionLayout")}
3121
+ actions={
3122
+ <SectionIconToggle
3123
+ label={"Absolute position" /* i18n-ignore Figma inspector action */}
3124
+ active={constrainedPosition}
3125
+ onClick={() =>
3126
+ onStyleChange(
3127
+ "position",
3128
+ constrainedPosition ? "relative" : "absolute",
3129
+ )
3130
+ }
3131
+ >
3132
+ <IconLayoutDistributeHorizontal className="size-3.5" />
3133
+ </SectionIconToggle>
3134
+ }
3135
+ >
3136
+ <div className="space-y-1.5">
3137
+ <SubsectionLabel>
3138
+ {"Alignment" /* i18n-ignore Figma inspector label */}
3139
+ </SubsectionLabel>
3140
+ <div className="flex items-center gap-3">
3141
+ <InspectorSegment>
3142
+ <InspectorIconButton
3143
+ label={t("editPanel.textAligns.left")}
3144
+ active={alignH === "left"}
3145
+ onClick={() => onStyleChange("justifyContent", "flex-start")}
3146
+ >
3147
+ <IconLayoutAlignLeft className="size-3.5" />
3148
+ </InspectorIconButton>
3149
+ <InspectorIconButton
3150
+ label={t("editPanel.textAligns.center")}
3151
+ active={alignH === "center"}
3152
+ onClick={() => onStyleChange("justifyContent", "center")}
3153
+ >
3154
+ <IconLayoutAlignCenter className="size-3.5" />
3155
+ </InspectorIconButton>
3156
+ <InspectorIconButton
3157
+ label={t("editPanel.textAligns.right")}
3158
+ active={alignH === "right"}
3159
+ onClick={() => onStyleChange("justifyContent", "flex-end")}
3160
+ >
3161
+ <IconLayoutAlignRight className="size-3.5" />
3162
+ </InspectorIconButton>
3163
+ </InspectorSegment>
3164
+ <InspectorSegment>
3165
+ <InspectorIconButton
3166
+ label={t("editPanel.alignSelfOptions.start")}
3167
+ active={alignV === "top"}
3168
+ onClick={() => onStyleChange("alignItems", "flex-start")}
3169
+ >
3170
+ <IconLayoutAlignTop className="size-3.5" />
3171
+ </InspectorIconButton>
3172
+ <InspectorIconButton
3173
+ label={t("editPanel.alignSelfOptions.center")}
3174
+ active={alignV === "middle"}
3175
+ onClick={() => onStyleChange("alignItems", "center")}
3176
+ >
3177
+ <IconLayoutAlignMiddle className="size-3.5" />
3178
+ </InspectorIconButton>
3179
+ <InspectorIconButton
3180
+ label={t("editPanel.alignSelfOptions.end")}
3181
+ active={alignV === "bottom"}
3182
+ onClick={() => onStyleChange("alignItems", "flex-end")}
3183
+ >
3184
+ <IconLayoutAlignBottom className="size-3.5" />
3185
+ </InspectorIconButton>
3186
+ </InspectorSegment>
3187
+ </div>
3188
+ </div>
3189
+
3190
+ <div className="space-y-1.5">
3191
+ <SubsectionLabel>{t("editPanel.labels.position")}</SubsectionLabel>
3192
+ <div className="grid grid-cols-2 gap-2">
3193
+ <ScrubStyleInput
3194
+ label="X"
3195
+ value={styles.left || ""}
3196
+ placeholder={element.boundingRect.x}
3197
+ inputClassName="h-6"
3198
+ onChange={(v) => onStyleChange("left", `${Math.round(v)}px`)}
3199
+ />
3200
+ <ScrubStyleInput
3201
+ label="Y"
3202
+ value={styles.top || ""}
3203
+ placeholder={element.boundingRect.y}
3204
+ inputClassName="h-6"
3205
+ onChange={(v) => onStyleChange("top", `${Math.round(v)}px`)}
3206
+ />
3207
+ </div>
3208
+ </div>
3209
+
3210
+ <div className="space-y-1.5">
3211
+ <SubsectionLabel>{t("editPanel.labels.rotation")}</SubsectionLabel>
3212
+ <div className="flex items-center gap-2">
3213
+ <div className="min-w-0 flex-1">
3214
+ <ScrubStyleInput
3215
+ label="R"
3216
+ value={`${parseRotationValue(styles.transform)}deg`}
3217
+ unit="deg"
3218
+ inputClassName="h-6"
3219
+ onChange={(v) =>
3220
+ onStyleChange(
3221
+ "transform",
3222
+ mergeRotationValue(styles.transform, v),
3223
+ )
3224
+ }
3225
+ />
3226
+ </div>
3227
+ <InspectorSegment>
3228
+ <InspectorIconButton
3229
+ label={t("editPanel.labels.flipHorizontal")}
3230
+ onClick={() => onStyleChange("scale", "-1 1")}
3231
+ >
3232
+ <IconFlipHorizontal className="size-4" />
3233
+ </InspectorIconButton>
3234
+ <InspectorIconButton
3235
+ label={t("editPanel.labels.flipVertical")}
3236
+ onClick={() => onStyleChange("scale", "1 -1")}
3237
+ >
3238
+ <IconFlipVertical className="size-4" />
3239
+ </InspectorIconButton>
3240
+ </InspectorSegment>
3241
+ </div>
3242
+ </div>
3243
+
3244
+ {constrainedPosition ? (
3245
+ <ConstraintsWidget
3246
+ value={constraintsValue}
3247
+ onChange={handleConstraintsChange}
3248
+ />
3249
+ ) : null}
3250
+ </PanelSection>
3251
+ );
3252
+ }
3253
+
3254
+ function FillProperties({
3255
+ element,
3256
+ onStyleChange,
3257
+ onStylesChange,
3258
+ }: {
3259
+ element: ElementInfo;
3260
+ onStyleChange: (property: string, value: string) => void;
3261
+ onStylesChange?: (styles: Record<string, string>) => void;
3262
+ }) {
3263
+ const t = useT();
3264
+ const styles = element.computedStyles;
3265
+ const isTextElement = TEXT_TAGS.has(element.tagName);
3266
+ const fillProperty = isTextElement ? "color" : "backgroundColor";
3267
+ const fillValue = isTextElement
3268
+ ? styles.color || ""
3269
+ : styles.backgroundColor || "";
3270
+ const backgroundLayers = isTextElement
3271
+ ? []
3272
+ : splitCssLayers(styles.backgroundImage || "");
3273
+ const hasBackgroundLayer = !isTextElement && backgroundLayers.length > 0;
3274
+ const hasVisibleFill =
3275
+ isTextElement || colorHasVisibleAlpha(fillValue) || hasBackgroundLayer;
3276
+
3277
+ // Non-destructive fill hide: stash the color before hiding so toggling
3278
+ // visible again restores the exact original value (Figma never loses color).
3279
+ // Keyed by `${element.id ?? tagName}:${fillProperty}` so separate elements
3280
+ // don't share stash slots.
3281
+ const [hiddenFillStash, setHiddenFillStash] = useState<
3282
+ Record<string, string>
3283
+ >({});
3284
+ const stashKey = `${element.id ?? element.tagName}:${fillProperty}`;
3285
+ const isHidden = fillValue === "transparent" || fillValue === "";
3286
+ const handleFillVisibilityToggle = () => {
3287
+ if (isHidden) {
3288
+ // Restore the stashed color, or fall back to a sensible default.
3289
+ const restored =
3290
+ hiddenFillStash[stashKey] ?? (isTextElement ? "#000000" : "#ffffff");
3291
+ onStyleChange(fillProperty, restored);
3292
+ setHiddenFillStash((prev) => {
3293
+ const next = { ...prev };
3294
+ delete next[stashKey];
3295
+ return next;
3296
+ });
3297
+ } else {
3298
+ // Stash the current color before going transparent.
3299
+ setHiddenFillStash((prev) => ({ ...prev, [stashKey]: fillValue }));
3300
+ onStyleChange(fillProperty, "transparent");
3301
+ }
3302
+ };
3303
+
3304
+ // Document colors: unique hex strings from all CSS color properties on the
3305
+ // selected element, collected via the existing selectionColorValues helper.
3306
+ const docColorHexes = selectionColorValues(element)
3307
+ .map((c) => {
3308
+ const parsed = parseCssColor(c.value);
3309
+ return parsed ? rgbaToHex(parsed) : null;
3310
+ })
3311
+ .filter((h): h is string => Boolean(h));
3312
+ // Deduplicate (selectionColorValues already dedupes by raw CSS value, but
3313
+ // hex normalisation may collapse additional entries e.g. rgb vs #hex).
3314
+ const seenHex = new Set<string>();
3315
+ const documentColors = docColorHexes.filter((h) => {
3316
+ const key = h.toUpperCase();
3317
+ if (seenHex.has(key)) return false;
3318
+ seenHex.add(key);
3319
+ return true;
3320
+ });
3321
+
3322
+ return (
3323
+ <PanelSection
3324
+ title={t("editPanel.sections.fill")}
3325
+ actions={
3326
+ <>
3327
+ {/* Figma color-styles affordance (grid icon) to the left of "+". */}
3328
+ <SectionIconButton
3329
+ label={"Styles" /* i18n-ignore Figma inspector action */}
3330
+ >
3331
+ <IconLayoutGrid className="size-3.5" />
3332
+ </SectionIconButton>
3333
+ <SectionIconButton
3334
+ label={t("editPanel.labels.addLayer")}
3335
+ onClick={() => {
3336
+ if (isTextElement) {
3337
+ onStyleChange(
3338
+ "color",
3339
+ cssColorOrFallback(styles.color, "#000000"),
3340
+ );
3341
+ return;
3342
+ }
3343
+ if (!colorHasVisibleAlpha(styles.backgroundColor)) {
3344
+ onStyleChange(
3345
+ "backgroundColor",
3346
+ cssColorOrFallback(styles.backgroundColor, "#ffffff"),
3347
+ );
3348
+ return;
3349
+ }
3350
+ const current = compactCssValue(styles.backgroundImage, "");
3351
+ const nextLayer = defaultGradientLayer(
3352
+ "linear",
3353
+ styles.backgroundColor || "#ffffff",
3354
+ );
3355
+ onStyleChange(
3356
+ "backgroundImage",
3357
+ current ? `${nextLayer}, ${current}` : nextLayer,
3358
+ );
3359
+ }}
3360
+ >
3361
+ <IconPlus className="size-3.5" />
3362
+ </SectionIconButton>
3363
+ </>
3364
+ }
3365
+ >
3366
+ {hasVisibleFill ? (
3367
+ <div className="space-y-1.5">
3368
+ {isTextElement || colorHasVisibleAlpha(fillValue) ? (
3369
+ /* Figma row: [swatch+hex trigger (flex-1)] [eye] [remove] */
3370
+ <div className="group flex items-center gap-1.5">
3371
+ <div className="min-w-0 flex-1">
3372
+ <ColorInput
3373
+ label=""
3374
+ value={fillValue}
3375
+ onChange={(v) => onStyleChange(fillProperty, v)}
3376
+ backgroundImage=""
3377
+ blendMode={
3378
+ isTextElement
3379
+ ? undefined
3380
+ : styles.backgroundBlendMode || "normal"
3381
+ }
3382
+ onBlendModeChange={
3383
+ isTextElement
3384
+ ? undefined
3385
+ : (v) => onStyleChange("backgroundBlendMode", v)
3386
+ }
3387
+ documentColors={documentColors}
3388
+ />
3389
+ </div>
3390
+ <SectionIconButton
3391
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3392
+ label={
3393
+ isHidden
3394
+ ? t("editPanel.labels.showLayer")
3395
+ : t("editPanel.labels.hideLayer")
3396
+ }
3397
+ onClick={handleFillVisibilityToggle}
3398
+ >
3399
+ {isHidden ? (
3400
+ <IconEyeOff className="size-3.5" />
3401
+ ) : (
3402
+ <IconEye className="size-3.5" />
3403
+ )}
3404
+ </SectionIconButton>
3405
+ <SectionIconButton
3406
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3407
+ label={t("editPanel.labels.removeLayer")}
3408
+ onClick={() => {
3409
+ if (isTextElement) {
3410
+ onStyleChange(fillProperty, "transparent");
3411
+ return;
3412
+ }
3413
+ if (onStylesChange) {
3414
+ onStylesChange({
3415
+ backgroundColor: "transparent",
3416
+ backgroundImage: "none",
3417
+ });
3418
+ } else {
3419
+ onStyleChange(fillProperty, "transparent");
3420
+ }
3421
+ }}
3422
+ >
3423
+ <IconMinus className="size-3.5" />
3424
+ </SectionIconButton>
3425
+ </div>
3426
+ ) : null}
3427
+ {!isTextElement
3428
+ ? backgroundLayers.map((layer, index) => {
3429
+ const gradient = parseGradientLayer(layer);
3430
+ const opacity = gradient
3431
+ ? averageGradientOpacity(gradient.stops)
3432
+ : 100;
3433
+ const label = gradient
3434
+ ? `${gradientLabel(gradient.type)} ${index + 1}`
3435
+ : `${"Image" /* i18n-ignore Figma inspector paint row */} ${
3436
+ index + 1
3437
+ }`;
3438
+ const replaceLayer = (nextLayer: string) => {
3439
+ const nextLayers = [...backgroundLayers];
3440
+ nextLayers[index] = nextLayer;
3441
+ onStyleChange("backgroundImage", joinCssLayers(nextLayers));
3442
+ };
3443
+ const removeLayer = () => {
3444
+ onStyleChange(
3445
+ "backgroundImage",
3446
+ joinCssLayers(
3447
+ backgroundLayers.filter(
3448
+ (_, layerIndex) => layerIndex !== index,
3449
+ ),
3450
+ ),
3451
+ );
3452
+ };
3453
+
3454
+ return (
3455
+ /* Figma row: [swatch+label+opacity% trigger (flex-1)] [eye] [remove] */
3456
+ <div
3457
+ key={`${layer}-${index}`}
3458
+ className="group flex items-center gap-1.5"
3459
+ >
3460
+ <Popover>
3461
+ <PopoverTrigger asChild>
3462
+ <button
3463
+ type="button"
3464
+ 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)]"
3465
+ >
3466
+ <span
3467
+ className="size-4 shrink-0 rounded-sm border border-[var(--design-editor-control-border)]"
3468
+ style={swatchStyle(layer)}
3469
+ />
3470
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
3471
+ {label}
3472
+ </span>
3473
+ <span className="shrink-0 tabular-nums text-muted-foreground">
3474
+ {opacity}%
3475
+ </span>
3476
+ </button>
3477
+ </PopoverTrigger>
3478
+ <PopoverContent
3479
+ side="left"
3480
+ align="start"
3481
+ sideOffset={8}
3482
+ className="w-80 p-0"
3483
+ >
3484
+ <FigmaColorPicker
3485
+ value={gradient?.stops[0]?.color ?? layer}
3486
+ onPaintValueChange={replaceLayer}
3487
+ onChange={(nextColor) => {
3488
+ if (!gradient) return;
3489
+ const firstStop = gradient.stops[0];
3490
+ if (!firstStop) return;
3491
+ replaceLayer(
3492
+ buildGradientLayer(gradient.type, [
3493
+ { ...firstStop, color: nextColor },
3494
+ ...gradient.stops.slice(1),
3495
+ ]),
3496
+ );
3497
+ }}
3498
+ paintType={gradient?.type ?? "image"}
3499
+ gradientType={gradient?.type}
3500
+ onGradientTypeChange={(type) => {
3501
+ if (!gradient) return;
3502
+ replaceLayer(
3503
+ buildGradientLayer(type, gradient.stops),
3504
+ );
3505
+ }}
3506
+ fillRows={[
3507
+ {
3508
+ id: `layer-${index}`,
3509
+ label,
3510
+ value: layer,
3511
+ type: gradient ? "gradient" : "image",
3512
+ selected: true,
3513
+ swatch: layer,
3514
+ },
3515
+ ]}
3516
+ selectedFillId={`layer-${index}`}
3517
+ />
3518
+ </PopoverContent>
3519
+ </Popover>
3520
+ <SectionIconButton
3521
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3522
+ label={
3523
+ opacity <= 0
3524
+ ? t("editPanel.labels.showLayer")
3525
+ : t("editPanel.labels.hideLayer")
3526
+ }
3527
+ onClick={() => {
3528
+ if (!gradient) return;
3529
+ replaceLayer(
3530
+ buildGradientLayer(
3531
+ gradient.type,
3532
+ gradient.stops.map((stop) => ({
3533
+ ...stop,
3534
+ opacity: opacity <= 0 ? 100 : 0,
3535
+ })),
3536
+ ),
3537
+ );
3538
+ }}
3539
+ >
3540
+ {opacity <= 0 ? (
3541
+ <IconEyeOff className="size-3.5" />
3542
+ ) : (
3543
+ <IconEye className="size-3.5" />
3544
+ )}
3545
+ </SectionIconButton>
3546
+ <SectionIconButton
3547
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3548
+ label={t("editPanel.labels.removeLayer")}
3549
+ onClick={removeLayer}
3550
+ >
3551
+ <IconMinus className="size-3.5" />
3552
+ </SectionIconButton>
3553
+ </div>
3554
+ );
3555
+ })
3556
+ : null}
3557
+ </div>
3558
+ ) : null}
3559
+ </PanelSection>
3560
+ );
3561
+ }
3562
+
3563
+ function StrokeProperties({
3564
+ element,
3565
+ onStyleChange,
3566
+ onStylesChange,
3567
+ }: {
3568
+ element: ElementInfo;
3569
+ onStyleChange: (property: string, value: string) => void;
3570
+ onStylesChange?: (styles: Record<string, string>) => void;
3571
+ }) {
3572
+ const t = useT();
3573
+ const styles = element.computedStyles;
3574
+ const borderVisible = strokeIsVisible(styles.borderWidth, styles.borderStyle);
3575
+ const outlineVisible = strokeIsVisible(
3576
+ styles.outlineWidth,
3577
+ styles.outlineStyle,
3578
+ );
3579
+
3580
+ return (
3581
+ <PanelSection
3582
+ title={t("editPanel.sections.stroke")}
3583
+ actions={
3584
+ <SectionIconButton
3585
+ label={t("editPanel.labels.addLayer")}
3586
+ onClick={() => {
3587
+ if (!borderVisible) {
3588
+ const borderColor = cssColorOrFallback(
3589
+ styles.borderColor || styles.color,
3590
+ "#000000",
3591
+ );
3592
+ commitStylePatch(
3593
+ {
3594
+ borderWidth: "1px",
3595
+ borderStyle: "solid",
3596
+ borderColor,
3597
+ },
3598
+ onStyleChange,
3599
+ onStylesChange,
3600
+ );
3601
+ return;
3602
+ }
3603
+ if (outlineVisible) {
3604
+ const outlineWidth = `${
3605
+ Math.max(1, cssLengthNumber(styles.outlineWidth, 1)) + 1
3606
+ }px`;
3607
+ const outlineStyle =
3608
+ styles.outlineStyle === "none"
3609
+ ? "solid"
3610
+ : styles.outlineStyle || "solid";
3611
+ const outlineColor = cssColorOrFallback(
3612
+ styles.outlineColor || styles.borderColor,
3613
+ "#000000",
3614
+ );
3615
+ commitStylePatch(
3616
+ {
3617
+ outlineWidth,
3618
+ outlineStyle,
3619
+ outlineColor,
3620
+ outlineOffset: styles.outlineOffset || "0px",
3621
+ },
3622
+ onStyleChange,
3623
+ onStylesChange,
3624
+ );
3625
+ return;
3626
+ }
3627
+ commitStylePatch(
3628
+ {
3629
+ outlineWidth: "1px",
3630
+ outlineStyle: "solid",
3631
+ outlineColor: cssColorOrFallback(styles.borderColor, "#000000"),
3632
+ outlineOffset: "0px",
3633
+ },
3634
+ onStyleChange,
3635
+ onStylesChange,
3636
+ );
3637
+ }}
3638
+ >
3639
+ <IconPlus className="size-3.5" />
3640
+ </SectionIconButton>
3641
+ }
3642
+ >
3643
+ {borderVisible ? (
3644
+ <StrokeLayerControl
3645
+ kind="border"
3646
+ visible={borderVisible}
3647
+ color={styles.borderColor || "#000000"}
3648
+ width={styles.borderWidth || "0px"}
3649
+ styleValue={styles.borderStyle || "none"}
3650
+ onStyleChange={onStyleChange}
3651
+ onRemove={() => {
3652
+ if (onStylesChange) {
3653
+ onStylesChange({ borderWidth: "0px", borderStyle: "none" });
3654
+ } else {
3655
+ onStyleChange("borderWidth", "0px");
3656
+ }
3657
+ }}
3658
+ />
3659
+ ) : null}
3660
+ {outlineVisible ? (
3661
+ <StrokeLayerControl
3662
+ kind="outline"
3663
+ visible={outlineVisible}
3664
+ color={styles.outlineColor || styles.borderColor || "#000000"}
3665
+ width={styles.outlineWidth || "0px"}
3666
+ styleValue={styles.outlineStyle || "solid"}
3667
+ onStyleChange={onStyleChange}
3668
+ onRemove={() => {
3669
+ if (onStylesChange) {
3670
+ onStylesChange({ outlineWidth: "0px", outlineStyle: "none" });
3671
+ } else {
3672
+ onStyleChange("outlineWidth", "0px");
3673
+ }
3674
+ }}
3675
+ />
3676
+ ) : null}
3677
+ </PanelSection>
3678
+ );
3679
+ }
3680
+
3681
+ function AppearanceProperties({
3682
+ element,
3683
+ onStyleChange,
3684
+ }: {
3685
+ element: ElementInfo;
3686
+ onStyleChange: (property: string, value: string) => void;
3687
+ }) {
3688
+ const t = useT();
3689
+ const styles = element.computedStyles;
3690
+ const hidden =
3691
+ styles.visibility === "hidden" ||
3692
+ styles.display === "none" ||
3693
+ parseNumericValue(styles.opacity || "1") === 0;
3694
+ return (
3695
+ <PanelSection
3696
+ title={t("root.commandAppearance")}
3697
+ actions={
3698
+ <>
3699
+ {/* Opacity / blend-mode affordance — matches Figma's pill icon */}
3700
+ <SectionIconButton
3701
+ label={
3702
+ "Opacity & blend mode" /* i18n-ignore Figma inspector action */
3703
+ }
3704
+ >
3705
+ <IconSlice className="size-3.5" />
3706
+ </SectionIconButton>
3707
+ {/* Visibility toggle */}
3708
+ <SectionIconToggle
3709
+ label={
3710
+ hidden
3711
+ ? "Show" /* i18n-ignore Figma inspector action */
3712
+ : "Hide" /* i18n-ignore Figma inspector action */
3713
+ }
3714
+ active={hidden}
3715
+ onClick={() =>
3716
+ onStyleChange("visibility", hidden ? "visible" : "hidden")
3717
+ }
3718
+ >
3719
+ {hidden ? (
3720
+ <IconEyeOff className="size-3.5" />
3721
+ ) : (
3722
+ <IconEye className="size-3.5" />
3723
+ )}
3724
+ </SectionIconToggle>
3725
+ {/* Styles / fill library affordance — matches Figma's droplet icon */}
3726
+ <SectionIconButton
3727
+ label={"Styles" /* i18n-ignore Figma inspector action */}
3728
+ >
3729
+ <IconDroplet className="size-3.5" />
3730
+ </SectionIconButton>
3731
+ </>
3732
+ }
3733
+ >
3734
+ <div className="grid grid-cols-2 gap-2">
3735
+ <ScrubInput
3736
+ label={t("editPanel.labels.opacity")}
3737
+ value={parseNumericValue(styles.opacity || "1") * 100}
3738
+ onChange={(v) => onStyleChange("opacity", String(v / 100))}
3739
+ min={0}
3740
+ max={100}
3741
+ step={1}
3742
+ unit="%"
3743
+ precision={1}
3744
+ labelClassName="w-0 overflow-hidden"
3745
+ inputClassName="h-6 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] shadow-none"
3746
+ />
3747
+ {/* M9: blend mode compact — inline select next to opacity, no separate labeled row */}
3748
+ <Select
3749
+ value={optionValue(
3750
+ BLEND_MODE_OPTIONS,
3751
+ styles.mixBlendMode || "normal",
3752
+ "normal",
3753
+ )}
3754
+ onValueChange={(value) => onStyleChange("mixBlendMode", value)}
3755
+ >
3756
+ <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)]">
3757
+ <SelectValue />
3758
+ </SelectTrigger>
3759
+ <SelectContent>
3760
+ {BLEND_MODE_OPTIONS.map((opt) => (
3761
+ <SelectItem
3762
+ key={opt.value}
3763
+ value={opt.value}
3764
+ className="text-[11px]"
3765
+ >
3766
+ {opt.label}
3767
+ </SelectItem>
3768
+ ))}
3769
+ </SelectContent>
3770
+ </Select>
3771
+ </div>
3772
+ {/* M7: use CornerRadiusControl (with independent-corners toggle) instead of bare ScrubInput */}
3773
+ <CornerRadiusControl styles={styles} onStyleChange={onStyleChange} />
3774
+ </PanelSection>
3775
+ );
3776
+ }
3777
+
3778
+ function EffectsProperties({
3779
+ element,
3780
+ onStyleChange,
3781
+ onStylesChange,
3782
+ }: {
3783
+ element: ElementInfo;
3784
+ onStyleChange: (property: string, value: string) => void;
3785
+ onStylesChange?: (styles: Record<string, string>) => void;
3786
+ }) {
3787
+ const t = useT();
3788
+ const styles = element.computedStyles;
3789
+ const blurValue = readBlurFilter(styles.filter);
3790
+ // M5 · Background (backdrop) blur is a distinct Figma effect type, backed by
3791
+ // CSS `backdrop-filter: blur()` (vs layer blur's `filter: blur()`).
3792
+ const backdropBlurValue = readBlurFilter(
3793
+ styles.backdropFilter || styles.webkitBackdropFilter,
3794
+ );
3795
+ const shadowLayers = parseShadowLayers(styles.boxShadow);
3796
+ const setShadowLayers = (layers: ShadowLayer[]) => {
3797
+ const boxShadow = serializeShadowLayers(layers);
3798
+ if (onStylesChange) onStylesChange({ boxShadow });
3799
+ else onStyleChange("boxShadow", boxShadow);
3800
+ };
3801
+ const addDropShadow = () =>
3802
+ setShadowLayers([
3803
+ ...shadowLayers,
3804
+ defaultDropShadowLayer(shadowLayers.length),
3805
+ ]);
3806
+ const addLayerBlur = () => onStyleChange("filter", "blur(4px)");
3807
+ const addBackgroundBlur = () => onStyleChange("backdropFilter", "blur(8px)");
3808
+
3809
+ return (
3810
+ <PanelSection
3811
+ title={t("editPanel.sections.effects")}
3812
+ actions={
3813
+ <DropdownMenu>
3814
+ <DropdownMenuTrigger asChild>
3815
+ <Button
3816
+ type="button"
3817
+ variant="ghost"
3818
+ size="icon"
3819
+ className="size-6 cursor-pointer rounded-md text-muted-foreground hover:text-foreground"
3820
+ aria-label={t("editPanel.labels.addLayer")}
3821
+ >
3822
+ <IconPlus className="size-3.5" />
3823
+ </Button>
3824
+ </DropdownMenuTrigger>
3825
+ <DropdownMenuContent align="end" className="min-w-44">
3826
+ <DropdownMenuItem
3827
+ className="gap-2 text-[11px]"
3828
+ onSelect={addDropShadow}
3829
+ >
3830
+ <IconShadow className="size-3.5" />
3831
+ {t("editPanel.labels.dropShadow")}
3832
+ </DropdownMenuItem>
3833
+ <DropdownMenuItem
3834
+ className="gap-2 text-[11px]"
3835
+ onSelect={addLayerBlur}
3836
+ >
3837
+ <IconBlur className="size-3.5" />
3838
+ {t("editPanel.labels.layerBlur")}
3839
+ </DropdownMenuItem>
3840
+ <DropdownMenuItem
3841
+ className="gap-2 text-[11px]"
3842
+ onSelect={addBackgroundBlur}
3843
+ >
3844
+ <IconBackground className="size-3.5" />
3845
+ {"Background blur" /* i18n-ignore Figma effect type */}
3846
+ </DropdownMenuItem>
3847
+ </DropdownMenuContent>
3848
+ </DropdownMenu>
3849
+ }
3850
+ >
3851
+ {shadowLayers.length ? (
3852
+ <div className="space-y-1.5">
3853
+ {shadowLayers.map((layer, index) => (
3854
+ <ShadowEffectRow
3855
+ key={layer.id}
3856
+ layer={layer}
3857
+ index={index}
3858
+ onChange={(patch) => {
3859
+ const next = shadowLayers.map((candidate) =>
3860
+ candidate.id === layer.id
3861
+ ? { ...candidate, ...patch }
3862
+ : candidate,
3863
+ );
3864
+ setShadowLayers(next);
3865
+ }}
3866
+ onRemove={() =>
3867
+ setShadowLayers(
3868
+ shadowLayers.filter((candidate) => candidate.id !== layer.id),
3869
+ )
3870
+ }
3871
+ />
3872
+ ))}
3873
+ </div>
3874
+ ) : null}
3875
+ {blurValue > 0 ? (
3876
+ /* Figma effect row for layer blur: flat row matching shadow rows */
3877
+ <Popover>
3878
+ <div className="group flex items-center gap-1.5">
3879
+ <PopoverTrigger asChild>
3880
+ <button
3881
+ type="button"
3882
+ 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)]"
3883
+ >
3884
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
3885
+ {t("editPanel.labels.layerBlur")}
3886
+ </span>
3887
+ <span className="shrink-0 tabular-nums text-muted-foreground">
3888
+ {Math.round(blurValue)}px
3889
+ </span>
3890
+ </button>
3891
+ </PopoverTrigger>
3892
+ <SectionIconButton
3893
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3894
+ label={t("editPanel.labels.removeLayer")}
3895
+ onClick={() => onStyleChange("filter", "none")}
3896
+ disabled={!styles.filter || styles.filter === "none"}
3897
+ >
3898
+ <IconMinus className="size-3.5" />
3899
+ </SectionIconButton>
3900
+ </div>
3901
+ <PopoverContent
3902
+ side="left"
3903
+ align="start"
3904
+ sideOffset={8}
3905
+ className="w-56 p-3"
3906
+ >
3907
+ <ScrubInput
3908
+ label={t("editPanel.labels.blur")}
3909
+ value={blurValue}
3910
+ onChange={(value) =>
3911
+ onStyleChange(
3912
+ "filter",
3913
+ `blur(${Math.max(0, Math.round(value))}px)`,
3914
+ )
3915
+ }
3916
+ unit="px"
3917
+ min={0}
3918
+ precision={1}
3919
+ labelClassName="w-16"
3920
+ inputClassName="h-6"
3921
+ />
3922
+ </PopoverContent>
3923
+ </Popover>
3924
+ ) : null}
3925
+ {backdropBlurValue > 0 ? (
3926
+ /* M5 · Background (backdrop) blur effect row — mirrors the layer-blur row */
3927
+ <Popover>
3928
+ <div className="group flex items-center gap-1.5">
3929
+ <PopoverTrigger asChild>
3930
+ <button
3931
+ type="button"
3932
+ 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)]"
3933
+ >
3934
+ <span className="min-w-0 flex-1 truncate font-medium text-foreground">
3935
+ {"Background blur" /* i18n-ignore Figma effect type */}
3936
+ </span>
3937
+ <span className="shrink-0 tabular-nums text-muted-foreground">
3938
+ {Math.round(backdropBlurValue)}px
3939
+ </span>
3940
+ </button>
3941
+ </PopoverTrigger>
3942
+ <SectionIconButton
3943
+ className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
3944
+ label={t("editPanel.labels.removeLayer")}
3945
+ onClick={() => onStyleChange("backdropFilter", "none")}
3946
+ disabled={
3947
+ !styles.backdropFilter || styles.backdropFilter === "none"
3948
+ }
3949
+ >
3950
+ <IconMinus className="size-3.5" />
3951
+ </SectionIconButton>
3952
+ </div>
3953
+ <PopoverContent
3954
+ side="left"
3955
+ align="start"
3956
+ sideOffset={8}
3957
+ className="w-56 p-3"
3958
+ >
3959
+ <ScrubInput
3960
+ label={t("editPanel.labels.blur")}
3961
+ value={backdropBlurValue}
3962
+ onChange={(value) =>
3963
+ onStyleChange(
3964
+ "backdropFilter",
3965
+ `blur(${Math.max(0, Math.round(value))}px)`,
3966
+ )
3967
+ }
3968
+ unit="px"
3969
+ min={0}
3970
+ precision={1}
3971
+ labelClassName="w-16"
3972
+ inputClassName="h-6"
3973
+ />
3974
+ </PopoverContent>
3975
+ </Popover>
3976
+ ) : null}
3977
+ </PanelSection>
3978
+ );
3979
+ }
3980
+
3981
+ interface SelectionColorValue {
3982
+ property: string;
3983
+ value: string;
3984
+ }
3985
+
3986
+ function selectionColorValues(element: ElementInfo): SelectionColorValue[] {
3987
+ const styles = element.computedStyles;
3988
+ const rawValues: SelectionColorValue[] = [
3989
+ { property: "color", value: styles.color },
3990
+ { property: "backgroundColor", value: styles.backgroundColor },
3991
+ { property: "borderColor", value: styles.borderColor },
3992
+ { property: "outlineColor", value: styles.outlineColor },
3993
+ ];
3994
+ const seen = new Set<string>();
3995
+ return rawValues
3996
+ .map((color) => ({ ...color, value: color.value?.trim() }))
3997
+ .filter((color): color is SelectionColorValue => Boolean(color.value))
3998
+ .filter(
3999
+ (color) =>
4000
+ color.value !== "transparent" && color.value !== "rgba(0, 0, 0, 0)",
4001
+ )
4002
+ .filter((color) => {
4003
+ const key = color.value.toLowerCase();
4004
+ if (seen.has(key)) return false;
4005
+ seen.add(key);
4006
+ return true;
4007
+ });
4008
+ }
4009
+
4010
+ /** Uppercase 6-char hex (no #) for a CSS color, matching Figma's row readout. */
4011
+ function selectionDisplayHex(value: string): string {
4012
+ const parsed = parseCssColor(value);
4013
+ if (!parsed) return value.replace(/^#/, "").toUpperCase();
4014
+ return rgbaToHex(parsed).replace(/^#/, "").toUpperCase();
4015
+ }
4016
+
4017
+ function SelectionColorsProperties({
4018
+ element,
4019
+ onStyleChange,
4020
+ }: {
4021
+ element: ElementInfo;
4022
+ onStyleChange: (property: string, value: string) => void;
4023
+ }) {
4024
+ // M6 · Figma's Selection colors collapses to a single "Show selection colors"
4025
+ // affordance, expanding to one editable [swatch · hex · opacity] row per
4026
+ // unique color — matching the Fill row grammar instead of a swatch strip.
4027
+ const [expanded, setExpanded] = useState(false);
4028
+ const colors = selectionColorValues(element);
4029
+ if (!colors.length) return null;
4030
+
4031
+ return (
4032
+ <PanelSection
4033
+ title={"Selection colors" /* i18n-ignore Figma inspector label */}
4034
+ >
4035
+ {expanded ? (
4036
+ <div className="space-y-1.5">
4037
+ {colors.map((color, index) => {
4038
+ const parsed = parseCssColor(color.value);
4039
+ const opacity = parsed ? alphaToOpacity(parsed.a) : 100;
4040
+ return (
4041
+ <Popover key={`${color.value}-${index}`}>
4042
+ <PopoverTrigger asChild>
4043
+ <button
4044
+ type="button"
4045
+ className="flex h-6 w-full items-center gap-1.5 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-[11px] hover:bg-[var(--design-editor-panel-raised-bg)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
4046
+ aria-label={color.value}
4047
+ >
4048
+ <span
4049
+ className="size-4 shrink-0 rounded-[3px] border border-border/60"
4050
+ style={swatchStyle(color.value)}
4051
+ />
4052
+ <span className="min-w-0 flex-1 truncate text-left uppercase tabular-nums">
4053
+ {selectionDisplayHex(color.value)}
4054
+ </span>
4055
+ <span className="shrink-0 tabular-nums text-muted-foreground">
4056
+ {opacity}%
4057
+ </span>
4058
+ </button>
4059
+ </PopoverTrigger>
4060
+ <PopoverContent
4061
+ side="left"
4062
+ align="start"
4063
+ sideOffset={8}
4064
+ className="w-80 p-0"
4065
+ >
4066
+ <FigmaColorPicker
4067
+ value={cssColorOrFallback(color.value, "#000000")}
4068
+ onChange={(value) => onStyleChange(color.property, value)}
4069
+ />
4070
+ </PopoverContent>
4071
+ </Popover>
4072
+ );
4073
+ })}
4074
+ </div>
4075
+ ) : (
4076
+ <button
4077
+ type="button"
4078
+ className="flex h-6 w-full items-center justify-between gap-2 rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-left text-[11px] text-muted-foreground hover:bg-[var(--design-editor-panel-raised-bg)] hover:text-foreground"
4079
+ onClick={() => setExpanded(true)}
4080
+ >
4081
+ <span className="truncate">
4082
+ {"Show selection colors" /* i18n-ignore Figma inspector label */}
4083
+ </span>
4084
+ <div className="flex shrink-0 items-center -space-x-1">
4085
+ {colors.slice(0, 3).map((color, index) => (
4086
+ <span
4087
+ key={`${color.value}-${index}`}
4088
+ className="size-3.5 rounded-sm border border-[var(--design-editor-panel-bg)]"
4089
+ style={swatchStyle(color.value)}
4090
+ />
4091
+ ))}
4092
+ </div>
4093
+ </button>
4094
+ )}
4095
+ </PanelSection>
4096
+ );
4097
+ }
4098
+
692
4099
  const TEXT_TAGS = new Set([
693
4100
  "p",
694
4101
  "h1",
@@ -708,66 +4115,185 @@ const TEXT_TAGS = new Set([
708
4115
  export function EditPanel({
709
4116
  selectedElement,
710
4117
  pageStyles = {},
4118
+ headerTrailing,
4119
+ width = 256,
4120
+ activeTab = "design",
4121
+ onActiveTabChange,
4122
+ tweaks = [],
4123
+ tweakValues = {},
4124
+ extensionContext,
4125
+ onTweakChange,
4126
+ onRequestTweaks,
711
4127
  onStyleChange,
4128
+ onStylesChange,
4129
+ onExport,
4130
+ exporting = false,
712
4131
  }: EditPanelProps) {
713
4132
  const t = useT();
4133
+ const [exportSettings, setExportSettings] = useState<ExportSettingsValue>({
4134
+ scale: 1,
4135
+ format: "png",
4136
+ suffix: "",
4137
+ });
4138
+ const [showExportPreview, setShowExportPreview] = useState(false);
714
4139
  const isTextElement = selectedElement
715
4140
  ? TEXT_TAGS.has(selectedElement.tagName)
716
4141
  : false;
717
- const isFlexContainer = selectedElement?.isFlexContainer ?? false;
4142
+ const handleActiveTabChange = useCallback(
4143
+ (tab: InspectorTab) => onActiveTabChange?.(tab),
4144
+ [onActiveTabChange],
4145
+ );
4146
+ const handleTweakChange = useCallback(
4147
+ (tweakId: string, value: string | number | boolean) => {
4148
+ onTweakChange?.(tweakId, value);
4149
+ },
4150
+ [onTweakChange],
4151
+ );
718
4152
 
719
4153
  return (
720
4154
  <div
721
4155
  className={cn(
722
- "w-64 border-l border-border bg-background overflow-y-auto",
723
- "flex flex-col",
4156
+ "shrink-0 bg-[var(--design-editor-panel-bg)]",
4157
+ "flex h-full min-h-0 flex-col overflow-hidden",
724
4158
  )}
4159
+ style={{ width }}
725
4160
  >
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>
4161
+ <InspectorTabsHeader
4162
+ activeTab={activeTab}
4163
+ onActiveTabChange={handleActiveTabChange}
4164
+ trailing={headerTrailing}
4165
+ />
738
4166
 
739
- <div className="flex-1 p-3 space-y-4 overflow-y-auto">
740
- {!selectedElement && (
741
- <PageProperties styles={pageStyles} onStyleChange={onStyleChange} />
742
- )}
4167
+ {activeTab === "design" ? (
4168
+ <>
4169
+ <SelectionHeader element={selectedElement} />
743
4170
 
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}
4171
+ <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
4172
+ {!selectedElement && (
4173
+ <PageProperties
4174
+ styles={pageStyles}
4175
+ onStyleChange={onStyleChange}
4176
+ />
4177
+ )}
4178
+
4179
+ {selectedElement && (
4180
+ <>
4181
+ <PositionLayoutProperties
4182
+ element={selectedElement}
4183
+ onStyleChange={onStyleChange}
4184
+ />
4185
+ <LayoutContextProperties
4186
+ element={selectedElement}
4187
+ onStyleChange={onStyleChange}
4188
+ onStylesChange={onStylesChange}
4189
+ />
4190
+ <AppearanceProperties
4191
+ element={selectedElement}
4192
+ onStyleChange={onStyleChange}
4193
+ />
4194
+ {isTextElement ? (
4195
+ <TypographyProperties
4196
+ element={selectedElement}
4197
+ onStyleChange={onStyleChange}
4198
+ />
4199
+ ) : null}
4200
+ <FillProperties
4201
+ element={selectedElement}
4202
+ onStyleChange={onStyleChange}
4203
+ onStylesChange={onStylesChange}
4204
+ />
4205
+ <StrokeProperties
4206
+ element={selectedElement}
4207
+ onStyleChange={onStyleChange}
4208
+ onStylesChange={onStylesChange}
4209
+ />
4210
+ <EffectsProperties
4211
+ element={selectedElement}
4212
+ onStyleChange={onStyleChange}
4213
+ onStylesChange={onStylesChange}
4214
+ />
4215
+ <SelectionColorsProperties
4216
+ element={selectedElement}
4217
+ onStyleChange={onStyleChange}
4218
+ />
4219
+ {isContainerElement(selectedElement) ? (
4220
+ <LayoutGuideProperties
4221
+ element={selectedElement}
4222
+ onStyleChange={onStyleChange}
4223
+ />
4224
+ ) : null}
4225
+ </>
4226
+ )}
4227
+ {onExport ? (
4228
+ <PanelSection
4229
+ title={t("editPanel.sections.export")}
4230
+ actions={
4231
+ <SectionIconToggle
4232
+ label={
4233
+ showExportPreview
4234
+ ? "Hide preview" /* i18n-ignore Figma inspector action */
4235
+ : "Show preview" /* i18n-ignore Figma inspector action */
4236
+ }
4237
+ active={showExportPreview}
4238
+ onClick={() => setShowExportPreview((shown) => !shown)}
4239
+ >
4240
+ <IconPhoto className="size-3.5" />
4241
+ </SectionIconToggle>
4242
+ }
4243
+ >
4244
+ <ExportSettingsPanel
4245
+ value={exportSettings}
4246
+ formats={["png", "svg"]}
4247
+ exporting={exporting}
4248
+ onChange={(patch) =>
4249
+ setExportSettings((current) => ({ ...current, ...patch }))
4250
+ }
4251
+ onExport={onExport}
4252
+ />
4253
+ {showExportPreview ? (
4254
+ <ExportPreview element={selectedElement} />
4255
+ ) : null}
4256
+ </PanelSection>
4257
+ ) : null}
4258
+ </div>
4259
+ </>
4260
+ ) : activeTab === "tweaks" ? (
4261
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
4262
+ <div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
4263
+ <h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
4264
+ {t("designEditor.tweaks")}
4265
+ </h3>
4266
+ {onRequestTweaks ? (
4267
+ <Tooltip>
4268
+ <TooltipTrigger asChild>
4269
+ <Button
4270
+ type="button"
4271
+ variant="ghost"
4272
+ size="icon"
4273
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
4274
+ aria-label={t("designEditor.addTweaks")}
4275
+ onClick={(event) => onRequestTweaks(event.currentTarget)}
4276
+ >
4277
+ <IconPlus className="size-3.5" />
4278
+ </Button>
4279
+ </TooltipTrigger>
4280
+ <TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
4281
+ </Tooltip>
4282
+ ) : null}
4283
+ </div>
4284
+ <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
4285
+ <TweaksPanelContent
4286
+ tweaks={tweaks}
4287
+ values={tweakValues}
4288
+ onChange={handleTweakChange}
4289
+ onRequestTweaks={onRequestTweaks}
4290
+ className="px-3 py-3"
759
4291
  />
760
- <Separator />
761
- </>
762
- )}
763
-
764
- {selectedElement && (
765
- <ElementProperties
766
- element={selectedElement}
767
- onStyleChange={onStyleChange}
768
- />
769
- )}
770
- </div>
4292
+ </div>
4293
+ </div>
4294
+ ) : extensionContext ? (
4295
+ <DesignExtensionsPanel context={extensionContext} />
4296
+ ) : null}
771
4297
  </div>
772
4298
  );
773
4299
  }