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