commit ae7d4004e129891cfe0c551f3a84fe36e5a185b3
parent 1b203b6b3ee04b0961b544581fd7d10fb99d25b1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 00:36:23 -0400
homepage, export: the narrow/wide switch is 32.5rem, so it moves with the reader's text size; the homepage keys the wordmark's fit by the wide row's count from the switch (re-review N5)
- Every header (the homepage, every site, the hub) switches rows at
`min-[32.5rem]` / `max-[32.5rem]`: 520 px at the default text size, 1040 px
at a 32 px browser text size. The export's 8 px gaps follow the same switch.
- The homepage's WORDMARK_FITS are two literal sets: below the switch keyed by
the narrow row's count, from it by the wide row's. A page-set root size
(which moves the page's rem but not a media query's) no longer leaves the
wide row scrolling while the name shows between 520 and 767 px.
e2e (Chromium, the browser text size set through CDP `Page.setFontSizes`):
- social.spec, both pointers: at 32 px the narrow row until 1040 px, the wide
one from there, the name with one marked link from 720 px, the row never
scrolling while the name shows, the header never overflowing; with the root
at 200 %, 520–767 px, the wide row never scrolls while the name shows.
- header.spec, both pointers: the same switch at 32 px on Rekietalyzer; with
the root at 200 %, the switch stays at 520 px and the row never scrolls
while the name shows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 181 insertions(+), 33 deletions(-)
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -34,17 +34,19 @@ import MobileMenu from "./MobileMenu";
// icon scrolls.
//
// THE HEADER KEEPS THE SITE'S NAME ON A NARROW SCREEN (the ruling of
-// 2026-09-28): below 520 px the social row holds only the links marked
+// 2026-09-28): below the switch the social row holds only the links marked
// `featured` (none marked → none; the footer always shows every link); from
-// 520 px it holds every link, up to four, the marked ones kept first
+// it the row holds every link, up to four, the marked ones kept first
// (headerSocialLinks, "narrow" and "wide"). Both rows are rendered and CSS
// 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). 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
+// the accessibility tree. The switch is 32.5rem — 520 px at the default text
+// size, 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) — in rem so it moves with the reader's text size, as the
+// keys and the title do. Below it 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.
@@ -100,7 +102,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 max-[520px]:gap-2 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-[32.5rem]:gap-2 lg:gap-5 flex-nowrap">
<Link
href="/"
data-header-brand=""
@@ -142,7 +144,7 @@ export default function Header() {
)}
<div data-header-group="" className="flex min-w-0 items-center">
{narrow > 0 && (
- <SocialScroll className="min-[520px]:hidden">
+ <SocialScroll className="min-[32.5rem]:hidden">
<SocialLinks
links={socialLinks}
placement="header"
@@ -152,7 +154,7 @@ export default function Header() {
</SocialScroll>
)}
{wide > 0 && (
- <SocialScroll className="hidden min-[520px]:block">
+ <SocialScroll className="hidden min-[32.5rem]:block">
<SocialLinks
links={socialLinks}
placement="header"
diff --git a/export/e2e/header.spec.ts b/export/e2e/header.spec.ts
@@ -198,6 +198,58 @@ for (const touch of [false, true]) {
});
}
+// The switch between the narrow and the wide row is 32.5rem, so it moves with
+// the reader's own text size (the browser's default font size, which rem in a
+// media query follows): at 32 px it is 1040 px. A page that sets its own root
+// size moves the page's rem but not a media query's; the export's wrap needs
+// no count, so there too the row never scrolls while the name shows.
+async function browserTextSize(page: Page, px: number) {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Page.enable");
+ await cdp.send("Page.setFontSizes", { fontSizes: { standard: px } });
+}
+for (const touch of [false, true]) {
+ test.describe(touch ? "at 200 % text, the switch, under a coarse pointer" : "at 200 % text, the switch, with a mouse", () => {
+ test.use({ hasTouch: touch });
+ test("the browser's text size at 32 px: the narrow row until 1040 px; the page's root at 200 %: the switch stays at 520 px; the row never scrolls while the name shows", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ await installRoutes(page);
+ writeSite({ ...LONGEST, socialLinks: lastMarked(SIX.slice(0, 4)) });
+ const four = SIX.slice(0, 4).map((l) => l.label);
+ await browserTextSize(page, 32);
+ for (const w of [600, 767, 1039, 1040, 1300]) {
+ await open(page, w);
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ const at = `browser text 32 px, ${w} px`;
+ expect(await labelsOf(headerRow(page)), at).toEqual(w < 1040 ? ["Diamond"] : four);
+ const l = await layout(page);
+ if (l.scrolls) expect(l.text, `${at}: the row scrolls while the name shows`).toBe(false);
+ expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0);
+ // 767 px at 32 px text is 383 px at the default size: past every real
+ // title's full-name width.
+ if (w >= 767) expect(l.text, `${at}: the name`).toBe(true);
+ }
+ await browserTextSize(page, 16);
+ await page.addInitScript(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ });
+ for (const w of [519, 520, 600, 680, 767]) {
+ await open(page, w);
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ const at = `root 200 %, ${w} px`;
+ expect(await labelsOf(headerRow(page)), at).toEqual(w < 520 ? ["Diamond"] : four);
+ const l = await layout(page);
+ if (l.scrolls) expect(l.text, `${at}: the row scrolls while the name shows`).toBe(false);
+ expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(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 });
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -47,24 +47,27 @@ function NavList({ className }: { className?: string }) {
// dressed alike, their 36 px boxes touching, so every glyph is 16 px from the
// next. There is no accent control: the homepage wears its own (layout.tsx).
// THE HEADER KEEPS THE NAME ON A NARROW SCREEN (the ruling of 2026-09-28): below
-// 520 px the row holds only the links marked `featured` (none marked → none;
-// the footer shows them all); from 520 px it holds every link, up to four, the
-// marked ones kept first (headerSocialLinks, "narrow" and "wide"). Both rows
-// are rendered and CSS 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 and the longest real site title fit the
-// export's bar, menu button included; this bar is shorter. With the wordmark
+// the switch the row holds only the links marked `featured` (none marked →
+// none; the footer shows them all); from it the row holds every link, up to
+// four, the marked ones kept first (headerSocialLinks, "narrow" and "wide").
+// Both rows are rendered and CSS shows one; the other is display:none, so
+// exactly one is focusable and in the accessibility tree. The switch is
+// 32.5rem, the export's (520 px at the default text size, where every link,
+// the toggle and the longest real site title fit the export's bar, menu button
+// included; this bar is shorter), in rem so it moves with the reader's text
+// size as the keys and the wordmark do. 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 LAST RESORTS, now rare: when the bar — a size container, `@container/bar`
-// — is still narrower than the full wordmark, a 12 px gap and the narrow group,
-// the wordmark's TEXT is hidden and the mark stays (the link keeps its name,
-// "Archilyzer home"); the class is chosen by the narrow row's 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):
+// — is still narrower than the full wordmark, a 12 px gap and the group the
+// bar shows, the wordmark's TEXT is hidden and the mark stays (the link keeps
+// its name, "Archilyzer home"); the class is chosen by the row's count from
+// WORDMARK_FITS — below the switch the narrow row's, from it the wide row's —
+// (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
// 0 < 200 < 208 < 240 / < 248
// 1 < 236 < 252 < 276 / < 292
@@ -81,13 +84,22 @@ function NavList({ className }: { className?: string }) {
// 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",
+// they scale with the reader's text size as the wordmark and the keys do. The
+// NARROW set applies below the switch and is keyed by the narrow row's count;
+// the WIDE set from the switch, keyed by the wide row's.
+const WORDMARK_FITS_NARROW: Record<number, string> = {
+ 0: "max-[32.5rem]:@max-[12.5rem]/bar:hidden pointer-coarse:max-[32.5rem]:@max-[13rem]/bar:hidden",
+ 1: "max-[32.5rem]:@max-[14.75rem]/bar:hidden pointer-coarse:max-[32.5rem]:@max-[15.75rem]/bar:hidden",
+ 2: "max-[32.5rem]:@max-[17rem]/bar:hidden pointer-coarse:max-[32.5rem]:@max-[18.5rem]/bar:hidden",
+ 3: "max-[32.5rem]:@max-[19.25rem]/bar:hidden pointer-coarse:max-[32.5rem]:@max-[21.25rem]/bar:hidden",
+ 4: "max-[32.5rem]:@max-[21.5rem]/bar:hidden pointer-coarse:max-[32.5rem]:@max-[24rem]/bar:hidden",
+};
+const WORDMARK_FITS_WIDE: Record<number, string> = {
+ 0: "min-[32.5rem]:@max-[12.5rem]/bar:hidden pointer-coarse:min-[32.5rem]:@max-[13rem]/bar:hidden",
+ 1: "min-[32.5rem]:@max-[14.75rem]/bar:hidden pointer-coarse:min-[32.5rem]:@max-[15.75rem]/bar:hidden",
+ 2: "min-[32.5rem]:@max-[17rem]/bar:hidden pointer-coarse:min-[32.5rem]:@max-[18.5rem]/bar:hidden",
+ 3: "min-[32.5rem]:@max-[19.25rem]/bar:hidden pointer-coarse:min-[32.5rem]:@max-[21.25rem]/bar:hidden",
+ 4: "min-[32.5rem]:@max-[21.5rem]/bar:hidden pointer-coarse:min-[32.5rem]:@max-[24rem]/bar:hidden",
};
export default function Header() {
@@ -106,7 +118,7 @@ export default function Header() {
<Wordmark
title={PROJECT_NAME}
lead={PROJECT_WORDMARK_LEAD}
- className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS[narrow] ?? WORDMARK_FITS[4]}`}
+ className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS_NARROW[narrow] ?? WORDMARK_FITS_NARROW[4]} ${WORDMARK_FITS_WIDE[wide] ?? WORDMARK_FITS_WIDE[4]}`}
/>
</Link>
<div className="ml-auto flex min-w-0 items-center gap-8">
@@ -115,7 +127,7 @@ export default function Header() {
</nav>
<div className="flex min-w-0 items-center">
{narrow > 0 && (
- <SocialScroll className="min-[520px]:hidden">
+ <SocialScroll className="min-[32.5rem]:hidden">
<SocialLinks
links={socialLinks}
placement="header"
@@ -125,7 +137,7 @@ export default function Header() {
</SocialScroll>
)}
{wide > 0 && (
- <SocialScroll className="hidden min-[520px]:block">
+ <SocialScroll className="hidden min-[32.5rem]:block">
<SocialLinks
links={socialLinks}
placement="header"
diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts
@@ -12,6 +12,7 @@ import {
} from "./fixture-social";
import { themeToggle } from "./helpers";
import { ADVERSARIAL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors";
+import { headerSocialLinks } from "../../common/lib/socialLinks";
// THE SOCIAL ROW: in the footer (every link) and in the header, which keeps the
// NAME on a narrow screen: below 520 px only the links marked `featured` (none
@@ -488,6 +489,87 @@ test("nothing scrolls the page sideways from 280 to 1400 px, and the footer keep
}
});
+// AT TWICE THE TEXT SIZE. The switch between the narrow and the wide row is
+// 32.5rem, so it moves with the reader's own text size (the browser's default
+// font size, which rem in a media query follows): at 32 px it is 1040 px, and
+// the bar is the default-size bar at half the width. A page that sets its own
+// root size moves the page's rem but not a media query's; there, from the
+// switch, the wordmark's fit is keyed by the WIDE row's count, so the text
+// drops before that row scrolls.
+async function browserTextSize(page: Page, px: number) {
+ const cdp = await page.context().newCDPSession(page);
+ await cdp.send("Page.enable");
+ await cdp.send("Page.setFontSizes", { fontSizes: { standard: px } });
+}
+
+const barState = (page: Page) =>
+ page.evaluate(() => {
+ const wm = document.querySelector("header [data-wordmark]") as HTMLElement;
+ const box = ([...document.querySelectorAll("header [data-social-scroll]")] as HTMLElement[]).find(
+ (e) => e.offsetParent !== null,
+ );
+ const header = document.querySelector("header") as HTMLElement;
+ return {
+ text: getComputedStyle(wm).display !== "none",
+ scrolls: box ? box.scrollWidth > box.clientWidth + 1 : false,
+ over: header.scrollWidth - header.clientWidth,
+ };
+ });
+
+for (const touch of [false, true]) {
+ test.describe(touch ? "at 200 % text, touch" : "at 200 % text, a mouse", () => {
+ test.use({ hasTouch: touch });
+
+ test("the browser's text size at 32 px: the narrow row until 1040 px, the wide one from there; the row never scrolls while the name shows", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ await browserTextSize(page, 32);
+ for (const count of [1, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ const narrow = headerSocialLinks(LINK_SETS[count], "narrow").map((l) => l.label);
+ const wide = headerSocialLinks(LINK_SETS[count], "wide").map((l) => l.label);
+ for (const width of [600, 720, 767, 900, 1039, 1040, 1100, 1300]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ const at = `${count} marked, ${width} px`;
+ expect(await labelsOf(headerRow(page)), at).toEqual(width < 1040 ? narrow : wide);
+ const s = await barState(page);
+ if (s.scrolls) expect(s.text, `${at}: the row scrolls while the name shows`).toBe(false);
+ expect(s.over, `${at}: the header overflows`).toBeLessThanOrEqual(0);
+ if (count === 1 && width >= 720) expect(s.text, `${at}: the name`).toBe(true);
+ }
+ }
+ });
+
+ test("the page's root at 200 %: from 520 to 767 px the wide row never scrolls while the name shows", async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ await page.addInitScript(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ });
+ for (const count of [1, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ const wide = headerSocialLinks(LINK_SETS[count], "wide").map((l) => l.label);
+ for (const width of [520, 560, 600, 640, 680, 720, 767]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ const at = `${count} marked, ${width} px`;
+ expect(await labelsOf(headerRow(page)), at).toEqual(wide);
+ const s = await barState(page);
+ if (s.scrolls) expect(s.text, `${at}: the row scrolls while the name shows`).toBe(false);
+ expect(s.over, `${at}: the header overflows`).toBeLessThanOrEqual(0);
+ }
+ }
+ });
+ });
+}
+
test("no links: no row in the header, and no Elsewhere column in the footer", async ({
page,
}) => {