authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 17:14:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:14:36-07:00
logec36176e4f25fac7e3c380f6a4b646116e4ccf3d
tree638612fb28a24d84775f3fe256c0bf5ef1c9a169
parent252df7742047af8a1b859dc652b2f40b639d16b3
signature Signed by PGP key B5690EEEBB952194

fix(web): onboarding wizard now supports light mode (#10432)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

4 files changed, 74 insertions(+), 65 deletions(-)

apps/web/src/components/onboarding/WelcomeWizard.tsx+1-1
......@@ -178,7 +178,7 @@ export function WelcomeWizard({
178178 );
179179
180180 return (
181 <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-black text-foreground">
181 <div className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
182182 {isElectron ? (
183183 <div
184184 aria-hidden
apps/web/src/hooks/useTheme.test.ts+12-8
......@@ -213,6 +213,7 @@ describe("onboarding theme", () => {
213213 const root = {
214214 classList: {
215215 add: (name: string) => classes.add(name),
216 contains: (name: string) => classes.has(name),
216217 remove: (name: string) => classes.delete(name),
217218 toggle: (name: string, force?: boolean) => {
218219 const next = force ?? !classes.has(name);
......@@ -298,9 +299,9 @@ describe("onboarding theme", () => {
298299 expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error);
299300 });
300301
301 it("stays dark during storage changes and restores the latest saved theme", async () => {
302 it("follows the saved appearance and restores the latest saved theme", async () => {
302303 const storage = createStorage();
303 storage.setItem("t3code:theme", "light");
304 storage.setItem("t3code:theme", "dark");
304305 const classes = new Set<string>();
305306 const styleValues = new Map<string, string>();
306307 const style = {
......@@ -361,7 +362,7 @@ describe("onboarding theme", () => {
361362 });
362363 vi.stubGlobal("getComputedStyle", () => ({
363364 backgroundColor:
364 root.dataset.onboardingSurface !== undefined
365 root.dataset.onboardingSurface !== undefined && classes.has("dark")
365366 ? "rgb(0, 0, 0)"
366367 : classes.has("dark")
367368 ? "rgb(10, 10, 10)"
......@@ -374,9 +375,10 @@ describe("onboarding theme", () => {
374375 });
375376
376377 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
377 expect(useTheme().resolvedTheme).toBe("light");
378 expect(useTheme().resolvedTheme).toBe("dark");
378379 const cleanup = mountOnboardingTheme();
379380
381 // A dark preference pins the wizard to true black.
380382 expect(root.dataset.onboardingSurface).toBe("");
381383 expect(classes.has("dark")).toBe(true);
382384 expect(root.style.backgroundColor).toBe("#000");
......@@ -384,12 +386,14 @@ describe("onboarding theme", () => {
384386 expect(useTheme().resolvedTheme).toBe("dark");
385387 expect(setDesktopTheme).toHaveBeenLastCalledWith("dark");
386388
387 storage.setItem("t3code:theme", "dark");
388 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
389 // A light preference switches the wizard to the default light palette.
389390 storage.setItem("t3code:theme", "light");
390391 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
391 expect(classes.has("dark")).toBe(true);
392 expect(useTheme().resolvedTheme).toBe("dark");
392 expect(classes.has("dark")).toBe(false);
393 expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)");
394 expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)");
395 expect(useTheme().resolvedTheme).toBe("light");
396 expect(setDesktopTheme).toHaveBeenLastCalledWith("light");
393397
394398 cleanup();
395399 expect(root.dataset.onboardingSurface).toBeUndefined();
apps/web/src/hooks/useTheme.ts+28-27
......@@ -310,9 +310,12 @@ export function syncBrowserChromeTheme() {
310310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
311311 );
312312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
313 const backgroundColor = onboardingActive
314 ? "#000"
315 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
313 // Dark onboarding pins a true-black canvas; light onboarding uses the
314 // default light palette and reads it back from the document like the app.
315 const backgroundColor =
316 onboardingActive && document.documentElement.classList.contains("dark")
317 ? "#000"
318 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
316319 if (!backgroundColor) return;
317320
318321 document.documentElement.style.backgroundColor = backgroundColor;
......@@ -353,13 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
353356 lastAppliedTheme.appearanceMode === appearanceMode &&
354357 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
355358 ) {
356 if (onboardingActive) {
357 document.documentElement.classList.add("dark");
358 syncBrowserChromeTheme();
359 syncDesktopTheme("dark", false, "dark");
360 } else {
361 syncDesktopTheme(theme, followSystem, appearanceMode);
362 }
359 syncDesktopTheme(theme, followSystem, appearanceMode);
363360 return;
364361 }
365362
......@@ -373,19 +370,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
373370 appearanceMode,
374371 themeHalves,
375372 );
376 if (onboardingActive) {
377 document.documentElement.classList.add("dark");
378 } else {
373 // Onboarding follows the saved light/dark appearance but never applies a
374 // custom palette, so the wizard keeps its fixed neutral tokens.
375 if (!onboardingActive) {
379376 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
380 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
381377 }
378 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
382379 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
383380 syncBrowserChromeTheme();
384 if (onboardingActive) {
385 syncDesktopTheme("dark", false, "dark");
386 } else {
387 syncDesktopTheme(theme, followSystem, appearanceMode);
388 }
381 syncDesktopTheme(theme, followSystem, appearanceMode);
389382 if (suppressTransitions) {
390383 // Force a reflow so the no-transitions class takes effect before removal
391384 void document.documentElement.offsetHeight;
......@@ -395,16 +388,20 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
395388 }
396389}
397390
398/** Own the document-wide dark palette used by the first-run wizard and its portals. */
391/**
392 * Own the document-wide palette used by the first-run wizard and its portals.
393 * The wizard follows the saved light or dark appearance (and system changes)
394 * but drops any custom theme palette until the returned cleanup runs.
395 */
399396export function mountOnboardingTheme(): () => void {
400397 if (typeof document === "undefined" || typeof window === "undefined") return () => {};
401398
402399 const root = document.documentElement;
403 applyThemePalette("dark", "dark");
400 // "system" is a reserved id with no palette, so this clears theme variables.
401 applyThemePalette("system");
404402 root.dataset.onboardingSurface = "";
405 root.classList.add("dark");
406 syncBrowserChromeTheme();
407 syncDesktopTheme("dark", false, "dark");
403 lastAppliedTheme = null;
404 applyTheme(getStored(), { preservePreview: false });
408405 emitChange();
409406
410407 return () => {
......@@ -478,9 +475,13 @@ function getSnapshot(): ThemeSnapshot {
478475 const systemDark = followSystem ? getSystemDark() : false;
479476 const themeHalves = readStoredThemeHalves();
480477
481 const resolvedTheme = isOnboardingThemeActive()
482 ? "dark"
483 : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves);
478 const resolvedTheme = resolveThemeAppearance(
479 theme,
480 systemDark,
481 followSystem,
482 appearanceMode,
483 themeHalves,
484 );
484485 if (
485486 lastSnapshot &&
486487 lastSnapshot.theme === theme &&
apps/web/src/index.css+33-29
......@@ -1185,36 +1185,40 @@ html[data-theme-id]:not([data-theme-id=""]) {
11851185}
11861186
11871187/* The first-run flow owns the whole document so portaled menus and tooltips
1188 use the same fixed palette as the wizard. This follows the theme mapping so
1189 saved custom themes cannot override it while onboarding is mounted. */
1188 use the same fixed palette as the wizard. Light onboarding uses the default
1189 light palette from :root above. Dark onboarding pins a true-black palette.
1190 This follows the theme mapping so saved custom themes cannot override it
1191 while onboarding is mounted. */
11901192html[data-onboarding-surface]:root {
1191 color-scheme: dark;
1192 --accent: #262626;
1193 --accent-foreground: #fff;
1194 --appearance-contrast-target: #fff;
1195 --app-chrome-background: #000;
1196 --background: #000;
1197 --border: #262626;
1198 --card: #000;
1199 --card-foreground: #fff;
1200 --destructive: var(--color-red-400);
1201 --foreground: #fff;
1202 --icon-muted: #a1a1aa;
1203 --input: #262626;
1204 --muted: #171717;
1205 --muted-foreground: #a1a1aa;
1206 --placeholder: #71717a;
1207 --popover: #171717;
1208 --popover-foreground: #fff;
1209 --ring: #737373;
1210 --secondary: #171717;
1211 --secondary-foreground: #fff;
1212 --secondary-label: #a1a1aa;
1213 --success-foreground: var(--color-emerald-400);
1214 --terminal-background: #000;
1215 --terminal-cursor: #fff;
1216 --terminal-foreground: #fff;
1217 --terminal-selection-background: rgb(255 255 255 / 20%);
1193 @variant dark {
1194 color-scheme: dark;
1195 --accent: #262626;
1196 --accent-foreground: #fff;
1197 --appearance-contrast-target: #fff;
1198 --app-chrome-background: #000;
1199 --background: #000;
1200 --border: #262626;
1201 --card: #000;
1202 --card-foreground: #fff;
1203 --destructive: var(--color-red-400);
1204 --foreground: #fff;
1205 --icon-muted: #a1a1aa;
1206 --input: #262626;
1207 --muted: #171717;
1208 --muted-foreground: #a1a1aa;
1209 --placeholder: #71717a;
1210 --popover: #171717;
1211 --popover-foreground: #fff;
1212 --ring: #737373;
1213 --secondary: #171717;
1214 --secondary-foreground: #fff;
1215 --secondary-label: #a1a1aa;
1216 --success-foreground: var(--color-emerald-400);
1217 --terminal-background: #000;
1218 --terminal-cursor: #fff;
1219 --terminal-foreground: #fff;
1220 --terminal-selection-background: rgb(255 255 255 / 20%);
1221 }
12181222}
12191223
12201224/* Theme-token dependency probes are restored synchronously, before paint. Keep