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:
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.