diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9de7d00c60ca..c43dbf6a445b 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -178,7 +178,7 @@ export function WelcomeWizard({ ); return ( -
+
{isElectron ? (
{ const root = { classList: { add: (name: string) => classes.add(name), + contains: (name: string) => classes.has(name), remove: (name: string) => classes.delete(name), toggle: (name: string, force?: boolean) => { const next = force ?? !classes.has(name); @@ -298,9 +299,9 @@ describe("onboarding theme", () => { expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); }); - it("stays dark during storage changes and restores the latest saved theme", async () => { + it("follows the saved appearance and restores the latest saved theme", async () => { const storage = createStorage(); - storage.setItem("t3code:theme", "light"); + storage.setItem("t3code:theme", "dark"); const classes = new Set(); const styleValues = new Map(); const style = { @@ -361,7 +362,7 @@ describe("onboarding theme", () => { }); vi.stubGlobal("getComputedStyle", () => ({ backgroundColor: - root.dataset.onboardingSurface !== undefined + root.dataset.onboardingSurface !== undefined && classes.has("dark") ? "rgb(0, 0, 0)" : classes.has("dark") ? "rgb(10, 10, 10)" @@ -374,9 +375,10 @@ describe("onboarding theme", () => { }); const { mountOnboardingTheme, useTheme } = await import("./useTheme"); - expect(useTheme().resolvedTheme).toBe("light"); + expect(useTheme().resolvedTheme).toBe("dark"); const cleanup = mountOnboardingTheme(); + // A dark preference pins the wizard to true black. expect(root.dataset.onboardingSurface).toBe(""); expect(classes.has("dark")).toBe(true); expect(root.style.backgroundColor).toBe("#000"); @@ -384,12 +386,14 @@ describe("onboarding theme", () => { expect(useTheme().resolvedTheme).toBe("dark"); expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); - storage.setItem("t3code:theme", "dark"); - storageHandler?.({ key: "t3code:theme" } as StorageEvent); + // A light preference switches the wizard to the default light palette. storage.setItem("t3code:theme", "light"); storageHandler?.({ key: "t3code:theme" } as StorageEvent); - expect(classes.has("dark")).toBe(true); - expect(useTheme().resolvedTheme).toBe("dark"); + expect(classes.has("dark")).toBe(false); + expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(useTheme().resolvedTheme).toBe("light"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); cleanup(); expect(root.dataset.onboardingSurface).toBeUndefined(); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 01928552acb0..3c3436eccf40 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -310,9 +310,12 @@ export function syncBrowserChromeTheme() { getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = onboardingActive - ? "#000" - : (themeChromeColor ?? surfaceColor ?? fallbackColor); + // Dark onboarding pins a true-black canvas; light onboarding uses the + // default light palette and reads it back from the document like the app. + const backgroundColor = + onboardingActive && document.documentElement.classList.contains("dark") + ? "#000" + : (themeChromeColor ?? surfaceColor ?? fallbackColor); if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -353,13 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview lastAppliedTheme.appearanceMode === appearanceMode && themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) ) { - if (onboardingActive) { - document.documentElement.classList.add("dark"); - syncBrowserChromeTheme(); - syncDesktopTheme("dark", false, "dark"); - } else { - syncDesktopTheme(theme, followSystem, appearanceMode); - } + syncDesktopTheme(theme, followSystem, appearanceMode); return; } @@ -373,19 +370,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - if (onboardingActive) { - document.documentElement.classList.add("dark"); - } else { + // Onboarding follows the saved light/dark appearance but never applies a + // custom palette, so the wizard keeps its fixed neutral tokens. + if (!onboardingActive) { applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); - document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); } + document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); - if (onboardingActive) { - syncDesktopTheme("dark", false, "dark"); - } else { - syncDesktopTheme(theme, followSystem, appearanceMode); - } + syncDesktopTheme(theme, followSystem, appearanceMode); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -395,16 +388,20 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } -/** Own the document-wide dark palette used by the first-run wizard and its portals. */ +/** + * Own the document-wide palette used by the first-run wizard and its portals. + * The wizard follows the saved light or dark appearance (and system changes) + * but drops any custom theme palette until the returned cleanup runs. + */ export function mountOnboardingTheme(): () => void { if (typeof document === "undefined" || typeof window === "undefined") return () => {}; const root = document.documentElement; - applyThemePalette("dark", "dark"); + // "system" is a reserved id with no palette, so this clears theme variables. + applyThemePalette("system"); root.dataset.onboardingSurface = ""; - root.classList.add("dark"); - syncBrowserChromeTheme(); - syncDesktopTheme("dark", false, "dark"); + lastAppliedTheme = null; + applyTheme(getStored(), { preservePreview: false }); emitChange(); return () => { @@ -478,9 +475,13 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); - const resolvedTheme = isOnboardingThemeActive() - ? "dark" - : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); + const resolvedTheme = resolveThemeAppearance( + theme, + systemDark, + followSystem, + appearanceMode, + themeHalves, + ); if ( lastSnapshot && lastSnapshot.theme === theme && diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 45d10f28d735..55d4728b2794 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1185,36 +1185,40 @@ html[data-theme-id]:not([data-theme-id=""]) { } /* The first-run flow owns the whole document so portaled menus and tooltips - use the same fixed palette as the wizard. This follows the theme mapping so - saved custom themes cannot override it while onboarding is mounted. */ + use the same fixed palette as the wizard. Light onboarding uses the default + light palette from :root above. Dark onboarding pins a true-black palette. + This follows the theme mapping so saved custom themes cannot override it + while onboarding is mounted. */ html[data-onboarding-surface]:root { - color-scheme: dark; - --accent: #262626; - --accent-foreground: #fff; - --appearance-contrast-target: #fff; - --app-chrome-background: #000; - --background: #000; - --border: #262626; - --card: #000; - --card-foreground: #fff; - --destructive: var(--color-red-400); - --foreground: #fff; - --icon-muted: #a1a1aa; - --input: #262626; - --muted: #171717; - --muted-foreground: #a1a1aa; - --placeholder: #71717a; - --popover: #171717; - --popover-foreground: #fff; - --ring: #737373; - --secondary: #171717; - --secondary-foreground: #fff; - --secondary-label: #a1a1aa; - --success-foreground: var(--color-emerald-400); - --terminal-background: #000; - --terminal-cursor: #fff; - --terminal-foreground: #fff; - --terminal-selection-background: rgb(255 255 255 / 20%); + @variant dark { + color-scheme: dark; + --accent: #262626; + --accent-foreground: #fff; + --appearance-contrast-target: #fff; + --app-chrome-background: #000; + --background: #000; + --border: #262626; + --card: #000; + --card-foreground: #fff; + --destructive: var(--color-red-400); + --foreground: #fff; + --icon-muted: #a1a1aa; + --input: #262626; + --muted: #171717; + --muted-foreground: #a1a1aa; + --placeholder: #71717a; + --popover: #171717; + --popover-foreground: #fff; + --ring: #737373; + --secondary: #171717; + --secondary-foreground: #fff; + --secondary-label: #a1a1aa; + --success-foreground: var(--color-emerald-400); + --terminal-background: #000; + --terminal-cursor: #fff; + --terminal-foreground: #fff; + --terminal-selection-background: rgb(255 255 255 / 20%); + } } /* Theme-token dependency probes are restored synchronously, before paint. Keep