commit ed5c706d6d4c68cf21f74082681201accbd151b8
parent 0197fb7cfcf4bffa741240bb325eef5a409c6a78
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 18:43:20 -0400
homepage: the social row and the Options gear in the header at every width; Changelog in the footer only
- The header: one group, the social row then the gear, boxes touching (glyph
to glyph 16 px), in the bar at every width. From md: wordmark · nav · group;
below md the nav has the rule below to itself and fits at 360 px. The bar
shows the last n of the header's links by width and pointer (mouse: 4 from
768, 3 from 360, else 2; touch: 4 from 768, 3 from 390, 2 from 340, else 1),
one CSS rule per step; no width from 320 up scrolls sideways.
- lib/nav.ts: HEADER_NAV (four) and FOOTER_NAV (the same, then Changelog); the
footer and the 404 page read FOOTER_NAV. ThemeMenu and ThemeToggle leave the
homepage header.
- The footer's Elsewhere column uses SocialLinks.
- e2e: the dev server reads its own settings (SETTINGS_FILE) and an empty
sites directory (SITES_DIR), never the checkout's; synthetic icons (a
gradient, one colour, a sized two-colour disc) and hostile ones written raw.
social.spec (the rule table from 320 px, sizes, focus, six links, featured,
none, the synthesized viewBox, per-copy ids, no script from a stored icon),
options.spec (name, modality, focus trap and return, the checked radio,
Escape/Close/outside click, 360×640, 36/44 px), the nav specs, and one
chooseTheme helper for theme.spec.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
12 files changed, 823 insertions(+), 62 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -141,6 +141,10 @@ yarn-error.log*
/homepage/out/
# the e2e's synthetic summary, and a killed run's temp copy (homepage/e2e/fixture-summary.ts)
/homepage/e2e/.e2e-summary.json*
+# the e2e's settings.json (social links only), and its temp copy (homepage/e2e/fixture-social.ts)
+/homepage/e2e/.e2e-settings.json*
+# the e2e's empty sites directory (no homepage.json; homepage/playwright.config.ts)
+/homepage/e2e/.e2e-sites/
# site config
/settings.json
diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx
@@ -1,15 +1,13 @@
import Link from "next/link";
-import {
- getSettings,
- sizeSocialSvg,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
import {
PROJECT_NAME,
PROJECT_TAGLINE,
} from "yt-dlp-transcript-common/lib/project";
import { currentHomepage } from "../lib/homepage";
-import { NAV } from "../lib/nav";
+import { FOOTER_NAV } from "../lib/nav";
// The project site's footer. Note the identity split it embodies: the wordmark
// and tagline are PRODUCT strings (the same on every install), while the social
@@ -36,7 +34,7 @@ export default function Footer() {
<nav aria-label="Footer" className="flex flex-col gap-3">
<span className="label-machine">Sections</span>
<ul className="flex flex-col gap-2 list-none">
- {NAV.map((item) => (
+ {FOOTER_NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
@@ -52,21 +50,7 @@ export default function Footer() {
{socialLinks.length > 0 && (
<div className="flex flex-col gap-3">
<span className="label-machine">Elsewhere</span>
- <ul className="flex items-center gap-4 list-none">
- {socialLinks.map((link, i) => (
- <li key={`${link.url}-${i}`}>
- <a
- href={link.url}
- title={link.label}
- aria-label={link.label}
- target="_blank"
- rel="noopener noreferrer"
- className="inline-block w-5 h-5 text-[var(--faint)] hover:text-[var(--foreground)] transition-colors [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }}
- />
- </li>
- ))}
- </ul>
+ <SocialLinks links={socialLinks} placement="footer" />
</div>
)}
</div>
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -6,14 +6,17 @@ 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 { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
-import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
-import { NAV } from "../lib/nav";
+import { OptionsDialog } from "yt-dlp-transcript-common/components/OptionsDialog";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { currentHomepage } from "../lib/homepage";
+import { HEADER_NAV } from "../lib/nav";
function NavList({ className }: { className?: string }) {
return (
<ul className={`flex items-center list-none ${className ?? ""}`}>
- {NAV.map((item) => (
+ {HEADER_NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
@@ -27,23 +30,48 @@ function NavList({ className }: { className?: string }) {
);
}
-// The project site's header: a hairline bar with the wordmark and the five
-// destinations. It carried NO links at all before this — the page it sat above
-// was the whole site.
+// The project site's header: a hairline bar with the wordmark and the four
+// destinations (Changelog is in the footer only; lib/nav.ts). It carried NO
+// links at all before this — the page it sat above was the whole site.
//
// The wordmark is PRODUCT identity (common/lib/project.ts), not the operator's
// editable homepage config: this bar says what the software is called, and that
// is the same string on every install. The operator's own naming still governs
-// /stats/ and the social links in the footer.
+// /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. Measured with the wordmark
+// at 148 px, the nav at 276 px and a key 36 px (44 px under a coarse pointer):
+// ≥ md (768) wordmark · nav · group, the nav 32 px from the group's first
+// box (40 px from its first glyph).
+// < md wordmark · group, and the nav alone on the rule below (the
+// four links fit a 360 px rule, so nothing scrolls).
+// THE BOUND: the header shows at most four links (headerSocialLinks); the
+// footer shows every link. The bar shows the LAST n of those four, by viewport
+// width and pointer — one CSS rule per step on the row below, each a width the
+// wordmark (148–152 px), a 12 px gap and the group fit inside the 20 px gutters:
+//
+// width mouse touch (coarse pointer, 44 px keys)
+// ≥ 768 4 4
+// 390–767 3 3
+// 360–389 3 2 (three 44 px keys and the gear need 380 px)
+// 340–359 2 2 (three 36 px keys and the gear need 348–352 px)
+// < 340 2 1 (two 44 px keys and the gear need 336 px)
+//
+// The row is one element; the ids inside its icons are still scoped per copy
+// (scopeSvgIds), because the footer inlines the same icons.
//
// The mark is the family's Found-line mark in its parent colours — bone on
// slate, no accent: the project spends no colour on its own chrome. The
// wordmark splits on the subject ("Archi" + "lyzer"); the link keeps its
// explicit name, "Archilyzer home".
export default function Header() {
+ const socialLinks = resolveHomepageSocialLinks(currentHomepage(), getSettings());
return (
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md">
- <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-6">
+ <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-3 md:gap-6">
<Link
href="/"
className="flex items-center gap-2.5 shrink-0"
@@ -56,25 +84,32 @@ export default function Header() {
className="text-[1.3rem] leading-none tracking-[-0.01em]"
/>
</Link>
- <nav aria-label="Main" className="ml-auto hidden md:block">
- <NavList className="gap-6" />
- </nav>
- <div className="ml-auto md:ml-0 flex items-center gap-2">
- <ThemeMenu />
- <ThemeToggle />
+ <div className="ml-auto flex items-center gap-8">
+ <nav aria-label="Main" className="hidden md:block">
+ <NavList className="gap-6" />
+ </nav>
+ <div className="flex items-center">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ className="max-md:[&>li:nth-last-child(4)]:hidden max-[390px]:pointer-coarse:[&>li:nth-last-child(3)]:hidden max-[360px]:[&>li:nth-last-child(3)]:hidden max-[340px]:pointer-coarse:[&>li:nth-last-child(2)]:hidden"
+ />
+ <OptionsDialog />
+ </div>
</div>
</div>
- {/* Below `md` the bar has no room for five labels beside the wordmark and
- the theme controls (at `sm` four fitted; Source made it five), so the
- nav drops to its own scrollable rule rather than collapsing behind a
- menu button — five links do not earn a disclosure widget. Only one of the two is ever in the accessibility
- tree (the other is display:none), but they carry distinct labels so a
- test or a screen reader can never conflate them. */}
+ {/* Below `md` the nav drops to its own rule rather than collapsing behind
+ a menu button — four links do not earn a disclosure widget, and they
+ fit a 360 px rule. Only one of the two navs is ever in the
+ accessibility tree (the other is display:none), but they carry
+ distinct labels so a test or a screen reader can never conflate them.
+ `overflow-x-auto` only guards a larger text setting; nothing scrolls
+ at the default size. */}
<nav
aria-label="Main, compact"
className="md:hidden border-t border-[var(--border)] overflow-x-auto"
>
- <NavList className="gap-5 px-5 h-10" />
+ <NavList className="gap-5 px-5 sm:px-6 h-10" />
</nav>
</header>
);
diff --git a/homepage/app/lib/nav.ts b/homepage/app/lib/nav.ts
@@ -1,16 +1,24 @@
-// The site's navigation, declared once and rendered by both the header and the
-// footer. Order is editorial: what the software is, where its code lives, how
-// to get it, what this deployment has done, what changed.
+// The site's navigation, declared once. Order is editorial: what the software
+// is, where its code lives, how to get it, what this deployment has done, what
+// changed.
+//
+// HEADER_NAV the header's links;
+// FOOTER_NAV the footer's "Sections" and the 404 page: the header's links,
+// then Changelog, which is in the footer only.
//
// Every entry must resolve on a `build:nodata` tree — /stats/ renders an honest
// no-data panel and /source/ an honest "nothing published" rather than 404ing —
// so the nav never has to be conditional.
export type NavItem = { href: string; label: string };
-export const NAV: NavItem[] = [
+export const HEADER_NAV: readonly NavItem[] = [
{ href: "/docs/", label: "Docs" },
{ href: "/source/", label: "Source" },
{ href: "/downloads/", label: "Downloads" },
{ href: "/stats/", label: "Stats" },
+];
+
+export const FOOTER_NAV: readonly NavItem[] = [
+ ...HEADER_NAV,
{ href: "/changelog/", label: "Changelog" },
];
diff --git a/homepage/app/not-found.tsx b/homepage/app/not-found.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { PageShell, PageHeading } from "./components/PageShell";
-import { NAV } from "./lib/nav";
+import { FOOTER_NAV } from "./lib/nav";
// Branded 404. Static export renders this to out/404.html, which Cloudflare
// Pages serves for unmatched paths.
@@ -15,7 +15,7 @@ export default function NotFound() {
<nav aria-label="Site sections" className="flex flex-col gap-3">
<span className="label-machine">Try one of these</span>
<ul className="flex flex-wrap gap-x-6 gap-y-2 list-none">
- {[{ href: "/", label: "Home" }, ...NAV].map((item) => (
+ {[{ href: "/", label: "Home" }, ...FOOTER_NAV].map((item) => (
<li key={item.href}>
<Link
href={item.href}
diff --git a/homepage/e2e/fixture-social.ts b/homepage/e2e/fixture-social.ts
@@ -0,0 +1,114 @@
+import fs from "node:fs";
+import {
+ normalizeSocialSvg,
+ type SocialLink,
+} from "../../common/lib/settingsSchema";
+
+// THE HOMEPAGE E2E'S SOCIAL LINKS, never the operator's.
+//
+// The header and the footer render `socialLinks` from settings.json. A checkout's
+// settings.json is the operator's own (a worktree seeds it from the primary), so
+// the e2e dev server reads THIS instead: `SETTINGS_FILE` (common/lib/paths.ts)
+// points it at e2e/.e2e-settings.json (gitignored), written by
+// playwright.config.ts before the server starts. Only the social links are set;
+// every other key reads as its default, and nothing else on the homepage reads
+// settings. (`SITES_DIR` points at an empty directory too, so no homepage.json
+// can override these links.)
+//
+// Each icon is given here in its SOURCE form — as an operator would paste it —
+// and goes through normalizeSocialSvg, the function every save path runs
+// (Settings, a site's form, the homepage config), so the page renders what a
+// save would have stored. The default trio is shaped like the icons operators
+// paste, all synthetic:
+// • Leaf — a gradient (an id, a url(#…) reference) and one solid colour,
+// which the normalizer themes to currentColor;
+// • Bubble — one colour, drawn white, as vendors ship them for dark grounds;
+// themed to currentColor;
+// • Disc — a two-colour disc with a letter, pasted with only width/height and
+// NO viewBox, so the page proves the normalizer made its viewBox.
+//
+// A spec that needs another list (six links, none, a hostile icon) rewrites the
+// file with writeFixtureSettings (or writeRawFixtureSettings, which skips the
+// normalizer the way a hand-edited file would) and restores FIXTURE_SOCIAL_TRIO
+// after: `next dev` reads settings on every request, and the suite runs one
+// worker.
+
+export const FIXTURE_SETTINGS_NAME = ".e2e-settings.json";
+
+const LEAF =
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` +
+ `<defs><linearGradient id="leaf-grad" x1="0" y1="0" x2="0" y2="1">` +
+ `<stop offset="0" stop-color="#E0A030"/><stop offset="1" stop-color="#1C9A5B"/>` +
+ `</linearGradient></defs>` +
+ `<path fill="url(#leaf-grad)" d="M12 2c4.9 3.6 8 7.6 8 11.3A8 8 0 0 1 4 13.3C4 9.6 7.1 5.6 12 2z"/>` +
+ `<circle fill="#333333" cx="12" cy="15" r="2.4"/></svg>`;
+
+const BUBBLE =
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">` +
+ `<path fill="white" d="M4 4h16a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>`;
+
+// A two-colour disc with a letter, sized and with no viewBox (the normalizer
+// makes `viewBox="0 0 81 81"`): a dark offset disc, a light disc with a dark
+// outline, a dark "F".
+export const FIXTURE_DISC_SVG =
+ `<svg xmlns="http://www.w3.org/2000/svg" width="81" height="81" fill="none">` +
+ `<circle cx="44" cy="43" r="37" fill="#1a1a1a"/>` +
+ `<circle cx="39" cy="39" r="38" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.5"/>` +
+ `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`;
+
+// Icons that must never run: each sets window.__hpHostile if it does. Written
+// raw (writeRawFixtureSettings), as a hand-edited settings.json could hold them.
+export const FIXTURE_HOSTILE_SVGS = [
+ `<svg/onload="window.__hpHostile=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"><img src="x:" onerror="window.__hpHostile=1"></svg>`,
+ `<svg viewBox="0 0 8 8"><image href="x:"/onerror="window.__hpHostile=1"/></svg>`,
+];
+
+const SQUARE = `<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>`;
+const RING = `<svg viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 4a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/></svg>`;
+const TRIANGLE = `<svg viewBox="0 0 24 24"><path d="M12 3l10 18H2z"/></svg>`;
+
+const raw = (label: string, svg: string, featured = false) => ({
+ label,
+ url: `https://${label.toLowerCase()}.example/fixture`,
+ svg,
+ ...(featured ? { featured: true } : {}),
+});
+
+export const FIXTURE_SOCIAL_TRIO = [
+ raw("Leaf", LEAF),
+ raw("Bubble", BUBBLE),
+ raw("Disc", FIXTURE_DISC_SVG),
+];
+
+// Six, none marked: the header takes the last four.
+export const FIXTURE_SOCIAL_SIX = [
+ raw("Square", SQUARE),
+ raw("Ring", RING),
+ ...FIXTURE_SOCIAL_TRIO.slice(0, 1),
+ raw("Triangle", TRIANGLE),
+ ...FIXTURE_SOCIAL_TRIO.slice(1),
+];
+
+// Six, two marked: the header takes those two.
+export const FIXTURE_SOCIAL_SIX_FEATURED = FIXTURE_SOCIAL_SIX.map((l) =>
+ l.label === "Square" || l.label === "Bubble" ? { ...l, featured: true } : l,
+);
+
+type RawLink = { label: string; url: string; svg: string; featured?: boolean };
+
+export function writeFixtureSettings(dest: string, links: RawLink[]): void {
+ const socialLinks: SocialLink[] = links.map((link) => {
+ const svg = normalizeSocialSvg(link.svg);
+ if (svg === null) throw new Error(`fixture icon "${link.label}" was refused`);
+ return { ...link, svg };
+ });
+ writeRawFixtureSettings(dest, socialLinks);
+}
+
+// The links as given, NOT normalized: what a hand-edited settings.json holds.
+export function writeRawFixtureSettings(dest: string, links: RawLink[]): void {
+ const tmp = `${dest}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify({ socialLinks: links }, null, 2));
+ fs.renameSync(tmp, dest);
+}
diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts
@@ -0,0 +1,48 @@
+import { expect, type Page } from "@playwright/test";
+
+// 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.
+
+export const optionsButton = (page: Page) =>
+ page.locator("header").getByRole("button", { name: "Options", exact: true });
+
+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;
+}
+
+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();
+ }
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeHidden();
+}
diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts
@@ -37,6 +37,38 @@ test("the hub link, when there is one, leaves for the hub", async ({ page }) =>
await expect(hub).toHaveAttribute("href", /^https?:\/\/[^/]/);
});
+// The header carries four destinations; the footer's "Sections" carries the
+// same four and Changelog, which is in the footer only (app/lib/nav.ts).
+const HEADER_LINKS = ["Docs", "Source", "Downloads", "Stats"];
+const FOOTER_LINKS = [...HEADER_LINKS, "Changelog"];
+
+const linkNames = (nav: import("@playwright/test").Locator) =>
+ nav.getByRole("link").evaluateAll((els) => els.map((e) => e.textContent?.trim()));
+
+test("the header nav has four links and no Changelog; the footer's has five", async ({
+ page,
+}) => {
+ for (const width of [360, 1280]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const main = page.getByRole("navigation", { name: width < 768 ? "Main, compact" : "Main", exact: true });
+ expect(await linkNames(main)).toEqual(HEADER_LINKS);
+ await expect(page.locator("header").getByRole("link", { name: "Changelog" })).toHaveCount(0);
+ expect(await linkNames(page.getByRole("navigation", { name: "Footer" }))).toEqual(FOOTER_LINKS);
+ }
+});
+
+test("Changelog is reachable from the footer on every page", async ({ page }) => {
+ for (const path of ["/", "/docs/", "/source/", "/downloads/", "/stats/", "/changelog/", "/no-such-page/"]) {
+ await page.goto(path);
+ await expect(
+ page.getByRole("navigation", { name: "Footer" }).getByRole("link", { name: "Changelog" }),
+ path,
+ ).toHaveAttribute("href", "/changelog/");
+ await expect(page.locator("header").getByRole("link", { name: "Changelog" }), path).toHaveCount(0);
+ }
+});
+
test("every nav destination resolves", async ({ page }) => {
// The nav must never point at a 404, including on a build with no corpus
// data — which is why /stats/ always exists and degrades in place.
diff --git a/homepage/e2e/options.spec.ts b/homepage/e2e/options.spec.ts
@@ -0,0 +1,176 @@
+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
@@ -0,0 +1,340 @@
+import path from "node:path";
+import { test, expect, type Locator, type Page } from "@playwright/test";
+import {
+ FIXTURE_SETTINGS_NAME,
+ FIXTURE_SOCIAL_SIX,
+ FIXTURE_SOCIAL_SIX_FEATURED,
+ FIXTURE_HOSTILE_SVGS,
+ FIXTURE_SOCIAL_TRIO,
+ writeFixtureSettings,
+ writeRawFixtureSettings,
+} from "./fixture-social";
+
+// 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
+// gradient and one themed colour), Bubble (one colour) and Disc (two colours,
+// pasted with only a size — no viewBox until the normalizer made one). No copy:
+// every link is an icon whose accessible name is its label.
+//
+// Specs that need another list rewrite the dev server's settings file and the
+// afterEach restores the trio; `next dev` reads it on every request.
+
+const settingsFile = () =>
+ path.resolve(test.info().project.testDir, FIXTURE_SETTINGS_NAME);
+
+test.afterEach(() => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_TRIO);
+});
+
+const TRIO = FIXTURE_SOCIAL_TRIO.map((l) => l.label);
+
+// The header's row, in the bar at every width, followed by the Options gear. It
+// shows the LAST n of its (at most four) links by width and pointer
+// (Header.tsx): mouse ≥ 768 → 4, 360–767 → 3, < 360 → 2; touch ≥ 768 → 4,
+// 390–767 → 3, 340–389 → 2, < 340 → 1. A role query sees only those.
+const headerRow = (page: Page) =>
+ page.locator('header [data-social-links="header"]');
+const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]');
+
+const labelsOf = (row: Locator) =>
+ row.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")));
+
+async function noHorizontalOverflow(page: Page) {
+ const { scroll, client } = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ client: document.documentElement.clientWidth,
+ }));
+ expect(scroll, "the page scrolls sideways").toBeLessThanOrEqual(client);
+}
+
+async function tabTo(page: Page, target: Locator) {
+ for (let i = 0; i < 25; i++) {
+ await page.keyboard.press("Tab");
+ if (await target.evaluate((el) => el === document.activeElement)) return;
+ }
+ throw new Error("Tab never reached the link");
+}
+
+for (const [width, shown] of [[320, 2], [339, 2], [359, 2], [360, 3], [390, 3], [768, 3], [1280, 3]] as const) {
+ test(`${width} px: the header's social row is on screen with the wordmark whole, and the page does not scroll sideways`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const row = headerRow(page);
+ await expect(row).toHaveCount(1);
+ expect(await labelsOf(row)).toEqual(TRIO.slice(-shown));
+
+ const box = (await row.boundingBox())!;
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(width);
+ expect(box.y).toBeGreaterThanOrEqual(0);
+ expect(box.y + box.height).toBeLessThanOrEqual(800);
+ for (const key of await row.getByRole("link").all()) {
+ const k = (await key.boundingBox())!;
+ expect(k.width).toBeGreaterThanOrEqual(24);
+ expect(k.height).toBeGreaterThanOrEqual(24);
+ expect(k.x + k.width).toBeLessThanOrEqual(width);
+ }
+
+ // The wordmark: one line, all of it on screen, and nothing drawn over it.
+ const home = page.getByRole("link", { name: "Archilyzer home" });
+ const wm = (await home.boundingBox())!;
+ expect(wm.x).toBeGreaterThanOrEqual(0);
+ expect(wm.x + wm.width).toBeLessThanOrEqual(width);
+ expect(wm.height).toBeLessThanOrEqual(32);
+ expect(
+ await home.evaluate((el) => el.scrollWidth <= el.clientWidth + 1),
+ "the wordmark is clipped",
+ ).toBe(true);
+ const clear = box.x >= wm.x + wm.width || box.y >= wm.y + wm.height;
+ expect(clear, "the row overlaps the wordmark").toBe(true);
+ await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport();
+
+ await noHorizontalOverflow(page);
+ });
+}
+
+test("every link: its href, its label as its name and title, a new tab with no opener, and no text", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const row of [headerRow(page), footerRow(page)]) {
+ for (const want of FIXTURE_SOCIAL_TRIO) {
+ const link = row.getByRole("link", { name: want.label, exact: true });
+ await expect(link).toHaveAttribute("href", want.url);
+ await expect(link).toHaveAttribute("title", want.label);
+ await expect(link).toHaveAttribute("target", "_blank");
+ await expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ // No copy: the link's only content is its icon.
+ await expect(link).toHaveText("");
+ await expect(link.locator("svg")).toHaveAttribute("aria-hidden", "true");
+ }
+ }
+});
+
+test("header icons are the muted foreground; a two-colour icon keeps its colours", async ({
+ page,
+}) => {
+ await page.goto("/");
+ // The glyph itself: Bubble was drawn white and themed to currentColor, so
+ // its path paints the link's colour, the muted foreground.
+ const { glyph, muted } = await page.evaluate(() => {
+ const probe = document.createElement("span");
+ probe.style.color = "var(--muted-foreground)";
+ document.body.append(probe);
+ const muted = getComputedStyle(probe).color;
+ probe.remove();
+ const path = document.querySelector(
+ 'header [data-social-links="header"] a[aria-label="Bubble"] path',
+ )!;
+ return { glyph: getComputedStyle(path).fill, muted };
+ });
+ expect(glyph).toBe(muted);
+ const disc = headerRow(page).getByRole("link", { name: "Disc" });
+ expect(await disc.innerHTML()).toContain('fill="#f4c542"');
+});
+
+// End to end for the normalizer's size rule: the fixture's Disc is pasted with
+// only width="81" height="81"; the page must carry the viewBox the normalizer
+// made, and the render size instead of 81.
+test("an icon pasted with a size and no viewBox renders with the viewBox made from its size", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const row of [headerRow(page), footerRow(page)]) {
+ const svg = row.getByRole("link", { name: "Disc" }).locator("svg");
+ await expect(svg).toHaveAttribute("viewBox", "0 0 81 81");
+ await expect(svg).toHaveAttribute("width", "20");
+ await expect(svg).toHaveAttribute("height", "20");
+ const box = (await svg.boundingBox())!;
+ expect(Math.round(box.width)).toBe(20);
+ }
+});
+
+// The same icon is inlined twice (the header, the footer). Every copy's ids
+// are its own, and the header's copy paints its gradient from its own defs.
+test("each copy of an icon owns its ids; the visible copy's gradient resolves inside it", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.goto("/");
+ const dupes = await page.evaluate(() => {
+ const seen = new Map<string, number>();
+ for (const el of document.querySelectorAll("[data-social-links] [id]")) {
+ seen.set(el.id, (seen.get(el.id) ?? 0) + 1);
+ }
+ return [...seen].filter(([, n]) => n > 1).map(([id]) => id);
+ });
+ expect(dupes).toEqual([]);
+ const own = await headerRow(page)
+ .getByRole("link", { name: "Leaf" })
+ .evaluate((a) => {
+ const svg = a.querySelector("svg")!;
+ const ref = /url\(#([^)]+)\)/.exec(
+ svg.querySelector("path")!.getAttribute("fill") ?? "",
+ )?.[1];
+ const target = ref ? document.getElementById(ref) : null;
+ return { ref, inside: !!target && svg.contains(target) };
+ });
+ expect(own.ref).toBeTruthy();
+ expect(own.inside).toBe(true);
+});
+
+test.describe("under a coarse pointer", () => {
+ test.use({ hasTouch: true });
+
+ test("the narrow bar under touch: the last one below 340, two to 389, three from 390", async ({
+ page,
+ }) => {
+ for (const [width, shown] of [[320, 1], [339, 1], [340, 2], [360, 2], [389, 2], [390, 3]] as const) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(TRIO.slice(-shown));
+ await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport();
+ await noHorizontalOverflow(page);
+ }
+ });
+
+ test("six links under touch at 360 px: the last two of the header's four", async ({ page }) => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label).slice(-2));
+ await noHorizontalOverflow(page);
+ });
+
+ for (const width of [360, 1280]) {
+ test(`${width} px: every key is at least 44 px, and the header row still fits`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true);
+ for (const row of [headerRow(page), footerRow(page)]) {
+ for (const key of await row.getByRole("link").all()) {
+ const k = (await key.boundingBox())!;
+ expect(k.width).toBeGreaterThanOrEqual(44);
+ expect(k.height).toBeGreaterThanOrEqual(44);
+ }
+ }
+ const box = (await headerRow(page).boundingBox())!;
+ expect(box.x + box.width).toBeLessThanOrEqual(width);
+ await noHorizontalOverflow(page);
+ });
+ }
+});
+
+test("keyboard focus shows the ring; in forced colours the browser's own outline, and none at rest", async ({
+ page,
+}) => {
+ await page.goto("/");
+ const link = headerRow(page).getByRole("link").first();
+ await tabTo(page, link);
+ const normal = await link.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("/");
+ const forced = headerRow(page).getByRole("link").first();
+ const rest = await forced.evaluate((el) => getComputedStyle(el).outlineStyle);
+ expect(rest, "a permanent outline in forced colours").toBe("none");
+ await tabTo(page, forced);
+ const focused = await forced.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) };
+ });
+ expect(focused.style).not.toBe("none");
+ expect(focused.width).toBeGreaterThan(0);
+});
+
+test("six links: the header shows the last four from md, three below it, two below 360; the footer all six", async ({
+ page,
+}) => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ const six = FIXTURE_SOCIAL_SIX.map((l) => l.label);
+ for (const [width, shown] of [[320, 2], [360, 3], [390, 3], [767, 3], [768, 4], [1280, 4]] as const) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-shown));
+ expect(await labelsOf(footerRow(page))).toEqual(six);
+ await noHorizontalOverflow(page);
+ }
+});
+
+// The gear 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 ({
+ 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())!);
+ 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) {
+ expect(g, "boxes overlap").toBeGreaterThanOrEqual(-0.5);
+ expect(Math.abs(g - gaps[0]), `gaps ${gaps.join(", ")}`).toBeLessThanOrEqual(1);
+ }
+ const lastNav = page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link").last();
+ const nav = (await lastNav.boundingBox())!;
+ expect(boxes[0].x - (nav.x + nav.width), "the nav's gap before the group").toBeGreaterThan(gaps[0] + 16);
+ });
+}
+
+test("six links, two marked for the header: the header shows those two", async ({ page }) => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX_FEATURED);
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page))).toEqual(["Square", "Bubble"]);
+ expect(await labelsOf(footerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label));
+});
+
+test("no links: no row in the header, and no Elsewhere column in the footer", async ({
+ page,
+}) => {
+ writeFixtureSettings(settingsFile(), []);
+ for (const width of [360, 1280]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(page.locator("header [data-social-links]")).toHaveCount(0);
+ await expect(page.locator("footer [data-social-links]")).toHaveCount(0);
+ await expect(page.locator("footer").getByText("Elsewhere")).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Archilyzer home" })).toBeVisible();
+ }
+});
+
+// A settings.json edited by hand (never through a save) holding icons that
+// would run script: none is inlined, none runs, and each link still renders —
+// as its label — while the good icons beside them render as icons.
+test("a stored icon that would run script is not inlined: no script runs, and the link shows its label", async ({
+ page,
+}) => {
+ const hostile = FIXTURE_HOSTILE_SVGS.map((svg, i) => ({
+ label: `Hostile ${i + 1}`,
+ url: `https://hostile-${i + 1}.example/`,
+ svg,
+ }));
+ writeRawFixtureSettings(settingsFile(), [...FIXTURE_SOCIAL_TRIO.slice(0, 1), ...hostile]);
+ await page.goto("/");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ expect(await page.evaluate(() => (window as unknown as { __hpHostile?: unknown }).__hpHostile)).toBeUndefined();
+ const footer = footerRow(page);
+ for (const h of hostile) {
+ const link = footer.getByRole("link", { name: h.label, exact: true });
+ await expect(link).toHaveAttribute("href", h.url);
+ await expect(link).toHaveText(h.label);
+ await expect(link.locator("svg, img, image")).toHaveCount(0);
+ }
+ // The good icon beside them is still an icon.
+ await expect(footer.getByRole("link", { name: "Leaf", exact: true }).locator("svg")).toHaveCount(1);
+ expect(await page.evaluate(() => document.querySelectorAll("[data-social-links] img, [data-social-links] image").length)).toBe(0);
+});
diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts
@@ -1,9 +1,10 @@
import { test, expect, type Page } from "@playwright/test";
import { REQUIRED_TOKENS } from "../../common/components/themeConfig";
import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand";
+import { chooseTheme } from "./helpers";
// The shared theme system (common/styles/tokens.css + ThemeScript +
-// ThemeProvider + ThemeToggle/ThemeMenu) as the project's own site uses it: it
+// ThemeProvider + the header's Options dialog) 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).
@@ -23,7 +24,7 @@ async function htmlState(page: Page) {
}, BASE_KEY);
}
-test("dark + Signal by default; the base toggle persists with no FOUC", async ({
+test("dark + Signal by default; a base chosen in Options persists with no FOUC", async ({
page,
}) => {
// The OS says light: the default is still dark (it is the site's, not the
@@ -39,16 +40,10 @@ test("dark + Signal by default; the base toggle persists with no FOUC", async ({
brand: ACCENTS.signal.onDark,
});
- const toggle = page.getByRole("button", { name: /switch to/i });
- await expect(toggle).toBeVisible();
-
- // Cycle to an explicit light base (dark → system → light). A click before
- // hydration is lost, so click until it is stored; a click React took commits
- // synchronously, so the check never races it into a second one.
- await expect(async () => {
- if ((await htmlState(page)).stored !== "light") await toggle.click();
- expect((await htmlState(page)).stored).toBe("light");
- }).toPass({ timeout: 10_000 });
+ // 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" });
+ await expect.poll(async () => (await htmlState(page)).stored).toBe("light");
let s = await htmlState(page);
expect(s.base).toBe("light");
expect(s.dark).toBe(false);
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
@@ -5,6 +6,11 @@ import {
FIXTURE_SUMMARY_NAME,
writeFixtureSummary,
} from "./e2e/fixture-summary";
+import {
+ FIXTURE_SETTINGS_NAME,
+ FIXTURE_SOCIAL_TRIO,
+ writeFixtureSettings,
+} from "./e2e/fixture-social";
// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted
// source. Kill any stale dev server on the port between runs.
@@ -20,6 +26,14 @@ import {
// summary it reads instead of
// public/homepage-summary.json (app/lib/summary.ts,
// ignored by a production build)
+// SETTINGS_FILE set below on the dev server: a settings.json
+// holding only the fixture's social links
+// (e2e/fixture-social.ts), never the checkout's
+// own — the header and footer render them
+// SITES_DIR set below on the dev server: an EMPTY
+// directory, so no homepage.json (whose
+// socialLinks would win over settings.json's)
+// is read from the checkout
// E2E_EXPECT_SOURCE `1` (set by whoever runs the suite, after
// `archilyzer source publish`): e2e/source.spec.ts
// FAILS on the /source/ page's empty state instead
@@ -35,6 +49,13 @@ const baseURL = `http://localhost:${PORT}`;
const FIXTURE_SUMMARY = path.resolve(process.cwd(), "e2e", FIXTURE_SUMMARY_NAME);
writeFixtureSummary(FIXTURE_SUMMARY);
+// The same for settings.json: the operator's social links move, and a worktree's
+// settings.json is a copy of theirs. The e2e reads the fixture's (the trio).
+const FIXTURE_SETTINGS = path.resolve(process.cwd(), "e2e", FIXTURE_SETTINGS_NAME);
+writeFixtureSettings(FIXTURE_SETTINGS, FIXTURE_SOCIAL_TRIO);
+const FIXTURE_SITES_DIR = path.resolve(process.cwd(), "e2e", ".e2e-sites");
+fs.mkdirSync(FIXTURE_SITES_DIR, { recursive: true });
+
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
@@ -48,7 +69,11 @@ export default defineConfig({
url: baseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
- env: { E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY },
+ env: {
+ E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY,
+ SETTINGS_FILE: FIXTURE_SETTINGS,
+ SITES_DIR: FIXTURE_SITES_DIR,
+ },
},
use: {
baseURL,