FN-7458: default startup theme mode to system
Default fresh dashboard startup and appearance reset to System mode while keeping Shadcn Ember as the color theme. - Set global themeMode defaults and Settings fallback props to system. - Resolve pre-hydration web and desktop startup from prefers-color-scheme, including invalid cache and fallback paths. - Update Appearance reset behavior, focused theme tests, docs, and the release changeset. Files changed: .changeset/fn-7458-system-theme-default.md | 7 ++ docs/dashboard-guide.md | 3 +- docs/settings-reference.md | 4 +- .../core/src/__tests__/global-settings.test.ts | 17 +++- packages/core/src/__tests__/store-settings.test.ts | 6 +- packages/core/src/settings-schema.ts | 6 +- .../app/__tests__/index-html-theme-link.test.ts | 18 ++++ .../dashboard/app/components/SettingsModal.tsx | 8 +- .../dashboard/app/components/ThemeSelector.tsx | 6 +- .../components/__tests__/ThemeDropdown.test.tsx | 9 +- .../components/__tests__/ThemeSelector.test.tsx | 2 +- .../dashboard/app/hooks/__tests__/useTheme.test.ts | 111 ++++++++++++++++++++- packages/dashboard/app/hooks/useTheme.ts | 21 +++- packages/dashboard/app/index.html | 13 ++- packages/desktop/src/renderer/index.html | 13 ++- 15 files changed, 211 insertions(+), 33 deletions(-) Fusion-Task-Id: FN-7458 Fusion-Task-Lineage: fb6184b4-9a6a-4131-9f97-f8cd12ef904c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7458-system-theme-default.md
Normal file
7
.changeset/fn-7458-system-theme-default.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Default fresh startup and theme reset to System mode.
|
||||
category: fix
|
||||
dev: Fresh global settings, pre-hydration scripts, and Appearance reset now keep Shadcn Ember while following OS light/dark preference.
|
||||
@@ -1620,8 +1620,9 @@ Non-Command-Center dashboard CSS uses `--text` as the canonical primary text tok
|
||||
|
||||
<!-- FNXC:DashboardTheming 2026-06-21-00:00: FN-6840 synced the user-facing theme docs to the shipped expanded Shadcn family, the Shadcn Custom color-picker preset, and the sidebar accent behavior that follows each theme's --accent token. -->
|
||||
<!-- 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. -->
|
||||
|
||||
Dark/light modes via `data-theme`; 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.
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `themeMode` | `"dark" \| "light" \| "system"` | `"dark"` | Dashboard theme mode. |
|
||||
| `colorTheme` | `ColorTheme` | `"default"` | Dashboard color theme preset. Use `"shadcn-custom"` to show the custom shadcn color picker in Settings → Appearance and the Command Center theme card. |
|
||||
| `themeMode` | `"dark" \| "light" \| "system"` | `"system"` | Dashboard theme mode. Fresh installs follow the operating system light/dark preference until the user chooses Light, Dark, or System. |
|
||||
| `colorTheme` | `ColorTheme` | `"shadcn-ember"` | Dashboard color theme preset. Use `"shadcn-custom"` to show the custom shadcn color picker in Settings → Appearance and the Command Center theme card. |
|
||||
| `shadcnCustomColors` | `Record<string, string>` | `undefined` | Optional shadcn design-token override map for `"shadcn-custom"` only. Keys are CSS token names such as `--accent`, `--bg`, `--surface`, `--card`, `--border`, `--text`, `--text-muted`, workflow status tokens, and `--color-success`/`--color-warning`/`--color-error`; values must be sanitized `#RGB` or `#RRGGBB` hex colors. Missing or invalid entries fall back to the `shadcn-custom` base defaults and are not applied to other themes. |
|
||||
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
|
||||
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
|
||||
|
||||
@@ -64,7 +64,7 @@ describe("GlobalSettingsStore", () => {
|
||||
|
||||
const raw = await readFile(join(dir, "settings.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
expect(parsed.themeMode).toBe("dark");
|
||||
expect(parsed.themeMode).toBe("system");
|
||||
expect(parsed.colorTheme).toBe("shadcn-ember");
|
||||
expect(parsed.ntfyEnabled).toBe(false);
|
||||
});
|
||||
@@ -86,6 +86,13 @@ describe("GlobalSettingsStore", () => {
|
||||
expect(settings.themeMode).toBe("light");
|
||||
});
|
||||
|
||||
it.each(["dark", "light", "system"] as const)("preserves explicit %s theme mode choices", async (themeMode) => {
|
||||
await store.init();
|
||||
await store.updateSettings({ themeMode });
|
||||
|
||||
await expect(store.getSettings()).resolves.toMatchObject({ themeMode });
|
||||
});
|
||||
|
||||
it("adopts the legacy ~/.pi/kb directory when ~/.fusion does not exist", async () => {
|
||||
const homeDir = makeTmpDir();
|
||||
process.env.HOME = homeDir;
|
||||
@@ -233,7 +240,7 @@ describe("GlobalSettingsStore", () => {
|
||||
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
|
||||
expect(settings.ntfyEnabled).toBe(true);
|
||||
expect(settings.ntfyTopic).toBe("my-topic");
|
||||
expect(settings.themeMode).toBe("dark"); // preserved default
|
||||
expect(settings.themeMode).toBe("system"); // preserved default
|
||||
});
|
||||
|
||||
it("does not overwrite an existing invalid settings file with defaults", async () => {
|
||||
@@ -486,7 +493,7 @@ describe("GlobalSettingsStore", () => {
|
||||
|
||||
const result = await store.readRaw();
|
||||
expect(result.futureField).toBe("hello");
|
||||
expect(result.themeMode).toBe("dark");
|
||||
expect(result.themeMode).toBe("system");
|
||||
});
|
||||
|
||||
it("readRaw returns empty object for missing file", async () => {
|
||||
@@ -555,7 +562,7 @@ describe("GlobalSettingsStore", () => {
|
||||
|
||||
// First call populates cache
|
||||
const first = await store.getSettings();
|
||||
expect(first.themeMode).toBe("dark");
|
||||
expect(first.themeMode).toBe("system");
|
||||
|
||||
// Update settings
|
||||
await store.updateSettings({ themeMode: "light" });
|
||||
@@ -865,7 +872,7 @@ describe("GlobalSettingsStore", () => {
|
||||
// Settings file should exist with defaults but no model fields persisted
|
||||
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
|
||||
// Only default theme fields should be present
|
||||
expect(raw.themeMode).toBe("dark");
|
||||
expect(raw.themeMode).toBe("system");
|
||||
expect(raw.colorTheme).toBe("shadcn-ember");
|
||||
// Model fields should not be persisted
|
||||
expect(raw.defaultProvider).toBeUndefined();
|
||||
|
||||
@@ -1421,7 +1421,7 @@ describe("TaskStore", () => {
|
||||
describe("global/project settings merging", () => {
|
||||
it("getSettings returns global defaults when no overrides exist", async () => {
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.themeMode).toBe("dark");
|
||||
expect(settings.themeMode).toBe("system");
|
||||
expect(settings.colorTheme).toBe("shadcn-ember");
|
||||
expect(settings.maxConcurrent).toBe(2);
|
||||
});
|
||||
@@ -1468,7 +1468,7 @@ describe("TaskStore", () => {
|
||||
const settings = await harness.store().getSettings();
|
||||
expect(settings.maxConcurrent).toBe(5);
|
||||
// themeMode should still be the global default, not "light"
|
||||
expect(settings.themeMode).toBe("dark");
|
||||
expect(settings.themeMode).toBe("system");
|
||||
|
||||
// Verify the project config doesn't contain themeMode
|
||||
const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8");
|
||||
@@ -1766,7 +1766,7 @@ describe("TaskStore", () => {
|
||||
expect(settings.maxWorktrees).toBe(4); // default
|
||||
expect(settings.pollIntervalMs).toBe(15000); // default
|
||||
// Global settings should still be present
|
||||
expect(settings.themeMode).toBe("dark"); // global default
|
||||
expect(settings.themeMode).toBe("system"); // global default
|
||||
});
|
||||
|
||||
it("includes global settings merged with project settings", async () => {
|
||||
|
||||
@@ -63,7 +63,11 @@ type ProjectSettingsSchema = Omit<ProjectSettings, MovedProjectSettingsKey>;
|
||||
|
||||
/** Default values for global (user-level) settings. */
|
||||
export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
themeMode: "dark",
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-07-03-00:00:
|
||||
Fresh installs must follow the operating system theme until the user explicitly chooses Light, Dark, or System. Keep this global default aligned with dashboard and desktop pre-hydration fallbacks.
|
||||
*/
|
||||
themeMode: "system",
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-06-30-00:00:
|
||||
New users and unset installs should start on Shadcn Ember. Existing users who explicitly stored colorTheme "default", "ocean", or another valid theme must remain on that selection, so the ids stay valid and only the absence/default seed changes to "shadcn-ember".
|
||||
|
||||
@@ -25,6 +25,24 @@ describe("index.html theme-data boot contract", () => {
|
||||
expect(script).toContain("style.fontSize");
|
||||
});
|
||||
|
||||
it("defaults web pre-hydration to system theme mode", () => {
|
||||
expect(script).toContain("localStorage.getItem('kb-dashboard-theme-mode') || 'system'");
|
||||
expect(script).toContain("var validModes = ['dark', 'light', 'system']");
|
||||
expect(script).toContain("mode = 'system'");
|
||||
expect(script).not.toContain("localStorage.getItem('kb-dashboard-theme-mode') || 'dark'");
|
||||
expect(script).toContain("fallbackSystemDark ? 'dark' : 'light'");
|
||||
});
|
||||
|
||||
it("keeps desktop renderer pre-hydration aligned with system default", () => {
|
||||
const desktopHtml = readFileSync(resolve(PACKAGE_ROOT, "../desktop/src/renderer/index.html"), "utf8");
|
||||
|
||||
expect(desktopHtml).toContain('localStorage.getItem("kb-dashboard-theme-mode") || "system"');
|
||||
expect(desktopHtml).toContain('var validModes = ["dark", "light", "system"]');
|
||||
expect(desktopHtml).toContain('mode = "system"');
|
||||
expect(desktopHtml).not.toContain('localStorage.getItem("kb-dashboard-theme-mode") || "dark"');
|
||||
expect(desktopHtml).toContain('fallbackSystemDark ? "dark" : "light"');
|
||||
});
|
||||
|
||||
it("keeps themeDataUrl resolution branches", () => {
|
||||
expect(script).toContain("http://");
|
||||
expect(script).toContain("https://");
|
||||
|
||||
@@ -679,8 +679,12 @@ export function SettingsModal({
|
||||
addToast,
|
||||
projectId,
|
||||
initialSection,
|
||||
themeMode = "dark",
|
||||
colorTheme = "ocean",
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-07-03-00:00:
|
||||
Settings/onboarding surfaces that render before App threads persisted appearance settings should mirror fresh startup defaults: System mode with Shadcn Ember.
|
||||
*/
|
||||
themeMode = "system",
|
||||
colorTheme = "shadcn-ember",
|
||||
onThemeModeChange,
|
||||
onColorThemeChange,
|
||||
dashboardFontScalePct = 100,
|
||||
|
||||
@@ -41,10 +41,10 @@ export function ThemeSelector({
|
||||
}: ThemeSelectorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const handleReset = useCallback(() => {
|
||||
onThemeModeChange("dark");
|
||||
onThemeModeChange("system");
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-06-30-00:00:
|
||||
Reset to defaults must match the new unset-install fallback so Settings restores Shadcn Ember without migrating explicit Ocean or legacy theme choices.
|
||||
FNXC:DashboardTheming 2026-07-03-00:00:
|
||||
Reset to defaults must match fresh-install behavior: System mode follows the OS preference while Shadcn Ember stays the default color theme, without migrating explicit Ocean or legacy theme choices.
|
||||
*/
|
||||
onColorThemeChange("shadcn-ember");
|
||||
onDashboardFontScaleChange(100);
|
||||
|
||||
@@ -120,13 +120,18 @@ describe("ThemeDropdown", () => {
|
||||
render(
|
||||
<ThemeDropdown
|
||||
colorTheme="default"
|
||||
themeMode="dark"
|
||||
themeMode="system"
|
||||
onColorThemeChange={vi.fn()}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /light/i }));
|
||||
const modeGroup = screen.getByRole("radiogroup", { name: /theme mode/i });
|
||||
expect(within(modeGroup).getByRole("button", { name: /light/i })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(modeGroup).getByRole("button", { name: /dark/i })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(modeGroup).getByRole("button", { name: /system/i })).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.click(within(modeGroup).getByRole("button", { name: /light/i }));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
|
||||
@@ -641,7 +641,7 @@ describe("ThemeSelector", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Reset to default theme"));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("dark");
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith("system");
|
||||
expect(onColorThemeChange).toHaveBeenCalledWith("shadcn-ember");
|
||||
});
|
||||
|
||||
|
||||
@@ -100,11 +100,26 @@ describe("useTheme", () => {
|
||||
document.querySelectorAll('link[id="theme-data"]').forEach((link) => link.remove());
|
||||
});
|
||||
|
||||
it("initializes with default values when localStorage is empty", () => {
|
||||
it("initializes with system theme and Shadcn Ember color defaults when localStorage is empty", () => {
|
||||
currentSystemDark = false;
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.themeMode).toBe("dark");
|
||||
expect(result.current.themeMode).toBe("system");
|
||||
expect(result.current.resolvedThemeMode).toBe("light");
|
||||
expect(result.current.colorTheme).toBe("shadcn-ember");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-ember");
|
||||
});
|
||||
|
||||
it("resolves fresh system theme to dark when the OS preference is dark", () => {
|
||||
currentSystemDark = true;
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.themeMode).toBe("system");
|
||||
expect(result.current.resolvedThemeMode).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("initializes from localStorage", () => {
|
||||
@@ -138,7 +153,7 @@ describe("useTheme", () => {
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.themeMode).toBe("dark");
|
||||
expect(result.current.themeMode).toBe("system");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.themeMode).toBe("light");
|
||||
@@ -756,11 +771,14 @@ describe("useTheme", () => {
|
||||
});
|
||||
|
||||
it("ignores invalid theme mode in localStorage", () => {
|
||||
currentSystemDark = false;
|
||||
localStorageMock[THEME_MODE_STORAGE_KEY] = "invalid";
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.themeMode).toBe("dark");
|
||||
expect(result.current.themeMode).toBe("system");
|
||||
expect(result.current.resolvedThemeMode).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("remaps legacy shadcn mono color theme from localStorage", () => {
|
||||
@@ -819,7 +837,7 @@ describe("useTheme", () => {
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.themeMode).toBe("dark");
|
||||
expect(result.current.themeMode).toBe("system");
|
||||
expect(result.current.colorTheme).toBe("shadcn-ember");
|
||||
});
|
||||
|
||||
@@ -877,6 +895,31 @@ describe("useTheme", () => {
|
||||
});
|
||||
|
||||
describe("getThemeInitScript", () => {
|
||||
let scriptSystemDark = true;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
scriptSystemDark = true;
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-color-theme");
|
||||
document.documentElement.style.fontSize = "";
|
||||
document.querySelectorAll('link[id="theme-data"]').forEach((link) => link.remove());
|
||||
const themeDataLink = document.createElement("link");
|
||||
themeDataLink.id = "theme-data";
|
||||
themeDataLink.rel = "stylesheet";
|
||||
themeDataLink.href = "/theme-data.css";
|
||||
document.head.appendChild(themeDataLink);
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)" ? scriptSystemDark : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => true,
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns a script string", () => {
|
||||
const script = getThemeInitScript();
|
||||
|
||||
@@ -922,6 +965,64 @@ describe("getThemeInitScript", () => {
|
||||
expect(script).toContain("prefers-color-scheme");
|
||||
expect(script).toContain("systemDark");
|
||||
expect(script).toContain("effectiveMode");
|
||||
expect(script).toContain("mode = localStorage.getItem('kb-dashboard-theme-mode') || 'system'");
|
||||
});
|
||||
|
||||
it("pre-hydration script resolves fresh startup from light OS preference", () => {
|
||||
scriptSystemDark = false;
|
||||
|
||||
window.eval(getThemeInitScript());
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-ember");
|
||||
});
|
||||
|
||||
it("pre-hydration script resolves fresh startup from dark OS preference", () => {
|
||||
scriptSystemDark = true;
|
||||
|
||||
window.eval(getThemeInitScript());
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-color-theme")).toBe("shadcn-ember");
|
||||
});
|
||||
|
||||
it("pre-hydration script preserves explicit cached dark and light modes", () => {
|
||||
scriptSystemDark = false;
|
||||
localStorage.setItem(THEME_MODE_STORAGE_KEY, "dark");
|
||||
window.eval(getThemeInitScript());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
|
||||
scriptSystemDark = true;
|
||||
localStorage.setItem(THEME_MODE_STORAGE_KEY, "light");
|
||||
window.eval(getThemeInitScript());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("pre-hydration script treats invalid cached theme mode as system", () => {
|
||||
scriptSystemDark = false;
|
||||
localStorage.setItem(THEME_MODE_STORAGE_KEY, "sepia-but-mode");
|
||||
|
||||
window.eval(getThemeInitScript());
|
||||
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("pre-hydration script catch fallback follows system preference", () => {
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => {
|
||||
throw new Error("localStorage disabled");
|
||||
},
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
});
|
||||
scriptSystemDark = false;
|
||||
|
||||
window.eval(getThemeInitScript());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
|
||||
scriptSystemDark = true;
|
||||
window.eval(getThemeInitScript());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("pre-hydration script applies only sanitized shadcn-custom overrides", () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ function isValidThemeMode(value: unknown): value is ThemeMode {
|
||||
}
|
||||
|
||||
function readCachedThemeMode(): ThemeMode {
|
||||
if (!isBrowser) return "dark";
|
||||
if (!isBrowser) return "system";
|
||||
try {
|
||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (isValidThemeMode(saved)) {
|
||||
@@ -80,7 +80,11 @@ function readCachedThemeMode(): ThemeMode {
|
||||
} catch {
|
||||
// localStorage not available, use default
|
||||
}
|
||||
return "dark";
|
||||
/*
|
||||
FNXC:DashboardTheming 2026-07-03-00:00:
|
||||
Missing or invalid browser cache must behave like a fresh install: System mode is selected and the resolved data-theme follows prefers-color-scheme before backend hydration.
|
||||
*/
|
||||
return "system";
|
||||
}
|
||||
|
||||
function readCachedColorTheme(): ColorTheme {
|
||||
@@ -464,7 +468,11 @@ export function getThemeInitScript(): string {
|
||||
return `
|
||||
(function() {
|
||||
try {
|
||||
var mode = localStorage.getItem('${THEME_MODE_STORAGE_KEY}') || 'dark';
|
||||
var mode = localStorage.getItem('${THEME_MODE_STORAGE_KEY}') || 'system';
|
||||
var validModes = ['dark', 'light', 'system'];
|
||||
if (!validModes.includes(mode)) {
|
||||
mode = 'system';
|
||||
}
|
||||
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.
|
||||
@@ -478,6 +486,7 @@ export function getThemeInitScript(): string {
|
||||
fontScale = ${DEFAULT_FONT_SCALE_PCT};
|
||||
}
|
||||
fontScale = Math.min(${MAX_FONT_SCALE_PCT}, Math.max(${MIN_FONT_SCALE_PCT}, Math.round(fontScale)));
|
||||
// FNXC:DashboardTheming 2026-07-03-00:00: Pre-hydration defaults to System mode so fresh startup matches the OS preference without waiting for React or backend settings hydration.
|
||||
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
@@ -526,7 +535,11 @@ export function getThemeInitScript(): string {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var fallbackSystemDark = false;
|
||||
try {
|
||||
fallbackSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} catch (matchMediaError) {}
|
||||
document.documentElement.setAttribute('data-theme', fallbackSystemDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-color-theme', '${DEFAULT_COLOR_THEME}');
|
||||
document.documentElement.style.fontSize = '${DEFAULT_FONT_SCALE_PCT}%';
|
||||
}
|
||||
|
||||
@@ -158,7 +158,11 @@
|
||||
// Theme initialization - runs before React to prevent flash
|
||||
(function() {
|
||||
try {
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
|
||||
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'system';
|
||||
var validModes = ['dark', 'light', 'system'];
|
||||
if (!validModes.includes(mode)) {
|
||||
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', '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'];
|
||||
// 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.
|
||||
@@ -172,6 +176,7 @@
|
||||
fontScale = 100;
|
||||
}
|
||||
fontScale = Math.min(125, Math.max(85, Math.round(fontScale)));
|
||||
// FNXC:DashboardTheming 2026-07-03-00:00: Fresh web startup uses System mode by default so first paint follows prefers-color-scheme until the user stores an explicit Light/Dark/System choice.
|
||||
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', effectiveMode);
|
||||
@@ -219,7 +224,11 @@
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
var fallbackSystemDark = false;
|
||||
try {
|
||||
fallbackSystemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} catch (matchMediaError) {}
|
||||
document.documentElement.setAttribute('data-theme', fallbackSystemDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-color-theme', 'shadcn-ember');
|
||||
document.documentElement.style.fontSize = '100%';
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
// Theme initialization - runs before React to prevent flash
|
||||
(function () {
|
||||
try {
|
||||
var mode = localStorage.getItem("kb-dashboard-theme-mode") || "dark";
|
||||
var mode = localStorage.getItem("kb-dashboard-theme-mode") || "system";
|
||||
var validModes = ["dark", "light", "system"];
|
||||
if (!validModes.includes(mode)) {
|
||||
mode = "system";
|
||||
}
|
||||
var colorTheme = localStorage.getItem("kb-dashboard-color-theme") || "shadcn-ember";
|
||||
var validThemes = [
|
||||
"default",
|
||||
@@ -95,12 +99,17 @@
|
||||
colorTheme = "shadcn-ember";
|
||||
}
|
||||
|
||||
// FNXC:DashboardTheming 2026-07-03-00:00: Desktop shell startup mirrors dashboard startup: default System mode resolves from prefers-color-scheme before React hydration.
|
||||
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var effectiveMode = mode === "system" ? (systemDark ? "dark" : "light") : mode;
|
||||
document.documentElement.setAttribute("data-theme", effectiveMode);
|
||||
document.documentElement.setAttribute("data-color-theme", colorTheme);
|
||||
} catch (error) {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
var fallbackSystemDark = false;
|
||||
try {
|
||||
fallbackSystemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
} catch (matchMediaError) {}
|
||||
document.documentElement.setAttribute("data-theme", fallbackSystemDark ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-color-theme", "shadcn-ember");
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user