Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mexport/e2e/responsive.spec.ts | 7++++---
Mexport/e2e/site-branding.spec.ts | 25++++++++++++++++++-------
Aexport/e2e/theme-accent.spec.ts | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dexport/e2e/theme-family.spec.ts | 51---------------------------------------------------
Mexport/e2e/theme.spec.ts | 150+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
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 }); });