| author | |
| committer | |
| log | ec36176e4f25fac7e3c380f6a4b646116e4ccf3d |
| tree | 638612fb28a24d84775f3fe256c0bf5ef1c9a169 |
| parent | 252df7742047af8a1b859dc652b2f40b639d16b3 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 179 | 179 | ||
| 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 | <div | 183 | <div |
| 184 | aria-hidden | 184 | 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 | }); |
| 300 | 301 | ||
| 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 !== undefined | 365 | 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 | }); |
| 375 | 376 | ||
| 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(); |
| 379 | 380 | ||
| 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"); |
| 386 | 388 | ||
| 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"); | ||
| 393 | 397 | ||
| 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 = onboardingActive | 313 | // 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; |
| 317 | 320 | ||
| 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 | } |
| 365 | 362 | ||
| ... | @@ -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 removal | 383 | // 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 | } |
| 397 | 390 | ||
| 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 | */ | ||
| 399 | export function mountOnboardingTheme(): () => void { | 396 | export function mountOnboardingTheme(): () => void { |
| 400 | if (typeof document === "undefined" || typeof window === "undefined") return () => {}; | 397 | if (typeof document === "undefined" || typeof window === "undefined") return () => {}; |
| 401 | 398 | ||
| 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(); |
| 409 | 406 | ||
| 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(); |
| 480 | 477 | ||
| 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 | } |
| 1186 | 1186 | ||
| 1187 | /* The first-run flow owns the whole document so portaled menus and tooltips | 1187 | /* 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 | 1188 | 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. */ | ||
| 1190 | html[data-onboarding-surface]:root { | 1192 | html[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 | } |
| 1219 | 1223 | ||
| 1220 | /* Theme-token dependency probes are restored synchronously, before paint. Keep | 1224 | /* Theme-token dependency probes are restored synchronously, before paint. Keep |