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.
|
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.
|
- **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.
|
- **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;
|
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 }) {
|
function ThemeSwatch({ className }: { className: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
|
<span className={`theme-option-swatch ${className}`} aria-hidden="true">
|
||||||
@@ -47,10 +51,7 @@ export function ThemeDropdown({
|
|||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
|
||||||
const currentTheme = useMemo(
|
const currentTheme = useMemo(() => resolveColorTheme(colorTheme), [colorTheme]);
|
||||||
() => COLOR_THEMES.find((theme) => theme.value === colorTheme) ?? COLOR_THEMES[0],
|
|
||||||
[colorTheme],
|
|
||||||
);
|
|
||||||
const listboxId = "theme-dropdown-listbox";
|
const listboxId = "theme-dropdown-listbox";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -104,47 +104,6 @@
|
|||||||
box-shadow: var(--focus-ring-strong);
|
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 {
|
.theme-option-swatch {
|
||||||
width: calc(var(--space-2xl) + var(--space-lg));
|
width: calc(var(--space-2xl) + var(--space-lg));
|
||||||
height: calc(var(--space-2xl) + var(--space-lg));
|
height: calc(var(--space-2xl) + var(--space-lg));
|
||||||
@@ -177,13 +136,6 @@
|
|||||||
background: var(--swatch-sample-4);
|
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 preview swatch colors */
|
||||||
.theme-swatch-default {
|
.theme-swatch-default {
|
||||||
--swatch-sample-1: #0d1117;
|
--swatch-sample-1: #0d1117;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useCallback } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sun, Moon, Monitor } from "lucide-react";
|
import { Sun, Moon, Monitor } from "lucide-react";
|
||||||
import type { ThemeMode, ColorTheme } from "@fusion/core";
|
import type { ThemeMode, ColorTheme } from "@fusion/core";
|
||||||
import { COLOR_THEMES, THEME_MODES } from "./themeOptions";
|
import { THEME_MODES } from "./themeOptions";
|
||||||
import { ShadcnColorPicker } from "./ShadcnColorPicker";
|
import { ThemeDropdown, resolveColorTheme } from "./ThemeDropdown";
|
||||||
|
|
||||||
interface ThemeSelectorProps {
|
interface ThemeSelectorProps {
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
@@ -40,6 +40,7 @@ export function ThemeSelector({
|
|||||||
onShadcnCustomColorsChange = () => {},
|
onShadcnCustomColorsChange = () => {},
|
||||||
}: ThemeSelectorProps) {
|
}: ThemeSelectorProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
|
const currentColorTheme = resolveColorTheme(colorTheme);
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
onThemeModeChange("system");
|
onThemeModeChange("system");
|
||||||
/*
|
/*
|
||||||
@@ -86,7 +87,7 @@ export function ThemeSelector({
|
|||||||
<div className="theme-preview-value">
|
<div className="theme-preview-value">
|
||||||
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
|
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
|
||||||
{" / "}
|
{" / "}
|
||||||
{COLOR_THEMES.find((t) => t.value === colorTheme)?.label}
|
{currentColorTheme.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,37 +106,17 @@ export function ThemeSelector({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color Theme Grid */}
|
{/*
|
||||||
<div className="theme-section-title">{t("theme.colorTheme", "Color Theme")}</div>
|
FNXC:Theme 2026-07-16-12:00:
|
||||||
<div className="theme-grid" role="radiogroup" aria-label={t("theme.colorThemeLabel", "Color theme")}>
|
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.
|
||||||
{COLOR_THEMES.map(({ value, label, className }) => (
|
*/}
|
||||||
<button
|
<ThemeDropdown
|
||||||
key={value}
|
colorTheme={colorTheme}
|
||||||
className={`theme-option${colorTheme === value ? " active" : ""}`}
|
onColorThemeChange={onColorThemeChange}
|
||||||
onClick={() => onColorThemeChange(value)}
|
shadcnCustomColors={shadcnCustomColors}
|
||||||
aria-pressed={colorTheme === value}
|
resolvedThemeMode={resolvedThemeMode}
|
||||||
aria-label={t(`theme.colorTheme.${value}`, `${label} theme`)}
|
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
|
||||||
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}
|
|
||||||
|
|
||||||
{/* Reset Button */}
|
{/* Reset Button */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,735 +1,87 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { COLOR_THEMES } from "@fusion/core";
|
import type { ColorTheme } from "@fusion/core";
|
||||||
import { ThemeSelector } from "../ThemeSelector";
|
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", () => {
|
describe("ThemeSelector", () => {
|
||||||
it("renders theme mode toggle buttons", () => {
|
it("uses the shared dropdown and preserves Settings mode, font-size, and reset controls", () => {
|
||||||
render(
|
const { onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange } = renderSelector("ocean");
|
||||||
<ThemeSelector
|
|
||||||
themeMode="dark"
|
|
||||||
colorTheme="default"
|
|
||||||
onThemeModeChange={vi.fn()}
|
|
||||||
onColorThemeChange={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText("Light mode")).toBeDefined();
|
expect(screen.getByRole("button", { name: "Ocean" })).toHaveAttribute("aria-haspopup", "listbox");
|
||||||
expect(screen.getByLabelText("Dark mode")).toBeDefined();
|
expect(screen.getAllByRole("radiogroup")).toHaveLength(2);
|
||||||
expect(screen.getByLabelText("System mode")).toBeDefined();
|
expect(screen.getByLabelText("Light mode")).toBeInTheDocument();
|
||||||
});
|
expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument();
|
||||||
|
|
||||||
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()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("Light mode"));
|
fireEvent.click(screen.getByLabelText("Light mode"));
|
||||||
expect(onThemeModeChange).toHaveBeenCalledWith("light");
|
fireEvent.click(screen.getByRole("button", { name: "Small" }));
|
||||||
|
|
||||||
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.getByLabelText("Reset to default theme"));
|
fireEvent.click(screen.getByLabelText("Reset to default theme"));
|
||||||
|
|
||||||
|
expect(onThemeModeChange).toHaveBeenCalledWith("light");
|
||||||
expect(onThemeModeChange).toHaveBeenCalledWith("system");
|
expect(onThemeModeChange).toHaveBeenCalledWith("system");
|
||||||
expect(onColorThemeChange).toHaveBeenCalledWith("shadcn-ember");
|
expect(onColorThemeChange).toHaveBeenCalledWith("shadcn-ember");
|
||||||
});
|
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
|
||||||
|
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(100);
|
||||||
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(onShadcnCustomColorsChange).toHaveBeenCalledWith({});
|
expect(onShadcnCustomColorsChange).toHaveBeenCalledWith({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each color theme has a swatch with four explicit sample colors", () => {
|
it("opens shared swatched options and selects a color theme", () => {
|
||||||
render(
|
const { onColorThemeChange } = renderSelector("forest");
|
||||||
<ThemeSelector
|
const trigger = screen.getByRole("button", { name: "Forest" });
|
||||||
themeMode="dark"
|
|
||||||
colorTheme="default"
|
|
||||||
onThemeModeChange={vi.fn()}
|
|
||||||
onColorThemeChange={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const themeOptions = screen.getAllByRole("button").filter(
|
fireEvent.click(trigger);
|
||||||
(btn) => btn.className.includes("theme-option")
|
|
||||||
);
|
|
||||||
expect(themeOptions.length).toBe(COLOR_THEMES.length);
|
|
||||||
|
|
||||||
themeOptions.forEach((btn) => {
|
const listbox = screen.getByRole("listbox", { name: "Color theme" });
|
||||||
const swatch = btn.querySelector(".theme-option-swatch");
|
expect(within(listbox).getAllByRole("option")).toHaveLength(COLOR_THEMES.length);
|
||||||
expect(swatch).toBeDefined();
|
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(onColorThemeChange).toHaveBeenCalledWith("ocean");
|
||||||
expect(samples?.length).toBe(4);
|
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 { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { CommandCenterControls } from "../CommandCenterControls";
|
||||||
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
|
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
|
||||||
|
|
||||||
@@ -34,17 +34,19 @@ const defaultSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderControls(projectId = "proj_123") {
|
function renderControls(projectId = "proj_123") {
|
||||||
|
const onColorThemeChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ConfirmDialogProvider>
|
<ConfirmDialogProvider>
|
||||||
<CommandCenterControls
|
<CommandCenterControls
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
colorTheme="default"
|
colorTheme="default"
|
||||||
themeMode="dark"
|
themeMode="dark"
|
||||||
onColorThemeChange={vi.fn()}
|
onColorThemeChange={onColorThemeChange}
|
||||||
onThemeModeChange={vi.fn()}
|
onThemeModeChange={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</ConfirmDialogProvider>,
|
</ConfirmDialogProvider>,
|
||||||
);
|
);
|
||||||
|
return { onColorThemeChange };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockGlobalConcurrency(overrides: Partial<{
|
function mockGlobalConcurrency(overrides: Partial<{
|
||||||
@@ -85,6 +87,18 @@ describe("CommandCenterControls concurrency markers", () => {
|
|||||||
document.body.innerHTML = "";
|
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.
|
// 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 () => {
|
it("aligns dashboard global and project markers with their native thumbs", async () => {
|
||||||
renderControls();
|
renderControls();
|
||||||
|
|||||||
Reference in New Issue
Block a user