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:
gsxdsm
2026-06-21 16:13:54 -07:00
parent ef4889585f
commit 58f758842b
26 changed files with 938 additions and 17 deletions

View 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.

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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,

View File

@@ -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`.

View File

@@ -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")}

View File

@@ -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={() => {

View File

@@ -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}
/>

View 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%;
}
}

View 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>
);
}

View File

@@ -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")}>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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({});
});
});

View File

@@ -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(

View File

@@ -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

View File

@@ -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("");
}
});
});

View File

@@ -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":

View File

@@ -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>

View File

@@ -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">

View 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;
}

View File

@@ -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" },

View File

@@ -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 = () => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}