commit f8259263659c84074bc5f03de179f101dd2d9642
parent c1b1c7f4e416ac665e8180da5149cb542ac4ff62
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:50:41 -0400
export: theme specs are base × accent — theme.spec rewritten, theme-accent.spec replaces theme-family (brand S2)
- theme.spec: a site opens on the system base (data-base light under a light
OS) and follows an emulateMedia flip live; the toggle cycles system → light
→ sepia → dark → system, each persisting across a `commit` reload that waits
on data-theme-ready; archive + light migrates to sepia pre-paint.
- theme-accent.spec (theme-family.spec is deleted): the menu's "Base" and
"Accent" groups (four bases; the custom-hex fixture's "Site colour" first,
tagged default, then the seven); pick Violet → persisted, applied before
hydration, computed --brand is Violet's value on light, sepia and dark; pick
the site's colour again → the key is removed and --brand is the fixture's
hex fitted per base.
- site-branding: the accent is data-accent="custom" with the inline
--accent-custom-light, and the computed --brand is #cc3366 on light.
- responsive: the phone menu's radio is "Sepia" (was "Archive").
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 260 insertions(+), 114 deletions(-)
diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts
@@ -90,9 +90,10 @@ test.describe("phone layout", () => {
const menu = page.getByRole("dialog");
await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible();
await expect(menu.getByRole("link", { name: "Search" })).toBeVisible();
- // The theme picker is a dropdown in the wide header; here it is a plain
- // radio list, because a popover inside a dialog is a focus-trap fight.
- await expect(menu.getByRole("radio", { name: "Archive" })).toBeVisible();
+ // The theme picker is a dropdown in the wide header; here it is two plain
+ // radio lists (Base, Accent), because a popover inside a dialog is a
+ // focus-trap fight.
+ await expect(menu.getByRole("radio", { name: "Sepia" })).toBeVisible();
});
test("the filters sheet applies a filter and the results change", async ({
diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts
@@ -81,15 +81,26 @@ test("the Downloads eyebrow appears only when something is behind it", async ({
}
});
-test("per-site accent is baked onto <html> as a brand override", async ({
+test("per-site accent is baked onto <html> as data-accent", async ({
page,
}) => {
await installRoutes(page);
+ await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
- // site.json sets accent #cc3366; the layout emits it as an inline --brand
- // override on <html> at prerender (overriding the family brass).
- await expect(page.locator("html")).toHaveAttribute(
- "style",
- /--brand:\s*#cc3366/,
- );
+ // site.json sets the custom hex #cc3366: the layout renders
+ // data-accent="custom" plus the hex fitted to each base as inline
+ // --accent-custom-* vars at prerender, so it is right on first paint.
+ const html = page.locator("html");
+ await expect(html).toHaveAttribute("data-accent", "custom");
+ await expect(html).toHaveAttribute("style", /--accent-custom-light:\s*#cc3366/);
+ // #cc3366 already reads at 4.5:1 on the light ground, so it is kept as is.
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ getComputedStyle(document.documentElement)
+ .getPropertyValue("--brand")
+ .trim(),
+ ),
+ )
+ .toBe("#cc3366");
});
diff --git a/export/e2e/theme-accent.spec.ts b/export/e2e/theme-accent.spec.ts
@@ -0,0 +1,141 @@
+import { test, expect, type Page } from "@playwright/test";
+import { ACCENTS } from "../../common/lib/brand";
+import { resolveAccent } from "../../common/lib/accent";
+
+// The ThemeMenu's two radio groups. A site opens in its OWN accent (the
+// fixture's site.json sets the custom hex #cc3366, so the layout renders
+// data-accent="custom" and the menu offers "Site colour" first, tagged
+// "default"). A reader's pick of a named accent persists and is applied before
+// paint; picking the site's own again REMOVES the stored key, so the reader
+// follows the site from then on. The base group sits in the same menu.
+
+const ACCENT_KEY = "ytdlp-tb:accent";
+const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, sepia, dark}
+
+async function state(page: Page) {
+ return page.evaluate((key) => {
+ const d = document.documentElement;
+ const cs = getComputedStyle(d);
+ return {
+ accent: d.getAttribute("data-accent"),
+ base: d.getAttribute("data-base"),
+ stored: localStorage.getItem(key),
+ brand: cs.getPropertyValue("--brand").trim(),
+ background: cs.getPropertyValue("--background").trim(),
+ };
+ }, ACCENT_KEY);
+}
+
+// Open the menu, absorbing a pre-hydration lost click on the trigger.
+async function openMenu(page: Page) {
+ const trigger = page.getByRole("button", { name: "Choose theme" });
+ const menu = page.getByRole("menu");
+ await expect(async () => {
+ if (!(await menu.isVisible())) await trigger.click();
+ await expect(menu).toBeVisible({ timeout: 1_000 });
+ }).toPass({ timeout: 10_000 });
+ return menu;
+}
+
+// An item's accessible name is its label, plus "default" on the site's own
+// accent (the visible tag) — so match the label at the start.
+async function pick(page: Page, label: string) {
+ const menu = await openMenu(page);
+ await menu.getByRole("menuitemradio", { name: new RegExp(`^${label}\\b`) }).click();
+ await expect(menu).toBeHidden();
+}
+
+test("the menu offers four bases and the site's colour first, then the seven accents", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const menu = await openMenu(page);
+
+ const base = menu.getByRole("group", { name: "Base" });
+ for (const name of ["System", "Light", "Sepia", "Dark"]) {
+ await expect(base.getByRole("menuitemradio", { name, exact: true })).toBeVisible();
+ }
+ await expect(base.getByRole("menuitemradio", { name: "System", exact: true })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+
+ const accent = menu.getByRole("group", { name: "Accent" });
+ const items = accent.getByRole("menuitemradio");
+ await expect(items).toHaveCount(8);
+ // The custom-hex site's own colour comes first, checked, with its tag.
+ await expect(items.first()).toHaveText(/Site colour\s*default/);
+ await expect(items.first()).toHaveAttribute("aria-checked", "true");
+ for (const a of Object.values(ACCENTS)) {
+ await expect(accent.getByRole("menuitemradio", { name: a.name, exact: true })).toBeVisible();
+ }
+});
+
+test("pick Violet: it persists before paint, and --brand is violet on each base", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "custom",
+ stored: null,
+ brand: FIXTURE.light,
+ });
+
+ await pick(page, "Violet");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "violet",
+ stored: "violet",
+ base: "light",
+ brand: ACCENTS.violet.onLight,
+ });
+
+ // The reload applies the pick on the first commit, before React hydrates —
+ // and hydration does not put the site's own accent back.
+ await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement.dataset.themeReady === "1");
+ expect((await state(page)).accent).toBe("violet");
+ await page.waitForLoadState("load");
+ await expect.poll(() => state(page)).toMatchObject({ accent: "violet", brand: ACCENTS.violet.onLight });
+
+ // Sepia, from the same menu: the ground changes and the accent keeps its
+ // on-sepia value.
+ await pick(page, "Sepia");
+ await expect.poll(() => state(page)).toMatchObject({
+ base: "sepia",
+ accent: "violet",
+ background: "#f4ecd8",
+ brand: ACCENTS.violet.onSepia,
+ });
+ expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:base"))).toBe("sepia");
+
+ await pick(page, "Dark");
+ await expect.poll(() => state(page)).toMatchObject({
+ base: "dark",
+ background: "#0c0a08",
+ brand: ACCENTS.violet.onDark,
+ });
+});
+
+test("picking the site's own colour again removes the stored accent", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ await pick(page, "Brass");
+ await expect.poll(() => state(page)).toMatchObject({ accent: "brass", stored: "brass" });
+
+ await pick(page, "Site colour");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "custom",
+ stored: null,
+ brand: FIXTURE.light,
+ });
+
+ // With nothing stored, the site's colour is fitted to each ground.
+ await pick(page, "Sepia");
+ await expect.poll(() => state(page)).toMatchObject({ base: "sepia", brand: FIXTURE.sepia });
+ await pick(page, "Dark");
+ await expect.poll(() => state(page)).toMatchObject({ base: "dark", brand: FIXTURE.dark });
+});
diff --git a/export/e2e/theme-family.spec.ts b/export/e2e/theme-family.spec.ts
@@ -1,51 +0,0 @@
-import { test, expect, type Page } from "@playwright/test";
-
-// Phase 5: the ThemeMenu family picker. Each family is a pure CSS token swap, so
-// picking one only sets data-theme on <html> (persisted, re-applied pre-paint).
-
-async function familyState(page: Page) {
- return page.evaluate(() => ({
- theme: document.documentElement.getAttribute("data-theme"),
- persisted: localStorage.getItem("ytdlp-tb:theme"),
- }));
-}
-
-async function pickFamily(page: Page, label: string) {
- await page.getByRole("button", { name: "Choose theme" }).click();
- await page.getByRole("menuitemradio", { name: label }).click();
-}
-
-test("theme-family picker switches + persists data-theme with no FOUC", async ({
- page,
-}) => {
- await page.goto("/");
- // Export defaults to the base family (no data-theme attribute).
- expect((await familyState(page)).theme).toBe(null);
- await expect(
- page.getByRole("button", { name: "Choose theme" }),
- ).toBeVisible();
-
- await pickFamily(page, "Selenized");
- let s = await familyState(page);
- expect(s.theme).toBe("selenized");
- expect(s.persisted).toBe("selenized");
-
- // Reload: the pre-paint script re-applies data-theme before React hydrates.
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- expect(
- await page.evaluate(() =>
- document.documentElement.getAttribute("data-theme"),
- ),
- ).toBe("selenized");
-
- await pickFamily(page, "Swiss");
- s = await familyState(page);
- expect(s.theme).toBe("swiss");
- expect(s.persisted).toBe("swiss");
-
- await pickFamily(page, "Archive");
- expect((await familyState(page)).theme).toBe("archive");
-});
diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts
@@ -1,71 +1,115 @@
-import { test, expect, type Page } from "@playwright/test";
+import { test, expect, type Locator, type Page } from "@playwright/test";
-// The export site defaults to the neutral "base" family (the pre-theme
-// zinc/white look) with a system-default mode. Base removes the data-theme
-// attribute (ThemeScript), so it reads as null; the mode toggle must apply,
-// persist across reloads, and be set before hydration.
+// A published site opens on the reader's SYSTEM base (tokens.css resolves it
+// to the light or dark block) and follows the OS live. The base toggle cycles
+// system → light → sepia → dark → system; every choice persists across a
+// reload and is applied by the pre-paint ThemeScript before hydration — the
+// reload waits on `data-theme-ready`, which the script sets last.
+
+const BASE_KEY = "ytdlp-tb:base";
async function state(page: Page) {
- return page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
+ return page.evaluate((key) => {
+ const d = document.documentElement;
+ return {
+ base: d.getAttribute("data-base"),
+ dark: d.classList.contains("dark"),
+ stored: localStorage.getItem(key),
+ background: getComputedStyle(d).getPropertyValue("--background").trim(),
+ };
+ }, BASE_KEY);
}
-async function cycleTo(page: Page, target: "dark" | "light") {
- const toggle = page.getByRole("button", { name: /switch to/i });
- for (let i = 0; i < 3; i++) {
- if ((await state(page)).mode === target) break;
- await toggle.click();
- }
+// A click before hydration is lost (the button is server-rendered inert), so
+// click until the stored base is the one wanted. A click React did take is
+// committed synchronously (a discrete event), so the check right after it
+// never races a taken click into a second one.
+async function toggleTo(page: Page, toggle: Locator, want: string | null) {
+ await expect(async () => {
+ if ((await state(page)).stored !== want) await toggle.click();
+ expect((await state(page)).stored).toBe(want);
+ }).toPass({ timeout: 10_000 });
}
-test("base family default; mode toggle applies + persists with no FOUC", async ({
+async function reloadOnCommit(page: Page) {
+ await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(
+ () => document.documentElement.dataset.themeReady === "1",
+ );
+}
+
+test("a site opens on the system base and follows the OS live", async ({
page,
}) => {
+ await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
- // Default base family => no data-theme attribute on <html>.
- expect((await state(page)).theme).toBe(null);
- await expect(page.getByRole("button", { name: /switch to/i })).toBeVisible();
+ await expect
+ .poll(() => state(page))
+ .toEqual({ base: "light", dark: false, stored: null, background: "#f3f6f7" });
+ await expect(page.getByRole("button", { name: "Switch to light" })).toBeVisible();
- // Explicit dark, then reload-persists before hydration.
- await cycleTo(page, "dark");
- let s = await state(page);
- expect(s.mode).toBe("dark");
- expect(s.dark).toBe(true);
- expect(s.theme).toBe(null);
+ // The OS flips while the page is open: "system" follows without a reload.
+ await page.emulateMedia({ colorScheme: "dark" });
+ await expect
+ .poll(() => state(page))
+ .toEqual({ base: "dark", dark: true, stored: null, background: "#0c0a08" });
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- let onCommit = await page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
- mode: localStorage.getItem("ytdlp-tb:mode"),
- }));
- expect(onCommit.mode).toBe("dark");
- expect(onCommit.dark).toBe(true);
- expect(onCommit.theme).toBe(null);
+ await page.emulateMedia({ colorScheme: "light" });
+ await expect.poll(async () => (await state(page)).base).toBe("light");
+});
- // Explicit light, then reload-persists.
- await cycleTo(page, "light");
- s = await state(page);
- expect(s.mode).toBe("light");
- expect(s.dark).toBe(false);
+test("the toggle cycles the four bases; each persists across a reload with no flash", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const toggle = page.getByRole("button", { name: /switch to/i });
+ await expect(toggle).toBeVisible();
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(
- () => document.documentElement.dataset.themeReady === "1",
- );
- onCommit = await page.evaluate(() => ({
- dark: document.documentElement.classList.contains("dark"),
- theme: document.documentElement.getAttribute("data-theme"),
+ const steps = [
+ { stored: "light", base: "light", dark: false, background: "#f3f6f7", next: "Switch to sepia" },
+ { stored: "sepia", base: "sepia", dark: false, background: "#f4ecd8", next: "Switch to dark" },
+ { stored: "dark", base: "dark", dark: true, background: "#0c0a08", next: "Switch to system" },
+ // Back to system: stored as "system", resolved against the (light) OS.
+ { stored: "system", base: "light", dark: false, background: "#f3f6f7", next: "Switch to light" },
+ ];
+
+ for (const step of steps) {
+ await toggleTo(page, toggle, step.stored);
+ const s = await state(page);
+ expect(s.base, `after ${step.stored}`).toBe(step.base);
+ expect(s.dark, `after ${step.stored}`).toBe(step.dark);
+ expect(s.background, `after ${step.stored}`).toBe(step.background);
+ await expect(page.getByRole("button", { name: step.next })).toBeVisible();
+
+ // The pre-paint script re-applies it on the very first commit, before
+ // React hydrates.
+ await reloadOnCommit(page);
+ const onCommit = await state(page);
+ expect(onCommit.stored, `reload after ${step.stored}`).toBe(step.stored);
+ expect(onCommit.base, `reload after ${step.stored}`).toBe(step.base);
+ expect(onCommit.dark, `reload after ${step.stored}`).toBe(step.dark);
+ }
+});
+
+test("the retired theme/mode keys migrate once, before paint", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.goto("/");
+ await page.evaluate(() => {
+ localStorage.removeItem("ytdlp-tb:base");
+ localStorage.setItem("ytdlp-tb:theme", "archive");
+ localStorage.setItem("ytdlp-tb:mode", "light");
+ });
+ await reloadOnCommit(page);
+ const s = await page.evaluate(() => ({
+ base: document.documentElement.getAttribute("data-base"),
+ stored: localStorage.getItem("ytdlp-tb:base"),
+ theme: localStorage.getItem("ytdlp-tb:theme"),
mode: localStorage.getItem("ytdlp-tb:mode"),
}));
- expect(onCommit.mode).toBe("light");
- expect(onCommit.dark).toBe(false);
- expect(onCommit.theme).toBe(null);
+ // archive + light was the paper look: it becomes sepia, and the old keys go.
+ expect(s).toEqual({ base: "sepia", stored: "sepia", theme: null, mode: null });
});