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({...@@ -178,7 +178,7 @@ export function WelcomeWizard({
178 );178 );
179179
180 return (180 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">
182 {isElectron ? (182 {isElectron ? (
183 <div183 <div
184 aria-hidden184 aria-hidden
apps/web/src/hooks/useTheme.test.ts+12-8
...@@ -213,6 +213,7 @@ describe("onboarding theme", () => {...@@ -213,6 +213,7 @@ describe("onboarding theme", () => {
213 const root = {213 const root = {
214 classList: {214 classList: {
215 add: (name: string) => classes.add(name),215 add: (name: string) => classes.add(name),
216 contains: (name: string) => classes.has(name),
216 remove: (name: string) => classes.delete(name),217 remove: (name: string) => classes.delete(name),
217 toggle: (name: string, force?: boolean) => {218 toggle: (name: string, force?: boolean) => {
218 const next = force ?? !classes.has(name);219 const next = force ?? !classes.has(name);
...@@ -298,9 +299,9 @@ describe("onboarding theme", () => {...@@ -298,9 +299,9 @@ describe("onboarding theme", () => {
298 expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error);299 expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error);
299 });300 });
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 () => {
302 const storage = createStorage();303 const storage = createStorage();
303 storage.setItem("t3code:theme", "light");304 storage.setItem("t3code:theme", "dark");
304 const classes = new Set<string>();305 const classes = new Set<string>();
305 const styleValues = new Map<string, string>();306 const styleValues = new Map<string, string>();
306 const style = {307 const style = {
...@@ -361,7 +362,7 @@ describe("onboarding theme", () => {...@@ -361,7 +362,7 @@ describe("onboarding theme", () => {
361 });362 });
362 vi.stubGlobal("getComputedStyle", () => ({363 vi.stubGlobal("getComputedStyle", () => ({
363 backgroundColor:364 backgroundColor:
364 root.dataset.onboardingSurface !== undefined365 root.dataset.onboardingSurface !== undefined && classes.has("dark")
365 ? "rgb(0, 0, 0)"366 ? "rgb(0, 0, 0)"
366 : classes.has("dark")367 : classes.has("dark")
367 ? "rgb(10, 10, 10)"368 ? "rgb(10, 10, 10)"
...@@ -374,9 +375,10 @@ describe("onboarding theme", () => {...@@ -374,9 +375,10 @@ describe("onboarding theme", () => {
374 });375 });
375376
376 const { mountOnboardingTheme, useTheme } = await import("./useTheme");377 const { mountOnboardingTheme, useTheme } = await import("./useTheme");
377 expect(useTheme().resolvedTheme).toBe("light");378 expect(useTheme().resolvedTheme).toBe("dark");
378 const cleanup = mountOnboardingTheme();379 const cleanup = mountOnboardingTheme();
379380
381 // A dark preference pins the wizard to true black.
380 expect(root.dataset.onboardingSurface).toBe("");382 expect(root.dataset.onboardingSurface).toBe("");
381 expect(classes.has("dark")).toBe(true);383 expect(classes.has("dark")).toBe(true);
382 expect(root.style.backgroundColor).toBe("#000");384 expect(root.style.backgroundColor).toBe("#000");
...@@ -384,12 +386,14 @@ describe("onboarding theme", () => {...@@ -384,12 +386,14 @@ describe("onboarding theme", () => {
384 expect(useTheme().resolvedTheme).toBe("dark");386 expect(useTheme().resolvedTheme).toBe("dark");
385 expect(setDesktopTheme).toHaveBeenLastCalledWith("dark");387 expect(setDesktopTheme).toHaveBeenLastCalledWith("dark");
386388
387 storage.setItem("t3code:theme", "dark");389 // A light preference switches the wizard to the default light palette.
388 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
389 storage.setItem("t3code:theme", "light");390 storage.setItem("t3code:theme", "light");
390 storageHandler?.({ key: "t3code:theme" } as StorageEvent);391 storageHandler?.({ key: "t3code:theme" } as StorageEvent);
391 expect(classes.has("dark")).toBe(true);392 expect(classes.has("dark")).toBe(false);
392 expect(useTheme().resolvedTheme).toBe("dark");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
394 cleanup();398 cleanup();
395 expect(root.dataset.onboardingSurface).toBeUndefined();399 expect(root.dataset.onboardingSurface).toBeUndefined();
apps/web/src/hooks/useTheme.ts+28-27
...@@ -310,9 +310,12 @@ export function syncBrowserChromeTheme() {...@@ -310,9 +310,12 @@ export function syncBrowserChromeTheme() {
310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,310 getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
311 );311 );
312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);312 const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
313 const backgroundColor = onboardingActive313 // Dark onboarding pins a true-black canvas; light onboarding uses the
314 ? "#000"314 // default light palette and reads it back from the document like the app.
315 : (themeChromeColor ?? surfaceColor ?? fallbackColor);315 const backgroundColor =
316 onboardingActive && document.documentElement.classList.contains("dark")
317 ? "#000"
318 : (themeChromeColor ?? surfaceColor ?? fallbackColor);
316 if (!backgroundColor) return;319 if (!backgroundColor) return;
317320
318 document.documentElement.style.backgroundColor = backgroundColor;321 document.documentElement.style.backgroundColor = backgroundColor;
...@@ -353,13 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -353,13 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
353 lastAppliedTheme.appearanceMode === appearanceMode &&356 lastAppliedTheme.appearanceMode === appearanceMode &&
354 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)357 themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves)
355 ) {358 ) {
356 if (onboardingActive) {359 syncDesktopTheme(theme, followSystem, appearanceMode);
357 document.documentElement.classList.add("dark");
358 syncBrowserChromeTheme();
359 syncDesktopTheme("dark", false, "dark");
360 } else {
361 syncDesktopTheme(theme, followSystem, appearanceMode);
362 }
363 return;360 return;
364 }361 }
365362
...@@ -373,19 +370,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -373,19 +370,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
373 appearanceMode,370 appearanceMode,
374 themeHalves,371 themeHalves,
375 );372 );
376 if (onboardingActive) {373 // Onboarding follows the saved light/dark appearance but never applies a
377 document.documentElement.classList.add("dark");374 // custom palette, so the wizard keeps its fixed neutral tokens.
378 } else {375 if (!onboardingActive) {
379 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);376 applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance);
380 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
381 }377 }
378 document.documentElement.classList.toggle("dark", resolvedAppearance === "dark");
382 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };379 lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves };
383 syncBrowserChromeTheme();380 syncBrowserChromeTheme();
384 if (onboardingActive) {381 syncDesktopTheme(theme, followSystem, appearanceMode);
385 syncDesktopTheme("dark", false, "dark");
386 } else {
387 syncDesktopTheme(theme, followSystem, appearanceMode);
388 }
389 if (suppressTransitions) {382 if (suppressTransitions) {
390 // Force a reflow so the no-transitions class takes effect before removal383 // Force a reflow so the no-transitions class takes effect before removal
391 void document.documentElement.offsetHeight;384 void document.documentElement.offsetHeight;
...@@ -395,16 +388,20 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview...@@ -395,16 +388,20 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview
395 }388 }
396}389}
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 */
399export function mountOnboardingTheme(): () => void {396export function mountOnboardingTheme(): () => void {
400 if (typeof document === "undefined" || typeof window === "undefined") return () => {};397 if (typeof document === "undefined" || typeof window === "undefined") return () => {};
401398
402 const root = document.documentElement;399 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");
404 root.dataset.onboardingSurface = "";402 root.dataset.onboardingSurface = "";
405 root.classList.add("dark");403 lastAppliedTheme = null;
406 syncBrowserChromeTheme();404 applyTheme(getStored(), { preservePreview: false });
407 syncDesktopTheme("dark", false, "dark");
408 emitChange();405 emitChange();
409406
410 return () => {407 return () => {
...@@ -478,9 +475,13 @@ function getSnapshot(): ThemeSnapshot {...@@ -478,9 +475,13 @@ function getSnapshot(): ThemeSnapshot {
478 const systemDark = followSystem ? getSystemDark() : false;475 const systemDark = followSystem ? getSystemDark() : false;
479 const themeHalves = readStoredThemeHalves();476 const themeHalves = readStoredThemeHalves();
480477
481 const resolvedTheme = isOnboardingThemeActive()478 const resolvedTheme = resolveThemeAppearance(
482 ? "dark"479 theme,
483 : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves);480 systemDark,
481 followSystem,
482 appearanceMode,
483 themeHalves,
484 );
484 if (485 if (
485 lastSnapshot &&486 lastSnapshot &&
486 lastSnapshot.theme === theme &&487 lastSnapshot.theme === theme &&
apps/web/src/index.css+33-29
...@@ -1185,36 +1185,40 @@ html[data-theme-id]:not([data-theme-id=""]) {...@@ -1185,36 +1185,40 @@ html[data-theme-id]:not([data-theme-id=""]) {
1185}1185}
11861186
1187/* The first-run flow owns the whole document so portaled menus and tooltips1187/* 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 so1188 use the same fixed palette as the wizard. Light onboarding uses the default
1189 saved custom themes cannot override it while onboarding is mounted. */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. */
1190html[data-onboarding-surface]:root {1192html[data-onboarding-surface]:root {
1191 color-scheme: dark;1193 @variant dark {
1192 --accent: #262626;1194 color-scheme: dark;
1193 --accent-foreground: #fff;1195 --accent: #262626;
1194 --appearance-contrast-target: #fff;1196 --accent-foreground: #fff;
1195 --app-chrome-background: #000;1197 --appearance-contrast-target: #fff;
1196 --background: #000;1198 --app-chrome-background: #000;
1197 --border: #262626;1199 --background: #000;
1198 --card: #000;1200 --border: #262626;
1199 --card-foreground: #fff;1201 --card: #000;
1200 --destructive: var(--color-red-400);1202 --card-foreground: #fff;
1201 --foreground: #fff;1203 --destructive: var(--color-red-400);
1202 --icon-muted: #a1a1aa;1204 --foreground: #fff;
1203 --input: #262626;1205 --icon-muted: #a1a1aa;
1204 --muted: #171717;1206 --input: #262626;
1205 --muted-foreground: #a1a1aa;1207 --muted: #171717;
1206 --placeholder: #71717a;1208 --muted-foreground: #a1a1aa;
1207 --popover: #171717;1209 --placeholder: #71717a;
1208 --popover-foreground: #fff;1210 --popover: #171717;
1209 --ring: #737373;1211 --popover-foreground: #fff;
1210 --secondary: #171717;1212 --ring: #737373;
1211 --secondary-foreground: #fff;1213 --secondary: #171717;
1212 --secondary-label: #a1a1aa;1214 --secondary-foreground: #fff;
1213 --success-foreground: var(--color-emerald-400);1215 --secondary-label: #a1a1aa;
1214 --terminal-background: #000;1216 --success-foreground: var(--color-emerald-400);
1215 --terminal-cursor: #fff;1217 --terminal-background: #000;
1216 --terminal-foreground: #fff;1218 --terminal-cursor: #fff;
1217 --terminal-selection-background: rgb(255 255 255 / 20%);1219 --terminal-foreground: #fff;
1220 --terminal-selection-background: rgb(255 255 255 / 20%);
1221 }
1218}1222}
12191223
1220/* Theme-token dependency probes are restored synchronously, before paint. Keep1224/* Theme-token dependency probes are restored synchronously, before paint. Keep