Archilyzer · Source

archilyzer

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

commit 5fc1740c355c63998cf3d475e828b03118114a12
parent 399329b7066e1a634d6fa8fcb71f39db3099cce0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 21:40:05 -0400

export: the header carries the social row and the theme toggle as one group, links to the official instances in place of the sites menu and the hub link; Changelog to the footer (release 14 slices H1, H2)

- The group is the homepage's: SocialLinks (placement header, at most four:
  featured, else the last four) in SocialScroll, then ThemeToggle
  variant="bare", 36/44 px keys touching. The footer's row is SocialLinks
  (placement footer) too.
- SiblingSwitcher is deleted; the Archilyzer link (visible text "Archilyzer",
  name "Archilyzer — official instances", same tab, INSTANCES_URL) takes its
  place, inline from lg and in the slide-out menu below; not on the hub. The
  hub backlink is gone from both; hubUrl still parses.
- Changelog leaves the header and the menu for the footer, after Use with AI.
- The nav moves into the menu below lg (at 768 px the nav, the link and a long
  title with four icons did not fit, and an icon would have scrolled first).
  The menu holds only the nav and the Archilyzer link; ThemeRadios is deleted.
- Narrow widths: the wordmark's text sits in a one-line clipped flex-wrap box
  behind a zero-width strut, so it drops exactly when it does not fit beside
  the group, for any title, measured by the browser in its own font; the brand
  link shrinks to the mark before the group gives way; the social row's box
  scrolls last, end first.
- header.spec.ts (new): short and long titles × 1–4 links × 280–430 px ×
  mouse/touch (no page or header overflow, every link shown, text drops before
  the row scrolls, the name stays the title), key sizes and spacing, focus
  ring, the Archilyzer link, no sites/hub/theme menu, Changelog in the footer
  only, six/featured, a hostile icon as its label with no request elsewhere.
  The fixture site is restored after each test (and by playwright.config.ts
  from `_e2ePristine` if a run dies). responsive, site-branding and the hub's
  official-instances specs follow.

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

Diffstat:
Mexport/CHANGELOG.md | 1+
Mexport/app/components/Footer.tsx | 51++++++++++++++-------------------------------------
Mexport/app/components/Header.tsx | 141++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mexport/app/components/MobileMenu.tsx | 83+++++++++++++++++++------------------------------------------------------------
Mexport/e2e-hub/official-instances.spec.ts | 17+++++++++++++++++
Aexport/e2e/header.spec.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/responsive.spec.ts | 23++++++++++++++---------
Mexport/e2e/site-branding.spec.ts | 2+-
Mexport/playwright.config.ts | 7++++++-
9 files changed, 418 insertions(+), 174 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -5,6 +5,7 @@ - **A social icon that fails the check is shown as its label, and every icon paints inside its box.** The footer inlines a social link's SVG only if it passes the same check a save runs (what an icon may contain is in `SITE.md`); otherwise the link shows its label as text, at most 10rem with an ellipsis. Each icon is clipped to its own box. Needs a rebuild and deploy of each site. - **A chart's stacked bars and stacked areas are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; a stacked area's bands each had a line in their own colour along the top. Both now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site. - **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark, and the slide-out menu's Base list offers those three. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site. +- **The header carries the operator's social links and one theme toggle, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's now end with the social icons (the site's `socialLinks`, else `settings.json`'s; at most four: the ones marked **Show in header** when any is, else the last four) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring; the footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour). The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. On a narrow screen no icon is hidden: the site's name drops first and its mark stays, whenever the name does not fit beside the icons (whatever its length), and only then do the icons scroll sideways in their own box, the last one in view first. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site. ## [0.10.0] - 2026-09-28 - **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site. diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx @@ -1,5 +1,4 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { safeSocialSvg, sizeSocialSvg } from "yt-dlp-transcript-common/lib/socialLinks"; import { listSites, resolveRelatedSites, @@ -11,6 +10,7 @@ import { } from "yt-dlp-transcript-common/lib/project"; import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand"; import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; +import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; @@ -70,13 +70,21 @@ export default function Footer() { > Use with AI </a> + {/* Changelog lives here, not in the header (release 14). */} + <a + href="/changelog" + className="underline underline-offset-2 hover:text-foreground transition-colors" + > + Changelog + </a> </div> <div className="flex flex-wrap items-center gap-x-4 gap-y-2"> {/* "Built with Archilyzer" — deliberately NOT gated on instanceMode() (being identical on every deployment is the point), NOT dependent on a configured hubUrl (that is the operator's family link, a - different thing), and NOT target="_blank": the header's hub link - navigates in the same tab, and only the social icons open new ones. + different thing), and NOT target="_blank": the header's Archilyzer + link navigates in the same tab, and only the social icons open new + ones. "Built with" sits OUTSIDE the anchor so the accessible name is exactly "Archilyzer" — and so does the parent mark before it, which is decorative (aria-hidden) and the same on every site. */} @@ -92,40 +100,9 @@ export default function Footer() { </a> </span> </span> - {socialLinks.length > 0 && ( - <ul className="flex items-center gap-3 list-none"> - {socialLinks.map((link, i) => { - // Inlined only if the stored icon passes the save-time check - // again (lib/socialLinks.ts safeSocialSvg); otherwise the label. - const svg = safeSocialSvg(link.svg); - return ( - <li key={`${link.url}-${i}`}> - {svg ? ( - <a - href={link.url} - title={link.label} - aria-label={link.label} - target="_blank" - rel="noopener noreferrer" - className="inline-block w-5 h-5 overflow-hidden [contain:paint] text-muted-foreground hover:text-brand transition-colors [&_svg]:w-full [&_svg]:h-full" - dangerouslySetInnerHTML={{ __html: sizeSocialSvg(svg) }} - /> - ) : ( - <a - href={link.url} - target="_blank" - rel="noopener noreferrer" - title={link.label} - className="inline-block max-w-40 truncate align-bottom text-muted-foreground hover:text-brand transition-colors" - > - {link.label} - </a> - )} - </li> - ); - })} - </ul> - )} + {/* The operator's social links, every one of them (the header shows + at most four): the shared row, its keys and focus ring. */} + <SocialLinks links={socialLinks} placement="footer" className="mx-0 pointer-coarse:mx-0" /> </div> </div> {related.length > 0 && ( diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -1,12 +1,11 @@ import Link from "next/link"; -import { ArrowUpLeft } from "lucide-react"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; -import { - listSites, - resolveHubUrl, - resolveRelatedSites, -} from "yt-dlp-transcript-common/lib/site"; +import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site"; +import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks"; +import { INSTANCES_URL } from "yt-dlp-transcript-common/lib/project"; 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 { BrandMark } from "yt-dlp-transcript-common/components/BrandMark"; import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; import { currentSite } from "../lib/site"; @@ -14,36 +13,52 @@ import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; import { hasArchives } from "../lib/archives"; import { hasDuplicates } from "../lib/duplicates"; -import SiblingSwitcher from "./SiblingSwitcher"; import MobileMenu from "./MobileMenu"; -// The export site's masthead: the Found-line mark + the split wordmark, calm -// sans nav, and the cross-site "family" chrome (hub backlink + sibling -// switcher). The mark's lit line follows the site's accent (lib/brand.ts -// headerMarkPalette); the hub wears the parent mark. The link's accessible name -// is the header title exactly — the mark is decorative and the wordmark's two -// spans join without a space. All cross-site data is resolved at build time -// from the pool, so single-site installs simply render neither. +// The export site's masthead (every site, and the hub): the Found-line mark + +// the split wordmark, the nav, a link to the Archilyzer home's official +// instances, and the homepage's group — the operator's social links +// (common/components/SocialLinks.tsx) and the theme toggle +// (common/components/ThemeToggle.tsx, `variant="bare"`) as the group's last +// key, their boxes touching, every glyph 16 px from the next. The mark's lit +// line follows the site's accent (lib/brand.ts headerMarkPalette); the hub +// wears the parent mark. The brand link's accessible name is the header title +// exactly — the mark is decorative and the wordmark's two spans join without a +// space. +// +// ≥ lg brand · nav · Archilyzer · group +// < lg brand · group · menu trigger (the nav, and the Archilyzer link, in +// the slide-out menu). The nav, the link and a long title with four +// icons do not fit a 768 px bar, and the nav gives way before any +// icon scrolls. +// +// The header shows at most four social links (headerSocialLinks: the +// `featured` ones when any is marked, else the last four); the footer shows +// them all. None is hidden by width. The Archilyzer link replaces the old +// sites dropdown and the hub backlink; it is absent on the hub, which lists +// the instances itself. Changelog is in the footer. +// +// ON A NARROW SCREEN THE HEADER MAKES ROOM FIRST: the wordmark's TEXT drops and +// the mark stays, exactly when the text does not fit — whatever the site's +// title. Titles differ in length ("Bonnellyzer", "Rekietalyzer"), so no width +// threshold is written down: the text sits in a one-line box (`h-7`, +// `overflow-hidden`, `flex-wrap`) behind a zero-width strut, and a flex item +// that does not fit beside the strut wraps to the box's second line, which is +// clipped. The browser measures the title in its own font, at the reader's +// text size, every time. The text stays in the DOM, so the link keeps its name. +// Below `lg` the brand link takes the bar's free space (basis 0, grow 1) and +// can shrink to the mark alone; only then does the group give way, and its +// social row scrolls in SocialScroll's box, the last resort, its END shown +// first, the toggle outside it. export default function Header() { const site = currentSite(); - const settings = getSettings(); - // The hub this site points visitors toward: its own hubUrl override, else the - // family default (resolveHubUrl). Only a plain site shows the backlink — the - // hub itself never links to itself — and never when the parent is this very - // deployment. const isSite = instanceMode() === "site"; - const resolvedHub = resolveHubUrl(site, settings); - const hubUrl = - isSite && resolvedHub && resolvedHub !== site.siteUrl - ? resolvedHub - : undefined; - // The sibling switcher is build-time family navigation — it belongs on a - // single site, not on the hub (whose "family" is the runtime shelf). - const related = isSite ? resolveRelatedSites(site, listSites()) : []; + const socialLinks = resolveSocialLinks(site, getSettings()); + const shown = headerSocialLinks(socialLinks).length; const showDownloads = hasArchives(); const showDuplicates = hasDuplicates(); - // The inline nav from md. Ask AI is new: the chat was only reachable from + // The inline nav from lg. Ask AI is new: the chat was only reachable from // the workspace control or from Use with AI, which is not where anyone looks // for it. const navLinks = [ @@ -53,14 +68,12 @@ export default function Header() { ...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []), { href: "/use-with-ai", label: "Use with AI" }, ]; - // The sheet also takes the two links the wide header keeps in its right - // cluster or its footer: Changelog, and Offline on a PWA-shipping site — - // gated exactly as Footer.tsx gates it, so the two never disagree about - // whether this instance has an offline mode. + // The sheet also takes Offline on a PWA-shipping site — gated exactly as + // Footer.tsx gates it, so the two never disagree about whether this instance + // has an offline mode. const menuLinks = [ ...navLinks, ...(site.pwa ? [{ href: "/offline/", label: "Offline" }] : []), - { href: "/changelog", label: "Changelog" }, ]; return ( @@ -68,17 +81,24 @@ export default function Header() { {/* min-h-14, not h-14, and never wrapping: a fixed-height flex-wrap row put the overflow rows OUTSIDE the sticky header's background at phone widths, and the page scrolled through them. */} - <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap"> - <Link href="/" className="flex min-w-0 items-center gap-2.5"> + <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-3 lg:gap-5 flex-nowrap"> + <Link + href="/" + data-header-brand="" + className="flex min-w-7 flex-1 basis-0 items-center lg:grow-0 lg:basis-auto" + > <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" /> - <Wordmark - title={site.headerTitle} - lead={site.wordmarkLead} - className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]" - /> + <span className="flex h-7 min-w-0 flex-1 flex-wrap items-center overflow-hidden"> + <span aria-hidden="true" className="h-7 w-0" /> + <Wordmark + title={site.headerTitle} + lead={site.wordmarkLead} + className="ml-2.5 whitespace-nowrap text-[1.35rem] leading-none tracking-[-0.01em]" + /> + </span> </Link> - <nav className="hidden md:flex items-center gap-4 text-sm font-medium"> + <nav className="hidden lg:flex shrink-0 items-center gap-4 text-sm font-medium"> {navLinks.map((l) => ( <Link key={l.href} @@ -90,31 +110,26 @@ export default function Header() { ))} </nav> - <div className="ml-auto flex items-center gap-2 sm:gap-3 shrink-0"> - <div className="hidden md:flex items-center gap-2 sm:gap-3"> - <SiblingSwitcher groups={related} /> - {hubUrl && ( - <a - href={hubUrl} - rel="noopener noreferrer" - className="inline-flex items-center gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground hover:text-brand transition-colors" - > - <ArrowUpLeft className="size-3.5" aria-hidden="true" /> - Hub - </a> - )} - <Link - href="/changelog" - className="text-sm text-muted-foreground hover:text-foreground transition-colors" + <div className="flex min-w-0 items-center gap-5 lg:ml-auto"> + {isSite && ( + <a + href={INSTANCES_URL} + aria-label="Archilyzer — official instances" + className="hidden lg:inline shrink-0 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Changelog - </Link> + Archilyzer + </a> + )} + <div data-header-group="" className="flex min-w-0 items-center"> + {shown > 0 && ( + <SocialScroll> + <SocialLinks links={socialLinks} placement="header" className="w-max px-1 [direction:ltr]" /> + </SocialScroll> + )} + <ThemeToggle variant="bare" /> </div> - {/* The one control that stays at every width: theme.spec resolves it - by /switch to/i, and one tap to flip light/dark is worth a slot. */} - <ThemeToggle /> - <MobileMenu links={menuLinks} sites={related} hubUrl={hubUrl} /> </div> + <MobileMenu links={menuLinks} instancesUrl={isSite ? INSTANCES_URL : undefined} /> </div> </header> ); diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; -import { ArrowUpLeft, MenuIcon } from "lucide-react"; +import { MenuIcon } from "lucide-react"; import { Button } from "yt-dlp-transcript-common/components/ui/button"; import { Sheet, @@ -11,29 +11,25 @@ import { SheetTitle, SheetTrigger, } from "yt-dlp-transcript-common/components/ui/sheet"; -import { ThemeRadios } from "yt-dlp-transcript-common/components/ThemeRadios"; -import type { SwitcherGroup } from "./SiblingSwitcher"; -// The phone half of the masthead. Below `md` the header keeps only the brand, -// the base toggle and this trigger; everything else the header offers — the -// nav, the sibling sites, the hub backlink, the base picker — -// moves in here, which is also how the Hub backlink and the sites list become -// reachable on a phone at all (they were `hidden sm:*` and a dropdown too small -// to hit). +// The narrow half of the masthead: the NAV. Below `lg` the header's bar keeps +// the brand, the social row, the theme toggle and this trigger; the nav's +// links (five or more do not fit a phone's or a tablet's bar beside the group) +// move in here, with the link to the Archilyzer home's official instances +// after them on a site (the bar shows both inline from `lg`). Nothing else: +// the theme is the bar's toggle, the sibling sites are the footer's, and +// Changelog is in the footer. // -// Header is a server component, so the link groups arrive as plain serialisable -// props. The base list is read from the client ThemeProvider and rendered as a -// NATIVE radio group ("Base"; ThemeRadios), not as a dropdown inside this -// dialog — one popover layer inside another is a focus-trap fight. There is no -// accent group: each site shows its own. +// Header is a server component, so the links arrive as plain serialisable +// props. export default function MobileMenu({ links, - sites, - hubUrl, + instancesUrl, }: { links: { href: string; label: string }[]; - sites: SwitcherGroup[]; - hubUrl?: string; + // The Archilyzer home's Official Instances (common/lib/project.ts + // INSTANCES_URL); absent on the hub, which lists the instances itself. + instancesUrl?: string; }) { const [open, setOpen] = useState(false); @@ -44,7 +40,7 @@ export default function MobileMenu({ variant="ghost" size="icon" aria-label="Open menu" - className="md:hidden" + className="shrink-0 lg:hidden" > <MenuIcon aria-hidden="true" /> </Button> @@ -72,56 +68,17 @@ export default function MobileMenu({ </Link> </SheetClose> ))} - {hubUrl && ( + {instancesUrl && ( <a - href={hubUrl} - rel="noopener noreferrer" - className="flex items-center gap-1.5 rounded-md px-2 py-3 text-base font-medium text-muted-foreground transition-colors hover:bg-accent" + href={instancesUrl} + aria-label="Archilyzer — official instances" + className="rounded-md px-2 py-3 text-base font-medium text-muted-foreground transition-colors hover:bg-accent" > - <ArrowUpLeft className="size-4" aria-hidden="true" /> - Hub + Archilyzer </a> )} </nav> - - {sites.length > 0 && ( - <div className="mt-4 px-2"> - <MenuHeading>Sites</MenuHeading> - {sites.map((group, gi) => ( - <div key={group.label ?? `group-${gi}`} className="mt-1"> - {group.label && ( - <p className="px-2 py-1 font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground/70"> - {group.label} - </p> - )} - <ul className="list-none"> - {group.sites.map((s) => ( - <li key={s.siteId}> - <a - href={s.url} - rel="noopener noreferrer" - className="block rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent" - > - {s.title} - </a> - </li> - ))} - </ul> - </div> - ))} - </div> - )} - - <ThemeRadios namePrefix="mobile-theme" groupClassName="mt-4 px-2" /> </SheetContent> </Sheet> ); } - -function MenuHeading({ children }: { children: React.ReactNode }) { - return ( - <p className="px-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground"> - {children} - </p> - ); -} diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -119,6 +119,23 @@ test.describe("hub official instances", () => { ).toHaveCount(0); }); + // The hub lists the instances itself: its header has the group (the theme + // toggle; the social row when there are links) and no link to the + // Archilyzer home's list, no sites menu and no hub link. + test("the hub's header has no Archilyzer link, sites menu or theme menu", async ({ page }) => { + await stubMember(page); + await page.route("**/hub-summary.json", (r) => + r.fulfill({ status: 404, body: "" }), + ); + await page.goto("/"); + const banner = page.getByRole("banner"); + await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible(); + await expect(banner.getByRole("link", { name: /official instances/i })).toHaveCount(0); + await expect(banner.getByRole("button", { name: /sites/i })).toHaveCount(0); + await expect(banner.getByRole("button", { name: "Choose theme" })).toHaveCount(0); + await expect(banner.getByRole("link", { name: "Changelog" })).toHaveCount(0); + }); + test("the hub has no link to ko-fi.com", async ({ page }) => { await stubMember(page); await page.route("**/hub-summary.json", (r) => diff --git a/export/e2e/header.spec.ts b/export/e2e/header.spec.ts @@ -0,0 +1,267 @@ +import fs from "node:fs"; +import path from "node:path"; +import { test, expect, type Locator, type Page } from "@playwright/test"; +import { INSTANCES_URL } from "../../common/lib/project"; +import { normalizeSocialSvg } from "../../common/lib/settingsSchema"; +import { ADVERSARIAL, EVIL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors"; +import { installRoutes } from "./helpers"; + +// THE EXPORT HEADER (export/app/components/Header.tsx): brand · nav · +// Archilyzer · the group (the social row and the theme toggle) from `lg`; +// brand · group · menu trigger below it. No sites dropdown, no hub link, no +// theme menu; Changelog is in the footer. On a narrow screen the wordmark's +// text drops first (whenever it does not fit, whatever the title), the mark +// stays, and the social row scrolls in its box only after that. +// +// The suite serves ONE site (SITE_ID=testsite) and currentSite() re-reads +// site.json on every dev render, so each test rewrites the fixture's title and +// social links and puts the file back after. The original rides along in +// `_e2ePristine`, which playwright.config.ts restores if a run dies mid-test. +// The icons are synthetic, put through the save path's normalizer. + +const SITE_FILE = path.resolve(process.cwd(), "e2e", "fixtures", "sites", "testsite", "site.json"); +let pristine = ""; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(() => { + pristine = fs.readFileSync(SITE_FILE, "utf8"); +}); + +test.afterEach(() => { + fs.writeFileSync(SITE_FILE, pristine); +}); + +type RawLink = { label: string; url: string; svg: string; featured?: boolean }; + +function writeSite(patch: { headerTitle?: string; wordmarkLead?: string; socialLinks?: RawLink[] }) { + const site = JSON.parse(pristine) as Record<string, unknown>; + fs.writeFileSync( + SITE_FILE, + `${JSON.stringify({ ...site, ...patch, _e2ePristine: pristine }, null, 2)}\n`, + ); +} + +const icon = (d: string) => `<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`; +const link = (label: string, d: string, featured = false): RawLink => ({ + label, + url: `https://${label.toLowerCase()}.example/fixture`, + svg: normalizeSocialSvg(icon(d))!, + ...(featured ? { featured: true } : {}), +}); +const SIX = [ + link("Square", "M4 4h16v16H4z"), + link("Triangle", "M12 3l10 18H2z"), + link("Bar", "M3 10h18v4H3z"), + link("Diamond", "M12 2l10 10-10 10L2 12z"), + link("Post", "M10 2h4v20h-4z"), + link("Chevron", "M4 8l8 8 8-8-3-3-5 5-5-5z"), +]; +const TITLES = { + short: { headerTitle: "Shortlyzer", wordmarkLead: "Short" }, + long: { headerTitle: "Longestfixturealyzer", wordmarkLead: "Longestfixture" }, +} as const; + +const banner = (page: Page) => page.getByRole("banner"); +const brand = (page: Page) => page.locator("header [data-header-brand]"); +const headerRow = (page: Page) => page.locator('header [data-social-links="header"]'); +const toggle = (page: Page) => banner(page).getByRole("button", { name: /^switch to /i }); + +async function open(page: Page, width: number) { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + await expect(toggle(page)).toBeVisible(); +} + +// What the narrow header is doing: is the wordmark's text on its visible line, +// does the row's box scroll, does anything overflow. +const layout = (page: Page) => + page.evaluate(() => { + const box = document.querySelector("header [data-header-brand] span.flex-wrap") as HTMLElement; + const text = box.lastElementChild as HTMLElement; + const scroll = document.querySelector("header [data-social-scroll]") as HTMLElement | null; + const row = document.querySelector("header > div") as HTMLElement; + return { + text: text.offsetTop < box.clientHeight - 1, + scrolls: scroll ? scroll.scrollWidth > scroll.clientWidth + 1 : false, + page: document.documentElement.scrollWidth - document.documentElement.clientWidth, + row: row.scrollWidth - row.clientWidth, + }; + }); + +for (const touch of [false, true]) { + test.describe(touch ? "under a coarse pointer" : "with a mouse", () => { + test.use({ hasTouch: touch }); + for (const [kind, title] of Object.entries(TITLES)) { + test(`${kind} title, 1–4 links, 280–430 px: nothing scrolls the page, no link hidden, the text drops before the row scrolls`, async ({ + page, + }) => { + test.setTimeout(120_000); + await installRoutes(page); + for (const n of [1, 2, 3, 4]) { + writeSite({ ...title, socialLinks: SIX.slice(0, n) }); + for (const w of [280, 300, 320, 340, 360, 375, 390, 414, 430]) { + await open(page, w); + const at = `${kind} title, ${n} link(s), ${w} px`; + await expect(headerRow(page).getByRole("link"), at).toHaveCount(n); + const l = await layout(page); + expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0); + expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); + if (l.scrolls) expect(l.text, `${at}: the row scrolls while the text shows`).toBe(false); + await expect(brand(page).locator("svg[data-brand-mark]"), at).toBeInViewport({ ratio: 1 }); + await expect(toggle(page), at).toBeInViewport({ ratio: 1 }); + // The name is the title whether or not the text shows. + await expect(brand(page), at).toHaveAccessibleName(title.headerTitle); + } + } + }); + } + }); +} + +test("a short title shows in full on a phone with one link; a long one gives way", async ({ page }) => { + await installRoutes(page); + writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 1) }); + await open(page, 390); + expect((await layout(page)).text).toBe(true); + writeSite({ ...TITLES.long, socialLinks: SIX.slice(0, 4) }); + await open(page, 390); + expect(await layout(page)).toMatchObject({ text: false, scrolls: false }); + await open(page, 1280); + expect((await layout(page)).text).toBe(true); +}); + +for (const width of [390, 1280]) { + test(`${width} px: the group's keys are 36 px, the toggle is the last, their boxes touch`, async ({ + page, + }) => { + await installRoutes(page); + writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 3) }); + await open(page, width); + const keys: Locator[] = [...(await headerRow(page).getByRole("link").all()), toggle(page)]; + const boxes = await Promise.all(keys.map((k) => k.boundingBox())); + for (const b of boxes) expect([b!.width, b!.height]).toEqual([36, 36]); + for (let i = 1; i < boxes.length; i++) { + expect(Math.abs(boxes[i]!.x - (boxes[i - 1]!.x + boxes[i - 1]!.width))).toBeLessThanOrEqual(0.5); + } + }); +} + +test.describe("under a coarse pointer", () => { + test.use({ hasTouch: true }); + test("the keys and the toggle are 44 px", async ({ page }) => { + await installRoutes(page); + writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 2) }); + await open(page, 390); + for (const k of [...(await headerRow(page).getByRole("link").all()), toggle(page)]) { + const b = (await k.boundingBox())!; + expect([b.width, b.height]).toEqual([44, 44]); + } + }); +}); + +test("keyboard focus draws the ring on a social key", async ({ page }) => { + await installRoutes(page); + writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 2) }); + await open(page, 1280); + const first = headerRow(page).getByRole("link").first(); + for (let i = 0; i < 30; i++) { + await page.keyboard.press("Tab"); + if (await first.evaluate((el) => el === document.activeElement)) break; + } + const s = await first.evaluate((el) => { + const cs = getComputedStyle(el); + return { outline: cs.outlineStyle, shadow: cs.boxShadow }; + }); + expect(s.outline).toBe("none"); + expect(s.shadow).toMatch(/0px 0px 0px 2px/); +}); + +test("the Archilyzer link goes to the official instances; no sites menu, hub link or theme menu", async ({ + page, +}) => { + await installRoutes(page); + await open(page, 1280); + const archilyzer = banner(page).getByRole("link", { + name: "Archilyzer — official instances", + exact: true, + }); + await expect(archilyzer).toBeVisible(); + await expect(archilyzer).toHaveText("Archilyzer"); + await expect(archilyzer).toHaveAttribute("href", INSTANCES_URL); + await expect(archilyzer).not.toHaveAttribute("target", /.+/); + await expect(banner(page).getByRole("button", { name: /sites/i })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0); + await expect(banner(page).getByRole("link", { name: "Hub", exact: true })).toHaveCount(0); + // Below lg it is in the menu instead (responsive.spec), not the bar. + await open(page, 390); + await expect(archilyzer).toBeHidden(); +}); + +test("Changelog is in the footer, not the header", async ({ page }) => { + await installRoutes(page); + await open(page, 1280); + await expect(banner(page).getByRole("link", { name: "Changelog" })).toHaveCount(0); + const footer = page.locator("footer"); + const changelog = footer.getByRole("link", { name: "Changelog", exact: true }); + await expect(changelog).toHaveAttribute("href", "/changelog"); + // After Use with AI, in the same row. + const order = await footer + .getByRole("link") + .evaluateAll((els) => els.map((e) => e.textContent?.trim() ?? "")); + expect(order.indexOf("Changelog")).toBe(order.indexOf("Use with AI") + 1); +}); + +test("six links: the header shows the last four, the footer all six; two marked: those two", async ({ + page, +}) => { + await installRoutes(page); + const labels = (loc: Locator) => + loc.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); + writeSite({ ...TITLES.short, socialLinks: SIX }); + await open(page, 1280); + expect(await labels(headerRow(page))).toEqual(SIX.slice(2).map((l) => l.label)); + expect(await labels(page.locator('footer [data-social-links="footer"]'))).toEqual( + SIX.map((l) => l.label), + ); + const featured = SIX.map((l) => + l.label === "Square" || l.label === "Post" ? { ...l, featured: true } : l, + ); + writeSite({ ...TITLES.short, socialLinks: featured }); + await open(page, 1280); + expect(await labels(headerRow(page))).toEqual(["Square", "Post"]); +}); + +// A hand-edited site.json can hold anything. The header (and the footer) +// inline an icon only if it passes the save path's check again; the rest show +// their label, run nothing and fetch nothing from anywhere else. +test("a hostile icon in a site's socialLinks is its label: nothing runs, nothing is fetched elsewhere", async ({ + page, +}) => { + await installRoutes(page); + const hostile: RawLink[] = [ + { label: "Hostile 1", url: "https://h1.example", svg: `<svg/onload="window.__hdrHostile=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>` }, + { label: "Hostile 2", url: "https://h2.example", svg: `<svg viewBox="0 0 8 8"><img src="x:" onerror="window.__hdrHostile=1"></svg>` }, + ...LOADS_ELSEWHERE.slice(0, 2).map((name, i) => ({ + label: `Hostile ${i + 3}`, + url: `https://h${i + 3}.example`, + svg: ADVERSARIAL[name], + })), + ]; + writeSite({ ...TITLES.short, socialLinks: hostile }); + const elsewhere: string[] = []; + page.on("request", (r) => { + const u = new URL(r.url()); + if (u.protocol.startsWith("http") && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") { + elsewhere.push(r.url()); + } + }); + await open(page, 1280); + await page.waitForLoadState("networkidle"); + for (const l of hostile) { + await expect(headerRow(page).getByRole("link", { name: l.label, exact: true })).toHaveText(l.label); + } + expect(await page.evaluate(() => (window as unknown as { __hdrHostile?: number }).__hdrHostile)).toBeUndefined(); + expect(elsewhere.filter((u) => u.startsWith(EVIL)), "requests to the icons' origin").toEqual([]); + expect(elsewhere, "requests to any other origin").toEqual([]); +}); diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data"; import { expectModalOpen, installRoutes } from "./helpers"; +import { INSTANCES_URL } from "../../common/lib/project"; // The phone. Every other spec in this suite runs at the project's 1440×1200 // desktop viewport, which is exactly why the export site could ship a header @@ -82,22 +83,26 @@ test.describe("phone layout", () => { }); } - test("the header menu carries the nav that the wide header shows inline", async ({ + test("the header menu carries the nav that the wide header shows inline, and nothing else", async ({ page, }) => { await page.goto("/"); + // The theme is the bar's toggle, beside the trigger. + await expect(page.getByRole("banner").getByRole("button", { name: /^switch to /i })).toBeVisible(); await page.getByRole("button", { name: "Open menu" }).click(); const menu = page.getByRole("dialog"); await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible(); await expect(menu.getByRole("link", { name: "Search" })).toBeVisible(); - // The base as a plain radio list, because a popover inside a dialog is a - // focus-trap fight; there is no accent list. - const base = menu.getByRole("radiogroup", { name: "Base" }); - for (const name of ["System", "Light", "Dark"]) { - await expect(base.getByRole("radio", { name, exact: true })).toBeVisible(); - } - await expect(base.getByRole("radio")).toHaveCount(3); - await expect(menu.getByRole("radiogroup", { name: "Accent" })).toHaveCount(0); + // The Archilyzer home's official instances, where the sites list was. + await expect( + menu.getByRole("link", { name: "Archilyzer — official instances", exact: true }), + ).toHaveAttribute("href", INSTANCES_URL); + // No theme radios; Changelog, the sibling sites and the hub are not here. + await expect(menu.getByRole("radiogroup")).toHaveCount(0); + await expect(menu.getByRole("radio")).toHaveCount(0); + await expect(menu.getByRole("link", { name: "Changelog" })).toHaveCount(0); + await expect(menu.getByRole("link", { name: "Hub" })).toHaveCount(0); + await expect(menu.getByText("Sites", { exact: true })).toHaveCount(0); }); test("the filters sheet applies a filter and the results change", async ({ diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts @@ -26,7 +26,7 @@ test("footer renders the site's social links", async ({ page }) => { await installRoutes(page); await page.goto("/"); await expect( - page.getByRole("link", { name: "GitHub" }), + page.locator("footer").getByRole("link", { name: "GitHub" }), ).toHaveAttribute("href", "https://github.com/example"); }); diff --git a/export/playwright.config.ts b/export/playwright.config.ts @@ -28,11 +28,16 @@ buildFixtureSettings(TEST_SETTINGS_FILE); // fixture site for one test and restores it afterwards. A run killed inside that // test leaves the key behind, and every modal spec before it would then fail on // the missing controls — so strip it here, before any spec runs. +// header.spec rewrites the fixture's title and social links for a test and +// carries the original file in `_e2ePristine` (parseSite drops the key): a run +// killed inside it is put back here the same way. const FIXTURE_SITE = path.join(TEST_SITES_DIR, "testsite", "site.json"); { const raw = fs.readFileSync(FIXTURE_SITE, "utf8"); const site = JSON.parse(raw) as Record<string, unknown>; - if ("transcriptDownloads" in site) { + if (typeof site._e2ePristine === "string") { + fs.writeFileSync(FIXTURE_SITE, site._e2ePristine); + } else if ("transcriptDownloads" in site) { delete site.transcriptDownloads; fs.writeFileSync(FIXTURE_SITE, `${JSON.stringify(site, null, 2)}\n`); }