Files
fusion/packages/dashboard/app/components/ThemeDropdown.tsx
gsxdsm 58f758842b 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
2026-06-21 16:13:54 -07:00

197 lines
7.4 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
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";
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 }) {
return (
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
<span className="theme-option-swatch-sample theme-option-swatch-sample-1" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-2" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-3" />
<span className="theme-option-swatch-sample theme-option-swatch-sample-4" />
</span>
);
}
/*
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,
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)));
const rootRef = useRef<HTMLDivElement | null>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const currentTheme = useMemo(
() => COLOR_THEMES.find((theme) => theme.value === colorTheme) ?? COLOR_THEMES[0],
[colorTheme],
);
const listboxId = "theme-dropdown-listbox";
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
useEffect(() => {
setActiveIndex(Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme)));
}, [colorTheme]);
useEffect(() => {
if (open) {
optionRefs.current[activeIndex]?.focus();
}
}, [activeIndex, open]);
const chooseTheme = (theme: ColorTheme) => {
onColorThemeChange(theme);
setOpen(false);
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
const selectedIndex = Math.max(0, COLOR_THEMES.findIndex((theme) => theme.value === colorTheme));
setActiveIndex(event.key === "ArrowUp" ? Math.max(0, selectedIndex - 1) : selectedIndex);
setOpen(true);
}
};
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number, theme: ColorTheme) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
setActiveIndex((index + delta + COLOR_THEMES.length) % COLOR_THEMES.length);
return;
}
if (event.key === "Home") {
event.preventDefault();
setActiveIndex(0);
return;
}
if (event.key === "End") {
event.preventDefault();
setActiveIndex(COLOR_THEMES.length - 1);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
chooseTheme(theme);
}
};
return (
<div className={`theme-dropdown${open ? " open" : ""}`} ref={rootRef}>
<button
type="button"
className="theme-dropdown-trigger btn"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<ThemeSwatch className={currentTheme.className} />
<span className="theme-dropdown-trigger-label">
{t(`theme.colorTheme.${currentTheme.value}`, currentTheme.label)}
</span>
<ChevronDown size={16} aria-hidden="true" />
</button>
{themeMode && onThemeModeChange ? (
<div className="theme-dropdown-modes" role="radiogroup" aria-label={t("theme.modeLabel", "Theme mode")}>
{THEME_MODES.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
className={`theme-dropdown-mode-btn btn btn-sm${themeMode === value ? " active" : ""}`}
aria-pressed={themeMode === value}
onClick={() => onThemeModeChange(value)}
title={t(`theme.${value}Mode`, `${label} mode`)}
>
<Icon size={16} aria-hidden="true" />
<span>{t(`theme.${value}`, label)}</span>
</button>
))}
</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")}>
{COLOR_THEMES.map(({ value, label, className }, index) => {
const selected = colorTheme === value;
return (
<button
key={value}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
className={`theme-dropdown-option${selected ? " active" : ""}`}
role="option"
aria-selected={selected}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => chooseTheme(value)}
onKeyDown={(event) => handleOptionKeyDown(event, index, value)}
>
<ThemeSwatch className={className} />
<span className="theme-dropdown-option-label">{t(`theme.colorTheme.${value}`, label)}</span>
{selected ? <Check size={16} aria-hidden="true" /> : null}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}
export type { ThemeDropdownProps };