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:
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");
+});