FN-6816: add shadcn custom color picker
Add a Shadcn Custom theme flow for editing and applying sanitized dashboard color tokens. - Add a lazy-loaded Shadcn color picker modal with token defaults, reset controls, and validation tests. - Persist sanitized custom color overrides through settings, pre-hydration theme bootstrapping, and Command Center/theme dropdown surfaces. - Register the Shadcn Custom theme option in core types, schema, docs, theme CSS, and release notes. Files changed: .changeset/fn-6816-shadcn-custom-colors.md | 5 + docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 3 +- packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 4 + packages/dashboard/app/App.tsx | 7 +- packages/dashboard/app/components/AppModals.tsx | 6 + .../dashboard/app/components/SettingsModal.tsx | 12 ++ .../dashboard/app/components/ShadcnColorPicker.css | 101 ++++++++++++++ .../dashboard/app/components/ShadcnColorPicker.tsx | 95 +++++++++++++ .../dashboard/app/components/ThemeDropdown.tsx | 23 +++- .../dashboard/app/components/ThemeSelector.css | 14 ++ .../dashboard/app/components/ThemeSelector.tsx | 19 ++- .../__tests__/ShadcnColorPicker.test.tsx | 50 +++++++ .../components/__tests__/ThemeDropdown.test.tsx | 19 +++ .../components/__tests__/ThemeSelector.test.tsx | 64 +++++++++ .../__tests__/shadcnCustomColors.test.ts | 52 ++++++++ .../components/command-center/CommandCenter.tsx | 15 +++ .../command-center/CommandCenterControls.tsx | 8 +- .../settings/sections/AppearanceSection.tsx | 8 +- .../dashboard/app/components/shadcnCustomColors.ts | 80 +++++++++++ packages/dashboard/app/components/themeOptions.ts | 1 + .../dashboard/app/hooks/__tests__/useTheme.test.ts | 108 +++++++++++++++ packages/dashboard/app/hooks/useTheme.ts | 91 ++++++++++++- packages/dashboard/app/index.html | 19 ++- packages/dashboard/app/public/theme-data.css | 148 ++++++++++++++++++++- 26 files changed, 938 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-6816 Fusion-Task-Lineage: f16baaa9-16f5-4526-8159-9a1ceff4f807
This commit is contained in:
5
.changeset/fn-6816-shadcn-custom-colors.md
Normal file
5
.changeset/fn-6816-shadcn-custom-colors.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string>` | `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. |
|
||||
|
||||
@@ -65,6 +65,7 @@ type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
|
||||
export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
themeMode: "dark",
|
||||
colorTheme: "default",
|
||||
shadcnCustomColors: undefined,
|
||||
dashboardFontScalePct: 100,
|
||||
language: undefined,
|
||||
defaultProvider: undefined,
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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`.
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -75,9 +75,12 @@ interface AppModalsProps {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
shadcnCustomColors: Record<string, string>;
|
||||
resolvedThemeMode: "dark" | "light";
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
setColorTheme: (theme: ColorTheme) => void;
|
||||
setDashboardFontScalePct: (scalePct: number) => void;
|
||||
setShadcnCustomColors: (colors: Record<string, string>) => 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={() => {
|
||||
|
||||
@@ -358,8 +358,14 @@ interface SettingsModalProps {
|
||||
onColorThemeChange?: (theme: ColorTheme) => void;
|
||||
/** Current dashboard font scale percentage */
|
||||
dashboardFontScalePct?: number;
|
||||
/** Current shadcn-custom color overrides */
|
||||
shadcnCustomColors?: Record<string, string>;
|
||||
/** 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<string, string>) => 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}
|
||||
/>
|
||||
|
||||
101
packages/dashboard/app/components/ShadcnColorPicker.css
Normal file
101
packages/dashboard/app/components/ShadcnColorPicker.css
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
||||
95
packages/dashboard/app/components/ShadcnColorPicker.tsx
Normal file
95
packages/dashboard/app/components/ShadcnColorPicker.tsx
Normal file
@@ -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<string, string>;
|
||||
onChange: (next: Record<string, string>) => 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 (
|
||||
<section className="shadcn-color-picker card" data-testid="shadcn-color-picker" aria-labelledby="shadcn-color-picker-title">
|
||||
<div className="shadcn-color-picker-header">
|
||||
<div>
|
||||
<h3 id="shadcn-color-picker-title" className="shadcn-color-picker-title">
|
||||
{t("theme.shadcnCustom.title", "Custom shadcn colors")}
|
||||
</h3>
|
||||
<p className="shadcn-color-picker-description">
|
||||
{t("theme.shadcnCustom.description", "Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={() => onChange({})}>
|
||||
{t("theme.shadcnCustom.reset", "Reset custom colors")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="shadcn-color-picker-grid">
|
||||
{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 (
|
||||
<div className="shadcn-color-picker-row" key={token.cssVar} data-testid={`shadcn-color-${token.cssVar}`}>
|
||||
<label className="shadcn-color-picker-label" htmlFor={inputId}>
|
||||
<span>{t(`theme.shadcnCustom.token.${token.cssVar}`, token.label)}</span>
|
||||
<code>{token.cssVar}</code>
|
||||
</label>
|
||||
<div className="shadcn-color-picker-controls">
|
||||
<input
|
||||
aria-label={t("theme.shadcnCustom.colorInput", "Pick {{label}} color", { label: token.label })}
|
||||
className="shadcn-color-picker-native"
|
||||
type="color"
|
||||
value={toColorInputValue(currentValue)}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
id={inputId}
|
||||
aria-label={t("theme.shadcnCustom.hexInput", "{{label}} hex color", { label: token.label })}
|
||||
className="input shadcn-color-picker-hex"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
value={currentValue}
|
||||
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
resolvedThemeMode?: "dark" | "light";
|
||||
onThemeModeChange?: (mode: ThemeMode) => void;
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => 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
|
||||
</div>
|
||||
) : 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" ? (
|
||||
<ShadcnColorPicker
|
||||
value={shadcnCustomColors}
|
||||
onChange={onShadcnCustomColorsChange}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<div className="theme-dropdown-popover" role="presentation">
|
||||
<div id={listboxId} className="theme-dropdown-list" role="listbox" aria-label={t("theme.colorThemeLabel", "Color theme")}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
resolvedThemeMode?: "dark" | "light";
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onColorThemeChange: (theme: ColorTheme) => void;
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => 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 (
|
||||
<div className="theme-selector">
|
||||
@@ -116,6 +124,15 @@ export function ThemeSelector({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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" ? (
|
||||
<ShadcnColorPicker
|
||||
value={shadcnCustomColors}
|
||||
onChange={onShadcnCustomColorsChange}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Reset Button */}
|
||||
<button
|
||||
className="theme-reset-btn"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ShadcnColorPicker } from "../ShadcnColorPicker";
|
||||
import { SHADCN_CUSTOM_COLOR_TOKENS } from "../shadcnCustomColors";
|
||||
|
||||
describe("ShadcnColorPicker", () => {
|
||||
it("renders one color control row per customizable token", () => {
|
||||
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="dark" />);
|
||||
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
|
||||
expect(screen.getByTestId(`shadcn-color-${token.cssVar}`)).toBeDefined();
|
||||
expect(screen.getByText(token.cssVar)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses light defaults when no override exists", () => {
|
||||
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="light" />);
|
||||
|
||||
const bgRow = screen.getByTestId("shadcn-color---bg");
|
||||
expect(within(bgRow).getByRole("textbox")).toHaveValue("#ffffff");
|
||||
});
|
||||
|
||||
it("emits sanitized changes and rejects invalid hex input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ShadcnColorPicker value={{}} onChange={onChange} resolvedThemeMode="dark" />);
|
||||
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
fireEvent.change(within(accentRow).getByRole("textbox"), { target: { value: "red" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({});
|
||||
|
||||
fireEvent.change(within(accentRow).getByRole("textbox"), { target: { value: "#FF8800" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ "--accent": "#FF8800" });
|
||||
});
|
||||
|
||||
it("normalizes short hex values for the native color input", () => {
|
||||
render(<ShadcnColorPicker value={{ "--accent": "#fff" }} onChange={vi.fn()} resolvedThemeMode="dark" />);
|
||||
|
||||
const accentRow = screen.getByTestId("shadcn-color---accent");
|
||||
expect(within(accentRow).getByLabelText("Pick Accent color")).toHaveValue("#ffffff");
|
||||
});
|
||||
|
||||
it("reset clears all custom color overrides", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ShadcnColorPicker value={{ "--accent": "#123456" }} onChange={onChange} resolvedThemeMode="dark" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset custom colors" }));
|
||||
expect(onChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,25 @@ describe("ThemeDropdown", () => {
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the shadcn custom picker only for shadcn-custom", () => {
|
||||
const { rerender } = render(<ThemeDropdown colorTheme="default" onColorThemeChange={vi.fn()} />);
|
||||
expect(screen.queryByTestId("shadcn-color-picker")).toBeNull();
|
||||
|
||||
rerender(<ThemeDropdown colorTheme="shadcn" onColorThemeChange={vi.fn()} />);
|
||||
expect(screen.queryByTestId("shadcn-color-picker")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThemeDropdown
|
||||
colorTheme="shadcn-custom"
|
||||
themeMode="light"
|
||||
resolvedThemeMode="light"
|
||||
shadcnCustomColors={{ "--accent": "#123456" }}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders compact theme mode controls when mode props are supplied", () => {
|
||||
const onThemeModeChange = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -127,6 +127,17 @@ describe("ThemeSelector", () => {
|
||||
expect(screen.getByLabelText("Lavender theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Neon Bloom theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Sepia theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Custom theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Blue theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Green theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Red theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Purple theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Pink theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Orange theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Yellow theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Mono theme")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Black theme")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders every shared swatch class from themeOptions", () => {
|
||||
@@ -643,6 +654,59 @@ describe("ThemeSelector", () => {
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
it("shows the shadcn custom picker only for shadcn-custom", () => {
|
||||
const { rerender } = render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="default"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("shadcn-color-picker")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="shadcn"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("shadcn-color-picker")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThemeSelector
|
||||
themeMode="light"
|
||||
colorTheme="shadcn-custom"
|
||||
shadcnCustomColors={{ "--accent": "#123456" }}
|
||||
resolvedThemeMode="light"
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
|
||||
expect(screen.getByLabelText("Shadcn Custom theme").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("reset to defaults clears shadcn custom color overrides", () => {
|
||||
const onShadcnCustomColorsChange = vi.fn();
|
||||
render(
|
||||
<ThemeSelector
|
||||
themeMode="dark"
|
||||
colorTheme="shadcn-custom"
|
||||
shadcnCustomColors={{ "--accent": "#123456" }}
|
||||
onThemeModeChange={vi.fn()}
|
||||
onColorThemeChange={vi.fn()}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Reset to default theme"));
|
||||
expect(onShadcnCustomColorsChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("each color theme has a swatch with four explicit sample colors", () => {
|
||||
render(
|
||||
<ThemeSelector
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SHADCN_CUSTOM_COLOR_TOKENS,
|
||||
applyShadcnCustomColorOverrides,
|
||||
cleanupShadcnCustomColorOverrides,
|
||||
isValidHexColor,
|
||||
sanitizeShadcnCustomColors,
|
||||
} from "../shadcnCustomColors";
|
||||
|
||||
describe("shadcnCustomColors", () => {
|
||||
it("validates only short and long hex color strings", () => {
|
||||
expect(isValidHexColor("#fff")).toBe(true);
|
||||
expect(isValidHexColor("#FF8800")).toBe(true);
|
||||
expect(isValidHexColor("red")).toBe(false);
|
||||
expect(isValidHexColor("url(javascript:alert(1))")).toBe(false);
|
||||
expect(isValidHexColor("#fff;color:red")).toBe(false);
|
||||
expect(isValidHexColor(";color:#fff")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops unknown tokens and invalid values when sanitizing", () => {
|
||||
expect(
|
||||
sanitizeShadcnCustomColors({
|
||||
"--accent": "#FF8800",
|
||||
"--bg": " #fff ",
|
||||
"--unknown": "#000000",
|
||||
"--text": "red",
|
||||
"--border": "url(#fff)",
|
||||
"--color-error": ";color:#fff",
|
||||
}),
|
||||
).toEqual({
|
||||
"--accent": "#FF8800",
|
||||
"--bg": "#fff",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies sanitized values and cleanup removes every custom token", () => {
|
||||
const element = document.createElement("div");
|
||||
const sanitized = applyShadcnCustomColorOverrides(element, {
|
||||
"--accent": "#123456",
|
||||
"--text": "url(bad)",
|
||||
});
|
||||
|
||||
expect(sanitized).toEqual({ "--accent": "#123456" });
|
||||
expect(element.style.getPropertyValue("--accent")).toBe("#123456");
|
||||
expect(element.style.getPropertyValue("--text")).toBe("");
|
||||
|
||||
cleanupShadcnCustomColorOverrides(element);
|
||||
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
|
||||
expect(element.style.getPropertyValue(token.cssVar)).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,8 +99,11 @@ interface CommandCenterProps {
|
||||
projectId?: string;
|
||||
colorTheme?: ColorTheme;
|
||||
themeMode?: ThemeMode;
|
||||
shadcnCustomColors?: Record<string, string>;
|
||||
resolvedThemeMode?: "dark" | "light";
|
||||
onColorThemeChange?: (theme: ColorTheme) => void;
|
||||
onThemeModeChange?: (mode: ThemeMode) => void;
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
|
||||
addToast?: (message: string, type?: ToastType) => void;
|
||||
nodesEnabled?: boolean;
|
||||
}
|
||||
@@ -110,8 +113,11 @@ function OverviewTab({
|
||||
projectId,
|
||||
colorTheme = "default",
|
||||
themeMode = "system",
|
||||
shadcnCustomColors = {},
|
||||
resolvedThemeMode = themeMode === "light" ? "light" : "dark",
|
||||
onColorThemeChange = () => {},
|
||||
onThemeModeChange = () => {},
|
||||
onShadcnCustomColorsChange = () => {},
|
||||
}: { range: DateRange } & CommandCenterProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const tokens = useAnalyticsArea<TokenAnalytics>("/command-center/tokens?groupBy=model", range, {
|
||||
@@ -259,8 +265,11 @@ function OverviewTab({
|
||||
projectId={projectId}
|
||||
colorTheme={colorTheme}
|
||||
themeMode={themeMode}
|
||||
shadcnCustomColors={shadcnCustomColors}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
/>
|
||||
);
|
||||
const throughputSection = (
|
||||
@@ -440,8 +449,11 @@ export function CommandCenter({
|
||||
projectId,
|
||||
colorTheme = "default",
|
||||
themeMode = "system",
|
||||
shadcnCustomColors = {},
|
||||
resolvedThemeMode = themeMode === "light" ? "light" : "dark",
|
||||
onColorThemeChange = () => {},
|
||||
onThemeModeChange = () => {},
|
||||
onShadcnCustomColorsChange = () => {},
|
||||
addToast = () => {},
|
||||
nodesEnabled = false,
|
||||
}: CommandCenterProps = {}) {
|
||||
@@ -504,8 +516,11 @@ export function CommandCenter({
|
||||
projectId={projectId}
|
||||
colorTheme={colorTheme}
|
||||
themeMode={themeMode}
|
||||
shadcnCustomColors={shadcnCustomColors}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
/>
|
||||
);
|
||||
case "tokens":
|
||||
|
||||
@@ -11,8 +11,11 @@ export interface CommandCenterControlsProps {
|
||||
projectId?: string;
|
||||
colorTheme: ColorTheme;
|
||||
themeMode: ThemeMode;
|
||||
shadcnCustomColors?: Record<string, string>;
|
||||
resolvedThemeMode?: "dark" | "light";
|
||||
onColorThemeChange: (theme: ColorTheme) => void;
|
||||
onThemeModeChange: (mode: ThemeMode) => void;
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type AsyncState<T> =
|
||||
@@ -63,7 +66,7 @@ function StatusPill({ paused, label }: { paused: boolean; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandCenterControls({ projectId, colorTheme, themeMode, onColorThemeChange, onThemeModeChange }: CommandCenterControlsProps) {
|
||||
export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcnCustomColors = {}, resolvedThemeMode = themeMode === "light" ? "light" : "dark", onColorThemeChange, onThemeModeChange, onShadcnCustomColorsChange = () => {} }: CommandCenterControlsProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const {
|
||||
globalPaused,
|
||||
@@ -188,8 +191,11 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, onColo
|
||||
<ThemeDropdown
|
||||
colorTheme={colorTheme}
|
||||
themeMode={themeMode}
|
||||
shadcnCustomColors={shadcnCustomColors}
|
||||
resolvedThemeMode={resolvedThemeMode}
|
||||
onColorThemeChange={onColorThemeChange}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ export interface AppearanceSectionProps extends SectionBaseProps {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
shadcnCustomColors?: Record<string, string>;
|
||||
resolvedThemeMode?: "dark" | "light";
|
||||
onThemeModeChange?: (mode: ThemeMode) => void;
|
||||
onColorThemeChange?: (theme: ColorTheme) => void;
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
|
||||
sessionBannersHidden: boolean;
|
||||
setSessionBannersHidden: (hidden: boolean) => void;
|
||||
}
|
||||
export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme, dashboardFontScalePct, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme, dashboardFontScalePct, shadcnCustomColors = {}, resolvedThemeMode, onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange, sessionBannersHidden, setSessionBannersHidden, }: AppearanceSectionProps) {
|
||||
const { t } = useTranslation("app");
|
||||
return (<>
|
||||
{scopeBanner}
|
||||
@@ -29,6 +32,9 @@ export function AppearanceSection({ scopeBanner, setForm, themeMode, colorTheme,
|
||||
}} onDashboardFontScaleChange={(scalePct) => {
|
||||
setForm((f) => ({ ...f, dashboardFontScalePct: scalePct }));
|
||||
onDashboardFontScaleChange?.(scalePct);
|
||||
}} shadcnCustomColors={shadcnCustomColors} resolvedThemeMode={resolvedThemeMode} onShadcnCustomColorsChange={(colors) => {
|
||||
setForm((f) => ({ ...f, shadcnCustomColors: colors }));
|
||||
onShadcnCustomColorsChange?.(colors);
|
||||
}}/>
|
||||
<LanguageSelector />
|
||||
<div className="form-group">
|
||||
|
||||
80
packages/dashboard/app/components/shadcnCustomColors.ts
Normal file
80
packages/dashboard/app/components/shadcnCustomColors.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export type ShadcnCustomColorToken = {
|
||||
cssVar: string;
|
||||
label: string;
|
||||
defaultDark: string;
|
||||
defaultLight: string;
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-06-20-18:25:
|
||||
Shadcn custom colors are a security boundary: overrides apply only when the selected theme is shadcn-custom, missing tokens must fall back to the CSS base defaults, and only sanitized #RGB/#RRGGBB hex values may be written as inline CSS custom properties.
|
||||
*/
|
||||
export const SHADCN_CUSTOM_COLOR_TOKENS = [
|
||||
{ cssVar: "--accent", label: "Accent", defaultDark: "#f97316", defaultLight: "#ea580c" },
|
||||
{ cssVar: "--bg", label: "Background", defaultDark: "#09090b", defaultLight: "#ffffff" },
|
||||
{ cssVar: "--surface", label: "Surface", defaultDark: "#0c0c0e", defaultLight: "#ffffff" },
|
||||
{ cssVar: "--card", label: "Card", defaultDark: "#18181b", defaultLight: "#ffffff" },
|
||||
{ cssVar: "--border", label: "Border", defaultDark: "#27272a", defaultLight: "#e4e4e7" },
|
||||
{ cssVar: "--text", label: "Text", defaultDark: "#fafafa", defaultLight: "#09090b" },
|
||||
{ cssVar: "--text-muted", label: "Muted text", defaultDark: "#a1a1aa", defaultLight: "#71717a" },
|
||||
{ cssVar: "--todo", label: "Todo", defaultDark: "#60a5fa", defaultLight: "#2563eb" },
|
||||
{ cssVar: "--in-progress", label: "In progress", defaultDark: "#38bdf8", defaultLight: "#0284c7" },
|
||||
{ cssVar: "--in-review", label: "In review", defaultDark: "#34d399", defaultLight: "#16a34a" },
|
||||
{ cssVar: "--triage", label: "Triage", defaultDark: "#f59e0b", defaultLight: "#d97706" },
|
||||
{ cssVar: "--done", label: "Done", defaultDark: "#71717a", defaultLight: "#a1a1aa" },
|
||||
{ cssVar: "--color-success", label: "Success", defaultDark: "#22c55e", defaultLight: "#16a34a" },
|
||||
{ cssVar: "--color-warning", label: "Warning", defaultDark: "#f59e0b", defaultLight: "#d97706" },
|
||||
{ cssVar: "--color-error", label: "Error", defaultDark: "#ef4444", defaultLight: "#dc2626" },
|
||||
] as const satisfies readonly ShadcnCustomColorToken[];
|
||||
|
||||
const SHADCN_CUSTOM_COLOR_TOKEN_SET: ReadonlySet<string> = new Set(
|
||||
SHADCN_CUSTOM_COLOR_TOKENS.map((token) => token.cssVar),
|
||||
);
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[\da-f]{3}|[\da-f]{6})$/i;
|
||||
|
||||
export function isValidHexColor(value: unknown): value is string {
|
||||
return typeof value === "string" && HEX_COLOR_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
export function sanitizeShadcnCustomColors(
|
||||
map: unknown,
|
||||
): Record<string, string> {
|
||||
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(map as Record<string, unknown>)) {
|
||||
if (!SHADCN_CUSTOM_COLOR_TOKEN_SET.has(key) || !isValidHexColor(value)) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = value.trim();
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function applyShadcnCustomColorOverrides(
|
||||
element: HTMLElement,
|
||||
map: unknown,
|
||||
): Record<string, string> {
|
||||
const sanitized = sanitizeShadcnCustomColors(map);
|
||||
cleanupShadcnCustomColorOverrides(element);
|
||||
for (const [cssVar, value] of Object.entries(sanitized)) {
|
||||
element.style.setProperty(cssVar, value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function cleanupShadcnCustomColorOverrides(element: HTMLElement): void {
|
||||
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
|
||||
element.style.removeProperty(token.cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
export function getShadcnCustomDefaultValue(
|
||||
token: ShadcnCustomColorToken,
|
||||
themeMode: "dark" | "light" = "dark",
|
||||
): string {
|
||||
return themeMode === "light" ? token.defaultLight : token.defaultDark;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export const COLOR_THEMES: { value: ColorTheme; label: string; className: string
|
||||
{ value: "neon-bloom", label: "Neon Bloom", className: "theme-swatch-neon-bloom" },
|
||||
{ value: "sepia", label: "Sepia", className: "theme-swatch-sepia" },
|
||||
{ value: "shadcn", label: "Shadcn", className: "theme-swatch-shadcn" },
|
||||
{ value: "shadcn-custom", label: "Shadcn Custom", className: "theme-swatch-shadcn-custom" },
|
||||
{ value: "shadcn-blue", label: "Shadcn Blue", className: "theme-swatch-shadcn-blue" },
|
||||
{ value: "shadcn-green", label: "Shadcn Green", className: "theme-swatch-shadcn-green" },
|
||||
{ value: "shadcn-red", label: "Shadcn Red", className: "theme-swatch-shadcn-red" },
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("../../api", () => ({
|
||||
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
|
||||
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
|
||||
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
|
||||
const SHADCN_CUSTOM_COLORS_STORAGE_KEY = "kb-dashboard-shadcn-custom-colors";
|
||||
|
||||
const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
|
||||
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
|
||||
@@ -79,6 +80,9 @@ describe("useTheme", () => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-color-theme");
|
||||
document.documentElement.style.fontSize = "";
|
||||
for (const cssVar of ["--accent", "--bg", "--surface", "--card", "--border", "--text", "--text-muted", "--todo", "--in-progress", "--in-review", "--triage", "--done", "--color-success", "--color-warning", "--color-error"]) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
|
||||
// Reset static theme-data link to mirror index.html markup
|
||||
document.querySelectorAll('link[id="theme-data"]').forEach((link) => link.remove());
|
||||
@@ -151,6 +155,28 @@ describe("useTheme", () => {
|
||||
expect(document.documentElement.style.fontSize).toBe("110%");
|
||||
});
|
||||
|
||||
it("hydrates shadcn custom colors from backend on mount", async () => {
|
||||
localStorageMock[COLOR_THEME_STORAGE_KEY] = "shadcn-custom";
|
||||
mockFetchGlobalSettings.mockResolvedValue({
|
||||
colorTheme: "shadcn-custom",
|
||||
shadcnCustomColors: {
|
||||
"--accent": "#aabbcc",
|
||||
"--bg": "url(bad)",
|
||||
"--unknown": "#000000",
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.shadcnCustomColors).toEqual({ "--accent": "#aabbcc" });
|
||||
});
|
||||
expect(result.current.colorTheme).toBe("shadcn-custom");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#aabbcc");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg")).toBe("");
|
||||
expect(JSON.parse(localStorageMock[SHADCN_CUSTOM_COLORS_STORAGE_KEY])).toEqual({ "--accent": "#aabbcc" });
|
||||
});
|
||||
|
||||
it("prefers backend over localStorage on hydration", async () => {
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
|
||||
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
|
||||
@@ -551,6 +577,70 @@ describe("useTheme", () => {
|
||||
document.head.removeChild(style);
|
||||
});
|
||||
|
||||
it("applies sanitized shadcn-custom inline overrides and falls back for missing tokens", () => {
|
||||
localStorageMock[COLOR_THEME_STORAGE_KEY] = "shadcn-custom";
|
||||
localStorageMock[SHADCN_CUSTOM_COLORS_STORAGE_KEY] = JSON.stringify({
|
||||
"--accent": "#123456",
|
||||
"--bg": "red",
|
||||
"--unknown": "#000000",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.colorTheme).toBe("shadcn-custom");
|
||||
expect(result.current.shadcnCustomColors).toEqual({ "--accent": "#123456" });
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#123456");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg")).toBe("");
|
||||
});
|
||||
|
||||
it("removes shadcn-custom inline overrides when switching away", () => {
|
||||
localStorageMock[COLOR_THEME_STORAGE_KEY] = "shadcn-custom";
|
||||
localStorageMock[SHADCN_CUSTOM_COLORS_STORAGE_KEY] = JSON.stringify({ "--accent": "#123456", "--bg": "#ffffff" });
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#123456");
|
||||
|
||||
act(() => {
|
||||
result.current.setColorTheme("shadcn");
|
||||
});
|
||||
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg")).toBe("");
|
||||
});
|
||||
|
||||
it("persists sanitized shadcn-custom colors through the write-through setter", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
act(() => {
|
||||
result.current.setShadcnCustomColors({ "--accent": "#fff", "--text": "url(bad)" });
|
||||
});
|
||||
|
||||
expect(result.current.shadcnCustomColors).toEqual({ "--accent": "#fff" });
|
||||
expect(JSON.parse(localStorageMock[SHADCN_CUSTOM_COLORS_STORAGE_KEY])).toEqual({ "--accent": "#fff" });
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ shadcnCustomColors: { "--accent": "#fff" } });
|
||||
});
|
||||
|
||||
it("hydrates partial shadcn-custom colors from backend without overriding in-flight user edits", async () => {
|
||||
let resolveFetch: (value: Partial<Settings>) => void;
|
||||
const pendingFetch = new Promise<Partial<Settings>>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
mockFetchGlobalSettings.mockReturnValue(pendingFetch);
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => {
|
||||
result.current.setShadcnCustomColors({ "--accent": "#abcdef" });
|
||||
});
|
||||
|
||||
resolveFetch!({ shadcnCustomColors: { "--accent": "#111111", "--bg": "#222222" } });
|
||||
await waitFor(() => {
|
||||
expect(mockFetchGlobalSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(result.current.shadcnCustomColors).toEqual({ "--accent": "#abcdef" });
|
||||
});
|
||||
|
||||
it("applies representative shadcn color-family design tokens with neutralized glow effects", () => {
|
||||
const style = document.createElement("style");
|
||||
const baseCss = readFileSync(resolve(PACKAGE_ROOT, "app/styles.css"), "utf8");
|
||||
@@ -772,6 +862,7 @@ describe("getThemeInitScript", () => {
|
||||
expect(script).toContain(THEME_MODE_STORAGE_KEY);
|
||||
expect(script).toContain(COLOR_THEME_STORAGE_KEY);
|
||||
expect(script).toContain(FONT_SCALE_STORAGE_KEY);
|
||||
expect(script).toContain(SHADCN_CUSTOM_COLORS_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("includes every supported theme in the validated theme list", () => {
|
||||
@@ -802,6 +893,23 @@ describe("getThemeInitScript", () => {
|
||||
expect(script).toContain("effectiveMode");
|
||||
});
|
||||
|
||||
it("pre-hydration script applies only sanitized shadcn-custom overrides", () => {
|
||||
const script = getThemeInitScript();
|
||||
localStorage.setItem(COLOR_THEME_STORAGE_KEY, "shadcn-custom");
|
||||
localStorage.setItem(SHADCN_CUSTOM_COLORS_STORAGE_KEY, JSON.stringify({
|
||||
"--accent": "#FF8800",
|
||||
"--bg": "red",
|
||||
"--text": "#fff",
|
||||
}));
|
||||
|
||||
window.eval(script);
|
||||
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-custom");
|
||||
expect(document.documentElement.style.getPropertyValue("--accent")).toBe("#FF8800");
|
||||
expect(document.documentElement.style.getPropertyValue("--text")).toBe("#fff");
|
||||
expect(document.documentElement.style.getPropertyValue("--bg")).toBe("");
|
||||
});
|
||||
|
||||
it("pre-hydration script resolves theme-data path like runtime loader", () => {
|
||||
const script = getThemeInitScript();
|
||||
const runScript = () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { COLOR_THEMES, type ThemeMode, type ColorTheme } from "@fusion/core";
|
||||
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
|
||||
import {
|
||||
SHADCN_CUSTOM_COLOR_TOKENS,
|
||||
applyShadcnCustomColorOverrides,
|
||||
cleanupShadcnCustomColorOverrides,
|
||||
sanitizeShadcnCustomColors,
|
||||
} from "../components/shadcnCustomColors";
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
|
||||
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
|
||||
const SHADCN_CUSTOM_COLORS_STORAGE_KEY = "kb-dashboard-shadcn-custom-colors";
|
||||
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
|
||||
const DEFAULT_FONT_SCALE_PCT = 100;
|
||||
const MIN_FONT_SCALE_PCT = 85;
|
||||
@@ -49,9 +56,12 @@ interface UseThemeReturn {
|
||||
themeMode: ThemeMode;
|
||||
colorTheme: ColorTheme;
|
||||
dashboardFontScalePct: number;
|
||||
shadcnCustomColors: Record<string, string>;
|
||||
resolvedThemeMode: "dark" | "light";
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
setColorTheme: (theme: ColorTheme) => void;
|
||||
setDashboardFontScalePct: (scalePct: number) => void;
|
||||
setShadcnCustomColors: (colors: Record<string, string>) => void;
|
||||
isSystemDark: boolean;
|
||||
}
|
||||
|
||||
@@ -105,6 +115,25 @@ function writeCachedColorTheme(theme: ColorTheme): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedShadcnCustomColors(): Record<string, string> {
|
||||
if (!isBrowser) return {};
|
||||
try {
|
||||
const saved = localStorage.getItem(SHADCN_CUSTOM_COLORS_STORAGE_KEY);
|
||||
return saved ? sanitizeShadcnCustomColors(JSON.parse(saved)) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedShadcnCustomColors(colors: Record<string, string>): void {
|
||||
if (!isBrowser) return;
|
||||
try {
|
||||
localStorage.setItem(SHADCN_CUSTOM_COLORS_STORAGE_KEY, JSON.stringify(sanitizeShadcnCustomColors(colors)));
|
||||
} catch {
|
||||
// localStorage not available, skip cache write
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFontScalePct(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_FONT_SCALE_PCT;
|
||||
@@ -150,6 +179,7 @@ function applyThemeAttributes(
|
||||
colorTheme: ColorTheme,
|
||||
dashboardFontScalePct: number,
|
||||
systemIsDark: boolean,
|
||||
shadcnCustomColors: Record<string, string>,
|
||||
): void {
|
||||
if (!isBrowser) return;
|
||||
|
||||
@@ -157,6 +187,11 @@ function applyThemeAttributes(
|
||||
document.documentElement.setAttribute("data-theme", effectiveMode);
|
||||
document.documentElement.setAttribute("data-color-theme", colorTheme);
|
||||
document.documentElement.style.fontSize = `${normalizeFontScalePct(dashboardFontScalePct)}%`;
|
||||
if (colorTheme === "shadcn-custom") {
|
||||
applyShadcnCustomColorOverrides(document.documentElement, shadcnCustomColors);
|
||||
} else {
|
||||
cleanupShadcnCustomColorOverrides(document.documentElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,6 +242,7 @@ export function useTheme(): UseThemeReturn {
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => readCachedThemeMode());
|
||||
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => readCachedColorTheme());
|
||||
const [dashboardFontScalePct, setDashboardFontScalePctState] = useState<number>(() => readCachedDashboardFontScalePct());
|
||||
const [shadcnCustomColors, setShadcnCustomColorsState] = useState<Record<string, string>>(() => readCachedShadcnCustomColors());
|
||||
const [isHydrating, setIsHydrating] = useState(true);
|
||||
|
||||
// Track system color scheme preference
|
||||
@@ -218,9 +254,11 @@ export function useTheme(): UseThemeReturn {
|
||||
const themeModeRef = useRef(themeMode);
|
||||
const colorThemeRef = useRef(colorTheme);
|
||||
const dashboardFontScalePctRef = useRef(dashboardFontScalePct);
|
||||
const shadcnCustomColorsRef = useRef(shadcnCustomColors);
|
||||
const userSetThemeModeRef = useRef(false);
|
||||
const userSetColorThemeRef = useRef(false);
|
||||
const userSetDashboardFontScalePctRef = useRef(false);
|
||||
const userSetShadcnCustomColorsRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
@@ -234,6 +272,10 @@ export function useTheme(): UseThemeReturn {
|
||||
dashboardFontScalePctRef.current = dashboardFontScalePct;
|
||||
}, [dashboardFontScalePct]);
|
||||
|
||||
useEffect(() => {
|
||||
shadcnCustomColorsRef.current = shadcnCustomColors;
|
||||
}, [shadcnCustomColors]);
|
||||
|
||||
// Hydrate canonical theme values from backend global settings.
|
||||
useEffect(() => {
|
||||
if (!isBrowser || !isHydrating) return;
|
||||
@@ -280,6 +322,17 @@ export function useTheme(): UseThemeReturn {
|
||||
writeCachedDashboardFontScalePct(hydratedScalePct);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userSetShadcnCustomColorsRef.current) {
|
||||
const hydratedColors = sanitizeShadcnCustomColors(globalSettings.shadcnCustomColors);
|
||||
if (JSON.stringify(shadcnCustomColorsRef.current) !== JSON.stringify(hydratedColors)) {
|
||||
shadcnCustomColorsRef.current = hydratedColors;
|
||||
setShadcnCustomColorsState(hydratedColors);
|
||||
}
|
||||
if (JSON.stringify(readCachedShadcnCustomColors()) !== JSON.stringify(hydratedColors)) {
|
||||
writeCachedShadcnCustomColors(hydratedColors);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[useTheme] Failed to hydrate theme from global settings", error);
|
||||
@@ -310,8 +363,8 @@ export function useTheme(): UseThemeReturn {
|
||||
|
||||
// Apply theme immediately on mount and when theme changes
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
applyThemeAttributes(themeMode, colorTheme, dashboardFontScalePct, isSystemDark);
|
||||
}, [themeMode, colorTheme, dashboardFontScalePct, isSystemDark]);
|
||||
applyThemeAttributes(themeMode, colorTheme, dashboardFontScalePct, isSystemDark, shadcnCustomColors);
|
||||
}, [themeMode, colorTheme, dashboardFontScalePct, isSystemDark, shadcnCustomColors]);
|
||||
|
||||
// Ensure theme-data.css is loaded/unloaded based on colorTheme.
|
||||
// This handles both initial hydration from backend and runtime theme changes.
|
||||
@@ -368,13 +421,30 @@ export function useTheme(): UseThemeReturn {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setShadcnCustomColors = useCallback((colors: Record<string, string>) => {
|
||||
const sanitizedColors = sanitizeShadcnCustomColors(colors);
|
||||
userSetShadcnCustomColorsRef.current = true;
|
||||
shadcnCustomColorsRef.current = sanitizedColors;
|
||||
setShadcnCustomColorsState(sanitizedColors);
|
||||
writeCachedShadcnCustomColors(sanitizedColors);
|
||||
|
||||
void updateGlobalSettings({ shadcnCustomColors: sanitizedColors }).catch((error) => {
|
||||
console.warn("[useTheme] Failed to persist shadcnCustomColors to global settings", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolvedThemeMode = getEffectiveThemeMode(themeMode, isSystemDark);
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
colorTheme,
|
||||
dashboardFontScalePct,
|
||||
shadcnCustomColors,
|
||||
resolvedThemeMode,
|
||||
setThemeMode,
|
||||
setColorTheme,
|
||||
setDashboardFontScalePct,
|
||||
setShadcnCustomColors,
|
||||
isSystemDark,
|
||||
};
|
||||
}
|
||||
@@ -407,6 +477,23 @@ export function getThemeInitScript(): string {
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
var shadcnCustomColorTokens = ${JSON.stringify(SHADCN_CUSTOM_COLOR_TOKENS.map((token) => token.cssVar))};
|
||||
for (var cleanupIndex = 0; cleanupIndex < shadcnCustomColorTokens.length; cleanupIndex += 1) {
|
||||
document.documentElement.style.removeProperty(shadcnCustomColorTokens[cleanupIndex]);
|
||||
}
|
||||
if (colorTheme === 'shadcn-custom') {
|
||||
try {
|
||||
var shadcnCustomColors = JSON.parse(localStorage.getItem('${SHADCN_CUSTOM_COLORS_STORAGE_KEY}') || '{}');
|
||||
var validHex = /^#(?:[\\da-f]{3}|[\\da-f]{6})$/i;
|
||||
for (var colorIndex = 0; colorIndex < shadcnCustomColorTokens.length; colorIndex += 1) {
|
||||
var cssVar = shadcnCustomColorTokens[colorIndex];
|
||||
var value = shadcnCustomColors && shadcnCustomColors[cssVar];
|
||||
if (typeof value === 'string' && validHex.test(value.trim())) {
|
||||
document.documentElement.style.setProperty(cssVar, value.trim());
|
||||
}
|
||||
}
|
||||
} catch (customColorError) {}
|
||||
}
|
||||
if (colorTheme !== 'default') {
|
||||
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||
var themeDataUrl;
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
try {
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
|
||||
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'];
|
||||
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'shadcn', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'];
|
||||
// FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 remaps the legacy mono id before pre-hydration validation so persisted users keep the red mono accent.
|
||||
if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';
|
||||
if (!validThemes.includes(colorTheme)) {
|
||||
@@ -176,6 +176,23 @@
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||
document.documentElement.style.fontSize = fontScale + '%';
|
||||
var shadcnCustomColorTokens = ['--accent', '--bg', '--surface', '--card', '--border', '--text', '--text-muted', '--todo', '--in-progress', '--in-review', '--triage', '--done', '--color-success', '--color-warning', '--color-error'];
|
||||
for (var cleanupIndex = 0; cleanupIndex < shadcnCustomColorTokens.length; cleanupIndex += 1) {
|
||||
document.documentElement.style.removeProperty(shadcnCustomColorTokens[cleanupIndex]);
|
||||
}
|
||||
if (colorTheme === 'shadcn-custom') {
|
||||
try {
|
||||
var shadcnCustomColors = JSON.parse(localStorage.getItem('kb-dashboard-shadcn-custom-colors') || '{}');
|
||||
var validHex = /^#(?:[\da-f]{3}|[\da-f]{6})$/i;
|
||||
for (var colorIndex = 0; colorIndex < shadcnCustomColorTokens.length; colorIndex += 1) {
|
||||
var cssVar = shadcnCustomColorTokens[colorIndex];
|
||||
var value = shadcnCustomColors && shadcnCustomColors[cssVar];
|
||||
if (typeof value === 'string' && validHex.test(value.trim())) {
|
||||
document.documentElement.style.setProperty(cssVar, value.trim());
|
||||
}
|
||||
}
|
||||
} catch (customColorError) {}
|
||||
}
|
||||
if (colorTheme !== 'default') {
|
||||
var base = document.baseURI || (document.location && document.location.href) || '';
|
||||
var themeDataUrl;
|
||||
|
||||
@@ -1358,26 +1358,158 @@ FN-6758 makes the default shadcn highlight/accent orange for focus rings, active
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .card.agent-active {
|
||||
/*
|
||||
FNXC:Theming 2026-06-20-18:30:
|
||||
FN-6816 keeps shadcn-custom visually identical to the base shadcn theme until sanitized user token overrides are applied inline by the dashboard theme hook.
|
||||
*/
|
||||
[data-color-theme="shadcn-custom"] {
|
||||
--bg: #09090b;
|
||||
--surface: #0c0c0e;
|
||||
--card: #18181b;
|
||||
--card-hover: #1f1f23;
|
||||
--surface-hover: color-mix(in srgb, var(--surface) 90%, var(--text) 10%);
|
||||
--border: #27272a;
|
||||
|
||||
--text: #fafafa;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-dim: #52525b;
|
||||
|
||||
--todo: #60a5fa;
|
||||
--in-progress: #38bdf8;
|
||||
--in-progress-rgb: 56, 189, 248;
|
||||
--in-review: #34d399;
|
||||
--triage: #f59e0b;
|
||||
--done: #71717a;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-muted: #71717a;
|
||||
|
||||
--font-primary: "Geist", "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
--space-xs: 4px;
|
||||
--space-sm: 6px;
|
||||
--space-md: 10px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 20px;
|
||||
--space-2xl: 28px;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius: var(--radius-md);
|
||||
|
||||
--btn-padding: 6px 12px;
|
||||
--btn-border-width: 1px;
|
||||
--card-padding: 10px 12px;
|
||||
--modal-padding: var(--space-md) var(--space-lg);
|
||||
--header-padding: var(--space-sm) var(--space-lg);
|
||||
--column-gap: var(--space-md);
|
||||
--board-padding: var(--space-md) var(--space-lg);
|
||||
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #000000 18%, transparent);
|
||||
--shadow-md: 0 1px 3px color-mix(in srgb, #000000 22%, transparent);
|
||||
--shadow-lg: 0 4px 12px color-mix(in srgb, #000000 24%, transparent);
|
||||
--shadow-glow: none;
|
||||
--glow-success: none;
|
||||
--glow-warning: none;
|
||||
--glow-danger: none;
|
||||
--focus-ring: 0 0 0 calc(var(--btn-border-width) * 3) color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
--focus-ring-strong: 0 0 0 calc(var(--btn-border-width) * 3) color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
--shadow: var(--shadow-lg);
|
||||
|
||||
--transition-instant: 0.05s ease;
|
||||
--transition-fast: 0.1s ease;
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.2s ease;
|
||||
|
||||
--cta-bg: #fafafa;
|
||||
--cta-border: #fafafa;
|
||||
--cta-text: #18181b;
|
||||
--cta-bg-hover: #e4e4e7;
|
||||
--cta-border-hover: #e4e4e7;
|
||||
--cta-glow: none;
|
||||
--logo-accent: var(--text);
|
||||
--color-info: #38bdf8;
|
||||
--accent: #f97316;
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn-custom"][data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--card: #ffffff;
|
||||
--card-hover: #f4f4f5;
|
||||
--surface-hover: color-mix(in srgb, var(--surface) 92%, var(--text) 8%);
|
||||
--border: #e4e4e7;
|
||||
|
||||
--text: #09090b;
|
||||
--text-muted: #71717a;
|
||||
--text-dim: #a1a1aa;
|
||||
|
||||
--todo: #2563eb;
|
||||
--in-progress: #0284c7;
|
||||
--in-progress-rgb: 2, 132, 199;
|
||||
--in-review: #16a34a;
|
||||
--triage: #d97706;
|
||||
--done: #a1a1aa;
|
||||
|
||||
--color-success: #16a34a;
|
||||
--color-warning: #d97706;
|
||||
--color-error: #dc2626;
|
||||
--color-muted: #71717a;
|
||||
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent);
|
||||
--shadow-md: 0 1px 3px color-mix(in srgb, var(--text) 10%, transparent);
|
||||
--shadow-lg: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--shadow-glow: none;
|
||||
--glow-success: none;
|
||||
--glow-warning: none;
|
||||
--glow-danger: none;
|
||||
--focus-ring: 0 0 0 calc(var(--btn-border-width) * 3) color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
--focus-ring-strong: 0 0 0 calc(var(--btn-border-width) * 3) color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
--shadow: var(--shadow-lg);
|
||||
|
||||
--cta-bg: #18181b;
|
||||
--cta-border: #18181b;
|
||||
--cta-text: #fafafa;
|
||||
--cta-bg-hover: #27272a;
|
||||
--cta-border-hover: #27272a;
|
||||
--cta-glow: none;
|
||||
--logo-accent: var(--text);
|
||||
--color-info: #0284c7;
|
||||
--accent: #ea580c;
|
||||
--accent-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .card.agent-active,
|
||||
[data-color-theme="shadcn-custom"] .card.agent-active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"][data-theme="light"] .card.agent-active {
|
||||
[data-color-theme="shadcn"][data-theme="light"] .card.agent-active,
|
||||
[data-color-theme="shadcn-custom"][data-theme="light"] .card.agent-active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .btn {
|
||||
[data-color-theme="shadcn"] .btn,
|
||||
[data-color-theme="shadcn-custom"] .btn {
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .btn-primary,
|
||||
[data-color-theme="shadcn"] .btn-task-create {
|
||||
[data-color-theme="shadcn"] .btn-task-create,
|
||||
[data-color-theme="shadcn-custom"] .btn-primary,
|
||||
[data-color-theme="shadcn-custom"] .btn-task-create {
|
||||
background: var(--cta-bg);
|
||||
border-color: var(--cta-border);
|
||||
color: var(--cta-text);
|
||||
@@ -1385,7 +1517,9 @@ FN-6758 makes the default shadcn highlight/accent orange for focus rings, active
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .btn-primary:hover,
|
||||
[data-color-theme="shadcn"] .btn-task-create:hover {
|
||||
[data-color-theme="shadcn"] .btn-task-create:hover,
|
||||
[data-color-theme="shadcn-custom"] .btn-primary:hover,
|
||||
[data-color-theme="shadcn-custom"] .btn-task-create:hover {
|
||||
background: var(--cta-bg-hover);
|
||||
border-color: var(--cta-border-hover);
|
||||
color: var(--cta-text);
|
||||
@@ -1393,7 +1527,9 @@ FN-6758 makes the default shadcn highlight/accent orange for focus rings, active
|
||||
}
|
||||
|
||||
[data-color-theme="shadcn"] .card,
|
||||
[data-color-theme="shadcn"] .column {
|
||||
[data-color-theme="shadcn"] .column,
|
||||
[data-color-theme="shadcn-custom"] .card,
|
||||
[data-color-theme="shadcn-custom"] .column {
|
||||
border-width: var(--btn-border-width);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user