commit c9f725dd8c7b1978cf9be0947d858f07a8454a8f
parent fdf1bcd119cd26343d4d69f5efa215dbb48ff919
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 19:25:12 -0400
homepage: on a very small screen the header keeps its mark and drops the word; the social links scroll only when they still cannot fit
- No social link is hidden by width any more: the width × pointer steps and
their nth-last-child classes are gone. The header shows at most four links
(featured, or the last four) at every width.
- The bar is a size container (`@container/bar`). The wordmark's TEXT is
hidden, and the mark kept (the link is still "Archilyzer home"), when the bar
is narrower than the full wordmark, a 12 px gap and the group need — a class
per link count (1–4) and per pointer, in rem so it follows the text size.
With three links the text shows from 348 px with a mouse and 380 px under
touch; with four, from 384 and 424 px.
- The last resort is common/components/SocialScroll: the row scrolls inside a
box, its END in view first (rtl box, ltr row, no script), no scrollbar, the
gear outside it, the header never wider than the screen; a link that takes
focus is scrolled into view with its ring (the one bit of script — Chromium
does not do it for a partly hidden link in an rtl box).
- The four nav links fit a 320 px rule.
- social.spec: at 320/340/360/390, mouse and touch, with 1, 3 and 4 links:
every link shown, nothing scrolls, the wordmark's text exactly where it fits,
the gear after the box, no page overflow; 768/1280 unchanged; the fallback at
280 px (end first, focus brings each link into view) and at 320 px with a
200 % text size; six links → the last four at every width.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 226 insertions(+), 78 deletions(-)
diff --git a/common/components/SocialScroll.tsx b/common/components/SocialScroll.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { cn } from "../lib/utils";
+
+// THE SOCIAL ROW'S LAST RESORT: a box that scrolls the row sideways when it
+// still cannot fit (a header that has already made room). The row's END is in
+// view first, with no script and no change to the DOM or tab order: the box is
+// `direction: rtl`, whose first scroll position is its right edge, and the row
+// inside it is `ltr` and `w-max` (as wide as its links, so it overflows to the
+// left, where an rtl box can scroll). No scrollbar is drawn; touch, a
+// trackpad, shift + wheel and the keyboard still scroll it. When the row fits,
+// nothing about it changes.
+//
+// The one piece of script: a link that takes focus is scrolled into view
+// (`nearest`, inside the 4 px `scroll-padding`, so its ring shows). Chromium
+// does not bring a partly hidden link into view on focus inside an rtl box.
+//
+// Put the row's own 4 px inline padding on the row (SocialLinks' className,
+// `px-1`) — the padding must be inside the scrolled content to be reachable —
+// and this box cancels it with `-mx-1`, so the key after the box (the header's
+// theme control) still meets the last link.
+export function SocialScroll({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+ <div
+ data-social-scroll=""
+ className={cn(
+ "min-w-0 overflow-x-auto [direction:rtl] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden scroll-px-1 -mx-1 -my-1 py-1",
+ className,
+ )}
+ onFocus={(event) => {
+ const target = event.target as HTMLElement;
+ if (target !== event.currentTarget) {
+ target.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ }}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -8,8 +8,10 @@ import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
import { OptionsDialog } from "yt-dlp-transcript-common/components/OptionsDialog";
import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
+import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks";
import { currentHomepage } from "../lib/homepage";
import { HEADER_NAV } from "../lib/nav";
@@ -42,36 +44,54 @@ function NavList({ className }: { className?: string }) {
// 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):
+// touching, so every glyph is 16 px from the next. The header shows at most
+// four links (headerSocialLinks); none is hidden by width. Measured with the
+// wordmark link at 148–152 px, the nav at 276 px and a key 36 px (44 px under a
+// coarse pointer):
// ≥ 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.
+// four links fit a 320 px rule, so nothing scrolls).
+// ON A VERY SMALL SCREEN THE HEADER MAKES ROOM FIRST: the wordmark's TEXT is
+// hidden, and the mark stays (the link keeps its name, "Archilyzer home"), when
+// the bar — a size container, `@container/bar` — is narrower than the full
+// wordmark, a 12 px gap and the group need. That depends on how many links the
+// header shows and on the pointer, so the class is chosen by count from
+// WORDMARK_FITS (the bar's content width in px at the default text size,
+// 152 + 12 + (n + 1) keys; the classes say it in rem):
+// links mouse (36 px keys) touch (44 px keys) viewport, below 640 px
+// 1 < 236 < 252 < 276 / < 292
+// 2 < 272 < 296 < 312 / < 336
+// 3 < 308 < 340 < 348 / < 380
+// 4 < 344 < 384 < 384 / < 424
+// With the mark alone, four links and the gear fit a 320 px screen under touch.
+// THE LAST RESORT is the scroll box around the row (SocialScroll): below that
+// (under 320 px, or with a much larger text size) the row scrolls sideways
+// inside the bar, its END shown first, the gear outside it, no scrollbar
+// drawn, and the header never wider than the screen. A link that takes focus
+// is scrolled into view with its ring.
//
// 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".
+// The wordmark text's hiding classes, by how many links the header shows:
+// complete literal classes, one set per count (see the table above), in rem so
+// they scale with the reader's text size as the wordmark and the keys do.
+const WORDMARK_FITS: Record<number, string> = {
+ 0: "@max-[12.5rem]/bar:hidden pointer-coarse:@max-[13rem]/bar:hidden",
+ 1: "@max-[14.75rem]/bar:hidden pointer-coarse:@max-[15.75rem]/bar:hidden",
+ 2: "@max-[17rem]/bar:hidden pointer-coarse:@max-[18.5rem]/bar:hidden",
+ 3: "@max-[19.25rem]/bar:hidden pointer-coarse:@max-[21.25rem]/bar:hidden",
+ 4: "@max-[21.5rem]/bar:hidden pointer-coarse:@max-[24rem]/bar:hidden",
+};
+
export default function Header() {
const socialLinks = resolveHomepageSocialLinks(currentHomepage(), getSettings());
+ const shown = headerSocialLinks(socialLinks).length;
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-3 md:gap-6">
+ <div className="@container/bar 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"
@@ -81,26 +101,26 @@ export default function Header() {
<Wordmark
title={PROJECT_NAME}
lead={PROJECT_WORDMARK_LEAD}
- className="text-[1.3rem] leading-none tracking-[-0.01em]"
+ className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS[shown] ?? WORDMARK_FITS[4]}`}
/>
</Link>
- <div className="ml-auto flex items-center gap-8">
+ <div className="ml-auto flex min-w-0 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"
- />
+ <div className="flex min-w-0 items-center">
+ {shown > 0 && (
+ <SocialScroll>
+ <SocialLinks links={socialLinks} placement="header" className="w-max px-1 [direction:ltr]" />
+ </SocialScroll>
+ )}
<OptionsDialog />
</div>
</div>
</div>
{/* 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
+ fit a 320 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
diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts
@@ -28,12 +28,13 @@ test.afterEach(() => {
const TRIO = FIXTURE_SOCIAL_TRIO.map((l) => l.label);
-// The header's row, in the bar at every width, followed by the Options gear. 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.
+// The header's row, in the bar at every width, followed by the Options gear;
+// none of its (at most four) links is hidden by width. On a very small screen
+// the wordmark's TEXT is hidden first (the mark stays); the row scrolls inside
+// its box only as the last resort (Header.tsx).
const headerRow = (page: Page) =>
page.locator('header [data-social-links="header"]');
+const scrollBox = (page: Page) => page.locator("header [data-social-scroll]");
const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]');
const labelsOf = (row: Locator) =>
@@ -55,45 +56,80 @@ async function tabTo(page: Page, target: Locator) {
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 ({
+// The bar's content width (in px, default text) below which the wordmark's
+// text is hidden, by how many links the header shows and the pointer: the
+// wordmark link (152 px), a 12 px gap, and the keys and the gear (Header.tsx
+// WORDMARK_FITS).
+const WORDMARK_NEEDS = {
+ mouse: { 1: 236, 3: 308, 4: 344 },
+ touch: { 1: 252, 3: 340, 4: 384 },
+} as const;
+
+const LINK_SETS = {
+ 1: FIXTURE_SOCIAL_TRIO.slice(-1),
+ 3: FIXTURE_SOCIAL_TRIO,
+ 4: FIXTURE_SOCIAL_SIX, // the header shows its last four
+} as const;
+
+async function narrowHeader(page: Page, width: number, count: 1 | 3 | 4, pointer: "mouse" | "touch") {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const labels = LINK_SETS[count].map((l) => l.label).slice(-4);
+ expect(await labelsOf(headerRow(page)), `${width} px, ${count} links`).toEqual(labels);
+
+ // Nothing is hidden but (maybe) the wordmark's text, and nothing scrolls.
+ const home = page.getByRole("link", { name: "Archilyzer home", exact: true });
+ const textShown = await home.locator("[data-wordmark]").isVisible();
+ expect(textShown, `${pointer} ${width} px, ${count}: wordmark text`).toBe(
+ width - 40 >= WORDMARK_NEEDS[pointer][count],
+ );
+ if (textShown) {
+ expect(await home.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), "the wordmark is clipped").toBe(true);
+ expect((await home.boundingBox())!.height).toBeLessThanOrEqual(32);
+ }
+ await expect(home.locator("svg").first()).toBeVisible(); // the mark stays
+
+ const box = scrollBox(page);
+ const scrolls = await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
+ expect(scrolls, `${pointer} ${width} px, ${count}: the row scrolls`).toBe(false);
+ const b = (await box.boundingBox())!;
+ const wm = (await home.boundingBox())!;
+ expect(b.x + 4 - (wm.x + wm.width), "the gap after the wordmark").toBeGreaterThanOrEqual(11.5);
+ for (const key of await headerRow(page).getByRole("link").all()) {
+ const k = (await key.boundingBox())!;
+ expect(k.x).toBeGreaterThanOrEqual(b.x);
+ expect(k.x + k.width).toBeLessThanOrEqual(b.x + b.width);
+ }
+ const gear = page.getByRole("button", { name: "Options", exact: true });
+ await expect(gear).toBeInViewport({ ratio: 1 });
+ expect((await gear.boundingBox())!.x, "the gear is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5);
+ await noHorizontalOverflow(page);
+}
+
+for (const width of [320, 340, 360, 390]) {
+ test(`${width} px, a mouse: 1, 3 and 4 links all shown, nothing scrolls, the wordmark's text only where it fits`, 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);
+ for (const count of [1, 3, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ await narrowHeader(page, width, count, "mouse");
}
+ });
+}
- // 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);
+test("768 and 1280 px: the full wordmark, the nav, the row and the gear in one bar", async ({ page }) => {
+ for (const width of [768, 1280]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const home = page.getByRole("link", { name: "Archilyzer home", exact: true });
+ await expect(home.locator("[data-wordmark]")).toBeVisible();
+ await expect(page.getByRole("navigation", { name: "Main", exact: true })).toBeVisible();
+ expect(await labelsOf(headerRow(page))).toEqual(TRIO);
+ expect(await scrollBox(page).evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(false);
await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport();
-
await 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,
@@ -184,24 +220,69 @@ test("each copy of an icon owns its ids; the visible copy's gradient resolves in
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 ({
+ for (const width of [320, 340, 360, 390]) {
+ test(`${width} px, touch: 1, 3 and 4 links all shown, nothing scrolls, the wordmark's text only where it fits`, async ({
+ page,
+ }) => {
+ for (const count of [1, 3, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ await narrowHeader(page, width, count, "touch");
+ }
+ });
+ }
+
+ // THE LAST RESORT: at 280 px, four 44 px keys, the gear and the mark do not
+ // fit, so the row scrolls inside its box — its END in view first, the gear
+ // outside it, the page never sideways, and each link scrolled fully into view
+ // (its focus ring too) when it takes focus.
+ test("280 px, four links: the row scrolls in its box, its end first; focus brings each link into view", async ({
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);
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ await page.setViewportSize({ width: 280, height: 800 });
+ await page.goto("/");
+ const box = scrollBox(page);
+ expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(true);
+ await noHorizontalOverflow(page);
+ await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport({ ratio: 1 });
+ const inBox = (key: import("@playwright/test").Locator) =>
+ Promise.all([box.boundingBox(), key.boundingBox()]).then(([b, k]) =>
+ k!.x - 2 >= b!.x - 0.5 && k!.x + k!.width + 2 <= b!.x + b!.width + 0.5,
+ );
+ const keys = await headerRow(page).getByRole("link").all();
+ expect(await inBox(keys[keys.length - 1]), "the last link, on load").toBe(true);
+ expect(await inBox(keys[0]), "the first link, on load").toBe(false);
+ for (const key of keys) {
+ await tabTo(page, key);
+ await page.waitForTimeout(100);
+ expect(await inBox(key), (await key.getAttribute("aria-label")) ?? "a link").toBe(true);
}
});
- test("six links under touch at 360 px: the last two of the header's four", async ({ page }) => {
+ test("320 px at a 200 % text size: the header does not scroll sideways; the last link's end and the gear are in view", async ({
+ page,
+ }) => {
writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
- await page.setViewportSize({ width: 360, height: 800 });
+ await page.addInitScript(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ });
+ await page.setViewportSize({ width: 320, height: 800 });
await page.goto("/");
- expect(await labelsOf(headerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label).slice(-2));
- await noHorizontalOverflow(page);
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ // The header only: at twice the text size the page's own content is not
+ // this slice's to reflow.
+ expect(await page.locator("header").evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
+ await expect(page.getByRole("button", { name: "Options", exact: true })).toBeInViewport({ ratio: 1 });
+ // The box may be narrower than one 88 px key here: the last link is the
+ // one at its end, in view as far as the box allows.
+ const [b, k] = await Promise.all([
+ scrollBox(page).boundingBox(),
+ headerRow(page).getByRole("link").last().boundingBox(),
+ ]);
+ expect(k!.x + k!.width).toBeLessThanOrEqual(b!.x + b!.width + 0.5);
+ expect(k!.x + k!.width).toBeGreaterThan(b!.x + b!.width / 2);
});
for (const width of [360, 1280]) {
@@ -252,15 +333,15 @@ test("keyboard focus shows the ring; in forced colours the browser's own outline
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 ({
+test("six links: the header shows the last four at every width, 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) {
+ for (const width of [320, 360, 390, 767, 768, 1280]) {
await page.setViewportSize({ width, height: 800 });
await page.goto("/");
- expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-shown));
+ expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-4));
expect(await labelsOf(footerRow(page))).toEqual(six);
await noHorizontalOverflow(page);
}