diff --git a/.changeset/fn-6816-shadcn-custom-colors.md b/.changeset/fn-6816-shadcn-custom-colors.md new file mode 100644 index 0000000000..23a6093e70 --- /dev/null +++ b/.changeset/fn-6816-shadcn-custom-colors.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a Shadcn Custom dashboard theme with persisted, sanitized design-token color picker overrides across Settings and Command Center theme selectors. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5f086d1352..a4a9c6e508 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1303,7 +1303,7 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok ### Theme system -Dark/light modes via `data-theme`; 74 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme with an orange default highlight/accent and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; legacy `shadcn-mono` selections migrate to Shadcn Mono Red), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. +Dark/light modes via `data-theme`; 75 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including the Shadcn zinc-neutral theme with an orange default highlight/accent, Shadcn Custom (the same base with sanitized per-token color-picker overrides), and its color family: Shadcn Blue/Green/Red/Purple/Pink/Orange/Yellow, Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; legacy `shadcn-mono` selections migrate to Shadcn Mono Red), Shadcn Black (pure black and white), Shadcn Gray (fully neutral zinc-gray accent), and Shadcn Gray Blue (blue-gray slate neutral surfaces with a muted slate-blue accent). Air is the minimal, borderless, paper-like preset with near-monochrome tokens and CSS-only chrome flattening. - **Base tokens** (`--bg`, `--surface`, etc.) — redefine in `:root`, `[data-theme="light"]`, and every theme block. - **Semantic tokens** (`--autopilot-pulse`, `--event-error-text`, `--badge-mission-*`, `--fab-*`) — `:root` + `[data-theme="light"]` only; no per-color-theme overrides. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4f5e9529ef..a7be0baacf 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -32,7 +32,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | Setting | Type | Default | Description | |---|---|---:|---| | `themeMode` | `"dark" \| "light" \| "system"` | `"dark"` | Dashboard theme mode. | -| `colorTheme` | `ColorTheme` | `"default"` | Dashboard color theme preset. | +| `colorTheme` | `ColorTheme` | `"default"` | Dashboard color theme preset. Use `"shadcn-custom"` to show the custom shadcn color picker in Settings → Appearance and the Command Center theme card. | +| `shadcnCustomColors` | `Record` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. | | `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `defaultProvider` | `string` | `undefined` | Default AI provider. | diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 764ed87f07..f983333e02 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -65,6 +65,7 @@ type ProjectSettingsSchema = Omit; export const DEFAULT_GLOBAL_SETTINGS = { themeMode: "dark", colorTheme: "default", + shadcnCustomColors: undefined, dashboardFontScalePct: 100, language: undefined, defaultProvider: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 04adcb5926..5a04a8cd15 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -330,6 +330,8 @@ export const COLOR_THEMES = [ "neon-bloom", "sepia", "shadcn", + // FNXC:DashboardTheming 2026-06-20-18:20: FN-6816 adds the user-customizable shadcn variant; keep this union in lockstep with dashboard theme options, swatches, theme-data base blocks, and the shadcn custom color token list. + "shadcn-custom", // FNXC:DashboardTheming 2026-06-19-16:07: FN-6756 extends the published color-theme union with shadcn-family accent variants; keep dashboard theme options, bootstrap validation, swatches, and theme-data token blocks in lockstep with this ordered list. // FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 renames the grayscale-base mono theme to shadcn-mono-red and adds the remaining mono accent variants; keep Shadcn Gray adjacent to Shadcn Black so the color-family order stays stable with FN-6814. // FNXC:DashboardTheming 2026-06-21-00:00: FN-6815 adds shadcn-gray-blue as the slate-neutral blue-gray sibling; keep it adjacent to Shadcn Gray so the published union mirrors dashboard option order. @@ -2959,6 +2961,8 @@ export interface GlobalSettings { themeMode?: ThemeMode; /** Color theme preference for accent colors and styling. Default: "default". */ colorTheme?: ColorTheme; + /** Token→hex override map for the customizable shadcn theme. Applied only when `colorTheme === "shadcn-custom"`; dashboard sanitizes keys and values before writing CSS custom properties. */ + shadcnCustomColors?: Record; /** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */ dashboardFontScalePct?: number; /** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 076ca46dc5..5cdabbde83 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -448,7 +448,7 @@ function AppInner() { const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects; // Theme management - required before useViewState - const { themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct } = useTheme(); + const { themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors } = useTheme(); // Background AI sessions - required before useModalManager const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id); @@ -1797,8 +1797,11 @@ function AppInner() { projectId={currentProject?.id} colorTheme={colorTheme} themeMode={themeMode} + shadcnCustomColors={shadcnCustomColors} + resolvedThemeMode={resolvedThemeMode} onColorThemeChange={setColorTheme} onThemeModeChange={setThemeMode} + onShadcnCustomColorsChange={setShadcnCustomColors} addToast={addToast} nodesEnabled={nodesEnabled} /> @@ -2254,7 +2257,7 @@ function AppInner() { }} taskOperations={{ moveTask, deleteTask, mergeTask, archiveTask, retryTask, resetTask, duplicateTask }} deepLink={{ handleDetailClose }} - settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }} + settings={{ prAuthAvailable, autoMerge, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors, resolvedThemeMode, setThemeMode, setColorTheme, setDashboardFontScalePct, setShadcnCustomColors }} onSettingsClose={handleSettingsCloseWithNav} onReopenOnboarding={reopenOnboardingWithNav} onOpenApprovals={(_approvalId) => handleTaskViewChange("mailbox")} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 62fea512f4..85acff708f 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -75,9 +75,12 @@ interface AppModalsProps { themeMode: ThemeMode; colorTheme: ColorTheme; dashboardFontScalePct: number; + shadcnCustomColors: Record; + resolvedThemeMode: "dark" | "light"; setThemeMode: (mode: ThemeMode) => void; setColorTheme: (theme: ColorTheme) => void; setDashboardFontScalePct: (scalePct: number) => void; + setShadcnCustomColors: (colors: Record) => void; }; /** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */ onSettingsClose?: () => void; @@ -324,7 +327,10 @@ export function AppModals({ onThemeModeChange={settings.setThemeMode} onColorThemeChange={settings.setColorTheme} dashboardFontScalePct={settings.dashboardFontScalePct} + shadcnCustomColors={settings.shadcnCustomColors} + resolvedThemeMode={settings.resolvedThemeMode} onDashboardFontScaleChange={settings.setDashboardFontScalePct} + onShadcnCustomColorsChange={settings.setShadcnCustomColors} onReopenOnboarding={onReopenOnboarding} onOpenApprovals={onOpenApprovals} onOpenWorkflowSettings={() => { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index e9b459bcca..ff35b0b074 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -358,8 +358,14 @@ interface SettingsModalProps { onColorThemeChange?: (theme: ColorTheme) => void; /** Current dashboard font scale percentage */ dashboardFontScalePct?: number; + /** Current shadcn-custom color overrides */ + shadcnCustomColors?: Record; + /** Resolved theme mode for shadcn-custom defaults */ + resolvedThemeMode?: "dark" | "light"; /** Called when dashboard font scale changes */ onDashboardFontScaleChange?: (scalePct: number) => void; + /** Called when shadcn-custom color overrides change */ + onShadcnCustomColorsChange?: (colors: Record) => void; /** Optional callback when user wants to reopen the onboarding guide */ onReopenOnboarding?: () => void; /** Optional callback to open approvals/mailbox view. */ @@ -609,7 +615,10 @@ export function SettingsModal({ onThemeModeChange, onColorThemeChange, dashboardFontScalePct = 100, + shadcnCustomColors = {}, + resolvedThemeMode, onDashboardFontScaleChange, + onShadcnCustomColorsChange, onReopenOnboarding, onOpenApprovals, onOpenWorkflowSettings, @@ -2615,9 +2624,12 @@ export function SettingsModal({ themeMode={themeMode} colorTheme={colorTheme} dashboardFontScalePct={dashboardFontScalePct} + shadcnCustomColors={shadcnCustomColors} + resolvedThemeMode={resolvedThemeMode} onThemeModeChange={onThemeModeChange} onColorThemeChange={onColorThemeChange} onDashboardFontScaleChange={onDashboardFontScaleChange} + onShadcnCustomColorsChange={onShadcnCustomColorsChange} sessionBannersHidden={sessionBannersHidden} setSessionBannersHidden={setSessionBannersHidden} /> diff --git a/packages/dashboard/app/components/ShadcnColorPicker.css b/packages/dashboard/app/components/ShadcnColorPicker.css new file mode 100644 index 0000000000..cbb8170fd7 --- /dev/null +++ b/packages/dashboard/app/components/ShadcnColorPicker.css @@ -0,0 +1,101 @@ +.shadcn-color-picker { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--card-padding); + border: var(--btn-border-width) solid var(--border); + background: var(--card); +} + +.shadcn-color-picker-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); +} + +.shadcn-color-picker-title { + margin: 0; + color: var(--text); + font-size: var(--font-size-base); + font-weight: 600; +} + +.shadcn-color-picker-description { + margin: var(--space-xs) 0 0; + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.shadcn-color-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 7), 1fr)); + gap: var(--space-sm); +} + +.shadcn-color-picker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} + +.shadcn-color-picker-label { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-xs); + color: var(--text); + font-size: var(--font-size-sm); + font-weight: 500; +} + +.shadcn-color-picker-label code { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 400; +} + +.shadcn-color-picker-controls { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.shadcn-color-picker-native { + width: calc(var(--space-xl) * 2); + height: calc(var(--space-xl) * 2); + padding: 0; + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + cursor: pointer; +} + +.shadcn-color-picker-hex { + width: calc(var(--space-2xl) * 3); + font-family: var(--font-mono); +} + +@media (max-width: 768px) { + .shadcn-color-picker-header, + .shadcn-color-picker-row, + .shadcn-color-picker-controls { + align-items: stretch; + flex-direction: column; + } + + .shadcn-color-picker-grid { + grid-template-columns: 1fr; + } + + .shadcn-color-picker-native, + .shadcn-color-picker-hex { + width: 100%; + } +} diff --git a/packages/dashboard/app/components/ShadcnColorPicker.tsx b/packages/dashboard/app/components/ShadcnColorPicker.tsx new file mode 100644 index 0000000000..6ed4d6b85a --- /dev/null +++ b/packages/dashboard/app/components/ShadcnColorPicker.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + SHADCN_CUSTOM_COLOR_TOKENS, + getShadcnCustomDefaultValue, + sanitizeShadcnCustomColors, +} from "./shadcnCustomColors"; +import "./ShadcnColorPicker.css"; + +export interface ShadcnColorPickerProps { + value?: Record; + onChange: (next: Record) => void; + resolvedThemeMode?: "dark" | "light"; +} + +function toColorInputValue(value: string): string { + const trimmed = value.trim(); + if (/^#[\da-f]{6}$/i.test(trimmed)) { + return trimmed; + } + if (/^#[\da-f]{3}$/i.test(trimmed)) { + const [, r, g, b] = trimmed; + return `#${r}${r}${g}${g}${b}${b}`; + } + return "#000000"; +} + +/* +FNXC:Theme 2026-06-20-18:38: +The shadcn custom picker is shared by Settings and Command Center; it only edits the sanitized token→hex override map while the parent surfaces decide visibility for shadcn-custom so no other theme receives inline overrides. +*/ +export function ShadcnColorPicker({ + value = {}, + onChange, + resolvedThemeMode = "dark", +}: ShadcnColorPickerProps) { + const { t } = useTranslation("app"); + const sanitizedValue = useMemo(() => sanitizeShadcnCustomColors(value), [value]); + + const updateToken = (cssVar: string, nextValue: string) => { + onChange(sanitizeShadcnCustomColors({ ...sanitizedValue, [cssVar]: nextValue })); + }; + + return ( +
+
+
+

+ {t("theme.shadcnCustom.title", "Custom shadcn colors")} +

+

+ {t("theme.shadcnCustom.description", "Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")} +

+
+ +
+
+ {SHADCN_CUSTOM_COLOR_TOKENS.map((token) => { + const fallback = getShadcnCustomDefaultValue(token, resolvedThemeMode); + const currentValue = sanitizedValue[token.cssVar] ?? fallback; + const inputId = `shadcn-color-${token.cssVar.replace(/^--/, "").replace(/[^a-z0-9]+/gi, "-")}`; + return ( +
+ +
+ updateToken(token.cssVar, event.currentTarget.value)} + /> + updateToken(token.cssVar, event.currentTarget.value)} + /> +
+
+ ); + })} +
+
+ ); +} diff --git a/packages/dashboard/app/components/ThemeDropdown.tsx b/packages/dashboard/app/components/ThemeDropdown.tsx index 537ce6bfcd..db9ee1cfc3 100644 --- a/packages/dashboard/app/components/ThemeDropdown.tsx +++ b/packages/dashboard/app/components/ThemeDropdown.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Check, ChevronDown } from "lucide-react"; import type { ColorTheme, ThemeMode } from "@fusion/core"; import { COLOR_THEMES, THEME_MODES } from "./themeOptions"; +import { ShadcnColorPicker } from "./ShadcnColorPicker"; import "./ThemeSelector.css"; import "./ThemeDropdown.css"; @@ -10,7 +11,10 @@ interface ThemeDropdownProps { colorTheme: ColorTheme; onColorThemeChange: (theme: ColorTheme) => void; themeMode?: ThemeMode; + shadcnCustomColors?: Record; + resolvedThemeMode?: "dark" | "light"; onThemeModeChange?: (mode: ThemeMode) => void; + onShadcnCustomColorsChange?: (colors: Record) => void; } function ThemeSwatch({ className }: { className: string }) { @@ -28,7 +32,15 @@ function ThemeSwatch({ className }: { className: string }) { FNXC:Theme 2026-06-19-12:10: FN-6727 requires Command Center operators to change the global app theme from a compact dropdown that previews each color theme with the same rich swatch chips used by Settings; this component accepts App-threaded setters instead of creating another theme owner. */ -export function ThemeDropdown({ colorTheme, onColorThemeChange, themeMode, onThemeModeChange }: ThemeDropdownProps) { +export function ThemeDropdown({ + colorTheme, + onColorThemeChange, + themeMode, + shadcnCustomColors = {}, + resolvedThemeMode = themeMode === "light" ? "light" : "dark", + onThemeModeChange, + onShadcnCustomColorsChange = () => {}, +}: ThemeDropdownProps) { const { t } = useTranslation("app"); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(() => Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme))); @@ -140,6 +152,15 @@ export function ThemeDropdown({ colorTheme, onColorThemeChange, themeMode, onThe ) : null} + {/* FNXC:Theme 2026-06-20-19:00: Command Center exposes the same shadcn-custom color picker as Settings and hides it for every other theme so non-custom themes never show orphaned override controls. */} + {colorTheme === "shadcn-custom" ? ( + + ) : null} + {open ? (
diff --git a/packages/dashboard/app/components/ThemeSelector.css b/packages/dashboard/app/components/ThemeSelector.css index 1063dcb0c0..65bf68ad40 100644 --- a/packages/dashboard/app/components/ThemeSelector.css +++ b/packages/dashboard/app/components/ThemeSelector.css @@ -388,6 +388,13 @@ --swatch-sample-4: #27272a; } +.theme-swatch-shadcn-custom { + --swatch-sample-1: #09090b; + --swatch-sample-2: #18181b; + --swatch-sample-3: #f97316; + --swatch-sample-4: #27272a; +} + .theme-swatch-shadcn-blue { --swatch-sample-1: #09090b; --swatch-sample-2: #18181b; @@ -728,6 +735,13 @@ --swatch-sample-4: #e4e4e7; } +[data-theme="light"] .theme-swatch-shadcn-custom { + --swatch-sample-1: #ffffff; + --swatch-sample-2: #f4f4f5; + --swatch-sample-3: #ea580c; + --swatch-sample-4: #e4e4e7; +} + [data-theme="light"] .theme-swatch-shadcn-blue { --swatch-sample-1: #ffffff; --swatch-sample-2: #f4f4f5; diff --git a/packages/dashboard/app/components/ThemeSelector.tsx b/packages/dashboard/app/components/ThemeSelector.tsx index e038c01367..58c7f89d46 100644 --- a/packages/dashboard/app/components/ThemeSelector.tsx +++ b/packages/dashboard/app/components/ThemeSelector.tsx @@ -4,14 +4,18 @@ import { useTranslation } from "react-i18next"; import { Sun, Moon, Monitor } from "lucide-react"; import type { ThemeMode, ColorTheme } from "@fusion/core"; import { COLOR_THEMES, THEME_MODES } from "./themeOptions"; +import { ShadcnColorPicker } from "./ShadcnColorPicker"; interface ThemeSelectorProps { themeMode: ThemeMode; colorTheme: ColorTheme; dashboardFontScalePct?: number; + shadcnCustomColors?: Record; + resolvedThemeMode?: "dark" | "light"; onThemeModeChange: (mode: ThemeMode) => void; onColorThemeChange: (theme: ColorTheme) => void; onDashboardFontScaleChange?: (scalePct: number) => void; + onShadcnCustomColorsChange?: (colors: Record) => void; } const FONT_SCALE_OPTIONS = [ @@ -28,16 +32,20 @@ export function ThemeSelector({ themeMode, colorTheme, dashboardFontScalePct = 100, + shadcnCustomColors = {}, + resolvedThemeMode = themeMode === "light" ? "light" : "dark", onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange = () => {}, + onShadcnCustomColorsChange = () => {}, }: ThemeSelectorProps) { const { t } = useTranslation("app"); const handleReset = useCallback(() => { onThemeModeChange("dark"); onColorThemeChange("default"); onDashboardFontScaleChange(100); - }, [onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange]); + onShadcnCustomColorsChange({}); + }, [onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange]); return (
@@ -116,6 +124,15 @@ export function ThemeSelector({ ))}
+ {/* FNXC:Theme 2026-06-20-19:00: The custom color picker must be visible only for shadcn-custom on every theme-selector surface; ThemeSelector and ThemeDropdown share COLOR_THEMES and the same picker component so their affordances stay synchronized. */} + {colorTheme === "shadcn-custom" ? ( + + ) : null} + {/* Reset Button */}