Archilyzer · Source

archilyzer

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

commit e13a20a96911e681dec15ea9884add09f01af7c8
parent c9f725dd8c7b1978cf9be0947d858f07a8454a8f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 19:32:15 -0400

homepage: one toggle that cycles the base in place of the Options dialog; the homepage's accent is pinned

- The header's theme control is common/components/ThemeToggle with a new
  `variant="bare"`: dressed as a social key (36 px, 44 px under a coarse
  pointer, no border, the hover square, the ring), a 20 px glyph, the last key
  of the group, named "Switch to {next}". The default variant is unchanged for
  the export and the editor.
- The homepage has no accent control, so ThemeScript and ThemeProvider take
  `pinAccent`: the stored accent is not read (and not removed) and the
  homepage keeps its own; without the prop both are unchanged.
- OptionsDialog and options.spec are deleted (no caller). ThemeRadios stays:
  the export's slide-out menu renders it.
- e2e: chooseTheme clicks the toggle until the base is reached; toggle.spec
  (the key at every width, 36/44 px, no other theme control or dialog, the
  whole cycle derived from THEME_BASES/nextBase with page, icon and name
  following, focus ring and forced-colours outline, a stored accent ignored
  with no flash); social.spec's group checks name the toggle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Dcommon/components/OptionsDialog.tsx | 77-----------------------------------------------------------------------------
Mcommon/components/ThemeProvider.tsx | 9+++++++--
Mcommon/components/ThemeRadios.tsx | 3+--
Mcommon/components/ThemeScript.tsx | 7++++++-
Mcommon/components/ThemeToggle.tsx | 22+++++++++++++++++++---
Mcommon/components/themeConfig.test.ts | 11+++++++++++
Mcommon/components/themeConfig.ts | 10++++++++--
Mhomepage/app/components/Header.tsx | 18++++++++++--------
Mhomepage/app/layout.tsx | 8+++++---
Mhomepage/e2e/helpers.ts | 71++++++++++++++++++++++++++++++++---------------------------------------
Dhomepage/e2e/options.spec.ts | 176-------------------------------------------------------------------------------
Mhomepage/e2e/social.spec.ts | 31++++++++++++++++---------------
Mhomepage/e2e/theme.spec.ts | 8++++----
Ahomepage/e2e/toggle.spec.ts | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 243 insertions(+), 332 deletions(-)

diff --git a/common/components/OptionsDialog.tsx b/common/components/OptionsDialog.tsx @@ -1,77 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { Settings, XIcon } from "lucide-react"; -import { - Dialog, - DialogClose, - DialogContent, - DialogTitle, - DialogTrigger, -} from "./ui/dialog"; -import { ThemeRadios } from "./ThemeRadios"; -import { cn } from "../lib/utils"; - -// ONE OPTIONS BUTTON in place of the theme dropdown (ThemeMenu) and the base -// toggle (ThemeToggle): a gear, named "Options", that opens a modal titled -// "Options" holding the theme's two radio groups, Base and Accent (ThemeRadios). -// -// A pick applies at once and the dialog stays open. Escape, the close button -// and a click outside close it; while it is open focus is trapped in it and the -// page does not scroll; on close focus returns to the gear (Radix Dialog, the -// shared ui/dialog.tsx, used as it is). The dialog opens with focus on the -// CHECKED Base radio, so an arrow key moves from the base in force. The content -// scrolls inside the dialog when a short screen cannot hold it. -// -// Styled here, not in the shared wrapper (the editor's command palette renders -// it too): the panel names `border-border` (no app sets a base border colour, -// and Tailwind v4's bare `border` is currentColor), and its own Close is a key -// like the gear, in place of the wrapper's. -// -// The trigger is dressed exactly as a social link's key (SocialLinks.tsx): a -// 36 px box, 44 px under a coarse pointer, no border, the muted foreground and -// the faint hover square, the ring colour's 2 px focus ring, and in forced -// colours the browser's own outline — so placed directly after the social row -// it reads as that row's last key. The name and the radio labels are e2e -// contracts. Must render inside a <ThemeProvider/>; the no-flash script and -// the provider are untouched by where this renders. -const KEY = - "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11"; - -const NAME_PREFIX = "options-theme"; - -export function OptionsDialog({ className }: { className?: string }) { - const content = useRef<HTMLDivElement>(null); - return ( - <Dialog> - <DialogTrigger aria-label="Options" title="Options" className={cn(KEY, className)}> - <Settings className="size-5" aria-hidden="true" /> - </DialogTrigger> - <DialogContent - ref={content} - showCloseButton={false} - // A dialog of two radio groups needs no prose; this is Radix's opt-out - // of its "missing Description" warning. - aria-describedby={undefined} - onOpenAutoFocus={(event) => { - const checked = content.current?.querySelector<HTMLInputElement>( - `input[name="${NAME_PREFIX}-base"]:checked`, - ); - if (checked) { - event.preventDefault(); - checked.focus(); - } - }} - className="max-h-[calc(100dvh-2rem)] gap-5 overflow-y-auto border-border p-5 text-foreground sm:max-w-sm" - > - <DialogTitle className="font-display text-lg">Options</DialogTitle> - <DialogClose aria-label="Close" className={cn(KEY, "absolute top-3 right-3")}> - <XIcon className="size-4" aria-hidden="true" /> - </DialogClose> - <div className="-mx-2 flex flex-col gap-5"> - <ThemeRadios namePrefix={NAME_PREFIX} /> - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx @@ -116,13 +116,18 @@ function store(key: string, value: string | null) { } } +// `pinAccent`: an app that offers no accent control (the homepage) keeps its +// own accent whatever the reader stored for the apps that do; the stored value +// is neither read nor removed. Pair with the same prop on ThemeScript. export function ThemeProvider({ defaultBase = "system", siteAccent = DEFAULT_ACCENT, + pinAccent = false, children, }: { defaultBase?: ThemeBase; siteAccent?: ThemeAccent; + pinAccent?: boolean; children: React.ReactNode; }) { // Initial state MUST equal what the server rendered (the defaults), so @@ -137,11 +142,11 @@ export function ThemeProvider({ useLayoutEffect(() => { const stored = readStored(); setBaseState(stored.base ?? defaultBase); - setAccentState(stored.accent ?? siteAccent); + setAccentState(pinAccent ? siteAccent : (stored.accent ?? siteAccent)); setSystemDark(systemPrefersDark()); setAdopted(true); // Effectively once: the defaults are stable props from the layout. - }, [defaultBase, siteAccent]); + }, [defaultBase, siteAccent, pinAccent]); // A LIVE OS preference: "system" follows a change made while the page is // open (the pre-paint script can only read it once). diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx @@ -12,8 +12,7 @@ import { // Sepia / Dark) and "Accent" (the seven named accents, the site's own tagged // "default"; a custom-hex site adds "Site colour" first) — the same lists // ThemeMenu's dropdown offers. Used where a dropdown inside another layer would -// be a focus-trap fight: the Options dialog (OptionsDialog.tsx) and the -// export's slide-out menu. A pick applies at once (attributes on <html>; +// be a focus-trap fight: the export's slide-out menu. A pick applies at once (attributes on <html>; // tokens.css does the rest) and nothing closes. // // Renders the two groups as siblings, each `groupClassName`, so a caller lays diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx @@ -15,14 +15,19 @@ import { buildThemeScript, type ThemeBase } from "./themeConfig"; // and the server editor. Pair with `suppressHydrationWarning` on <html>, since // this mutates the element before React hydrates — and with ThemeProvider, // which re-asserts the same attributes after hydration. +// +// `pinAccent` (the homepage, which offers no accent control): the stored +// accent is not read; see buildThemeScript. export function ThemeScript({ defaultBase = "system", + pinAccent = false, }: { defaultBase?: ThemeBase; + pinAccent?: boolean; }) { return ( <script - dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }} + dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase, pinAccent }) }} suppressHydrationWarning /> ); diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx @@ -17,7 +17,21 @@ const LABEL: Record<ThemeBase, string> = { dark: "dark", }; -export function ThemeToggle({ className }: { className?: string }) { +// `variant="bare"` (the homepage): dressed as a social link's key +// (SocialLinks.tsx) — a 36 px box (44 px under a coarse pointer), no border, the +// muted foreground and the faint hover square, a 20 px glyph, the ring +// colour's 2 px focus ring and, in forced colours, the browser's own outline — +// so it reads as the last key of the row it follows. The default is unchanged. +const BARE = + "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11"; + +export function ThemeToggle({ + className, + variant = "default", +}: { + className?: string; + variant?: "default" | "bare"; +}) { const { base, cycleBase } = useTheme(); const Icon = ICON[base]; @@ -29,11 +43,13 @@ export function ThemeToggle({ className }: { className?: string }) { title={`Theme: ${LABEL[base]}`} data-theme-base={base} className={cn( - "inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]", + variant === "bare" + ? BARE + : "inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]", className, )} > - <Icon className="h-4 w-4" aria-hidden="true" /> + <Icon className={variant === "bare" ? "size-5" : "h-4 w-4"} aria-hidden="true" /> </button> ); } diff --git a/common/components/themeConfig.test.ts b/common/components/themeConfig.test.ts @@ -217,3 +217,14 @@ test("isThemeAccent takes the seven ids and custom only", () => { assert.ok(!isThemeAccent("#cc3366")); assert.ok(!isThemeAccent("Signal")); }); + +// The homepage offers no accent control, so its script does not read the +// stored accent (pinAccent); every other app's script is what it was. +test("pinAccent: the stored accent is never read; without it the script is unchanged", () => { + const read = `getItem(${JSON.stringify(ACCENT_KEY)})`; + for (const defaultBase of ["system", "dark"] as const) { + assert.equal(buildThemeScript({ defaultBase, pinAccent: false }), buildThemeScript({ defaultBase })); + assert.ok(buildThemeScript({ defaultBase }).includes(read)); + assert.ok(!buildThemeScript({ defaultBase, pinAccent: true }).includes(read)); + } +}); diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts @@ -194,9 +194,15 @@ export function migrateLegacy({ // Storage may throw (privacy modes): each storage touch is guarded, so a // failure still paints the default base and still sets the marker. // themeConfig.test.ts runs this string in node:vm over the whole matrix. +// +// `pinAccent`: an app with no accent control of its own (the homepage) never +// reads the stored accent — step 4 is skipped, the server-rendered accent +// stays, and the reader's stored value is left where it is for the apps that +// offer the choice. Without it the script is exactly what it was. export function buildThemeScript({ defaultBase = "system", -}: { defaultBase?: ThemeBase } = {}): string { + pinAccent = false, +}: { defaultBase?: ThemeBase; pinAccent?: boolean } = {}): string { const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system"; const q = JSON.stringify; return ( @@ -212,7 +218,7 @@ export function buildThemeScript({ "}" + `s.removeItem(${q(LEGACY_THEME_KEY)});s.removeItem(${q(LEGACY_MODE_KEY)});` + "}" + - `a=s.getItem(${q(ACCENT_KEY)});` + + (pinAccent ? "" : `a=s.getItem(${q(ACCENT_KEY)});`) + "}catch(e){}" + `if(b!=='light'&&b!=='sepia'&&b!=='dark'&&b!=='system')b=${q(fallback)};` + "var r=b;" + diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx @@ -6,7 +6,7 @@ import { import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; -import { OptionsDialog } from "yt-dlp-transcript-common/components/OptionsDialog"; +import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle"; import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; @@ -41,10 +41,12 @@ function NavList({ className }: { className?: string }) { // is the same string on every install. The operator's own naming still governs // /stats/ and the social links, which this bar carries as well as the footer. // -// THE SOCIAL ROW AND THE GEAR ARE ONE GROUP, in the bar at every width: the -// operator's links (common/components/SocialLinks.tsx) and then "Options" -// (common/components/OptionsDialog.tsx), dressed alike, their 36 px boxes -// touching, so every glyph is 16 px from the next. The header shows at most +// THE SOCIAL ROW AND THE THEME TOGGLE ARE ONE GROUP, in the bar at every width: +// the operator's links (common/components/SocialLinks.tsx) and then the toggle +// that cycles the base (common/components/ThemeToggle.tsx, `variant="bare"`), +// dressed alike, their 36 px boxes touching, so every glyph is 16 px from the +// next. The homepage offers no accent control: its accent is pinned +// (layout.tsx). The header shows at most // four links (headerSocialLinks); none is hidden by width. Measured with the // wordmark link at 148–152 px, the nav at 276 px and a key 36 px (44 px under a // coarse pointer): @@ -64,10 +66,10 @@ function NavList({ className }: { className?: string }) { // 2 < 272 < 296 < 312 / < 336 // 3 < 308 < 340 < 348 / < 380 // 4 < 344 < 384 < 384 / < 424 -// With the mark alone, four links and the gear fit a 320 px screen under touch. +// With the mark alone, four links and the toggle fit a 320 px screen under touch. // THE LAST RESORT is the scroll box around the row (SocialScroll): below that // (under 320 px, or with a much larger text size) the row scrolls sideways -// inside the bar, its END shown first, the gear outside it, no scrollbar +// inside the bar, its END shown first, the toggle outside it, no scrollbar // drawn, and the header never wider than the screen. A link that takes focus // is scrolled into view with its ring. // @@ -114,7 +116,7 @@ export default function Header() { <SocialLinks links={socialLinks} placement="header" className="w-max px-1 [direction:ltr]" /> </SocialScroll> )} - <OptionsDialog /> + <ThemeToggle variant="bare" /> </div> </div> </div> diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx @@ -71,9 +71,11 @@ export default function RootLayout({ > <body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--brand-soft)] selection:text-[var(--foreground)]"> {/* The project's own site opens on the dark base in Signal, the - family's accent; a reader can pick any other. */} - <ThemeScript defaultBase="dark" /> - <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}> + family's accent. A reader cycles the base with the header's toggle; + the accent is pinned to Signal (the header offers no accent + control), whatever this origin's storage holds. */} + <ThemeScript defaultBase="dark" pinAccent /> + <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT} pinAccent> <Header /> <main className="flex-1 w-full">{children}</main> <Footer /> diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts @@ -1,48 +1,41 @@ import { expect, type Page } from "@playwright/test"; +import { THEME_BASES, nextBase, type ThemeBase } from "../../common/components/themeConfig"; // THE ONE WAY a homepage spec changes the theme through the UI: the header's -// gear, "Options", opens a dialog with two radio groups, Base and Accent -// (common/components/OptionsDialog.tsx + ThemeRadios.tsx). Specs that set -// `localStorage` directly do not go through here. -// -// The labels are the radios' names: Base "System" | "Light" | "Sepia" | "Dark"; -// Accent a named accent ("Signal", "Brass", "Vermilion", "Violet", "Sakura", -// "Blue", "Green"). The dialog closes again with Escape before this returns. +// toggle (common/components/ThemeToggle.tsx, `variant="bare"`), which cycles +// the base in nextBase's order, shows the base in force as `data-theme-base`, +// and is named "Switch to {next}". Specs that only need a ground set +// `localStorage` instead. The homepage has no accent control (its accent is +// pinned), so there is no accent to choose here. -export const optionsButton = (page: Page) => - page.locator("header").getByRole("button", { name: "Options", exact: true }); +export const themeToggle = (page: Page) => + page.locator("header").getByRole("button", { name: /^switch to /i }); -export const optionsDialog = (page: Page) => - page.getByRole("dialog", { name: "Options" }); - -// A click before hydration is lost, so click until the dialog is open. -export async function openOptions(page: Page) { - const dialog = optionsDialog(page); - await expect(async () => { - if (!(await dialog.isVisible())) await optionsButton(page).click(); - await expect(dialog).toBeVisible({ timeout: 1_000 }); - }).toPass({ timeout: 15_000 }); - return dialog; +// The cycle as the toggle walks it, from `start`: every base once. Derived +// from THEME_BASES and nextBase, so a base added or removed there needs no +// change here. +export function themeCycle(start: ThemeBase): ThemeBase[] { + const out: ThemeBase[] = [start]; + for (let b = nextBase(start); b !== start && out.length <= THEME_BASES.length; b = nextBase(b)) { + out.push(b); + } + return out; } -export async function chooseTheme( - page: Page, - choice: { base?: string; accent?: string }, -) { - const dialog = await openOptions(page); - if (choice.base) { - const radio = dialog - .getByRole("radiogroup", { name: "Base" }) - .getByRole("radio", { name: choice.base, exact: true }); - await radio.check(); - } - if (choice.accent) { - // An accent's name carries "default" after the site's own. - const radio = dialog - .getByRole("radiogroup", { name: "Accent" }) - .getByRole("radio", { name: new RegExp(`^${choice.accent}( default)?$`) }); - await radio.check(); +export const baseLabel = (b: ThemeBase) => + THEME_BASES.find((x) => x.id === b)!.label.toLowerCase(); + +// Click the toggle until the base in force is `base`. A click before +// hydration is lost, so each step waits for the attribute to move. +export async function chooseTheme(page: Page, { base }: { base: ThemeBase }) { + const toggle = themeToggle(page); + for (let i = 0; i <= THEME_BASES.length; i++) { + const current = (await toggle.getAttribute("data-theme-base")) as ThemeBase; + if (current === base) return; + await expect(async () => { + if ((await toggle.getAttribute("data-theme-base")) === current) await toggle.click(); + expect(await toggle.getAttribute("data-theme-base")).not.toBe(current); + }).toPass({ timeout: 10_000 }); } - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); + expect(await toggle.getAttribute("data-theme-base")).toBe(base); } diff --git a/homepage/e2e/options.spec.ts b/homepage/e2e/options.spec.ts @@ -1,176 +0,0 @@ -import { test, expect, type Page } from "@playwright/test"; -import { chooseTheme, openOptions, optionsButton, optionsDialog } from "./helpers"; - -// ONE OPTIONS BUTTON: the header's gear, named "Options", opens a modal titled -// "Options" with the theme's radio groups, Base and Accent. It replaced the -// theme dropdown ("Choose theme") and the base toggle ("Switch to …"). - -const html = (page: Page) => - page.evaluate(() => ({ - base: document.documentElement.getAttribute("data-base"), - accent: document.documentElement.getAttribute("data-accent"), - })); - -for (const width of [360, 1280]) { - test(`${width} px: one Options gear in the header, a 36 px box, and no other theme button`, async ({ - page, - }) => { - await page.setViewportSize({ width, height: 800 }); - await page.goto("/"); - const gear = optionsButton(page); - await expect(gear).toBeVisible(); - await expect(gear).toBeInViewport(); - const box = (await gear.boundingBox())!; - expect(box.width).toBe(36); - expect(box.height).toBe(36); - expect(box.x + box.width).toBeLessThanOrEqual(width); - await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); - await expect(page.getByRole("button", { name: /switch to/i })).toHaveCount(0); - await expect(gear).toHaveText(""); - }); -} - -test.describe("under a coarse pointer", () => { - test.use({ hasTouch: true }); - test("the gear is 44 px", async ({ page }) => { - await page.setViewportSize({ width: 360, height: 800 }); - await page.goto("/"); - const box = (await optionsButton(page).boundingBox())!; - expect(box.width).toBe(44); - expect(box.height).toBe(44); - }); -}); - -test("the dialog: its role and name, both radio groups, focus kept inside, Escape closes and focus returns", async ({ - page, -}) => { - await page.goto("/"); - const dialog = await openOptions(page); - // Modal: the page behind leaves the accessibility tree while it is open. - await expect(page.getByRole("link", { name: "Archilyzer home" })).toHaveCount(0); - await expect(dialog.getByRole("heading", { name: "Options" })).toBeVisible(); - const base = dialog.getByRole("radiogroup", { name: "Base" }); - const accent = dialog.getByRole("radiogroup", { name: "Accent" }); - expect( - await base.getByRole("radio").evaluateAll((els) => els.map((e) => e.closest("label")?.textContent?.trim())), - ).toEqual(["System", "Light", "Sepia", "Dark"]); - await expect(accent.getByRole("radio")).toHaveCount(7); - await expect(accent.getByRole("radio", { name: "Signal default" })).toBeChecked(); - // It opens on the CHECKED base (the homepage's default is Dark), so an arrow - // key moves from the base in force. - await expect(base.getByRole("radio", { name: "Dark", exact: true })).toBeChecked(); - await expect(base.getByRole("radio", { name: "Dark", exact: true })).toBeFocused(); - - // Tab and Shift+Tab never leave the dialog. - for (const key of ["Tab", "Shift+Tab"]) { - for (let i = 0; i < 14; i++) { - await page.keyboard.press(key); - expect( - await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')), - `${key} #${i + 1} left the dialog`, - ).toBe(true); - } - } - - await page.keyboard.press("Escape"); - await expect(dialog).toBeHidden(); - await expect(optionsButton(page)).toBeFocused(); - await expect(page.getByRole("link", { name: "Archilyzer home" })).toHaveCount(1); -}); - -test("a choice applies at once and the dialog stays open; the close button and a click outside close it", async ({ - page, -}) => { - await page.goto("/"); - const dialog = await openOptions(page); - await dialog.getByRole("radio", { name: "Sepia", exact: true }).check(); - await expect.poll(() => html(page)).toMatchObject({ base: "sepia" }); - await expect(dialog).toBeVisible(); - await dialog.getByRole("radio", { name: "Violet", exact: true }).check(); - await expect.poll(() => html(page)).toEqual({ base: "sepia", accent: "violet" }); - await expect(dialog).toBeVisible(); - expect( - await page.evaluate(() => [localStorage.getItem("ytdlp-tb:base"), localStorage.getItem("ytdlp-tb:accent")]), - ).toEqual(["sepia", "violet"]); - - // Its own Close: a 36 px key. - const close = dialog.getByRole("button", { name: "Close", exact: true }); - const cb = (await close.boundingBox())!; - expect([cb.width, cb.height]).toEqual([36, 36]); - await close.click(); - await expect(dialog).toBeHidden(); - await expect(optionsButton(page)).toBeFocused(); - - await openOptions(page); - await page.mouse.click(5, 400); - await expect(optionsDialog(page)).toBeHidden(); - await expect(optionsButton(page)).toBeFocused(); - - // The helper the other specs use lands the same way. - await chooseTheme(page, { base: "Dark", accent: "Signal" }); - await expect.poll(() => html(page)).toEqual({ base: "dark", accent: "signal" }); -}); - -test("360 × 640: the dialog fits, scrolls inside when it must, locks the page, and nothing scrolls sideways", async ({ - page, -}) => { - await page.setViewportSize({ width: 360, height: 640 }); - await page.goto("/"); - const dialog = await openOptions(page); - const box = (await dialog.boundingBox())!; - expect(box.x).toBeGreaterThanOrEqual(0); - expect(box.y).toBeGreaterThanOrEqual(0); - expect(box.x + box.width).toBeLessThanOrEqual(360); - expect(box.y + box.height).toBeLessThanOrEqual(640); - // Its last choice is reachable (by the dialog's own scroll when it must). - const last = dialog.getByRole("radio", { name: "Green", exact: true }); - await last.scrollIntoViewIfNeeded(); - await expect(last).toBeInViewport(); - await last.check(); - await expect.poll(() => html(page)).toMatchObject({ accent: "green" }); - - const { scroll, client } = await page.evaluate(() => ({ - scroll: document.documentElement.scrollWidth, - client: document.documentElement.clientWidth, - })); - expect(scroll).toBeLessThanOrEqual(client); - - // The page behind does not scroll while the dialog is open. - const before = await page.evaluate(() => window.scrollY); - await page.mouse.move(180, 5); - await page.mouse.wheel(0, 800); - await page.waitForTimeout(200); - expect(await page.evaluate(() => window.scrollY)).toBe(before); -}); - -test("the gear's focus: the ring, and in forced colours the browser's own outline, none at rest", async ({ - page, -}) => { - await page.goto("/"); - const gear = optionsButton(page); - const tabTo = async () => { - for (let i = 0; i < 25; i++) { - await page.keyboard.press("Tab"); - if (await gear.evaluate((el) => el === document.activeElement)) return; - } - throw new Error("Tab never reached the gear"); - }; - await tabTo(); - const normal = await gear.evaluate((el) => { - const s = getComputedStyle(el); - return { outline: s.outlineStyle, shadow: s.boxShadow }; - }); - expect(normal.outline).toBe("none"); - expect(normal.shadow).toMatch(/0px 0px 0px 2px/); - - await page.emulateMedia({ forcedColors: "active" }); - await page.goto("/"); - expect(await gear.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("none"); - await tabTo(); - const forced = await gear.evaluate((el) => { - const s = getComputedStyle(el); - return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) }; - }); - expect(forced.style).not.toBe("none"); - expect(forced.width).toBeGreaterThan(0); -}); diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts @@ -9,6 +9,7 @@ import { writeFixtureSettings, writeRawFixtureSettings, } from "./fixture-social"; +import { themeToggle } from "./helpers"; // THE SOCIAL ROW: in the header at every width (at most four links), and in the // footer (every link). The links are the e2e's own (fixture-social.ts): Leaf (a @@ -28,7 +29,7 @@ test.afterEach(() => { const TRIO = FIXTURE_SOCIAL_TRIO.map((l) => l.label); -// The header's row, in the bar at every width, followed by the Options gear; +// The header's row, in the bar at every width, followed by the theme toggle; // none of its (at most four) links is hidden by width. On a very small screen // the wordmark's TEXT is hidden first (the mark stays); the row scrolls inside // its box only as the last resort (Header.tsx). @@ -58,7 +59,7 @@ async function tabTo(page: Page, target: Locator) { // The bar's content width (in px, default text) below which the wordmark's // text is hidden, by how many links the header shows and the pointer: the -// wordmark link (152 px), a 12 px gap, and the keys and the gear (Header.tsx +// wordmark link (152 px), a 12 px gap, and the keys and the toggle (Header.tsx // WORDMARK_FITS). const WORDMARK_NEEDS = { mouse: { 1: 236, 3: 308, 4: 344 }, @@ -100,9 +101,9 @@ async function narrowHeader(page: Page, width: number, count: 1 | 3 | 4, pointer expect(k.x).toBeGreaterThanOrEqual(b.x); expect(k.x + k.width).toBeLessThanOrEqual(b.x + b.width); } - const gear = page.getByRole("button", { name: "Options", exact: true }); - await expect(gear).toBeInViewport({ ratio: 1 }); - expect((await gear.boundingBox())!.x, "the gear is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5); + const toggle = themeToggle(page); + await expect(toggle).toBeInViewport({ ratio: 1 }); + expect((await toggle.boundingBox())!.x, "the toggle is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5); await noHorizontalOverflow(page); } @@ -117,7 +118,7 @@ for (const width of [320, 340, 360, 390]) { }); } -test("768 and 1280 px: the full wordmark, the nav, the row and the gear in one bar", async ({ page }) => { +test("768 and 1280 px: the full wordmark, the nav, the row and the toggle in one bar", async ({ page }) => { for (const width of [768, 1280]) { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); @@ -126,7 +127,7 @@ test("768 and 1280 px: the full wordmark, the nav, the row and the gear in one b await expect(page.getByRole("navigation", { name: "Main", exact: true })).toBeVisible(); expect(await labelsOf(headerRow(page))).toEqual(TRIO); expect(await scrollBox(page).evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(false); - await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport(); + await expect(themeToggle(page)).toBeInViewport(); await noHorizontalOverflow(page); } }); @@ -231,8 +232,8 @@ test.describe("under a coarse pointer", () => { }); } - // THE LAST RESORT: at 280 px, four 44 px keys, the gear and the mark do not - // fit, so the row scrolls inside its box — its END in view first, the gear + // THE LAST RESORT: at 280 px, four 44 px keys, the toggle and the mark do not + // fit, so the row scrolls inside its box — its END in view first, the toggle // outside it, the page never sideways, and each link scrolled fully into view // (its focus ring too) when it takes focus. test("280 px, four links: the row scrolls in its box, its end first; focus brings each link into view", async ({ @@ -244,7 +245,7 @@ test.describe("under a coarse pointer", () => { const box = scrollBox(page); expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(true); await noHorizontalOverflow(page); - await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport({ ratio: 1 }); + await expect(themeToggle(page)).toBeInViewport({ ratio: 1 }); const inBox = (key: import("@playwright/test").Locator) => Promise.all([box.boundingBox(), key.boundingBox()]).then(([b, k]) => k!.x - 2 >= b!.x - 0.5 && k!.x + k!.width + 2 <= b!.x + b!.width + 0.5, @@ -259,7 +260,7 @@ test.describe("under a coarse pointer", () => { } }); - test("320 px at a 200 % text size: the header does not scroll sideways; the last link's end and the gear are in view", async ({ + test("320 px at a 200 % text size: the header does not scroll sideways; the last link's end and the toggle are in view", async ({ page, }) => { writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX); @@ -274,7 +275,7 @@ test.describe("under a coarse pointer", () => { // The header only: at twice the text size the page's own content is not // this slice's to reflow. expect(await page.locator("header").evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); - await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport({ ratio: 1 }); + await expect(themeToggle(page)).toBeInViewport({ ratio: 1 }); // The box may be narrower than one 88 px key here: the last link is the // one at its end, in view as far as the box allows. const [b, k] = await Promise.all([ @@ -347,18 +348,18 @@ test("six links: the header shows the last four at every width, the footer all s } }); -// The gear sits in the row's rhythm: its box directly after the last link's, +// The toggle sits in the row's rhythm: its box directly after the last link's, // as the links' boxes sit after each other, so glyph to glyph is the same all // along; the nav keeps a larger gap before the group. for (const width of [1024, 1280]) { - test(`${width} px: the gear is spaced like a fourth key, and the nav stands apart`, async ({ + test(`${width} px: the toggle is spaced like a fourth key, and the nav stands apart`, async ({ page, }) => { await page.setViewportSize({ width, height: 800 }); await page.goto("/"); const boxes: { x: number; y: number; width: number; height: number }[] = []; for (const key of await headerRow(page).getByRole("link").all()) boxes.push((await key.boundingBox())!); - boxes.push((await page.getByRole("button", { name: "Options", exact: true }).boundingBox())!); + boxes.push((await themeToggle(page).boundingBox())!); expect(boxes).toHaveLength(TRIO.length + 1); const gaps = boxes.slice(1).map((b, i) => b.x - (boxes[i].x + boxes[i].width)); for (const g of gaps) { diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts @@ -4,7 +4,7 @@ import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand"; import { chooseTheme } from "./helpers"; // The shared theme system (common/styles/tokens.css + ThemeScript + -// ThemeProvider + the header's Options dialog) as the project's own site uses it: it +// ThemeProvider + the header's toggle) as the project's own site uses it: it // opens on the DARK base in Signal, the family's accent. A reader's base // persists across reloads and is applied before hydration (no flash of the // wrong theme). @@ -40,9 +40,9 @@ test("dark + Signal by default; a base chosen in Options persists with no FOUC", brand: ACCENTS.signal.onDark, }); - // An explicit Light base, through the header's Options dialog (chooseTheme - // retries the gear until hydration has made it live). - await chooseTheme(page, { base: "Light" }); + // An explicit Light base, through the header's toggle (chooseTheme retries + // a click until hydration has made it live). + await chooseTheme(page, { base: "light" }); await expect.poll(async () => (await htmlState(page)).stored).toBe("light"); let s = await htmlState(page); expect(s.base).toBe("light"); diff --git a/homepage/e2e/toggle.spec.ts b/homepage/e2e/toggle.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, type Page } from "@playwright/test"; +import { nextBase, type ThemeBase } from "../../common/components/themeConfig"; +import { baseLabel, themeCycle, themeToggle } from "./helpers"; + +// THE HOMEPAGE'S THEME CONTROL is one toggle that cycles the base +// (ThemeToggle, `variant="bare"`), the last key of the header's group; there is +// no options dialog and no accent control, and the accent is pinned to the +// homepage's own. The expected cycle is derived from THEME_BASES / nextBase, so +// removing a base there needs no change here. + +const htmlState = (page: Page) => + page.evaluate(() => ({ + base: document.documentElement.getAttribute("data-base"), + accent: document.documentElement.getAttribute("data-accent"), + ready: document.documentElement.getAttribute("data-theme-ready"), + })); + +for (const width of [320, 360, 390, 768, 1280]) { + test(`${width} px: the toggle is in the header, a 36 px key, and there is no other theme control`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + const toggle = themeToggle(page); + await expect(toggle).toBeInViewport({ ratio: 1 }); + const box = (await toggle.boundingBox())!; + expect([box.width, box.height]).toEqual([36, 36]); + await expect(toggle).toHaveText(""); + await expect(page.getByRole("button", { name: "Options" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); + await expect(page.getByRole("dialog")).toHaveCount(0); + }); +} + +test.describe("under a coarse pointer", () => { + test.use({ hasTouch: true }); + test("the toggle is a 44 px key", async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/"); + const box = (await themeToggle(page).boundingBox())!; + expect([box.width, box.height]).toEqual([44, 44]); + }); +}); + +test("each click moves to the next base: the page, the icon and the name follow, round the whole cycle", async ({ + page, +}) => { + // The OS says light, so "system" resolves to light. + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/"); + const toggle = themeToggle(page); + const start = (await toggle.getAttribute("data-theme-base")) as ThemeBase; + expect(start).toBe("dark"); // the homepage's own default + const cycle = themeCycle(start); + let current = start; + for (let i = 0; i < cycle.length; i++) { + const next = nextBase(current); + await expect(toggle).toHaveAccessibleName(`Switch to ${baseLabel(next)}`); + const icon = await toggle.locator("svg").getAttribute("class"); + await expect(async () => { + if ((await toggle.getAttribute("data-theme-base")) === current) await toggle.click(); + expect(await toggle.getAttribute("data-theme-base")).toBe(next); + }).toPass({ timeout: 10_000 }); + expect(await toggle.locator("svg").getAttribute("class"), `${current} → ${next}: the icon`).not.toBe(icon); + const resolved = next === "system" ? "light" : next; + await expect.poll(async () => (await htmlState(page)).base).toBe(resolved); + current = next; + } + expect(current, "the cycle comes back to its start").toBe(start); +}); + +test("the toggle's focus: the ring, and in forced colours the browser's own outline, none at rest", async ({ + page, +}) => { + await page.goto("/"); + const toggle = themeToggle(page); + const tabTo = async () => { + for (let i = 0; i < 25; i++) { + await page.keyboard.press("Tab"); + if (await toggle.evaluate((el) => el === document.activeElement)) return; + } + throw new Error("Tab never reached the toggle"); + }; + await tabTo(); + const normal = await toggle.evaluate((el) => { + const s = getComputedStyle(el); + return { outline: s.outlineStyle, shadow: s.boxShadow }; + }); + expect(normal.outline).toBe("none"); + expect(normal.shadow).toMatch(/0px 0px 0px 2px/); + + await page.emulateMedia({ forcedColors: "active" }); + await page.goto("/"); + expect(await toggle.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("none"); + await tabTo(); + const forced = await toggle.evaluate((el) => { + const s = getComputedStyle(el); + return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) }; + }); + expect(forced.style).not.toBe("none"); + expect(forced.width).toBeGreaterThan(0); +}); + +// With no accent control on the homepage, a stored accent (set on this origin +// by an earlier build that had one) must not tint it: the pre-paint script +// and the provider both ignore it, and it stays in storage untouched. +test("a stored accent is ignored: the homepage keeps its own, with no flash, and the stored value stays", async ({ + page, +}) => { + await page.addInitScript(() => { + try { + localStorage.setItem("ytdlp-tb:accent", "violet"); + } catch {} + }); + await page.goto("/", { waitUntil: "commit" }); + await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1"); + // Before hydration: the pre-paint script's work only. + expect((await htmlState(page)).accent).toBe("signal"); + await page.waitForLoadState("load"); + await expect.poll(async () => (await htmlState(page)).accent).toBe("signal"); + await page.waitForTimeout(300); + expect((await htmlState(page)).accent).toBe("signal"); + expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:accent"))).toBe("violet"); +});