Archilyzer · Source

archilyzer

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

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:
Acommon/components/SocialScroll.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mhomepage/app/components/Header.tsx | 74+++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mhomepage/e2e/social.spec.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
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); }