@agent-native/core 0.80.0 → 0.80.2

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 (228) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +28 -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 +14 -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 +15 -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 +15 -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 +15 -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 +15 -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 +16 -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 +14 -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 +14 -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 +14 -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 +14 -1
  23. package/corpus/core/docs/content/skills-guide.mdx +5 -0
  24. package/corpus/core/docs/content/template-design.mdx +14 -1
  25. package/corpus/core/package.json +1 -1
  26. package/corpus/core/src/application-state/handlers.ts +1 -1
  27. package/corpus/core/src/brand-kit/index.ts +1 -1
  28. package/corpus/core/src/cli/skills.ts +18 -0
  29. package/corpus/core/src/client/AgentPanel.tsx +1 -1
  30. package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
  31. package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
  32. package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
  33. package/corpus/core/src/extensions/path.ts +1 -4
  34. package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
  35. package/corpus/core/src/mcp/build-server.ts +2 -2
  36. package/corpus/core/src/mcp/embed-app.ts +35 -1
  37. package/corpus/core/src/org/handlers.ts +5 -5
  38. package/corpus/core/src/org/plugin.ts +2 -2
  39. package/corpus/core/src/scripts/call-agent.ts +1 -1
  40. package/corpus/core/src/scripts/db/safety.ts +1 -1
  41. package/corpus/core/src/server/auth.ts +3 -0
  42. package/corpus/core/src/server/builder-design-systems.ts +308 -0
  43. package/corpus/core/src/server/core-routes-plugin.ts +29 -3
  44. package/corpus/core/src/server/index.ts +8 -0
  45. package/corpus/core/src/styles/agent-native.css +14 -7
  46. package/corpus/core/src/usage/store.ts +2 -2
  47. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
  48. package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
  49. package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
  50. package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
  51. package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
  52. package/corpus/templates/analytics/app/i18n/zh-TW.ts +37 -0
  53. package/corpus/templates/analytics/app/i18n-data.ts +404 -0
  54. package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
  55. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
  56. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
  57. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +49 -26
  58. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +137 -36
  59. package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
  60. package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
  61. package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
  62. package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
  63. package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
  66. package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
  67. package/corpus/templates/analytics/server/lib/dashboard-report.ts +49 -26
  68. package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
  69. package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
  70. package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
  71. package/corpus/templates/analytics/server/lib/session-replay.ts +29 -8
  72. package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
  73. package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
  74. package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
  75. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
  76. package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
  77. package/corpus/templates/content/shared/document-export.ts +1 -1
  78. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
  79. package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +17 -0
  80. package/corpus/templates/design/actions/add-localhost-screens.ts +32 -5
  81. package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
  82. package/corpus/templates/design/actions/update-file.ts +9 -2
  83. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +33 -75
  84. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +72 -9
  85. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +41 -39
  86. package/corpus/templates/design/app/components/design/EditPanel.tsx +1029 -249
  87. package/corpus/templates/design/app/components/design/LayersPanel.tsx +450 -101
  88. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +978 -154
  89. package/corpus/templates/design/app/components/design/TweaksPanel.tsx +7 -9
  90. package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +133 -66
  91. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1234 -344
  92. package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +144 -94
  93. package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +301 -72
  94. package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +876 -286
  95. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
  96. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
  97. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +30 -7
  98. package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +212 -155
  99. package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
  100. package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +487 -107
  101. package/corpus/templates/design/app/components/design/inspector/index.ts +27 -0
  102. package/corpus/templates/design/app/global.css +29 -3
  103. package/corpus/templates/design/app/i18n/zh-TW.ts +13 -0
  104. package/corpus/templates/design/app/i18n-data.ts +239 -1
  105. package/corpus/templates/design/app/pages/DesignEditor.tsx +1023 -353
  106. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
  107. package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
  108. package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
  109. package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
  110. package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
  111. package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
  112. package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
  113. package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
  114. package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
  115. package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
  116. package/corpus/templates/design/shared/code-layer.ts +209 -9
  117. package/corpus/templates/design/shared/html-content.ts +21 -0
  118. package/corpus/templates/design/shared/pen-path.ts +207 -0
  119. package/corpus/templates/mail/app/lib/utils.ts +2 -2
  120. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
  121. package/corpus/templates/slides/AGENTS.md +3 -2
  122. package/corpus/templates/slides/actions/import-file.ts +26 -17
  123. package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
  124. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
  125. package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
  126. package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
  127. package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
  128. package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
  129. package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
  130. package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
  131. package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
  132. package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
  133. package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
  134. package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
  135. package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
  136. package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
  137. package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
  138. package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
  139. package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
  140. package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
  141. package/dist/application-state/handlers.js +1 -1
  142. package/dist/application-state/handlers.js.map +1 -1
  143. package/dist/brand-kit/index.d.ts +1 -1
  144. package/dist/brand-kit/index.js +1 -1
  145. package/dist/brand-kit/index.js.map +1 -1
  146. package/dist/cli/skills.d.ts.map +1 -1
  147. package/dist/cli/skills.js +18 -0
  148. package/dist/cli/skills.js.map +1 -1
  149. package/dist/client/AgentPanel.js +1 -1
  150. package/dist/client/AgentPanel.js.map +1 -1
  151. package/dist/client/MultiTabAssistantChat.js +1 -1
  152. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  153. package/dist/client/extensions/portable-extension.d.ts +1 -1
  154. package/dist/client/extensions/portable-extension.d.ts.map +1 -1
  155. package/dist/client/extensions/portable-extension.js.map +1 -1
  156. package/dist/code-agents/transcript-normalizer.js +1 -1
  157. package/dist/code-agents/transcript-normalizer.js.map +1 -1
  158. package/dist/collab/awareness.d.ts +2 -2
  159. package/dist/collab/awareness.d.ts.map +1 -1
  160. package/dist/collab/routes.d.ts +1 -1
  161. package/dist/extensions/path.d.ts +1 -1
  162. package/dist/extensions/path.d.ts.map +1 -1
  163. package/dist/extensions/path.js.map +1 -1
  164. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  165. package/dist/integrations/a2a-continuation-processor.js +1 -1
  166. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  167. package/dist/mcp/build-server.d.ts +1 -1
  168. package/dist/mcp/build-server.d.ts.map +1 -1
  169. package/dist/mcp/build-server.js +1 -1
  170. package/dist/mcp/build-server.js.map +1 -1
  171. package/dist/mcp/embed-app.d.ts.map +1 -1
  172. package/dist/mcp/embed-app.js +35 -1
  173. package/dist/mcp/embed-app.js.map +1 -1
  174. package/dist/notifications/routes.d.ts +3 -3
  175. package/dist/observability/routes.d.ts +8 -8
  176. package/dist/org/handlers.js +5 -5
  177. package/dist/org/handlers.js.map +1 -1
  178. package/dist/org/plugin.js +2 -2
  179. package/dist/org/plugin.js.map +1 -1
  180. package/dist/resources/handlers.d.ts +1 -1
  181. package/dist/scripts/call-agent.js +1 -1
  182. package/dist/scripts/call-agent.js.map +1 -1
  183. package/dist/scripts/db/safety.js +1 -1
  184. package/dist/scripts/db/safety.js.map +1 -1
  185. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  186. package/dist/server/auth.d.ts.map +1 -1
  187. package/dist/server/auth.js +3 -0
  188. package/dist/server/auth.js.map +1 -1
  189. package/dist/server/builder-design-systems.d.ts +28 -0
  190. package/dist/server/builder-design-systems.d.ts.map +1 -0
  191. package/dist/server/builder-design-systems.js +217 -0
  192. package/dist/server/builder-design-systems.js.map +1 -0
  193. package/dist/server/core-routes-plugin.d.ts +1 -0
  194. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  195. package/dist/server/core-routes-plugin.js +20 -3
  196. package/dist/server/core-routes-plugin.js.map +1 -1
  197. package/dist/server/index.d.ts +1 -0
  198. package/dist/server/index.d.ts.map +1 -1
  199. package/dist/server/index.js +1 -0
  200. package/dist/server/index.js.map +1 -1
  201. package/dist/styles/agent-native.css +14 -7
  202. package/dist/usage/store.js +2 -2
  203. package/dist/usage/store.js.map +1 -1
  204. package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
  205. package/docs/content/locales/ar-SA/template-design.mdx +14 -1
  206. package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
  207. package/docs/content/locales/de-DE/template-design.mdx +15 -1
  208. package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
  209. package/docs/content/locales/es-ES/template-design.mdx +15 -1
  210. package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
  211. package/docs/content/locales/fr-FR/template-design.mdx +15 -1
  212. package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
  213. package/docs/content/locales/hi-IN/template-design.mdx +15 -1
  214. package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
  215. package/docs/content/locales/ja-JP/template-design.mdx +16 -1
  216. package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
  217. package/docs/content/locales/ko-KR/template-design.mdx +14 -1
  218. package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
  219. package/docs/content/locales/pt-BR/template-design.mdx +14 -1
  220. package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
  221. package/docs/content/locales/zh-CN/template-design.mdx +14 -1
  222. package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
  223. package/docs/content/locales/zh-TW/template-design.mdx +14 -1
  224. package/docs/content/skills-guide.mdx +5 -0
  225. package/docs/content/template-design.mdx +14 -1
  226. package/package.json +1 -1
  227. package/corpus/templates/design/actions/import-figma-file.ts +0 -166
  228. package/corpus/templates/slides/server/lib/fig-design-system.ts +0 -110
@@ -10,7 +10,8 @@ import {
10
10
  type HslaColor,
11
11
  type RgbaColor,
12
12
  } from "@shared/color-utils";
13
- import { IconChevronDown, IconColorPicker, IconX } from "@tabler/icons-react";
13
+ import type { ShaderDescriptor } from "@shared/shader-presets";
14
+ import { IconChevronDown, IconColorPicker } from "@tabler/icons-react";
14
15
  import {
15
16
  useEffect,
16
17
  useRef,
@@ -26,13 +27,6 @@ import {
26
27
  PopoverContent,
27
28
  PopoverTrigger,
28
29
  } from "@/components/ui/popover";
29
- import {
30
- Select,
31
- SelectContent,
32
- SelectItem,
33
- SelectTrigger,
34
- SelectValue,
35
- } from "@/components/ui/select";
36
30
  import {
37
31
  Tooltip,
38
32
  TooltipContent,
@@ -40,6 +34,22 @@ import {
40
34
  } from "@/components/ui/tooltip";
41
35
  import { cn } from "@/lib/utils";
42
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
+
43
53
  // ─── Public types ──────────────────────────────────────────────────────────────
44
54
 
45
55
  export type FigmaColorMode = "hex" | "rgb" | "hsl" | "hsb";
@@ -53,6 +63,9 @@ export type FigmaPaintType =
53
63
  | "diamond"
54
64
  | "image"
55
65
  | "video"
66
+ | "shader"
67
+ | "noise"
68
+ | "pattern"
56
69
  | "none";
57
70
 
58
71
  // These interfaces remain so EditPanel's prop types don't break, even though
@@ -116,6 +129,7 @@ export interface FigmaColorPickerLabels {
116
129
  export interface FigmaColorPickerProps {
117
130
  value: string;
118
131
  onChange: (value: string) => void;
132
+ onPaintValueChange?: (value: string) => void;
119
133
  label?: string;
120
134
  opacity?: number;
121
135
  onOpacityChange?: (opacity: number) => void;
@@ -140,6 +154,25 @@ export interface FigmaColorPickerProps {
140
154
  onGradientStopChange?: (id: string, patch: FigmaGradientStopPatch) => void;
141
155
  onAddGradientStop?: () => void;
142
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;
143
176
  labels?: Partial<FigmaColorPickerLabels>;
144
177
  disabled?: boolean;
145
178
  className?: string;
@@ -414,6 +447,90 @@ function IconNoneFill({ className }: { className?: string }) {
414
447
  );
415
448
  }
416
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
+
417
534
  // ─── Paint type definitions (only supported types rendered) ────────────────────
418
535
 
419
536
  const PAINT_TYPES: Array<{
@@ -428,39 +545,36 @@ const PAINT_TYPES: Array<{
428
545
  { type: "diamond", label: "Diamond", Icon: IconDiamondGradient }, // i18n-ignore paint type label
429
546
  { type: "image", label: "Image", Icon: IconImageFill }, // i18n-ignore paint type label
430
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
431
551
  { type: "none", label: "None", Icon: IconNoneFill }, // i18n-ignore paint type label
432
552
  ];
433
553
 
434
- // ─── Document swatch placeholder (shown until real doc colors are provided) ────
435
-
436
- const PLACEHOLDER_SWATCHES = [
437
- "#1f2937",
438
- "#6b7280",
439
- "#ffffff",
440
- "#a3a3a3",
441
- "#f8fafc",
442
- "#111111",
443
- "#000000",
444
- "#a8a29e",
445
- "#22c1e8",
446
- "#f08ce3",
447
- "#737373",
448
- "#050505",
449
- "#23d4b5",
450
- "#d9d9d2",
451
- "#e6d05c",
452
- "#9ca3af",
453
- "#020617",
454
- "#171717",
455
- "#d4d4d8",
456
- "#27272a",
457
- ];
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";
458
571
 
459
572
  // ─── Main component ────────────────────────────────────────────────────────────
460
573
 
461
574
  export function FigmaColorPicker({
462
575
  value,
463
576
  onChange,
577
+ onPaintValueChange,
464
578
  label: _label,
465
579
  opacity,
466
580
  onOpacityChange,
@@ -475,14 +589,17 @@ export function FigmaColorPicker({
475
589
  onRemoveFill: _onRemoveFill,
476
590
  paintType,
477
591
  onPaintTypeChange,
478
- gradientType: _gradientType,
479
- onGradientTypeChange: _onGradientTypeChange,
592
+ gradientType,
593
+ onGradientTypeChange,
480
594
  gradientStops: _gradientStops,
481
595
  selectedStopId: _selectedStopId,
482
596
  onGradientStopSelect: _onGradientStopSelect,
483
597
  onGradientStopChange: _onGradientStopChange,
484
598
  onAddGradientStop: _onAddGradientStop,
485
599
  onRemoveGradientStop: _onRemoveGradientStop,
600
+ documentColors,
601
+ shaderContext,
602
+ onShaderChange,
486
603
  labels,
487
604
  disabled = false,
488
605
  className,
@@ -496,23 +613,95 @@ export function FigmaColorPicker({
496
613
  const [mode, setMode] = useState<FigmaColorMode>("hex");
497
614
  const [hexDraft, setHexDraft] = useState(() => toDisplayHex(color));
498
615
  const [open, setOpen] = useState(false);
499
- const [sourceTab, setSourceTab] = useState<"custom" | "libraries">("custom");
500
616
  const [picking, setPicking] = useState(false);
501
617
  const skipNextHexBlurCommitRef = useRef(false);
502
618
 
503
- const effectivePaintType =
504
- paintType ?? inferPaintType(value, effectiveOpacity);
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;
505
661
 
506
662
  useEffect(() => {
507
663
  setHexDraft(toDisplayHex(color));
508
664
  }, [color.r, color.g, color.b]);
509
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
+
510
694
  // ── Emit helpers ────────────────────────────────────────────────────────────
511
695
 
512
696
  const emitColor = (nextColor: RgbaColor, nextOpacity = effectiveOpacity) => {
513
697
  onChange(rgbaToCss(withColorOpacity(nextColor, nextOpacity)));
514
698
  };
515
699
 
700
+ const emitPaintValue = (nextValue: string) => {
701
+ if (onPaintValueChange) onPaintValueChange(nextValue);
702
+ else onChange(nextValue);
703
+ };
704
+
516
705
  const emitColorFromHsv = (nextHsv: HsvaColor) => {
517
706
  emitColor(hsvToRgba({ ...nextHsv, a: opacityToAlpha(effectiveOpacity) }));
518
707
  };
@@ -524,7 +713,12 @@ export function FigmaColorPicker({
524
713
  const commitHex = () => {
525
714
  const parsed = parseCssColor(`#${hexDraft.replace(/^#/, "")}`);
526
715
  if (!parsed) {
527
- setHexDraft(toDisplayHex(color));
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 });
528
722
  return;
529
723
  }
530
724
  const hexIncludesAlpha = hasHexAlpha(hexDraft);
@@ -540,12 +734,99 @@ export function FigmaColorPicker({
540
734
  else onChange(rgbaToCss(withColorOpacity(color, nextOpacity)));
541
735
  };
542
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
+
543
811
  const setPaintType = (nextType: FigmaPaintType) => {
544
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.
545
822
  if (onPaintTypeChange) {
823
+ setLocalPaintType(nextType);
546
824
  onPaintTypeChange(nextType);
547
825
  return;
548
826
  }
827
+
828
+ setLocalPaintType(nextType);
829
+
549
830
  if (nextType === "none") {
550
831
  onChange("transparent");
551
832
  return;
@@ -554,6 +835,36 @@ export function FigmaColorPicker({
554
835
  emitColor(color, effectiveOpacity > 0 ? effectiveOpacity : 100);
555
836
  return;
556
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
+ }
557
868
  };
558
869
 
559
870
  const pickScreenColor = async () => {
@@ -619,11 +930,11 @@ export function FigmaColorPicker({
619
930
  <ScrubbyNumberInput
620
931
  key={ch}
621
932
  aria-label={ch.toUpperCase()}
622
- value={color[ch]}
933
+ value={fieldColor[ch]}
623
934
  min={0}
624
935
  max={255}
625
936
  disabled={disabled}
626
- onChange={(next) => emitColor({ ...color, [ch]: next })}
937
+ onChange={(next) => emitFieldColor({ ...fieldColor, [ch]: next })}
627
938
  />
628
939
  ))}
629
940
  </div>
@@ -634,27 +945,27 @@ export function FigmaColorPicker({
634
945
  <div className="flex gap-1">
635
946
  <ScrubbyNumberInput
636
947
  aria-label={copy.hue}
637
- value={hsl.h}
948
+ value={fieldHsl.h}
638
949
  min={0}
639
950
  max={360}
640
951
  disabled={disabled}
641
- onChange={(h) => emitColorFromHsl({ ...hsl, h })}
952
+ onChange={(h) => emitFieldHsl({ ...fieldHsl, h })}
642
953
  />
643
954
  <ScrubbyNumberInput
644
955
  aria-label={copy.saturation}
645
- value={hsl.s}
956
+ value={fieldHsl.s}
646
957
  min={0}
647
958
  max={100}
648
959
  disabled={disabled}
649
- onChange={(s) => emitColorFromHsl({ ...hsl, s })}
960
+ onChange={(s) => emitFieldHsl({ ...fieldHsl, s })}
650
961
  />
651
962
  <ScrubbyNumberInput
652
963
  aria-label={copy.lightness}
653
- value={hsl.l}
964
+ value={fieldHsl.l}
654
965
  min={0}
655
966
  max={100}
656
967
  disabled={disabled}
657
- onChange={(l) => emitColorFromHsl({ ...hsl, l })}
968
+ onChange={(l) => emitFieldHsl({ ...fieldHsl, l })}
658
969
  />
659
970
  </div>
660
971
  );
@@ -664,27 +975,27 @@ export function FigmaColorPicker({
664
975
  <div className="flex gap-1">
665
976
  <ScrubbyNumberInput
666
977
  aria-label={copy.hue}
667
- value={hsv.h}
978
+ value={fieldHsv.h}
668
979
  min={0}
669
980
  max={360}
670
981
  disabled={disabled}
671
- onChange={(h) => emitColorFromHsv({ ...hsv, h })}
982
+ onChange={(h) => emitFieldHsv({ ...fieldHsv, h })}
672
983
  />
673
984
  <ScrubbyNumberInput
674
985
  aria-label={copy.saturation}
675
- value={hsv.s}
986
+ value={fieldHsv.s}
676
987
  min={0}
677
988
  max={100}
678
989
  disabled={disabled}
679
- onChange={(s) => emitColorFromHsv({ ...hsv, s })}
990
+ onChange={(s) => emitFieldHsv({ ...fieldHsv, s })}
680
991
  />
681
992
  <ScrubbyNumberInput
682
993
  aria-label={copy.brightness}
683
- value={hsv.v}
994
+ value={fieldHsv.v}
684
995
  min={0}
685
996
  max={100}
686
997
  disabled={disabled}
687
- onChange={(v) => emitColorFromHsv({ ...hsv, v })}
998
+ onChange={(v) => emitFieldHsv({ ...fieldHsv, v })}
688
999
  />
689
1000
  </div>
690
1001
  );
@@ -710,10 +1021,10 @@ export function FigmaColorPicker({
710
1021
  {/* Flat swatch chip — no shadow-inner (Figma is flat) */}
711
1022
  <span
712
1023
  className="size-4 shrink-0 rounded-[3px] border border-border/60"
713
- style={swatchStyle(rgbaToCss(color))}
1024
+ style={triggerSwatchStyle(value, color)}
714
1025
  />
715
1026
  <span className="min-w-0 flex-1 truncate text-left tabular-nums uppercase text-[11px]">
716
- {toDisplayHex(color)}
1027
+ {triggerLabel(effectivePaintType, color)}
717
1028
  </span>
718
1029
  <span className="tabular-nums text-muted-foreground text-[11px]">
719
1030
  {effectiveOpacity}%
@@ -729,70 +1040,31 @@ export function FigmaColorPicker({
729
1040
  className="z-[10000] w-[252px] p-0 shadow-xl"
730
1041
  >
731
1042
  <div className="overflow-hidden rounded-md bg-popover text-popover-foreground">
732
- {/* ── Header: Custom | Libraries + ✕ ────────────────────────── */}
733
- <div className="flex items-center justify-between px-3 py-1.5">
734
- <div className="flex items-center gap-2">
735
- {(["custom", "libraries"] as const).map((tab) => (
736
- <button
737
- key={tab}
738
- type="button"
739
- role="tab"
740
- aria-selected={sourceTab === tab}
741
- onClick={() => setSourceTab(tab)}
742
- className={cn(
743
- "cursor-pointer rounded px-1.5 py-0.5 text-[11px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
744
- sourceTab === tab
745
- ? "bg-[var(--design-editor-control-bg)] text-foreground shadow-[inset_0_0_0_1px_var(--design-editor-control-border)]"
746
- : "text-muted-foreground hover:text-foreground",
747
- )}
748
- >
749
- {
750
- tab === "custom"
751
- ? "Custom" /* i18n-ignore Figma picker tab */
752
- : "Libraries" /* i18n-ignore Figma picker tab */
753
- }
754
- </button>
755
- ))}
756
- </div>
757
- <Tooltip>
758
- <TooltipTrigger asChild>
759
- <button
760
- type="button"
761
- aria-label={
762
- "Close" /* i18n-ignore Figma picker close label */
763
- }
764
- onClick={() => setOpen(false)}
765
- className="flex size-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
766
- >
767
- <IconX className="size-3" />
768
- </button>
769
- </TooltipTrigger>
770
- <TooltipContent>{"Close" /* i18n-ignore */}</TooltipContent>
771
- </Tooltip>
772
- </div>
773
-
774
- {sourceTab === "custom" ? (
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
+ ) : (
775
1058
  <>
776
- {/* ── Paint-type icon row ──────────────────────────────────── */}
777
- <div className="border-t border-border/70 px-2 py-1.5">
778
- <div className="grid grid-cols-8 gap-px">
779
- {PAINT_TYPES.map(({ type, label, Icon }) => {
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 }) => {
780
1067
  const isActive = effectivePaintType === type;
781
- const isGradientType =
782
- type === "linear" ||
783
- type === "radial" ||
784
- type === "angular" ||
785
- type === "diamond";
786
- const gradientDisabled =
787
- isGradientType &&
788
- !onPaintTypeChange &&
789
- !_onGradientTypeChange &&
790
- !_onAddGradientStop;
791
- const imageDisabled =
792
- (type === "image" || type === "video") &&
793
- !onPaintTypeChange;
794
- const isDisabled =
795
- disabled || gradientDisabled || imageDisabled;
796
1068
  return (
797
1069
  <Tooltip key={type}>
798
1070
  <TooltipTrigger asChild>
@@ -800,227 +1072,358 @@ export function FigmaColorPicker({
800
1072
  type="button"
801
1073
  aria-label={label}
802
1074
  aria-pressed={isActive}
803
- disabled={isDisabled}
1075
+ disabled={disabled}
804
1076
  onClick={() => setPaintType(type)}
805
1077
  className={cn(
806
- "flex h-6 w-full cursor-pointer items-center justify-center rounded-sm transition-colors",
1078
+ "flex h-8 w-full cursor-pointer flex-col items-center justify-center gap-0.5 rounded transition-colors",
807
1079
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
808
1080
  "active:scale-95",
809
1081
  isActive
810
- ? "bg-primary/10 text-primary shadow-[inset_0_0_0_1px_var(--primary)]"
1082
+ ? "bg-accent text-accent-foreground ring-1 ring-primary/60"
811
1083
  : "text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground",
812
- isDisabled && "pointer-events-none opacity-40",
1084
+ disabled && "pointer-events-none opacity-40",
813
1085
  )}
814
1086
  >
815
- <Icon className="size-3.5" />
1087
+ <Icon className="size-4" />
816
1088
  </button>
817
1089
  </TooltipTrigger>
818
- <TooltipContent>{label}</TooltipContent>
1090
+ <TooltipContent side="bottom" className="text-[10px]">
1091
+ {label}
1092
+ </TooltipContent>
819
1093
  </Tooltip>
820
1094
  );
821
1095
  })}
822
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>
823
1135
  </div>
824
1136
 
825
- {/* ── 2D Saturation/Brightness field (edge-to-edge, no side padding) */}
826
- <div className="border-t border-border/70">
827
- <SaturationBrightnessField
828
- hsv={hsv}
829
- label={copy.saturationBrightness}
830
- disabled={disabled}
831
- onChange={emitColorFromHsv}
832
- />
833
- </div>
834
-
835
- {/* ── Eyedropper + Hue slider / Swatch + Alpha slider ─────── */}
836
- {/* Left gutter spans both rows with dropper centered vertically */}
837
- <div className="mt-2.5 px-3">
838
- <div className="grid grid-cols-[1.5rem_1fr] items-center gap-x-2">
839
- {/* Eyedropper centered across the two slider rows via row-span-2 */}
840
- <div className="row-span-2 flex items-center justify-center">
841
- <Tooltip>
842
- <TooltipTrigger asChild>
843
- <button
844
- type="button"
845
- aria-label={
846
- "Pick color" /* i18n-ignore browser eyedropper label */
847
- }
848
- disabled={disabled || !hasEyeDropper}
849
- onClick={() => void pickScreenColor()}
850
- className={cn(
851
- "flex size-6 cursor-pointer items-center justify-center rounded-sm transition-colors",
852
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
853
- picking
854
- ? "bg-primary/10 text-primary ring-1 ring-primary/50"
855
- : "text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground",
856
- (disabled || !hasEyeDropper) &&
857
- "pointer-events-none opacity-40",
858
- )}
859
- >
860
- <IconColorPicker className="size-4" />
861
- </button>
862
- </TooltipTrigger>
863
- <TooltipContent>
864
- {
865
- hasEyeDropper
866
- ? "Pick color" // i18n-ignore browser eyedropper label
867
- : "Not supported in this browser" // i18n-ignore browser eyedropper disabled label
868
- }
869
- </TooltipContent>
870
- </Tooltip>
871
- </div>
872
-
873
- {/* Hue track */}
874
- <ColorTrack
875
- label={copy.hue}
876
- value={hsv.h}
877
- min={0}
878
- max={360}
1137
+ {/* ── Image fill controls ─────────────────────────────────── */}
1138
+ {effectivePaintType === "image" && (
1139
+ <div>
1140
+ <ImageFillControls
1141
+ value={imageFill}
879
1142
  disabled={disabled}
880
- backgroundImage="linear-gradient(90deg, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)"
881
- onChange={(next) =>
882
- emitColorFromHsv({ ...hsv, h: next === 360 ? 0 : next })
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 */
883
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}
884
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>
885
1264
 
886
- {/* Current-color swatch left of alpha (matches Figma's layout) */}
887
- <div className="flex items-center gap-2">
888
- <span
889
- className="size-[18px] shrink-0 rounded-[3px] border border-border/60"
890
- style={swatchStyle(rgbaToCss(color))}
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
+ }}
891
1287
  />
892
- {/* Alpha track fills remaining width */}
893
- <div className="flex-1">
894
- <ColorTrack
895
- label={copy.opacity}
896
- value={effectiveOpacity}
897
- min={0}
898
- max={100}
899
- disabled={disabled}
900
- backgroundImage={alphaTrackBackground(color)}
901
- backgroundSize="8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%"
902
- backgroundPosition="0 0, 0 4px, 4px -4px, -4px 0, 0 0"
903
- onChange={setOpacity}
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))}
904
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>
905
1322
  </div>
906
1323
  </div>
907
1324
  </div>
908
- </div>
1325
+ )}
909
1326
 
910
1327
  {/* ── Value row: [Hex ▾] [value input(s)] [opacity %] ─────── */}
911
- <div className="mt-2.5 px-3 pb-3">
912
- <div className="grid grid-cols-[4.5rem_1fr_3rem] items-center gap-1">
913
- {/* Model dropdown */}
914
- <Select
915
- value={mode}
916
- onValueChange={(v) => setMode(v as FigmaColorMode)}
917
- disabled={disabled}
918
- >
919
- <SelectTrigger className="h-6 w-[4.5rem] rounded-md border-transparent bg-transparent px-1.5 text-[11px] font-semibold shadow-none hover:bg-[var(--design-editor-control-bg)] focus:ring-0 focus:ring-offset-0 focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-3 [&>svg]:shrink-0">
920
- <SelectValue />
921
- </SelectTrigger>
922
- <SelectContent className="text-[11px]">
923
- <SelectItem value="hex" className="text-[11px]">
924
- Hex
925
- </SelectItem>{" "}
926
- {/* i18n-ignore color mode */}
927
- <SelectItem value="rgb" className="text-[11px]">
928
- RGB
929
- </SelectItem>{" "}
930
- {/* i18n-ignore color mode */}
931
- <SelectItem value="hsl" className="text-[11px]">
932
- HSL
933
- </SelectItem>{" "}
934
- {/* i18n-ignore color mode */}
935
- <SelectItem value="hsb" className="text-[11px]">
936
- HSB
937
- </SelectItem>{" "}
938
- {/* i18n-ignore color mode */}
939
- </SelectContent>
940
- </Select>
941
-
942
- {/* Value field(s) — adapts to mode */}
943
- {renderValueInputs()}
944
-
945
- {/* Opacity % field */}
946
- <div className="flex h-6 overflow-hidden rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)]">
947
- <ScrubbyNumberInput
948
- aria-label={copy.opacity}
949
- value={effectiveOpacity}
950
- min={0}
951
- max={100}
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}
952
1336
  disabled={disabled}
953
- onChange={setOpacity}
954
- 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"
955
- compact
1337
+ onChange={(v) => setMode(v as FigmaColorMode)}
956
1338
  />
957
- <span className="flex w-4 shrink-0 items-center justify-center border-l border-border/60 text-[10px] text-muted-foreground">
958
- %
959
- </span>
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>
960
1372
  </div>
961
1373
  </div>
962
- </div>
1374
+ )}
963
1375
 
964
- {/* ── "On this page" source + swatches ────────────────────── */}
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. */}
965
1381
  <div className="border-t border-border/70 px-3 py-2.5">
966
- {/* Source selector styled as a full-width bordered dropdown */}
967
- <button
968
- type="button"
969
- className="mb-2 flex h-6 w-full items-center justify-between rounded-md border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 text-left text-[11px] text-foreground hover:bg-[var(--design-editor-panel-raised-bg)]"
970
- >
971
- {"On this page" /* i18n-ignore Figma picker source */}
972
- <IconChevronDown className="size-3 text-muted-foreground" />
973
- </button>
974
-
975
- {/* Swatch grid: flat chips (no shadow-inner), 8 columns */}
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 */}
976
1388
  <div className="grid grid-cols-8 gap-1">
977
- {PLACEHOLDER_SWATCHES.map((swatch, index) => {
978
- const isCurrentColor =
979
- rgbaToHex(parseCssColor(swatch) ?? FALLBACK_COLOR) ===
980
- rgbaToHex(color);
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;
981
1398
  return (
982
- <Tooltip key={`${swatch}-${index}`}>
1399
+ <Tooltip key={docColor}>
983
1400
  <TooltipTrigger asChild>
984
1401
  <button
985
1402
  type="button"
986
1403
  disabled={disabled}
987
- aria-label={swatch}
988
- aria-pressed={isCurrentColor}
1404
+ aria-label={currentHex}
1405
+ aria-pressed={isActive}
989
1406
  className={cn(
990
1407
  "size-5 rounded-sm border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
991
- isCurrentColor
1408
+ isActive
992
1409
  ? "border-primary ring-1 ring-primary"
993
1410
  : "border-border/60",
994
1411
  )}
995
- style={swatchStyle(swatch)}
996
- onClick={() =>
997
- emitColor(parseCssColor(swatch) ?? color)
998
- }
1412
+ style={swatchStyle(docColor)}
1413
+ onClick={() => {
1414
+ const parsed = parseCssColor(docColor) ?? color;
1415
+ if (activeGradient) emitStopColor(parsed);
1416
+ else emitColor(parsed);
1417
+ }}
999
1418
  />
1000
1419
  </TooltipTrigger>
1001
- <TooltipContent>{swatch}</TooltipContent>
1420
+ <TooltipContent>{currentHex}</TooltipContent>
1002
1421
  </Tooltip>
1003
1422
  );
1004
1423
  })}
1005
1424
  </div>
1006
1425
  </div>
1007
1426
  </>
1008
- ) : (
1009
- /* ── Libraries tab: honest empty state ────────────────────── */
1010
- <div className="border-t border-border/70 p-3">
1011
- <div className="rounded-md border border-dashed border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-3 py-5 text-center">
1012
- <p className="text-[11px] font-medium text-foreground">
1013
- {
1014
- "No color libraries connected" /* i18n-ignore Figma picker library empty state */
1015
- }
1016
- </p>
1017
- <p className="mt-1 text-[10px] text-muted-foreground">
1018
- {
1019
- "Connect a library in the Assets panel to browse colors here." /* i18n-ignore Figma picker library hint */
1020
- }
1021
- </p>
1022
- </div>
1023
- </div>
1024
1427
  )}
1025
1428
  </div>
1026
1429
  </PopoverContent>
@@ -1031,6 +1434,104 @@ export function FigmaColorPicker({
1031
1434
 
1032
1435
  // ─── Sub-components ────────────────────────────────────────────────────────────
1033
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
+
1034
1535
  function SaturationBrightnessField({
1035
1536
  hsv,
1036
1537
  label,
@@ -1319,7 +1820,14 @@ function ScrubbyNumberInput({
1319
1820
 
1320
1821
  // ─── Utilities ─────────────────────────────────────────────────────────────────
1321
1822
 
1322
- function inferPaintType(value: string, opacity: number): FigmaPaintType {
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";
1323
1831
  const parsed = parseCssColor(value);
1324
1832
  if (opacity <= 0 || parsed?.a === 0 || value.trim() === "transparent") {
1325
1833
  return "none";
@@ -1327,11 +1835,93 @@ function inferPaintType(value: string, opacity: number): FigmaPaintType {
1327
1835
  return "solid";
1328
1836
  }
1329
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
+
1330
1887
  /** Show hex without the leading # for the display field (matches Figma). */
1331
1888
  function toDisplayHex(color: RgbaColor): string {
1332
1889
  return rgbaToHex(color).replace(/^#/, "").toUpperCase();
1333
1890
  }
1334
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
+
1335
1925
  function swatchStyle(value: string): {
1336
1926
  backgroundColor?: string;
1337
1927
  backgroundImage?: string;