commit ab18ea575333255b40f201bb37aac46c25d5e467
parent ba96514be8d7c63c3e0adca6e83ac2de9f00432e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 23:47:41 -0400
common, homepage, export, editor: on a narrow screen the header keeps the name and shows only the links marked for it
The operator's ruling: on narrow screens a site's header keeps the site's
NAME and shows only the link(s) marked for the header; the other social links
are in the footer.
- headerSocialLinks(links, "wide" | "narrow"): wide, every link up to four,
the `featured` ones kept first and then the last of the rest, in configured
order; narrow, the `featured` ones only (up to four), none marked → none.
- The homepage's, every site's and the hub's headers render both rows; CSS
shows the narrow one below 520 px and the wide one from 520 px (where every
link, the toggle, the menu button and the longest real title fit). The other
is display:none, so exactly one is focusable and in the accessibility tree;
each copy scopes its icons' ids. The wordmark's text drop and the scroll box
stay as last resorts; the homepage's thresholds follow the narrow row.
- The editor's checkbox is "Keep in header on small screens", hint "On small
screens the header shows only these; the rest stay in the footer."; the key
stays `featured`. SETTINGS.md and SITE.md regenerated.
- Fixtures mark the last link. Specs: headerSocialLinks for both widths; the
homepage, a site (a short title and "Bonnellyzer") and the hub at 360 and
390 px, both pointers: the full name, the marked link alone (in the tree
too), none marked → no row; from 520 px every link; the footer every link;
nothing scrolls 280–1400 px; focus brand → link → toggle (→ menu).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
20 files changed, 445 insertions(+), 153 deletions(-)
diff --git a/SETTINGS.md b/SETTINGS.md
@@ -474,7 +474,7 @@ Per entry — each entry spells its own values.
| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. |
| `url` | Link target: http(s), mailto: or a site-relative path. |
| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked when it is saved new or edited and again every time it is rendered (a link whose icon fails at render shows its label instead; `archilyzer doctor` names it). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text and animate/animateTransform/set — no script, style block, foreignObject, a, image, title, desc or any HTML element (a title or desc holding text only is removed); SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); a `style` attribute of presentation properties only; an href or url(…) only to an id inside the icon, written plainly; no CSS escape, comment or function that loads anything (image-set, image, cross-fade, element, src, paint, @import); ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused save names why; export from a drawing program with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). |
-| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4. The footer shows every link. Written only when true. |
+| `featured` | Keep this link in the header on small screens (the editor's "Keep in header on small screens"). A narrow header shows only the featured links (up to 4, the last 4 if more are marked; none marked → none, so the name has the room); a wide header shows every link, up to 4, the featured ones kept first, then the last of the rest. The footer shows every link. Written only when true. |
Default:
diff --git a/SITE.md b/SITE.md
@@ -80,7 +80,7 @@ Per entry — each entry spells its own values.
| `label` | The link's name: the icon's accessible name and its tooltip, never text beside it. Shown as text only in place of an icon that fails the check at render. |
| `url` | Link target: http(s), mailto: or a site-relative path. |
| `svg` | Inline SVG markup: ONE well-formed `<svg>` element, checked when it is saved new or edited and again every time it is rendered (a link whose icon fails at render shows its label instead; `archilyzer doctor` names it). It may contain shapes, groups, defs, gradients, patterns, clip paths, masks, filters, text and animate/animateTransform/set — no script, style block, foreignObject, a, image, title, desc or any HTML element (a title or desc holding text only is removed); SVG presentation attributes plus aria-*, data-* and xmlns:* — no event handler (on…); a `style` attribute of presentation properties only; an href or url(…) only to an id inside the icon, written plainly; no CSS escape, comment or function that loads anything (image-set, image, cross-fade, element, src, paint, @import); ids plain names. A leading XML declaration, a DOCTYPE without an internal subset and comments are removed. Normalized on save: width/height stripped, aria-hidden added, a single-colour icon's fills made fill="currentColor" (an icon of two or more colours keeps them). A root with no viewBox but a numeric width W and height H (unitless or px) is given `viewBox="0 0 W H"`, so a file pasted as downloaded is accepted. A refused save names why; export from a drawing program with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). |
-| `featured` | Show this link in a header. A header shows at most 4 links: the featured ones when any link is marked, else all of them; of those, the last 4. The footer shows every link. Written only when true. |
+| `featured` | Keep this link in the header on small screens (the editor's "Keep in header on small screens"). A narrow header shows only the featured links (up to 4, the last 4 if more are marked; none marked → none, so the name has the room); a wide header shows every link, up to 4, the featured ones kept first, then the last of the rest. The footer shows every link. Written only when true. |
## `groups`
diff --git a/common/components/SocialLinks.tsx b/common/components/SocialLinks.tsx
@@ -5,6 +5,7 @@ import {
safeSocialSvg,
scopeSvgIds,
sizeSocialSvg,
+ type HeaderWidth,
} from "../lib/socialLinks";
import { cn } from "../lib/utils";
@@ -14,9 +15,10 @@ export type SocialLinksPlacement = "header" | "footer";
// (settings.json `socialLinks`, or a site's or the homepage's own list), each an
// icon with its label as its accessible name — never text beside it.
//
-// - `placement: "header"` shows at most four (headerSocialLinks: the
-// `featured` ones when any is marked, else all; of those the last four);
-// `"footer"` shows every link.
+// - `placement: "header"` shows the header's list for `width`
+// (headerSocialLinks): "wide", every link up to four (the `featured` ones
+// kept first); "narrow", only the `featured` ones. A header renders both,
+// each shown by CSS at its own widths; `"footer"` shows every link.
// - Each link is a 36 px key around a 20 px glyph, 44 px under a coarse
// pointer. The glyph is the link's colour (`--muted-foreground`, the
// foreground on hover) when the icon is single-colour; an icon of two or
@@ -46,18 +48,22 @@ const TEXT_KEY =
export function SocialLinks({
links,
placement,
+ width = "wide",
className,
}: {
links: readonly SocialLink[];
placement: SocialLinksPlacement;
+ // The header's list: which of its two widths this copy is.
+ width?: HeaderWidth;
className?: string;
}) {
const scope = `sl${useId().replace(/[^A-Za-z0-9_-]/g, "")}`;
- const shown = placement === "header" ? headerSocialLinks(links) : [...links];
+ const shown = placement === "header" ? headerSocialLinks(links, width) : [...links];
if (shown.length === 0) return null;
return (
<ul
data-social-links={placement}
+ data-header-width={placement === "header" ? width : undefined}
className={cn(
"flex items-center list-none",
// The footer's column is left-aligned under its eyebrow: pull the first
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -584,10 +584,12 @@ export const SOCIAL_LINK_FIELD_DOCS: FieldDocs<SocialLink> = {
"from a drawing program with presentation attributes rather than a style " +
"block (in Inkscape, save as Plain SVG).",
featured:
- "Show this link in a header. A header shows at most 4 links: the " +
- "featured ones when any link is marked, else all of them; of those, the " +
- "last 4. The footer shows every link. " +
- "Written only when true.",
+ "Keep this link in the header on small screens (the editor's \"Keep in " +
+ "header on small screens\"). A narrow header shows only the featured " +
+ "links (up to 4, the last 4 if more are marked; none marked → none, so " +
+ "the name has the room); a wide header shows every link, up to 4, the " +
+ "featured ones kept first, then the last of the rest. The footer shows " +
+ "every link. Written only when true.",
};
// Each field is documented in ARCHIVE_STORAGE_SETTINGS_FIELD_DOCS below (rendered into SETTINGS.md).
diff --git a/common/lib/socialLinks.test.ts b/common/lib/socialLinks.test.ts
@@ -23,36 +23,53 @@ test("the header bound is four", () => {
assert.equal(HEADER_SOCIAL_LINKS_MAX, 4);
});
-test("header: four or fewer links, none marked → all of them, in order", () => {
- assert.deepEqual(headerSocialLinks([]), []);
- assert.deepEqual(labels(headerSocialLinks([link("A")])), ["A"]);
+test("wide: four or fewer links → all of them, in order, marked or not", () => {
+ assert.deepEqual(headerSocialLinks([], "wide"), []);
+ assert.deepEqual(labels(headerSocialLinks([link("A")], "wide")), ["A"]);
assert.deepEqual(
- labels(headerSocialLinks([link("A"), link("B"), link("C"), link("D")])),
+ labels(headerSocialLinks([link("A"), link("B", true), link("C"), link("D")], "wide")),
["A", "B", "C", "D"],
);
});
-test("header: six links, none marked → the last four", () => {
+test("wide: six links, none marked → the last four", () => {
const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l));
- assert.deepEqual(labels(headerSocialLinks(six)), ["C", "D", "E", "F"]);
+ assert.deepEqual(labels(headerSocialLinks(six, "wide")), ["C", "D", "E", "F"]);
});
-test("header: any link marked → the marked ones only, in order", () => {
- const six = [link("A"), link("B", true), link("C"), link("D", true), link("E"), link("F")];
- assert.deepEqual(labels(headerSocialLinks(six)), ["B", "D"]);
- // Marking is choosing, whatever the count: one marked of three is one shown.
- assert.deepEqual(labels(headerSocialLinks([link("A"), link("B", true), link("C")])), ["B"]);
+test("wide: six links, some marked → the marked kept first, the last of the rest after, in configured order", () => {
+ const six = [link("A", true), link("B"), link("C"), link("D", true), link("E"), link("F")];
+ assert.deepEqual(labels(headerSocialLinks(six, "wide")), ["A", "D", "E", "F"]);
+ const one = [link("A", true), link("B"), link("C"), link("D"), link("E"), link("F")];
+ assert.deepEqual(labels(headerSocialLinks(one, "wide")), ["A", "D", "E", "F"]);
});
-test("header: more than four marked → the last four marked", () => {
+test("wide: more than four marked → the last four marked", () => {
const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l, l !== "F"));
- assert.deepEqual(labels(headerSocialLinks(six)), ["B", "C", "D", "E"]);
+ assert.deepEqual(labels(headerSocialLinks(six, "wide")), ["B", "C", "D", "E"]);
});
-test("header: the input is not changed", () => {
+test("narrow: none marked → none; the name wins and the footer has them all", () => {
+ assert.deepEqual(headerSocialLinks([], "narrow"), []);
+ assert.deepEqual(headerSocialLinks([link("A"), link("B")], "narrow"), []);
const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l));
- headerSocialLinks(six);
+ assert.deepEqual(headerSocialLinks(six, "narrow"), []);
+});
+
+test("narrow: the marked ones only, in order; more than four marked → the last four", () => {
+ assert.deepEqual(labels(headerSocialLinks([link("A"), link("B"), link("C", true)], "narrow")), ["C"]);
+ const six = [link("A"), link("B", true), link("C"), link("D", true), link("E"), link("F")];
+ assert.deepEqual(labels(headerSocialLinks(six, "narrow")), ["B", "D"]);
+ const many = ["A", "B", "C", "D", "E", "F"].map((l) => link(l, l !== "F"));
+ assert.deepEqual(labels(headerSocialLinks(many, "narrow")), ["B", "C", "D", "E"]);
+});
+
+test("header: the input is not changed", () => {
+ const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l, l === "B"));
+ headerSocialLinks(six, "wide");
+ headerSocialLinks(six, "narrow");
assert.equal(six.length, 6);
+ assert.deepEqual(labels(six), ["A", "B", "C", "D", "E", "F"]);
});
test("parseSocialLinks: featured is kept only when exactly true", () => {
diff --git a/common/lib/socialLinks.ts b/common/lib/socialLinks.ts
@@ -9,18 +9,30 @@ import { normalizeSocialSvg, socialSvgProblem, SVG_ID_RE } from "./socialSvg";
// A header holds at most this many social links. The footer always holds all.
export const HEADER_SOCIAL_LINKS_MAX = 4;
-// Which links a header shows, in their configured order:
-// - any link marked `featured` → the marked ones only;
-// - none marked → all of them;
-// and of that list, the LAST `HEADER_SOCIAL_LINKS_MAX`. Last, not first: the
-// newest link an operator adds goes at the end of the list, and is the one they
-// most want seen.
+// The header's two widths (the ruling of 2026-09-28): a header keeps the
+// site's NAME on a narrow screen and shows only the links marked for it; the
+// rest are in the footer, which always shows every link.
+export type HeaderWidth = "wide" | "narrow";
+
+// Which links a header shows at `width`, in their configured order:
+// - wide: every link, up to HEADER_SOCIAL_LINKS_MAX; with more configured,
+// the `featured` ones are kept first, then the last of the rest fill the
+// row;
+// - narrow: the `featured` ones only, up to HEADER_SOCIAL_LINKS_MAX; none
+// marked → none (the name wins; the footer has them all).
+// Where a bound cuts, the LAST ones are kept: the newest link an operator adds
+// goes at the end of the list, and is the one they most want seen.
export function headerSocialLinks<T extends Pick<SocialLink, "featured">>(
links: readonly T[],
+ width: HeaderWidth,
): T[] {
- const featured = links.filter((link) => link.featured === true);
- const pool = featured.length > 0 ? featured : links;
- return pool.slice(-HEADER_SOCIAL_LINKS_MAX);
+ const featured = links.filter((link) => link.featured === true).slice(-HEADER_SOCIAL_LINKS_MAX);
+ if (width === "narrow") return featured;
+ if (links.length <= HEADER_SOCIAL_LINKS_MAX) return [...links];
+ const room = HEADER_SOCIAL_LINKS_MAX - featured.length;
+ const rest = room > 0 ? links.filter((link) => !featured.includes(link)).slice(-room) : [];
+ const keep = new Set<T>([...featured, ...rest]);
+ return links.filter((link) => keep.has(link));
}
// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -6,7 +6,7 @@
- **Building the homepage now publishes the source: a read-only git mirror, its raw tree and a fresh tarball, behind a gate.** `archilyzer build homepage`, the `/sites` Homepage jobs and `pnpm ops build-homepage` run `archilyzer source publish` between compose and `next build`. It makes a fresh clone of the private `main` (the repository itself is never rewritten), rewrites that copy with git-filter-repo using your scrub rules (file contents and commit messages; your home directory becomes `/home/user` without a rule), and publishes it under `homepage/public` for `git clone https://archilyzer.pages.dev/source/archilyzer.git`, beside `/source/tree/` and the Downloads tarball. Before anything is written, every object of the rewritten history and every file about to be published is searched for every string you have denied; **one hit refuses the build**, and its log names the string only by where you wrote it (`denylist line 3 (len 5)`) and each hit by its object, field and byte offset — never a byte of the object. **A refusal withdraws the source**: the last publish is removed from `homepage/public` and the last build's copy from `homepage/out`, and **Deploy homepage refuses** a build whose source was not audited under today's rules and today's `main` ("run `archilyzer build homepage`, then deploy"). The rules live outside the repo, in `~/.config/archilyzer/source-scrub.txt` and `source-denylist.txt` (`ARCHILYZER_CONFIG_DIR`, `SOURCE_SCRUB_FILE`, `SOURCE_DENYLIST_FILE`); **without them the build refuses**, naming the missing file. **Put everything private in the denylist before any deploy, a preview included**: previews are public, and every deployment stays reachable at its own address until you delete it. Install git-filter-repo once (`pipx install git-filter-repo`; the editor's process needs `~/.local/bin` on its `PATH` to find it) — without it the build fetches it through `pipx run`, which needs the network — and gitleaks if you want its secret scan too. An unchanged `main` with unchanged rules is skipped, so a rebuild costs about 20 seconds only when something moved. A checkout with no git repository (the docker image, a tarball install) builds with the /source page's empty state. `archilyzer source publish --check` audits without writing, `archilyzer source audit <clone>/.git` checks any clone, `archilyzer build homepage --no-source` removes the published source instead, and `archilyzer doctor` reports the tools, the two files (rule counts and permissions, never their contents) and the last publish. `create-archives.sh` is gone. See PUBLISH.md, "The source mirror (homepage)".
- **umtool reads the corpus from its checkout (or `TRANSCRIPTS_DIR`), and the song project's data defaults to `~/.local/share/archilyzer/song`.** If yours is elsewhere, link it there before restarting umtool: `mkdir -p ~/.local/share/archilyzer && ln -s <where the data is> ~/.local/share/archilyzer/song` (the data stays where it is). With no `CHANNELS_DIR`, umtool reads the corpus at `$TRANSCRIPTS_DIR/channels`, else the checkout's own `transcripts/channels`; it used to fall back to an absolute path that existed on one machine only. The song project's videos default to `~/reports/quartering-uh-song/videos`; `SONG_DIR` and `VIDEO_ROOT` still win. The song project's tracked manifests record their paths relative to the song folders, and the twenty one-off `umtool/song/*.sh` run logs, which only ever ran on the machine that wrote them, are gone.
- **umtool's production build no longer reads the corpus folder.** Since umtool began finding the corpus from its checkout (the bullet above), `next build` treated the checkout's whole `transcripts/channels` as files to bundle. On a real archive it ran out of memory and was killed, so umtool could not be rebuilt. The build now ignores that folder and finishes in about 25 s at under 1 GB, the same as a checkout with no corpus. Nothing changes when umtool runs.
-- **A social icon pasted with only a width and height is accepted, and each social link can be shown in a header.** The social-link editors (Settings, a site's form) refused an SVG with no `viewBox`, so a vendor's logo file as downloaded, which often carries only its size, was refused. On save, a root with a numeric width and height (unitless or px) and no viewBox is now given `viewBox="0 0 W H"`; a percentage, `em`, or a missing or zero side is still refused. Each link has a **Show in header** checkbox, stored as `featured: true` only when checked, with the hint "With none checked, the header shows the last four.": a header shows at most four links, the checked ones when any is checked, else the last four (the homepage's header reads it). A file with neither is read and rendered as before. `SETTINGS.md` and `SITE.md` list `featured`.
+- **A social icon pasted with only a width and height is accepted, and each social link can be kept in the header on small screens.** The social-link editors (Settings, a site's form) refused an SVG with no `viewBox`, so a vendor's logo file as downloaded, which often carries only its size, was refused. On save, a root with a numeric width and height (unitless or px) and no viewBox is now given `viewBox="0 0 W H"`; a percentage, `em`, or a missing or zero side is still refused. Each link has a **Keep in header on small screens** checkbox, stored as `featured: true` only when checked, with the hint "On small screens the header shows only these; the rest stay in the footer.": a narrow header (under 520 px) shows only the checked links, none when none is checked; a wide one shows every link, up to four, the checked ones kept first (the homepage's, every site's and the hub's headers read it). A file with neither is read and rendered as before. `SETTINGS.md` and `SITE.md` describe `featured`.
- **A social icon is checked by what it may contain, when it is saved new or edited and every time it is shown, and a refused one says why.** An icon must be one well-formed `<svg>` of shapes, groups, gradients, clips, masks, filters, text and simple animation, with SVG presentation attributes: no script, `style` block, `foreignObject`, link, embedded image, `title`/`desc` with anything but text (text-only ones are removed), or HTML element; no event handler, however it is written; a `style` attribute of presentation properties only; a reference only to something inside the icon, written plainly; and nothing that could load from elsewhere (a CSS escape or comment, `image-set(`, `image(`, `cross-fade(`, `element(`, `src(`, `paint(`, `@import`). Comments, a leading XML declaration and a plain DOCTYPE are removed. A refused save ends with the reason ("… has an invalid SVG: it has an event handler attribute.", "… it links to something outside the icon.") and never repeats the markup; for a drawing program's file it says to export it with presentation attributes rather than a style block (in Inkscape, save as Plain SVG). **Upgrading:** an icon an older build stored is kept as it is when a save does not change it — a pause, a priority or a title still saves — but a page shows it as its label until its SVG is replaced; `archilyzer doctor`'s new "social icons" line names every stored icon that fails, by file and label, with the reason.
- **Two grounds, Light and Dark, and no accent picker in the header.** The editor's header keeps its theme toggle, which cycles System, Light and Dark; the theme menu (Base and Accent) is gone, and the editor wears its own accent, Signal. A stored choice of the retired third ground loads as Light and is rewritten once; a stored accent is not read and is left in storage. A site's accent is still set in its form; the form's hint no longer says a reader can pick another.
- **The hub URL hints say what the setting does now.** Settings' **Family hub URL** and a site's **Hub URL** no longer promise a Hub link in the header (it was removed): the value is published as `hubUrl` in each site's `/site.json` and `/corpus.json`, so the hub can tell its member sites. `SETTINGS.md` and `SITE.md` say the same.
diff --git a/editor/app/components/SocialLinksField.tsx b/editor/app/components/SocialLinksField.tsx
@@ -96,8 +96,9 @@ export function SocialLinksField({ value, onChange, name }: Props) {
placeholder='<svg viewBox="0 0 24 24"><path d="…"/></svg>'
className="rounded border border-border bg-card px-2 py-1 text-xs font-mono"
/>
- {/* A header shows at most four links: the ones checked here, or,
- with none checked, the last four (common/lib/socialLinks.ts). */}
+ {/* On a small screen a header shows only the links checked here
+ (`featured`); a wide one shows every link, up to four, these
+ kept first (common/lib/socialLinks.ts headerSocialLinks). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-2 text-sm">
<input
@@ -106,10 +107,10 @@ export function SocialLinksField({ value, onChange, name }: Props) {
onChange={(e) => update(idx, { featured: e.target.checked })}
className="accent-brand"
/>
- Show in header
+ Keep in header on small screens
</label>
<span className="text-xs text-muted-foreground">
- With none checked, the header shows the last four.
+ On small screens the header shows only these; the rest stay in the footer.
</span>
</div>
</div>
diff --git a/editor/app/components/socialLinksJson.test.ts b/editor/app/components/socialLinksJson.test.ts
@@ -5,7 +5,7 @@ import { socialLinksJson } from "./socialLinksJson";
const SVG = `<svg viewBox="0 0 24 24"><path d="M0 0"/></svg>`;
-test("Show in header: a checked row posts featured: true, an unchecked one no key", () => {
+test("Keep in header on small screens: a checked row posts featured: true, an unchecked one no key", () => {
const posted = JSON.parse(
socialLinksJson([
{ label: " A ", url: " https://a.example ", svg: ` ${SVG} `, featured: true },
diff --git a/editor/app/components/socialLinksJson.ts b/editor/app/components/socialLinksJson.ts
@@ -6,7 +6,8 @@ export type SocialRow = {
label: string;
url: string;
svg: string;
- // "Show in header". Posted only when checked, the way the schema stores it.
+ // "Keep in header on small screens". Posted only when checked, the way the
+ // schema stores it.
featured?: boolean;
};
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -42,7 +42,7 @@ test("saves global default social links", async ({ page }) => {
await page
.getByPlaceholder(/<svg viewbox/i)
.fill('<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>');
- await page.getByRole("checkbox", { name: "Show in header" }).check();
+ await page.getByRole("checkbox", { name: "Keep in header on small screens" }).check();
await page.getByRole("button", { name: /save settings/i }).click();
await expect(
@@ -54,7 +54,7 @@ test("saves global default social links", async ({ page }) => {
}>("test-settings.json");
expect(saved.socialLinks).toHaveLength(1);
expect(saved.socialLinks?.[0].label).toBe("GitHub");
- // "Show in header" is stored as featured: true.
+ // "Keep in header on small screens" is stored as featured: true.
expect(saved.socialLinks?.[0].featured).toBe(true);
// SVG was normalized on save (fill="currentColor" injected).
expect(saved.socialLinks?.[0].svg).toContain("currentColor");
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -5,7 +5,7 @@
- **A social icon that fails the check is shown as its label, and every icon paints inside its box.** The footer inlines a social link's SVG only if it passes the same check a save runs (what an icon may contain is in `SITE.md`); otherwise the link shows its label as text, at most 10rem with an ellipsis. Each icon is clipped to its own box. Needs a rebuild and deploy of each site.
- **A chart's stacked bars are separated by a 2 px gap in the chart card's colour.** A stacked bar's segments were drawn touching; they now have a 2 px gap in the card's colour between them, and in high-contrast mode the system's background colour. Stacked areas keep their line in each series' colour along the top, charts of one series, line charts and side-by-side bars are unchanged. Needs a rebuild and deploy of each site.
- **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site.
-- **The header carries the operator's social links and one theme toggle, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's now end with the social icons (the site's `socialLinks`, else `settings.json`'s; at most four: the ones marked **Show in header** when any is, else the last four) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring; the footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour). The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. On a narrow screen no icon is hidden: the site's name drops first and its mark stays, whenever the name does not fit beside the icons (whatever its length, and the same before and after the page's font has loaded), and only then do the icons scroll sideways in their own box, the last one in view first. The footer's icons wrap onto a second line rather than widen the page. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site.
+- **The header carries the operator's social links and one theme toggle, keeps the site's name on a small screen, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's end with the social icons (the site's `socialLinks`, else `settings.json`'s) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring. From 520 px wide the header shows every link, up to four (with more, the ones marked **Keep in header on small screens** first, then the last of the rest); below 520 px it shows only the marked ones (none marked → none) and keeps the site's name beside them. The footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour), and wraps them rather than widen the page. The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. Only as last resorts, for a very long name on a phone, does the name drop (its mark stays; the same before and after the page's font has loaded) and do the icons scroll sideways in their own box. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site.
## [0.10.0] - 2026-09-28
- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site.
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -33,15 +33,21 @@ import MobileMenu from "./MobileMenu";
// icons do not fit a 768 px bar, and the nav gives way before any
// icon scrolls.
//
-// The header shows at most four social links (headerSocialLinks: the
-// `featured` ones when any is marked, else the last four); the footer shows
-// them all. None is hidden by width. The Archilyzer link replaces the old
+// 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
+// `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
+// (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). 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.
//
-// ON A NARROW SCREEN THE HEADER MAKES ROOM FIRST: the wordmark's TEXT drops and
-// the mark stays, exactly when the text does not fit — whatever the site's
-// title. Titles differ in length ("Bonnellyzer", "Rekietalyzer"), so no width
+// 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
+// the site's title. Titles differ in length ("Bonnellyzer", "Rekietalyzer"), so no width
// threshold is written down: the text sits in a one-line box (`h-7`,
// `overflow-hidden`, `flex-wrap`) behind a zero-width strut, and a flex item
// that does not fit beside the strut wraps to the box's second line, which is
@@ -60,7 +66,8 @@ export default function Header() {
const site = currentSite();
const isSite = instanceMode() === "site";
const socialLinks = resolveSocialLinks(site, getSettings());
- const shown = headerSocialLinks(socialLinks).length;
+ const narrow = headerSocialLinks(socialLinks, "narrow").length;
+ const wide = headerSocialLinks(socialLinks, "wide").length;
const showDownloads = hasArchives();
const showDuplicates = hasDuplicates();
@@ -128,9 +135,24 @@ export default function Header() {
</a>
)}
<div data-header-group="" className="flex min-w-0 items-center">
- {shown > 0 && (
- <SocialScroll>
- <SocialLinks links={socialLinks} placement="header" className="w-max px-1 [direction:ltr]" />
+ {narrow > 0 && (
+ <SocialScroll className="min-[520px]:hidden">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="narrow"
+ className="w-max px-1 [direction:ltr]"
+ />
+ </SocialScroll>
+ )}
+ {wide > 0 && (
+ <SocialScroll className="hidden min-[520px]:block">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="wide"
+ className="w-max px-1 [direction:ltr]"
+ />
</SocialScroll>
)}
<ThemeToggle variant="bare" />
diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts
@@ -1,4 +1,7 @@
+import fs from "node:fs";
+import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
+import { normalizeSocialSvg } from "../../common/lib/settingsSchema";
import { resolveAccent } from "../../common/lib/accent";
import { ACCENTS } from "../../common/lib/brand";
@@ -136,6 +139,46 @@ test.describe("hub official instances", () => {
await expect(banner.getByRole("link", { name: "Changelog" })).toHaveCount(0);
});
+ // 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);
+ }
+ });
+
test("the hub has no link to ko-fi.com", async ({ page }) => {
await stubMember(page);
await page.route("**/hub-summary.json", (r) =>
diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json
@@ -10,7 +10,8 @@
{
"label": "GitHub",
"url": "https://github.com/example",
- "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M1 1h2\"/></svg>"
+ "svg": "<svg aria-hidden=\"true\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M1 1h2\"/></svg>",
+ "featured": true
}
],
"relatedSites": [{ "label": "Friends", "siteIds": ["othersite"] }],
diff --git a/export/e2e/header.spec.ts b/export/e2e/header.spec.ts
@@ -9,9 +9,12 @@ import { installRoutes } from "./helpers";
// THE EXPORT HEADER (export/app/components/Header.tsx): brand · nav ·
// Archilyzer · the group (the social row and the theme toggle) from `lg`;
// brand · group · menu trigger below it. No sites dropdown, no hub link, no
-// theme menu; Changelog is in the footer. On a narrow screen the wordmark's
-// text drops first (whenever it does not fit, whatever the title), the mark
-// stays, and the social row scrolls in its box only after that.
+// theme menu; Changelog is in the footer. The header keeps the NAME on a
+// narrow screen: below 520 px its row holds only the links marked `featured`
+// (none marked → none), from 520 px every link up to four, the marked ones
+// kept first; the footer holds every link. Only as last resorts does the
+// wordmark's text drop (whenever it does not fit, whatever the title) and the
+// social row scroll in its box.
//
// The suite serves ONE site (SITE_ID=testsite) and currentSite() re-reads
// site.json on every dev render, so each test rewrites the fixture's title and
@@ -64,7 +67,14 @@ const TITLES = {
const banner = (page: Page) => page.getByRole("banner");
const brand = (page: Page) => page.locator("header [data-header-brand]");
-const headerRow = (page: Page) => page.locator('header [data-social-links="header"]');
+// The row at the current width: a narrow and a wide copy are both rendered and
+// CSS shows one.
+const headerRow = (page: Page) => page.locator('header [data-social-links="header"]:visible');
+const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]');
+const marked = (links: RawLink[]) => links.map((l) => ({ ...l, featured: true }));
+const lastMarked = (links: RawLink[]) => links.map((l, i) => (i === links.length - 1 ? { ...l, featured: true } : l));
+const labelsOf = (loc: Locator) =>
+ loc.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")));
const toggle = (page: Page) => banner(page).getByRole("button", { name: /^switch to /i });
async function open(page: Page, width: number) {
@@ -79,7 +89,9 @@ const layout = (page: Page) =>
page.evaluate(() => {
const box = document.querySelector("header [data-header-brand] span.flex-wrap") as HTMLElement;
const text = box.lastElementChild as HTMLElement;
- const scroll = document.querySelector("header [data-social-scroll]") as HTMLElement | null;
+ const scroll = ([...document.querySelectorAll("header [data-social-scroll]")] as HTMLElement[]).find(
+ (e) => e.offsetParent !== null,
+ );
const row = document.querySelector("header > div") as HTMLElement;
return {
text: text.offsetTop < box.clientHeight - 1,
@@ -93,18 +105,20 @@ for (const touch of [false, true]) {
test.describe(touch ? "under a coarse pointer" : "with a mouse", () => {
test.use({ hasTouch: touch });
for (const [kind, title] of Object.entries(TITLES)) {
- test(`${kind} title, 1–4 links, 280–430 px: nothing scrolls the page, no link hidden, the text drops before the row scrolls`, async ({
+ test(`${kind} title, 0–4 marked links, 280–430 px: nothing scrolls the page, only the marked shown, the text drops before the row scrolls`, async ({
page,
}) => {
- test.setTimeout(120_000);
+ test.setTimeout(150_000);
await installRoutes(page);
- for (const n of [1, 2, 3, 4]) {
- writeSite({ ...title, socialLinks: SIX.slice(0, n) });
+ for (const n of [0, 1, 2, 3, 4]) {
+ // Four links, the last n marked.
+ writeSite({ ...title, socialLinks: SIX.slice(0, 4).map((l, i) => (i >= 4 - n ? { ...l, featured: true } : l)) });
for (const w of [280, 300, 320, 340, 360, 375, 390, 414, 430]) {
await open(page, w);
- const at = `${kind} title, ${n} link(s), ${w} px`;
+ const at = `${kind} title, ${n} marked, ${w} px`;
await expect(headerRow(page).getByRole("link"), at).toHaveCount(n);
const l = await layout(page);
+ if (n === 0) expect(l.scrolls, `${at}: a row with no links`).toBe(false);
expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0);
expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0);
if (l.scrolls) expect(l.text, `${at}: the row scrolls while the text shows`).toBe(false);
@@ -131,7 +145,7 @@ test.describe("from lg, under a coarse pointer, a very long title", () => {
writeSite({
headerTitle: "Longestfixturearchivealyzer",
wordmarkLead: "Longestfixturearchive",
- socialLinks: SIX.slice(0, 4),
+ socialLinks: lastMarked(SIX.slice(0, 4)),
});
await open(page, 1024);
for (let w = 1024; w <= 1050; w += 2) {
@@ -153,7 +167,7 @@ for (const touch of [false, true]) {
test.use({ hasTouch: touch });
test("320 and 360 px: the header fits, every key in reach", async ({ page }) => {
await installRoutes(page);
- writeSite({ ...TITLES.long, socialLinks: SIX.slice(0, 4) });
+ writeSite({ ...TITLES.long, socialLinks: marked(SIX.slice(0, 4)) });
await page.addInitScript(() => {
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.style.fontSize = "200%";
@@ -174,7 +188,7 @@ for (const touch of [false, true]) {
// for one (at 320 px under touch the mark, the 88 px toggle and the
// 72 px menu button leave it none: the phone header is the operator's
// to rule on).
- const room = await page.locator("header [data-social-scroll]").evaluate((el) => el.clientWidth);
+ const room = await page.locator("header [data-social-scroll]:visible").evaluate((el) => el.clientWidth);
if (room >= (touch ? 88 : 72)) {
await expect(headerRow(page).getByRole("link").last()).toBeInViewport();
}
@@ -220,7 +234,7 @@ test("the title shows at the same widths in the fallback face and in Archivo", a
const archivo = () =>
page.evaluate(() => [...document.fonts].filter((f) => /Archivo/i.test(f.family)).map((f) => f.status));
for (const title of [TITLES.short, { headerTitle: "Mediumfixturealyzer", wordmarkLead: "Mediumfixture" }, TITLES.long]) {
- writeSite({ ...title, socialLinks: SIX.slice(0, 3) });
+ writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 3)) });
await page.route(/\.woff2(\?|$)/, (r) => r.abort());
const fallback = await fits();
expect(await archivo(), "the web font is blocked").not.toContain("loaded");
@@ -231,12 +245,79 @@ test("the title shows at the same widths in the fallback face and in Archivo", a
}
});
-test("a short title shows in full on a phone with one link; a long one gives way", async ({ page }) => {
+// 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" } };
+for (const touch of [false, true]) {
+ test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => {
+ test.use({ hasTouch: touch });
+ for (const [kind, title] of Object.entries(NARROW_TITLES)) {
+ test(`${kind} title at 360 and 390 px: the full name, the marked link alone, focus brand → link → toggle → menu`, async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 4)) });
+ for (const w of [360, 390]) {
+ await open(page, w);
+ expect((await layout(page)).text, `${w} px: the name`).toBe(true);
+ expect(await labelsOf(headerRow(page))).toEqual(["Diamond"]);
+ for (const other of ["Square", "Triangle", "Bar"]) {
+ await expect(banner(page).getByRole("link", { name: other, exact: true })).toHaveCount(0);
+ }
+ expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label));
+ const order: string[] = [];
+ for (let i = 0; i < 4; i++) {
+ await page.keyboard.press("Tab");
+ order.push(
+ await page.evaluate(
+ () => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim() ?? "",
+ ),
+ );
+ }
+ expect(order[0], "the brand").toBe(title.headerTitle);
+ expect(order[1]).toBe("Diamond");
+ expect(order[2]).toMatch(/^Switch to /);
+ expect(order[3]).toBe("Open menu");
+ }
+ });
+ }
+ 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) });
+ for (const w of [320, 360, 390]) {
+ await open(page, w);
+ expect((await layout(page)).text, `${w} px: the name`).toBe(true);
+ await expect(banner(page).locator('[data-social-links="header"]:visible')).toHaveCount(0);
+ expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label));
+ }
+ });
+ });
+}
+
+test("from 520 px every link shows, up to four; the footer keeps every link; nothing scrolls the page from 280 to 1400 px", async ({
+ page,
+}) => {
await installRoutes(page);
- writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 1) });
+ writeSite({ ...NARROW_TITLES.long, 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(
+ w < 520 ? ["Diamond"] : SIX.slice(0, 4).map((l) => l.label),
+ );
+ expect(await labelsOf(footerRow(page))).toEqual(SIX.slice(0, 4).map((l) => l.label));
+ expect((await layout(page)).page, `${w} px: the page scrolls sideways`).toBeLessThanOrEqual(0);
+ if (w >= 520) expect((await layout(page)).text, `${w} px: the name`).toBe(true);
+ }
+});
+
+test("a short title shows in full on a phone with one marked link; a very long one gives way", async ({ page }) => {
+ await installRoutes(page);
+ writeSite({ ...TITLES.short, socialLinks: lastMarked(SIX.slice(0, 1)) });
await open(page, 390);
expect((await layout(page)).text).toBe(true);
- writeSite({ ...TITLES.long, socialLinks: SIX.slice(0, 4) });
+ writeSite({ ...TITLES.long, socialLinks: marked(SIX.slice(0, 4)) });
await open(page, 390);
expect(await layout(page)).toMatchObject({ text: false, scrolls: false });
await open(page, 1280);
@@ -248,9 +329,10 @@ for (const width of [390, 1280]) {
page,
}) => {
await installRoutes(page);
- writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 3) });
+ writeSite({ ...TITLES.short, socialLinks: marked(SIX.slice(0, 3)) });
await open(page, width);
const keys: Locator[] = [...(await headerRow(page).getByRole("link").all()), toggle(page)];
+ expect(keys).toHaveLength(4);
const boxes = await Promise.all(keys.map((k) => k.boundingBox()));
for (const b of boxes) expect([b!.width, b!.height]).toEqual([36, 36]);
for (let i = 1; i < boxes.length; i++) {
@@ -263,9 +345,11 @@ test.describe("under a coarse pointer", () => {
test.use({ hasTouch: true });
test("the keys and the toggle are 44 px", async ({ page }) => {
await installRoutes(page);
- writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 2) });
+ writeSite({ ...TITLES.short, socialLinks: marked(SIX.slice(0, 2)) });
await open(page, 390);
- for (const k of [...(await headerRow(page).getByRole("link").all()), toggle(page)]) {
+ const keys = [...(await headerRow(page).getByRole("link").all()), toggle(page)];
+ expect(keys).toHaveLength(3);
+ for (const k of keys) {
const b = (await k.boundingBox())!;
expect([b.width, b.height]).toEqual([44, 44]);
}
@@ -324,24 +408,24 @@ test("Changelog is in the footer, not the header", async ({ page }) => {
expect(order.indexOf("Changelog")).toBe(order.indexOf("Use with AI") + 1);
});
-test("six links: the header shows the last four, the footer all six; two marked: those two", async ({
+test("six links: wide shows the last four, narrow none; two marked: wide keeps them first, narrow shows them alone", async ({
page,
}) => {
await installRoutes(page);
- const labels = (loc: Locator) =>
- loc.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")));
writeSite({ ...TITLES.short, socialLinks: SIX });
await open(page, 1280);
- expect(await labels(headerRow(page))).toEqual(SIX.slice(2).map((l) => l.label));
- expect(await labels(page.locator('footer [data-social-links="footer"]'))).toEqual(
- SIX.map((l) => l.label),
- );
+ expect(await labelsOf(headerRow(page))).toEqual(SIX.slice(2).map((l) => l.label));
+ expect(await labelsOf(footerRow(page))).toEqual(SIX.map((l) => l.label));
+ await open(page, 390);
+ await expect(headerRow(page)).toHaveCount(0);
const featured = SIX.map((l) =>
l.label === "Square" || l.label === "Post" ? { ...l, featured: true } : l,
);
writeSite({ ...TITLES.short, socialLinks: featured });
await open(page, 1280);
- expect(await labels(headerRow(page))).toEqual(["Square", "Post"]);
+ expect(await labelsOf(headerRow(page))).toEqual(["Square", "Diamond", "Post", "Chevron"]);
+ await open(page, 390);
+ expect(await labelsOf(headerRow(page))).toEqual(["Square", "Post"]);
});
// A hand-edited site.json can hold anything. The header (and the footer)
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -3,7 +3,7 @@
## [Unreleased]
- **`/#instances` goes straight to Official Instances.** The section carries `id="instances"`, clear of the sticky header, and every archive's header now links there (`INSTANCES_URL` in `common/lib/project.ts`). With no sites the link lands on the top of the page.
-- **The social links are in the header, at every width, beside the theme toggle.** The operator's social icons (`homepage.json`'s, else `settings.json`'s) now sit in the header's bar as well as in the footer's Elsewhere column, followed by the theme toggle, all spaced alike. From 768 px wide the bar is wordmark, nav, icons, toggle; below 768 px it is wordmark, icons, toggle, and the nav has the rule below to itself, where its four links fit. The header shows at most four links: the ones marked **Show in header** when any is, else the last four; the footer shows them all. No link is hidden on a small screen: when the icons and the full wordmark do not fit side by side, the wordmark's text is dropped and its mark stays (with three links, the full wordmark shows from 348 px wide with a mouse and from 380 px on a touch screen; with four, from 384 and 424 px). Only on a screen narrower than that for the mark too, or at a much larger text size, do the icons scroll sideways in their own box, the last one in view first. Each is an icon named by its label, with no text beside it.
+- **The social links are in the header beside the theme toggle, and on a small screen the header keeps the name.** The operator's social icons (`homepage.json`'s, else `settings.json`'s) sit in the header's bar as well as in the footer's Elsewhere column, followed by the theme toggle, all spaced alike. From 768 px wide the bar is wordmark, nav, icons, toggle; below 768 px it is wordmark, icons, toggle, and the nav has the rule below to itself, where its four links fit. From 520 px wide the header shows every link, up to four (with more, the ones marked **Keep in header on small screens** first, then the last of the rest); below 520 px it shows only the marked ones (none marked → none), and the wordmark keeps its full name: "Archilyzer" shows from 292 px wide on a touch screen with one marked link. The footer always shows every link. Only as last resorts, on a screen narrower still or at a much larger text size, does the wordmark's text drop (its mark stays) and do the icons scroll sideways in their own box, the last one in view first. Each is an icon named by its label, with no text beside it.
- **One theme toggle in place of the two theme buttons.** The header's theme menu (Base and Accent) and its base toggle are one toggle, the last of the header's icons, that cycles the ground (System, Light, Dark) and is named for the next one. There is no accent to pick: the homepage's is its own, Signal, and an accent stored by an earlier build is ignored here, with no flash.
- **Two grounds, Light and Dark.** The third ground, the warm paper one, is gone: the toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light.
- **Changelog is in the footer only.** The header's nav is Docs, Source, Downloads and Stats; the footer's Sections list and the 404 page keep Changelog.
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -46,32 +46,34 @@ function NavList({ className }: { className?: string }) {
// that cycles the base (common/components/ThemeToggle.tsx, `variant="bare"`),
// 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 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):
+// 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
+// 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 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
+// < 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):
-// links mouse (36 px keys) touch (44 px keys) viewport, below 640 px
+// links mouse (36 px keys) touch (44 px keys) viewport
+// 0 < 200 < 208 < 240 / < 248
// 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 toggle 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 toggle 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.
+// And after that the row scrolls sideways inside the bar (SocialScroll), its
+// END shown first, the toggle outside it, 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
@@ -90,7 +92,8 @@ const WORDMARK_FITS: Record<number, string> = {
export default function Header() {
const socialLinks = resolveHomepageSocialLinks(currentHomepage(), getSettings());
- const shown = headerSocialLinks(socialLinks).length;
+ const narrow = headerSocialLinks(socialLinks, "narrow").length;
+ const wide = headerSocialLinks(socialLinks, "wide").length;
return (
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md">
<div className="@container/bar max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-3 md:gap-6">
@@ -103,7 +106,7 @@ export default function Header() {
<Wordmark
title={PROJECT_NAME}
lead={PROJECT_WORDMARK_LEAD}
- className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS[shown] ?? WORDMARK_FITS[4]}`}
+ className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS[narrow] ?? WORDMARK_FITS[4]}`}
/>
</Link>
<div className="ml-auto flex min-w-0 items-center gap-8">
@@ -111,9 +114,24 @@ export default function Header() {
<NavList className="gap-6" />
</nav>
<div className="flex min-w-0 items-center">
- {shown > 0 && (
- <SocialScroll>
- <SocialLinks links={socialLinks} placement="header" className="w-max px-1 [direction:ltr]" />
+ {narrow > 0 && (
+ <SocialScroll className="min-[520px]:hidden">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="narrow"
+ className="w-max px-1 [direction:ltr]"
+ />
+ </SocialScroll>
+ )}
+ {wide > 0 && (
+ <SocialScroll className="hidden min-[520px]:block">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="wide"
+ className="w-max px-1 [direction:ltr]"
+ />
</SocialScroll>
)}
<ThemeToggle variant="bare" />
diff --git a/homepage/e2e/fixture-social.ts b/homepage/e2e/fixture-social.ts
@@ -73,13 +73,16 @@ const raw = (label: string, svg: string, featured = false) => ({
...(featured ? { featured: true } : {}),
});
+// The LAST link is marked for the header (`featured`): a narrow header shows it
+// alone, a wide one shows all three.
export const FIXTURE_SOCIAL_TRIO = [
raw("Leaf", LEAF),
raw("Bubble", BUBBLE),
- raw("Disc", FIXTURE_DISC_SVG),
+ raw("Disc", FIXTURE_DISC_SVG, true),
];
-// Six, none marked: the header takes the last four.
+// Six, the last (Disc) marked: a wide header takes the last four, a narrow one
+// Disc alone.
export const FIXTURE_SOCIAL_SIX = [
raw("Square", SQUARE),
raw("Ring", RING),
@@ -88,11 +91,16 @@ export const FIXTURE_SOCIAL_SIX = [
...FIXTURE_SOCIAL_TRIO.slice(1),
];
-// Six, two marked: the header takes those two.
-export const FIXTURE_SOCIAL_SIX_FEATURED = FIXTURE_SOCIAL_SIX.map((l) =>
+// Six, two marked (Square, Bubble; Disc not): a narrow header takes those two,
+// a wide one those two and then the last two of the rest.
+export const FIXTURE_SOCIAL_SIX_FEATURED = FIXTURE_SOCIAL_SIX.map(({ featured: _, ...l }) =>
l.label === "Square" || l.label === "Bubble" ? { ...l, featured: true } : l,
);
+// The same links with no mark at all: a narrow header shows none of them.
+export const unmarked = <T extends { featured?: boolean }>(links: readonly T[]) =>
+ links.map(({ featured: _, ...l }) => l);
+
type RawLink = { label: string; url: string; svg: string; featured?: boolean };
export function writeFixtureSettings(dest: string, links: RawLink[]): void {
diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts
@@ -6,14 +6,16 @@ import {
FIXTURE_SOCIAL_SIX_FEATURED,
FIXTURE_HOSTILE_SVGS,
FIXTURE_SOCIAL_TRIO,
+ unmarked,
writeFixtureSettings,
writeRawFixtureSettings,
} from "./fixture-social";
import { themeToggle } from "./helpers";
import { ADVERSARIAL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors";
-// THE SOCIAL ROW: in the header at every width (at most four links), and in the
-// footer (every link). The links are the e2e's own (fixture-social.ts): Leaf (a
+// 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
+// marked → none), from 520 px every link up to four, the marked ones kept first. The links are the e2e's own (fixture-social.ts): Leaf (a
// gradient and one themed colour), Bubble (one colour) and Disc (two colours,
// pasted with only a size — no viewBox until the normalizer made one). No copy:
// every link is an icon whose accessible name is its label.
@@ -30,13 +32,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 theme toggle;
-// 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).
+// The header's row at the current width (a narrow and a wide copy are both
+// rendered; CSS shows one), followed by the theme toggle. Only as last resorts
+// does the wordmark's TEXT hide (the mark stays) or the row scroll inside its
+// box (Header.tsx).
const headerRow = (page: Page) =>
- page.locator('header [data-social-links="header"]');
-const scrollBox = (page: Page) => page.locator("header [data-social-scroll]");
+ page.locator('header [data-social-links="header"]:visible');
+const scrollBox = (page: Page) => page.locator("header [data-social-scroll]:visible");
const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]');
const labelsOf = (row: Locator) =>
@@ -63,21 +65,32 @@ async function tabTo(page: Page, target: Locator) {
// wordmark link (152 px), a 12 px gap, and the keys and the toggle (Header.tsx
// WORDMARK_FITS).
const WORDMARK_NEEDS = {
- mouse: { 1: 236, 3: 308, 4: 344 },
- touch: { 1: 252, 3: 340, 4: 384 },
+ mouse: { 0: 200, 1: 236, 3: 308, 4: 344 },
+ touch: { 0: 208, 1: 252, 3: 340, 4: 384 },
} as const;
+const allMarked = <T extends object>(links: readonly T[]) => links.map((l) => ({ ...l, featured: true }));
+
+// The link sets by how many a NARROW header shows (its marked ones).
const LINK_SETS = {
- 1: FIXTURE_SOCIAL_TRIO.slice(-1),
- 3: FIXTURE_SOCIAL_TRIO,
- 4: FIXTURE_SOCIAL_SIX, // the header shows its last four
+ 0: unmarked(FIXTURE_SOCIAL_TRIO),
+ 1: FIXTURE_SOCIAL_TRIO, // Disc, the last, is marked
+ 3: allMarked(FIXTURE_SOCIAL_TRIO),
+ 4: allMarked(FIXTURE_SOCIAL_SIX), // the narrow header shows the last four
} as const;
-async function narrowHeader(page: Page, width: number, count: 1 | 3 | 4, pointer: "mouse" | "touch") {
+async function narrowHeader(page: Page, width: number, count: 0 | 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);
+ const labels = LINK_SETS[count]
+ .filter((l) => (l as { featured?: boolean }).featured === true)
+ .map((l) => l.label)
+ .slice(-4);
+ expect(await labelsOf(headerRow(page)), `${width} px, ${count} marked`).toEqual(labels);
+ // The copy for the other width is out of the accessibility tree.
+ for (const other of LINK_SETS[count].map((l) => l.label).filter((l) => !labels.includes(l))) {
+ await expect(page.getByRole("banner").getByRole("link", { name: other, exact: true })).toHaveCount(0);
+ }
// Nothing is hidden but (maybe) the wordmark's text, and nothing scrolls.
const home = page.getByRole("link", { name: "Archilyzer home", exact: true });
@@ -91,28 +104,32 @@ async function narrowHeader(page: Page, width: number, count: 1 | 3 | 4, pointer
}
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 toggle = themeToggle(page);
await expect(toggle).toBeInViewport({ ratio: 1 });
- expect((await toggle.boundingBox())!.x, "the toggle is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5);
+ if (count > 0) {
+ 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);
+ }
+ expect((await toggle.boundingBox())!.x, "the toggle is after the box").toBeGreaterThanOrEqual(b.x + b.width - 4.5);
+ } else {
+ await expect(page.locator("header [data-social-scroll]:visible")).toHaveCount(0);
+ }
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 ({
+ test(`${width} px, a mouse: 0, 1, 3 and 4 marked links, only those shown, nothing scrolls, the wordmark's text where it fits`, async ({
page,
}) => {
- for (const count of [1, 3, 4] as const) {
+ for (const count of [0, 1, 3, 4] as const) {
writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
await narrowHeader(page, width, count, "mouse");
}
@@ -190,12 +207,13 @@ test("an icon pasted with a size and no viewBox renders with the viewBox made fr
}
});
-// The same icon is inlined twice (the header, the footer). Every copy's ids
-// are its own, and the header's copy paints its gradient from its own defs.
+// The same icon is inlined up to three times (the header's narrow and wide
+// rows, the footer). Every copy's ids are its own, and the visible header
+// copy paints its gradient from its own defs.
test("each copy of an icon owns its ids; the visible copy's gradient resolves inside it", async ({
page,
}) => {
- await page.setViewportSize({ width: 360, height: 800 });
+ await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
const dupes = await page.evaluate(() => {
const seen = new Map<string, number>();
@@ -223,10 +241,10 @@ test.describe("under a coarse pointer", () => {
test.use({ hasTouch: true });
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 ({
+ test(`${width} px, touch: 0, 1, 3 and 4 marked links, only those shown, nothing scrolls, the wordmark's text where it fits`, async ({
page,
}) => {
- for (const count of [1, 3, 4] as const) {
+ for (const count of [0, 1, 3, 4] as const) {
writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
await narrowHeader(page, width, count, "touch");
}
@@ -237,10 +255,10 @@ test.describe("under a coarse pointer", () => {
// fit, so the row scrolls inside its box — its END in view first, the toggle
// 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 ({
+ test("280 px, four marked links: the row scrolls in its box, its end first; focus brings each link into view", async ({
page,
}) => {
- writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ writeFixtureSettings(settingsFile(), LINK_SETS[4]);
await page.setViewportSize({ width: 280, height: 800 });
await page.goto("/");
const box = scrollBox(page);
@@ -268,7 +286,7 @@ test.describe("under a coarse pointer", () => {
test("320 px at a 200 % text size: the header does not scroll sideways; the last link's end and the toggle are in view", async ({
page,
}) => {
- writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ writeFixtureSettings(settingsFile(), LINK_SETS[4]);
await page.addInitScript(() => {
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.style.fontSize = "200%";
@@ -339,15 +357,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 at every width, the footer all six", async ({
+test("six links, the last marked: a narrow header shows it alone, a wide one the last four, the footer all six", async ({
page,
}) => {
writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
const six = FIXTURE_SOCIAL_SIX.map((l) => l.label);
- for (const width of [320, 360, 390, 767, 768, 1280]) {
+ for (const width of [320, 360, 390, 519, 520, 767, 768, 1280]) {
await page.setViewportSize({ width, height: 800 });
await page.goto("/");
- expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-4));
+ expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(width < 520 ? ["Disc"] : six.slice(-4));
expect(await labelsOf(footerRow(page))).toEqual(six);
await noHorizontalOverflow(page);
}
@@ -404,13 +422,72 @@ for (const width of [1024, 1280]) {
});
}
-test("six links, two marked for the header: the header shows those two", async ({ page }) => {
+test("six links, two marked: a narrow header shows those two; a wide one those two and the last two of the rest, in order", async ({
+ page,
+}) => {
writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX_FEATURED);
+ await page.setViewportSize({ width: 390, height: 800 });
await page.goto("/");
expect(await labelsOf(headerRow(page))).toEqual(["Square", "Bubble"]);
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page))).toEqual(["Square", "Triangle", "Bubble", "Disc"]);
expect(await labelsOf(footerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label));
});
+// 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 mark → the marked
+// link → the toggle. The footer keeps every link at every width.
+for (const touch of [false, true]) {
+ test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => {
+ test.use({ hasTouch: touch });
+ for (const width of [360, 390]) {
+ test(`${width} px: the full name, the marked link alone, then the toggle, in focus order`, async ({ page }) => {
+ 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();
+ const banner = page.getByRole("banner");
+ await expect(banner.getByRole("link", { name: "Disc", exact: true })).toHaveCount(1);
+ for (const other of ["Leaf", "Bubble"]) {
+ await expect(banner.getByRole("link", { name: other, exact: true })).toHaveCount(0);
+ }
+ expect(await labelsOf(footerRow(page))).toEqual(TRIO);
+ const order: string[] = [];
+ for (let i = 0; i < 3; i++) {
+ await page.keyboard.press("Tab");
+ order.push(
+ await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent ?? ""),
+ );
+ }
+ expect(order.slice(0, 2)).toEqual(["Archilyzer home", "Disc"]);
+ expect(order[2]).toMatch(/^Switch to /);
+ });
+ }
+ test("none marked: the narrow header has no social link, and the name shows", async ({ page }) => {
+ writeFixtureSettings(settingsFile(), unmarked(FIXTURE_SOCIAL_TRIO));
+ for (const width of [320, 360, 390]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(page.getByRole("link", { name: "Archilyzer home", exact: true }).locator("[data-wordmark]")).toBeVisible();
+ for (const l of TRIO) {
+ await expect(page.getByRole("banner").getByRole("link", { name: l, exact: true })).toHaveCount(0);
+ }
+ expect(await labelsOf(footerRow(page))).toEqual(TRIO);
+ }
+ });
+ });
+}
+
+test("nothing scrolls the page sideways from 280 to 1400 px, and the footer keeps every link", async ({ page }) => {
+ for (const width of [280, 300, 320, 360, 390, 430, 519, 520, 640, 767, 768, 1024, 1280, 1400]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await noHorizontalOverflow(page);
+ expect(await labelsOf(footerRow(page)), `${width} px`).toEqual(TRIO);
+ }
+});
+
test("no links: no row in the header, and no Elsewhere column in the footer", async ({
page,
}) => {