Skip to content

Commit 1ea8ca0

Browse files
benjaminleonardclaudedavid-crespo
authored
Disable light theme on login and device auth pages (#3138)
Force dark mode on /login/ and /device/ routes by setting data-theme=dark on <html> before first paint and at runtime. Keeps theme preference unaffected on other pages. Fixes #3136 --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 0f06ce9 commit 1ea8ca0

File tree

5 files changed

+126
-17
lines changed

5 files changed

+126
-17
lines changed

app/stores/theme.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useEffect, useSyncExternalStore } from 'react'
9+
import { useLocation } from 'react-router'
910
import { create } from 'zustand'
1011
import { persist } from 'zustand/middleware'
1112

13+
/**
14+
* Pages that should always render in dark mode. Keep in sync with
15+
* public/theme-init.js.
16+
*/
17+
const FORCE_DARK_PATHS = ['/login/', '/device/']
18+
1219
export type Theme = 'dark' | 'light' | 'system'
1320

1421
type ThemeStore = {
@@ -43,7 +50,12 @@ function getSystemIsLight() {
4350
export function useApplyTheme() {
4451
const { theme: pref } = useThemeStore()
4552
const systemIsLight = useSyncExternalStore(subscribeToMediaQuery, getSystemIsLight)
46-
const theme = pref === 'system' ? (systemIsLight ? 'light' : 'dark') : pref
53+
const resolvedPref = pref === 'system' ? (systemIsLight ? 'light' : 'dark') : pref
54+
55+
const { pathname } = useLocation()
56+
const forceDark = FORCE_DARK_PATHS.some((p) => pathname.startsWith(p))
57+
58+
const theme = forceDark ? 'dark' : resolvedPref
4759

4860
useEffect(() => {
4961
document.documentElement.dataset.theme = theme

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"@base-ui/react": "^1.1.0",
3838
"@floating-ui/react": "^0.26.23",
3939
"@headlessui/react": "^2.2.9",
40-
"@oxide/design-system": "^6.0.1",
40+
"@oxide/design-system": "^7.0.1",
4141
"@react-aria/live-announcer": "^3.3.4",
4242
"@tailwindcss/container-queries": "^0.1.1",
4343
"@tailwindcss/vite": "^4.1.17",

public/theme-init.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@
99
// Set theme before first paint to prevent flash of wrong color scheme.
1010
// Mirrors logic in app/stores/theme.ts. Must stay in sync.
1111
;(function () {
12-
var p = 'dark'
12+
// Resolve preference from localStorage (zustand persist format)
13+
let pref = 'dark'
1314
try {
14-
var raw = localStorage.getItem('theme-preference')
15-
var stored = raw ? JSON.parse(raw) : null
16-
// match zustand persist format
17-
if (stored && stored.state && stored.state.theme) p = stored.state.theme
15+
const raw = localStorage.getItem('theme-preference')
16+
const stored = raw ? JSON.parse(raw) : null
17+
if (stored && stored.state && stored.state.theme) pref = stored.state.theme
1818
} catch (_e) {}
19-
if (p === 'system')
20-
p = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
21-
document.documentElement.dataset.theme = p
19+
20+
const systemIsLight = matchMedia('(prefers-color-scheme: light)').matches
21+
const resolvedPref = pref === 'system' ? (systemIsLight ? 'light' : 'dark') : pref
22+
23+
// Keep in sync with FORCE_DARK_PATHS in app/stores/theme.ts
24+
const forceDarkPaths = ['/login/', '/device/']
25+
const forceDark = forceDarkPaths.some((p) => location.pathname.startsWith(p))
26+
27+
document.documentElement.dataset.theme = forceDark ? 'dark' : resolvedPref
2228
})()

test/e2e/theme.e2e.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,99 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import type { Page } from '@playwright/test'
9+
810
import { expect, test } from './utils'
911

12+
/** Seed theme preference into localStorage before any page loads. */
13+
async function seedTheme(page: Page, theme: string) {
14+
await page.addInitScript((t) => {
15+
localStorage.setItem(
16+
'theme-preference',
17+
JSON.stringify({ state: { theme: t }, version: 0 })
18+
)
19+
}, theme)
20+
}
21+
22+
/**
23+
* Block the app entry so React never boots. This isolates theme-init.js,
24+
* letting us test the pre-hydration theme. The #root empty check in tests
25+
* ensures this block is still working.
26+
*/
27+
async function blockReact(page: Page) {
28+
await page.route('**/app/main.tsx*', (route) => route.abort('blockedbyclient'))
29+
}
30+
31+
test.describe('theme-init.js (pre-hydration)', () => {
32+
test('defaults to dark with no stored preference', async ({ page }) => {
33+
await blockReact(page)
34+
await page.goto('/projects', { waitUntil: 'domcontentloaded' })
35+
await expect(page.locator('#root')).toBeEmpty()
36+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
37+
})
38+
39+
test('respects stored light preference', async ({ page }) => {
40+
await seedTheme(page, 'light')
41+
await blockReact(page)
42+
await page.goto('/projects', { waitUntil: 'domcontentloaded' })
43+
await expect(page.locator('#root')).toBeEmpty()
44+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
45+
})
46+
47+
test('respects stored dark preference', async ({ page }) => {
48+
await seedTheme(page, 'dark')
49+
await blockReact(page)
50+
await page.goto('/projects', { waitUntil: 'domcontentloaded' })
51+
await expect(page.locator('#root')).toBeEmpty()
52+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
53+
})
54+
55+
test('system preference resolves to emulated color scheme', async ({ page }) => {
56+
await seedTheme(page, 'system')
57+
await blockReact(page)
58+
59+
await page.emulateMedia({ colorScheme: 'light' })
60+
await page.goto('/projects', { waitUntil: 'domcontentloaded' })
61+
await expect(page.locator('#root')).toBeEmpty()
62+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
63+
64+
await page.emulateMedia({ colorScheme: 'dark' })
65+
await page.goto('/projects', { waitUntil: 'domcontentloaded' })
66+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
67+
})
68+
69+
test('forces dark on auth pages regardless of preference', async ({ page }) => {
70+
await seedTheme(page, 'light')
71+
await blockReact(page)
72+
73+
for (const path of ['/login/default-silo/saml/mock-idp', '/device/verify']) {
74+
await page.goto(path, { waitUntil: 'domcontentloaded' })
75+
await expect(page.locator('#root')).toBeEmpty()
76+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
77+
}
78+
})
79+
})
80+
81+
test('Login and device pages force dark theme even when preference is light', async ({
82+
page,
83+
}) => {
84+
// Set theme to light via the UI
85+
await page.goto('/projects')
86+
await page.getByRole('button', { name: 'User menu' }).click()
87+
await page.getByRole('menuitem', { name: 'Theme' }).click()
88+
await page.getByRole('menuitemradio', { name: 'Light' }).click()
89+
await page.keyboard.press('Escape')
90+
91+
const html = page.locator('html')
92+
await expect(html).toHaveAttribute('data-theme', 'light')
93+
94+
await page.goto('/login/default-silo/saml/mock-idp')
95+
await expect(html).toHaveAttribute('data-theme', 'dark')
96+
97+
await page.goto('/device/verify')
98+
await expect(html).toHaveAttribute('data-theme', 'dark')
99+
})
100+
10101
test('Serial console terminal updates colors on theme change', async ({ page }) => {
11102
await page.goto('/projects/mock-project/instances/db1/serial-console')
12103

0 commit comments

Comments
 (0)