FN-7287: collapse custom shadcn color controls

Collapse dashboard shadcn color overrides behind an accessible shared toggle by default.

- Add an icon-only expand/collapse affordance with aria state for the shared shadcn color picker.
- Move reset and color override inputs into the expanded controls panel with responsive styling.
- Cover collapsed and expanded picker behavior across theme selector surfaces.
- Add a patch changeset for the dashboard theme control fix.

Files changed:
 .changeset/fn-7287-collapse-shadcn-colors.md       |   7 ++
 .../dashboard/app/components/ShadcnColorPicker.css |  26 +++++-
 .../dashboard/app/components/ShadcnColorPicker.tsx | 104 +++++++++++++--------
 .../__tests__/ShadcnColorPicker.test.tsx           |  48 +++++++++-
 .../components/__tests__/ThemeDropdown.test.tsx    |  11 +++
 .../components/__tests__/ThemeSelector.test.tsx    |  11 +++
 6 files changed, 163 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-7287
Fusion-Task-Lineage: 315ef23e-07e1-473c-8187-a40d0516960e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 10:26:23 -07:00
parent a92f15efaa
commit 6b3eec0a53
6 changed files with 163 additions and 44 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Collapse custom shadcn color controls by default in dashboard theme surfaces.
category: fix
dev: Adds an accessible shared show/collapse affordance for the custom shadcn color picker.

View File

@@ -14,6 +14,10 @@
gap: var(--space-md);
}
.shadcn-color-picker-heading {
min-width: 0;
}
.shadcn-color-picker-title {
margin: 0;
color: var(--text);
@@ -27,6 +31,21 @@
font-size: var(--font-size-xs);
}
.shadcn-color-picker-toggle {
flex: 0 0 auto;
}
.shadcn-color-picker-controls-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.shadcn-color-picker-actions {
display: flex;
justify-content: flex-end;
}
.shadcn-color-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 7), 1fr));
@@ -85,11 +104,16 @@
@media (max-width: 768px) {
.shadcn-color-picker-header,
.shadcn-color-picker-row,
.shadcn-color-picker-controls {
.shadcn-color-picker-controls,
.shadcn-color-picker-actions {
align-items: stretch;
flex-direction: column;
}
.shadcn-color-picker-toggle {
align-self: flex-start;
}
.shadcn-color-picker-grid {
grid-template-columns: 1fr;
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useId, useMemo, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
SHADCN_CUSTOM_COLOR_TOKENS,
@@ -28,6 +29,9 @@ function toColorInputValue(value: string): string {
/*
FNXC:Theme 2026-06-20-18:38:
The shadcn custom picker is shared by Settings and Command Center; it only edits the sanitized token→hex override map while the parent surfaces decide visibility for shadcn-custom so no other theme receives inline overrides.
FNXC:Theme 2026-06-30-00:00:
Custom shadcn controls default collapsed to keep dashboard theme surfaces compact while leaving the override editor discoverable through an accessible icon affordance wherever this shared picker renders.
*/
export function ShadcnColorPicker({
value = {},
@@ -35,16 +39,23 @@ export function ShadcnColorPicker({
resolvedThemeMode = "dark",
}: ShadcnColorPickerProps) {
const { t } = useTranslation("app");
const [expanded, setExpanded] = useState(false);
const controlsId = useId();
const sanitizedValue = useMemo(() => sanitizeShadcnCustomColors(value), [value]);
const updateToken = (cssVar: string, nextValue: string) => {
onChange(sanitizeShadcnCustomColors({ ...sanitizedValue, [cssVar]: nextValue }));
};
const toggleLabel = expanded
? t("theme.shadcnCustom.collapse", "Collapse custom colors")
: t("theme.shadcnCustom.show", "Show custom colors");
const ToggleIcon = expanded ? ChevronUp : ChevronDown;
return (
<section className="shadcn-color-picker card" data-testid="shadcn-color-picker" aria-labelledby="shadcn-color-picker-title">
<div className="shadcn-color-picker-header">
<div>
<div className="shadcn-color-picker-heading">
<h3 id="shadcn-color-picker-title" className="shadcn-color-picker-title">
{t("theme.shadcnCustom.title", "Custom shadcn colors")}
</h3>
@@ -52,44 +63,61 @@ export function ShadcnColorPicker({
{t("theme.shadcnCustom.description", "Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")}
</p>
</div>
<button type="button" className="btn" onClick={() => onChange({})}>
{t("theme.shadcnCustom.reset", "Reset custom colors")}
<button
type="button"
className="btn btn-icon btn-sm shadcn-color-picker-toggle"
aria-expanded={expanded}
aria-controls={controlsId}
aria-label={toggleLabel}
title={toggleLabel}
onClick={() => setExpanded((current) => !current)}
>
<ToggleIcon size={16} aria-hidden="true" />
</button>
</div>
<div className="shadcn-color-picker-grid">
{SHADCN_CUSTOM_COLOR_TOKENS.map((token) => {
const fallback = getShadcnCustomDefaultValue(token, resolvedThemeMode);
const currentValue = sanitizedValue[token.cssVar] ?? fallback;
const inputId = `shadcn-color-${token.cssVar.replace(/^--/, "").replace(/[^a-z0-9]+/gi, "-")}`;
return (
<div className="shadcn-color-picker-row" key={token.cssVar} data-testid={`shadcn-color-${token.cssVar}`}>
<label className="shadcn-color-picker-label" htmlFor={inputId}>
<span>{t(`theme.shadcnCustom.token.${token.cssVar}`, token.label)}</span>
<code>{token.cssVar}</code>
</label>
<div className="shadcn-color-picker-controls">
<input
aria-label={t("theme.shadcnCustom.colorInput", "Pick {{label}} color", { label: token.label })}
className="shadcn-color-picker-native"
type="color"
value={toColorInputValue(currentValue)}
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
/>
<input
id={inputId}
aria-label={t("theme.shadcnCustom.hexInput", "{{label}} hex color", { label: token.label })}
className="input shadcn-color-picker-hex"
type="text"
inputMode="text"
spellCheck={false}
value={currentValue}
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
/>
</div>
</div>
);
})}
</div>
{expanded ? (
<div id={controlsId} className="shadcn-color-picker-controls-panel" data-testid="shadcn-color-picker-controls">
<div className="shadcn-color-picker-actions">
<button type="button" className="btn btn-sm" onClick={() => onChange({})}>
{t("theme.shadcnCustom.reset", "Reset custom colors")}
</button>
</div>
<div className="shadcn-color-picker-grid">
{SHADCN_CUSTOM_COLOR_TOKENS.map((token) => {
const fallback = getShadcnCustomDefaultValue(token, resolvedThemeMode);
const currentValue = sanitizedValue[token.cssVar] ?? fallback;
const inputId = `shadcn-color-${token.cssVar.replace(/^--/, "").replace(/[^a-z0-9]+/gi, "-")}`;
return (
<div className="shadcn-color-picker-row" key={token.cssVar} data-testid={`shadcn-color-${token.cssVar}`}>
<label className="shadcn-color-picker-label" htmlFor={inputId}>
<span>{t(`theme.shadcnCustom.token.${token.cssVar}`, token.label)}</span>
<code>{token.cssVar}</code>
</label>
<div className="shadcn-color-picker-controls">
<input
aria-label={t("theme.shadcnCustom.colorInput", "Pick {{label}} color", { label: token.label })}
className="shadcn-color-picker-native"
type="color"
value={toColorInputValue(currentValue)}
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
/>
<input
id={inputId}
aria-label={t("theme.shadcnCustom.hexInput", "{{label}} hex color", { label: token.label })}
className="input shadcn-color-picker-hex"
type="text"
inputMode="text"
spellCheck={false}
value={currentValue}
onChange={(event) => updateToken(token.cssVar, event.currentTarget.value)}
/>
</div>
</div>
);
})}
</div>
</div>
) : null}
</section>
);
}

View File

@@ -1,30 +1,57 @@
import { readFileSync } from "node:fs";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ShadcnColorPicker } from "../ShadcnColorPicker";
import { SHADCN_CUSTOM_COLOR_TOKENS } from "../shadcnCustomColors";
function expandCustomColors() {
const toggle = screen.getByRole("button", { name: "Show custom colors" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveAttribute("aria-controls");
fireEvent.click(toggle);
expect(screen.getByRole("button", { name: "Collapse custom colors" })).toHaveAttribute("aria-expanded", "true");
}
describe("ShadcnColorPicker", () => {
it("renders one color control row per customizable token", () => {
it("is collapsed by default and toggles all custom color controls without dangling shells", () => {
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="dark" />);
expect(screen.getByTestId("shadcn-color-picker")).toBeDefined();
expect(screen.getByText("Custom shadcn colors")).toBeDefined();
expect(screen.getByText("Override shadcn design tokens with hex colors. Blank tokens use the theme defaults.")).toBeDefined();
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
expect(screen.queryByTestId(`shadcn-color-${token.cssVar}`)).toBeNull();
}
expandCustomColors();
expect(screen.getByTestId("shadcn-color-picker-controls")).toBeDefined();
expect(screen.getByRole("button", { name: "Reset custom colors" })).toBeDefined();
for (const token of SHADCN_CUSTOM_COLOR_TOKENS) {
expect(screen.getByTestId(`shadcn-color-${token.cssVar}`)).toBeDefined();
expect(screen.getByText(token.cssVar)).toBeDefined();
}
fireEvent.click(screen.getByRole("button", { name: "Collapse custom colors" }));
expect(screen.getByRole("button", { name: "Show custom colors" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("shadcn-color-picker-controls")).toBeNull();
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
});
it("uses light defaults when no override exists", () => {
it("uses light defaults when no override exists after expansion", () => {
render(<ShadcnColorPicker value={{}} onChange={vi.fn()} resolvedThemeMode="light" />);
expandCustomColors();
const bgRow = screen.getByTestId("shadcn-color---bg");
expect(within(bgRow).getByRole("textbox")).toHaveValue("#ffffff");
});
it("emits sanitized changes and rejects invalid hex input", () => {
it("emits sanitized changes and rejects invalid hex input after expansion", () => {
const onChange = vi.fn();
render(<ShadcnColorPicker value={{}} onChange={onChange} resolvedThemeMode="dark" />);
expandCustomColors();
const accentRow = screen.getByTestId("shadcn-color---accent");
fireEvent.change(within(accentRow).getByRole("textbox"), { target: { value: "red" } });
expect(onChange).toHaveBeenLastCalledWith({});
@@ -33,18 +60,29 @@ describe("ShadcnColorPicker", () => {
expect(onChange).toHaveBeenLastCalledWith({ "--accent": "#FF8800" });
});
it("normalizes short hex values for the native color input", () => {
it("normalizes short hex values for the native color input after expansion", () => {
render(<ShadcnColorPicker value={{ "--accent": "#fff" }} onChange={vi.fn()} resolvedThemeMode="dark" />);
expandCustomColors();
const accentRow = screen.getByTestId("shadcn-color---accent");
expect(within(accentRow).getByLabelText("Pick Accent color")).toHaveValue("#ffffff");
});
it("reset clears all custom color overrides", () => {
it("reset clears all custom color overrides after expansion", () => {
const onChange = vi.fn();
render(<ShadcnColorPicker value={{ "--accent": "#123456" }} onChange={onChange} resolvedThemeMode="dark" />);
expect(screen.queryByRole("button", { name: "Reset custom colors" })).toBeNull();
expandCustomColors();
fireEvent.click(screen.getByRole("button", { name: "Reset custom colors" }));
expect(onChange).toHaveBeenCalledWith({});
});
it("keeps mobile collapsed and expanded layout rules tokenized", () => {
const css = readFileSync("app/components/ShadcnColorPicker.css", "utf8");
expect(css).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?\.shadcn-color-picker-toggle \{[\s\S]*?align-self: flex-start;/);
expect(css).toMatch(/\.shadcn-color-picker-controls-panel \{[\s\S]*?gap: var\(--space-sm\);/);
expect(css).not.toMatch(/#[\da-f]{3,8}\b|rgb\(/i);
});
});

View File

@@ -77,6 +77,17 @@ describe("ThemeDropdown", () => {
/>,
);
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");
});
it("renders compact theme mode controls when mode props are supplied", () => {

View File

@@ -644,6 +644,17 @@ describe("ThemeSelector", () => {
/>
);
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");
});