@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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +34 -0
- package/corpus/core/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ar-SA/template-design.mdx +55 -1
- package/corpus/core/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/de-DE/template-design.mdx +66 -1
- package/corpus/core/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/es-ES/template-design.mdx +65 -1
- package/corpus/core/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/fr-FR/template-design.mdx +64 -1
- package/corpus/core/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/hi-IN/template-design.mdx +59 -1
- package/corpus/core/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ja-JP/template-design.mdx +62 -1
- package/corpus/core/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/ko-KR/template-design.mdx +58 -1
- package/corpus/core/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/pt-BR/template-design.mdx +62 -1
- package/corpus/core/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-CN/template-design.mdx +50 -1
- package/corpus/core/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/locales/zh-TW/template-design.mdx +52 -1
- package/corpus/core/docs/content/skills-guide.mdx +5 -0
- package/corpus/core/docs/content/template-design.mdx +59 -1
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/agent/engine/index.ts +1 -0
- package/corpus/core/src/agent/engine/registry.ts +40 -0
- package/corpus/core/src/agent/production-agent.ts +6 -2
- package/corpus/core/src/application-state/handlers.ts +1 -1
- package/corpus/core/src/brand-kit/index.ts +1 -1
- package/corpus/core/src/cli/design-connect.ts +490 -0
- package/corpus/core/src/cli/index.ts +16 -0
- package/corpus/core/src/cli/skills.ts +144 -3
- package/corpus/core/src/client/AgentPanel.tsx +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +43 -7
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +1 -1
- package/corpus/core/src/client/agent-chat.ts +27 -3
- package/corpus/core/src/client/blocks/library/MermaidBlock.tsx +1 -1
- package/corpus/core/src/client/blocks/library/diagram.tsx +2 -2
- package/corpus/core/src/client/blocks/library/wireframe.tsx +1 -1
- package/corpus/core/src/client/chat/message-components.tsx +1 -1
- package/corpus/core/src/client/chat-model-groups.ts +1 -2
- package/corpus/core/src/client/components/LiveCursorOverlay.tsx +33 -66
- package/corpus/core/src/client/composer/TiptapComposer.tsx +99 -24
- package/corpus/core/src/client/composer/useVoiceDictation.ts +429 -260
- package/corpus/core/src/client/extensions/portable-extension.ts +1 -1
- package/corpus/core/src/client/frame-protocol.ts +17 -2
- package/corpus/core/src/client/rich-markdown-editor/RegistryBlockNode.tsx +2 -2
- package/corpus/core/src/client/sharing/ShareButton.tsx +20 -15
- package/corpus/core/src/client/use-agent-engine-configured.ts +80 -28
- package/corpus/core/src/code-agents/transcript-normalizer.ts +1 -1
- package/corpus/core/src/extensions/path.ts +1 -4
- package/corpus/core/src/integrations/a2a-continuation-processor.ts +1 -1
- package/corpus/core/src/mcp/build-server.ts +1 -1
- package/corpus/core/src/org/handlers.ts +5 -5
- package/corpus/core/src/org/plugin.ts +2 -2
- package/corpus/core/src/scripts/call-agent.ts +1 -1
- package/corpus/core/src/scripts/db/safety.ts +1 -1
- package/corpus/core/src/server/auth.ts +3 -0
- package/corpus/core/src/server/builder-design-systems.ts +308 -0
- package/corpus/core/src/server/core-routes-plugin.ts +29 -3
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/core/src/server/transcribe-voice.ts +57 -22
- package/corpus/core/src/shared/mcp-embed-headers.ts +23 -1
- package/corpus/core/src/styles/agent-native.css +60 -4
- package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/corpus/core/src/usage/store.ts +2 -2
- package/corpus/core/src/vite/client.ts +2 -0
- package/corpus/core/src/voice/index.ts +18 -0
- package/corpus/core/src/voice/voice-cleanup-prompt.ts +38 -0
- package/corpus/core/src/voice/voice-context.ts +217 -0
- package/corpus/core/src/voice/voice-replacements.ts +75 -0
- package/corpus/templates/analytics/AGENTS.md +4 -3
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +298 -15
- package/corpus/templates/analytics/actions/get-sql-dashboard.ts +9 -4
- package/corpus/templates/analytics/actions/list-session-recordings.ts +6 -1
- package/corpus/templates/analytics/actions/mutate-dashboard.ts +18 -2
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +12 -2
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +557 -400
- package/corpus/templates/analytics/app/global.css +0 -4
- package/corpus/templates/analytics/app/hooks/use-replay-storage-status.ts +66 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +39 -0
- package/corpus/templates/analytics/app/i18n-data.ts +424 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +333 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +2 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-layout.ts +121 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +82 -34
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +7 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +144 -36
- package/corpus/templates/analytics/changelog/2026-06-28-ask-tab-now-matches-the-darker-app-background.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-dashboard-email-reports-now-use-cleaner-light-mode-snapshots.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-28-sessions-now-show-only-signed-in-recordings-with-playable-re.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-agents-can-now-place-new-charts-into-requested-das.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-analytics-queries-now-report-failed-source-query-errors-inst.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-charts-can-now-be-dropped-between-full-dashboard-rows-to-cre.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-dashboard-email-reports-include-the-full-dashboard-image-wit.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-main-surface-border-reaches-the-top.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-session-replay-now-records-playable-sessions-in-production-a.md +6 -0
- package/corpus/templates/analytics/changelog/2026-06-29-set-up-session-replay-storage-from-settings-connect-builder-.md +6 -0
- package/corpus/templates/analytics/scripts/cleanup-empty-session-recordings.ts +102 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +3 -3
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +75 -30
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +5 -2
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +81 -20
- package/corpus/templates/analytics/server/lib/replay-storage.ts +79 -0
- package/corpus/templates/analytics/server/lib/s3-upload-provider.ts +255 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +53 -12
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +11 -2
- package/corpus/templates/analytics/server/plugins/onboarding.ts +97 -0
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +4 -4
- package/corpus/templates/assets/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/brain/app/components/layout/Layout.tsx +29 -2
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +131 -42
- package/corpus/templates/brain/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/brain/app/i18n-data.ts +20 -0
- package/corpus/templates/brain/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +29 -1
- package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +417 -299
- package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/calendar/app/i18n-data.ts +20 -0
- package/corpus/templates/calendar/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +6 -19
- package/corpus/templates/chat/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/clips/AGENTS.md +4 -0
- package/corpus/templates/clips/actions/cleanup-transcript.ts +39 -9
- package/corpus/templates/clips/actions/finalize-meeting.ts +81 -12
- package/corpus/templates/clips/actions/lib/agents-md-context.ts +4 -1
- package/corpus/templates/clips/app/components/library/library-layout.tsx +3 -3
- package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +1 -1
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +2 -2
- package/corpus/templates/clips/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
- package/corpus/templates/clips/desktop/src/lib/personal-vocabulary.ts +13 -8
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +24 -0
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +105 -5
- package/corpus/templates/clips/desktop/src/lib/voice-dictation.ts +60 -1
- package/corpus/templates/content/actions/set-image-alt-text.ts +1 -1
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +1 -1
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +5 -5
- package/corpus/templates/content/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/content/shared/document-export.ts +1 -1
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +8 -18
- package/corpus/templates/design/.agents/skills/visual-edit/SKILL.md +154 -0
- package/corpus/templates/design/AGENTS.md +95 -5
- package/corpus/templates/design/DESIGN.md +82 -0
- package/corpus/templates/design/DEVELOPING.md +43 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +453 -0
- package/corpus/templates/design/actions/apply-shader.ts +277 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +303 -0
- package/corpus/templates/design/actions/connect-localhost.ts +183 -0
- package/corpus/templates/design/actions/edit-design.ts +195 -42
- package/corpus/templates/design/actions/generate-design.ts +71 -1
- package/corpus/templates/design/actions/generate-screens.ts +193 -0
- package/corpus/templates/design/actions/get-code-layer-projection.ts +191 -0
- package/corpus/templates/design/actions/get-shader.ts +119 -0
- package/corpus/templates/design/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/design/actions/list-localhost-connections.ts +95 -0
- package/corpus/templates/design/actions/navigate.ts +55 -2
- package/corpus/templates/design/actions/update-file.ts +9 -2
- package/corpus/templates/design/actions/view-screen.ts +68 -1
- package/corpus/templates/design/agent-native.app-skill.json +10 -0
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +719 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +2120 -103
- package/corpus/templates/design/app/components/design/DesignEditorSkeleton.tsx +74 -38
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +401 -0
- package/corpus/templates/design/app/components/design/EditPanel.tsx +3966 -440
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +1473 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +3940 -147
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +700 -13
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +105 -63
- package/corpus/templates/design/app/components/design/index.ts +0 -5
- package/corpus/templates/design/app/components/design/inspector/AlignmentMatrix.tsx +353 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +1589 -0
- package/corpus/templates/design/app/components/design/inspector/ConstraintsWidget.tsx +511 -0
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +395 -0
- package/corpus/templates/design/app/components/design/inspector/FigmaColorPicker.tsx +2010 -0
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +601 -0
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +242 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +420 -0
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +238 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderControls.stories.tsx +71 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderControls.tsx +575 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderEffectRow.tsx +106 -0
- package/corpus/templates/design/app/components/design/inspector/ShaderFillsPanel.tsx +423 -0
- package/corpus/templates/design/app/components/design/inspector/figma-icons.tsx +741 -0
- package/corpus/templates/design/app/components/design/inspector/index.ts +87 -0
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +201 -0
- package/corpus/templates/design/app/components/design/types.ts +23 -0
- package/corpus/templates/design/app/components/layout/Layout.tsx +24 -13
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +4 -4
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +474 -36
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +22 -5
- package/corpus/templates/design/app/components/visual-editor/SaveStatusIndicator.tsx +24 -11
- package/corpus/templates/design/app/global.css +149 -5
- package/corpus/templates/design/app/hooks/use-agent-generating.ts +4 -0
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +130 -8
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +329 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +277 -0
- package/corpus/templates/design/app/i18n-data.ts +3028 -1
- package/corpus/templates/design/app/pages/DesignEditor.tsx +5831 -904
- package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +57 -221
- package/corpus/templates/design/changelog/2026-06-28-annotate-mode-now-combines-drawing-and-comment-pins-in-one-c.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-design-editor-panel-borders-are-cleaner-so-the-canvas-edge-s.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-drawing-prompts-now-reliably-open-and-submit-to-the-design-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-editor-app-menus-now-use-an-outline-agent-native-mark-with-b.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-figma-style-code-design-mode.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-layer-selection-now-updates-chat-context-without-opening-the.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-layer-selections-now-preserve-hidden-and-locked-canvas-state.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-live-cursors-now-keep-names-beside-a-cleaner-pointer-marker.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-mode-switching-now-lives-in-a-compact-bottom-right-icon-dock.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-handles-zooming-drawing-opening-and-dele.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-now-selects-screens-on-click-and-opens-them-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-screen-overview-zooms-into-the-active-screen-with-smoother-e.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-selected-element-chips-in-chat-are-shorter-and-easier-to-sca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-the-design-editor-toolbar-is-more-compact-and-the-app-menu-m.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-at-the-top-of-the-design-sidebar-instead-of-.md +6 -0
- package/corpus/templates/design/changelog/2026-06-28-tweaks-now-live-in-the-right-inspector-beside-the-design-tab.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-active-sidebar-rows-use-soft-highlights.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-agents-can-now-open-screen-overview-focus-screens-and-add.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-canvas-selection-no-longer-flashes-broken-prototype-html-whe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-command-menu-selections-are-clearer-in-dark-mode.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-agents-now-edit-broad-copy-changes-more-reliably.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-editor-extensions-now-run-inline-beside-the-artboard.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-design-mode-top-chrome-now-groups-collaborators-preview-shar.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-element-selection-now-stays-on-the-intended-layer-after-sele.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-full-view-controls-now-sit-above-frames-inst.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-overview-tools-can-draw-shapes-create-screens-and-kee.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-sidebars-use-darker-chrome-and-chat-warns-immediately.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-pen-tool-now-creates-anchor-based-bezier-vectors-with-ha.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-visual-edit-can-open-localhost-routes-as-url-backed-screens.md +6 -0
- package/corpus/templates/design/package.json +1 -0
- package/corpus/templates/design/server/db/schema.ts +26 -0
- package/corpus/templates/design/server/handlers/import-figma-system.ts +38 -85
- package/corpus/templates/design/server/lib/design-snapshot.ts +9 -1
- package/corpus/templates/design/server/plugins/agent-chat.ts +3 -0
- package/corpus/templates/design/shared/api.ts +27 -0
- package/corpus/templates/design/shared/canvas-frames.ts +103 -0
- package/corpus/templates/design/shared/canvas-math.ts +1058 -0
- package/corpus/templates/design/shared/code-layer.ts +2412 -0
- package/corpus/templates/design/shared/color-utils.ts +180 -0
- package/corpus/templates/design/shared/generation-session.ts +61 -0
- package/corpus/templates/design/shared/html-content.ts +21 -0
- package/corpus/templates/design/shared/pen-path.ts +207 -0
- package/corpus/templates/design/shared/shader-presets.ts +782 -0
- package/corpus/templates/design/shared/shader-safety.ts +137 -0
- package/corpus/templates/design/shared/source-mode.ts +237 -0
- package/corpus/templates/forms/app/components/layout/Sidebar.tsx +149 -6
- package/corpus/templates/forms/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/forms/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/forms/app/i18n/en-US.ts +2 -0
- package/corpus/templates/forms/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/forms/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/forms/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/forms/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/forms/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/forms/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/forms/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/forms/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/forms/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/macros/app/components/layout/AppLayout.tsx +2 -2
- package/corpus/templates/macros/changelog/2026-06-28-left-sidebar-footer-controls-use-less-divider-chrome.md +6 -0
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +408 -247
- package/corpus/templates/mail/app/i18n/ar-SA.ts +4 -0
- package/corpus/templates/mail/app/i18n/de-DE.ts +4 -0
- package/corpus/templates/mail/app/i18n/en-US.ts +4 -0
- package/corpus/templates/mail/app/i18n/es-ES.ts +4 -0
- package/corpus/templates/mail/app/i18n/fr-FR.ts +4 -0
- package/corpus/templates/mail/app/i18n/hi-IN.ts +4 -0
- package/corpus/templates/mail/app/i18n/ja-JP.ts +4 -0
- package/corpus/templates/mail/app/i18n/ko-KR.ts +4 -0
- package/corpus/templates/mail/app/i18n/pt-BR.ts +4 -0
- package/corpus/templates/mail/app/i18n/zh-CN.ts +4 -0
- package/corpus/templates/mail/app/i18n/zh-TW.ts +4 -0
- package/corpus/templates/mail/app/lib/utils.ts +2 -2
- package/corpus/templates/mail/changelog/2026-06-28-pinned-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +6 -8
- package/corpus/templates/plan/app/components/plan/wireframe/Wireframe.tsx +1 -1
- package/corpus/templates/plan/app/global.css +61 -78
- package/corpus/templates/plan/app/pages/PlansPage.tsx +8 -0
- package/corpus/templates/plan/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-28-plans-use-the-shared-neutral-light-and-dark-theme.md +6 -0
- package/corpus/templates/plan/changelog/2026-06-28-sending-open-feedback-from-a-plan-now-opens-the-inline-plan-.md +6 -0
- package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +9 -10
- package/corpus/templates/slides/AGENTS.md +3 -2
- package/corpus/templates/slides/actions/import-file.ts +26 -17
- package/corpus/templates/slides/actions/index-design-system-with-builder.ts +259 -0
- package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +35 -167
- package/corpus/templates/slides/app/components/design-system/fig-import-response.ts +9 -13
- package/corpus/templates/slides/app/components/layout/Sidebar.tsx +5 -5
- package/corpus/templates/slides/app/context/DeckContext.tsx +1 -1
- package/corpus/templates/slides/app/i18n/ar-SA.ts +9 -37
- package/corpus/templates/slides/app/i18n/de-DE.ts +10 -25
- package/corpus/templates/slides/app/i18n/en-US.ts +9 -26
- package/corpus/templates/slides/app/i18n/es-ES.ts +10 -28
- package/corpus/templates/slides/app/i18n/fr-FR.ts +10 -28
- package/corpus/templates/slides/app/i18n/hi-IN.ts +9 -26
- package/corpus/templates/slides/app/i18n/ja-JP.ts +10 -22
- package/corpus/templates/slides/app/i18n/ko-KR.ts +9 -23
- package/corpus/templates/slides/app/i18n/pt-BR.ts +10 -28
- package/corpus/templates/slides/app/i18n/zh-CN.ts +9 -22
- package/corpus/templates/slides/app/i18n/zh-TW.ts +9 -22
- package/corpus/templates/slides/changelog/2026-06-28-left-sidebar-collapse-motion-and-footer-chrome-are-quieter.md +6 -0
- package/corpus/templates/slides/server/handlers/import-figma-system.ts +44 -12
- package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
- package/corpus/templates/videos/app/components/NewCompositionPopover.tsx +1 -1
- package/corpus/templates/videos/app/components/Sidebar.tsx +2 -2
- package/corpus/templates/videos/app/components/layout/Layout.tsx +29 -2
- package/corpus/templates/videos/app/components/layout/NavSidebar.tsx +113 -30
- package/corpus/templates/videos/app/i18n/ar-SA.ts +2 -0
- package/corpus/templates/videos/app/i18n/de-DE.ts +2 -0
- package/corpus/templates/videos/app/i18n/en-US.ts +2 -0
- package/corpus/templates/videos/app/i18n/es-ES.ts +2 -0
- package/corpus/templates/videos/app/i18n/fr-FR.ts +2 -0
- package/corpus/templates/videos/app/i18n/hi-IN.ts +2 -0
- package/corpus/templates/videos/app/i18n/ja-JP.ts +2 -0
- package/corpus/templates/videos/app/i18n/ko-KR.ts +2 -0
- package/corpus/templates/videos/app/i18n/pt-BR.ts +2 -0
- package/corpus/templates/videos/app/i18n/zh-CN.ts +2 -0
- package/corpus/templates/videos/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/videos/changelog/2026-06-28-left-sidebar-collapses-into-an-animated-icon-rail.md +6 -0
- package/dist/agent/engine/index.d.ts +1 -1
- package/dist/agent/engine/index.d.ts.map +1 -1
- package/dist/agent/engine/index.js +1 -1
- package/dist/agent/engine/index.js.map +1 -1
- package/dist/agent/engine/registry.d.ts +9 -0
- package/dist/agent/engine/registry.d.ts.map +1 -1
- package/dist/agent/engine/registry.js +37 -0
- package/dist/agent/engine/registry.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +4 -3
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/application-state/handlers.js +1 -1
- package/dist/application-state/handlers.js.map +1 -1
- package/dist/brand-kit/index.d.ts +1 -1
- package/dist/brand-kit/index.js +1 -1
- package/dist/brand-kit/index.js.map +1 -1
- package/dist/cli/design-connect.d.ts +52 -0
- package/dist/cli/design-connect.d.ts.map +1 -0
- package/dist/cli/design-connect.js +409 -0
- package/dist/cli/design-connect.js.map +1 -0
- package/dist/cli/index.js +15 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/skills.d.ts +3 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +139 -3
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +38 -8
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +1 -1
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat.d.ts +6 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +17 -3
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/blocks/library/MermaidBlock.js +1 -1
- package/dist/client/blocks/library/MermaidBlock.js.map +1 -1
- package/dist/client/blocks/library/diagram.js +1 -1
- package/dist/client/blocks/library/diagram.js.map +1 -1
- package/dist/client/blocks/library/wireframe.js +1 -1
- package/dist/client/blocks/library/wireframe.js.map +1 -1
- package/dist/client/chat/message-components.js +1 -1
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/chat-model-groups.d.ts.map +1 -1
- package/dist/client/chat-model-groups.js +1 -2
- package/dist/client/chat-model-groups.js.map +1 -1
- package/dist/client/components/LiveCursorOverlay.d.ts +1 -2
- package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -1
- package/dist/client/components/LiveCursorOverlay.js +19 -40
- package/dist/client/components/LiveCursorOverlay.js.map +1 -1
- package/dist/client/composer/TiptapComposer.d.ts +3 -1
- package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/client/composer/TiptapComposer.js +86 -24
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/composer/useVoiceDictation.d.ts +9 -0
- package/dist/client/composer/useVoiceDictation.d.ts.map +1 -1
- package/dist/client/composer/useVoiceDictation.js +154 -32
- package/dist/client/composer/useVoiceDictation.js.map +1 -1
- package/dist/client/extensions/portable-extension.d.ts +1 -1
- package/dist/client/extensions/portable-extension.d.ts.map +1 -1
- package/dist/client/extensions/portable-extension.js.map +1 -1
- package/dist/client/frame-protocol.d.ts +17 -2
- package/dist/client/frame-protocol.d.ts.map +1 -1
- package/dist/client/frame-protocol.js.map +1 -1
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js +2 -2
- package/dist/client/rich-markdown-editor/RegistryBlockNode.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +2 -0
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +7 -6
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts +5 -0
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +51 -25
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/code-agents/transcript-normalizer.js +1 -1
- package/dist/code-agents/transcript-normalizer.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/extensions/path.d.ts +1 -1
- package/dist/extensions/path.d.ts.map +1 -1
- package/dist/extensions/path.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/integrations/a2a-continuation-processor.js +1 -1
- package/dist/integrations/a2a-continuation-processor.js.map +1 -1
- package/dist/mcp/build-server.d.ts +1 -1
- package/dist/mcp/build-server.d.ts.map +1 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/org/handlers.js +5 -5
- package/dist/org/handlers.js.map +1 -1
- package/dist/org/plugin.js +2 -2
- package/dist/org/plugin.js.map +1 -1
- package/dist/scripts/call-agent.js +1 -1
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/scripts/db/safety.js +1 -1
- package/dist/scripts/db/safety.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +3 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/builder-design-systems.d.ts +28 -0
- package/dist/server/builder-design-systems.d.ts.map +1 -0
- package/dist/server/builder-design-systems.js +217 -0
- package/dist/server/builder-design-systems.js.map +1 -0
- package/dist/server/core-routes-plugin.d.ts +1 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +20 -3
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts.map +1 -1
- package/dist/server/transcribe-voice.js +40 -24
- package/dist/server/transcribe-voice.js.map +1 -1
- package/dist/shared/mcp-embed-headers.d.ts +1 -0
- package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
- package/dist/shared/mcp-embed-headers.js +20 -1
- package/dist/shared/mcp-embed-headers.js.map +1 -1
- package/dist/styles/agent-native.css +60 -4
- package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/dist/usage/store.js +2 -2
- package/dist/usage/store.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +2 -0
- package/dist/vite/client.js.map +1 -1
- package/dist/voice/index.d.ts +5 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +4 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/voice-cleanup-prompt.d.ts +6 -0
- package/dist/voice/voice-cleanup-prompt.d.ts.map +1 -0
- package/dist/voice/voice-cleanup-prompt.js +26 -0
- package/dist/voice/voice-cleanup-prompt.js.map +1 -0
- package/dist/voice/voice-context.d.ts +25 -0
- package/dist/voice/voice-context.d.ts.map +1 -0
- package/dist/voice/voice-context.js +156 -0
- package/dist/voice/voice-context.js.map +1 -0
- package/dist/voice/voice-replacements.d.ts +4 -0
- package/dist/voice/voice-replacements.d.ts.map +1 -0
- package/dist/voice/voice-replacements.js +52 -0
- package/dist/voice/voice-replacements.js.map +1 -0
- package/docs/content/locales/ar-SA/skills-guide.mdx +5 -0
- package/docs/content/locales/ar-SA/template-design.mdx +55 -1
- package/docs/content/locales/de-DE/skills-guide.mdx +5 -0
- package/docs/content/locales/de-DE/template-design.mdx +66 -1
- package/docs/content/locales/es-ES/skills-guide.mdx +5 -0
- package/docs/content/locales/es-ES/template-design.mdx +65 -1
- package/docs/content/locales/fr-FR/skills-guide.mdx +5 -0
- package/docs/content/locales/fr-FR/template-design.mdx +64 -1
- package/docs/content/locales/hi-IN/skills-guide.mdx +5 -0
- package/docs/content/locales/hi-IN/template-design.mdx +59 -1
- package/docs/content/locales/ja-JP/skills-guide.mdx +5 -0
- package/docs/content/locales/ja-JP/template-design.mdx +62 -1
- package/docs/content/locales/ko-KR/skills-guide.mdx +5 -0
- package/docs/content/locales/ko-KR/template-design.mdx +58 -1
- package/docs/content/locales/pt-BR/skills-guide.mdx +5 -0
- package/docs/content/locales/pt-BR/template-design.mdx +62 -1
- package/docs/content/locales/zh-CN/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-CN/template-design.mdx +50 -1
- package/docs/content/locales/zh-TW/skills-guide.mdx +5 -0
- package/docs/content/locales/zh-TW/template-design.mdx +52 -1
- package/docs/content/skills-guide.mdx +5 -0
- package/docs/content/template-design.mdx +59 -1
- package/package.json +2 -1
- package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +10 -0
- package/corpus/templates/design/actions/import-figma-file.ts +0 -166
- package/corpus/templates/design/app/components/design/DesignToolbar.tsx +0 -339
- package/corpus/templates/design/app/components/design/PresentMode.tsx +0 -68
- package/corpus/templates/design/app/components/design/ViewportTabs.tsx +0 -149
- package/corpus/templates/design/app/components/design/ZoomControls.tsx +0 -157
- 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
|
+
}
|