@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
@@ -0,0 +1,2010 @@
1
+ import {
2
+ alphaToOpacity,
3
+ parseCssColor,
4
+ rgbaToCss,
5
+ rgbaToHex,
6
+ rgbaToHsl,
7
+ hslToRgba,
8
+ opacityToAlpha,
9
+ withColorOpacity,
10
+ type HslaColor,
11
+ type RgbaColor,
12
+ } from "@shared/color-utils";
13
+ import type { ShaderDescriptor } from "@shared/shader-presets";
14
+ import { IconChevronDown, IconColorPicker } from "@tabler/icons-react";
15
+ import {
16
+ useEffect,
17
+ useRef,
18
+ useState,
19
+ type JSX,
20
+ type KeyboardEvent,
21
+ type PointerEvent,
22
+ } from "react";
23
+
24
+ import { Input } from "@/components/ui/input";
25
+ import {
26
+ Popover,
27
+ PopoverContent,
28
+ PopoverTrigger,
29
+ } from "@/components/ui/popover";
30
+ import {
31
+ Tooltip,
32
+ TooltipContent,
33
+ TooltipTrigger,
34
+ } from "@/components/ui/tooltip";
35
+ import { cn } from "@/lib/utils";
36
+
37
+ import {
38
+ GradientEditor,
39
+ defaultGradient,
40
+ gradientToCss,
41
+ parseGradientCss,
42
+ type GradientKind,
43
+ type GradientValue,
44
+ } from "./GradientEditor";
45
+ import {
46
+ ImageFillControls,
47
+ imageFillToCss,
48
+ parseImageFillCss,
49
+ type ImageFillValue,
50
+ } from "./ImageFillControls";
51
+ import { ShaderFillsPanel, shaderDescriptorToCss } from "./ShaderFillsPanel";
52
+
53
+ // ─── Public types ──────────────────────────────────────────────────────────────
54
+
55
+ export type FigmaColorMode = "hex" | "rgb" | "hsl" | "hsb";
56
+ export type FigmaGradientType = "linear" | "radial" | "angular" | "diamond";
57
+ export type FigmaFillType = "solid" | "gradient" | "image";
58
+ export type FigmaPaintType =
59
+ | "solid"
60
+ | "linear"
61
+ | "radial"
62
+ | "angular"
63
+ | "diamond"
64
+ | "image"
65
+ | "video"
66
+ | "shader"
67
+ | "noise"
68
+ | "pattern"
69
+ | "none";
70
+
71
+ // These interfaces remain so EditPanel's prop types don't break, even though
72
+ // the popover no longer renders the fills/gradient-stops list.
73
+ export interface FigmaFillRow {
74
+ id: string;
75
+ label: string;
76
+ value: string;
77
+ type: FigmaFillType;
78
+ opacity?: number;
79
+ swatch?: string;
80
+ selected?: boolean;
81
+ }
82
+
83
+ export interface FigmaFillRowPatch {
84
+ value?: string;
85
+ opacity?: number;
86
+ }
87
+
88
+ export interface FigmaGradientStop {
89
+ id: string;
90
+ color: string;
91
+ position: number;
92
+ opacity?: number;
93
+ label?: string;
94
+ }
95
+
96
+ export interface FigmaGradientStopPatch {
97
+ color?: string;
98
+ position?: number;
99
+ opacity?: number;
100
+ }
101
+
102
+ export interface FigmaColorPickerLabels {
103
+ trigger: string;
104
+ hex: string;
105
+ red: string;
106
+ green: string;
107
+ blue: string;
108
+ hue: string;
109
+ saturation: string;
110
+ saturationBrightness: string;
111
+ lightness: string;
112
+ brightness: string;
113
+ opacity: string;
114
+ blendMode: string;
115
+ fills: string;
116
+ addFill: string;
117
+ removeFill: string;
118
+ gradientType: string;
119
+ gradientStops: string;
120
+ addStop: string;
121
+ removeStop: string;
122
+ stopPosition: string;
123
+ linear: string;
124
+ radial: string;
125
+ angular: string;
126
+ diamond: string;
127
+ }
128
+
129
+ export interface FigmaColorPickerProps {
130
+ value: string;
131
+ onChange: (value: string) => void;
132
+ onPaintValueChange?: (value: string) => void;
133
+ label?: string;
134
+ opacity?: number;
135
+ onOpacityChange?: (opacity: number) => void;
136
+ blendMode?: string;
137
+ onBlendModeChange?: (mode: string) => void;
138
+ showBlendMode?: boolean;
139
+ // Accepted but unused in the popover — list management lives in the sidebar.
140
+ fillRows?: FigmaFillRow[];
141
+ selectedFillId?: string;
142
+ onFillSelect?: (id: string) => void;
143
+ onFillChange?: (id: string, patch: FigmaFillRowPatch) => void;
144
+ onAddFill?: () => void;
145
+ onRemoveFill?: (id: string) => void;
146
+ paintType?: FigmaPaintType;
147
+ onPaintTypeChange?: (type: FigmaPaintType) => void;
148
+ gradientType?: FigmaGradientType;
149
+ onGradientTypeChange?: (type: FigmaGradientType) => void;
150
+ // Accepted but unused in the popover — gradient stop handles belong on canvas.
151
+ gradientStops?: FigmaGradientStop[];
152
+ selectedStopId?: string;
153
+ onGradientStopSelect?: (id: string) => void;
154
+ onGradientStopChange?: (id: string, patch: FigmaGradientStopPatch) => void;
155
+ onAddGradientStop?: () => void;
156
+ onRemoveGradientStop?: (id: string) => void;
157
+ /**
158
+ * Colors already present in the document (e.g. unique hex values collected
159
+ * from the current selection or page). Rendered as a swatch grid under the
160
+ * "Document colors" heading — click any swatch to apply it. Deduplicated and
161
+ * limited in the caller; the component renders whatever is passed.
162
+ */
163
+ documentColors?: string[];
164
+ /**
165
+ * Optional design context forwarded to the apply-shader action when a shader
166
+ * fill is selected, so the agent can write real shader code for the target.
167
+ */
168
+ shaderContext?: {
169
+ designId?: string;
170
+ fileId?: string;
171
+ nodeId?: string;
172
+ selector?: string;
173
+ };
174
+ /** Notified when a shader fill is applied/tuned (descriptor + CSS fallback). */
175
+ onShaderChange?: (descriptor: ShaderDescriptor, css: string) => void;
176
+ labels?: Partial<FigmaColorPickerLabels>;
177
+ disabled?: boolean;
178
+ className?: string;
179
+ }
180
+
181
+ // ─── Internal types ────────────────────────────────────────────────────────────
182
+
183
+ interface HsvaColor {
184
+ h: number;
185
+ s: number;
186
+ v: number;
187
+ a: number;
188
+ }
189
+
190
+ // ─── Constants ─────────────────────────────────────────────────────────────────
191
+
192
+ const FALLBACK_COLOR: RgbaColor = { r: 0, g: 0, b: 0, a: 1 };
193
+
194
+ const DEFAULT_LABELS: FigmaColorPickerLabels = {
195
+ trigger: "Open color picker", // i18n-ignore fallback component label
196
+ hex: "Hex", // i18n-ignore fallback component label
197
+ red: "R", // i18n-ignore fallback component label
198
+ green: "G", // i18n-ignore fallback component label
199
+ blue: "B", // i18n-ignore fallback component label
200
+ hue: "H", // i18n-ignore fallback component label
201
+ saturation: "S", // i18n-ignore fallback component label
202
+ saturationBrightness: "Saturation and brightness", // i18n-ignore fallback component label
203
+ lightness: "L", // i18n-ignore fallback component label
204
+ brightness: "B", // i18n-ignore fallback component label
205
+ opacity: "Opacity", // i18n-ignore fallback component label
206
+ blendMode: "Blend", // i18n-ignore fallback component label
207
+ fills: "Fills", // i18n-ignore fallback component label
208
+ addFill: "Add fill", // i18n-ignore fallback component label
209
+ removeFill: "Remove fill", // i18n-ignore fallback component label
210
+ gradientType: "Type", // i18n-ignore fallback component label
211
+ gradientStops: "Gradient stops", // i18n-ignore fallback component label
212
+ addStop: "Add stop", // i18n-ignore fallback component label
213
+ removeStop: "Remove stop", // i18n-ignore fallback component label
214
+ stopPosition: "Position", // i18n-ignore fallback component label
215
+ linear: "Linear", // i18n-ignore fallback component label
216
+ radial: "Radial", // i18n-ignore fallback component label
217
+ angular: "Angular", // i18n-ignore fallback component label
218
+ diamond: "Diamond", // i18n-ignore fallback component label
219
+ };
220
+
221
+ // Figma-matching checkerboard: explicit light/dark tiles for legibility.
222
+ const CHECKER_A = "#d4d4d4";
223
+ const CHECKER_B = "#a3a3a3";
224
+ const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`;
225
+
226
+ // ─── Paint-type icon SVGs (Tabler style, distinct per type) ────────────────────
227
+
228
+ function IconSolid({ className }: { className?: string }) {
229
+ return (
230
+ <svg
231
+ viewBox="0 0 24 24"
232
+ fill="none"
233
+ stroke="currentColor"
234
+ strokeWidth={2}
235
+ strokeLinecap="round"
236
+ strokeLinejoin="round"
237
+ className={className}
238
+ >
239
+ <rect
240
+ x="4"
241
+ y="4"
242
+ width="16"
243
+ height="16"
244
+ rx="2"
245
+ fill="currentColor"
246
+ stroke="none"
247
+ />
248
+ </svg>
249
+ );
250
+ }
251
+
252
+ function IconLinearGradient({ className }: { className?: string }) {
253
+ return (
254
+ <svg
255
+ viewBox="0 0 24 24"
256
+ fill="none"
257
+ stroke="currentColor"
258
+ strokeWidth={2}
259
+ strokeLinecap="round"
260
+ strokeLinejoin="round"
261
+ className={className}
262
+ >
263
+ <defs>
264
+ <linearGradient id="lg-ico" x1="0" y1="0" x2="1" y2="0">
265
+ <stop offset="0%" stopColor="currentColor" stopOpacity="0" />
266
+ <stop offset="100%" stopColor="currentColor" stopOpacity="1" />
267
+ </linearGradient>
268
+ </defs>
269
+ <rect
270
+ x="4"
271
+ y="4"
272
+ width="16"
273
+ height="16"
274
+ rx="2"
275
+ fill="url(#lg-ico)"
276
+ stroke="currentColor"
277
+ strokeOpacity="0.5"
278
+ />
279
+ </svg>
280
+ );
281
+ }
282
+
283
+ function IconRadialGradient({ className }: { className?: string }) {
284
+ return (
285
+ <svg
286
+ viewBox="0 0 24 24"
287
+ fill="none"
288
+ stroke="currentColor"
289
+ strokeWidth={2}
290
+ strokeLinecap="round"
291
+ strokeLinejoin="round"
292
+ className={className}
293
+ >
294
+ <defs>
295
+ <radialGradient id="rg-ico" cx="50%" cy="50%" r="50%">
296
+ <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
297
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
298
+ </radialGradient>
299
+ </defs>
300
+ <rect
301
+ x="4"
302
+ y="4"
303
+ width="16"
304
+ height="16"
305
+ rx="2"
306
+ fill="url(#rg-ico)"
307
+ stroke="currentColor"
308
+ strokeOpacity="0.5"
309
+ />
310
+ </svg>
311
+ );
312
+ }
313
+
314
+ function IconAngularGradient({ className }: { className?: string }) {
315
+ return (
316
+ <svg
317
+ viewBox="0 0 24 24"
318
+ fill="none"
319
+ stroke="currentColor"
320
+ strokeWidth={2}
321
+ strokeLinecap="round"
322
+ strokeLinejoin="round"
323
+ className={className}
324
+ >
325
+ <defs>
326
+ <linearGradient id="ag-ico" x1="0" y1="0" x2="1" y2="1">
327
+ <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
328
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
329
+ </linearGradient>
330
+ </defs>
331
+ <rect
332
+ x="4"
333
+ y="4"
334
+ width="16"
335
+ height="16"
336
+ rx="2"
337
+ fill="url(#ag-ico)"
338
+ stroke="currentColor"
339
+ strokeOpacity="0.5"
340
+ />
341
+ <line
342
+ x1="12"
343
+ y1="4"
344
+ x2="12"
345
+ y2="20"
346
+ stroke="currentColor"
347
+ strokeOpacity="0.4"
348
+ strokeDasharray="2 2"
349
+ />
350
+ <line
351
+ x1="4"
352
+ y1="12"
353
+ x2="20"
354
+ y2="12"
355
+ stroke="currentColor"
356
+ strokeOpacity="0.4"
357
+ strokeDasharray="2 2"
358
+ />
359
+ </svg>
360
+ );
361
+ }
362
+
363
+ function IconDiamondGradient({ className }: { className?: string }) {
364
+ return (
365
+ <svg
366
+ viewBox="0 0 24 24"
367
+ fill="none"
368
+ stroke="currentColor"
369
+ strokeWidth={2}
370
+ strokeLinecap="round"
371
+ strokeLinejoin="round"
372
+ className={className}
373
+ >
374
+ <defs>
375
+ <radialGradient
376
+ id="dg-ico"
377
+ cx="50%"
378
+ cy="50%"
379
+ r="50%"
380
+ gradientTransform="scale(1, 1)"
381
+ >
382
+ <stop offset="0%" stopColor="currentColor" stopOpacity="1" />
383
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
384
+ </radialGradient>
385
+ </defs>
386
+ <polygon
387
+ points="12,4 20,12 12,20 4,12"
388
+ fill="url(#dg-ico)"
389
+ stroke="currentColor"
390
+ strokeOpacity="0.5"
391
+ />
392
+ </svg>
393
+ );
394
+ }
395
+
396
+ function IconImageFill({ className }: { className?: string }) {
397
+ return (
398
+ <svg
399
+ viewBox="0 0 24 24"
400
+ fill="none"
401
+ stroke="currentColor"
402
+ strokeWidth={2}
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ className={className}
406
+ >
407
+ <rect x="3" y="3" width="18" height="18" rx="2" />
408
+ <path d="m3 16 5-5 4 4 3-3 6 6" />
409
+ <circle cx="8.5" cy="8.5" r="1.5" />
410
+ </svg>
411
+ );
412
+ }
413
+
414
+ function IconVideoFill({ className }: { className?: string }) {
415
+ return (
416
+ <svg
417
+ viewBox="0 0 24 24"
418
+ fill="none"
419
+ stroke="currentColor"
420
+ strokeWidth={2}
421
+ strokeLinecap="round"
422
+ strokeLinejoin="round"
423
+ className={className}
424
+ >
425
+ {/* Frame border */}
426
+ <rect x="3" y="5" width="18" height="14" rx="2" />
427
+ {/* Play triangle — filled, no stroke for clarity at small size */}
428
+ <polygon points="10,9 10,15 16,12" fill="currentColor" stroke="none" />
429
+ </svg>
430
+ );
431
+ }
432
+
433
+ function IconNoneFill({ className }: { className?: string }) {
434
+ return (
435
+ <svg
436
+ viewBox="0 0 24 24"
437
+ fill="none"
438
+ stroke="currentColor"
439
+ strokeWidth={2}
440
+ strokeLinecap="round"
441
+ strokeLinejoin="round"
442
+ className={className}
443
+ >
444
+ <circle cx="12" cy="12" r="9" />
445
+ <line x1="5.636" y1="5.636" x2="18.364" y2="18.364" />
446
+ </svg>
447
+ );
448
+ }
449
+
450
+ function IconShaderFill({ className }: { className?: string }) {
451
+ // Droplet — Figma uses a teardrop for shader/blur-type fills.
452
+ return (
453
+ <svg
454
+ viewBox="0 0 24 24"
455
+ fill="none"
456
+ stroke="currentColor"
457
+ strokeWidth={2}
458
+ strokeLinecap="round"
459
+ strokeLinejoin="round"
460
+ className={className}
461
+ >
462
+ <defs>
463
+ <linearGradient id="shader-ico" x1="0" y1="0" x2="1" y2="1">
464
+ <stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
465
+ <stop offset="100%" stopColor="currentColor" stopOpacity="0.25" />
466
+ </linearGradient>
467
+ </defs>
468
+ <path
469
+ d="M12 3c3.5 4 6 7 6 10a6 6 0 0 1-12 0c0-3 2.5-6 6-10z"
470
+ fill="url(#shader-ico)"
471
+ stroke="currentColor"
472
+ strokeOpacity="0.7"
473
+ />
474
+ </svg>
475
+ );
476
+ }
477
+
478
+ function IconNoiseFill({ className }: { className?: string }) {
479
+ return (
480
+ <svg
481
+ viewBox="0 0 24 24"
482
+ fill="currentColor"
483
+ stroke="none"
484
+ className={className}
485
+ >
486
+ <rect
487
+ x="3.5"
488
+ y="3.5"
489
+ width="17"
490
+ height="17"
491
+ rx="2"
492
+ fill="none"
493
+ stroke="currentColor"
494
+ strokeWidth={1.6}
495
+ strokeOpacity="0.6"
496
+ />
497
+ {[
498
+ [7, 7],
499
+ [11, 6.5],
500
+ [15, 8],
501
+ [8, 10.5],
502
+ [13, 11],
503
+ [16.5, 11.5],
504
+ [6.5, 13],
505
+ [10, 14],
506
+ [14, 13.5],
507
+ [9, 16.5],
508
+ [13, 16.5],
509
+ [16, 15.5],
510
+ ].map(([cx, cy], index) => (
511
+ <circle key={index} cx={cx} cy={cy} r="0.9" />
512
+ ))}
513
+ </svg>
514
+ );
515
+ }
516
+
517
+ function IconPatternFill({ className }: { className?: string }) {
518
+ return (
519
+ <svg
520
+ viewBox="0 0 24 24"
521
+ fill="none"
522
+ stroke="currentColor"
523
+ strokeWidth={1.7}
524
+ strokeLinecap="round"
525
+ strokeLinejoin="round"
526
+ className={className}
527
+ >
528
+ <rect x="3.5" y="3.5" width="17" height="17" rx="2" strokeOpacity="0.6" />
529
+ <path d="M3.5 9h17M3.5 15h17M9 3.5v17M15 3.5v17" strokeOpacity="0.85" />
530
+ </svg>
531
+ );
532
+ }
533
+
534
+ // ─── Paint type definitions (only supported types rendered) ────────────────────
535
+
536
+ const PAINT_TYPES: Array<{
537
+ type: FigmaPaintType;
538
+ label: string;
539
+ Icon: (props: { className?: string }) => JSX.Element;
540
+ }> = [
541
+ { type: "solid", label: "Solid", Icon: IconSolid }, // i18n-ignore paint type label
542
+ { type: "linear", label: "Linear", Icon: IconLinearGradient }, // i18n-ignore paint type label
543
+ { type: "radial", label: "Radial", Icon: IconRadialGradient }, // i18n-ignore paint type label
544
+ { type: "angular", label: "Angular", Icon: IconAngularGradient }, // i18n-ignore paint type label
545
+ { type: "diamond", label: "Diamond", Icon: IconDiamondGradient }, // i18n-ignore paint type label
546
+ { type: "image", label: "Image", Icon: IconImageFill }, // i18n-ignore paint type label
547
+ { type: "video", label: "Video", Icon: IconVideoFill }, // i18n-ignore paint type label
548
+ { type: "shader", label: "Shader", Icon: IconShaderFill }, // i18n-ignore paint type label
549
+ { type: "noise", label: "Noise", Icon: IconNoiseFill }, // i18n-ignore paint type label
550
+ { type: "pattern", label: "Pattern", Icon: IconPatternFill }, // i18n-ignore paint type label
551
+ { type: "none", label: "None", Icon: IconNoneFill }, // i18n-ignore paint type label
552
+ ];
553
+
554
+ // Alias used internally before the exported constant is defined below.
555
+ // Both point at the same member set — keep reads using this name so the
556
+ // component body compiles even though the exported constant is declared
557
+ // at the bottom of the file (hoisting doesn't apply to const).
558
+ const GRADIENT_TYPES = new Set<FigmaPaintType>([
559
+ "linear",
560
+ "radial",
561
+ "angular",
562
+ "diamond",
563
+ ]);
564
+
565
+ // Static fallback fills for the "functional but minimal" paint types so the
566
+ // element fill always reflects the selected mode even without bespoke editors.
567
+ const NOISE_FALLBACK_CSS =
568
+ "repeating-conic-gradient(#0000 0% 25%, #00000010 0% 50%) 0 0 / 6px 6px, #8a8a8a";
569
+ const PATTERN_FALLBACK_CSS =
570
+ "repeating-linear-gradient(45deg, #00000014 0 6px, #ffffff14 6px 12px), #9aa0a6";
571
+
572
+ // ─── Main component ────────────────────────────────────────────────────────────
573
+
574
+ export function FigmaColorPicker({
575
+ value,
576
+ onChange,
577
+ onPaintValueChange,
578
+ label: _label,
579
+ opacity,
580
+ onOpacityChange,
581
+ blendMode: _blendMode,
582
+ onBlendModeChange: _onBlendModeChange,
583
+ showBlendMode: _showBlendMode,
584
+ fillRows: _fillRows,
585
+ selectedFillId: _selectedFillId,
586
+ onFillSelect: _onFillSelect,
587
+ onFillChange: _onFillChange,
588
+ onAddFill: _onAddFill,
589
+ onRemoveFill: _onRemoveFill,
590
+ paintType,
591
+ onPaintTypeChange,
592
+ gradientType,
593
+ onGradientTypeChange,
594
+ gradientStops: _gradientStops,
595
+ selectedStopId: _selectedStopId,
596
+ onGradientStopSelect: _onGradientStopSelect,
597
+ onGradientStopChange: _onGradientStopChange,
598
+ onAddGradientStop: _onAddGradientStop,
599
+ onRemoveGradientStop: _onRemoveGradientStop,
600
+ documentColors,
601
+ shaderContext,
602
+ onShaderChange,
603
+ labels,
604
+ disabled = false,
605
+ className,
606
+ }: FigmaColorPickerProps) {
607
+ const copy = { ...DEFAULT_LABELS, ...labels };
608
+ const color = parseCssColor(value) ?? FALLBACK_COLOR;
609
+ const hsv = rgbaToHsv(color);
610
+ const hsl = rgbaToHsl(color);
611
+ const effectiveOpacity = opacity ?? alphaToOpacity(color.a);
612
+
613
+ const [mode, setMode] = useState<FigmaColorMode>("hex");
614
+ const [hexDraft, setHexDraft] = useState(() => toDisplayHex(color));
615
+ const [open, setOpen] = useState(false);
616
+ const [picking, setPicking] = useState(false);
617
+ const skipNextHexBlurCommitRef = useRef(false);
618
+
619
+ // Whole-popover view: the standard picker, or the Figma "Shader fills" panel.
620
+ const [view, setView] = useState<"picker" | "shader">("picker");
621
+
622
+ // Self-managed paint-type fallback for when EditPanel doesn't drive it.
623
+ const [localPaintType, setLocalPaintType] = useState<FigmaPaintType | null>(
624
+ null,
625
+ );
626
+
627
+ // Locally-managed gradient/image/shader state, seeded from the current value
628
+ // so the editors round-trip when EditPanel passes the CSS back through value.
629
+ const [localGradient, setLocalGradient] = useState<GradientValue | null>(
630
+ null,
631
+ );
632
+ const [selectedStopId, setSelectedStopId] = useState<string>("");
633
+ const [imageFill, setImageFill] = useState<ImageFillValue>({
634
+ url: "",
635
+ fit: "fill",
636
+ });
637
+ const [shaderDescriptor, setShaderDescriptor] =
638
+ useState<ShaderDescriptor | null>(null);
639
+
640
+ // The user's explicit paint-type click (localPaintType) wins over the
641
+ // EditPanel-driven `paintType` prop so selecting a gradient/image/shader
642
+ // engages its editor even when EditPanel doesn't complete the structural
643
+ // fill switch. localPaintType is reset below when the prop changes (i.e. a
644
+ // different element/fill is selected) so the picker still follows selection.
645
+ const effectivePaintType: FigmaPaintType =
646
+ localPaintType ?? paintType ?? inferPaintType(value, effectiveOpacity);
647
+
648
+ // Resolve the active gradient: prefer EditPanel-driven props; otherwise parse
649
+ // the live CSS value, falling back to local edit state.
650
+ const parsedGradient = parseGradientCss(value, gradientType ?? "linear");
651
+ const activeGradient: GradientValue | null = GRADIENT_TYPES.has(
652
+ effectivePaintType,
653
+ )
654
+ ? (localGradient ??
655
+ parsedGradient ??
656
+ defaultGradient(
657
+ effectivePaintType as GradientKind,
658
+ toCssColor(color) || "#000000",
659
+ ))
660
+ : null;
661
+
662
+ useEffect(() => {
663
+ setHexDraft(toDisplayHex(color));
664
+ }, [color.r, color.g, color.b]);
665
+
666
+ // The local override (the user's explicit paint-type click) persists for the
667
+ // life of the open popover so EditPanel bouncing `paintType` back to solid
668
+ // can't wipe a just-selected gradient/image/shader. A new element selection
669
+ // remounts this popover content, which resets the local state naturally.
670
+
671
+ // Keep image-fill state synced when the incoming value is an image fill.
672
+ useEffect(() => {
673
+ if (effectivePaintType !== "image") return;
674
+ const parsed = parseImageFillCss(value);
675
+ if (
676
+ parsed &&
677
+ (parsed.url !== imageFill.url || parsed.fit !== imageFill.fit)
678
+ ) {
679
+ setImageFill(parsed);
680
+ }
681
+ // eslint-disable-next-line react-hooks/exhaustive-deps
682
+ }, [value, effectivePaintType]);
683
+
684
+ // Ensure a selected stop id exists whenever a gradient is active.
685
+ useEffect(() => {
686
+ if (!activeGradient) return;
687
+ const ids = activeGradient.stops.map((s) => s.id);
688
+ if (!ids.includes(selectedStopId)) {
689
+ setSelectedStopId(activeGradient.stops[0]?.id ?? "");
690
+ }
691
+ // eslint-disable-next-line react-hooks/exhaustive-deps
692
+ }, [activeGradient?.stops.map((s) => s.id).join(",")]);
693
+
694
+ // ── Emit helpers ────────────────────────────────────────────────────────────
695
+
696
+ const emitColor = (nextColor: RgbaColor, nextOpacity = effectiveOpacity) => {
697
+ onChange(rgbaToCss(withColorOpacity(nextColor, nextOpacity)));
698
+ };
699
+
700
+ const emitPaintValue = (nextValue: string) => {
701
+ if (onPaintValueChange) onPaintValueChange(nextValue);
702
+ else onChange(nextValue);
703
+ };
704
+
705
+ const emitColorFromHsv = (nextHsv: HsvaColor) => {
706
+ emitColor(hsvToRgba({ ...nextHsv, a: opacityToAlpha(effectiveOpacity) }));
707
+ };
708
+
709
+ const emitColorFromHsl = (nextHsl: HslaColor) => {
710
+ emitColor(hslToRgba({ ...nextHsl, a: opacityToAlpha(effectiveOpacity) }));
711
+ };
712
+
713
+ const commitHex = () => {
714
+ const parsed = parseCssColor(`#${hexDraft.replace(/^#/, "")}`);
715
+ if (!parsed) {
716
+ setHexDraft(toDisplayHex(activeGradient ? fieldColor : color));
717
+ return;
718
+ }
719
+ if (activeGradient) {
720
+ const hexIncludesAlpha = hasHexAlpha(hexDraft);
721
+ emitStopColor(hexIncludesAlpha ? parsed : { ...parsed, a: fieldColor.a });
722
+ return;
723
+ }
724
+ const hexIncludesAlpha = hasHexAlpha(hexDraft);
725
+ const nextOpacity = hexIncludesAlpha
726
+ ? alphaToOpacity(parsed.a)
727
+ : effectiveOpacity;
728
+ if (hexIncludesAlpha && onOpacityChange) onOpacityChange(nextOpacity);
729
+ emitColor(parsed, nextOpacity);
730
+ };
731
+
732
+ const setOpacity = (nextOpacity: number) => {
733
+ if (onOpacityChange) onOpacityChange(nextOpacity);
734
+ else onChange(rgbaToCss(withColorOpacity(color, nextOpacity)));
735
+ };
736
+
737
+ // ── Gradient editing ─────────────────────────────────────────────────────────
738
+
739
+ const emitGradient = (next: GradientValue) => {
740
+ setLocalGradient(next);
741
+ if (onGradientTypeChange && next.kind !== gradientType) {
742
+ onGradientTypeChange(next.kind as FigmaGradientType);
743
+ }
744
+ emitPaintValue(gradientToCss(next));
745
+ };
746
+
747
+ const selectedStop =
748
+ activeGradient?.stops.find((s) => s.id === selectedStopId) ??
749
+ activeGradient?.stops[0];
750
+
751
+ // The 2D field edits the selected gradient stop's color when in gradient mode.
752
+ const fieldColor: RgbaColor = activeGradient
753
+ ? (parseCssColor(selectedStop?.color ?? "#000000") ?? FALLBACK_COLOR)
754
+ : color;
755
+ const fieldHsv = rgbaToHsv(fieldColor);
756
+ const fieldHsl = rgbaToHsl(fieldColor);
757
+
758
+ // In gradient mode, mirror the selected stop's color into the hex draft.
759
+ const selectedStopColor = selectedStop?.color;
760
+ useEffect(() => {
761
+ if (!activeGradient || !selectedStopColor) return;
762
+ const parsed = parseCssColor(selectedStopColor);
763
+ if (parsed) setHexDraft(toDisplayHex(parsed));
764
+ // eslint-disable-next-line react-hooks/exhaustive-deps
765
+ }, [selectedStopColor, selectedStopId]);
766
+
767
+ const emitStopColor = (nextColor: RgbaColor) => {
768
+ if (!activeGradient || !selectedStop) return;
769
+ emitGradient({
770
+ ...activeGradient,
771
+ stops: activeGradient.stops.map((stop) =>
772
+ stop.id === selectedStop.id
773
+ ? { ...stop, color: rgbaToCss(nextColor) }
774
+ : stop,
775
+ ),
776
+ });
777
+ };
778
+
779
+ // Value-row emit helpers: route to the selected stop in gradient mode,
780
+ // else to the solid color (preserving the existing solid behavior).
781
+ const emitFieldColor = (next: RgbaColor) => {
782
+ if (activeGradient) emitStopColor({ ...next, a: fieldColor.a });
783
+ else emitColor(next);
784
+ };
785
+ const emitFieldHsl = (next: HslaColor) => {
786
+ if (activeGradient) emitStopColor(hslToRgba({ ...next, a: fieldColor.a }));
787
+ else emitColorFromHsl(next);
788
+ };
789
+ const emitFieldHsv = (next: HsvaColor) => {
790
+ if (activeGradient) emitStopColor(hsvToRgba({ ...next, a: fieldColor.a }));
791
+ else emitColorFromHsv(next);
792
+ };
793
+
794
+ // ── Image editing ─────────────────────────────────────────────────────────────
795
+
796
+ const emitImageFill = (next: ImageFillValue) => {
797
+ setImageFill(next);
798
+ emitPaintValue(imageFillToCss(next));
799
+ };
800
+
801
+ // ── Shader editing ──────────────────────────────────────────────────────────────
802
+
803
+ const emitShader = (descriptor: ShaderDescriptor, css: string) => {
804
+ setShaderDescriptor(descriptor);
805
+ onShaderChange?.(descriptor, css);
806
+ emitPaintValue(css);
807
+ };
808
+
809
+ // ── Paint-type switching (does real work for every type) ──────────────────────
810
+
811
+ const setPaintType = (nextType: FigmaPaintType) => {
812
+ if (disabled) return;
813
+
814
+ // Shader opens the dedicated Figma "Shader fills" panel.
815
+ if (nextType === "shader") {
816
+ setLocalPaintType("shader");
817
+ setView("shader");
818
+ return;
819
+ }
820
+
821
+ // Defer structural fill changes to EditPanel when it manages layered fills.
822
+ if (onPaintTypeChange) {
823
+ setLocalPaintType(nextType);
824
+ onPaintTypeChange(nextType);
825
+ return;
826
+ }
827
+
828
+ setLocalPaintType(nextType);
829
+
830
+ if (nextType === "none") {
831
+ onChange("transparent");
832
+ return;
833
+ }
834
+ if (nextType === "solid") {
835
+ emitColor(color, effectiveOpacity > 0 ? effectiveOpacity : 100);
836
+ return;
837
+ }
838
+ if (GRADIENT_TYPES.has(nextType)) {
839
+ const base =
840
+ activeGradient ??
841
+ defaultGradient(
842
+ nextType as GradientKind,
843
+ toCssColor(color) || "#000000",
844
+ );
845
+ const next: GradientValue = { ...base, kind: nextType as GradientKind };
846
+ setSelectedStopId(next.stops[0]?.id ?? "");
847
+ emitGradient(next);
848
+ return;
849
+ }
850
+ if (nextType === "image") {
851
+ emitPaintValue(imageFill.url ? imageFillToCss(imageFill) : "transparent");
852
+ return;
853
+ }
854
+ if (nextType === "video") {
855
+ // No standalone CSS for video; mark the fill type and keep a checker fill
856
+ // until a source is wired. The agent can replace it with a <video> layer.
857
+ emitPaintValue("transparent");
858
+ return;
859
+ }
860
+ if (nextType === "noise") {
861
+ emitPaintValue(NOISE_FALLBACK_CSS);
862
+ return;
863
+ }
864
+ if (nextType === "pattern") {
865
+ emitPaintValue(PATTERN_FALLBACK_CSS);
866
+ return;
867
+ }
868
+ };
869
+
870
+ const pickScreenColor = async () => {
871
+ const EyeDropperCtor = (
872
+ window as unknown as {
873
+ EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> };
874
+ }
875
+ ).EyeDropper;
876
+ if (!EyeDropperCtor || disabled) return;
877
+ setPicking(true);
878
+ try {
879
+ const result = await new EyeDropperCtor().open();
880
+ if (result.sRGBHex) onChange(result.sRGBHex);
881
+ } catch {
882
+ // Browser cancels are expected; keep the current color.
883
+ } finally {
884
+ setPicking(false);
885
+ }
886
+ };
887
+
888
+ const hasEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
889
+
890
+ // ── Value row inputs by mode ─────────────────────────────────────────────────
891
+
892
+ function renderValueInputs() {
893
+ if (mode === "hex") {
894
+ return (
895
+ <Input
896
+ value={hexDraft}
897
+ disabled={disabled}
898
+ aria-label={copy.hex}
899
+ spellCheck={false}
900
+ className="h-6 min-w-0 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-[11px] tabular-nums uppercase"
901
+ onChange={(e) => setHexDraft(e.target.value)}
902
+ onFocus={(e) => e.target.select()}
903
+ onKeyDown={(e) => {
904
+ if (e.key === "Enter") {
905
+ e.preventDefault();
906
+ commitHex();
907
+ skipNextHexBlurCommitRef.current = true;
908
+ e.currentTarget.blur();
909
+ }
910
+ if (e.key === "Escape") {
911
+ setHexDraft(toDisplayHex(color));
912
+ skipNextHexBlurCommitRef.current = true;
913
+ e.currentTarget.blur();
914
+ }
915
+ }}
916
+ onBlur={() => {
917
+ if (skipNextHexBlurCommitRef.current) {
918
+ skipNextHexBlurCommitRef.current = false;
919
+ return;
920
+ }
921
+ commitHex();
922
+ }}
923
+ />
924
+ );
925
+ }
926
+ if (mode === "rgb") {
927
+ return (
928
+ <div className="flex gap-1">
929
+ {(["r", "g", "b"] as const).map((ch) => (
930
+ <ScrubbyNumberInput
931
+ key={ch}
932
+ aria-label={ch.toUpperCase()}
933
+ value={fieldColor[ch]}
934
+ min={0}
935
+ max={255}
936
+ disabled={disabled}
937
+ onChange={(next) => emitFieldColor({ ...fieldColor, [ch]: next })}
938
+ />
939
+ ))}
940
+ </div>
941
+ );
942
+ }
943
+ if (mode === "hsl") {
944
+ return (
945
+ <div className="flex gap-1">
946
+ <ScrubbyNumberInput
947
+ aria-label={copy.hue}
948
+ value={fieldHsl.h}
949
+ min={0}
950
+ max={360}
951
+ disabled={disabled}
952
+ onChange={(h) => emitFieldHsl({ ...fieldHsl, h })}
953
+ />
954
+ <ScrubbyNumberInput
955
+ aria-label={copy.saturation}
956
+ value={fieldHsl.s}
957
+ min={0}
958
+ max={100}
959
+ disabled={disabled}
960
+ onChange={(s) => emitFieldHsl({ ...fieldHsl, s })}
961
+ />
962
+ <ScrubbyNumberInput
963
+ aria-label={copy.lightness}
964
+ value={fieldHsl.l}
965
+ min={0}
966
+ max={100}
967
+ disabled={disabled}
968
+ onChange={(l) => emitFieldHsl({ ...fieldHsl, l })}
969
+ />
970
+ </div>
971
+ );
972
+ }
973
+ // hsb
974
+ return (
975
+ <div className="flex gap-1">
976
+ <ScrubbyNumberInput
977
+ aria-label={copy.hue}
978
+ value={fieldHsv.h}
979
+ min={0}
980
+ max={360}
981
+ disabled={disabled}
982
+ onChange={(h) => emitFieldHsv({ ...fieldHsv, h })}
983
+ />
984
+ <ScrubbyNumberInput
985
+ aria-label={copy.saturation}
986
+ value={fieldHsv.s}
987
+ min={0}
988
+ max={100}
989
+ disabled={disabled}
990
+ onChange={(s) => emitFieldHsv({ ...fieldHsv, s })}
991
+ />
992
+ <ScrubbyNumberInput
993
+ aria-label={copy.brightness}
994
+ value={fieldHsv.v}
995
+ min={0}
996
+ max={100}
997
+ disabled={disabled}
998
+ onChange={(v) => emitFieldHsv({ ...fieldHsv, v })}
999
+ />
1000
+ </div>
1001
+ );
1002
+ }
1003
+
1004
+ // ─────────────────────────────────────────────────────────────────────────────
1005
+
1006
+ return (
1007
+ <div className={cn("space-y-1.5", className)}>
1008
+ <Popover open={open} onOpenChange={setOpen}>
1009
+ <PopoverTrigger asChild>
1010
+ {/* Trigger: compact swatch + hex + opacity% — matches Figma's fill row */}
1011
+ <button
1012
+ type="button"
1013
+ disabled={disabled}
1014
+ aria-label={copy.trigger}
1015
+ className={cn(
1016
+ "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] shadow-none",
1017
+ "hover:bg-[var(--design-editor-panel-raised-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
1018
+ disabled && "pointer-events-none opacity-50",
1019
+ )}
1020
+ >
1021
+ {/* Flat swatch chip — no shadow-inner (Figma is flat) */}
1022
+ <span
1023
+ className="size-4 shrink-0 rounded-[3px] border border-border/60"
1024
+ style={triggerSwatchStyle(value, color)}
1025
+ />
1026
+ <span className="min-w-0 flex-1 truncate text-left tabular-nums uppercase text-[11px]">
1027
+ {triggerLabel(effectivePaintType, color)}
1028
+ </span>
1029
+ <span className="tabular-nums text-muted-foreground text-[11px]">
1030
+ {effectiveOpacity}%
1031
+ </span>
1032
+ </button>
1033
+ </PopoverTrigger>
1034
+
1035
+ {/* Figma popover: ~240px wide, uniform 12px padding, tight controls */}
1036
+ <PopoverContent
1037
+ side="left"
1038
+ align="start"
1039
+ sideOffset={8}
1040
+ className="z-[10000] w-[252px] p-0 shadow-xl"
1041
+ >
1042
+ <div className="overflow-hidden rounded-md bg-popover text-popover-foreground">
1043
+ {view === "shader" ? (
1044
+ <ShaderFillsPanel
1045
+ descriptor={shaderDescriptor ?? undefined}
1046
+ applyContext={shaderContext}
1047
+ disabled={disabled}
1048
+ onApply={emitShader}
1049
+ onBack={() => {
1050
+ setView("picker");
1051
+ if (effectivePaintType === "shader") {
1052
+ // No shader chosen — revert to a solid so the row isn't dead.
1053
+ if (!shaderDescriptor) setPaintType("solid");
1054
+ }
1055
+ }}
1056
+ />
1057
+ ) : (
1058
+ <>
1059
+ {/* ── Paint-type icon row (Figma-style, full-width tabs) ─── */}
1060
+ {/* 11 types → two rows: first 6, then 5. Each icon is a
1061
+ clearly-hittable 36×32px target with a distinct active
1062
+ accent so the selected mode is immediately obvious. */}
1063
+ <div className="border-b border-border/70 px-2 pt-2 pb-1.5">
1064
+ {/* Row 1: Solid · Linear · Radial · Angular · Diamond · Image */}
1065
+ <div className="mb-1 grid grid-cols-6 gap-1">
1066
+ {PAINT_TYPES.slice(0, 6).map(({ type, label, Icon }) => {
1067
+ const isActive = effectivePaintType === type;
1068
+ return (
1069
+ <Tooltip key={type}>
1070
+ <TooltipTrigger asChild>
1071
+ <button
1072
+ type="button"
1073
+ aria-label={label}
1074
+ aria-pressed={isActive}
1075
+ disabled={disabled}
1076
+ onClick={() => setPaintType(type)}
1077
+ className={cn(
1078
+ "flex h-8 w-full cursor-pointer flex-col items-center justify-center gap-0.5 rounded transition-colors",
1079
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1080
+ "active:scale-95",
1081
+ isActive
1082
+ ? "bg-accent text-accent-foreground ring-1 ring-primary/60"
1083
+ : "text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground",
1084
+ disabled && "pointer-events-none opacity-40",
1085
+ )}
1086
+ >
1087
+ <Icon className="size-4" />
1088
+ </button>
1089
+ </TooltipTrigger>
1090
+ <TooltipContent side="bottom" className="text-[10px]">
1091
+ {label}
1092
+ </TooltipContent>
1093
+ </Tooltip>
1094
+ );
1095
+ })}
1096
+ </div>
1097
+ {/* Row 2: Video · Shader · Noise · Pattern · None */}
1098
+ <div className="grid grid-cols-5 gap-1">
1099
+ {PAINT_TYPES.slice(6).map(({ type, label, Icon }) => {
1100
+ const isActive = effectivePaintType === type;
1101
+ return (
1102
+ <Tooltip key={type}>
1103
+ <TooltipTrigger asChild>
1104
+ <button
1105
+ type="button"
1106
+ aria-label={label}
1107
+ aria-pressed={isActive}
1108
+ disabled={disabled}
1109
+ onClick={() => setPaintType(type)}
1110
+ className={cn(
1111
+ "flex h-8 w-full cursor-pointer flex-col items-center justify-center gap-0.5 rounded transition-colors",
1112
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1113
+ "active:scale-95",
1114
+ isActive
1115
+ ? "bg-accent text-accent-foreground ring-1 ring-primary/60"
1116
+ : "text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground",
1117
+ disabled && "pointer-events-none opacity-40",
1118
+ )}
1119
+ >
1120
+ <Icon className="size-4" />
1121
+ </button>
1122
+ </TooltipTrigger>
1123
+ <TooltipContent side="bottom" className="text-[10px]">
1124
+ {label}
1125
+ </TooltipContent>
1126
+ </Tooltip>
1127
+ );
1128
+ })}
1129
+ </div>
1130
+ {/* Active-type label — shows which mode is selected */}
1131
+ <p className="mt-1 text-center text-[10px] font-medium text-muted-foreground">
1132
+ {PAINT_TYPES.find((p) => p.type === effectivePaintType)
1133
+ ?.label ?? effectivePaintType}
1134
+ </p>
1135
+ </div>
1136
+
1137
+ {/* ── Image fill controls ─────────────────────────────────── */}
1138
+ {effectivePaintType === "image" && (
1139
+ <div>
1140
+ <ImageFillControls
1141
+ value={imageFill}
1142
+ disabled={disabled}
1143
+ onChange={emitImageFill}
1144
+ />
1145
+ </div>
1146
+ )}
1147
+
1148
+ {/* ── Video fill: source field ────────────────────────────── */}
1149
+ {effectivePaintType === "video" && (
1150
+ <div className="px-3 py-2">
1151
+ <p className="mb-1.5 text-[10px] text-muted-foreground">
1152
+ {
1153
+ "Paste a video URL to use as the fill." /* i18n-ignore */
1154
+ }
1155
+ </p>
1156
+ <Input
1157
+ defaultValue=""
1158
+ disabled={disabled}
1159
+ placeholder={"Video URL (mp4, webm)" /* i18n-ignore */}
1160
+ aria-label={"Video URL" /* i18n-ignore */}
1161
+ spellCheck={false}
1162
+ className="h-6 w-full rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-[11px]"
1163
+ onKeyDown={(e) => {
1164
+ if (e.key === "Enter") {
1165
+ e.preventDefault();
1166
+ const url = e.currentTarget.value.trim();
1167
+ if (url)
1168
+ emitPaintValue(
1169
+ `url("${url}") center / cover no-repeat`,
1170
+ );
1171
+ e.currentTarget.blur();
1172
+ }
1173
+ }}
1174
+ onBlur={(e) => {
1175
+ const url = e.currentTarget.value.trim();
1176
+ if (url)
1177
+ emitPaintValue(
1178
+ `url("${url}") center / cover no-repeat`,
1179
+ );
1180
+ }}
1181
+ />
1182
+ </div>
1183
+ )}
1184
+
1185
+ {/* ── Gradient editor (linear / radial / angular / diamond) ── */}
1186
+ {activeGradient && (
1187
+ <div>
1188
+ <GradientEditor
1189
+ value={activeGradient}
1190
+ selectedStopId={selectedStopId}
1191
+ disabled={disabled}
1192
+ onSelectStop={setSelectedStopId}
1193
+ onChange={emitGradient}
1194
+ />
1195
+ </div>
1196
+ )}
1197
+
1198
+ {/* ── 2D Saturation/Brightness field ──────────────────────── */}
1199
+ {/* Hidden for non-color fills (image/video/noise/pattern). */}
1200
+ {(effectivePaintType === "solid" ||
1201
+ effectivePaintType === "none" ||
1202
+ activeGradient) && (
1203
+ <div className="border-t border-border/70">
1204
+ <SaturationBrightnessField
1205
+ hsv={fieldHsv}
1206
+ label={copy.saturationBrightness}
1207
+ disabled={disabled}
1208
+ onChange={(nextHsv) => {
1209
+ if (activeGradient) {
1210
+ emitStopColor(
1211
+ hsvToRgba({
1212
+ ...nextHsv,
1213
+ a: opacityToAlpha(effectiveOpacity),
1214
+ }),
1215
+ );
1216
+ } else {
1217
+ emitColorFromHsv(nextHsv);
1218
+ }
1219
+ }}
1220
+ />
1221
+ </div>
1222
+ )}
1223
+
1224
+ {/* ── Eyedropper + Hue slider / Swatch + Alpha slider ─────── */}
1225
+ {/* Color sliders only apply to color-based fills. */}
1226
+ {(effectivePaintType === "solid" ||
1227
+ effectivePaintType === "none" ||
1228
+ activeGradient) && (
1229
+ <div className="mt-2.5 px-3">
1230
+ <div className="grid grid-cols-[1.5rem_1fr] items-center gap-x-2">
1231
+ {/* Eyedropper centered across the two slider rows via row-span-2 */}
1232
+ <div className="row-span-2 flex items-center justify-center">
1233
+ <Tooltip>
1234
+ <TooltipTrigger asChild>
1235
+ <button
1236
+ type="button"
1237
+ aria-label={
1238
+ "Pick color" /* i18n-ignore browser eyedropper label */
1239
+ }
1240
+ disabled={disabled || !hasEyeDropper}
1241
+ onClick={() => void pickScreenColor()}
1242
+ className={cn(
1243
+ "flex size-6 cursor-pointer items-center justify-center rounded-sm transition-colors",
1244
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1245
+ picking
1246
+ ? "bg-primary/10 text-primary ring-1 ring-primary/50"
1247
+ : "text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground",
1248
+ (disabled || !hasEyeDropper) &&
1249
+ "pointer-events-none opacity-40",
1250
+ )}
1251
+ >
1252
+ <IconColorPicker className="size-4" />
1253
+ </button>
1254
+ </TooltipTrigger>
1255
+ <TooltipContent>
1256
+ {
1257
+ hasEyeDropper
1258
+ ? "Pick color" // i18n-ignore browser eyedropper label
1259
+ : "Not supported in this browser" // i18n-ignore browser eyedropper disabled label
1260
+ }
1261
+ </TooltipContent>
1262
+ </Tooltip>
1263
+ </div>
1264
+
1265
+ {/* Hue track */}
1266
+ <ColorTrack
1267
+ label={copy.hue}
1268
+ value={fieldHsv.h}
1269
+ min={0}
1270
+ max={360}
1271
+ disabled={disabled}
1272
+ backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
1273
+ onChange={(next) => {
1274
+ const h = next === 360 ? 0 : next;
1275
+ if (activeGradient) {
1276
+ emitStopColor(
1277
+ hsvToRgba({
1278
+ ...fieldHsv,
1279
+ h,
1280
+ a: fieldColor.a,
1281
+ }),
1282
+ );
1283
+ } else {
1284
+ emitColorFromHsv({ ...hsv, h });
1285
+ }
1286
+ }}
1287
+ />
1288
+
1289
+ {/* Current-color swatch left of alpha (matches Figma's layout) */}
1290
+ <div className="flex items-center gap-2">
1291
+ <span
1292
+ className="size-[18px] shrink-0 rounded-[3px] border border-border/60"
1293
+ style={swatchStyle(rgbaToCss(fieldColor))}
1294
+ />
1295
+ {/* Alpha track fills remaining width */}
1296
+ <div className="flex-1">
1297
+ <ColorTrack
1298
+ label={copy.opacity}
1299
+ value={
1300
+ activeGradient
1301
+ ? alphaToOpacity(fieldColor.a)
1302
+ : effectiveOpacity
1303
+ }
1304
+ min={0}
1305
+ max={100}
1306
+ disabled={disabled}
1307
+ backgroundImage={alphaTrackBackground(fieldColor)}
1308
+ backgroundSize="8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%"
1309
+ backgroundPosition="0 0, 0 4px, 4px -4px, -4px 0, 0 0"
1310
+ onChange={(next) => {
1311
+ if (activeGradient) {
1312
+ emitStopColor({
1313
+ ...fieldColor,
1314
+ a: opacityToAlpha(next),
1315
+ });
1316
+ } else {
1317
+ setOpacity(next);
1318
+ }
1319
+ }}
1320
+ />
1321
+ </div>
1322
+ </div>
1323
+ </div>
1324
+ </div>
1325
+ )}
1326
+
1327
+ {/* ── Value row: [Hex ▾] [value input(s)] [opacity %] ─────── */}
1328
+ {(effectivePaintType === "solid" ||
1329
+ effectivePaintType === "none" ||
1330
+ activeGradient) && (
1331
+ <div className="mt-2.5 px-3 pb-3">
1332
+ <div className="grid grid-cols-[4.5rem_1fr_3rem] items-center gap-1">
1333
+ {/* Model pill — bare text+chevron, no border or bg box (Figma-style) */}
1334
+ <ColorModelPill
1335
+ value={mode}
1336
+ disabled={disabled}
1337
+ onChange={(v) => setMode(v as FigmaColorMode)}
1338
+ />
1339
+
1340
+ {/* Value field(s) — adapts to mode */}
1341
+ {renderValueInputs()}
1342
+
1343
+ {/* Opacity % field */}
1344
+ <div className="flex h-6 overflow-hidden rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]">
1345
+ <ScrubbyNumberInput
1346
+ aria-label={copy.opacity}
1347
+ value={
1348
+ activeGradient
1349
+ ? alphaToOpacity(fieldColor.a)
1350
+ : effectiveOpacity
1351
+ }
1352
+ min={0}
1353
+ max={100}
1354
+ disabled={disabled}
1355
+ onChange={(next) => {
1356
+ if (activeGradient) {
1357
+ emitStopColor({
1358
+ ...fieldColor,
1359
+ a: opacityToAlpha(next),
1360
+ });
1361
+ } else {
1362
+ setOpacity(next);
1363
+ }
1364
+ }}
1365
+ className="h-full min-w-0 flex-1 rounded-none border-0 bg-transparent px-1 text-[11px] tabular-nums shadow-none focus-visible:ring-0"
1366
+ compact
1367
+ />
1368
+ <span className="flex w-4 shrink-0 items-center justify-center border-l border-border/60 text-[10px] text-muted-foreground">
1369
+ %
1370
+ </span>
1371
+ </div>
1372
+ </div>
1373
+ </div>
1374
+ )}
1375
+
1376
+ {/* ── Document colors ──────────────────────────────────────── */}
1377
+ {/* Renders the palette of colors already used in the design.
1378
+ When `documentColors` is provided, those swatches are shown;
1379
+ otherwise falls back to the single current color so the
1380
+ section is never empty. */}
1381
+ <div className="border-t border-border/70 px-3 py-2.5">
1382
+ {/* Source label — matches Figma layout */}
1383
+ <div className="mb-2 flex h-6 w-full items-center justify-between px-0.5 text-[11px] text-muted-foreground">
1384
+ {"Document colors" /* i18n-ignore Figma picker source */}
1385
+ </div>
1386
+
1387
+ {/* Swatch grid: document palette when available, else current color */}
1388
+ <div className="grid grid-cols-8 gap-1">
1389
+ {(documentColors && documentColors.length > 0
1390
+ ? documentColors
1391
+ : [rgbaToCss(color)]
1392
+ ).map((docColor) => {
1393
+ const currentHex = rgbaToHex(
1394
+ parseCssColor(docColor) ?? color,
1395
+ );
1396
+ const isActive =
1397
+ rgbaToHex(color) === currentHex && !activeGradient;
1398
+ return (
1399
+ <Tooltip key={docColor}>
1400
+ <TooltipTrigger asChild>
1401
+ <button
1402
+ type="button"
1403
+ disabled={disabled}
1404
+ aria-label={currentHex}
1405
+ aria-pressed={isActive}
1406
+ className={cn(
1407
+ "size-5 rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1408
+ isActive
1409
+ ? "border-primary ring-1 ring-primary"
1410
+ : "border-border/60",
1411
+ )}
1412
+ style={swatchStyle(docColor)}
1413
+ onClick={() => {
1414
+ const parsed = parseCssColor(docColor) ?? color;
1415
+ if (activeGradient) emitStopColor(parsed);
1416
+ else emitColor(parsed);
1417
+ }}
1418
+ />
1419
+ </TooltipTrigger>
1420
+ <TooltipContent>{currentHex}</TooltipContent>
1421
+ </Tooltip>
1422
+ );
1423
+ })}
1424
+ </div>
1425
+ </div>
1426
+ </>
1427
+ )}
1428
+ </div>
1429
+ </PopoverContent>
1430
+ </Popover>
1431
+ </div>
1432
+ );
1433
+ }
1434
+
1435
+ // ─── Sub-components ────────────────────────────────────────────────────────────
1436
+
1437
+ const COLOR_MODES: Array<{ value: FigmaColorMode; label: string }> = [
1438
+ { value: "hex", label: "Hex" }, // i18n-ignore color mode
1439
+ { value: "rgb", label: "RGB" }, // i18n-ignore color mode
1440
+ { value: "hsl", label: "HSL" }, // i18n-ignore color mode
1441
+ { value: "hsb", label: "HSB" }, // i18n-ignore color mode
1442
+ ];
1443
+
1444
+ /**
1445
+ * Figma-style bare color-model selector: text + small chevron, no border/bg box.
1446
+ * Hover reveals a subtle bg tint; active mode is font-semibold.
1447
+ * Renders its own lightweight dropdown (no Radix Select overhead).
1448
+ */
1449
+ function ColorModelPill({
1450
+ value,
1451
+ disabled,
1452
+ onChange,
1453
+ }: {
1454
+ value: FigmaColorMode;
1455
+ disabled: boolean;
1456
+ onChange: (mode: FigmaColorMode) => void;
1457
+ }) {
1458
+ const [menuOpen, setMenuOpen] = useState(false);
1459
+ const containerRef = useRef<HTMLDivElement>(null);
1460
+ const label =
1461
+ COLOR_MODES.find((m) => m.value === value)?.label ?? value.toUpperCase();
1462
+
1463
+ // Close on outside click.
1464
+ useEffect(() => {
1465
+ if (!menuOpen) return;
1466
+ const handler = (e: MouseEvent) => {
1467
+ if (!containerRef.current?.contains(e.target as Node)) {
1468
+ setMenuOpen(false);
1469
+ }
1470
+ };
1471
+ document.addEventListener("mousedown", handler);
1472
+ return () => document.removeEventListener("mousedown", handler);
1473
+ }, [menuOpen]);
1474
+
1475
+ return (
1476
+ <div ref={containerRef} className="relative">
1477
+ <button
1478
+ type="button"
1479
+ disabled={disabled}
1480
+ aria-haspopup="listbox"
1481
+ aria-expanded={menuOpen}
1482
+ onClick={() => setMenuOpen((o) => !o)}
1483
+ className={cn(
1484
+ "flex h-6 w-[4.5rem] items-center gap-0.5 rounded px-1.5",
1485
+ "text-[11px] font-semibold text-foreground",
1486
+ "bg-transparent border-0 shadow-none",
1487
+ "hover:bg-[var(--design-editor-control-bg)]",
1488
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1489
+ "transition-colors",
1490
+ disabled && "pointer-events-none opacity-50",
1491
+ )}
1492
+ >
1493
+ <span className="flex-1 text-left">{label}</span>
1494
+ <IconChevronDown className="size-3 shrink-0 text-muted-foreground" />
1495
+ </button>
1496
+
1497
+ {menuOpen && (
1498
+ <div
1499
+ role="listbox"
1500
+ aria-label="Color model" // i18n-ignore aria label
1501
+ className={cn(
1502
+ "absolute left-0 top-full z-[10001] mt-0.5 min-w-[4.5rem]",
1503
+ "rounded-md border border-border bg-popover shadow-lg",
1504
+ "overflow-hidden py-0.5",
1505
+ )}
1506
+ >
1507
+ {COLOR_MODES.map((m) => (
1508
+ <button
1509
+ key={m.value}
1510
+ type="button"
1511
+ role="option"
1512
+ aria-selected={m.value === value}
1513
+ onClick={() => {
1514
+ onChange(m.value);
1515
+ setMenuOpen(false);
1516
+ }}
1517
+ className={cn(
1518
+ "flex w-full items-center px-2 py-1 text-[11px]",
1519
+ "hover:bg-accent hover:text-accent-foreground",
1520
+ "focus-visible:outline-none focus-visible:bg-accent",
1521
+ m.value === value
1522
+ ? "font-semibold text-foreground"
1523
+ : "font-normal text-foreground/80",
1524
+ )}
1525
+ >
1526
+ {m.label}
1527
+ </button>
1528
+ ))}
1529
+ </div>
1530
+ )}
1531
+ </div>
1532
+ );
1533
+ }
1534
+
1535
+ function SaturationBrightnessField({
1536
+ hsv,
1537
+ label,
1538
+ disabled,
1539
+ onChange,
1540
+ }: {
1541
+ hsv: HsvaColor;
1542
+ label: string;
1543
+ disabled: boolean;
1544
+ onChange: (color: HsvaColor) => void;
1545
+ }) {
1546
+ const fieldRef = useRef<HTMLDivElement>(null);
1547
+ const draggingRef = useRef(false);
1548
+ const hueColor = rgbaToCss(hsvToRgba({ h: hsv.h, s: 100, v: 100, a: 1 }));
1549
+
1550
+ const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
1551
+ const rect = fieldRef.current?.getBoundingClientRect();
1552
+ if (!rect) return;
1553
+ const nextSaturation = ((event.clientX - rect.left) / rect.width) * 100;
1554
+ const nextBrightness =
1555
+ 100 - ((event.clientY - rect.top) / rect.height) * 100;
1556
+ onChange({
1557
+ ...hsv,
1558
+ s: clamp(nextSaturation, 0, 100),
1559
+ v: clamp(nextBrightness, 0, 100),
1560
+ });
1561
+ };
1562
+
1563
+ const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
1564
+ if (disabled) return;
1565
+ const step = event.shiftKey ? 10 : 1;
1566
+ if (event.key === "ArrowRight") {
1567
+ event.preventDefault();
1568
+ onChange({ ...hsv, s: clamp(hsv.s + step, 0, 100) });
1569
+ }
1570
+ if (event.key === "ArrowLeft") {
1571
+ event.preventDefault();
1572
+ onChange({ ...hsv, s: clamp(hsv.s - step, 0, 100) });
1573
+ }
1574
+ if (event.key === "ArrowUp") {
1575
+ event.preventDefault();
1576
+ onChange({ ...hsv, v: clamp(hsv.v + step, 0, 100) });
1577
+ }
1578
+ if (event.key === "ArrowDown") {
1579
+ event.preventDefault();
1580
+ onChange({ ...hsv, v: clamp(hsv.v - step, 0, 100) });
1581
+ }
1582
+ };
1583
+
1584
+ return (
1585
+ <div
1586
+ ref={fieldRef}
1587
+ tabIndex={disabled ? -1 : 0}
1588
+ aria-label={label}
1589
+ aria-disabled={disabled}
1590
+ onPointerDown={(event) => {
1591
+ if (disabled) return;
1592
+ draggingRef.current = true;
1593
+ event.currentTarget.setPointerCapture(event.pointerId);
1594
+ updateFromPointer(event);
1595
+ }}
1596
+ onPointerMove={(event) => {
1597
+ if (!draggingRef.current || disabled) return;
1598
+ updateFromPointer(event);
1599
+ }}
1600
+ onPointerUp={(event) => {
1601
+ draggingRef.current = false;
1602
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
1603
+ event.currentTarget.releasePointerCapture(event.pointerId);
1604
+ }
1605
+ }}
1606
+ onPointerCancel={() => {
1607
+ draggingRef.current = false;
1608
+ }}
1609
+ onKeyDown={stepWithKeyboard}
1610
+ className={cn(
1611
+ "relative h-48 w-full cursor-crosshair overflow-hidden outline-none",
1612
+ "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
1613
+ "active:cursor-grabbing",
1614
+ disabled && "cursor-not-allowed opacity-60",
1615
+ )}
1616
+ style={{
1617
+ backgroundImage: `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueColor} 100%)`,
1618
+ }}
1619
+ >
1620
+ {/* Handle: size-4, white ring, consistent foreground shadow */}
1621
+ <span
1622
+ className="pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
1623
+ style={{
1624
+ left: `${hsv.s}%`,
1625
+ top: `${100 - hsv.v}%`,
1626
+ }}
1627
+ />
1628
+ </div>
1629
+ );
1630
+ }
1631
+
1632
+ function ColorTrack({
1633
+ label,
1634
+ value,
1635
+ min,
1636
+ max,
1637
+ disabled,
1638
+ backgroundImage,
1639
+ backgroundSize,
1640
+ backgroundPosition,
1641
+ onChange,
1642
+ }: {
1643
+ label: string;
1644
+ value: number;
1645
+ min: number;
1646
+ max: number;
1647
+ disabled: boolean;
1648
+ backgroundImage: string;
1649
+ backgroundSize?: string;
1650
+ backgroundPosition?: string;
1651
+ onChange: (value: number) => void;
1652
+ }) {
1653
+ const trackRef = useRef<HTMLDivElement>(null);
1654
+ const draggingRef = useRef(false);
1655
+ const percent = ((value - min) / (max - min)) * 100;
1656
+
1657
+ const updateFromPointer = (event: PointerEvent<HTMLDivElement>) => {
1658
+ const rect = trackRef.current?.getBoundingClientRect();
1659
+ if (!rect) return;
1660
+ const next = min + ((event.clientX - rect.left) / rect.width) * (max - min);
1661
+ onChange(clamp(next, min, max));
1662
+ };
1663
+
1664
+ const stepWithKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
1665
+ if (disabled) return;
1666
+ const step = event.shiftKey ? 10 : 1;
1667
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
1668
+ event.preventDefault();
1669
+ onChange(clamp(value + step, min, max));
1670
+ }
1671
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
1672
+ event.preventDefault();
1673
+ onChange(clamp(value - step, min, max));
1674
+ }
1675
+ if (event.key === "Home") {
1676
+ event.preventDefault();
1677
+ onChange(min);
1678
+ }
1679
+ if (event.key === "End") {
1680
+ event.preventDefault();
1681
+ onChange(max);
1682
+ }
1683
+ };
1684
+
1685
+ return (
1686
+ <div
1687
+ ref={trackRef}
1688
+ role="slider"
1689
+ tabIndex={disabled ? -1 : 0}
1690
+ aria-label={label}
1691
+ aria-valuemin={min}
1692
+ aria-valuemax={max}
1693
+ aria-valuenow={Math.round(value)}
1694
+ aria-disabled={disabled}
1695
+ onKeyDown={stepWithKeyboard}
1696
+ onPointerDown={(event) => {
1697
+ if (disabled) return;
1698
+ draggingRef.current = true;
1699
+ event.currentTarget.setPointerCapture(event.pointerId);
1700
+ updateFromPointer(event);
1701
+ }}
1702
+ onPointerMove={(event) => {
1703
+ if (!draggingRef.current || disabled) return;
1704
+ updateFromPointer(event);
1705
+ }}
1706
+ onPointerUp={(event) => {
1707
+ draggingRef.current = false;
1708
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
1709
+ event.currentTarget.releasePointerCapture(event.pointerId);
1710
+ }
1711
+ }}
1712
+ onPointerCancel={() => {
1713
+ draggingRef.current = false;
1714
+ }}
1715
+ className={cn(
1716
+ "relative h-3.5 cursor-pointer rounded-full border border-border/60 outline-none",
1717
+ "ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
1718
+ "active:cursor-grabbing",
1719
+ disabled && "cursor-not-allowed opacity-60",
1720
+ )}
1721
+ style={{ backgroundImage, backgroundSize, backgroundPosition }}
1722
+ >
1723
+ {/* Thumb overhangs the track slightly, matching Figma */}
1724
+ <span
1725
+ className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_hsl(var(--foreground)/0.6)]"
1726
+ style={{ left: `${clamp(percent, 0, 100)}%` }}
1727
+ />
1728
+ </div>
1729
+ );
1730
+ }
1731
+
1732
+ /**
1733
+ * A number input with select-on-focus and Esc-to-revert.
1734
+ * The `compact` prop removes inner padding for use inside bordered wrappers.
1735
+ */
1736
+ function ScrubbyNumberInput({
1737
+ "aria-label": ariaLabel,
1738
+ value,
1739
+ min,
1740
+ max,
1741
+ disabled,
1742
+ onChange,
1743
+ className,
1744
+ compact = false,
1745
+ }: {
1746
+ "aria-label": string;
1747
+ value: number;
1748
+ min: number;
1749
+ max: number;
1750
+ disabled: boolean;
1751
+ onChange: (value: number) => void;
1752
+ className?: string;
1753
+ compact?: boolean;
1754
+ }) {
1755
+ const [draft, setDraft] = useState<string>(() => String(value));
1756
+ const skipBlurRef = useRef(false);
1757
+
1758
+ useEffect(() => {
1759
+ setDraft(String(value));
1760
+ }, [value]);
1761
+
1762
+ const commit = () => {
1763
+ const parsed = Number(draft);
1764
+ if (!Number.isFinite(parsed)) {
1765
+ setDraft(String(value));
1766
+ return;
1767
+ }
1768
+ onChange(clamp(parsed, min, max));
1769
+ };
1770
+
1771
+ return (
1772
+ <input
1773
+ type="number"
1774
+ aria-label={ariaLabel}
1775
+ value={draft}
1776
+ min={min}
1777
+ max={max}
1778
+ disabled={disabled}
1779
+ className={cn(
1780
+ "h-6 w-full rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] text-center text-[11px] tabular-nums",
1781
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1782
+ compact && "border-0 shadow-none focus-visible:ring-0",
1783
+ className,
1784
+ )}
1785
+ onChange={(e) => setDraft(e.target.value)}
1786
+ onFocus={(e) => e.target.select()}
1787
+ onKeyDown={(e) => {
1788
+ if (e.key === "Enter") {
1789
+ e.preventDefault();
1790
+ commit();
1791
+ skipBlurRef.current = true;
1792
+ e.currentTarget.blur();
1793
+ }
1794
+ if (e.key === "Escape") {
1795
+ setDraft(String(value));
1796
+ skipBlurRef.current = true;
1797
+ e.currentTarget.blur();
1798
+ }
1799
+ if (e.key === "ArrowUp") {
1800
+ e.preventDefault();
1801
+ const step = e.shiftKey ? 10 : 1;
1802
+ onChange(clamp(value + step, min, max));
1803
+ }
1804
+ if (e.key === "ArrowDown") {
1805
+ e.preventDefault();
1806
+ const step = e.shiftKey ? 10 : 1;
1807
+ onChange(clamp(value - step, min, max));
1808
+ }
1809
+ }}
1810
+ onBlur={() => {
1811
+ if (skipBlurRef.current) {
1812
+ skipBlurRef.current = false;
1813
+ return;
1814
+ }
1815
+ commit();
1816
+ }}
1817
+ />
1818
+ );
1819
+ }
1820
+
1821
+ // ─── Utilities ─────────────────────────────────────────────────────────────────
1822
+
1823
+ export function inferPaintType(value: string, opacity: number): FigmaPaintType {
1824
+ const lower = value.trim().toLowerCase();
1825
+ if (lower.includes("gradient(")) {
1826
+ if (lower.startsWith("radial-gradient")) return "radial";
1827
+ if (lower.startsWith("conic-gradient")) return "angular";
1828
+ return "linear";
1829
+ }
1830
+ if (lower.startsWith("url(")) return "image";
1831
+ const parsed = parseCssColor(value);
1832
+ if (opacity <= 0 || parsed?.a === 0 || value.trim() === "transparent") {
1833
+ return "none";
1834
+ }
1835
+ return "solid";
1836
+ }
1837
+
1838
+ /** The set of paint types that render a gradient editor. */
1839
+ export const GRADIENT_PAINT_TYPES: ReadonlySet<FigmaPaintType> = new Set([
1840
+ "linear",
1841
+ "radial",
1842
+ "angular",
1843
+ "diamond",
1844
+ ]);
1845
+
1846
+ /**
1847
+ * Pure helper: resolves the effective paint type and which editor panel should
1848
+ * be visible given the three-level precedence:
1849
+ *
1850
+ * localPaintType (user's explicit click this session)
1851
+ * ?? paintType prop (EditPanel-driven structural type)
1852
+ * ?? inferred from the CSS value string
1853
+ *
1854
+ * The shader panel is a view-level switch (not just a paint-type), so
1855
+ * `showShaderPanel` is derived directly from the effective type.
1856
+ *
1857
+ * @param paintType The `paintType` prop from the parent (or undefined).
1858
+ * @param localPaintType The user's explicit in-session selection (or null).
1859
+ * @param value The current CSS fill value string.
1860
+ * @param opacity The current opacity (0–100).
1861
+ */
1862
+ export function resolveActivePaint(
1863
+ paintType: FigmaPaintType | undefined,
1864
+ localPaintType: FigmaPaintType | null,
1865
+ value: string,
1866
+ opacity: number,
1867
+ ): {
1868
+ effectivePaintType: FigmaPaintType;
1869
+ showGradientEditor: boolean;
1870
+ showImageControls: boolean;
1871
+ showShaderPanel: boolean;
1872
+ } {
1873
+ const effectivePaintType: FigmaPaintType =
1874
+ localPaintType ?? paintType ?? inferPaintType(value, opacity);
1875
+ return {
1876
+ effectivePaintType,
1877
+ showGradientEditor: GRADIENT_PAINT_TYPES.has(effectivePaintType),
1878
+ showImageControls: effectivePaintType === "image",
1879
+ showShaderPanel: effectivePaintType === "shader",
1880
+ };
1881
+ }
1882
+
1883
+ function toCssColor(color: RgbaColor): string {
1884
+ return rgbaToCss(color);
1885
+ }
1886
+
1887
+ /** Show hex without the leading # for the display field (matches Figma). */
1888
+ function toDisplayHex(color: RgbaColor): string {
1889
+ return rgbaToHex(color).replace(/^#/, "").toUpperCase();
1890
+ }
1891
+
1892
+ function triggerLabel(type: FigmaPaintType, color: RgbaColor): string {
1893
+ if (type === "solid") return toDisplayHex(color);
1894
+ if (type === "none") return "None";
1895
+ if (type === "image") return "Image";
1896
+ if (type === "video") return "Video";
1897
+ if (type === "shader") return "Shader";
1898
+ if (type === "noise") return "Noise";
1899
+ if (type === "pattern") return "Pattern";
1900
+ return `${type[0].toUpperCase()}${type.slice(1)} gradient`;
1901
+ }
1902
+
1903
+ function triggerSwatchStyle(
1904
+ value: string,
1905
+ color: RgbaColor,
1906
+ ): {
1907
+ backgroundColor?: string;
1908
+ backgroundImage?: string;
1909
+ backgroundSize?: string;
1910
+ backgroundPosition?: string;
1911
+ } {
1912
+ const lower = value.trim().toLowerCase();
1913
+ if (!lower || lower === "transparent") {
1914
+ return {
1915
+ backgroundImage: CHECKERBOARD_IMAGE,
1916
+ backgroundSize: "8px 8px",
1917
+ };
1918
+ }
1919
+ if (lower.includes("gradient(") || lower.startsWith("url(")) {
1920
+ return swatchStyle(value);
1921
+ }
1922
+ return swatchStyle(rgbaToCss(color));
1923
+ }
1924
+
1925
+ function swatchStyle(value: string): {
1926
+ backgroundColor?: string;
1927
+ backgroundImage?: string;
1928
+ backgroundSize?: string;
1929
+ backgroundPosition?: string;
1930
+ } {
1931
+ const parsed = parseCssColor(value);
1932
+ if (parsed && parsed.a < 1) {
1933
+ return {
1934
+ backgroundImage: `${CHECKERBOARD_IMAGE}, linear-gradient(${rgbaToCss(parsed)}, ${rgbaToCss(parsed)})`,
1935
+ backgroundSize: "8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%",
1936
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0, 0 0",
1937
+ };
1938
+ }
1939
+ if (parsed) return { backgroundColor: rgbaToCss(parsed) };
1940
+ return { backgroundImage: value || "none" };
1941
+ }
1942
+
1943
+ function alphaTrackBackground(color: RgbaColor): string {
1944
+ return `${CHECKERBOARD_IMAGE}, linear-gradient(90deg, rgba(${color.r}, ${color.g}, ${color.b}, 0), rgba(${color.r}, ${color.g}, ${color.b}, 1))`;
1945
+ }
1946
+
1947
+ function rgbaToHsv(color: RgbaColor): HsvaColor {
1948
+ const r = clampFloat(color.r / 255, 0, 1);
1949
+ const g = clampFloat(color.g / 255, 0, 1);
1950
+ const b = clampFloat(color.b / 255, 0, 1);
1951
+ const max = Math.max(r, g, b);
1952
+ const min = Math.min(r, g, b);
1953
+ const delta = max - min;
1954
+
1955
+ let h = 0;
1956
+ if (delta !== 0) {
1957
+ if (max === r) h = ((g - b) / delta) % 6;
1958
+ else if (max === g) h = (b - r) / delta + 2;
1959
+ else h = (r - g) / delta + 4;
1960
+ h *= 60;
1961
+ if (h < 0) h += 360;
1962
+ }
1963
+
1964
+ return {
1965
+ h: Math.round(h),
1966
+ s: max === 0 ? 0 : Math.round((delta / max) * 100),
1967
+ v: Math.round(max * 100),
1968
+ a: color.a,
1969
+ };
1970
+ }
1971
+
1972
+ function hsvToRgba(color: HsvaColor): RgbaColor {
1973
+ const h = ((color.h % 360) + 360) % 360;
1974
+ const s = clampFloat(color.s, 0, 100) / 100;
1975
+ const v = clampFloat(color.v, 0, 100) / 100;
1976
+ const chroma = v * s;
1977
+ const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
1978
+ const m = v - chroma;
1979
+
1980
+ let r = 0,
1981
+ g = 0,
1982
+ b = 0;
1983
+ if (h < 60) [r, g, b] = [chroma, x, 0];
1984
+ else if (h < 120) [r, g, b] = [x, chroma, 0];
1985
+ else if (h < 180) [r, g, b] = [0, chroma, x];
1986
+ else if (h < 240) [r, g, b] = [0, x, chroma];
1987
+ else if (h < 300) [r, g, b] = [x, 0, chroma];
1988
+ else [r, g, b] = [chroma, 0, x];
1989
+
1990
+ return {
1991
+ r: clamp(Math.round((r + m) * 255), 0, 255),
1992
+ g: clamp(Math.round((g + m) * 255), 0, 255),
1993
+ b: clamp(Math.round((b + m) * 255), 0, 255),
1994
+ a: clampFloat(color.a, 0, 1),
1995
+ };
1996
+ }
1997
+
1998
+ function clamp(value: number, min: number, max: number): number {
1999
+ if (!Number.isFinite(value)) return min;
2000
+ return Math.max(min, Math.min(max, Math.round(value)));
2001
+ }
2002
+
2003
+ function clampFloat(value: number, min: number, max: number): number {
2004
+ if (!Number.isFinite(value)) return min;
2005
+ return Math.max(min, Math.min(max, value));
2006
+ }
2007
+
2008
+ function hasHexAlpha(value: string): boolean {
2009
+ return /^#?(?:[0-9a-f]{4}|[0-9a-f]{8})$/i.test(value.trim());
2010
+ }