FN-8146: restore persistent theme selection

Restore the full Settings theme picker while preserving Shadcn Mono selections across reloads.

- Merge the Settings selector into the current-theme row with shared dropdown controls.
- Restore Shadcn Mono across theme metadata, token styles, and dashboard/desktop bootstrap validation.
- Scope Mono light swatches correctly and cover restored options and persistence with tests.

Files changed:
 .changeset/FN-8146-theme-dropdown-current-row.md   |  7 +++
 docs/dashboard-guide.md                            |  5 ++-
 packages/core/src/types/execution-and-ui.ts        |  1 +
 .../dashboard/app/components/ThemeDropdown.css     | 52 ++++++++++++++++++++++
 .../dashboard/app/components/ThemeDropdown.tsx     | 42 ++++++++++++++---
 .../dashboard/app/components/ThemeSelector.css     | 42 +----------------
 .../dashboard/app/components/ThemeSelector.tsx     | 45 +++++--------------
 .../components/__tests__/ThemeDropdown.test.tsx    | 46 ++++++++++++++++---
 .../components/__tests__/ThemeSelector.test.tsx    | 29 ++++++++----
 .../__tests__/CommandCenterControls.test.tsx       | 15 ++++++-
 packages/dashboard/app/components/themeOptions.ts  |  2 +
 .../dashboard/app/hooks/__tests__/useTheme.test.ts |  8 ++--
 packages/dashboard/app/hooks/useTheme.ts           |  8 ++--
 packages/dashboard/app/index.html                  |  5 +--
 packages/dashboard/app/public/theme-data.css       | 16 ++++++-
 packages/desktop/src/renderer/index.html           |  1 +
 16 files changed, 213 insertions(+), 111 deletions(-)

Fusion-Task-Id: FN-8146

Fusion-Task-Lineage: 32ec7f21-ee6b-4459-8e7b-f5a5435c4994

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 23:58:57 -07:00
parent bf6a974b58
commit 6206af8ce9
16 changed files with 213 additions and 111 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Settings theme selector is merged into the current-theme row and lists every color theme.
category: feature
dev: ThemeDropdown gains a current-row trigger variant used by ThemeSelector; the static preview markup/CSS was removed. Tests assert the explicit historical theme set on Settings and Command Center, including restored Shadcn Mono.

View File

@@ -1852,10 +1852,11 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok
<!-- FNXC:DashboardTheming 2026-07-01-00:00: Glass Silver is the silver/gray frosted sibling of Glass and is selectable anywhere color themes are listed, so keep the inventory count and theme description in this guide aligned with COLOR_THEMES. -->
<!-- FNXC:DashboardTheming 2026-07-03-00:00: Fresh installs and reset-to-default use System mode so the dashboard follows the OS light/dark preference before and after hydration while keeping Shadcn Ember as the default color theme. -->
<!-- FNXC:DashboardTheming 2026-07-16-00:00: FN-8151 adds Cobalt, Clay, and Moss; keep this inventory synchronized with the core COLOR_THEMES union and selector surfaces. -->
<!-- FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores historical Shadcn Mono as a persistent selector and makes the Settings current-theme row its single all-themes dropdown trigger while Command Center stays compact. -->
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. 80 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), 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. 81 color themes via `data-color-theme` (lazy-loaded from `app/public/theme-data.css`), including Cobalt (saturated blue), Clay (warm terracotta), Moss (muted forest green), 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 and Shadcn Mono Red/Blue/Green/Purple/Pink/Orange/Yellow (grayscale surfaces with color-specific accents; Shadcn Mono retains its selected id), 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 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.
Choose color themes from **Settings → Appearance** or from the Command Center **Overview** theme card. Settings merges its selector into the current-theme row, which opens the full color-theme list; Command Center keeps the compact `ThemeDropdown` trigger. Both list every selectable color theme with the same 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

@@ -128,6 +128,7 @@ export const COLOR_THEMES = [
"shadcn-pink",
"shadcn-orange",
"shadcn-yellow",
"shadcn-mono",
"shadcn-mono-red",
"shadcn-mono-blue",
"shadcn-mono-green",

View File

@@ -37,6 +37,54 @@ Glass theme surfaces create stronger translucent stacking contexts, so the Comma
white-space: nowrap;
}
.theme-dropdown-trigger--current-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-color: var(--border);
background: var(--card);
color: var(--text);
text-align: start;
}
.theme-dropdown-current-row-icon {
display: flex;
align-items: center;
justify-content: center;
inline-size: calc(var(--space-2xl) + var(--space-md));
block-size: calc(var(--space-2xl) + var(--space-md));
border-radius: var(--radius);
background: var(--surface);
color: var(--todo);
}
.theme-dropdown-current-row-info {
display: grid;
gap: var(--space-xs);
min-inline-size: 0;
}
.theme-dropdown-current-row-label {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.theme-dropdown-current-row-value {
overflow: hidden;
color: var(--text);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-dropdown-trigger--current-row:hover,
.theme-dropdown-trigger--current-row:focus-visible {
border-color: var(--accent);
background: color-mix(in srgb, var(--card) 90%, var(--accent));
}
.theme-dropdown-modes {
display: flex;
flex-wrap: wrap;
@@ -109,4 +157,8 @@ Glass theme surfaces create stronger translucent stacking contexts, so the Comma
.theme-dropdown-list {
max-block-size: min(50vh, calc(var(--space-2xl) * 10));
}
.theme-dropdown-trigger--current-row {
padding: var(--space-md);
}
}

View File

@@ -15,6 +15,7 @@ interface ThemeDropdownProps {
resolvedThemeMode?: "dark" | "light";
onThemeModeChange?: (mode: ThemeMode) => void;
onShadcnCustomColorsChange?: (colors: Record<string, string>) => void;
triggerVariant?: "compact" | "current-row";
}
export function resolveColorTheme(colorTheme: ColorTheme) {
@@ -35,6 +36,9 @@ function ThemeSwatch({ className }: { className: string }) {
/*
FNXC:Theme 2026-06-19-12:10:
FN-6727 requires Command Center operators to change the global app theme from a compact dropdown that previews each color theme with the same rich swatch chips used by Settings; this component accepts App-threaded setters instead of creating another theme owner.
FNXC:Theme 2026-07-16-14:30:
FN-8146 merges Settings' current-theme summary into this dropdown trigger. The Settings variant carries mode and combined-value context while Command Center retains the compact swatch-and-label trigger.
*/
export function ThemeDropdown({
colorTheme,
@@ -44,6 +48,7 @@ export function ThemeDropdown({
resolvedThemeMode = themeMode === "light" ? "light" : "dark",
onThemeModeChange,
onShadcnCustomColorsChange = () => {},
triggerVariant = "compact",
}: ThemeDropdownProps) {
const { t } = useTranslation("app");
const [open, setOpen] = useState(false);
@@ -52,6 +57,13 @@ export function ThemeDropdown({
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const currentTheme = useMemo(() => resolveColorTheme(colorTheme), [colorTheme]);
const currentMode = themeMode === "system" ? resolvedThemeMode : themeMode;
const CurrentModeIcon = THEME_MODES.find((mode) => mode.value === currentMode)?.icon;
const modeLabel = themeMode === "system"
? t("theme.system", "System")
: t(`theme.${themeMode}`, themeMode ? `${themeMode.charAt(0).toUpperCase()}${themeMode.slice(1)}` : "Dark");
const currentThemeLabel = t(`theme.colorTheme.${currentTheme.value}`, currentTheme.label);
const currentRowLabel = `${t("theme.currentTheme", "Current theme")} ${modeLabel} / ${currentThemeLabel}`;
const listboxId = "theme-dropdown-listbox";
useEffect(() => {
@@ -118,20 +130,38 @@ export function ThemeDropdown({
};
return (
<div className={`theme-dropdown${open ? " open" : ""}`} ref={rootRef}>
<div className={`theme-dropdown${triggerVariant === "current-row" ? " theme-dropdown--current-row" : ""}${open ? " open" : ""}`} ref={rootRef}>
<button
type="button"
className="theme-dropdown-trigger btn"
className={`theme-dropdown-trigger btn${triggerVariant === "current-row" ? " theme-dropdown-trigger--current-row" : ""}`}
aria-label={triggerVariant === "current-row" ? currentRowLabel : undefined}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<ThemeSwatch className={currentTheme.className} />
<span className="theme-dropdown-trigger-label">
{t(`theme.colorTheme.${currentTheme.value}`, currentTheme.label)}
</span>
{triggerVariant === "current-row" ? (
<>
<span className="theme-dropdown-current-row-icon" aria-hidden="true">
{CurrentModeIcon ? <CurrentModeIcon size={20} /> : null}
</span>
<span className="theme-dropdown-current-row-info">
<span className="theme-dropdown-current-row-label">{t("theme.currentTheme", "Current theme")}</span>
<span className="theme-dropdown-current-row-value">
{modeLabel} / {currentThemeLabel}
</span>
</span>
<ThemeSwatch className={currentTheme.className} />
</>
) : (
<>
<ThemeSwatch className={currentTheme.className} />
<span className="theme-dropdown-trigger-label">
{currentThemeLabel}
</span>
</>
)}
<ChevronDown size={16} aria-hidden="true" />
</button>

View File

@@ -425,6 +425,7 @@
--swatch-sample-4: #27272a;
}
.theme-swatch-shadcn-mono,
.theme-swatch-shadcn-mono-red {
--swatch-sample-1: #09090b;
--swatch-sample-2: #18181b;
@@ -786,6 +787,7 @@
--swatch-sample-4: #e4e4e7;
}
[data-theme="light"] .theme-swatch-shadcn-mono,
[data-theme="light"] .theme-swatch-shadcn-mono-red {
--swatch-sample-1: #ffffff;
--swatch-sample-2: #f4f4f5;
@@ -1231,46 +1233,6 @@
box-shadow: var(--focus-ring-strong);
}
/* Theme current preview in settings */
.theme-current-preview {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 20px;
}
.theme-preview-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--surface);
border-radius: var(--radius);
color: var(--todo);
}
.theme-preview-info {
flex: 1;
}
.theme-preview-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 2px;
}
.theme-preview-value {
font-size: 14px;
font-weight: 600;
color: var(--text);
text-transform: capitalize;
}
@media (max-width: 768px) {
/* Model combobox dropdown: mobile-friendly sizing */
.model-combobox-dropdown {

View File

@@ -1,10 +1,9 @@
import "./ThemeSelector.css";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Sun, Moon, Monitor } from "lucide-react";
import type { ThemeMode, ColorTheme } from "@fusion/core";
import { THEME_MODES } from "./themeOptions";
import { ThemeDropdown, resolveColorTheme } from "./ThemeDropdown";
import { ThemeDropdown } from "./ThemeDropdown";
interface ThemeSelectorProps {
themeMode: ThemeMode;
@@ -40,7 +39,6 @@ export function ThemeSelector({
onShadcnCustomColorsChange = () => {},
}: ThemeSelectorProps) {
const { t } = useTranslation("app");
const currentColorTheme = resolveColorTheme(colorTheme);
const handleReset = useCallback(() => {
onThemeModeChange("system");
/*
@@ -71,26 +69,16 @@ export function ThemeSelector({
))}
</div>
{/* Current Theme Preview */}
<div className="theme-current-preview">
<div className="theme-preview-icon">
{themeMode === "light" ? (
<Sun size={20} />
) : themeMode === "dark" ? (
<Moon size={20} />
) : (
<Monitor size={20} />
)}
</div>
<div className="theme-preview-info">
<div className="theme-preview-label">{t("theme.currentTheme", "Current theme")}</div>
<div className="theme-preview-value">
{themeMode === "system" ? "System" : `${themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}`}
{" / "}
{currentColorTheme.label}
</div>
</div>
</div>
{/* FNXC:Theme 2026-07-16-14:30: FN-8146 makes the current-theme row the sole Settings color-theme trigger, replacing the separate static preview and standalone dropdown without adding a second mode control. */}
<ThemeDropdown
triggerVariant="current-row"
colorTheme={colorTheme}
themeMode={themeMode}
onColorThemeChange={onColorThemeChange}
shadcnCustomColors={shadcnCustomColors}
resolvedThemeMode={resolvedThemeMode}
onShadcnCustomColorsChange={onShadcnCustomColorsChange}
/>
<div className="theme-section-title">{t("theme.fontSize", "Font Size")}</div>
<div className="theme-font-size-toggle" role="radiogroup" aria-label={t("theme.fontSizeLabel", "Dashboard font size")}>
@@ -106,17 +94,6 @@ export function ThemeSelector({
))}
</div>
{/*
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,9 +1,19 @@
import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { COLOR_THEMES } from "../themeOptions";
import { ThemeDropdown } from "../ThemeDropdown";
// FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly.
const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const;
function renderedThemeIds(listbox: HTMLElement) {
return within(listbox).getAllByRole("option").map((option) => {
const swatch = option.querySelector<HTMLElement>(".theme-option-swatch");
expect(swatch).toBeTruthy();
return [...(swatch?.classList ?? [])].find((className) => className.startsWith("theme-swatch-"))?.replace("theme-swatch-", "");
});
}
describe("ThemeDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -21,12 +31,27 @@ describe("ThemeDropdown", () => {
const listbox = screen.getByRole("listbox", { name: /color theme/i });
expect(trigger.getAttribute("aria-expanded")).toBe("true");
for (const theme of COLOR_THEMES) {
const option = within(listbox)
.getAllByRole("option")
.find((element) => element.textContent?.trim().startsWith(theme.label));
expect(option?.querySelector(`.${theme.className}`)).toBeTruthy();
}
expect(renderedThemeIds(listbox)).toEqual(EXPECTED_THEME_IDS);
});
it("renders a current-row trigger with mode context and all historical theme options", () => {
render(
<ThemeDropdown
triggerVariant="current-row"
colorTheme="forest"
themeMode="system"
resolvedThemeMode="light"
onColorThemeChange={vi.fn()}
/>,
);
const trigger = screen.getByRole("button", { name: /current theme system \/ forest/i });
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
expect(trigger.querySelector(".theme-dropdown-current-row-icon svg")).toBeTruthy();
expect(trigger.querySelector(".theme-swatch-forest")).toBeTruthy();
fireEvent.click(trigger);
expect(renderedThemeIds(screen.getByRole("listbox", { name: /color theme/i }))).toEqual(EXPECTED_THEME_IDS);
});
it("labels only Shadcn Ember as the default option", () => {
@@ -166,6 +191,13 @@ describe("ThemeDropdown", () => {
expect(getComputedStyle(popover!).zIndex).toBe("10002");
});
it("keeps Shadcn Mono and Mono Red swatches scoped to the active light mode", () => {
const css = readFileSync("app/components/ThemeSelector.css", "utf8");
expect(css).toContain('[data-theme="light"] .theme-swatch-shadcn-mono,\n[data-theme="light"] .theme-swatch-shadcn-mono-red');
expect(css).not.toContain('[data-theme="light"] .theme-swatch-shadcn-mono,\n.theme-swatch-shadcn-mono-red');
});
it("preserves the mobile static in-flow popover branch without dropdown elevation", () => {
const css = readFileSync("app/components/ThemeDropdown.css", "utf8");

View File

@@ -2,7 +2,17 @@ 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 } from "../themeOptions";
// FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly.
const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const;
function renderedThemeIds(listbox: HTMLElement) {
return within(listbox).getAllByRole("option").map((option) => {
const swatch = option.querySelector<HTMLElement>(".theme-option-swatch");
expect(swatch).toBeTruthy();
return [...(swatch?.classList ?? [])].find((className) => className.startsWith("theme-swatch-"))?.replace("theme-swatch-", "");
});
}
function renderSelector(colorTheme: ColorTheme | undefined) {
const onThemeModeChange = vi.fn();
@@ -29,7 +39,9 @@ describe("ThemeSelector", () => {
it("uses the shared dropdown and preserves Settings mode, font-size, and reset controls", () => {
const { onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange, onShadcnCustomColorsChange } = renderSelector("ocean");
expect(screen.getByRole("button", { name: "Ocean" })).toHaveAttribute("aria-haspopup", "listbox");
const currentThemeTrigger = screen.getByRole("button", { name: /current theme dark \/ ocean/i });
expect(currentThemeTrigger).toHaveAttribute("aria-haspopup", "listbox");
expect(currentThemeTrigger.querySelector(".theme-dropdown-current-row-icon svg")).toBeTruthy();
expect(screen.getAllByRole("radiogroup")).toHaveLength(2);
expect(screen.getByLabelText("Light mode")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Small" })).toBeInTheDocument();
@@ -48,12 +60,12 @@ describe("ThemeSelector", () => {
it("opens shared swatched options and selects a color theme", () => {
const { onColorThemeChange } = renderSelector("forest");
const trigger = screen.getByRole("button", { name: "Forest" });
const trigger = screen.getByRole("button", { name: /current theme dark \/ forest/i });
fireEvent.click(trigger);
const listbox = screen.getByRole("listbox", { name: "Color theme" });
expect(within(listbox).getAllByRole("option")).toHaveLength(COLOR_THEMES.length);
expect(renderedThemeIds(listbox)).toEqual(EXPECTED_THEME_IDS);
const oceanOption = within(listbox).getByRole("option", { name: "Ocean" });
expect(oceanOption.querySelector(".theme-swatch-ocean")).toBeTruthy();
fireEvent.click(oceanOption);
@@ -70,18 +82,19 @@ describe("ThemeSelector", () => {
["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 });
const trigger = screen.getByRole("button", { name: new RegExp(`current theme dark / ${expectedLabel}`, "i") });
expect(trigger).toHaveTextContent(expectedLabel);
expect(trigger).toHaveTextContent(`Dark / ${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", () => {
it("uses one merged current-theme trigger and renders Shadcn Custom's picker exactly once", () => {
renderSelector("shadcn-custom");
expect(document.querySelector(".theme-current-preview")).toBeNull();
expect(screen.getAllByTestId("shadcn-color-picker")).toHaveLength(1);
expect(screen.getByRole("button", { name: "Shadcn Custom" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /current theme dark \/ shadcn custom/i })).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, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { CommandCenterControls } from "../CommandCenterControls";
import { ConfirmDialogProvider } from "../../../hooks/useConfirm";
@@ -73,6 +73,17 @@ function expectCommandCenterUseOffset(testId: string, ratio: number) {
);
}
// FNXC:Theme 2026-07-16-14:30: FN-8146 pins the historical Settings-grid set, including restored shadcn-mono, so a removal from COLOR_THEMES cannot make the all-themes checks pass circularly.
const EXPECTED_THEME_IDS = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'] as const;
function renderedThemeIds(listbox: HTMLElement) {
return within(listbox).getAllByRole("option").map((option) => {
const swatch = option.querySelector<HTMLElement>(".theme-option-swatch");
expect(swatch).toBeTruthy();
return [...(swatch?.classList ?? [])].find((className) => className.startsWith("theme-swatch-"))?.replace("theme-swatch-", "");
});
}
describe("CommandCenterControls concurrency markers", () => {
beforeEach(() => {
legacyMocks.fetchConfig.mockResolvedValue({ maxConcurrent: 12, rootDir: "/workspace/project" });
@@ -94,6 +105,8 @@ describe("CommandCenterControls concurrency markers", () => {
expect(themeCard).toContainElement(trigger);
fireEvent.click(trigger);
const listbox = screen.getByRole("listbox", { name: "Color theme" });
expect(renderedThemeIds(listbox)).toEqual(EXPECTED_THEME_IDS);
fireEvent.click(screen.getByRole("option", { name: "Ocean" }));
expect(onColorThemeChange).toHaveBeenCalledWith("ocean");

View File

@@ -85,6 +85,8 @@ export const COLOR_THEMES: { value: ColorTheme; label: string; className: string
{ value: "shadcn-pink", label: "Shadcn Pink", className: "theme-swatch-shadcn-pink" },
{ value: "shadcn-orange", label: "Shadcn Orange", className: "theme-swatch-shadcn-orange" },
{ value: "shadcn-yellow", label: "Shadcn Yellow", className: "theme-swatch-shadcn-yellow" },
/* FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores the historical Shadcn Mono selector after its FN-6813 migration to Mono Red, so operators can still choose the id shown by the old Settings grid. It intentionally shares Mono Red's tokens. */
{ value: "shadcn-mono", label: "Shadcn Mono", className: "theme-swatch-shadcn-mono" },
{ value: "shadcn-mono-red", label: "Shadcn Mono Red", className: "theme-swatch-shadcn-mono-red" },
{ value: "shadcn-mono-blue", label: "Shadcn Mono Blue", className: "theme-swatch-shadcn-mono-blue" },
{ value: "shadcn-mono-green", label: "Shadcn Mono Green", className: "theme-swatch-shadcn-mono-green" },

View File

@@ -781,13 +781,13 @@ describe("useTheme", () => {
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("remaps legacy shadcn mono color theme from localStorage", () => {
it("preserves restored shadcn mono color theme from localStorage", () => {
localStorageMock[COLOR_THEME_STORAGE_KEY] = "shadcn-mono";
const { result } = renderHook(() => useTheme());
expect(result.current.colorTheme).toBe("shadcn-mono-red");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-mono-red");
expect(result.current.colorTheme).toBe("shadcn-mono");
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-mono");
});
it("preserves explicit Glass and Glass Silver color themes from localStorage", () => {
@@ -946,7 +946,7 @@ describe("getThemeInitScript", () => {
expect(script).toContain(theme);
});
expect(script).toContain("validThemes");
expect(script).toContain("if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';");
expect(script).not.toContain("colorTheme === 'shadcn-mono'");
expect(script).toContain("colorTheme = 'shadcn-ember'");
});

View File

@@ -90,9 +90,8 @@ function readCachedThemeMode(): ThemeMode {
function readCachedColorTheme(): ColorTheme {
if (!isBrowser) return DEFAULT_COLOR_THEME;
try {
let colorTheme = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
// FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 keeps existing shadcn-mono users on the renamed red mono variant before the validity guard would otherwise fall back to default.
if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';
const colorTheme = localStorage.getItem(COLOR_THEME_STORAGE_KEY);
// FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores Shadcn Mono as a persisted theme, so cached selections must pass validation without remapping to Mono Red.
if (colorTheme && VALID_COLOR_THEMES.includes(colorTheme as ColorTheme)) {
return colorTheme as ColorTheme;
}
@@ -476,8 +475,7 @@ export function getThemeInitScript(): string {
var colorTheme = localStorage.getItem('${COLOR_THEME_STORAGE_KEY}') || '${DEFAULT_COLOR_THEME}';
var validThemes = ${JSON.stringify(VALID_COLOR_THEMES)};
// FNXC:DashboardTheming 2026-06-30-00:00: Unset startup theme is Shadcn Ember; explicit stored legacy ids such as "default" and "ocean" remain valid and must not be migrated.
// FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 remaps the legacy mono id before bootstrap validation so persisted users keep the red mono accent.
if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';
// FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 keeps Shadcn Mono's stored id intact so pre-hydration and React hydration select the same restored theme.
if (!validThemes.includes(colorTheme)) {
colorTheme = '${DEFAULT_COLOR_THEME}';
}

View File

@@ -164,10 +164,9 @@
mode = 'system';
}
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'shadcn-ember';
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'];
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'slate', 'ash', 'air', 'graphite', 'silver', 'solarized', 'factory', 'factory-mono', 'ayu', 'one-dark', 'nord', 'dracula', 'gruvbox', 'tokyo-night', 'catppuccin-mocha', 'github-dark', 'everforest', 'rose-pine', 'kanagawa', 'night-owl', 'palenight', 'monokai-pro', 'slime', 'brutalist', 'neon-city', 'parchment', 'terminal', 'glass', 'glass-silver', 'horizon', 'vitesse', 'outrun', 'snazzy', 'porple', 'espresso', 'mars', 'poimandres', 'ember', 'rust', 'copper', 'foundry', 'carbon', 'sandstone', 'lagoon', 'frost', 'lavender', 'neon-bloom', 'sepia', 'cobalt', 'clay', 'moss', 'shadcn', 'shadcn-ember', 'shadcn-custom', 'shadcn-blue', 'shadcn-green', 'shadcn-red', 'shadcn-purple', 'shadcn-pink', 'shadcn-orange', 'shadcn-yellow', 'shadcn-mono', 'shadcn-mono-red', 'shadcn-mono-blue', 'shadcn-mono-green', 'shadcn-mono-purple', 'shadcn-mono-pink', 'shadcn-mono-orange', 'shadcn-mono-yellow', 'shadcn-black', 'shadcn-gray', 'shadcn-gray-blue'];
// FNXC:DashboardTheming 2026-06-30-00:00: Unset startup theme is Shadcn Ember; explicit stored legacy ids such as "default" and "ocean" remain valid and must not be migrated.
// FNXC:DashboardTheming 2026-06-20-00:00: FN-6813 remaps the legacy mono id before pre-hydration validation so persisted users keep the red mono accent.
if (colorTheme === 'shadcn-mono') colorTheme = 'shadcn-mono-red';
// FNXC:DashboardTheming 2026-07-16-14:30: FN-8146 restores Shadcn Mono as a distinct persisted choice, so bootstrap must retain its stored id instead of remapping it to Mono Red.
if (!validThemes.includes(colorTheme)) {
colorTheme = 'shadcn-ember';
}

View File

@@ -2575,8 +2575,12 @@ FN-6756 adds shadcn color-family themes as exact zinc-neutral shadcn variants wi
/*
FNXC:DashboardTheming 2026-06-20-00:00:
FN-6813 makes the shadcn mono theme a color family: shadcn-mono-red preserves the renamed legacy red accent while the other mono variants reuse the same zinc-neutral tokens and swap only accent/CTA values. Legacy stored shadcn-mono selections remap in bootstrap/theme hooks so users keep the red mono theme instead of falling back to default.
FN-6813 makes the shadcn mono theme a color family: shadcn-mono-red preserves the renamed red accent while the other mono variants reuse the same zinc-neutral tokens and swap only accent/CTA values.
FNXC:DashboardTheming 2026-07-16-14:30:
FN-8146 restores Shadcn Mono as a persistent option that shares Mono Red tokens. Keep both dark and light selectors fully scoped so neither theme leaks its light ramp into the other mode.
*/
[data-color-theme="shadcn-mono"],
[data-color-theme="shadcn-mono-red"] {
--bg: #09090b;
--surface: #0c0c0e;
@@ -2649,6 +2653,7 @@ FN-6813 makes the shadcn mono theme a color family: shadcn-mono-red preserves th
}
[data-color-theme="shadcn-mono"][data-theme="light"],
[data-color-theme="shadcn-mono-red"][data-theme="light"] {
--bg: #ffffff;
--surface: #ffffff;
@@ -3786,6 +3791,7 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-pink"] .card.agent-active,
[data-color-theme="shadcn-orange"] .card.agent-active,
[data-color-theme="shadcn-yellow"] .card.agent-active,
[data-color-theme="shadcn-mono"] .card.agent-active,
[data-color-theme="shadcn-mono-red"] .card.agent-active,
[data-color-theme="shadcn-mono-blue"] .card.agent-active,
[data-color-theme="shadcn-mono-green"] .card.agent-active,
@@ -3808,6 +3814,7 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-pink"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-orange"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-yellow"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-mono"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-mono-red"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-mono-blue"][data-theme="light"] .card.agent-active,
[data-color-theme="shadcn-mono-green"][data-theme="light"] .card.agent-active,
@@ -3830,6 +3837,7 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-pink"] .btn,
[data-color-theme="shadcn-orange"] .btn,
[data-color-theme="shadcn-yellow"] .btn,
[data-color-theme="shadcn-mono"] .btn,
[data-color-theme="shadcn-mono-red"] .btn,
[data-color-theme="shadcn-mono-blue"] .btn,
[data-color-theme="shadcn-mono-green"] .btn,
@@ -3859,6 +3867,8 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-orange"] .btn-task-create,
[data-color-theme="shadcn-yellow"] .btn-primary,
[data-color-theme="shadcn-yellow"] .btn-task-create,
[data-color-theme="shadcn-mono"] .btn-primary,
[data-color-theme="shadcn-mono"] .btn-task-create,
[data-color-theme="shadcn-mono-red"] .btn-primary,
[data-color-theme="shadcn-mono-red"] .btn-task-create,
[data-color-theme="shadcn-mono-blue"] .btn-primary,
@@ -3899,6 +3909,8 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-orange"] .btn-task-create:hover,
[data-color-theme="shadcn-yellow"] .btn-primary:hover,
[data-color-theme="shadcn-yellow"] .btn-task-create:hover,
[data-color-theme="shadcn-mono"] .btn-primary:hover,
[data-color-theme="shadcn-mono"] .btn-task-create:hover,
[data-color-theme="shadcn-mono-red"] .btn-primary:hover,
[data-color-theme="shadcn-mono-red"] .btn-task-create:hover,
[data-color-theme="shadcn-mono-blue"] .btn-primary:hover,
@@ -3939,6 +3951,8 @@ FN-6815 adds Shadcn Gray Blue as the slate-neutral shadcn variant: the whole sur
[data-color-theme="shadcn-orange"] .column,
[data-color-theme="shadcn-yellow"] .card,
[data-color-theme="shadcn-yellow"] .column,
[data-color-theme="shadcn-mono"] .card,
[data-color-theme="shadcn-mono"] .column,
[data-color-theme="shadcn-mono-red"] .card,
[data-color-theme="shadcn-mono-red"] .column,
[data-color-theme="shadcn-mono-blue"] .card,

View File

@@ -85,6 +85,7 @@
"shadcn-pink",
"shadcn-orange",
"shadcn-yellow",
"shadcn-mono",
"shadcn-mono-red",
"shadcn-mono-blue",
"shadcn-mono-green",