Public: anyone can read this; nobody can change it.
Boot style follows data-theme so a theme toggle repaints the ground #32
Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.
theme-boot → main 2 commits
4 files
changed +69
−21
src/design/themes.ts +14 −12
| @@ -429,16 +429,18 @@ export function metaThemeColor(name: ThemeName): string { | ||
| 429 | 429 | * while Safari often keeps the prior paint. Matching `html`/`body` to the |
| 430 | 430 | * theme before the external stylesheet arrives kills that flash. Kept tiny |
| 431 | 431 | * and nonce'd — CSP forbids unnonce'd inline style. |
| 432 | * | |
| 433 | * The same for every page, whatever theme it was rendered with: the theme | |
| 434 | * menu flips `data-theme` on the root without a reload, so the ground must | |
| 435 | * follow the attribute, not the theme the server saw. With no attribute the | |
| 436 | * system pair follows the OS; each `html[data-theme]` rule outranks it. | |
| 432 | 437 | */ |
| 433 | export function bootCss(theme: ThemeName | undefined): string { | |
| 434 | if (theme !== undefined) { | |
| 435 | const t = THEMES[theme]; | |
| 436 | return `html,body{background-color:${t.tokens.bg};color-scheme:${t.appearance}}`; | |
| 437 | } | |
| 438 | const light = THEMES.light; | |
| 439 | const dark = THEMES.dark; | |
| 440 | return ( | |
| 441 | `html,body{background-color:${light.tokens.bg};color-scheme:light}` + | |
| 442 | `@media (prefers-color-scheme: dark){html,body{background-color:${dark.tokens.bg};color-scheme:dark}}` | |
| 443 | ); | |
| 444 | } | |
| 438 | export const bootCss = [ | |
| 439 | `html,body{background-color:${THEMES.light.tokens.bg};color-scheme:light}`, | |
| 440 | `@media (prefers-color-scheme: dark){html,body{background-color:${THEMES.dark.tokens.bg};color-scheme:dark}}`, | |
| 441 | ...THEME_NAMES.map((name) => { | |
| 442 | const t = THEMES[name]; | |
| 443 | const root = `html[data-theme="${name}"]`; | |
| 444 | return `${root},${root} body{background-color:${t.tokens.bg};color-scheme:${t.appearance}}`; | |
| 445 | }), | |
| 446 | ].join(""); |
src/web/layout.ts +1 −1
| @@ -620,7 +620,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 620 | 620 | rel="icon" |
| 621 | 621 | href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232563eb'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='3' y='3' width='26' height='26' rx='8' fill='url(%23g)'/%3E%3C/svg%3E" |
| 622 | 622 | /> |
| 623 | ${raw(`<style nonce="${nonce}">${bootCss(theme)}</style>`)} | |
| 623 | ${raw(`<style nonce="${nonce}">${bootCss}</style>`)} | |
| 624 | 624 | <link rel="stylesheet" href="${stylesheetAsset().path}" /> |
| 625 | 625 | ${when( |
| 626 | 626 | options.scriptAsset, |
test/themes.test.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { bootCss, THEME_NAMES, THEMES } from "../src/design/themes.ts"; | |
| 3 | ||
| 4 | describe("bootCss: the ground before the stylesheet arrives (#1240)", () => { | |
| 5 | test("with no data-theme, the system pair follows the OS", () => { | |
| 6 | expect(bootCss).toStartWith( | |
| 7 | "html,body{background-color:#fafafa;color-scheme:light}" + | |
| 8 | "@media (prefers-color-scheme: dark){html,body{background-color:#09090b;color-scheme:dark}}", | |
| 9 | ); | |
| 10 | }); | |
| 11 | ||
| 12 | test("every theme paints its own ground from the attribute, after the media rule", () => { | |
| 13 | const media = bootCss.indexOf("@media"); | |
| 14 | for (const name of THEME_NAMES) { | |
| 15 | const t = THEMES[name]; | |
| 16 | const rule = `html[data-theme="${name}"],html[data-theme="${name}"] body{background-color:${t.tokens.bg};color-scheme:${t.appearance}}`; | |
| 17 | expect(bootCss).toContain(rule); | |
| 18 | expect(bootCss.indexOf(rule)).toBeGreaterThan(media); | |
| 19 | } | |
| 20 | }); | |
| 21 | ||
| 22 | test("names no theme on its own, so a toggle never leaves a stale ground", () => { | |
| 23 | // Every rule outside the system pair is keyed on data-theme. | |
| 24 | const rest = bootCss.slice(bootCss.indexOf("}}") + 2); | |
| 25 | for (const rule of rest.split("}").filter(Boolean)) { | |
| 26 | expect(rule).toStartWith("html[data-theme="); | |
| 27 | } | |
| 28 | }); | |
| 29 | }); |
test/web.test.ts +25 −8
| @@ -588,15 +588,26 @@ describe("themes", () => { | ||
| 588 | 588 | |
| 589 | 589 | test("no cookie means system: no data-theme, switcher present", async () => { |
| 590 | 590 | const text = await (await fetch(base)).text(); |
| 591 | expect(text).not.toContain("data-theme="); | |
| 591 | expect(text).not.toMatch(/<html[^>]*\sdata-theme=/); | |
| 592 | 592 | expect(text).toContain('action="/theme"'); |
| 593 | 593 | for (const name of THEME_NAMES) { |
| 594 | 594 | expect(text).toContain(`value="${name}"`); |
| 595 | 595 | } |
| 596 | // Critical paint before the stylesheet: system pair (light default + dark media). | |
| 597 | expect(text).toMatch( | |
| 598 | /<style nonce="[^"]+">html,body\{background-color:#fafafa;color-scheme:light\}@media \(prefers-color-scheme: dark\)\{html,body\{background-color:#09090b;color-scheme:dark\}\}<\/style>/, | |
| 596 | // Critical paint before the stylesheet: system pair (light default + dark | |
| 597 | // media), then a rule per theme so a toggle repaints without a reload. | |
| 598 | const boot = text.match(/<style nonce="[^"]+">([^<]*)<\/style>/)?.[1]; | |
| 599 | expect(boot).toStartWith( | |
| 600 | "html,body{background-color:#fafafa;color-scheme:light}@media (prefers-color-scheme: dark){html,body{background-color:#09090b;color-scheme:dark}}", | |
| 599 | 601 | ); |
| 602 | expect(boot).toContain( | |
| 603 | 'html[data-theme="dark"],html[data-theme="dark"] body{background-color:#09090b;color-scheme:dark}', | |
| 604 | ); | |
| 605 | expect(boot).toContain( | |
| 606 | 'html[data-theme="light"],html[data-theme="light"] body{background-color:#fafafa;color-scheme:light}', | |
| 607 | ); | |
| 608 | for (const name of THEME_NAMES) { | |
| 609 | expect(boot).toContain(`html[data-theme="${name}"] body{`); | |
| 610 | } | |
| 600 | 611 | }); |
| 601 | 612 | |
| 602 | 613 | test("choosing a theme sets the cookie and stamps the page", async () => { |
| @@ -616,10 +627,16 @@ describe("themes", () => { | ||
| 616 | 627 | expect(text).toContain('data-theme="midnight"'); |
| 617 | 628 | // The switcher shows the active choice. |
| 618 | 629 | expect(text).toMatch(/value="midnight"\s+checked/); |
| 619 | // Boot paint matches midnight, not the system pair. | |
| 620 | expect(text).toMatch( | |
| 621 | /<style nonce="[^"]+">html,body\{background-color:#16161e;color-scheme:dark\}<\/style>/, | |
| 630 | // Boot paint follows the attribute: midnight's ground for this page, and | |
| 631 | // the same rules as a system page, so switching away repaints at once. | |
| 632 | const boot = text.match(/<style nonce="[^"]+">([^<]*)<\/style>/)?.[1]; | |
| 633 | expect(boot).toContain( | |
| 634 | 'html[data-theme="midnight"],html[data-theme="midnight"] body{background-color:#16161e;color-scheme:dark}', | |
| 622 | 635 | ); |
| 636 | const system = (await (await fetch(base)).text()).match( | |
| 637 | /<style nonce="[^"]+">([^<]*)<\/style>/, | |
| 638 | )?.[1]; | |
| 639 | expect(boot).toBe(system!); | |
| 623 | 640 | }); |
| 624 | 641 | |
| 625 | 642 | test("system clears the cookie; junk never sticks", async () => { |
| @@ -641,7 +658,7 @@ describe("themes", () => { | ||
| 641 | 658 | const text = await ( |
| 642 | 659 | await fetch(base, { headers: { cookie: "forge_theme=evil" } }) |
| 643 | 660 | ).text(); |
| 644 | expect(text).not.toContain("data-theme="); | |
| 661 | expect(text).not.toMatch(/<html[^>]*\sdata-theme=/); | |
| 645 | 662 | }); |
| 646 | 663 | |
| 647 | 664 | test("theme POST refuses cross-origin and foreign referers", async () => { |
new above
offline