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:
7
.changeset/FN-8124-settings-theme-dropdown.md
Normal file
7
.changeset/FN-8124-settings-theme-dropdown.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user