Archilyzer · Source

archilyzer

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

commit aaf834d18bd67f3d47715dd49a8b0e66dd31561d
parent c50648aa92ca02b0ba5120c41050646dd9368a15
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 00:27:40 -0400

export: below 520 px the header bar's two gaps are 8 px, so every real site title shows in full at 360 px with one marked link under touch (re-review N1)

The ruling: the gaps name → row and toggle → menu go from 12 to 8 px below
the 520 px switch; type, the mark–name gap and the reserved width's margin are
unchanged. Every site and the hub (one header).

e2e: header.spec checks all six real titles at 360 px under touch with one
marked link (the full name, the link, nothing overflows), and runs the
full-name and focus-order test for Hasanalyzer and Rekietalyzer as well as a
short title and Bonnellyzer; the none-marked and 280–1400 px tests use
Rekietalyzer. The hub's name test runs under both pointers.

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

Diffstat:
Mexport/app/components/Header.tsx | 12++++++++----
Mexport/e2e-hub/official-instances.spec.ts | 78++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mexport/e2e/header.spec.ts | 46+++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 91 insertions(+), 45 deletions(-)

diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx @@ -41,9 +41,13 @@ import MobileMenu from "./MobileMenu"; // shows one; the other is display:none, so exactly one is focusable and in // the accessibility tree. 520 px is where every link (up to four, 44 px keys), // the toggle, the menu button and the longest real site title fit the bar -// (release-14.md, the measured table). The Archilyzer link replaces the old -// sites dropdown and the hub backlink; it is absent on the hub, which lists -// the instances itself. Changelog is in the footer. +// (release-14.md, the measured table). Below 520 px the bar's two gaps +// (name → row, toggle → menu) are 8 px, not 12 (the ruling of 2026-09-29), so +// every real title shows in full at 360 px with one marked link under touch; +// type, the mark–name gap and the reserved width are unchanged. The +// Archilyzer link replaces the old sites dropdown and the hub backlink; it is +// absent on the hub, which lists the instances itself. Changelog is in the +// footer. // // THE LAST RESORTS, now rare (a very long title on a phone): the wordmark's // TEXT drops and the mark stays, exactly when the text does not fit — whatever @@ -94,7 +98,7 @@ export default function Header() { {/* min-h-14, not h-14, and never wrapping: a fixed-height flex-wrap row put the overflow rows OUTSIDE the sticky header's background at phone widths, and the page scrolled through them. */} - <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-3 lg:gap-5 flex-nowrap"> + <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-3 max-[520px]:gap-2 lg:gap-5 flex-nowrap"> <Link href="/" data-header-brand="" diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -142,42 +142,48 @@ test.describe("hub official instances", () => { // The hub's header follows the same ruling as every site's: on a narrow // screen the name and only the marked link(s); from 520 px every link. The // hub's links come from the settings file the suite serves; this test writes - // three (the last marked) and puts the file back. - test("the hub's header keeps its name on a phone and shows only the marked link", async ({ page }) => { - const file = path.resolve(process.cwd(), "test-settings.hub.json"); - const pristine = fs.readFileSync(file, "utf8"); - try { - const icon = (d: string) => normalizeSocialSvg(`<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`)!; - const links = [ - { label: "Square", url: "https://square.example/", svg: icon("M4 4h16v16H4z") }, - { label: "Bar", url: "https://bar.example/", svg: icon("M3 10h18v4H3z") }, - { label: "Post", url: "https://post.example/", svg: icon("M10 2h4v20h-4z"), featured: true }, - ]; - fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(pristine), socialLinks: links }, null, 2)); - await stubMember(page); - await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); - const banner = page.getByRole("banner"); - const row = page.locator('header [data-social-links="header"]:visible'); - for (const width of [360, 390]) { - await page.setViewportSize({ width, height: 800 }); - await page.goto("/"); - await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible(); - const text = await page.locator("header [data-header-brand] span.flex-wrap").evaluate( - (box) => (box.lastElementChild as HTMLElement).offsetTop < box.clientHeight - 1, - ); - expect(text, `${width} px: the name`).toBe(true); - await expect(row.getByRole("link")).toHaveCount(1); - await expect(banner.getByRole("link", { name: "Post", exact: true })).toHaveCount(1); - await expect(banner.getByRole("link", { name: "Square", exact: true })).toHaveCount(0); - } - await page.setViewportSize({ width: 1280, height: 800 }); - await page.goto("/"); - await expect(row.getByRole("link")).toHaveCount(3); - await expect(page.locator('footer [data-social-links="footer"]').getByRole("link")).toHaveCount(3); - } finally { - fs.writeFileSync(file, pristine); - } - }); + // three (the last marked) and puts the file back. Both pointers: under + // touch the keys are 44 px, and the name still shows at 360 px. + for (const touch of [false, true]) { + test.describe(touch ? "under a coarse pointer" : "with a mouse", () => { + test.use({ hasTouch: touch }); + test("the hub's header keeps its name at 360 and 390 px and shows only the marked link", async ({ page }) => { + const file = path.resolve(process.cwd(), "test-settings.hub.json"); + const pristine = fs.readFileSync(file, "utf8"); + try { + const icon = (d: string) => normalizeSocialSvg(`<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`)!; + const links = [ + { label: "Square", url: "https://square.example/", svg: icon("M4 4h16v16H4z") }, + { label: "Bar", url: "https://bar.example/", svg: icon("M3 10h18v4H3z") }, + { label: "Post", url: "https://post.example/", svg: icon("M10 2h4v20h-4z"), featured: true }, + ]; + fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(pristine), socialLinks: links }, null, 2)); + await stubMember(page); + await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" })); + const banner = page.getByRole("banner"); + const row = page.locator('header [data-social-links="header"]:visible'); + for (const width of [360, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.goto("/"); + await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible(); + const text = await page.locator("header [data-header-brand] span.flex-wrap").evaluate( + (box) => (box.lastElementChild as HTMLElement).offsetTop < box.clientHeight - 1, + ); + expect(text, `${width} px: the name`).toBe(true); + await expect(row.getByRole("link")).toHaveCount(1); + await expect(banner.getByRole("link", { name: "Post", exact: true })).toHaveCount(1); + await expect(banner.getByRole("link", { name: "Square", exact: true })).toHaveCount(0); + } + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/"); + await expect(row.getByRole("link")).toHaveCount(3); + await expect(page.locator('footer [data-social-links="footer"]').getByRole("link")).toHaveCount(3); + } finally { + fs.writeFileSync(file, pristine); + } + }); + }); + } test("the hub has no link to ko-fi.com", async ({ page }) => { await stubMember(page); diff --git a/export/e2e/header.spec.ts b/export/e2e/header.spec.ts @@ -247,9 +247,45 @@ test("the title shows at the same widths in the fallback face and in Archivo", a // THE RULING: on a narrow screen the header keeps the NAME and shows only the // marked link(s), in the accessibility tree too; focus goes brand → the marked -// link → the toggle → the menu. A short title and a real long one -// ("Bonnellyzer", 141 px) both show in full at 360 px, even under touch. -const NARROW_TITLES = { short: TITLES.short, long: { headerTitle: "Bonnellyzer", wordmarkLead: "Bonnell" } }; +// link → the toggle → the menu. Every real site title shows in full at 360 px +// with one marked link, even under touch: below 520 px the bar's two gaps are +// 8 px. Rekietalyzer and Hasanalyzer are the longest. +const REAL_TITLES = [ + { headerTitle: "Jeralyzer", wordmarkLead: "Jer" }, + { headerTitle: "Anilyzer", wordmarkLead: "Ani" }, + { headerTitle: "Bonnellyzer", wordmarkLead: "Bonnell" }, + { headerTitle: "Hasanalyzer", wordmarkLead: "Hasan" }, + { headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta" }, + { headerTitle: "Jasolyzer", wordmarkLead: "Jaso" }, +] as const; +const LONGEST = REAL_TITLES[4]; +const NARROW_TITLES = { + short: TITLES.short, + Bonnellyzer: REAL_TITLES[2], + Hasanalyzer: REAL_TITLES[3], + Rekietalyzer: LONGEST, +}; + +test.describe("the narrow header, touch, every real title", () => { + test.use({ hasTouch: true }); + test("360 px, one marked link: the full name, the link, and nothing overflows", async ({ page }) => { + await installRoutes(page); + for (const title of REAL_TITLES) { + writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 4)) }); + await open(page, 360); + await page.evaluate(() => document.fonts.ready); + const at = `${title.headerTitle} at 360 px`; + const l = await layout(page); + expect(l.text, `${at}: the name`).toBe(true); + expect(l.scrolls, `${at}: the row scrolls`).toBe(false); + expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0); + expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0); + expect(await labelsOf(headerRow(page)), at).toEqual(["Diamond"]); + await expect(page.getByRole("button", { name: "Open menu" }), at).toBeInViewport({ ratio: 1 }); + } + }); +}); + for (const touch of [false, true]) { test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => { test.use({ hasTouch: touch }); @@ -285,7 +321,7 @@ for (const touch of [false, true]) { } test("none marked: the narrow header has no social link, and the name shows", async ({ page }) => { await installRoutes(page); - writeSite({ ...NARROW_TITLES.long, socialLinks: SIX.slice(0, 4) }); + writeSite({ ...LONGEST, socialLinks: SIX.slice(0, 4) }); for (const w of [320, 360, 390]) { await open(page, w); expect((await layout(page)).text, `${w} px: the name`).toBe(true); @@ -300,7 +336,7 @@ test("from 520 px every link shows, up to four; the footer keeps every link; not page, }) => { await installRoutes(page); - writeSite({ ...NARROW_TITLES.long, socialLinks: lastMarked(SIX.slice(0, 4)) }); + writeSite({ ...LONGEST, socialLinks: lastMarked(SIX.slice(0, 4)) }); for (const w of [280, 320, 360, 390, 430, 519, 520, 640, 768, 1023, 1024, 1280, 1400]) { await open(page, w); expect(await labelsOf(headerRow(page)), `${w} px`).toEqual(