Skip to content

Commit 05b3d69

Browse files
Ebonsignoriclaude
andauthored
Use Primer Brand color tokens on Brand-migrated surfaces (Docs 2026) (#63177)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fb9ff1 commit 05b3d69

39 files changed

Lines changed: 501 additions & 138 deletions

‎src/color-schemes/components/BrandThemeProvider.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PropsWithChildren } from 'react'
1+
import { useEffect, useState, type PropsWithChildren } from 'react'
22
import { useTheme as usePrimerTheme } from '@primer/react'
33
import { ThemeProvider } from '@primer/react-brand'
44

@@ -9,7 +9,31 @@ export const BrandThemeProvider = ({ children }: PropsWithChildren) => {
99
// otherwise many unhandled edge cases.
1010
// E.g. auto mode + dark mode + light scheme.
1111
const { resolvedColorScheme } = usePrimerTheme()
12-
const colorMode = getBrandColorMode(resolvedColorScheme)
12+
13+
// Brand's ThemeProvider renders a real `<div data-color-mode>`, and brand
14+
// declares its ENTIRE palette on the bare `[data-color-mode="light"]` /
15+
// `[data-color-mode="dark"]` attribute. So a nested wrapper re-declares every
16+
// brand token for its own subtree — canvas, text, borders, links, the lot.
17+
//
18+
// `resolvedColorScheme` is only correct after PRC's cookie-reading effect has
19+
// run. On the server it resolves to light, and the first client render has to
20+
// match the server markup, so it is light there too. Emitting `light` would
21+
// override the correct mode colorModeScript has already stamped on `<html>`
22+
// before first paint, and every brand token on the page would resolve to its
23+
// light value until React hydrates — a white flash on every dark-mode load.
24+
//
25+
// We cannot server-render the real mode: that HTML is shared-cacheable in the
26+
// CDN and must be identical for every request, which is why the pre-paint
27+
// script exists at all.
28+
//
29+
// `auto` matches none of brand's blocks, so the wrapper declares nothing and
30+
// brand's tokens inherit from `<html>`. Verified in-browser: with `<html>` at
31+
// dark, a wrapper at `auto` resolves byte-identical token values to the root,
32+
// whereas a wrapper at `light` flips all 16 tokens this app uses.
33+
const [hydrated, setHydrated] = useState(false)
34+
useEffect(() => setHydrated(true), [])
35+
36+
const colorMode = hydrated ? getBrandColorMode(resolvedColorScheme) : 'auto'
1337

1438
return <ThemeProvider colorMode={colorMode}>{children}</ThemeProvider>
1539
}

‎src/content-render/stylesheets/accessibility.scss‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@
4848
.tooltipped {
4949
&:focus-visible::before,
5050
&:focus-visible::after {
51-
outline: 2px solid var(--color-focus-outset);
51+
// --color-focus-outset is defined nowhere in this app, which made the whole
52+
// `outline` shorthand invalid at computed-value time — so the longhands reset
53+
// and `outline-style: none` won, leaving high-contrast users with NO focus
54+
// ring at all, in either colour mode. Brand's focus token, matching the same
55+
// repair already made in annotate.scss.
56+
outline: var(--brand-borderWidth-thick, 2px) solid
57+
var(--brand-color-focus, #0377ff);
5258
outline-offset: 2px;
5359
}
5460
}

‎src/content-render/stylesheets/syntax-highlighting.scss‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ from https://unpkg.com/highlight.js@9.15.8/styles/github.css
88
.hljs {
99
display: block;
1010
padding: 0.5em;
11-
color: var(--color-fg-default);
11+
// The block's BASE text colour — the tokens below are prettylights, which has
12+
// no Brand equivalent and stays on Primer deliberately, but this one is just
13+
// "default text" and was painting Primer's #e6edf3 inside a Brand-framed code
14+
// block. The `background` is inert here (markdown-overrides paints the `pre`).
15+
color: var(--brand-color-text-default);
1216
background: var(--color-canvas-subtle);
1317
}
1418

‎src/fixtures/tests/playwright-rendering.spec.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import dotenv from 'dotenv'
22
import { test, expect } from '@playwright/test'
33
import { turnOffExperimentsInPage } from '../helpers/turn-off-experiments'
4-
import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED } from '../../frame/lib/constants'
4+
import {
5+
HOVERCARDS_ENABLED,
6+
ANALYTICS_ENABLED,
7+
COLOR_MODE_COOKIE_NAME,
8+
} from '../../frame/lib/constants'
59

610
// This exists for the benefit of local testing.
711
// In GitHub Actions, we rely on setting the environment variable directly
@@ -19,6 +23,52 @@ test('view home page', async ({ page }) => {
1923
await expect(page).toHaveTitle(/GitHub Docs/)
2024
})
2125

26+
test.describe('Brand document canvas', () => {
27+
test('follows system color scheme changes in auto mode without a cookie', async ({ page }) => {
28+
await page.emulateMedia({ colorScheme: 'dark' })
29+
await page.goto('/get-started/foo/bar')
30+
await expect(page.locator('html')).toHaveAttribute('data-color-mode', 'auto')
31+
32+
// Check both the initial dark paint and live preference changes without reloading.
33+
for (const colorScheme of ['dark', 'light', 'dark'] as const) {
34+
await page.emulateMedia({ colorScheme })
35+
const backgroundColor = colorScheme === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'
36+
const textColor = colorScheme === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(0, 0, 0)'
37+
38+
for (const selector of ['html', 'body']) {
39+
await expect(page.locator(selector)).toHaveCSS('background-color', backgroundColor)
40+
await expect(page.locator(selector)).toHaveCSS('color', textColor)
41+
}
42+
}
43+
})
44+
45+
for (const colorMode of ['light', 'dark'] as const) {
46+
test(`preserves explicit ${colorMode} mode against the opposite system preference`, async ({
47+
page,
48+
context,
49+
baseURL,
50+
}) => {
51+
await page.emulateMedia({ colorScheme: colorMode === 'light' ? 'dark' : 'light' })
52+
await context.addCookies([
53+
{
54+
name: COLOR_MODE_COOKIE_NAME,
55+
value: encodeURIComponent(JSON.stringify({ color_mode: colorMode })),
56+
url: new URL('/', baseURL).href,
57+
},
58+
])
59+
await page.goto('/get-started/foo/bar')
60+
await expect(page.locator('html')).toHaveAttribute('data-color-mode', colorMode)
61+
62+
const backgroundColor = colorMode === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'
63+
const textColor = colorMode === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(0, 0, 0)'
64+
for (const selector of ['html', 'body']) {
65+
await expect(page.locator(selector)).toHaveCSS('background-color', backgroundColor)
66+
await expect(page.locator(selector)).toHaveCSS('color', textColor)
67+
}
68+
})
69+
}
70+
})
71+
2272
test('logo link keeps current version', async ({ page }) => {
2373
await page.goto('/enterprise-cloud@latest')
2474
await turnOffExperimentsInPage(page)

‎src/frame/components/DefaultLayout.module.scss‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,13 @@
6161
display: flex;
6262
}
6363
}
64+
65+
// The a11y skip link is a filled accent chip. Was primer/css
66+
// `color-bg-accent-emphasis color-fg-on-emphasis`, which paints the chip from
67+
// the PRC palette on a Brand surface. The Brand accent fill inverts between
68+
// modes (dark green in light, light green in dark), so onEmphasis text is
69+
// correct here.
70+
.skipButton {
71+
background-color: var(--brand-color-accent-primary);
72+
color: var(--brand-color-text-onEmphasis);
73+
}

‎src/frame/components/DefaultLayout.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ export const DefaultLayout = (props: Props) => {
223223
{/* a11y */}
224224
<a
225225
href="#main-content"
226-
className="visually-hidden skip-button color-bg-accent-emphasis color-fg-on-emphasis"
226+
className={cx('visually-hidden skip-button', styles.skipButton)}
227227
inert={isNarrowMenuOpen}
228228
aria-hidden={isNarrowMenuOpen || undefined}
229229
>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
11
.logoContainer {
22
z-index: 3;
33
}
4+
5+
// Header rule under the simple error-page banner. Was primer/css
6+
// `border-bottom color-border-muted`; the utility emits the whole shorthand
7+
// with !important, so the replacement has to own the shorthand too.
8+
.headerBorder {
9+
border-bottom: 1px solid var(--brand-color-border-muted);
10+
}
11+
12+
// Wordmark logo + "GitHub Docs" link. Was primer/css `color-fg-default`.
13+
.fgDefault {
14+
color: var(--brand-color-text-default);
15+
}
16+
17+
// Footer copyright line. Was primer/css `color-fg-muted`.
18+
.fgMuted {
19+
color: var(--brand-color-text-muted);
20+
}

‎src/frame/components/GenericError.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,20 @@ export function GenericError() {
4040
export const SimpleHeader = () => {
4141
const router = useRouter()
4242
return (
43-
<div className="border-bottom color-border-muted no-print">
43+
<div className={`no-print ${styles.headerBorder}`}>
4444
<header
4545
className="container-xl p-responsive py-3 position-relative d-flex width-full"
4646
role="banner"
4747
aria-label="Main"
4848
>
4949
<div className={`d-flex flex-items-center ${styles.logoContainer}`} id="github-logo-mobile">
5050
<Link href={`/${router.locale}`} aria-hidden="true" tabIndex={-1}>
51-
<MarkGithubIcon size={32} className="color-fg-default" />
51+
<MarkGithubIcon size={32} className={styles.fgDefault} />
5252
</Link>
5353

5454
<Link
5555
href={`/${router.locale}`}
56-
className="no-underline h4 color-fg-default no-wrap pl-2"
56+
className={`no-underline h4 no-wrap pl-2 ${styles.fgDefault}`}
5757
>
5858
GitHub Docs
5959
</Link>
@@ -68,7 +68,7 @@ export const SimpleFooter = () => {
6868
<footer className="py-6">
6969
<div className="container-xl px-3 px-md-6">
7070
<ul role="list" className="d-flex flex-wrap list-style-none">
71-
<li role="listitem" className="d-flex mr-xl-3 color-fg-muted">
71+
<li role="listitem" className={`d-flex mr-xl-3 ${styles.fgMuted}`}>
7272
<span>&copy; {new Date().getFullYear()} GitHub, Inc.</span>
7373
</li>
7474
<li role="listitem" className="ml-3">

‎src/frame/components/article/ArticleGridLayout.module.scss‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,13 +214,11 @@
214214
// rules' right overshoot; those now stop at the body column's edge.)
215215
position: relative;
216216
z-index: 1;
217-
// HOLDBACK: stays on Primer's canvas token deliberately. Brand's
218-
// --brand-color-canvas-default is pure #000 in dark mode, but the page behind
219-
// it is still painted #0d1117 by @primer/css's `body` rule — so adopting the
220-
// brand canvas here makes this surface a visibly darker panel floating on a
221-
// lighter page. The canvas can only move once the page canvas does; those
222-
// should change together in one commit.
223-
background-color: var(--color-canvas-default);
217+
// Brand's canvas, matching the page. This was held back on Primer's token
218+
// while `body` was still painted #0d1117 by @primer/css; that moved to
219+
// --brand-color-canvas-default in src/frame/stylesheets/index.scss, so the
220+
// holdback's own release condition ("change together in one commit") is met.
221+
background-color: var(--brand-color-canvas-default);
224222
// Extend the border up to the bottom of the secondary bar (like the left
225223
// rail's divider). The grid's 24px padding-top + the article wrapper's 24px
226224
// top margin (`my-4`) otherwise leave the border starting ~48px below the

‎src/frame/components/page-footer/DocsFooter.module.scss‎

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,10 @@
1-
// In dark mode the footer sits on the repo's canvas (#0d1117) while brand's
2-
// canvas-subtle comes from brand's own palette (#0f1511). Those are near-identical,
3-
// so the controls would lose their fill and read as bare outlines. Use Primer's
4-
// surfaces there instead: they're built to contrast with this background. Selector
5-
// pattern follows octicon-table-optimization.scss.
6-
@mixin footer-control-surfaces-dark {
7-
--color-btn-bg: var(--color-canvas-subtle);
8-
--color-btn-border: var(--color-border-default);
9-
--color-btn-text: var(--color-fg-default);
10-
--brand-footer-backToTop-bgColor-rest: var(--color-canvas-subtle);
11-
// Brand's own dark border token is a different grey from Primer's, which would
12-
// leave Back to top outlined differently from the three buttons beside it.
13-
--brand-footer-backToTop-borderColor-rest: var(--color-border-default);
14-
}
15-
161
.docsFooter {
172
// Brand defaults the footer to canvas-subtle (a grey panel); the design puts it on
183
// the page canvas so it reads as continuous with the article and the band above.
19-
// Deliberately the *repo's* canvas, not brand's: brand's canvas-default is pure
20-
// black in dark mode while these pages sit on #0d1117, which would make the footer
21-
// a visibly darker slab.
22-
--brand-footer-bg-color: var(--bgColor-default, var(--color-canvas-default));
4+
// Brand's canvas, not Primer's: every other painted element in here is brand, and
5+
// `--bgColor-default` is undefined in this app so the old value fell through to
6+
// Primer's blue-tinted #0d1117 in dark mode.
7+
--brand-footer-bg-color: var(--brand-color-canvas-default);
238

249
// The design draws all four footer controls (Yes, No, Make a contribution and
2510
// Back to top) on brand's subtle canvas with a subtle border (#f2f5f3 on #d2d9d4,
@@ -29,6 +14,18 @@
2914
// read rather than overriding the rules: the survey's selected states apply
3015
// `color-bg-success-emphasis` / `color-bg-danger-emphasis`, which set
3116
// background-color directly and must keep winning over the rest state.
17+
//
18+
// BOTH spellings are required. @primer/css declares the button like this:
19+
// border-color: var(--button-default-borderColor-rest, var(--color-btn-border));
20+
// The legacy `--color-btn-*` names are only the FALLBACK. They used to win because
21+
// the `--button-default-*` names were undefined in this app, but index.scss now
22+
// imports the Primer Primitives functional themes, which define them — so setting
23+
// only the legacy names would silently lose to Primer's palette (#3d444d borders
24+
// in dark). Setting the modern names is what actually takes effect; the legacy
25+
// ones are kept so this still works if the functional themes are ever dropped.
26+
--button-default-bgColor-rest: var(--brand-color-canvas-subtle);
27+
--button-default-borderColor-rest: var(--brand-color-border-subtle);
28+
--button-default-fgColor-rest: var(--brand-color-text-default);
3229
--color-btn-bg: var(--brand-color-canvas-subtle);
3330
--color-btn-border: var(--brand-color-border-subtle);
3431
--color-btn-text: var(--brand-color-text-default);
@@ -37,16 +34,6 @@
3734
// the same treatment as the other three. Its border already matches.
3835
--brand-footer-backToTop-bgColor-rest: var(--brand-color-canvas-subtle);
3936

40-
:global([data-color-mode="dark"]) & {
41-
@include footer-control-surfaces-dark;
42-
}
43-
44-
@media (prefers-color-scheme: dark) {
45-
:global([data-color-mode="auto"][data-dark-theme*="dark"]) & {
46-
@include footer-control-surfaces-dark;
47-
}
48-
}
49-
5037
// The band above supplies the visual break, and the design puts a 2px emphasized
5138
// rule directly beneath it, on the footer's own top edge. Brand's default here is
5239
// a much brighter green (scale-green-3), so this stays an explicit override.
@@ -132,8 +119,7 @@
132119
.copyrightText {
133120
order: 2;
134121
padding: var(--base-size-20);
135-
border-top: 1px solid
136-
var(--borderColor-muted, var(--color-border-muted, #d2d9d4));
122+
border-top: 1px solid var(--brand-color-border-subtle);
137123
}
138124
}
139125

@@ -174,7 +160,10 @@
174160

175161
// KO law requires the privacy statement link to be conspicuous.
176162
&[data-conspicuous="true"] {
177-
color: var(--fgColor-attention, var(--color-fg-attention, #9a6700));
163+
// Brand ships no attention token, so this chains to Primer's — but the old name
164+
// is `--color-attention-fg`; spelled `--color-fg-attention` nothing in the chain
165+
// resolved and the hardcoded light amber won in dark mode too.
166+
color: var(--fgColor-attention, var(--color-attention-fg));
178167
font-weight: 600;
179168
}
180169
}

0 commit comments

Comments
 (0)