FN-8124: reuse dashboard theme dropdown in Settings

Unify Settings appearance theme selection with the dashboard control.

- Reuse ThemeDropdown and its custom-color picker in ThemeSelector
- Remove the obsolete Settings color-theme grid styles
- Cover shared dropdown interactions and document the unified control
- Add a patch changeset for the Settings theme selector

Files changed:
 .changeset/FN-8124-settings-theme-dropdown.md      |   7 +
 docs/dashboard-guide.md                            |   2 +-
 .../dashboard/app/components/ThemeDropdown.tsx     |   9 +-
 .../dashboard/app/components/ThemeSelector.css     |  48 --
 .../dashboard/app/components/ThemeSelector.tsx     |  49 +-
 .../components/__tests__/ThemeSelector.test.tsx    | 778 ++-------------------
 .../__tests__/CommandCenterControls.test.tsx       |  18 +-
 7 files changed, 109 insertions(+), 802 deletions(-)

Fusion-Task-Id: FN-8124

Fusion-Task-Lineage: 6bd87f2a-d9e1-4374-8f54-1a2266c4004f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 12:48:51 -07:00
parent fdea8a4294
commit 47f9aa7144
7 changed files with 114 additions and 807 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Settings now uses the same compact color-theme dropdown as the dashboard.
category: fix
dev: AppearanceSection/ThemeSelector reuse ThemeDropdown; the old .theme-grid affordance and orphaned CSS were removed. Shared swatch classes retained.

View File

@@ -1848,7 +1848,7 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok
Dark/light modes via `data-theme`; fresh installs default to System mode so the resolved theme follows `prefers-color-scheme` until the user explicitly chooses Light, Dark, or System. 76 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. Glass Silver preserves the Glass theme's frosted translucent surfaces and transparent modal overlay behavior while using silver and graphite accents instead of purple/pink.
Choose Shadcn variants from **Settings → Appearance** or from the Command Center **Overview** theme card; both selectors use the same `themeOptions.ts` labels and color-chip swatches. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue.
Choose Shadcn variants from **Settings → Appearance** or from the Command Center **Overview** theme card; both use the same compact `ThemeDropdown`, labels, and color-chip swatches. The left sidebar active-item highlight and resize accent use the active theme's `--accent`, so they follow the selected Shadcn accent instead of staying fixed blue.
- **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

@@ -17,6 +17,10 @@ interface ThemeDropdownProps {
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
}
export function resolveColorTheme(colorTheme: ColorTheme) {
return COLOR_THEMES.find((theme) => theme.value === colorTheme) ?? COLOR_THEMES[0];
}
function ThemeSwatch({ className }: { className: string }) {
return (
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
@@ -47,10 +51,7 @@ export function ThemeDropdown({
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 currentTheme = useMemo(() => resolveColorTheme(colorTheme), [colorTheme]);
const listboxId = "theme-dropdown-listbox";
useEffect(() => {

View File

@@ -104,47 +104,6 @@
box-shadow: var(--focus-ring-strong);
}
.theme-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
}
@media (max-width: 768px) {
.theme-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.theme-option {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
background: var(--card);
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-option:hover {
border-color: var(--todo);
background: var(--card-hover);
}
.theme-option.active {
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 14%, transparent);
}
.theme-option:focus-visible {
outline: none;
border-color: var(--todo);
box-shadow: var(--focus-ring-strong);
}
.theme-option-swatch {
width: calc(var(--space-2xl) + var(--space-lg));
height: calc(var(--space-2xl) + var(--space-lg));
@@ -177,13 +136,6 @@
background: var(--swatch-sample-4);
}
.theme-option-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
text-align: center;
}
/* Theme preview swatch colors */
.theme-swatch-default {
--swatch-sample-1: #0d1117;

View File

@@ -3,8 +3,8 @@ import { useCallback } from "react";
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";
import { THEME_MODES } from "./themeOptions";
import { ThemeDropdown, resolveColorTheme } from "./ThemeDropdown";
interface ThemeSelectorProps {
themeMode: ThemeMode;
@@ -40,6 +40,7 @@ export function ThemeSelector({
onShadcnCustomColorsChange = () => {},
}: ThemeSelectorProps) {
const { t } = useTranslation("app");
const currentColorTheme = resolveColorTheme(colorTheme);
const handleReset = useCallback(() => {
onThemeModeChange("system");
/*
@@ -86,7 +87,7 @@ export function ThemeSelector({
<div className="theme-preview-value">
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
{" / "}
{COLOR_THEMES.find((t) => t.value === colorTheme)?.label}
{currentColorTheme.label}
</div>
</div>
</div>
@@ -105,37 +106,17 @@ export function ThemeSelector({
))}
</div>
{/* Color Theme Grid */}
<div className="theme-section-title">{t("theme.colorTheme", "Color Theme")}</div>
<div className="theme-grid" role="radiogroup" aria-label={t("theme.colorThemeLabel", "Color theme")}>
{COLOR_THEMES.map(({ value, label, className }) => (
<button
key={value}
className={`theme-option${colorTheme === value ? " active" : ""}`}
onClick={() => onColorThemeChange(value)}
aria-pressed={colorTheme === value}
aria-label={t(`theme.colorTheme.${value}`, `${label} theme`)}
title={t(`theme.colorTheme.${value}`, label)}
>
<div 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" />
</div>
<span className="theme-option-label">{t(`theme.colorTheme.${value}`, label)}</span>
</button>
))}
</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}
{/*
FNXC:Theme 2026-07-16-12:00:
Settings Appearance reuses ThemeDropdown so it has the same compact, keyboard-accessible color-theme affordance as the Command Center. ThemeSelector retains its mode, font-size, and reset controls, while ThemeDropdown remains the sole custom-color picker owner to avoid duplicate controls.
*/}
<ThemeDropdown
colorTheme={colorTheme}
onColorThemeChange={onColorThemeChange}
shadcnCustomColors={shadcnCustomColors}
resolvedThemeMode={resolvedThemeMode}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
/>
{/* Reset Button */}
<button

View File

@@ -1,735 +1,87 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { COLOR_THEMES } from "@fusion/core";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { ColorTheme } from "@fusion/core";
import { ThemeSelector } from "../ThemeSelector";
import { COLOR_THEMES as THEME_OPTIONS } from "../themeOptions";
import { COLOR_THEMES } from "../themeOptions";
function renderSelector(colorTheme: ColorTheme | undefined) {
const onThemeModeChange = vi.fn();
const onColorThemeChange = vi.fn();
const onDashboardFontScaleChange = vi.fn();
const onShadcnCustomColorsChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme={colorTheme as ColorTheme}
shadcnCustomColors={{ "--accent": "#123456" }}
onThemeModeChange={onThemeModeChange}
onColorThemeChange={onColorThemeChange}
onDashboardFontScaleChange={onDashboardFontScaleChange}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
/>,
);
return { onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange };
}
describe("ThemeSelector", () => {
it("renders theme mode toggle buttons", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
it("uses the shared dropdown and preserves Settings mode, font-size, and reset controls", () => {
const { onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange } = renderSelector("ocean");
expect(screen.getByLabelText("Light mode")).toBeDefined();
expect(screen.getByLabelText("Dark mode")).toBeDefined();
expect(screen.getByLabelText("System mode")).toBeDefined();
});
it("marks current theme mode as active", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const lightBtn = screen.getByLabelText("Light mode");
expect(lightBtn.className).toContain("active");
expect(lightBtn.getAttribute("aria-pressed")).toBe("true");
});
it("marks non-active theme modes as not pressed", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const lightBtn = screen.getByLabelText("Light mode");
expect(lightBtn.className).not.toContain("active");
expect(lightBtn.getAttribute("aria-pressed")).toBe("false");
});
it("calls onThemeModeChange when a mode is clicked", () => {
const onThemeModeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={onThemeModeChange}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: "Ocean" })).toHaveAttribute("aria-haspopup", "listbox");
expect(screen.getAllByRole("radiogroup")).toHaveLength(2);
expect(screen.getByLabelText("Light mode")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Light mode"));
expect(onThemeModeChange).toHaveBeenCalledWith("light");
fireEvent.click(screen.getByLabelText("System mode"));
expect(onThemeModeChange).toHaveBeenCalledWith("system");
});
it("renders all color theme options", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
// FNXC:Theme 2026-06-22-09:30: Assert the accessibility invariant — every theme in the
// shared COLOR_THEMES list renders an accessibly-labeled option — instead of a frozen
// hardcoded label list that drifts whenever themes are renamed/added (e.g. FN-6813 mono variants).
const colorThemeGroup = screen.getByRole("radiogroup", { name: "Color theme" });
const themeButtons = within(colorThemeGroup).getAllByRole("button");
expect(themeButtons).toHaveLength(THEME_OPTIONS.length);
for (const theme of THEME_OPTIONS) {
expect(screen.getByLabelText(`${theme.label} theme`)).toBeDefined();
}
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
/*
FNXC:Theme 2026-06-25-16:55:
The default color theme can be renamed for users without changing its persisted value; assert the selected default option through shared theme metadata so tests do not go stale on label-only copy changes.
*/
const defaultTheme = THEME_OPTIONS.find((theme) => theme.value === "default")!;
expect(screen.getByLabelText(`${defaultTheme.label} theme`).getAttribute("aria-pressed")).toBe("true");
});
it("marks only Shadcn Ember as the default color theme label", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="shadcn-ember"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByLabelText("Shadcn Ember (Default) theme")).toBeDefined();
expect(screen.getByLabelText("Ocean theme")).toBeDefined();
expect(screen.queryByLabelText("Ocean (Default) theme")).toBeNull();
expect(THEME_OPTIONS.filter((theme) => theme.label.includes("(Default)")).map((theme) => theme.value)).toEqual([
"shadcn-ember",
]);
});
it("renders every shared swatch class from themeOptions", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
for (const theme of THEME_OPTIONS) {
const option = screen.getByLabelText(`${theme.label} theme`);
expect(option.querySelector(`.${theme.className}`)).toBeTruthy();
}
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
});
it("renders the Glass Silver affordance with a non-empty label and swatch", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="glass-silver"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const option = screen.getByLabelText("Glass Silver theme");
expect(option).toHaveTextContent("Glass Silver");
expect(option.querySelector(".theme-swatch-glass-silver")).toBeTruthy();
expect(option.getAttribute("aria-pressed")).toBe("true");
});
it("marks current color theme as active", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const oceanBtn = screen.getByLabelText("Ocean theme");
expect(oceanBtn.className).toContain("active");
expect(oceanBtn.getAttribute("aria-pressed")).toBe("true");
});
it("calls onColorThemeChange when a color theme is clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Forest theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("forest");
fireEvent.click(screen.getByLabelText("Berry theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("berry");
fireEvent.click(screen.getByLabelText("Zen theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("zen");
});
it("calls onColorThemeChange when a new color theme is clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Nord theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("nord");
fireEvent.click(screen.getByLabelText("Dracula theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("dracula");
fireEvent.click(screen.getByLabelText("Gruvbox theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("gruvbox");
fireEvent.click(screen.getByLabelText("Tokyo Night theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("tokyo-night");
});
it("calls onColorThemeChange when newest color themes are clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Catppuccin Mocha theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("catppuccin-mocha");
fireEvent.click(screen.getByLabelText("GitHub Dark theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("github-dark");
fireEvent.click(screen.getByLabelText("Everforest theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("everforest");
fireEvent.click(screen.getByLabelText("Rosé Pine theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("rose-pine");
fireEvent.click(screen.getByLabelText("Kanagawa theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("kanagawa");
});
it("calls onColorThemeChange when grey color themes are clicked", () => {
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByLabelText("Slate theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("slate");
fireEvent.click(screen.getByLabelText("Ash theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("ash");
fireEvent.click(screen.getByLabelText("Graphite theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("graphite");
fireEvent.click(screen.getByLabelText("Silver theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("silver");
fireEvent.click(screen.getByLabelText("Ember theme"));
expect(onColorThemeChange).toHaveBeenCalledWith("ember");
});
it("displays Nord in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="nord"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Nord/)).toBeDefined();
});
it("displays Dracula in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="dracula"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Dracula/)).toBeDefined();
});
it("displays Gruvbox in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="gruvbox"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Gruvbox/)).toBeDefined();
});
it("displays Tokyo Night in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="tokyo-night"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Tokyo Night/)).toBeDefined();
});
it("displays Catppuccin Mocha in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="catppuccin-mocha"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Catppuccin Mocha/)).toBeDefined();
});
it("displays GitHub Dark in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="github-dark"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ GitHub Dark/)).toBeDefined();
});
it("displays Everforest in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="everforest"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Everforest/)).toBeDefined();
});
it("displays Rosé Pine in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="rose-pine"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Rosé Pine/)).toBeDefined();
});
it("displays Kanagawa in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="kanagawa"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Kanagawa/)).toBeDefined();
});
it("displays Slate in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="slate"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Slate/)).toBeDefined();
});
it("displays Ash in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ash"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Ash/)).toBeDefined();
});
it("displays Graphite in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="graphite"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Graphite/)).toBeDefined();
});
it("displays Silver in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="silver"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Silver/)).toBeDefined();
});
it("displays Ember in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ember"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Ember/)).toBeDefined();
});
it("displays dramatic theme names in preview when selected", () => {
const { rerender } = render(
<ThemeSelector
themeMode="dark"
colorTheme="brutalist"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const dramaticThemes = [
["brutalist", "Brutalist"],
["neon-city", "Neon City"],
["parchment", "Parchment"],
["terminal", "Terminal"],
["glass", "Glass"],
["horizon", "Horizon"],
["vitesse", "Vitesse"],
["outrun", "Outrun"],
["snazzy", "Snazzy"],
["porple", "Porple"],
["espresso", "Espresso"],
["mars", "Mars"],
["poimandres", "Poimandres"],
["rust", "Rust"],
["copper", "Copper"],
["foundry", "Foundry"],
["carbon", "Carbon"],
["sandstone", "Sandstone"],
["lagoon", "Lagoon"],
["frost", "Frost"],
["lavender", "Lavender"],
["neon-bloom", "Neon Bloom"],
["sepia", "Sepia"],
] as const;
dramaticThemes.forEach(([value, label]) => {
rerender(
<ThemeSelector
themeMode="dark"
colorTheme={value}
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(new RegExp(`Dark \\/ ${label}`))).toBeDefined();
});
});
it("displays light Catppuccin Mocha in preview when light mode", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="catppuccin-mocha"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Light \/ Catppuccin Mocha/)).toBeDefined();
});
it("displays light Tokyo Night in preview when light mode", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="tokyo-night"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Light \/ Tokyo Night/)).toBeDefined();
});
it("displays current theme preview", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Current theme/)).toBeDefined();
expect(screen.getByText(/Dark \/ Ocean/)).toBeDefined();
});
it("displays system theme in preview when system mode", () => {
render(
<ThemeSelector
themeMode="system"
colorTheme="solarized"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/System \/ Solarized/)).toBeDefined();
});
it("displays Factory in preview when selected", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="factory"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Dark \/ Factory/)).toBeDefined();
});
it("displays light theme in preview when light mode", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="forest"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByText(/Light \/ Forest/)).toBeDefined();
});
it("shows correct icon for dark mode in preview", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("shows correct icon for light mode in preview", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("shows correct icon for system mode in preview", () => {
render(
<ThemeSelector
themeMode="system"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
const previewIcon = screen.getByText(/Current theme/).closest(".theme-current-preview")?.querySelector("svg");
expect(previewIcon).toBeDefined();
});
it("renders reset to defaults button", () => {
render(
<ThemeSelector
themeMode="light"
colorTheme="ocean"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
expect(screen.getByLabelText("Reset to default theme")).toBeDefined();
});
it("calls both change handlers when reset is clicked", () => {
const onThemeModeChange = vi.fn();
const onColorThemeChange = vi.fn();
render(
<ThemeSelector
themeMode="light"
colorTheme="ocean"
onThemeModeChange={onThemeModeChange}
onColorThemeChange={onColorThemeChange}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Small" }));
fireEvent.click(screen.getByLabelText("Reset to default theme"));
expect(onThemeModeChange).toHaveBeenCalledWith("light");
expect(onThemeModeChange).toHaveBeenCalledWith("system");
expect(onColorThemeChange).toHaveBeenCalledWith("shadcn-ember");
});
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();
const showCustomColors = screen.getByRole("button", { name: "Show custom colors" });
expect(showCustomColors).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
expect(screen.queryByTestId("shadcn-color---accent")).toBeNull();
fireEvent.click(showCustomColors);
expect(screen.getByRole("button", { name: "Collapse custom colors" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("shadcn-color-picker-controls")).toBeDefined();
const accentRow = screen.getByTestId("shadcn-color---accent");
expect(within(accentRow).getByRole("textbox")).toHaveValue("#123456");
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(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(100);
expect(onShadcnCustomColorsChange).toHaveBeenCalledWith({});
});
it("each color theme has a swatch with four explicit sample colors", () => {
render(
<ThemeSelector
themeMode="dark"
colorTheme="default"
onThemeModeChange={vi.fn()}
onColorThemeChange={vi.fn()}
/>
);
it("opens shared swatched options and selects a color theme", () => {
const { onColorThemeChange } = renderSelector("forest");
const trigger = screen.getByRole("button", { name: "Forest" });
const themeOptions = screen.getAllByRole("button").filter(
(btn) => btn.className.includes("theme-option")
);
expect(themeOptions.length).toBe(COLOR_THEMES.length);
fireEvent.click(trigger);
themeOptions.forEach((btn) => {
const swatch = btn.querySelector(".theme-option-swatch");
expect(swatch).toBeDefined();
const listbox = screen.getByRole("listbox", { name: "Color theme" });
expect(within(listbox).getAllByRole("option")).toHaveLength(COLOR_THEMES.length);
const oceanOption = within(listbox).getByRole("option", { name: "Ocean" });
expect(oceanOption.querySelector(".theme-swatch-ocean")).toBeTruthy();
fireEvent.click(oceanOption);
const samples = swatch?.querySelectorAll(".theme-option-swatch-sample");
expect(samples?.length).toBe(4);
});
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it.each([
[undefined as unknown as ColorTheme, "Fusion Legacy"],
["not-a-theme" as unknown as ColorTheme, "Fusion Legacy"],
["default" as ColorTheme, "Fusion Legacy"],
["ocean" as ColorTheme, "Ocean"],
["forest" as ColorTheme, "Forest"],
])("uses the dashboard fallback and label for %s", (colorTheme, expectedLabel) => {
const { onColorThemeChange } = renderSelector(colorTheme);
const trigger = screen.getByRole("button", { name: expectedLabel });
expect(trigger).toHaveTextContent(expectedLabel);
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("option", { name: "Ocean" }));
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
});
it("renders Shadcn Custom's picker exactly once through the shared dropdown", () => {
renderSelector("shadcn-custom");
expect(screen.getAllByTestId("shadcn-color-picker")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Shadcn Custom" })).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { CommandCenterControls } from "../CommandCenterControls";
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
@@ -34,17 +34,19 @@ const defaultSettings = {
};
function renderControls(projectId = "proj_123") {
const onColorThemeChange = vi.fn();
render(
<ConfirmDialogProvider>
<CommandCenterControls
projectId={projectId}
colorTheme="default"
themeMode="dark"
onColorThemeChange={vi.fn()}
onColorThemeChange={onColorThemeChange}
onThemeModeChange={vi.fn()}
/>
</ConfirmDialogProvider>,
);
return { onColorThemeChange };
}
function mockGlobalConcurrency(overrides: Partial<{
@@ -85,6 +87,18 @@ describe("CommandCenterControls concurrency markers", () => {
document.body.innerHTML = "";
});
it("keeps the Theme card's compact dropdown interactive", () => {
const { onColorThemeChange } = renderControls();
const themeCard = screen.getByTestId("cc-controls-theme");
const trigger = screen.getByRole("button", { name: "Fusion Legacy" });
expect(themeCard).toContainElement(trigger);
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("option", { name: "Ocean" }));
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");
});
// FNXC:GlobalConcurrencyControls 2026-07-15-12:00: FN-8007 requires dashboard markers to use the exact native-thumb coordinate system when the expanded range max exceeds the persisted cap.
it("aligns dashboard global and project markers with their native thumbs", async () => {
renderControls();