Archilyzer · Source

archilyzer

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

commit 43cd4216526af04027304e0e8b345bbb19817bac
parent 0d5522c19638f47c6c6d86f55f9dbcd88f148fc9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 23:12:54 -0400

export, common: the brand shrinks first at lg; the wordmark's width is reserved from the display face; the footer's row wraps; the header at 200 % text (review L1, L2, L6, L9)

- L1: `lg:shrink-[999]` on the brand link, so from lg a very long title drops
  its text before any icon scrolls (1024–1046 px under touch did both).
- L2: the export header's wordmark keeps a width reserved from Archivo's own
  metrics at the wordmark's instances (common/lib/wordmarkMetrics.ts,
  generated from the vendored font by common/bin/gen-wordmark-metrics.py;
  lib/wordmarkWidth.ts, +3 %: the served font renders 0.2–1.7 % wider than
  the file's advances sum to). The fit is the same in the fallback face and
  after Archivo loads, so a title never shows and then vanishes.
- L6: SocialLinks' footer row wraps; eight links under touch at 320 px no
  longer widen the page (export and homepage specs).
- L9: at 200 % text the header itself never overflows. The 43 px the review
  saw at 320 px are the search page's own controls, not the header; at 320 px
  under touch the mark, toggle and menu button leave the row's box no room.

Tests: header.spec's 1024–1050 px band, the font blocked vs loaded at 360,
375, 390, 412 and 430 px (fails without the reservation), 200 % text at 320
and 360 px both pointers, eight footer links; wordmarkWidth.test (the table's
sha is the vendored font's).

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

Diffstat:
Acommon/bin/gen-wordmark-metrics.py | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/SocialLinks.tsx | 5+++--
Mcommon/components/Wordmark.tsx | 4++++
Acommon/lib/wordmarkMetrics.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/wordmarkWidth.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/wordmarkWidth.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/Header.tsx | 17++++++++++++-----
Mexport/e2e/header.spec.ts | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/e2e/social.spec.ts | 27+++++++++++++++++++++++++++
9 files changed, 415 insertions(+), 7 deletions(-)

diff --git a/common/bin/gen-wordmark-metrics.py b/common/bin/gen-wordmark-metrics.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Write common/lib/wordmarkMetrics.ts: the advance of each Latin character +Archivo maps, at the wordmark's two instances (wdth 118; wght 720 for the lead, +380 for the suffix), measured from the vendored variable font +(umtool/report-to-video/fonts/Archivo[wdth,wght].ttf, the same bytes the video +lockup is outlined from). + +The export header reserves the wordmark's width from this table +(common/lib/wordmarkWidth.ts), so whether a title fits beside the header's +icons is decided by the display face's widths, before and after the web font +loads: the fallback face is narrower, and a title that fitted in it would +otherwise show and then vanish when Archivo arrived. + + python3 common/bin/gen-wordmark-metrics.py # rewrite the module + python3 common/bin/gen-wordmark-metrics.py --stdout # print it + +Needs fontTools (pip install fonttools). The module records the fontTools +version it was generated with. +""" +import hashlib +import json +import os +import sys + +import fontTools +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.normpath(os.path.join(HERE, "..", "..")) +FONT = "Archivo[wdth,wght].ttf" +FONT_PATH = os.path.join(REPO, "umtool", "report-to-video", "fonts", FONT) +OUT = os.path.join(REPO, "common", "lib", "wordmarkMetrics.ts") +WEIGHTS = (720, 380) +WDTH = 118 +# Basic Latin, Latin-1 and Latin Extended-A: what a site's title is written in. +WANT = [cp for a, b in ((32, 126), (160, 383)) for cp in range(a, b + 1)] + + +def runs(cps): + out = [] + for cp in cps: + if out and cp == out[-1][1] + 1: + out[-1][1] = cp + else: + out.append([cp, cp]) + return out + + +def wrap(values, indent=" ", width=100): + lines, line = [], "" + for v in values: + s = f"{v}," + if line and len(indent) + len(line) + 1 + len(s) > width: + lines.append(indent + line) + line = s + else: + line = f"{line} {s}" if line else s + if line: + lines.append(indent + line) + return "\n".join(lines) + + +def build(): + raw = open(FONT_PATH, "rb").read() + sha = hashlib.sha256(raw).hexdigest() + base = TTFont(FONT_PATH) + upm = base["head"].unitsPerEm + mapped = base.getBestCmap() + cps = [cp for cp in WANT if cp in mapped] + adv = {} + for w in WEIGHTS: + inst = instancer.instantiateVariableFont(TTFont(FONT_PATH), {"wght": w, "wdth": WDTH}) + cmap = inst.getBestCmap() + hmtx = inst["hmtx"].metrics + adv[w] = [hmtx[cmap[cp]][0] for cp in cps] + parts = [ + "// GENERATED by common/bin/gen-wordmark-metrics.py -- do not edit; re-run it.", + "//", + f"// The advance, in font units, of each Latin character {FONT} maps, at the", + f"// wordmark's instances (wdth {WDTH}; wght {' and '.join(map(str, WEIGHTS))}):", + "// what lib/wordmarkWidth.ts reserves a title's width by.", + "export const WORDMARK_METRICS = {", + f" font: {json.dumps(FONT)},", + f" sha256: {json.dumps(sha)},", + f" fontTools: {json.dumps(fontTools.version)},", + f" unitsPerEm: {upm},", + f" wdth: {WDTH},", + " // [first, last] codepoint ranges; the advances below follow them in order.", + " ranges: [", + wrap([f"[{a}, {b}]" for a, b in runs(cps)], indent=" "), + " ] as ReadonlyArray<readonly [number, number]>,", + " advances: {", + ] + for w in WEIGHTS: + parts.append(f" {w}: [") + parts.append(wrap(adv[w])) + parts.append(" ],") + parts += [" } as Readonly<Record<720 | 380, readonly number[]>>,", "};", ""] + return "\n".join(parts) + + +if __name__ == "__main__": + text = build() + if "--stdout" in sys.argv[1:]: + sys.stdout.write(text) + else: + with open(OUT, "w") as f: + f.write(text) + print(f"wrote {os.path.relpath(OUT)}") diff --git a/common/components/SocialLinks.tsx b/common/components/SocialLinks.tsx @@ -61,8 +61,9 @@ export function SocialLinks({ className={cn( "flex items-center list-none", // The footer's column is left-aligned under its eyebrow: pull the first - // key out by its inset so the first glyph lines up with the text. - placement === "footer" && "-mx-2 pointer-coarse:-mx-3", + // key out by its inset so the first glyph lines up with the text. The + // footer shows every link, so its row wraps rather than widen the page. + placement === "footer" && "flex-wrap -mx-2 pointer-coarse:-mx-3", className, )} > diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx @@ -20,11 +20,14 @@ export function Wordmark({ lead, className, leadStyle, + style, }: { title: string; lead?: string; className?: string; leadStyle?: React.CSSProperties; + // Laid over the outer span's own (the export header reserves its width). + style?: React.CSSProperties; }) { const parts = splitWordmark(title, lead); return ( @@ -34,6 +37,7 @@ export function Wordmark({ style={{ fontFamily: "var(--font-display)", fontStretch: "118%", + ...style, }} > <span diff --git a/common/lib/wordmarkMetrics.ts b/common/lib/wordmarkMetrics.ts @@ -0,0 +1,58 @@ +// GENERATED by common/bin/gen-wordmark-metrics.py -- do not edit; re-run it. +// +// The advance, in font units, of each Latin character Archivo[wdth,wght].ttf maps, at the +// wordmark's instances (wdth 118; wght 720 and 380): +// what lib/wordmarkWidth.ts reserves a title's width by. +export const WORDMARK_METRICS = { + font: "Archivo[wdth,wght].ttf", + sha256: "0e094a7d3c7c4c25cf1310c4b30014f1dae9332220b1c2c88f4fa996f0b05053", + fontTools: "4.65.0", + unitsPerEm: 1000, + wdth: 118, + // [first, last] codepoint ranges; the advances below follow them in order. + ranges: [ + [32, 126], [160, 383], + ] as ReadonlyArray<readonly [number, number]>, + advances: { + 720: [ + 279, 329, 473, 708, 653, 1074, 911, 253, 362, 362, 424, 704, 325, 393, 325, 305, 724, 663, + 722, 727, 715, 726, 728, 679, 737, 728, 330, 333, 704, 704, 704, 674, 1188, 867, 855, 886, + 880, 814, 747, 955, 917, 345, 687, 875, 697, 1061, 916, 954, 810, 954, 873, 810, 780, 900, + 833, 1137, 867, 839, 794, 346, 305, 346, 704, 608, 280, 711, 710, 702, 710, 715, 421, 710, + 700, 290, 288, 673, 290, 1078, 700, 723, 710, 710, 439, 653, 441, 700, 645, 966, 685, 645, + 612, 361, 283, 361, 704, 279, 329, 712, 721, 617, 711, 281, 719, 385, 811, 479, 603, 704, 393, + 811, 365, 400, 704, 430, 430, 280, 703, 686, 394, 266, 430, 466, 603, 1024, 1023, 1024, 674, + 867, 867, 867, 867, 867, 867, 1199, 886, 814, 814, 814, 814, 345, 345, 345, 345, 880, 916, + 954, 954, 954, 954, 954, 704, 954, 900, 900, 900, 900, 839, 824, 731, 711, 711, 711, 711, 711, + 711, 1140, 702, 715, 715, 715, 715, 290, 290, 290, 290, 726, 700, 723, 723, 723, 723, 723, + 704, 723, 700, 700, 700, 700, 645, 710, 645, 867, 711, 867, 711, 867, 711, 886, 702, 886, 702, + 886, 702, 886, 702, 880, 710, 880, 710, 814, 715, 814, 715, 814, 715, 814, 715, 814, 715, 955, + 710, 955, 710, 955, 710, 955, 710, 917, 700, 917, 700, 345, 290, 345, 290, 345, 290, 345, 290, + 345, 290, 1032, 579, 687, 288, 875, 673, 673, 697, 290, 697, 290, 697, 290, 697, 290, 697, + 290, 916, 700, 916, 700, 916, 700, 700, 916, 700, 954, 723, 954, 723, 954, 723, 1448, 1184, + 873, 439, 873, 439, 873, 439, 810, 653, 810, 653, 810, 653, 810, 653, 780, 441, 780, 441, 780, + 441, 900, 700, 900, 700, 900, 700, 900, 700, 900, 700, 900, 700, 1137, 966, 839, 645, 839, + 794, 612, 794, 612, 794, 612, 421, + ], + 380: [ + 300, 278, 353, 683, 592, 1025, 809, 183, 336, 336, 424, 682, 277, 393, 277, 301, 692, 605, + 680, 690, 655, 688, 691, 628, 698, 691, 281, 281, 682, 682, 682, 629, 1207, 827, 827, 889, + 879, 816, 733, 954, 885, 306, 636, 808, 639, 1014, 886, 958, 792, 958, 852, 798, 733, 869, + 777, 1087, 840, 775, 763, 293, 301, 293, 682, 568, 222, 661, 660, 639, 660, 665, 352, 657, + 647, 241, 239, 606, 241, 1023, 647, 673, 660, 660, 380, 592, 372, 646, 576, 831, 601, 576, + 574, 318, 280, 318, 682, 300, 278, 660, 685, 578, 655, 280, 674, 325, 824, 455, 572, 682, 393, + 824, 355, 400, 682, 390, 390, 222, 652, 651, 394, 235, 390, 440, 572, 989, 988, 989, 629, 827, + 827, 827, 827, 827, 827, 1215, 889, 816, 816, 816, 816, 306, 306, 306, 306, 879, 886, 958, + 958, 958, 958, 958, 682, 958, 869, 869, 869, 869, 775, 798, 708, 661, 661, 661, 661, 661, 661, + 1096, 639, 665, 665, 665, 665, 241, 241, 241, 241, 672, 647, 673, 673, 673, 673, 673, 682, + 673, 646, 646, 646, 646, 576, 658, 576, 827, 661, 827, 661, 827, 661, 889, 639, 889, 639, 889, + 639, 889, 639, 879, 660, 879, 660, 816, 665, 816, 665, 816, 665, 816, 665, 816, 665, 954, 657, + 954, 657, 954, 657, 954, 657, 885, 647, 885, 647, 306, 241, 306, 241, 306, 241, 306, 241, 306, + 241, 942, 480, 636, 239, 808, 606, 606, 639, 241, 639, 241, 639, 241, 622, 241, 639, 241, 886, + 647, 886, 647, 886, 647, 647, 886, 646, 958, 673, 958, 673, 958, 673, 1496, 1134, 852, 380, + 852, 380, 852, 380, 798, 592, 798, 592, 798, 592, 798, 592, 733, 372, 733, 372, 733, 372, 869, + 646, 869, 646, 869, 646, 869, 646, 869, 646, 869, 646, 1087, 831, 775, 576, 775, 763, 574, + 763, 574, 763, 574, 352, + ], + } as Readonly<Record<720 | 380, readonly number[]>>, +}; diff --git a/common/lib/wordmarkWidth.test.ts b/common/lib/wordmarkWidth.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WORDMARK_METRICS } from "./wordmarkMetrics"; +import { wordmarkWidthEm } from "./wordmarkWidth"; + +// Run with: pnpm --filter yt-dlp-transcript-common test + +const FONT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "umtool", + "report-to-video", + "fonts", + WORDMARK_METRICS.font, +); + +test("the table was generated from the vendored Archivo", () => { + const sha = createHash("sha256").update(fs.readFileSync(FONT)).digest("hex"); + assert.equal(WORDMARK_METRICS.sha256, sha, "re-run common/bin/gen-wordmark-metrics.py"); + const size = WORDMARK_METRICS.ranges.reduce((n, [a, b]) => n + b - a + 1, 0); + assert.equal(WORDMARK_METRICS.advances[720].length, size); + assert.equal(WORDMARK_METRICS.advances[380].length, size); +}); + +test("the lead is set heavier, so wider, than the same letters as suffix", () => { + const heavy = wordmarkWidthEm("Wwww", "Wwww"); + const light = wordmarkWidthEm("xWwww", "x") - wordmarkWidthEm("x", "x"); + assert.ok(heavy > light, `${heavy} vs ${light}`); +}); + +test("longer titles are wider; tracking narrows by its value per character", () => { + assert.ok(wordmarkWidthEm("Rekietalyzer", "Rekieta") > wordmarkWidthEm("Jeralyzer", "Jer")); + const plain = wordmarkWidthEm("Archilyzer", "Archi"); + const tracked = wordmarkWidthEm("Archilyzer", "Archi", -0.01); + assert.ok(Math.abs(plain - tracked - 0.1 * 1.03) < 0.002, `${plain} → ${tracked}`); +}); + +test("a character the table does not hold counts wide, not zero", () => { + const latin = wordmarkWidthEm("Ab", "Ab"); + assert.ok(wordmarkWidthEm("Ab中", "Ab") - latin > 1.2); +}); diff --git a/common/lib/wordmarkWidth.ts b/common/lib/wordmarkWidth.ts @@ -0,0 +1,43 @@ +import { splitWordmark } from "./brand"; +import { WORDMARK_METRICS } from "./wordmarkMetrics"; + +// A wordmark's width as the DISPLAY FACE sets it, in em of its font size — +// so a layout can reserve it before the web font has loaded. The export +// header's wordmark drops its text when the text does not fit beside the +// icons (export/app/components/Header.tsx); measured in the fallback face, +// which is narrower, a title could fit, show, and then vanish when Archivo +// arrived. Reserving this width makes the decision the same either way. +// +// The lead is weight 720 and the suffix 380 (common/components/Wordmark.tsx), +// both at wdth 118; each character is its advance from the vendored font +// (lib/wordmarkMetrics.ts, generated) plus `letterSpacingEm`, kerning left +// out, and MARGIN more: the web font Google serves renders 0.2–1.7 % wider +// than the vendored file's advances sum to (measured in Chromium on six +// titles), and the reserved box must never be narrower than the text. A +// character the table does not hold counts as UNMAPPED_EM, wider than any it +// does. + +const UNMAPPED_EM = 1.25; +const MARGIN = 1.03; + +function advanceEm(cp: number, wght: 720 | 380): number { + const { ranges, advances, unitsPerEm } = WORDMARK_METRICS; + let offset = 0; + for (const [first, last] of ranges) { + if (cp >= first && cp <= last) return advances[wght][offset + cp - first] / unitsPerEm; + offset += last - first + 1; + } + return UNMAPPED_EM; +} + +export function wordmarkWidthEm(title: string, lead: string | undefined, letterSpacingEm = 0): number { + const parts = splitWordmark(title, lead); + let em = 0; + for (const [text, wght] of [ + [parts.lead, 720], + [parts.suffix, 380], + ] as const) { + for (const ch of text) em += advanceEm(ch.codePointAt(0)!, wght) + letterSpacingEm; + } + return Math.round(em * MARGIN * 1000) / 1000; +} diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -8,6 +8,7 @@ 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 { wordmarkWidthEm } from "yt-dlp-transcript-common/lib/wordmarkWidth"; import { currentSite } from "../lib/site"; import { headerMarkPalette } from "../lib/brand"; import { instanceMode } from "../lib/mode"; @@ -44,12 +45,17 @@ import MobileMenu from "./MobileMenu"; // 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. +// clipped. The text keeps a WIDTH RESERVED from the display face's own +// metrics (lib/wordmarkWidth.ts, in em, so it scales with the reader's text +// size): the decision is the same in the fallback face, which is narrower, +// and after Archivo loads — a title never shows and then vanishes. 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. +// first, the toggle outside it. From `lg` the link is sized by its content, +// so it shrinks first by weight (`shrink-[999]`): with a very long title the +// text drops before any icon scrolls there too. export default function Header() { const site = currentSite(); const isSite = instanceMode() === "site"; @@ -85,7 +91,7 @@ export default function Header() { <Link href="/" data-header-brand="" - className="flex min-w-7 flex-1 basis-0 items-center lg:grow-0 lg:basis-auto" + className="flex min-w-7 flex-1 basis-0 items-center lg:grow-0 lg:basis-auto lg:shrink-[999]" > <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" /> <span className="flex h-7 min-w-0 flex-1 flex-wrap items-center overflow-hidden"> @@ -93,7 +99,8 @@ export default function Header() { <Wordmark title={site.headerTitle} lead={site.wordmarkLead} - className="ml-2.5 whitespace-nowrap text-[1.35rem] leading-none tracking-[-0.01em]" + className="ml-2.5 shrink-0 whitespace-nowrap text-[1.35rem] leading-none tracking-[-0.01em]" + style={{ width: `${wordmarkWidthEm(site.headerTitle, site.wordmarkLead, -0.01)}em` }} /> </span> </Link> diff --git a/export/e2e/header.spec.ts b/export/e2e/header.spec.ts @@ -119,6 +119,118 @@ for (const touch of [false, true]) { }); } +// From lg the brand link is sized by its content; with a very long title it +// must shrink first (`shrink-[999]`), so its text drops before any icon +// scrolls — as below lg. +test.describe("from lg, under a coarse pointer, a very long title", () => { + test.use({ hasTouch: true }); + test("1024–1050 px: the text drops before the icons scroll, and nothing scrolls the page", async ({ + page, + }) => { + await installRoutes(page); + writeSite({ + headerTitle: "Longestfixturearchivealyzer", + wordmarkLead: "Longestfixturearchive", + socialLinks: SIX.slice(0, 4), + }); + await open(page, 1024); + for (let w = 1024; w <= 1050; w += 2) { + await page.setViewportSize({ width: w, height: 800 }); + const l = await layout(page); + expect(l.scrolls, `${w} px: the icons scroll`).toBe(false); + expect(l.page, `${w} px: the page scrolls sideways`).toBeLessThanOrEqual(0); + expect(l.row, `${w} px: the header row overflows`).toBeLessThanOrEqual(0); + } + }); +}); + +// At twice the text size the header's fixed parts (the mark, the toggle, the +// menu trigger) are twice as wide; the social row's box takes up the rest and +// scrolls, so the header itself never widens the page. (The page's own +// content below is not this header's.) +for (const touch of [false, true]) { + test.describe(touch ? "at 200 % text, under a coarse pointer" : "at 200 % text, with a mouse", () => { + test.use({ hasTouch: touch }); + test("320 and 360 px: the header fits, every key in reach", async ({ page }) => { + await installRoutes(page); + writeSite({ ...TITLES.long, socialLinks: SIX.slice(0, 4) }); + await page.addInitScript(() => { + document.addEventListener("DOMContentLoaded", () => { + document.documentElement.style.fontSize = "200%"; + }); + }); + for (const w of [320, 360]) { + await open(page, w); + await expect(page.locator("html")).toHaveCSS("font-size", "32px"); + const header = await page.locator("header").first().evaluate((el) => ({ + over: el.scrollWidth - el.clientWidth, + right: Math.max(...[...el.querySelectorAll("a, button")].filter((e) => (e as HTMLElement).offsetParent && !e.closest("[data-social-scroll]")).map((e) => e.getBoundingClientRect().right)), + })); + expect(header.over, `${w} px: the header overflows`).toBeLessThanOrEqual(0); + expect(header.right, `${w} px: a control past the edge`).toBeLessThanOrEqual(w); + await expect(toggle(page)).toBeInViewport({ ratio: 1 }); + await expect(page.getByRole("button", { name: "Open menu" })).toBeInViewport({ ratio: 1 }); + // The last key is at the box's end, in view, wherever the box has room + // for one (at 320 px under touch the mark, the 88 px toggle and the + // 72 px menu button leave it none: the phone header is the operator's + // to rule on). + const room = await page.locator("header [data-social-scroll]").evaluate((el) => el.clientWidth); + if (room >= (touch ? 88 : 72)) { + await expect(headerRow(page).getByRole("link").last()).toBeInViewport(); + } + expect(w === 360 || !touch ? room : 1, `${w} px: the row's box`).toBeGreaterThan(0); + } + }); + }); +} + +// The footer shows every link: its row wraps rather than widen the page. +test.describe("under a coarse pointer, eight links", () => { + test.use({ hasTouch: true }); + test("320 px: the footer's row wraps, and nothing scrolls the page", async ({ page }) => { + await installRoutes(page); + const eight = [...SIX, link("Ring", "M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18z"), link("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z")]; + writeSite({ ...TITLES.short, socialLinks: eight }); + await open(page, 320); + const row = page.locator('footer [data-social-links="footer"]'); + await expect(row.getByRole("link")).toHaveCount(8); + const boxes = await row.getByRole("link").evaluateAll((els) => els.map((e) => e.getBoundingClientRect()).map((r) => ({ right: r.right, top: r.top }))); + for (const b of boxes) expect(b.right).toBeLessThanOrEqual(320); + expect(new Set(boxes.map((b) => Math.round(b.top))).size, "one row of eight").toBeGreaterThan(1); + expect((await layout(page)).page).toBeLessThanOrEqual(0); + }); +}); + +// The title's fit does not wait on the web font: its width is reserved from +// the display face's metrics (lib/wordmarkWidth.ts), so in the fallback face +// (narrower) and in Archivo the text is shown, or not, at the same widths. +test("the title shows at the same widths in the fallback face and in Archivo", async ({ page }) => { + test.setTimeout(120_000); + await installRoutes(page); + const widths = [360, 375, 390, 412, 430]; + const fits = async () => { + const out: boolean[] = []; + for (const w of widths) { + await open(page, w); + await page.evaluate(() => document.fonts.ready); + out.push((await layout(page)).text); + } + return out; + }; + const archivo = () => + page.evaluate(() => [...document.fonts].filter((f) => /Archivo/i.test(f.family)).map((f) => f.status)); + for (const title of [TITLES.short, { headerTitle: "Mediumfixturealyzer", wordmarkLead: "Mediumfixture" }, TITLES.long]) { + writeSite({ ...title, socialLinks: SIX.slice(0, 3) }); + await page.route(/\.woff2(\?|$)/, (r) => r.abort()); + const fallback = await fits(); + expect(await archivo(), "the web font is blocked").not.toContain("loaded"); + await page.unroute(/\.woff2(\?|$)/); + const loaded = await fits(); + expect(await archivo(), "the web font loaded").toContain("loaded"); + expect(loaded, `${title.headerTitle} at ${widths.join(", ")} px`).toEqual(fallback); + } +}); + 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) }); diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts @@ -353,6 +353,33 @@ test("six links: the header shows the last four at every width, the footer all s } }); +// The footer shows every link: with eight under touch at 320 px its row wraps +// rather than widen the page. +test.describe("under a coarse pointer, eight links", () => { + test.use({ hasTouch: true }); + test("320 px: the footer's row wraps, and nothing scrolls the page", async ({ page }) => { + const extra = (label: string, d: string) => ({ + label, + url: `https://${label.toLowerCase()}.example/fixture`, + svg: `<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`, + }); + writeFixtureSettings(settingsFile(), [ + ...FIXTURE_SOCIAL_SIX, + extra("Bar", "M3 10h18v4H3z"), + extra("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z"), + ]); + await page.setViewportSize({ width: 320, height: 800 }); + await page.goto("/"); + await expect(footerRow(page).getByRole("link")).toHaveCount(8); + const boxes = await footerRow(page) + .getByRole("link") + .evaluateAll((els) => els.map((e) => e.getBoundingClientRect()).map((r) => ({ right: r.right, top: r.top }))); + for (const b of boxes) expect(b.right).toBeLessThanOrEqual(320); + expect(new Set(boxes.map((b) => Math.round(b.top))).size, "one row of eight").toBeGreaterThan(1); + await noHorizontalOverflow(page); + }); +}); + // 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.