Public: anyone can read this; nobody can change it.

Boot style follows data-theme so a theme toggle repaints the ground #32

Merged opened this view on origin

Check · Review · Land done · Live — Landed; nothing deploys from this branch, so this is the end of its path.

Merged

theme-boot → main 2 commits

  • Merged. theme-boot was merged into main.

Review

Sign in to review this change.
4 files changed +69 −21
src/design/themes.ts +14 −12
@@ -429,16 +429,18 @@ export function metaThemeColor(name: ThemeName): string {
429429 * while Safari often keeps the prior paint. Matching `html`/`body` to the
430430 * theme before the external stylesheet arrives kills that flash. Kept tiny
431431 * 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.
432437 */
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 {
620620 rel="icon"
621621 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"
622622 />
623 ${raw(`<style nonce="${nonce}">${bootCss(theme)}</style>`)}
623 ${raw(`<style nonce="${nonce}">${bootCss}</style>`)}
624624 <link rel="stylesheet" href="${stylesheetAsset().path}" />
625625 ${when(
626626 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", () => {
588588
589589 test("no cookie means system: no data-theme, switcher present", async () => {
590590 const text = await (await fetch(base)).text();
591 expect(text).not.toContain("data-theme=");
591 expect(text).not.toMatch(/<html[^>]*\sdata-theme=/);
592592 expect(text).toContain('action="/theme"');
593593 for (const name of THEME_NAMES) {
594594 expect(text).toContain(`value="${name}"`);
595595 }
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}}",
599601 );
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 }
600611 });
601612
602613 test("choosing a theme sets the cookie and stamps the page", async () => {
@@ -616,10 +627,16 @@ describe("themes", () => {
616627 expect(text).toContain('data-theme="midnight"');
617628 // The switcher shows the active choice.
618629 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}',
622635 );
636 const system = (await (await fetch(base)).text()).match(
637 /<style nonce="[^"]+">([^<]*)<\/style>/,
638 )?.[1];
639 expect(boot).toBe(system!);
623640 });
624641
625642 test("system clears the cookie; junk never sticks", async () => {
@@ -641,7 +658,7 @@ describe("themes", () => {
641658 const text = await (
642659 await fetch(base, { headers: { cookie: "forge_theme=evil" } })
643660 ).text();
644 expect(text).not.toContain("data-theme=");
661 expect(text).not.toMatch(/<html[^>]*\sdata-theme=/);
645662 });
646663
647664 test("theme POST refuses cross-origin and foreign referers", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs