Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/color-schemes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,18 @@ Primer React uses slightly different terminology than the underlying CSS or the

Key properties:
- **Cache-safe**: The script is identical for every request, so the HTML stays shared-cacheable in the CDN. The theme is never server-rendered from the cookie (that would vary per user and poison the cache).
- **`auto` is resolved**: `data-color-mode` is always a concrete `light` or `dark`, kept in step by a `matchMedia` listener; the raw preference stays on `data-color-mode-preference` for analytics.
- **No drift**: Its validation allowlists and defaults are derived from the same `CssColorMode`, `SupportedTheme`, and `defaultCSSTheme` exports used by `useTheme`. A test in `tests/color-mode-script.ts` runs the script against a fake `document` and asserts parity with `getCssTheme`.
- **CSP**: Because the script is inline, `src/frame/middleware/helmet.ts` adds its `sha256` hash to the `script-src` directive. The hash is computed from the exact script string at startup, so it never needs manual maintenance, and a hash (not a nonce) keeps the response cacheable.

### Why `auto` is resolved before paint

`@primer/react-brand` has no `auto` color mode: it declares its palette on `:root, [data-color-mode="light"]` and `[data-color-mode="dark"]`, with no `prefers-color-scheme` at-rules (`@primer/primitives` has them, so it is unaffected). That makes `<html data-color-mode="auto">` a _light_ root, and a nested wrapper in a different mode re-declares the whole palette for its subtree. Resolving once, at the root, avoids both: `BrandThemeProvider` mirrors `<html>` and declares no mode of its own.

The resolved mode comes from the **effective theme**, not the raw `color_mode`, because github.com's day and night themes are chosen independently — `light` mode can itself be a dark theme.

This is a workaround for a gap in Brand and belongs upstream; until it lands, every consumer has to hand Brand a concrete mode.

## Setup & Usage

To access the current theme in a component:
Expand Down
53 changes: 22 additions & 31 deletions src/color-schemes/components/BrandThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,30 @@
import { useEffect, useState, type PropsWithChildren } from 'react'
import { useTheme as usePrimerTheme } from '@primer/react'
import { ThemeProvider } from '@primer/react-brand'

import { getBrandColorMode } from '@/color-schemes/lib/get-brand-color-mode'
import { getBrandColorMode, type BrandColorMode } from '@/color-schemes/lib/get-brand-color-mode'

// Brand reads `colorMode="auto"` as "snapshot the OS on mount" rather than
// "inherit", so this only ever passes a concrete mode.
export const BrandThemeProvider = ({ children }: PropsWithChildren) => {
// We need to resolve the color scheme through PRC first, because there are
// otherwise many unhandled edge cases.
// E.g. auto mode + dark mode + light scheme.
const { resolvedColorScheme } = usePrimerTheme()
// Seeded to match SSR; reading the DOM here would break hydration.
const [colorMode, setColorMode] = useState<BrandColorMode>('light')

// Brand's ThemeProvider renders a real `<div data-color-mode>`, and brand
// declares its ENTIRE palette on the bare `[data-color-mode="light"]` /
// `[data-color-mode="dark"]` attribute. So a nested wrapper re-declares every
// brand token for its own subtree — canvas, text, borders, links, the lot.
//
// `resolvedColorScheme` is only correct after PRC's cookie-reading effect has
// run. On the server it resolves to light, and the first client render has to
// match the server markup, so it is light there too. Emitting `light` would
// override the correct mode colorModeScript has already stamped on `<html>`
// before first paint, and every brand token on the page would resolve to its
// light value until React hydrates — a white flash on every dark-mode load.
//
// We cannot server-render the real mode: that HTML is shared-cacheable in the
// CDN and must be identical for every request, which is why the pre-paint
// script exists at all.
//
// `auto` matches none of brand's blocks, so the wrapper declares nothing and
// brand's tokens inherit from `<html>`. Verified in-browser: with `<html>` at
// dark, a wrapper at `auto` resolves byte-identical token values to the root,
// whereas a wrapper at `light` flips all 16 tokens this app uses.
const [hydrated, setHydrated] = useState(false)
useEffect(() => setHydrated(true), [])
useEffect(() => {
setColorMode(getBrandColorMode())
// colorModeScript re-stamps <html> when the OS flips under `auto`.
const observer = new MutationObserver(() => setColorMode(getBrandColorMode()))
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-color-mode'],
})
return () => observer.disconnect()
}, [])

const colorMode = hydrated ? getBrandColorMode(resolvedColorScheme) : 'auto'

return <ThemeProvider colorMode={colorMode}>{children}</ThemeProvider>
// Brand spreads rest props after its own attribute, so `data-color-mode={undefined}`
// drops it from the wrapper div; the prop still feeds brand's context.
return (
<ThemeProvider colorMode={colorMode} data-color-mode={undefined}>
{children}
</ThemeProvider>
)
}
7 changes: 6 additions & 1 deletion src/color-schemes/components/useTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ function filterMode(mode = ''): CssColorMode | undefined {
}
}

function filterTheme({ name = '', color_mode = '' } = {}): SupportedTheme | undefined {
// `?? {}` rather than a default parameter: a default only covers `undefined`, and
// the cookie can carry an explicit `null` (`{"light_theme":null}`).
function filterTheme(
theme?: { name?: string; color_mode?: string } | null,
): SupportedTheme | undefined {
const { name = '', color_mode = '' } = theme ?? {}
if (Object.values<string>(SupportedTheme).includes(name)) {
return name as SupportedTheme
}
Expand Down
19 changes: 18 additions & 1 deletion src/color-schemes/lib/color-mode-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { CssColorMode, SupportedTheme, defaultCSSTheme } from '@/color-schemes/c
// page first paints with the SSR default theme and only switches to the user's
// real theme after the React bundle hydrates, causing a visible flash.
//
// `data-color-mode` is always concrete, never `auto` — @primer/react-brand has
// no `auto` palette — and follows the effective theme, because a `light` mode
// can carry a dark day theme. See src/color-schemes/README.md.
//
// The output is identical for every request, so the HTML stays shared-cacheable
// in our CDN. The validation allowlists and defaults are derived from the same
// enums used by `useTheme`, so they can't drift, and `helmet.ts` hashes this
Expand All @@ -31,8 +35,21 @@ css={colorMode:fMode(p.color_mode)||D.colorMode,lightTheme:fTheme(p.light_theme)
}catch(e){}
try{
var h=document.documentElement;
h.setAttribute('data-color-mode',css.colorMode);
var q=window.matchMedia?window.matchMedia('(prefers-color-scheme: dark)'):null;
var apply=function(){
var night=css.colorMode==='auto'?!!(q&&q.matches):css.colorMode==='dark';
var theme=night?css.darkTheme:css.lightTheme;
var mode=theme.indexOf('dark')===0?'dark':'light';
h.setAttribute('data-color-mode',mode);
h.setAttribute('data-'+mode+'-theme',theme);
};
h.setAttribute('data-color-mode-preference',css.colorMode);
h.setAttribute('data-light-theme',css.lightTheme);
h.setAttribute('data-dark-theme',css.darkTheme);
apply();
if(css.colorMode==='auto'&&q){
if(q.addEventListener)q.addEventListener('change',apply);
else if(q.addListener)q.addListener(apply);
}
}catch(e){}
})();`
9 changes: 7 additions & 2 deletions src/color-schemes/lib/get-brand-color-mode.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export function getBrandColorMode(resolvedColorScheme?: string) {
return resolvedColorScheme?.startsWith('dark') ? 'dark' : 'light'
export type BrandColorMode = 'light' | 'dark'

// Brand's palette follows <html>'s `data-color-mode`, resolved to a concrete mode
// before first paint — not PRC's `resolvedColorScheme`, which is the THEME.
export function getBrandColorMode(): BrandColorMode {
if (typeof document === 'undefined') return 'light' // SSR fallback
return document.documentElement.getAttribute('data-color-mode') === 'dark' ? 'dark' : 'light'
}
197 changes: 184 additions & 13 deletions src/color-schemes/tests/color-mode-script.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { describe, expect, test } from 'vitest'

import { colorModeScript } from '../lib/color-mode-script'
import { getCssTheme } from '../components/useTheme'
import { getCssTheme, SupportedTheme } from '../components/useTheme'

// The inline script can't import the React `useTheme` module at runtime (it
// runs before any bundle loads), so it reimplements the same validation. These
// tests run the script against a fake `document` and assert it produces the
// exact same result as `getCssTheme`, which keeps the two in sync.
function runScript(rawCookie: string) {
// The inline script runs before any bundle loads, so it reimplements
// `useTheme`'s validation instead of importing it. These tests assert the two
// stay in sync.
function runScript(
rawCookie: string,
{ prefersDark = false, matchMedia = true, legacyListener = false } = {},
) {
const attrs: Record<string, string> = {}
const listeners: Array<(event: { matches: boolean }) => void> = []
// `matches` reads this through a getter, so `flipSystemPreference` changes
// what an already-registered handler sees.
const os = { prefersDark }
const subscribe = (handler: (event: { matches: boolean }) => void) => {
listeners.push(handler)
}
const fakeDocument = {
cookie: rawCookie,
documentElement: {
Expand All @@ -17,21 +26,52 @@ function runScript(rawCookie: string) {
},
},
}
new Function('document', colorModeScript)(fakeDocument)
return attrs
const fakeWindow = matchMedia
? {
matchMedia(query: string) {
return {
get matches() {
return query.includes('dark') ? os.prefersDark : !os.prefersDark
},
...(legacyListener
? { addListener: subscribe }
: {
addEventListener: (_: string, handler: (event: { matches: boolean }) => void) =>
subscribe(handler),
}),
}
},
}
: {}

new Function('document', 'window', colorModeScript)(fakeDocument, fakeWindow)
return {
attrs,
listeners,
flipSystemPreference() {
os.prefersDark = !os.prefersDark
for (const handler of listeners) handler({ matches: os.prefersDark })
},
}
}

function cookieFor(value: object) {
// The real cookie value is URL-encoded JSON, like the browser stores it.
return `color_mode=${encodeURIComponent(JSON.stringify(value))}`
}

function expectMatchesGetCssTheme(rawCookie: string, cookieValue: string) {
function expectMatchesGetCssTheme(rawCookie: string, cookieValue: string, prefersDark = false) {
const css = getCssTheme(cookieValue)
expect(runScript(rawCookie)).toEqual({
'data-color-mode': css.colorMode,
'data-light-theme': css.lightTheme,
'data-dark-theme': css.darkTheme,
// Primitives select on the (mode, theme) pair, so the effective theme has to
// land on the attribute for the resolved mode.
const mode = css.colorMode === 'auto' ? (prefersDark ? 'dark' : 'light') : css.colorMode
const theme = mode === 'dark' ? css.darkTheme : css.lightTheme
const resolved = theme.startsWith('dark') ? 'dark' : 'light'
expect(runScript(rawCookie, { prefersDark }).attrs).toEqual({
'data-color-mode': resolved,
'data-color-mode-preference': css.colorMode,
'data-light-theme': resolved === 'light' ? theme : css.lightTheme,
'data-dark-theme': resolved === 'dark' ? theme : css.darkTheme,
})
}

Expand Down Expand Up @@ -71,11 +111,142 @@ describe('colorModeScript', () => {
expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value))
})

test('survives an explicitly null theme without discarding the mode', () => {
// A default parameter covers `undefined`, not `null`.
const value = { color_mode: 'dark', light_theme: null }
expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value))
expect(runScript(cookieFor(value)).attrs['data-color-mode']).toBe('dark')
})

test('reads the cookie even when other cookies are present', () => {
const value = { color_mode: 'light' }
const rawCookie = `_octo=GH1.1; color_mode=${encodeURIComponent(
JSON.stringify(value),
)}; logged_in=no`
expectMatchesGetCssTheme(rawCookie, JSON.stringify(value))
})

describe('resolves `auto` against the system preference', () => {
for (const [prefersDark, expected] of [
[false, 'light'],
[true, 'dark'],
] as const) {
test(`prefers-color-scheme: ${expected}`, () => {
const { attrs } = runScript(cookieFor({ color_mode: 'auto' }), { prefersDark })
expect(attrs['data-color-mode']).toBe(expected)
expect(attrs['data-color-mode-preference']).toBe('auto')
})
}

test('no cookie at all still resolves, because the default mode is auto', () => {
expect(runScript('', { prefersDark: true }).attrs['data-color-mode']).toBe('dark')
expect(runScript('').attrs['data-color-mode']).toBe('light')
})

test('keeps following the OS after load, but only for `auto`', () => {
expect(runScript(cookieFor({ color_mode: 'auto' })).listeners).toHaveLength(1)
expect(runScript(cookieFor({ color_mode: 'light' })).listeners).toHaveLength(0)
expect(runScript(cookieFor({ color_mode: 'dark' })).listeners).toHaveLength(0)
})

test('the registered listener actually re-resolves the attributes', () => {
const run = runScript(cookieFor({ color_mode: 'auto' }))
expect(run.attrs['data-color-mode']).toBe('light')
run.flipSystemPreference()
expect(run.attrs['data-color-mode']).toBe('dark')
run.flipSystemPreference()
expect(run.attrs['data-color-mode']).toBe('light')
expect(run.attrs['data-color-mode-preference']).toBe('auto')
})

test('falls back to the deprecated addListener when addEventListener is absent', () => {
// Pre-14 Safari exposes only `addListener`, so this branch is live.
const run = runScript(cookieFor({ color_mode: 'auto' }), { legacyListener: true })
expect(run.attrs['data-color-mode']).toBe('light')
expect(run.listeners).toHaveLength(1)
run.flipSystemPreference()
expect(run.attrs['data-color-mode']).toBe('dark')

expect(
runScript(cookieFor({ color_mode: 'dark' }), { legacyListener: true }).listeners,
).toHaveLength(0)
})

test('still writes the attributes when matchMedia is unavailable', () => {
// The script's DOM block sits in a try/catch, so an unguarded matchMedia
// call would leave <html> with no attributes at all.
const { attrs } = runScript(cookieFor({ color_mode: 'auto' }), { matchMedia: false })
expect(attrs['data-color-mode']).toBe('light')
expect(attrs['data-color-mode-preference']).toBe('auto')
expect(
runScript(cookieFor({ color_mode: 'dark' }), { matchMedia: false }).attrs[
'data-color-mode'
],
).toBe('dark')
})

test('an explicit mode wins over the opposite system preference', () => {
expect(
runScript(cookieFor({ color_mode: 'light' }), { prefersDark: true }).attrs[
'data-color-mode'
],
).toBe('light')
expect(runScript(cookieFor({ color_mode: 'dark' })).attrs['data-color-mode']).toBe('dark')
})
})

describe('follows the effective theme, not the raw mode', () => {
test('a dark day theme makes the document dark', () => {
const { attrs } = runScript(
cookieFor({ color_mode: 'light', light_theme: { name: 'dark', color_mode: 'dark' } }),
)
expect(attrs['data-color-mode']).toBe('dark')
expect(attrs['data-dark-theme']).toBe('dark')
})

test('a dark day theme variant reaches primitives intact', () => {
const { attrs } = runScript(
cookieFor({
color_mode: 'light',
light_theme: { name: 'dark_dimmed', color_mode: 'dark' },
dark_theme: { name: 'dark_high_contrast', color_mode: 'dark' },
}),
)
// Resolved dark by the DAY theme, so data-dark-theme carries that, not the
// separately configured night theme.
expect(attrs['data-color-mode']).toBe('dark')
expect(attrs['data-dark-theme']).toBe('dark_dimmed')
})

test('dark theme variants still count as dark', () => {
for (const name of ['dark_dimmed', 'dark_high_contrast']) {
const { attrs } = runScript(
cookieFor({ color_mode: 'dark', dark_theme: { name, color_mode: 'dark' } }),
)
expect(attrs['data-color-mode']).toBe('dark')
expect(attrs['data-dark-theme']).toBe(name)
}
})

test('auto picks the theme belonging to the side the OS is on', () => {
const value = cookieFor({
color_mode: 'auto',
light_theme: { name: 'dark', color_mode: 'dark' },
dark_theme: { name: 'dark_dimmed', color_mode: 'dark' },
})
expect(runScript(value).attrs['data-color-mode']).toBe('dark')
expect(runScript(value, { prefersDark: true }).attrs['data-color-mode']).toBe('dark')
})

test('every supported theme classifies the same under both operators', () => {
// <html> must classify a theme's lightness the same way @primer/react does
// for its own wrapper: `startsWith('dark')` here, `includes('dark')` there.
// A name like `high_contrast_dark` would split them.
for (const name of Object.values(SupportedTheme)) {
expect(`${name} startsWith:${name.startsWith('dark')}`).toBe(
`${name} startsWith:${name.includes('dark')}`,
)
}
})
})
})
Loading
Loading