| 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 | 178 | ); |
| 179 | 179 | |
| 180 | 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 | 182 | {isElectron ? ( |
| 183 | 183 | <div |
| 184 | 184 | aria-hidden |
apps/web/src/hooks/useTheme.test.ts+12-8| ... | ... | @@ -213,6 +213,7 @@ describe("onboarding theme", () => { |
| 213 | 213 | const root = { |
| 214 | 214 | classList: { |
| 215 | 215 | add: (name: string) => classes.add(name), |
| 216 | contains: (name: string) => classes.has(name), | |
| 216 | 217 | remove: (name: string) => classes.delete(name), |
| 217 | 218 | toggle: (name: string, force?: boolean) => { |
| 218 | 219 | const next = force ?? !classes.has(name); |
| ... | ... | @@ -298,9 +299,9 @@ describe("onboarding theme", () => { |
| 298 | 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 | 303 | const storage = createStorage(); |
| 303 | storage.setItem("t3code:theme", "light"); | |
| 304 | storage.setItem("t3code:theme", "dark"); | |
| 304 | 305 | const classes = new Set<string>(); |
| 305 | 306 | const styleValues = new Map<string, string>(); |
| 306 | 307 | const style = { |
| ... | ... | @@ -361,7 +362,7 @@ describe("onboarding theme", () => { |
| 361 | 362 | }); |
| 362 | 363 | vi.stubGlobal("getComputedStyle", () => ({ |
| 363 | 364 | backgroundColor: |
| 364 | root.dataset.onboardingSurface !== undefined | |
| 365 | root.dataset.onboardingSurface !== undefined && classes.has("dark") | |
| 365 | 366 | ? "rgb(0, 0, 0)" |
| 366 | 367 | : classes.has("dark") |
| 367 | 368 | ? "rgb(10, 10, 10)" |
| ... | ... | @@ -374,9 +375,10 @@ describe("onboarding theme", () => { |
| 374 | 375 | }); |
| 375 | 376 | |
| 376 | 377 | const { mountOnboardingTheme, useTheme } = await import("./useTheme"); |
| 377 | expect(useTheme().resolvedTheme).toBe("light"); | |
| 378 | expect(useTheme().resolvedTheme).toBe("dark"); | |
| 378 | 379 | const cleanup = mountOnboardingTheme(); |
| 379 | 380 | |
| 381 | // A dark preference pins the wizard to true black. | |
| 380 | 382 | expect(root.dataset.onboardingSurface).toBe(""); |
| 381 | 383 | expect(classes.has("dark")).toBe(true); |
| 382 | 384 | expect(root.style.backgroundColor).toBe("#000"); |
| ... | ... | @@ -384,12 +386,14 @@ describe("onboarding theme", () => { |
| 384 | 386 | expect(useTheme().resolvedTheme).toBe("dark"); |
| 385 | 387 | expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); |
| 386 | 388 | |
| 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. | |
| 389 | 390 | storage.setItem("t3code:theme", "light"); |
| 390 | 391 | 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"); | |
| 393 | 397 | |
| 394 | 398 | cleanup(); |
| 395 | 399 | expect(root.dataset.onboardingSurface).toBeUndefined(); |
apps/web/src/hooks/useTheme.ts+28-27| ... | ... | @@ -310,9 +310,12 @@ export function syncBrowserChromeTheme() { |
| 310 | 310 | getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, |
| 311 | 311 | ); |
| 312 | 312 | 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); | |
| 316 | 319 | if (!backgroundColor) return; |
| 317 | 320 | |
| 318 | 321 | document.documentElement.style.backgroundColor = backgroundColor; |
| ... | ... | @@ -353,13 +356,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview |
| 353 | 356 | lastAppliedTheme.appearanceMode === appearanceMode && |
| 354 | 357 | themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) |
| 355 | 358 | ) { |
| 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); | |
| 363 | 360 | return; |
| 364 | 361 | } |
| 365 | 362 | |
| ... | ... | @@ -373,19 +370,15 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview |
| 373 | 370 | appearanceMode, |
| 374 | 371 | themeHalves, |
| 375 | 372 | ); |
| 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) { | |
| 379 | 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 | 379 | lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; |
| 383 | 380 | syncBrowserChromeTheme(); |
| 384 | if (onboardingActive) { | |
| 385 | syncDesktopTheme("dark", false, "dark"); | |
| 386 | } else { | |
| 387 | syncDesktopTheme(theme, followSystem, appearanceMode); | |
| 388 | } | |
| 381 | syncDesktopTheme(theme, followSystem, appearanceMode); | |
| 389 | 382 | if (suppressTransitions) { |
| 390 | 383 | // Force a reflow so the no-transitions class takes effect before removal |
| 391 | 384 | void document.documentElement.offsetHeight; |
| ... | ... | @@ -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 | 396 | export function mountOnboardingTheme(): () => void { |
| 400 | 397 | if (typeof document === "undefined" || typeof window === "undefined") return () => {}; |
| 401 | 398 | |
| 402 | 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 | 402 | root.dataset.onboardingSurface = ""; |
| 405 | root.classList.add("dark"); | |
| 406 | syncBrowserChromeTheme(); | |
| 407 | syncDesktopTheme("dark", false, "dark"); | |
| 403 | lastAppliedTheme = null; | |
| 404 | applyTheme(getStored(), { preservePreview: false }); | |
| 408 | 405 | emitChange(); |
| 409 | 406 | |
| 410 | 407 | return () => { |
| ... | ... | @@ -478,9 +475,13 @@ function getSnapshot(): ThemeSnapshot { |
| 478 | 475 | const systemDark = followSystem ? getSystemDark() : false; |
| 479 | 476 | const themeHalves = readStoredThemeHalves(); |
| 480 | 477 | |
| 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 | ); | |
| 484 | 485 | if ( |
| 485 | 486 | lastSnapshot && |
| 486 | 487 | lastSnapshot.theme === theme && |
apps/web/src/index.css+33-29| ... | ... | @@ -1185,36 +1185,40 @@ html[data-theme-id]:not([data-theme-id=""]) { |
| 1185 | 1185 | } |
| 1186 | 1186 | |
| 1187 | 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 | |
| 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. */ | |
| 1190 | 1192 | html[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 | } | |
| 1218 | 1222 | } |
| 1219 | 1223 | |
| 1220 | 1224 | /* Theme-token dependency probes are restored synchronously, before paint. Keep |