commit ba5ae2347fc02867852ddc27aa079bd6b16db7f5
parent e5b783d46c528ffcbd4f22366fd78fc0c01f23c4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 00:48:57 -0400
Merge r14/two-grounds-headers (release 14 slices T1, H1, H2) — two grounds, Light and Dark, and each site wears its own accent; the export and hub headers carry the social row and the theme toggle, link to the official instances, Changelog in the footer; on a narrow screen the header keeps the name and shows only the links marked for it; stacked bars take the surface gap, stacked areas keep their edge; reviewed SHIP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
110 files changed, 3962 insertions(+), 1591 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 (a narrow header shows fewer). 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:
@@ -484,7 +484,7 @@ Default:
## `homepageUrl`
-Absolute public URL of the family hub (e.g. "https://archilyzer-hub.pages.dev"). Every export site links back to it ("the family" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL.
+Absolute public URL of the family hub (e.g. "https://archilyzer-hub.pages.dev"). The default for every site's `hubUrl` (a site's own wins): published as `hubUrl` in the site's public `/site.json` and `/corpus.json`, so the hub can tell its member sites from arbitrary added origins. No page links to it (the header's Hub link was removed in release 14). Empty = none published. Normalized to a trailing-slash-free http(s) URL.
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 (a narrow header shows fewer). 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`
@@ -147,7 +147,7 @@ Default: absent
## `accent`
-Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.
+Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's accent on every page; a reader does not pick one. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.
Default: absent
@@ -208,6 +208,6 @@ Default: absent
## `hubUrl`
-Per-site override for the hub this site belongs under (the PWA it points visitors toward). Absent = the family default, `settings.json` `homepageUrl`. Surfaced on the public /site.json so a hub can tell member sites from arbitrary added origins.
+Per-site override for the hub this site belongs under. Absent = the family default, `settings.json` `homepageUrl`. Published on the public `/site.json` and `/corpus.json` so a hub can tell member sites from arbitrary added origins; the header does not link to it (release 14).
Default: absent
diff --git a/common/bin/gen-wordmark-metrics.py b/common/bin/gen-wordmark-metrics.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Write common/lib/wordmarkMetrics.ts: the advance of each Latin character
+Archivo maps, at the wordmark's two instances (wdth 118; wght 720 for the lead,
+380 for the suffix), measured from the vendored variable font
+(umtool/report-to-video/fonts/Archivo[wdth,wght].ttf, the same bytes the video
+lockup is outlined from).
+
+The export header reserves the wordmark's width from this table
+(common/lib/wordmarkWidth.ts), so whether a title fits beside the header's
+icons is decided by the display face's widths, before and after the web font
+loads: the fallback face is narrower, and a title that fitted in it would
+otherwise show and then vanish when Archivo arrived.
+
+ python3 common/bin/gen-wordmark-metrics.py # rewrite the module
+ python3 common/bin/gen-wordmark-metrics.py --stdout # print it
+
+Needs fontTools (pip install fonttools). The module records the fontTools
+version it was generated with.
+"""
+import hashlib
+import json
+import os
+import sys
+
+import fontTools
+from fontTools.ttLib import TTFont
+from fontTools.varLib import instancer
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.normpath(os.path.join(HERE, "..", ".."))
+FONT = "Archivo[wdth,wght].ttf"
+FONT_PATH = os.path.join(REPO, "umtool", "report-to-video", "fonts", FONT)
+OUT = os.path.join(REPO, "common", "lib", "wordmarkMetrics.ts")
+WEIGHTS = (720, 380)
+WDTH = 118
+# Basic Latin, Latin-1 and Latin Extended-A: what a site's title is written in.
+WANT = [cp for a, b in ((32, 126), (160, 383)) for cp in range(a, b + 1)]
+
+
+def runs(cps):
+ out = []
+ for cp in cps:
+ if out and cp == out[-1][1] + 1:
+ out[-1][1] = cp
+ else:
+ out.append([cp, cp])
+ return out
+
+
+def wrap(values, indent=" ", width=100):
+ lines, line = [], ""
+ for v in values:
+ s = f"{v},"
+ if line and len(indent) + len(line) + 1 + len(s) > width:
+ lines.append(indent + line)
+ line = s
+ else:
+ line = f"{line} {s}" if line else s
+ if line:
+ lines.append(indent + line)
+ return "\n".join(lines)
+
+
+def build():
+ raw = open(FONT_PATH, "rb").read()
+ sha = hashlib.sha256(raw).hexdigest()
+ base = TTFont(FONT_PATH)
+ upm = base["head"].unitsPerEm
+ mapped = base.getBestCmap()
+ cps = [cp for cp in WANT if cp in mapped]
+ adv = {}
+ for w in WEIGHTS:
+ inst = instancer.instantiateVariableFont(TTFont(FONT_PATH), {"wght": w, "wdth": WDTH})
+ cmap = inst.getBestCmap()
+ hmtx = inst["hmtx"].metrics
+ adv[w] = [hmtx[cmap[cp]][0] for cp in cps]
+ parts = [
+ "// GENERATED by common/bin/gen-wordmark-metrics.py -- do not edit; re-run it.",
+ "//",
+ f"// The advance, in font units, of each Latin character {FONT} maps, at the",
+ f"// wordmark's instances (wdth {WDTH}; wght {' and '.join(map(str, WEIGHTS))}):",
+ "// what lib/wordmarkWidth.ts reserves a title's width by.",
+ "export const WORDMARK_METRICS = {",
+ f" font: {json.dumps(FONT)},",
+ f" sha256: {json.dumps(sha)},",
+ f" fontTools: {json.dumps(fontTools.version)},",
+ f" unitsPerEm: {upm},",
+ f" wdth: {WDTH},",
+ " // [first, last] codepoint ranges; the advances below follow them in order.",
+ " ranges: [",
+ wrap([f"[{a}, {b}]" for a, b in runs(cps)], indent=" "),
+ " ] as ReadonlyArray<readonly [number, number]>,",
+ " advances: {",
+ ]
+ for w in WEIGHTS:
+ parts.append(f" {w}: [")
+ parts.append(wrap(adv[w]))
+ parts.append(" ],")
+ parts += [" } as Readonly<Record<720 | 380, readonly number[]>>,", "};", ""]
+ return "\n".join(parts)
+
+
+if __name__ == "__main__":
+ text = build()
+ if "--stdout" in sys.argv[1:]:
+ sys.stdout.write(text)
+ else:
+ with open(OUT, "w") as f:
+ f.write(text)
+ print(f"wrote {os.path.relpath(OUT)}")
diff --git a/common/components/BrandMark.test.ts b/common/components/BrandMark.test.ts
@@ -21,7 +21,7 @@ test("the ring's corner is the ground's: MARK_GROUND_RX of MARK_VIEWBOX", () =>
assert.equal(pct, 21.875);
assert.ok(BRAND_MARK_RING_CLASS.split(" ").includes(`rounded-[${pct}%]`));
// The ring is the dark variant only, 1px, spread with no blur or offset,
- // in the palette's ring colour; light and sepia get no shadow class at all.
+ // in the palette's ring colour; light gets no shadow class at all.
const shadows = BRAND_MARK_RING_CLASS.split(" ").filter((c) => c.includes("shadow"));
assert.deepEqual(shadows, ["dark:shadow-[0_0_0_1px_var(--mark-ring)]"]);
// The svg must not clip the ground a second time at that corner.
diff --git a/common/components/BrandMark.tsx b/common/components/BrandMark.tsx
@@ -11,7 +11,7 @@ export type BrandMarkPalette = Readonly<Record<MarkTone, string>>;
// clears it. So on dark — `.dark` on <html>, tokens.css's `@custom-variant
// dark` — the tile gets a 1px ring OUTSIDE it, in the palette's own dim
// (`--mark-ring`, set from `palette.dim` below), so the ring reads as part of
-// the mark. Light and sepia are untouched.
+// the mark. Light is untouched.
// - A box-shadow, so it takes no layout: the mark's size and the header's
// alignment are the same on every base.
// - `rounded-[21.875%]` is the ground's corner (MARK_GROUND_RX / MARK_VIEWBOX,
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,23 +48,28 @@ 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
- // key out by its inset so the first glyph lines up with the text.
- placement === "footer" && "-mx-2 pointer-coarse:-mx-3",
+ // key out by its inset so the first glyph lines up with the text. The
+ // footer shows every link, so its row wraps rather than widen the page.
+ placement === "footer" && "flex-wrap -mx-2 pointer-coarse:-mx-3",
className,
)}
>
diff --git a/common/components/SocialScroll.tsx b/common/components/SocialScroll.tsx
@@ -16,6 +16,10 @@ import { cn } from "../lib/utils";
// (`nearest`, inside the 4 px `scroll-padding`, so its ring shows). Chromium
// does not bring a partly hidden link into view on focus inside an rtl box.
//
+// `tabIndex={-1}`: Firefox makes a scroll container that overflows a tab stop
+// of its own, with no name. The links inside are the stops, and focus scrolls
+// them into view, so the box itself is taken out of the order.
+//
// Put the row's own 4 px inline padding on the row (SocialLinks' className,
// `px-1`) — the padding must be inside the scrolled content to be reachable —
// and this box cancels it with `-mx-1`, so the key after the box (the header's
@@ -30,6 +34,7 @@ export function SocialScroll({
return (
<div
data-social-scroll=""
+ tabIndex={-1}
className={cn(
"min-w-0 overflow-x-auto [direction:rtl] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden scroll-px-1 -mx-1 -my-1 py-1",
className,
diff --git a/common/components/ThemeMenu.tsx b/common/components/ThemeMenu.tsx
@@ -1,86 +0,0 @@
-"use client";
-
-import { Palette } from "lucide-react";
-import { useTheme } from "./ThemeProvider";
-import {
- THEME_BASES,
- accentOptions,
- isThemeAccent,
- isThemeBase,
-} from "./themeConfig";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuLabel,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
-import { cn } from "../lib/utils";
-
-// The theme picker: a palette dropdown with two radio groups — the BASE
-// (System / Light / Sepia / Dark) and the ACCENT (the seven named accents, the
-// site's own tagged "default"; a custom-hex site adds its colour first as
-// "Site colour"). Sits beside the quick base toggle (ThemeToggle). Picking only
-// changes attributes on <html>; tokens.css does the rest. The `menuitemradio`
-// names are an e2e contract. Must render inside a <ThemeProvider/>.
-export function ThemeMenu({ className }: { className?: string }) {
- const { base, accent, siteAccent, setBase, setAccent } = useTheme();
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger
- aria-label="Choose theme"
- title="Theme"
- className={cn(
- "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring",
- className,
- )}
- >
- <Palette className="h-4 w-4" aria-hidden="true" />
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="min-w-52">
- <DropdownMenuLabel>Base</DropdownMenuLabel>
- <DropdownMenuRadioGroup
- aria-label="Base"
- value={base}
- onValueChange={(v) => {
- if (isThemeBase(v)) setBase(v);
- }}
- >
- {THEME_BASES.map((b) => (
- <DropdownMenuRadioItem key={b.id} value={b.id}>
- {b.label}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- <DropdownMenuSeparator />
- <DropdownMenuLabel>Accent</DropdownMenuLabel>
- <DropdownMenuRadioGroup
- aria-label="Accent"
- value={accent}
- onValueChange={(v) => {
- if (isThemeAccent(v)) setAccent(v);
- }}
- >
- {accentOptions(siteAccent).map((o) => (
- <DropdownMenuRadioItem key={o.id} value={o.id}>
- <span
- aria-hidden="true"
- className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
- style={{ background: o.swatch }}
- />
- <span>{o.label}</span>
- {o.isSiteDefault && (
- <span className="ml-auto pl-3 font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
- default
- </span>
- )}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx
@@ -9,33 +9,34 @@ import {
useMemo,
useState,
} from "react";
-import { BASE_GROUNDS, DEFAULT_ACCENT, isAccentId, type AccentId } from "../lib/brand";
+import { BASE_GROUNDS, DEFAULT_ACCENT } from "../lib/brand";
import {
- ACCENT_KEY,
BASE_KEY,
LEGACY_MODE_KEY,
LEGACY_THEME_KEY,
+ RETIRED_BASE,
isThemeBase,
migrateLegacy,
nextBase,
resolveBase,
+ storedBase,
type ResolvedBase,
type ThemeAccent,
type ThemeBase,
} from "./themeConfig";
-// Runtime theme controller: the reader's BASE (light | sepia | dark | system)
-// and ACCENT (one of the seven, defaulting to the site's own).
+// Runtime theme controller: the reader's BASE (light | dark | system). The
+// ACCENT is the site's own (`siteAccent`), never the reader's: a stored pick
+// from before is neither read nor removed.
//
// The pre-paint <ThemeScript/> colours the FIRST paint by setting attributes on
// <html> from localStorage before hydration. Hydration can reset <html>'s
-// attributes to what the server rendered — the className without `.dark`, and
-// the SITE's `data-accent` over a reader's pick — so on mount this provider
-// RE-ASSERTS the persisted, resolved theme (data-base, .dark, data-accent,
-// theme-color) in a useLayoutEffect, before paint. That is idempotent: it
-// recomputes what the script computed from the same storage, so it never
-// fights the script and never flashes. After that it writes to the DOM only on
-// a reader's change and on a live OS-preference change.
+// attributes to what the server rendered — the className without `.dark` — so
+// on mount this provider RE-ASSERTS the persisted, resolved theme (data-base,
+// .dark, data-accent, theme-color) in a useLayoutEffect, before paint. That is
+// idempotent: it recomputes what the script computed from the same storage, so
+// it never fights the script and never flashes. After that it writes to the
+// DOM only on a reader's change and on a live OS-preference change.
type ThemeContextValue = {
/** The reader's choice, "system" included. */
@@ -44,16 +45,12 @@ type ThemeContextValue = {
resolvedBase: ResolvedBase;
/** Whether the dark base is applied. */
isDark: boolean;
- /** The accent applied: the reader's pick, else the site's. */
+ /** The accent applied: the site's own ("custom" for a site with its own
+ * hex). */
accent: ThemeAccent;
- /** The site's own accent ("custom" for a site with its own hex). */
- siteAccent: ThemeAccent;
setBase: (b: ThemeBase) => void;
- /** system → light → sepia → dark → system. */
+ /** system → light → dark → system. */
cycleBase: () => void;
- /** Picking the site's own accent REMOVES the stored pick, so the reader
- * follows the site's default from then on. */
- setAccent: (a: ThemeAccent) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -68,9 +65,10 @@ function systemPrefersDark(): boolean {
}
}
-// What storage holds, after the same one-time legacy migration the pre-paint
-// script runs (a no-op when the script already ran it).
-function readStored(): { base: ThemeBase | null; accent: AccentId | null } {
+// The stored base, after the same one-time migrations the pre-paint script
+// runs (a no-op when the script already ran them): the legacy keys, and a
+// stored RETIRED_BASE rewritten to "light".
+function readStoredBase(): ThemeBase | null {
try {
const s = window.localStorage;
let base = s.getItem(BASE_KEY);
@@ -87,13 +85,10 @@ function readStored(): { base: ThemeBase | null; accent: AccentId | null } {
s.removeItem(LEGACY_THEME_KEY);
s.removeItem(LEGACY_MODE_KEY);
}
- const accent = s.getItem(ACCENT_KEY);
- return {
- base: isThemeBase(base) ? base : null,
- accent: isAccentId(accent) ? accent : null,
- };
+ if (base === RETIRED_BASE) s.setItem(BASE_KEY, "light");
+ return storedBase(base);
} catch {
- return { base: null, accent: null };
+ return null;
}
}
@@ -107,46 +102,38 @@ function applyToDom(resolved: ResolvedBase, accent: ThemeAccent) {
}
}
-function store(key: string, value: string | null) {
+function store(key: string, value: string) {
try {
- if (value === null) window.localStorage.removeItem(key);
- else window.localStorage.setItem(key, value);
+ window.localStorage.setItem(key, value);
} catch {
/* storage disabled: the choice holds for this page only */
}
}
-// `pinAccent`: an app that offers no accent control (the homepage) keeps its
-// own accent whatever the reader stored for the apps that do; the stored value
-// is neither read nor removed. Pair with the same prop on ThemeScript.
export function ThemeProvider({
defaultBase = "system",
siteAccent = DEFAULT_ACCENT,
- pinAccent = false,
children,
}: {
defaultBase?: ThemeBase;
siteAccent?: ThemeAccent;
- pinAccent?: boolean;
children: React.ReactNode;
}) {
// Initial state MUST equal what the server rendered (the defaults), so
- // hydration matches; the persisted values are adopted just below.
+ // hydration matches; the persisted base is adopted just below.
const [base, setBaseState] = useState<ThemeBase>(defaultBase);
- const [accent, setAccentState] = useState<ThemeAccent>(siteAccent);
const [systemDark, setSystemDark] = useState(false);
const [adopted, setAdopted] = useState(false);
+ const accent = siteAccent;
// Adopt the persisted choice before paint. The state updates re-render
// synchronously, and the effect below then re-asserts it to <html>.
useLayoutEffect(() => {
- const stored = readStored();
- setBaseState(stored.base ?? defaultBase);
- setAccentState(pinAccent ? siteAccent : (stored.accent ?? siteAccent));
+ setBaseState(readStoredBase() ?? defaultBase);
setSystemDark(systemPrefersDark());
setAdopted(true);
- // Effectively once: the defaults are stable props from the layout.
- }, [defaultBase, siteAccent, pinAccent]);
+ // Effectively once: the default is a stable prop from the layout.
+ }, [defaultBase]);
// A LIVE OS preference: "system" follows a change made while the page is
// open (the pre-paint script can only read it once).
@@ -180,34 +167,16 @@ export function ThemeProvider({
const cycleBase = useCallback(() => setBase(nextBase(base)), [base, setBase]);
- const setAccent = useCallback(
- (a: ThemeAccent) => {
- if (a === siteAccent) {
- setAccentState(a);
- store(ACCENT_KEY, null);
- return;
- }
- // "custom" only ever means THIS site's own colour (above); a named
- // accent is the only thing a reader can store.
- if (!isAccentId(a)) return;
- setAccentState(a);
- store(ACCENT_KEY, a);
- },
- [siteAccent],
- );
-
const value = useMemo<ThemeContextValue>(
() => ({
base,
resolvedBase,
isDark: resolvedBase === "dark",
accent,
- siteAccent,
setBase,
cycleBase,
- setAccent,
}),
- [base, resolvedBase, accent, siteAccent, setBase, cycleBase, setAccent],
+ [base, resolvedBase, accent, setBase, cycleBase],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx
@@ -1,119 +0,0 @@
-"use client";
-
-import { useTheme } from "./ThemeProvider";
-import {
- THEME_BASES,
- accentOptions,
- isThemeAccent,
- isThemeBase,
-} from "./themeConfig";
-
-// The theme's two choices as NATIVE radio groups: "Base" (System / Light /
-// Sepia / Dark) and "Accent" (the seven named accents, the site's own tagged
-// "default"; a custom-hex site adds "Site colour" first) — the same lists
-// ThemeMenu's dropdown offers. Used where a dropdown inside another layer would
-// be a focus-trap fight: the export's slide-out menu. A pick applies at once (attributes on <html>;
-// tokens.css does the rest) and nothing closes.
-//
-// Renders the two groups as siblings, each `groupClassName`, so a caller lays
-// them out. The radiogroups are named "Base" and "Accent", and each radio by its
-// label — an e2e contract. `namePrefix` keeps two instances on one page from
-// sharing a radio group. Must render inside a <ThemeProvider/>.
-export function ThemeRadios({
- namePrefix,
- groupClassName,
-}: {
- namePrefix: string;
- groupClassName?: string;
-}) {
- const { base, accent, siteAccent, setBase, setAccent } = useTheme();
- return (
- <>
- <div className={groupClassName}>
- <GroupHeading>Base</GroupHeading>
- <RadioList
- name={`${namePrefix}-base`}
- label="Base"
- value={base}
- options={THEME_BASES}
- onPick={(v) => {
- if (isThemeBase(v)) setBase(v);
- }}
- />
- </div>
- <div className={groupClassName}>
- <GroupHeading>Accent</GroupHeading>
- <RadioList
- name={`${namePrefix}-accent`}
- label="Accent"
- value={accent}
- options={accentOptions(siteAccent)}
- onPick={(v) => {
- if (isThemeAccent(v)) setAccent(v);
- }}
- />
- </div>
- </>
- );
-}
-
-function GroupHeading({ children }: { children: React.ReactNode }) {
- return (
- <p className="px-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- {children}
- </p>
- );
-}
-
-function RadioList({
- name,
- label,
- value,
- options,
- onPick,
-}: {
- name: string;
- label: string;
- value: string;
- options: ReadonlyArray<{
- id: string;
- label: string;
- // An accent's dot (a CSS colour) and whether it is the site's own.
- swatch?: string;
- isSiteDefault?: boolean;
- }>;
- onPick: (id: string) => void;
-}) {
- return (
- <div role="radiogroup" aria-label={label} className="mt-1 flex flex-col">
- {options.map((o) => (
- <label
- key={o.id}
- className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent"
- >
- <input
- type="radio"
- name={name}
- value={o.id}
- checked={value === o.id}
- onChange={() => onPick(o.id)}
- className="size-4 accent-[var(--brand)]"
- />
- {o.swatch && (
- <span
- aria-hidden="true"
- className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
- style={{ background: o.swatch }}
- />
- )}
- {o.label}
- {o.isSiteDefault && (
- <span className="ml-auto font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
- default
- </span>
- )}
- </label>
- ))}
- </div>
- );
-}
diff --git a/common/components/ThemeScript.tsx b/common/components/ThemeScript.tsx
@@ -2,32 +2,23 @@ import { buildThemeScript, type ThemeBase } from "./themeConfig";
// Server component (NOT "use client"): renders an inline <script> that runs
// synchronously BEFORE first paint, so there is no flash of the wrong theme.
-// From localStorage it migrates the retired theme/mode keys once, then sets
-// `data-base` (the resolved light | sepia | dark ground) and `.dark` on <html>,
-// `data-accent` when the reader stored an accent of their own, the browser
-// chrome colour, and — last — the `data-theme-ready` marker the e2e no-flash
-// tests wait for. The script's source and its order are
-// themeConfig.buildThemeScript (unit-tested in node:vm).
+// From localStorage it migrates the retired theme/mode keys once and a stored
+// retired base to "light", then sets `data-base` (the resolved light | dark
+// ground) and `.dark` on <html>, the browser chrome colour, and — last — the
+// `data-theme-ready` marker the e2e no-flash tests wait for. The script's
+// source and its order are themeConfig.buildThemeScript (unit-tested in
+// node:vm).
//
-// The site's OWN accent is not this script's business: the layout
-// server-renders it as `<html data-accent>`, and the script only replaces it
-// with a reader's valid stored pick. Works identically for static-export sites
-// and the server editor. Pair with `suppressHydrationWarning` on <html>, since
-// this mutates the element before React hydrates — and with ThemeProvider,
-// which re-asserts the same attributes after hydration.
-//
-// `pinAccent` (the homepage, which offers no accent control): the stored
-// accent is not read; see buildThemeScript.
-export function ThemeScript({
- defaultBase = "system",
- pinAccent = false,
-}: {
- defaultBase?: ThemeBase;
- pinAccent?: boolean;
-}) {
+// The accent is not this script's business: the layout server-renders the
+// site's own as `<html data-accent>`, and a reader's stored pick from before
+// is not read. Works identically for static-export sites and the server
+// editor. Pair with `suppressHydrationWarning` on <html>, since this mutates
+// the element before React hydrates — and with ThemeProvider, which
+// re-asserts the same attributes after hydration.
+export function ThemeScript({ defaultBase = "system" }: { defaultBase?: ThemeBase }) {
return (
<script
- dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase, pinAccent }) }}
+ dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }}
suppressHydrationWarning
/>
);
diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx
@@ -1,19 +1,19 @@
"use client";
-import { BookOpen, Monitor, Moon, Sun } from "lucide-react";
+import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { nextBase, type ThemeBase } from "./themeConfig";
import { cn } from "../lib/utils";
-// The quick base toggle: cycles system → light → sepia → dark → system. The
-// icon shows the base in force, the label names the next one ("Switch to …" —
-// the e2e specs find it by /switch to/i). The accent lives in ThemeMenu. Must
-// be rendered inside a <ThemeProvider/>.
-const ICON = { system: Monitor, light: Sun, sepia: BookOpen, dark: Moon } as const;
+// The theme control: cycles the base, system → light → dark → system
+// (themeConfig THEME_BASES). The icon shows the base in force, the label names
+// the next one ("Switch to …" — the e2e specs find it by /switch to/i). There
+// is no accent control: each site shows its own. Must be rendered inside a
+// <ThemeProvider/>.
+const ICON = { system: Monitor, light: Sun, dark: Moon } as const;
const LABEL: Record<ThemeBase, string> = {
system: "system",
light: "light",
- sepia: "sepia",
dark: "dark",
};
diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx
@@ -20,11 +20,14 @@ export function Wordmark({
lead,
className,
leadStyle,
+ style,
}: {
title: string;
lead?: string;
className?: string;
leadStyle?: React.CSSProperties;
+ // Laid over the outer span's own (the export header reserves its width).
+ style?: React.CSSProperties;
}) {
const parts = splitWordmark(title, lead);
return (
@@ -34,6 +37,7 @@ export function Wordmark({
style={{
fontFamily: "var(--font-display)",
fontStretch: "118%",
+ ...style,
}}
>
<span
diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx
@@ -26,6 +26,7 @@ import {
import { useMediaQuery } from "../../lib/useMediaQuery";
import type { ChartData } from "../../lib/chartAggregate";
import { xAxisLabel, yAxisLabel, type ChartConfig } from "../../lib/chartConfig";
+import { BAR_GAP } from "./surfaceGap";
const AXIS_LABEL_STYLE = { fill: "var(--muted-foreground)", fontSize: 11 };
@@ -187,6 +188,7 @@ export function ChartView({
name={m.label}
fill={`var(--color-${m.id})`}
radius={2}
+ {...(config.type === "stackedBar" && meta.length > 1 ? BAR_GAP : {})}
stackId={config.type === "stackedBar" ? "a" : undefined}
/>
))}
diff --git a/common/components/charts/CrossSiteChart.tsx b/common/components/charts/CrossSiteChart.tsx
@@ -22,6 +22,7 @@ import { ChartMessage } from "./ChartMessage";
import type { HomepageSummary } from "../../lib/homepageSummary";
import { type ChartState } from "../../lib/homepageChart";
import { buildTimeSeries } from "../../lib/homepageChartData";
+import { BAR_GAP } from "./surfaceGap";
// The stacked renderer: area (cumulative/Growth) or bars, optionally 100%-share.
// Stacking shows per-series composition *and* the combined total at once — the
@@ -67,6 +68,9 @@ export function CrossSiteChart({
}
const isArea = state.chartType === "area";
+ // The marks spec's surface gap between touching bar segments
+ // (charts/surfaceGap.ts): only when there is more than one series to part.
+ const stacked = series.length > 1;
const isShare = state.valueMode === "share";
const stackOffset = isShare ? "expand" : undefined;
const showLegend = state.breakdown === "channel" && series.length > 1;
@@ -133,6 +137,7 @@ export function CrossSiteChart({
dataKey={s.id}
name={s.label}
fill={s.color}
+ {...(stacked ? BAR_GAP : {})}
stackId="a"
isAnimationActive={false}
/>
diff --git a/common/components/charts/surfaceGap.ts b/common/components/charts/surfaceGap.ts
@@ -0,0 +1,14 @@
+// THE SURFACE GAP (the marks spec) between touching BARS: the segments of a
+// stacked bar are parted by a 2 px gap in the colour behind the plot, never by
+// a line drawn around them. `--chart-gap` is that colour (tokens.css: the
+// chart surface, and the reader's Canvas in forced colours). Recharts draws in
+// CSS pixels, so a width here is a width on screen. A segment's stroke is
+// centred on its edge: 1 px inside each of two touching segments makes the
+// 2 px gap (the outer edges meet the surface, so nothing shows there).
+//
+// Stacked AREAS keep their series-coloured top edge (ruled after review,
+// 2026-09-28): a surface stroke along a band's top also runs along the stack's
+// upper edge and over any band under ~2 px, and on translucent fills it erased
+// small values and cut peaks. A gap there waits on opaque fills (a ruling), a
+// gap on inner boundaries only, and the growth chart's thin-band rule.
+export const BAR_GAP = { stroke: "var(--chart-gap)", strokeWidth: 2 } as const;
diff --git a/common/components/themeConfig.test.ts b/common/components/themeConfig.test.ts
@@ -1,25 +1,25 @@
// The pre-paint theme script, run for real: buildThemeScript's string executed
// in node:vm against a fake <html>, localStorage, matchMedia and theme-color
// metas, over the whole legacy-migration matrix. The expected values come from
-// the pure helpers (migrateLegacy, resolveBase), so the inline script and the
-// runtime provider cannot drift apart.
+// the pure helpers (migrateLegacy, storedBase, resolveBase), so the inline
+// script and the runtime provider cannot drift apart.
import { test } from "node:test";
import assert from "node:assert/strict";
import vm from "node:vm";
-import { ACCENT_IDS, BASE_GROUNDS, isAccentId } from "../lib/brand";
+import { ACCENT_IDS, BASE_GROUNDS } from "../lib/brand";
import {
ACCENT_KEY,
BASE_KEY,
LEGACY_MODE_KEY,
LEGACY_THEME_KEY,
+ RETIRED_BASE,
THEME_BASES,
buildThemeScript,
- isThemeBase,
- isThemeAccent,
migrateLegacy,
nextBase,
resolveBase,
+ storedBase,
type ThemeBase,
} from "./themeConfig";
@@ -28,6 +28,8 @@ type Env = {
prefersDark: boolean;
serverAccent?: string;
storageThrows?: boolean;
+ // Reads work, writes throw (a full or read-only storage).
+ writeThrows?: boolean;
matchMediaThrows?: boolean;
};
@@ -73,8 +75,8 @@ function run(defaultBase: ThemeBase, env: Env): Result {
? { getItem: fail, setItem: fail, removeItem: fail }
: {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
- setItem: (k: string, v: string) => void store.set(k, String(v)),
- removeItem: (k: string) => void store.delete(k),
+ setItem: (k: string, v: string) => (env.writeThrows ? fail() : void store.set(k, String(v))),
+ removeItem: (k: string) => (env.writeThrows ? fail() : void store.delete(k)),
};
ctx.window = {
get localStorage() {
@@ -109,12 +111,13 @@ function run(defaultBase: ThemeBase, env: Env): Result {
const THEMES = [null, "base", "archive", "selenized", "swiss", "archilyzer", "terminal"];
const MODES = [null, "light", "dark", "system", "bogus"];
-const BASES = [null, "light", "sepia", "dark", "system", "bogus"];
+const BASES = [null, "light", RETIRED_BASE, "dark", "system", "bogus"];
const DEFAULTS: ThemeBase[] = ["system", "light", "dark"];
test("migrateLegacy follows the plan's table", () => {
assert.equal(migrateLegacy({ theme: null, mode: "light" }), "light");
- assert.equal(migrateLegacy({ theme: "archive", mode: "light" }), "sepia");
+ // The old "archive" paper theme was the retired third ground: light now.
+ assert.equal(migrateLegacy({ theme: "archive", mode: "light" }), "light");
assert.equal(migrateLegacy({ theme: "selenized", mode: "light" }), "light");
assert.equal(migrateLegacy({ theme: "archive", mode: "dark" }), "dark");
assert.equal(migrateLegacy({ theme: "selenized", mode: "dark" }), "dark");
@@ -140,18 +143,21 @@ test("the pre-paint script over the whole legacy × stored-base × default × OS
const label = JSON.stringify({ theme, mode, base, defaultBase, prefersDark });
// 1. Migration: only when no base is stored; legacy keys always go.
+ // 2. The retired base is rewritten to light, in storage too.
const migrated = base === null ? migrateLegacy({ theme, mode }) : null;
- const storedAfter = base ?? migrated;
+ const storedAfter = base === RETIRED_BASE ? "light" : (base ?? migrated);
assert.equal(r.store.get(BASE_KEY) ?? null, storedAfter, `${label}: stored base`);
assert.ok(!r.store.has(LEGACY_THEME_KEY), `${label}: legacy theme key kept`);
assert.ok(!r.store.has(LEGACY_MODE_KEY), `${label}: legacy mode key kept`);
- // 2–3. Validated base, resolved against the OS.
- const effective: ThemeBase = isThemeBase(storedAfter) ? storedAfter : defaultBase;
+ // 3–4. Validated base, resolved against the OS.
+ const effective: ThemeBase = storedBase(storedAfter) ?? defaultBase;
const resolved = resolveBase(effective, prefersDark);
assert.equal(r.attrs.get("data-base"), resolved, `${label}: data-base`);
assert.equal(r.dark, resolved === "dark", `${label}: .dark`);
+ assert.ok(resolved === "light" || resolved === "dark", `${label}: a third ground`);
+
// 5. Browser chrome follows the resolved ground.
assert.deepEqual(r.metas, [BASE_GROUNDS[resolved], BASE_GROUNDS[resolved]], `${label}: theme-color`);
@@ -163,18 +169,37 @@ test("the pre-paint script over the whole legacy × stored-base × default × OS
assert.equal(cases, THEMES.length * MODES.length * BASES.length * DEFAULTS.length * 2);
});
-test("data-accent comes only from a valid stored id; otherwise the server's stays", () => {
+test("a stored accent is ignored: the server's data-accent stays, and the key is left in place", () => {
for (const stored of [null, ...ACCENT_IDS, "custom", "#cc3366", "bogus", ""])
for (const serverAccent of [undefined, "brass", "custom"]) {
const r = run("system", { storage: { [ACCENT_KEY]: stored }, prefersDark: false, serverAccent });
- const expected = stored && isAccentId(stored) ? stored : serverAccent;
- assert.equal(r.attrs.get("data-accent"), expected, JSON.stringify({ stored, serverAccent }));
- // The script never writes the accent key.
- assert.equal(r.store.get(ACCENT_KEY) ?? null, stored);
+ const label = JSON.stringify({ stored, serverAccent });
+ assert.equal(r.attrs.get("data-accent"), serverAccent, label);
+ assert.ok(!r.log.includes("data-accent"), `${label}: the script wrote data-accent`);
+ assert.equal(r.store.get(ACCENT_KEY) ?? null, stored, `${label}: the stored accent moved`);
+ assert.equal(r.log.at(-1), "data-theme-ready");
+ }
+ assert.ok(!buildThemeScript().includes(ACCENT_KEY));
+});
+
+test("the retired base paints light even when storage will not take the rewrite", () => {
+ for (const defaultBase of DEFAULTS)
+ for (const prefersDark of [false, true]) {
+ const r = run(defaultBase, { storage: { [BASE_KEY]: RETIRED_BASE }, prefersDark, writeThrows: true });
+ assert.equal(r.attrs.get("data-base"), "light");
+ assert.equal(r.dark, false);
assert.equal(r.log.at(-1), "data-theme-ready");
}
});
+test("storedBase: light, dark and system, the retired one as light, anything else null", () => {
+ assert.equal(storedBase("light"), "light");
+ assert.equal(storedBase("dark"), "dark");
+ assert.equal(storedBase("system"), "system");
+ assert.equal(storedBase(RETIRED_BASE), "light");
+ for (const v of [null, "", "bogus", "Light", 1]) assert.equal(storedBase(v), null);
+});
+
test("storage that throws still paints the default base and sets the marker", () => {
for (const defaultBase of DEFAULTS)
for (const prefersDark of [false, true]) {
@@ -199,32 +224,14 @@ test("an invalid defaultBase falls back to system", () => {
assert.equal(r.attrs.get("data-base"), "dark");
});
-test("the toggle cycles system → light → sepia → dark → system, in picker order", () => {
+test("the toggle cycles system → light → dark → system, in THEME_BASES order", () => {
const seen: ThemeBase[] = [];
let b: ThemeBase = "system";
- for (let i = 0; i < 4; i++) {
+ for (let i = 0; i < 3; i++) {
seen.push(b);
b = nextBase(b);
}
assert.equal(b, "system");
assert.deepEqual(seen, THEME_BASES.map((x) => x.id));
- assert.deepEqual(THEME_BASES.map((x) => x.label), ["System", "Light", "Sepia", "Dark"]);
-});
-
-test("isThemeAccent takes the seven ids and custom only", () => {
- for (const id of ACCENT_IDS) assert.ok(isThemeAccent(id));
- assert.ok(isThemeAccent("custom"));
- assert.ok(!isThemeAccent("#cc3366"));
- assert.ok(!isThemeAccent("Signal"));
-});
-
-// The homepage offers no accent control, so its script does not read the
-// stored accent (pinAccent); every other app's script is what it was.
-test("pinAccent: the stored accent is never read; without it the script is unchanged", () => {
- const read = `getItem(${JSON.stringify(ACCENT_KEY)})`;
- for (const defaultBase of ["system", "dark"] as const) {
- assert.equal(buildThemeScript({ defaultBase, pinAccent: false }), buildThemeScript({ defaultBase }));
- assert.ok(buildThemeScript({ defaultBase }).includes(read));
- assert.ok(!buildThemeScript({ defaultBase, pinAccent: true }).includes(read));
- }
+ assert.deepEqual(THEME_BASES.map((x) => x.label), ["System", "Light", "Dark"]);
});
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -1,33 +1,29 @@
// Shared, dependency-light theme constants, types and the pre-paint script's
// source. Used by the pre-paint ThemeScript (server), the runtime ThemeProvider
-// (client), the pickers, the unit tests and the homepage e2e (REQUIRED_TOKENS).
+// (client), the toggle, the unit tests and the homepage e2e (REQUIRED_TOKENS).
// Keys are namespaced under the existing `ytdlp-tb:*` localStorage convention.
//
-// A READER THEME IS TWO INDEPENDENT CHOICES (plans/brand-and-themes.md):
-// • a BASE — a light, sepia or dark ground, or "system", which follows the
-// OS between light and dark. `html[data-base]` selects one of the three
+// THE THEME (plans/brand-and-themes.md; two grounds since release 14, T1):
+// • the reader's BASE — the light or the dark ground, or "system", which
+// follows the OS between them. `html[data-base]` selects one of the two
// token blocks in common/styles/tokens.css; `.dark` is on <html> iff the
-// resolved base is dark, so Tailwind's `dark:` utilities keep working and
-// sepia styles as a light ground.
-// • an ACCENT — one of the seven named accents in lib/brand.ts. Each site
-// defaults to its own (a server-rendered `html[data-accent]`); a reader's
-// pick is stored only while it differs from the site's.
+// resolved base is dark, so Tailwind's `dark:` utilities keep working. A
+// stored RETIRED_BASE (the third ground, retired in release 14) is read
+// as, and rewritten to, "light".
+// • the ACCENT — one of the seven named accents in lib/brand.ts, or a
+// site's own hex. It is the SITE's (a server-rendered `html[data-accent]`),
+// never the reader's: a reader's stored pick from before is ignored, and
+// left in storage.
//
// PURE: no React, no DOM at import time. lib/brand.ts is pure too.
-import {
- ACCENTS,
- ACCENT_IDS,
- BASE_GROUNDS,
- isAccentId,
- type AccentId,
-} from "../lib/brand";
+import { BASE_GROUNDS, type AccentId } from "../lib/brand";
-// The reader's base ("light" | "sepia" | "dark" | "system").
+// The reader's base ("light" | "dark" | "system"; RETIRED_BASE, from before
+// release 14, is migrated to "light").
export const BASE_KEY = "ytdlp-tb:base";
-// The reader's accent: an accent id, stored only while it differs from the
-// site's own. (Before the base × accent themes this key held a hex that
-// nothing in the UI wrote; a stored value that is not an accent id is ignored.)
+// A reader's accent pick from before release 14. Nothing reads it and nothing
+// deletes it: each app paints its own accent.
export const ACCENT_KEY = "ytdlp-tb:accent";
// The retired theme-family and light/dark-mode keys. Read once by the
// migration (migrateLegacy, and the same table inside the pre-paint script),
@@ -35,59 +31,41 @@ export const ACCENT_KEY = "ytdlp-tb:accent";
export const LEGACY_THEME_KEY = "ytdlp-tb:theme";
export const LEGACY_MODE_KEY = "ytdlp-tb:mode";
-// The three grounds a base resolves to, and the reader's choice (which adds
+// The two grounds a base resolves to, and the reader's choice (which adds
// "system").
-export type ResolvedBase = "light" | "sepia" | "dark";
+export type ResolvedBase = "light" | "dark";
export type ThemeBase = ResolvedBase | "system";
+// The third ground, retired in release 14: a stored base of this value is
+// light. The one place its name is spelled.
+export const RETIRED_BASE = "sepia";
+
// What `html[data-accent]` can carry: a named accent, or "custom" — a site
// whose site.json accent is its own hex (the inline `--accent-custom-*` vars).
-// A reader can never STORE "custom": it only ever means "this site's colour".
export type ThemeAccent = AccentId | "custom";
-// Picker order.
+// The toggle's cycle order, and every base a reader can store.
export const THEME_BASES: ReadonlyArray<{ id: ThemeBase; label: string }> = [
{ id: "system", label: "System" },
{ id: "light", label: "Light" },
- { id: "sepia", label: "Sepia" },
{ id: "dark", label: "Dark" },
];
export function isThemeBase(v: unknown): v is ThemeBase {
- return v === "system" || v === "light" || v === "sepia" || v === "dark";
-}
-
-export function isThemeAccent(v: unknown): v is ThemeAccent {
- return v === "custom" || isAccentId(v);
+ return v === "system" || v === "light" || v === "dark";
}
-// The accent choices a picker offers on a site whose own accent is
-// `siteAccent`: the seven named accents in brand order, each with its swatch
-// (`--swatch-<id>` is that accent's value on the base in force, so the dot
-// shows what the pick will paint) and a flag on the site's own. A custom-hex
-// site adds its colour first, as "Site colour".
-export type AccentOption = {
- id: ThemeAccent;
- label: string;
- swatch: string;
- isSiteDefault: boolean;
-};
-
-export function accentOptions(siteAccent: ThemeAccent): AccentOption[] {
- const named: AccentOption[] = ACCENT_IDS.map((id) => ({
- id,
- label: ACCENTS[id].name,
- swatch: `var(--swatch-${id})`,
- isSiteDefault: id === siteAccent,
- }));
- return siteAccent === "custom"
- ? [{ id: "custom", label: "Site colour", swatch: "var(--swatch-custom)", isSiteDefault: true }, ...named]
- : named;
+// What a stored base means: a base, RETIRED_BASE → "light", anything else null
+// (the app's default applies).
+export function storedBase(v: unknown): ThemeBase | null {
+ if (v === RETIRED_BASE) return "light";
+ return isThemeBase(v) ? v : null;
}
-// ThemeToggle's cycle: system → light → sepia → dark → system.
+// ThemeToggle's cycle, THEME_BASES in order: system → light → dark → system.
export function nextBase(b: ThemeBase): ThemeBase {
- return b === "system" ? "light" : b === "light" ? "sepia" : b === "sepia" ? "dark" : "system";
+ const i = THEME_BASES.findIndex((t) => t.id === b);
+ return THEME_BASES[(i + 1) % THEME_BASES.length].id;
}
// A choice resolved against the OS preference.
@@ -158,21 +136,21 @@ export const REQUIRED_TOKENS = [
// by the caller, whatever it returned.
//
// stored mode result
-// light light — or sepia when the old theme was "archive" (paper)
+// light light (the old "archive" paper theme too: it was the
+// third ground until that was retired)
// dark dark
// system system
// absent/other null: nothing is stored and the app's default base applies
//
// The old theme FAMILY is otherwise dropped: every family retired, and the
-// accent is the site's again.
+// accent is the site's.
export function migrateLegacy({
- theme,
mode,
}: {
theme: string | null;
mode: string | null;
}): ThemeBase | null {
- if (mode === "light") return theme === "archive" ? "sepia" : "light";
+ if (mode === "light") return "light";
if (mode === "dark") return "dark";
if (mode === "system") return "system";
return null;
@@ -182,54 +160,48 @@ export function migrateLegacy({
// synchronously before first paint, in this order:
// 1. when no base is stored yet, migrate the legacy keys (migrateLegacy's
// table); then delete both legacy keys;
-// 2. validate the stored base, falling back to `defaultBase`;
-// 3. set `data-base` to the RESOLVED ground and toggle `.dark`;
-// 4. set `data-accent` only from a valid stored accent id — otherwise the
-// server-rendered default (the site's own accent) stays;
+// 2. a stored RETIRED_BASE becomes "light", in storage too (once);
+// 3. validate the stored base, falling back to `defaultBase`;
+// 4. set `data-base` to the RESOLVED ground and toggle `.dark`;
// 5. point every `meta[name=theme-color]` at the resolved ground;
// 6. LAST: `data-theme-ready="1"`, the e2e no-flash marker, so it is present
// only once every attribute above is set. e2e's reloads resolve on
// navigation commit, possibly before this head script has run, and wait
// for the marker instead of racing.
+// The accent is not read: `html[data-accent]` is the server's (the site's
+// own), and a stored ACCENT_KEY stays where it is, unread.
// Storage may throw (privacy modes): each storage touch is guarded, so a
// failure still paints the default base and still sets the marker.
// themeConfig.test.ts runs this string in node:vm over the whole matrix.
-//
-// `pinAccent`: an app with no accent control of its own (the homepage) never
-// reads the stored accent — step 4 is skipped, the server-rendered accent
-// stays, and the reader's stored value is left where it is for the apps that
-// offer the choice. Without it the script is exactly what it was.
export function buildThemeScript({
defaultBase = "system",
- pinAccent = false,
-}: { defaultBase?: ThemeBase; pinAccent?: boolean } = {}): string {
+}: { defaultBase?: ThemeBase } = {}): string {
const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system";
const q = JSON.stringify;
return (
"(function(){try{" +
- "var d=document.documentElement,b=null,a=null,s;" +
+ "var d=document.documentElement,b=null,s;" +
"try{s=window.localStorage;" +
`b=s.getItem(${q(BASE_KEY)});` +
`var lt=s.getItem(${q(LEGACY_THEME_KEY)}),lm=s.getItem(${q(LEGACY_MODE_KEY)});` +
"if(lt!==null||lm!==null){" +
"if(b===null){" +
- "var m=lm==='light'?(lt==='archive'?'sepia':'light'):lm==='dark'?'dark':lm==='system'?'system':null;" +
+ "var m=lm==='light'?'light':lm==='dark'?'dark':lm==='system'?'system':null;" +
`if(m){s.setItem(${q(BASE_KEY)},m);b=m;}` +
"}" +
`s.removeItem(${q(LEGACY_THEME_KEY)});s.removeItem(${q(LEGACY_MODE_KEY)});` +
"}" +
- (pinAccent ? "" : `a=s.getItem(${q(ACCENT_KEY)});`) +
+ `if(b===${q(RETIRED_BASE)}){b='light';s.setItem(${q(BASE_KEY)},'light');}` +
"}catch(e){}" +
- `if(b!=='light'&&b!=='sepia'&&b!=='dark'&&b!=='system')b=${q(fallback)};` +
+ `if(b===${q(RETIRED_BASE)})b='light';` +
+ `if(b!=='light'&&b!=='dark'&&b!=='system')b=${q(fallback)};` +
"var r=b;" +
"if(b==='system'){r='light';try{if(window.matchMedia('(prefers-color-scheme: dark)').matches)r='dark';}catch(e){}}" +
"d.setAttribute('data-base',r);" +
"d.classList.toggle('dark',r==='dark');" +
- `if(a&&${q(ACCENT_IDS)}.indexOf(a)>=0)d.setAttribute('data-accent',a);` +
`var g=${q(BASE_GROUNDS)}[r];` +
"try{var ms=document.querySelectorAll('meta[name=\"theme-color\"]');for(var i=0;i<ms.length;i++)ms[i].setAttribute('content',g);}catch(e){}" +
"d.setAttribute('data-theme-ready','1');" +
"}catch(e){}})();"
);
}
-
diff --git a/common/components/themeTokens.test.ts b/common/components/themeTokens.test.ts
@@ -69,16 +69,22 @@ function rule(selector: string): Rule {
const BASE_SELECTOR: Record<BaseGround, string> = {
light: ':root, html[data-base="light"]',
- sepia: 'html[data-base="sepia"]',
dark: 'html[data-base="dark"]',
};
-const ON: Record<BaseGround, "onLight" | "onSepia" | "onDark"> = {
+const ON: Record<BaseGround, "onLight" | "onDark"> = {
light: "onLight",
- sepia: "onSepia",
dark: "onDark",
};
+test("two bases: tokens.css has a block for light and dark and none for any other", () => {
+ const blocks = RULES.map((r) => r.selector.match(/html\[data-base="([^"]+)"\]/g) ?? [])
+ .flat()
+ .map((sel) => sel.slice('html[data-base="'.length, -2));
+ assert.deepEqual([...new Set(blocks)].sort(), ["dark", "light"]);
+ assert.deepEqual([...BASE_GROUND_IDS], ["light", "dark"]);
+});
+
test("every base declares every required token, color-scheme and every swatch", () => {
for (const base of BASE_GROUND_IDS) {
const r = rule(BASE_SELECTOR[base]);
@@ -92,7 +98,7 @@ test("every base declares every required token, color-scheme and every swatch",
}
});
-test("--base-light|sepia|dark are 1 on their own base and 0 on the others", () => {
+test("--base-light|dark are 1 on their own base and 0 on the other", () => {
// lib/siteColor.ts perBaseColor multiplies each base's channel by these, so
// exactly one may be 1 — two would add two colours' channels together.
for (const base of BASE_GROUND_IDS) {
@@ -142,7 +148,7 @@ test("each base defaults --brand to Signal, keeps --primary neutral and rings in
assert.equal(r.decls.get("--brand"), "var(--swatch-signal)");
assert.equal(r.decls.get("--ring"), "var(--brand)");
assert.doesNotMatch(r.decls.get("--primary") ?? "", /brand|swatch/);
- assert.equal(r.decls.get("--primary"), base === "dark" ? "#efe7d8" : base === "sepia" ? "#33281a" : "#202a31");
+ assert.equal(r.decls.get("--primary"), base === "dark" ? "#efe7d8" : "#202a31");
assert.equal(r.decls.get("--brand-ink"), base === "dark" ? BASE_GROUNDS.dark : "#ffffff");
assert.match(r.decls.get("--brand-strong") ?? "", base === "dark" ? /var\(--brand\).*white/ : /var\(--brand\).*black/);
assert.match(r.decls.get("--brand-soft") ?? "", base === "dark" ? /var\(--brand\) 16%/ : /var\(--brand\) 12%/);
@@ -271,5 +277,5 @@ test("status text reads at 4.5:1 on its own soft fill, on every base", () => {
}
}
}
- assert.equal(checks, 30); // 3 bases × 2 grounds × 5 pairs
+ assert.equal(checks, 20); // 2 bases × 2 grounds × 5 pairs
});
diff --git a/common/components/ui/alert.tsx b/common/components/ui/alert.tsx
@@ -5,7 +5,7 @@ import { cn } from "../../lib/utils"
// A token-driven Alert primitive replacing the family's ad-hoc role="alert" /
// role="status" notice divs. Each variant is a soft-filled, colored-border block
-// that recolors correctly on every base (light, sepia, dark) via the semantic
+// that recolors correctly on every base (light, dark) via the semantic
// tokens. Callers keep control of the a11y role (default "alert"; pass
// role="status" for non-urgent notices) so existing e2e querying by role stays
// green. An optional leading icon is supported via the grid layout below.
diff --git a/common/components/ui/badge.tsx b/common/components/ui/badge.tsx
@@ -21,7 +21,7 @@ const badgeVariants = cva(
// Soft-filled semantic tones — colorful but calm, legible on every
// base (token hues tuned as text-on-soft). The accent reads on its own
// tint only as --brand-strong: plain --brand on --brand-soft falls to
- // ~4:1 on the light and sepia grounds.
+ // ~4:1 on the light ground.
success:
"border-success/30 bg-success-soft text-success [a&]:hover:bg-success/15",
warning:
diff --git a/common/components/ui/sonner.tsx b/common/components/ui/sonner.tsx
@@ -13,8 +13,8 @@ import { useTheme } from "../ThemeProvider"
const Toaster = ({ ...props }: ToasterProps) => {
// Reuse the family theme controller rather than next-themes, so toast chrome
- // tracks the base in force: sepia is a light ground, and "system" is already
- // resolved (live) by the provider.
+ // tracks the base in force: "system" is already resolved (live) by the
+ // provider.
const { isDark } = useTheme()
return (
diff --git a/common/lib/accent.test.ts b/common/lib/accent.test.ts
@@ -45,7 +45,6 @@ test("resolveAccent: a named accent reads its table row", () => {
assert.deepEqual(resolveAccent(id), {
id,
light: ACCENTS[id].onLight,
- sepia: ACCENTS[id].onSepia,
dark: ACCENTS[id].onDark,
});
}
@@ -58,7 +57,7 @@ test("resolveAccent: absent or malformed is the default accent (Signal)", () =>
}
});
-// Custom hexes chosen to need every kind of fit: too light for light/sepia,
+// Custom hexes chosen to need every kind of fit: too light for light,
// too dark for dark, already fine, and the extremes.
const CUSTOMS = [
"#cc3366", "#ffff00", "#00ffff", "#ffffff", "#000000", "#808080", "#1e90ff",
@@ -83,16 +82,16 @@ test("resolveAccent: a custom hex is fitted to 4.5:1 on every ground and with it
});
test("resolveAccent: a custom hex that already passes is kept exactly; others move the right way", () => {
- // #cc3366 is 4.57:1 on light (kept), 4.21 on sepia (darkened), 3.98 on dark
- // (lightened). The themes slice's site-branding spec expects #cc3366 on light.
+ // #cc3366 is 4.57:1 on light (kept) and 3.98 on dark (lightened). The
+ // themes slice's site-branding spec expects #cc3366 on light.
const r = resolveAccent("#CC3366");
assert.equal(r.light, "#cc3366");
- assert.notEqual(r.sepia, "#cc3366");
assert.notEqual(r.dark, "#cc3366");
const lum = (h: string) => contrastRatio(h, "#000000");
- assert.ok(lum(r.sepia) < lum("#cc3366"), "sepia is darker");
assert.ok(lum(r.dark) > lum("#cc3366"), "dark is lighter");
- // A dark-enough colour is kept on light and sepia, a light-enough on dark.
+ // The one that is too light for light is darkened there.
+ assert.ok(lum(resolveAccent("#ffff00").light) < lum("#ffff00"), "light is darker");
+ // A dark-enough colour is kept on light, a light-enough on dark.
assert.equal(resolveAccent("#101010").light, "#101010");
assert.equal(resolveAccent("#f0f0f0").dark, "#f0f0f0");
});
@@ -123,7 +122,6 @@ test("customAccentVars: only a custom hex carries inline vars", () => {
const r = resolveAccent("#cc3366");
assert.deepEqual(customAccentVars("#cc3366"), {
"--accent-custom-light": r.light,
- "--accent-custom-sepia": r.sepia,
"--accent-custom-dark": r.dark,
});
});
diff --git a/common/lib/accent.ts b/common/lib/accent.ts
@@ -51,7 +51,6 @@ export function parseAccentSetting(input: unknown): string | undefined {
export type ResolvedAccent = {
id: AccentId | "custom";
light: string;
- sepia: string;
dark: string;
};
@@ -68,7 +67,7 @@ function meetsRule(hex: string, base: BaseGround): boolean {
// Fit a custom hex to one base: unchanged when it already meets the rule
// (brand.ts MIN_ACCENT_CONTRAST against the ground AND the ink), otherwise
-// mixed toward black (light/sepia) or white (dark) in 1 % steps until it does.
+// mixed toward black (light) or white (dark) in 1 % steps until it does.
// Mixing keeps the hue; full black/white always passes, so this terminates.
function fitAccent(hex: string, base: BaseGround): string {
if (meetsRule(hex, base)) return hex;
@@ -93,12 +92,11 @@ export function resolveAccent(input: unknown): ResolvedAccent {
return {
id: "custom",
light: fitAccent(setting, "light"),
- sepia: fitAccent(setting, "sepia"),
dark: fitAccent(setting, "dark"),
};
}
const a = ACCENTS[setting && isAccentId(setting) ? setting : DEFAULT_ACCENT];
- return { id: a.id, light: a.onLight, sepia: a.onSepia, dark: a.onDark };
+ return { id: a.id, light: a.onLight, dark: a.onDark };
}
// The PUBLISHED accent: always a hex. An id becomes its on-dark value (the
diff --git a/common/lib/brand.test.ts b/common/lib/brand.test.ts
@@ -21,9 +21,8 @@ import {
} from "./brand";
import { PROJECT_NAME, PROJECT_WORDMARK_LEAD } from "./project";
-const ON: Record<BaseGround, "onLight" | "onSepia" | "onDark"> = {
+const ON: Record<BaseGround, "onLight" | "onDark"> = {
light: "onLight",
- sepia: "onSepia",
dark: "onDark",
};
@@ -31,7 +30,8 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () =>
assert.deepEqual(Object.keys(ACCENTS), [...ACCENT_IDS]);
for (const id of ACCENT_IDS) {
assert.equal(ACCENTS[id].id, id);
- for (const k of ["onDark", "onLight", "onSepia"] as const) {
+ assert.deepEqual(Object.keys(ACCENTS[id]).sort(), ["id", "name", "onDark", "onLight"]);
+ for (const k of ["onDark", "onLight"] as const) {
assert.match(ACCENTS[id][k], /^#[0-9a-f]{6}$/, `${id}.${k}`);
}
}
@@ -39,13 +39,13 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () =>
// The whole table, literally, against plans/brand-and-themes.md "Accents":
// a typo that still clears 4.5:1 would pass the contrast test below.
assert.deepEqual(ACCENTS, {
- signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73", onSepia: "#2b756e" },
- brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a", onSepia: "#8e6119" },
- vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f", onSepia: "#b3431f" },
- violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4", onSepia: "#6a4bc4" },
- sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a", onSepia: "#a83a6a" },
- blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8", onSepia: "#2d5fb8" },
- green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c", onSepia: "#3d772b" },
+ signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73" },
+ brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a" },
+ vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f" },
+ violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4" },
+ sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a" },
+ blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8" },
+ green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c" },
});
assert.equal(isAccentId("brass"), true);
assert.equal(isAccentId("Brass"), false);
@@ -53,8 +53,8 @@ test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () =>
});
// THE CONTRAST RULE: every accent's value on every base reaches 4.5:1 against
-// that base's ground AND against the ink set on it (white on light/sepia, the
-// dark ground on dark). 7 accents × 3 bases × 2 = 42 checks.
+// that base's ground AND against the ink set on it (white on light, the dark
+// ground on dark). 7 accents × 2 bases × 2 = 28 checks.
test("accents: every value reaches 4.5:1 on its ground and with its ink", () => {
// The bar itself is pinned as a literal: comparing only against the
// imported constant would let an edit to brand.ts lower rule and test
@@ -71,9 +71,8 @@ test("accents: every value reaches 4.5:1 on its ground and with its ink", () =>
checks += 2;
}
}
- assert.equal(checks, 42);
+ assert.equal(checks, 28);
assert.equal(ACCENT_INK.light, "#ffffff");
- assert.equal(ACCENT_INK.sepia, "#ffffff");
assert.equal(ACCENT_INK.dark, BASE_GROUNDS.dark);
});
@@ -94,7 +93,7 @@ test("contrastRatio: the sRGB curve, at the 4.5:1 boundary on white", () => {
});
test("bases and icon palettes are the plan's", () => {
- assert.deepEqual(BASE_GROUNDS, { light: "#f3f6f7", sepia: "#f4ecd8", dark: "#0c0a08" });
+ assert.deepEqual(BASE_GROUNDS, { light: "#f3f6f7", dark: "#0c0a08" });
assert.deepEqual(ICON_PALETTES.archilyzer, { ground: "#151b20", dim: "#586977", lit: "#e7edf1" });
assert.deepEqual(childIconPalette(ACCENTS.brass.onDark), {
ground: "#0c0a08",
@@ -104,9 +103,8 @@ test("bases and icon palettes are the plan's", () => {
});
// The mark's two rules (brand.ts MIN_MARK_DIM_CONTRAST, MIN_MARK_LIT_OVER_DIM).
-// A child site can be lit by any named accent — its own, Signal when it has
-// none, or the one a reader picks, which the header mark follows — so the
-// child dim is held against all seven. The parent mark is lit in bone; the
+// A child site can be lit by any named accent — its own, or Signal when it
+// has none — so the child dim is held against all seven. The parent mark is lit in bone; the
// Media mark's Signal on the same slate is held in brandMedia.test.ts.
const LIT_OF: ReadonlyArray<[string, string, string]> = [
...ACCENT_IDS.map((id): [string, string, string] => [`child · ${id}`, ICON_PALETTES.child.dim, ACCENTS[id].onDark]),
diff --git a/common/lib/brand.ts b/common/lib/brand.ts
@@ -1,7 +1,7 @@
-// THE BRAND, AS DATA — the Found-line mark, the accent palette, the three base
+// THE BRAND, AS DATA — the Found-line mark, the accent palette, the two base
// grounds and the wordmark split. plans/brand-and-themes.md "The design" is the
// source of every value here; where the design canvas and the plan differ, the
-// plan wins (notably the contrast-corrected on-light / on-sepia accents).
+// plan wins (notably the contrast-corrected on-light accents).
//
// PURE: zero imports, no I/O, no framework types, no zod. It is safe in server
// components, `"use client"` trees (the editor's accent swatches), route
@@ -31,17 +31,16 @@ export type Accent = {
name: string;
onDark: string;
onLight: string;
- onSepia: string;
};
export const ACCENTS: Readonly<Record<AccentId, Accent>> = {
- signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73", onSepia: "#2b756e" },
- brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a", onSepia: "#8e6119" },
- vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f", onSepia: "#b3431f" },
- violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4", onSepia: "#6a4bc4" },
- sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a", onSepia: "#a83a6a" },
- blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8", onSepia: "#2d5fb8" },
- green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c", onSepia: "#3d772b" },
+ signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73" },
+ brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a" },
+ vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f" },
+ violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4" },
+ sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a" },
+ blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8" },
+ green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c" },
};
// An absent accent reads as this one — on a child site AND on the family's own
@@ -58,19 +57,17 @@ export function isAccentId(v: unknown): v is AccentId {
// against these, and the browser chrome colour is taken from them.
export const BASE_GROUNDS = {
light: "#f3f6f7",
- sepia: "#f4ecd8",
dark: "#0c0a08",
} as const;
export type BaseGround = keyof typeof BASE_GROUNDS;
-export const BASE_GROUND_IDS = ["light", "sepia", "dark"] as const satisfies ReadonlyArray<BaseGround>;
+export const BASE_GROUND_IDS = ["light", "dark"] as const satisfies ReadonlyArray<BaseGround>;
// The text set ON an accent fill (a filled button, a badge): white on the light
-// and sepia bases, the dark ground on the dark base.
+// base, the dark ground on the dark base.
export const ACCENT_INK: Readonly<Record<BaseGround, string>> = {
light: "#ffffff",
- sepia: "#ffffff",
dark: BASE_GROUNDS.dark,
};
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -367,6 +367,31 @@ test("elements an icon has no use for, named", () => {
}
});
+test("only a drawing program's leftovers say how to export: a style, metadata, its own namespace", () => {
+ const HINT = /presentation attributes rather than a style block \(in Inkscape, save as Plain SVG\)$/;
+ for (const inner of [
+ `<style>path{fill:red}</style>`,
+ `<metadata><x/></metadata>`,
+ `<sodipodi:namedview/>`,
+ `<rect inkscape:label="x"/>`,
+ `<rect style="position:fixed"/>`,
+ ]) {
+ assert.match(socialSvgProblem(OK(inner)) ?? "", HINT, inner);
+ }
+ for (const inner of [
+ `<a href="#a"><rect/></a>`,
+ `<image href="#a"/>`,
+ `<title><path/></title>`,
+ `<foreignObject/>`,
+ `<rect src="x"/>`,
+ `<rect formaction="x"/>`,
+ ]) {
+ const problem = socialSvgProblem(OK(inner));
+ assert.ok(problem, inner);
+ assert.doesNotMatch(problem, /presentation attributes|Plain SVG/, inner);
+ }
+});
+
test("attributes an icon has no use for, named; a style with an escape or an import", () => {
assert.equal(socialSvgProblem(OK(`<rect src="x"/>`)), SVG_PROBLEM.attribute("src"));
assert.equal(socialSvgProblem(OK(`<rect formaction="x"/>`)), SVG_PROBLEM.attribute("formaction"));
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -172,78 +172,89 @@ export type Paths = {
let cached: Paths | null = null;
+// Every path in getPaths() is built on the repo root, which findMonorepoRoot()
+// finds by walking up from `process.cwd()`. Turbopack evaluates `process.cwd()`
+// statically, and a path op on a value derived from it becomes an asset
+// reference — to every file under it when the path is a directory
+// (plans/FACTS.md, "A path joined from `process.cwd()` …"). So every join on
+// such a value goes through this one opted-out call. Nothing changes at run
+// time.
+function under(...parts: string[]): string {
+ return path.join(/* turbopackIgnore: true */ ...parts);
+}
+
export function getPaths(): Paths {
if (cached) return cached;
const monorepoRoot = findMonorepoRoot();
const transcriptsDir =
- process.env.TRANSCRIPTS_DIR ?? path.join(monorepoRoot, "transcripts");
- const exportDir = path.join(monorepoRoot, "export");
+ process.env.TRANSCRIPTS_DIR ?? under(monorepoRoot, "transcripts");
+ const exportDir = under(monorepoRoot, "export");
const exportPublicDir =
- process.env.EXPORT_PUBLIC_DIR ?? path.join(exportDir, "public");
+ process.env.EXPORT_PUBLIC_DIR ?? under(exportDir, "public");
// Staging sibling of the served public dir (so it lands inside the test data
// root when EXPORT_PUBLIC_DIR is overridden). Not served; composed into
// exportPublicDir per site by the build:site step.
const exportIndexDir =
process.env.EXPORT_INDEX_DIR ??
- path.join(path.dirname(exportPublicDir), ".export-index");
- const exportSharedDir = path.join(exportIndexDir, "shared");
- const sitesDir = process.env.SITES_DIR ?? path.join(transcriptsDir, "sites");
- const homepageDir = path.join(sitesDir, "_homepage");
+ under(path.dirname(/* turbopackIgnore: true */ exportPublicDir), ".export-index");
+ const exportSharedDir = under(exportIndexDir, "shared");
+ const sitesDir = process.env.SITES_DIR ?? under(transcriptsDir, "sites");
+ const homepageDir = under(sitesDir, "_homepage");
const configDir =
process.env.ARCHILYZER_CONFIG_DIR ??
path.join(os.homedir(), ".config", "archilyzer");
cached = {
monorepoRoot,
transcriptsDir,
- channelsDir: path.join(transcriptsDir, "channels"),
+ channelsDir: under(transcriptsDir, "channels"),
savedVideosDir:
- process.env.SAVED_VIDEOS_DIR ?? path.join(transcriptsDir, "saved-videos"),
+ process.env.SAVED_VIDEOS_DIR ?? under(transcriptsDir, "saved-videos"),
sitesDir,
homepageDir,
- homepageConfigFile: path.join(homepageDir, "homepage.json"),
- homepageChartTemplatesFile: path.join(homepageDir, "chart-templates.json"),
- jobsDir: path.join(transcriptsDir, ".jobs"),
- workerScratchDir: path.join(transcriptsDir, ".worker-scratch"),
- schedulerStateFile: path.join(transcriptsDir, ".scheduler", "state.json"),
- autoQueueStateFile: path.join(transcriptsDir, ".auto-queue", "state.json"),
- workerDefaultsFile: path.join(transcriptsDir, ".workers", "defaults.json"),
- widgetPresetsFile: path.join(transcriptsDir, ".widget", "presets.json"),
- lmdbPath: path.join(transcriptsDir, "index.mdb"),
+ homepageConfigFile: under(homepageDir, "homepage.json"),
+ homepageChartTemplatesFile: under(homepageDir, "chart-templates.json"),
+ jobsDir: under(transcriptsDir, ".jobs"),
+ workerScratchDir: under(transcriptsDir, ".worker-scratch"),
+ schedulerStateFile: under(transcriptsDir, ".scheduler", "state.json"),
+ autoQueueStateFile: under(transcriptsDir, ".auto-queue", "state.json"),
+ workerDefaultsFile: under(transcriptsDir, ".workers", "defaults.json"),
+ widgetPresetsFile: under(transcriptsDir, ".widget", "presets.json"),
+ lmdbPath: under(transcriptsDir, "index.mdb"),
exportDir,
exportPublicDir,
- exportSummariesDir: path.join(exportPublicDir, "summaries"),
- exportTranscriptsDir: path.join(exportPublicDir, "transcripts"),
- exportSubsDir: path.join(exportPublicDir, "subs"),
- exportPostsDir: path.join(exportPublicDir, "posts"),
- exportDigestsDir: path.join(exportPublicDir, "digests"),
- exportStatsDir: path.join(exportPublicDir, "stats"),
+ exportSummariesDir: under(exportPublicDir, "summaries"),
+ exportTranscriptsDir: under(exportPublicDir, "transcripts"),
+ exportSubsDir: under(exportPublicDir, "subs"),
+ exportPostsDir: under(exportPublicDir, "posts"),
+ exportDigestsDir: under(exportPublicDir, "digests"),
+ exportStatsDir: under(exportPublicDir, "stats"),
exportIndexDir,
exportSharedDir,
- exportSharedTranscriptsDir: path.join(exportSharedDir, "transcripts"),
- exportSharedSubsDir: path.join(exportSharedDir, "subs"),
- exportSharedPostsDir: path.join(exportSharedDir, "posts"),
- exportSharedDigestsDir: path.join(exportSharedDir, "digests"),
- exportSitesIndexDir: path.join(exportIndexDir, "sites"),
+ exportSharedTranscriptsDir: under(exportSharedDir, "transcripts"),
+ exportSharedSubsDir: under(exportSharedDir, "subs"),
+ exportSharedPostsDir: under(exportSharedDir, "posts"),
+ exportSharedDigestsDir: under(exportSharedDir, "digests"),
+ exportSitesIndexDir: under(exportIndexDir, "sites"),
exportBuildsDir:
process.env.EXPORT_BUILDS_DIR ??
- path.join(path.dirname(exportPublicDir), ".export-builds"),
+ under(path.dirname(/* turbopackIgnore: true */ exportPublicDir), ".export-builds"),
settingsFile:
- process.env.SETTINGS_FILE ?? path.join(monorepoRoot, "settings.json"),
+ process.env.SETTINGS_FILE ?? under(monorepoRoot, "settings.json"),
editorChangelogFile:
process.env.EDITOR_CHANGELOG_FILE ??
- path.join(monorepoRoot, "editor", "CHANGELOG.md"),
+ under(monorepoRoot, "editor", "CHANGELOG.md"),
exportChangelogFile:
process.env.EXPORT_CHANGELOG_FILE ??
- path.join(exportDir, "CHANGELOG.md"),
+ under(exportDir, "CHANGELOG.md"),
chartsConfigFile:
process.env.CHARTS_CONFIG_FILE ??
- path.join(monorepoRoot, "chart-templates.json"),
+ under(monorepoRoot, "chart-templates.json"),
globalAliasesFile:
process.env.SEARCH_ALIASES_FILE ??
- path.join(transcriptsDir, "search-aliases.json"),
+ under(transcriptsDir, "search-aliases.json"),
globalTagsFile:
process.env.CURATED_TAGS_FILE ??
- path.join(transcriptsDir, TAGS_FILENAME),
+ under(transcriptsDir, TAGS_FILENAME),
ytdlpBin: process.env.YTDLP_BIN ?? "yt-dlp",
whisperBin: process.env.WHISPER_BIN ?? "whisper-cli",
whisperModel:
@@ -263,7 +274,7 @@ export function getPaths(): Paths {
galleryDlBin: process.env.GALLERY_DL_BIN ?? "gallery-dl",
parakeetBin:
process.env.PARAKEET_STITCH_BIN ??
- path.join(monorepoRoot, "scripts", "parakeet-stitch.mjs"),
+ under(monorepoRoot, "scripts", "parakeet-stitch.mjs"),
parakeetCliBin: process.env.PARAKEET_CLI ?? "parakeet-cli",
parakeetModel: process.env.PARAKEET_MODEL ?? "",
// Speaker-diarization wrapper, same shape as parakeetBin: a script we own,
@@ -271,7 +282,7 @@ export function getPaths(): Paths {
// today, pyannote later) without touching any caller.
diarizeBin:
process.env.DIARIZE_BIN ??
- path.join(monorepoRoot, "scripts", "diarize.mjs"),
+ under(monorepoRoot, "scripts", "diarize.mjs"),
ollamaUrl: (process.env.OLLAMA_URL ?? "http://127.0.0.1:11434").replace(
/\/+$/,
"",
@@ -288,13 +299,18 @@ export function getPaths(): Paths {
return cached;
}
+// Walks up from the working directory to the workspace root; falls back to
+// the working directory itself (an app's own directory). Every path op on
+// these cwd-derived values opts out of Turbopack's tracing, so what the
+// fallback evaluates to at build time never becomes an asset reference.
function findMonorepoRoot(): string {
- let dir = process.cwd();
+ const start = path.resolve(/* turbopackIgnore: true */ process.cwd());
+ let dir = start;
for (let i = 0; i < 8; i++) {
- if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
- const parent = path.dirname(dir);
+ if (fs.existsSync(path.join(/* turbopackIgnore: true */ dir, "pnpm-workspace.yaml"))) return dir;
+ const parent = path.dirname(/* turbopackIgnore: true */ dir);
if (parent === dir) break;
dir = parent;
}
- return process.cwd();
+ return start;
}
diff --git a/common/lib/project.ts b/common/lib/project.ts
@@ -32,6 +32,11 @@ export const PROJECT_WORDMARK_LEAD = "Archi";
// valid. Only then is changing this string a cosmetic follow-up.
export const PROJECT_URL = "https://archilyzer.pages.dev";
+// The homepage's Official Instances section, where every archive's header
+// links in place of the old sites dropdown (homepage/app/page.tsx,
+// `id="instances"`).
+export const INSTANCES_URL = `${PROJECT_URL}/#instances`;
+
export const PROJECT_TAGLINE = "Self-hosted, searchable video-transcript archives.";
// Where a visitor gets the source as a tarball. The canonical public copy is
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 (a narrow header shows fewer). 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).
@@ -1500,7 +1502,7 @@ export const siteSettingsSchema = z.object({
"Default social links applied to every site that doesn't define its own. A site inherits these unless its site.json carries an explicit `socialLinks` array — see Site.socialLinks / resolveSocialLinks in common/lib/site.ts. The one presentation field that lives globally so a shared footer doesn't have to be repeated per site.",
),
homepageUrl: settingsField((v): string => normalizeHomepageUrl(v)).describe(
- "Absolute public URL of the family hub (e.g. \"https://archilyzer-hub.pages.dev\"). Every export site links back to it (\"the family\" backlink) when set. Empty = no hub link rendered. Normalized to a trailing-slash-free http(s) URL.",
+ "Absolute public URL of the family hub (e.g. \"https://archilyzer-hub.pages.dev\"). The default for every site's `hubUrl` (a site's own wins): published as `hubUrl` in the site's public `/site.json` and `/corpus.json`, so the hub can tell its member sites from arbitrary added origins. No page links to it (the header's Hub link was removed in release 14). Empty = none published. Normalized to a trailing-slash-free http(s) URL.",
),
savedVideoBackup: settingsField((v): SavedVideoBackupSettings => sanitizeSavedVideoBackup(v)).describe(
"Backup configuration for the saved-video store (Phase 4 of the video-persistence feature). When enabled with a destination, the store is mirrored there (additively, no deletes) with a per-backup manifest, and the sync scheduler runs the backup on the configured cadence. See common/controller/backupSavedVideos.ts.",
diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts
@@ -6,10 +6,10 @@ import { resolveAccent } from "./accent";
import { ACCENTS, ACCENT_IDS, BASE_GROUNDS, BASE_GROUND_IDS, contrastRatio, type BaseGround } from "./brand";
// What the browser paints for a perBaseColor value on `base`: tokens.css sets
-// `--base-<base>` to 1 and the other two to 0 (`null`: no token sheet at all,
+// `--base-<base>` to 1 and the other to 0 (`null`: no token sheet at all,
// so every var() takes its fallback). Returns "#rrggbb".
function paint(css: string, base: BaseGround | null): string {
- const flags = css.replace(/var\(--base-(light|sepia|dark), ([01])\)/g, (_, b, fallback) =>
+ const flags = css.replace(/var\(--base-(light|dark), ([01])\)/g, (_, b, fallback) =>
base === null ? fallback : b === base ? "1" : "0",
);
const channels = [...flags.matchAll(/calc\(([^()]*)\)/g)].map((m) =>
@@ -38,7 +38,7 @@ test("siteColor: a named accent is its per-base swatch, whatever hex rides along
test("siteColor: a custom hex is fitted to each base, as the site's own pages fit it", () => {
// A pale hex: 1.43:1 on the light ground as published, so the old card
- // painted it nearly invisible on light and sepia.
+ // painted it nearly invisible on light.
const pale = "#f4c2d7";
const fitted = resolveAccent(pale);
const css = siteColor({ accent: "#F4C2D7" }, seriesColor(0));
@@ -49,8 +49,8 @@ test("siteColor: a custom hex is fitted to each base, as the site's own pages fi
}
// Fitted, not as published, where the ground needs it; kept where it reads.
assert.deepEqual(
- { light: paint(css, "light"), sepia: paint(css, "sepia"), dark: paint(css, "dark") },
- { light: "#846974", sepia: "#7c636e", dark: pale },
+ { light: paint(css, "light"), dark: paint(css, "dark") },
+ { light: "#846974", dark: pale },
);
// A hex that already reads on a ground is kept there exactly (#cc3366 on
// light), and the chart colour is never used for one.
@@ -59,7 +59,7 @@ test("siteColor: a custom hex is fitted to each base, as the site's own pages fi
});
test("perBaseColor: one value, each base's colour; the light one with no token sheet", () => {
- const v = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" };
+ const v = { light: "#010203", dark: "#ffeedd" };
const css = perBaseColor(v);
for (const base of BASE_GROUND_IDS) assert.equal(paint(css, base), v[base]);
assert.equal(paint(css, null), v.light);
@@ -68,11 +68,11 @@ test("perBaseColor: one value, each base's colour; the light one with no token s
});
test("perBaseColor: anything but a #rrggbb per base throws, never paints rgb(NaN …)", () => {
- const ok = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" };
+ const ok = { light: "#010203", dark: "#ffeedd" };
for (const bad of ["#fff", "red", "", "var(--brand)", "#12345g", "#1234567"]) {
- assert.throws(() => perBaseColor({ ...ok, sepia: bad }), RangeError, bad);
+ assert.throws(() => perBaseColor({ ...ok, dark: bad }), RangeError, bad);
}
- assert.throws(() => perBaseColor({ light: "#010203" } as never), /dark|sepia/);
+ assert.throws(() => perBaseColor({ light: "#010203" } as never), /dark/);
});
test("fittedHex: a published hex fitted per base; anything else is undefined", () => {
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -86,18 +86,18 @@ export function siteChartColors(sites: readonly { accentId?: string }[]): string
return slot.map((k) => seriesColor(k));
}
-// ONE CSS colour that is `values.light` on the light base, `values.sepia` on
-// sepia and `values.dark` on dark — for a colour a component knows and the
-// token sheet cannot (a custom hex is per site). tokens.css sets
-// `--base-light|sepia|dark` to 1 on their own base and 0 on the others, so each
-// channel is a calc() over the three and the browser resolves it to a plain
-// rgb() for whichever base is in force, switching with it. It goes anywhere a
-// colour goes (a background, a border, color-mix()); the fallbacks paint the
-// light value on a page with no token sheet, as `:root` does.
+// ONE CSS colour that is `values.light` on the light base and `values.dark` on
+// dark — for a colour a component knows and the token sheet cannot (a custom
+// hex is per site). tokens.css sets `--base-light|dark` to 1 on their own base
+// and 0 on the other, so each channel is a calc() over the two and the browser
+// resolves it to a plain rgb() for whichever base is in force, switching with
+// it. It goes anywhere a colour goes (a background, a border, color-mix());
+// the fallbacks paint the light value on a page with no token sheet, as
+// `:root` does.
//
// The value is CSS only: never parse it or compare it as a hex. Each value
// must be a `#rrggbb` (resolveAccent's output); anything else THROWS rather
-// than paint `rgb(NaN …)`, which a browser drops without a word. A fourth
+// than paint `rgb(NaN …)`, which a browser drops without a word. A third
// base needs its own flag in tokens.css (themeTokens.test.ts holds exactly one
// 1 per base, over the same BASE_GROUND_IDS this walks).
const RRGGBB = /^#[0-9a-f]{6}$/i;
diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts
@@ -113,7 +113,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
cloudflareProject:
"Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.",
accent:
- 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.',
+ 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s accent on every page; a reader does not pick one. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.',
siteUrl:
"Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.",
relatedSites:
@@ -129,7 +129,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
archiveMaxBytes:
"Per-site served-file size cap in bytes: any archive larger is dropped from what is served and flagged in the manifest, so a capped host (Cloudflare Pages: 25 MB) will not reject the deploy. 0 = no cap. Absent = the global default. Negative or non-numeric values are dropped.",
hubUrl:
- "Per-site override for the hub this site belongs under (the PWA it points visitors toward). Absent = the family default, `settings.json` `homepageUrl`. Surfaced on the public /site.json so a hub can tell member sites from arbitrary added origins.",
+ "Per-site override for the hub this site belongs under. Absent = the family default, `settings.json` `homepageUrl`. Published on the public `/site.json` and `/corpus.json` so a hub can tell member sites from arbitrary added origins; the header does not link to it (release 14).",
};
// siteId shares the group-id grammar: lowercase slug, used as a directory name.
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/common/lib/socialSvg.ts b/common/lib/socialSvg.ts
@@ -108,12 +108,17 @@ const FRAGMENT_RE = /^#[A-Za-z_][\w.:-]*$/;
// The reasons a pasted icon is refused, by class. The editor shows them after
// the link's label, as text (React escapes it). None echoes the markup: a tag
// or attribute NAME is reduced to letters, digits and `_.:-` and cut to 40
-// characters before it is named. The classes a drawing program's export
-// causes (a style block, its own elements and attributes, an old DOCTYPE) say
-// how to export an acceptable file.
+// characters before it is named. Only the refusals a drawing program's export
+// causes say how to export an acceptable file: a style (a `<style>` block or a
+// `style` attribute), `<metadata>`, and an element or attribute in the
+// program's own namespace (`inkscape:`, `sodipodi:`, …); an old DOCTYPE has a
+// sentence of its own. An `<a>`, an `<image>`, a `<title>` with markup or a
+// `src` is not something an export setting removes, so it gets no hint.
const nameOf = (name: string) => name.replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 40) || "?";
const EXPORT_HINT =
"export it with presentation attributes rather than a style block (in Inkscape, save as Plain SVG)";
+const fromDrawingProgram = (name: string) => /^(style|metadata)$/i.test(name) || name.includes(":");
+const withHint = (name: string) => (fromDrawingProgram(name) ? `; ${EXPORT_HINT}` : "");
export const SVG_PROBLEM = {
markup: "it is not one well-formed <svg> element",
doctype: "it has a DOCTYPE with an internal subset; export it again without one, or delete the DOCTYPE",
@@ -123,9 +128,8 @@ export const SVG_PROBLEM = {
style: `it has a style an icon cannot use; ${EXPORT_HINT}`,
id: "it has an id an icon cannot use",
viewBox: "it has no viewBox, and no numeric width and height to make one from",
- element: (name: string) => `it has an element an icon has no use for (${nameOf(name)}); ${EXPORT_HINT}`,
- attribute: (name: string) =>
- `it has an attribute an icon has no use for (${nameOf(name)}); ${EXPORT_HINT}`,
+ element: (name: string) => `it has an element an icon has no use for (${nameOf(name)})${withHint(name)}`,
+ attribute: (name: string) => `it has an attribute an icon has no use for (${nameOf(name)})${withHint(name)}`,
} as const;
// The only properties a `style` attribute may set: presentation, never layout
diff --git a/common/lib/wordmarkMetrics.ts b/common/lib/wordmarkMetrics.ts
@@ -0,0 +1,58 @@
+// GENERATED by common/bin/gen-wordmark-metrics.py -- do not edit; re-run it.
+//
+// The advance, in font units, of each Latin character Archivo[wdth,wght].ttf maps, at the
+// wordmark's instances (wdth 118; wght 720 and 380):
+// what lib/wordmarkWidth.ts reserves a title's width by.
+export const WORDMARK_METRICS = {
+ font: "Archivo[wdth,wght].ttf",
+ sha256: "0e094a7d3c7c4c25cf1310c4b30014f1dae9332220b1c2c88f4fa996f0b05053",
+ fontTools: "4.65.0",
+ unitsPerEm: 1000,
+ wdth: 118,
+ // [first, last] codepoint ranges; the advances below follow them in order.
+ ranges: [
+ [32, 126], [160, 383],
+ ] as ReadonlyArray<readonly [number, number]>,
+ advances: {
+ 720: [
+ 279, 329, 473, 708, 653, 1074, 911, 253, 362, 362, 424, 704, 325, 393, 325, 305, 724, 663,
+ 722, 727, 715, 726, 728, 679, 737, 728, 330, 333, 704, 704, 704, 674, 1188, 867, 855, 886,
+ 880, 814, 747, 955, 917, 345, 687, 875, 697, 1061, 916, 954, 810, 954, 873, 810, 780, 900,
+ 833, 1137, 867, 839, 794, 346, 305, 346, 704, 608, 280, 711, 710, 702, 710, 715, 421, 710,
+ 700, 290, 288, 673, 290, 1078, 700, 723, 710, 710, 439, 653, 441, 700, 645, 966, 685, 645,
+ 612, 361, 283, 361, 704, 279, 329, 712, 721, 617, 711, 281, 719, 385, 811, 479, 603, 704, 393,
+ 811, 365, 400, 704, 430, 430, 280, 703, 686, 394, 266, 430, 466, 603, 1024, 1023, 1024, 674,
+ 867, 867, 867, 867, 867, 867, 1199, 886, 814, 814, 814, 814, 345, 345, 345, 345, 880, 916,
+ 954, 954, 954, 954, 954, 704, 954, 900, 900, 900, 900, 839, 824, 731, 711, 711, 711, 711, 711,
+ 711, 1140, 702, 715, 715, 715, 715, 290, 290, 290, 290, 726, 700, 723, 723, 723, 723, 723,
+ 704, 723, 700, 700, 700, 700, 645, 710, 645, 867, 711, 867, 711, 867, 711, 886, 702, 886, 702,
+ 886, 702, 886, 702, 880, 710, 880, 710, 814, 715, 814, 715, 814, 715, 814, 715, 814, 715, 955,
+ 710, 955, 710, 955, 710, 955, 710, 917, 700, 917, 700, 345, 290, 345, 290, 345, 290, 345, 290,
+ 345, 290, 1032, 579, 687, 288, 875, 673, 673, 697, 290, 697, 290, 697, 290, 697, 290, 697,
+ 290, 916, 700, 916, 700, 916, 700, 700, 916, 700, 954, 723, 954, 723, 954, 723, 1448, 1184,
+ 873, 439, 873, 439, 873, 439, 810, 653, 810, 653, 810, 653, 810, 653, 780, 441, 780, 441, 780,
+ 441, 900, 700, 900, 700, 900, 700, 900, 700, 900, 700, 900, 700, 1137, 966, 839, 645, 839,
+ 794, 612, 794, 612, 794, 612, 421,
+ ],
+ 380: [
+ 300, 278, 353, 683, 592, 1025, 809, 183, 336, 336, 424, 682, 277, 393, 277, 301, 692, 605,
+ 680, 690, 655, 688, 691, 628, 698, 691, 281, 281, 682, 682, 682, 629, 1207, 827, 827, 889,
+ 879, 816, 733, 954, 885, 306, 636, 808, 639, 1014, 886, 958, 792, 958, 852, 798, 733, 869,
+ 777, 1087, 840, 775, 763, 293, 301, 293, 682, 568, 222, 661, 660, 639, 660, 665, 352, 657,
+ 647, 241, 239, 606, 241, 1023, 647, 673, 660, 660, 380, 592, 372, 646, 576, 831, 601, 576,
+ 574, 318, 280, 318, 682, 300, 278, 660, 685, 578, 655, 280, 674, 325, 824, 455, 572, 682, 393,
+ 824, 355, 400, 682, 390, 390, 222, 652, 651, 394, 235, 390, 440, 572, 989, 988, 989, 629, 827,
+ 827, 827, 827, 827, 827, 1215, 889, 816, 816, 816, 816, 306, 306, 306, 306, 879, 886, 958,
+ 958, 958, 958, 958, 682, 958, 869, 869, 869, 869, 775, 798, 708, 661, 661, 661, 661, 661, 661,
+ 1096, 639, 665, 665, 665, 665, 241, 241, 241, 241, 672, 647, 673, 673, 673, 673, 673, 682,
+ 673, 646, 646, 646, 646, 576, 658, 576, 827, 661, 827, 661, 827, 661, 889, 639, 889, 639, 889,
+ 639, 889, 639, 879, 660, 879, 660, 816, 665, 816, 665, 816, 665, 816, 665, 816, 665, 954, 657,
+ 954, 657, 954, 657, 954, 657, 885, 647, 885, 647, 306, 241, 306, 241, 306, 241, 306, 241, 306,
+ 241, 942, 480, 636, 239, 808, 606, 606, 639, 241, 639, 241, 639, 241, 622, 241, 639, 241, 886,
+ 647, 886, 647, 886, 647, 647, 886, 646, 958, 673, 958, 673, 958, 673, 1496, 1134, 852, 380,
+ 852, 380, 852, 380, 798, 592, 798, 592, 798, 592, 798, 592, 733, 372, 733, 372, 733, 372, 869,
+ 646, 869, 646, 869, 646, 869, 646, 869, 646, 869, 646, 1087, 831, 775, 576, 775, 763, 574,
+ 763, 574, 763, 574, 352,
+ ],
+ } as Readonly<Record<720 | 380, readonly number[]>>,
+};
diff --git a/common/lib/wordmarkWidth.test.ts b/common/lib/wordmarkWidth.test.ts
@@ -0,0 +1,46 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { WORDMARK_METRICS } from "./wordmarkMetrics";
+import { wordmarkWidthEm } from "./wordmarkWidth";
+
+// Run with: pnpm --filter yt-dlp-transcript-common test
+
+const FONT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+ "umtool",
+ "report-to-video",
+ "fonts",
+ WORDMARK_METRICS.font,
+);
+
+test("the table was generated from the vendored Archivo", () => {
+ const sha = createHash("sha256").update(fs.readFileSync(FONT)).digest("hex");
+ assert.equal(WORDMARK_METRICS.sha256, sha, "re-run common/bin/gen-wordmark-metrics.py");
+ const size = WORDMARK_METRICS.ranges.reduce((n, [a, b]) => n + b - a + 1, 0);
+ assert.equal(WORDMARK_METRICS.advances[720].length, size);
+ assert.equal(WORDMARK_METRICS.advances[380].length, size);
+});
+
+test("the lead is set heavier, so wider, than the same letters as suffix", () => {
+ const heavy = wordmarkWidthEm("Wwww", "Wwww");
+ const light = wordmarkWidthEm("xWwww", "x") - wordmarkWidthEm("x", "x");
+ assert.ok(heavy > light, `${heavy} vs ${light}`);
+});
+
+test("longer titles are wider; tracking narrows by its value per character", () => {
+ assert.ok(wordmarkWidthEm("Rekietalyzer", "Rekieta") > wordmarkWidthEm("Jeralyzer", "Jer"));
+ const plain = wordmarkWidthEm("Archilyzer", "Archi");
+ const tracked = wordmarkWidthEm("Archilyzer", "Archi", -0.01);
+ assert.ok(Math.abs(plain - tracked - 0.1 * 1.03) < 0.002, `${plain} → ${tracked}`);
+});
+
+test("a character the table does not hold counts wide, not zero", () => {
+ const latin = wordmarkWidthEm("Ab", "Ab");
+ assert.ok(wordmarkWidthEm("Ab中", "Ab") - latin > 1.2);
+});
diff --git a/common/lib/wordmarkWidth.ts b/common/lib/wordmarkWidth.ts
@@ -0,0 +1,44 @@
+import { splitWordmark } from "./brand";
+import { WORDMARK_METRICS } from "./wordmarkMetrics";
+
+// A wordmark's width as the DISPLAY FACE sets it, in em of its font size —
+// so a layout can reserve it before the web font has loaded. The export
+// header's wordmark drops its text when the text does not fit beside the
+// icons (export/app/components/Header.tsx); measured in the fallback face,
+// which is narrower, a title could fit, show, and then vanish when Archivo
+// arrived. Reserving this width makes the decision the same either way.
+//
+// The lead is weight 720 and the suffix 380 (common/components/Wordmark.tsx),
+// both at wdth 118; each character is its advance from the vendored font
+// (lib/wordmarkMetrics.ts, generated) plus `letterSpacingEm`, kerning left
+// out, and MARGIN more: the web font Google serves renders 0.2–2.4 % wider
+// than the vendored file's advances sum to (measured in Chromium). The header
+// sets this as the wordmark's MIN-width, not its width: where a platform
+// renders the text wider still, the box grows with it (and the text drops a
+// little sooner) rather than clipping its last letter. A character the table
+// does not hold counts as UNMAPPED_EM, wider than any it does.
+
+const UNMAPPED_EM = 1.25;
+const MARGIN = 1.03;
+
+function advanceEm(cp: number, wght: 720 | 380): number {
+ const { ranges, advances, unitsPerEm } = WORDMARK_METRICS;
+ let offset = 0;
+ for (const [first, last] of ranges) {
+ if (cp >= first && cp <= last) return advances[wght][offset + cp - first] / unitsPerEm;
+ offset += last - first + 1;
+ }
+ return UNMAPPED_EM;
+}
+
+export function wordmarkWidthEm(title: string, lead: string | undefined, letterSpacingEm = 0): number {
+ const parts = splitWordmark(title, lead);
+ let em = 0;
+ for (const [text, wght] of [
+ [parts.lead, 720],
+ [parts.suffix, 380],
+ ] as const) {
+ for (const ch of text) em += advanceEm(ch.codePointAt(0)!, wght) + letterSpacingEm;
+ }
+ return Math.round(em * MARGIN * 1000) / 1000;
+}
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -5,20 +5,20 @@
@import "../../common/styles/tokens.css";
A reader theme is TWO independent choices (plans/brand-and-themes.md):
- • `html[data-base="light|sepia|dark"]` selects the BASE — the ground, text,
+ • `html[data-base="light|dark"]` selects the BASE — the ground, text,
lines, status and chart colours. "System" is not a block: the pre-paint
ThemeScript resolves it to light or dark before first paint.
• `html[data-accent="<id>|custom"]` selects the ACCENT — `--brand`. Each
- site server-renders its own; a reader may pick another.
+ site server-renders its own, and a reader does not pick one.
`html.dark` is set iff the resolved base is dark. The `@custom-variant dark`
below makes Tailwind's `dark:` utilities follow that class (not the OS media
- query), so sepia styles as a light ground.
+ query).
`:root` always matches <html>, so the light block below is also the
fallback for a page no script has touched (no-JS, the editor before its
- script runs). The sepia and dark blocks are `html[data-base=…]` (0,1,1) and
- win over it; the accent rules come AFTER all three at the same specificity,
- so a `data-accent` wins `--brand` on every base.
+ script runs). The dark block is `html[data-base=…]` (0,1,1) and wins over
+ it; the accent rules come AFTER both at the same specificity, so a
+ `data-accent` wins `--brand` on every base.
TOKEN NAMING:
• The shadcn "new-york" contract owns the unprefixed names: --primary,
@@ -30,13 +30,13 @@
--brand-ink, and --brand-mark, the header mark's on-dark value) so it
never collides with shadcn's neutral `--accent`.
• `--swatch-<id>` is each named accent's value ON THIS BASE (lib/brand.ts
- ACCENTS — themeTokens.test.ts keeps the two equal), so a picker can
- show the colour a choice will actually paint.
- • `--base-light`, `--base-sepia`, `--base-dark` are 1 on their own base
- and 0 on the others. They let a colour that differs per base but is
- known only to a component be ONE CSS value: each channel a calc() over
- the three (lib/siteColor.ts perBaseColor — a custom-hex site's card on
- the homepage and the hub, fitted to each ground).
+ ACCENTS — themeTokens.test.ts keeps the two equal), so a component
+ can name an accent's colour on the ground in force.
+ • `--base-light` and `--base-dark` are 1 on their own base and 0 on the
+ other. They let a colour that differs per base but is known only to a
+ component be ONE CSS value: each channel a calc() over the two
+ (lib/siteColor.ts perBaseColor — a custom-hex site's card on the
+ homepage and the hub, fitted to each ground).
========================================================================== */
@import "tw-animate-css";
@@ -215,7 +215,6 @@ html[data-base="light"] {
/* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
--base-light: 1;
- --base-sepia: 0;
--base-dark: 0;
--brand: var(--swatch-signal);
@@ -243,7 +242,7 @@ html[data-base="light"] {
chart-1 is a blue, not Signal (ΔE 17.3 from it).
chart-6 (release 11) is a rust, Vermilion's family: the sixth site's
colour. The only hue family that clears every pair with chart-1..5 on
- all three bases is the red-browns (OKLCH h ≈ 15–60); this one's worst
+ both bases is the red-browns (OKLCH h ≈ 15–60); this one's worst
pair with them is CVD ΔE 12.5 deutan and normal 16.6, 8.1:1 on the chart
surface and 7.4:1 on the ground. It sits near --state-gone (ΔE 8.4
normal), which is only ever labelled text, never a chart mark. */
@@ -260,91 +259,6 @@ html[data-base="light"] {
}
/* ---------------------------------------------------------------------------
- SEPIA — warm paper for long reading. New with the base × accent themes; its
- status, panel and chart colours derive from the retired archive "paper"
- block, darkened for this darker ground (every text colour ≥ 4.5:1 on it).
- --------------------------------------------------------------------------- */
-html[data-base="sepia"] {
- color-scheme: light;
-
- --background: #f4ecd8;
- --surface: #ece2ca;
- --foreground: #33281a;
- --card: #faf4e6;
- --card-foreground: #33281a;
- --popover: #faf4e6;
- --popover-foreground: #33281a;
- --primary: #33281a;
- --primary-foreground: #f4ecd8;
- --secondary: #e9dec3;
- --secondary-foreground: #33281a;
- --muted: #ece2ca;
- --muted-foreground: #6b5c43;
- --accent: #e4d7b8;
- --accent-foreground: #33281a;
- --destructive: #b3261e;
- --destructive-foreground: #ffffff;
- --destructive-soft: rgba(179, 38, 30, 0.12);
- --border: #dccdaa;
- --border-strong: #c4b187;
- --input: #d3c29c;
- --ring: var(--brand);
- --faint: #857a64;
- --panel: rgba(250, 244, 230, 0.72);
- --panel-2: rgba(236, 226, 202, 0.72);
-
- /* Each deep enough to read at 4.5:1 on its OWN soft fill over the ground
- and the card, as the @theme note promises (themeTokens.test.ts). */
- --success: #256829;
- --success-foreground: #ffffff;
- --success-soft: rgba(37, 104, 41, 0.14);
- --warning: #8c4c00;
- --warning-foreground: #ffffff;
- --warning-soft: rgba(140, 76, 0, 0.14);
- --info: #1858bc;
- --info-foreground: #ffffff;
- --info-soft: rgba(24, 88, 188, 0.12);
-
- /* Each named accent's on-sepia value (lib/brand.ts ACCENTS.onSepia). */
- --swatch-signal: #2b756e;
- --swatch-brass: #8e6119;
- --swatch-vermilion: #b3431f;
- --swatch-violet: #6a4bc4;
- --swatch-sakura: #a83a6a;
- --swatch-blue: #2d5fb8;
- --swatch-green: #3d772b;
- --swatch-custom: var(--accent-custom-sepia, var(--swatch-signal));
-
- /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
- --base-light: 0;
- --base-sepia: 1;
- --base-dark: 0;
-
- --brand: var(--swatch-signal);
- --brand-strong: color-mix(in oklab, var(--brand) 78%, black);
- --brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
- --brand-ink: #ffffff;
-
- --state-gone: #a3392a;
- --state-gone-soft: rgba(163, 57, 42, 0.12);
-
- /* Same hue order as light, stepped for the paper surface. Adjacent pairs
- pass every check; all pairs pass with green ↔ amber in the CVD floor
- band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. chart-6, the
- rust, is light's: worst pair CVD 9.1 deutan, normal 16.6. */
- --chart-1: #3574d6;
- --chart-2: #2a7d4f;
- --chart-3: #5e3aa8;
- --chart-4: #a8741a;
- --chart-5: #bb4585;
- --chart-6: #823c10;
- --chart-surface: #faf4e6;
- --chart-grid: rgba(51, 40, 26, 0.09);
- --chart-axis: #6b5c43;
- --chart-tooltip-bg: #fffaf0;
-}
-
-/* ---------------------------------------------------------------------------
DARK — warm ink. The former archive "ink" face (the homepage's archive room),
with a neutral primary, and the --destructive-soft and --state-gone(-soft)
it never declared.
@@ -400,7 +314,6 @@ html[data-base="dark"] {
/* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
--base-light: 0;
- --base-sepia: 0;
--base-dark: 1;
--brand: var(--swatch-signal);
@@ -464,9 +377,24 @@ html[data-accent="green"] {
--brand-mark: #7cc46a;
}
/* A site whose site.json accent is its own hex: the layout renders
- `data-accent="custom"` plus the fitted `--accent-custom-light|sepia|dark`
+ `data-accent="custom"` plus the fitted `--accent-custom-light|dark`
inline on <html>. */
html[data-accent="custom"] {
--brand: var(--swatch-custom);
--brand-mark: var(--accent-custom-dark, #5fa8a0);
}
+
+/* ---------------------------------------------------------------------------
+ THE CHART GAP — the colour that parts touching chart marks, the marks
+ spec's surface gap (common/components/charts/surfaceGap.ts): the chart
+ surface on every base (a chart card is `--card`, the same value), and the
+ reader's Canvas in forced colours.
+ --------------------------------------------------------------------------- */
+:root {
+ --chart-gap: var(--chart-surface);
+}
+@media (forced-colors: active) {
+ :root {
+ --chart-gap: Canvas;
+ }
+}
diff --git a/common/testing/chartPixels.ts b/common/testing/chartPixels.ts
@@ -0,0 +1,110 @@
+// WHAT A CHART PAINTED, for the e2e specs: a screenshot of the chart read back
+// as pixels in the page (the browser's own PNG decoder, an <img> drawn to a
+// canvas), so a test checks the rendered geometry — where the stack's top
+// is, which colours show — rather than what a style says a stroke is.
+//
+// No Playwright import (common/ does not depend on it): the page and the
+// locator are typed by the two methods this uses.
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Shooter = { screenshot(opts?: any): Promise<Buffer> };
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Evaluator = { evaluate(fn: any, arg: any): Promise<any> };
+
+export type Rgb = [number, number, number];
+
+// "rgb(1, 2, 3)" / "rgba(1, 2, 3, 0.5)" → [1, 2, 3].
+export function rgbOf(css: string): Rgb {
+ const m = css.match(/[\d.]+/g);
+ if (!m || m.length < 3) throw new Error(`not an rgb() colour: ${css}`);
+ return [Number(m[0]), Number(m[1]), Number(m[2])];
+}
+
+// `alpha` of `fg` over `bg`, as the browser composites a translucent fill.
+export function over(fg: Rgb, alpha: number, bg: Rgb): Rgb {
+ return [0, 1, 2].map((c) => Math.round(fg[c] * alpha + bg[c] * (1 - alpha))) as Rgb;
+}
+
+export type Painted = {
+ // Per requested column: the first row, from the top, of a run of `run` rows
+ // that each differ from `ground` by more than `tol` in some channel; null
+ // when the column is all ground.
+ tops: (number | null)[];
+ // Per requested colour: how many pixels are within `tol` of it, and in how
+ // many distinct columns.
+ counts: { pixels: number; columns: number }[];
+ width: number;
+ height: number;
+};
+
+export async function painted(
+ page: Evaluator,
+ target: Shooter,
+ opts: { columns: number[]; ground: Rgb; colours: Rgb[]; tol?: number; run?: number },
+): Promise<Painted> {
+ const png = await target.screenshot({ scale: "css", animations: "disabled" });
+ return page.evaluate(
+ async ({
+ b64,
+ columns,
+ ground,
+ colours,
+ tol,
+ run,
+ }: {
+ b64: string;
+ columns: number[];
+ ground: Rgb;
+ colours: Rgb[];
+ tol: number;
+ run: number;
+ }) => {
+ const img = new Image();
+ img.src = `data:image/png;base64,${b64}`;
+ await img.decode();
+ const c = document.createElement("canvas");
+ c.width = img.naturalWidth;
+ c.height = img.naturalHeight;
+ const ctx = c.getContext("2d")!;
+ ctx.drawImage(img, 0, 0);
+ const { data, width, height } = ctx.getImageData(0, 0, c.width, c.height);
+ const at = (x: number, y: number) => (y * width + x) * 4;
+ const differs = (x: number, y: number) => {
+ const p = at(x, y);
+ return [0, 1, 2].some((k) => Math.abs(data[p + k] - ground[k]) > tol);
+ };
+ const tops = columns.map((cx) => {
+ const x = Math.min(width - 1, Math.max(0, Math.round(cx)));
+ for (let y = 0; y + run <= height; y++) {
+ let solid = true;
+ for (let d = 0; d < run && solid; d++) solid = differs(x, y + d);
+ if (solid) return y;
+ }
+ return null;
+ });
+ const counts = colours.map((col) => {
+ let pixels = 0;
+ const cols = new Set<number>();
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const p = at(x, y);
+ if ([0, 1, 2].every((k) => Math.abs(data[p + k] - col[k]) <= tol)) {
+ pixels++;
+ cols.add(x);
+ }
+ }
+ }
+ return { pixels, columns: cols.size };
+ });
+ return { tops, counts, width, height };
+ },
+ {
+ b64: png.toString("base64"),
+ columns: opts.columns,
+ ground: opts.ground,
+ colours: opts.colours,
+ tol: opts.tol ?? 12,
+ run: opts.run ?? 3,
+ },
+ );
+}
diff --git a/common/views/laneState.ts b/common/views/laneState.ts
@@ -45,8 +45,8 @@ export function deriveLaneState({
}
// No new palette. These map onto the station tones the channel line already
-// uses (see pipeline/tone.ts): a bespoke hue here would be wrong on all three
-// bases (light, sepia, dark) at once. Deliberately NOT a second copy
+// uses (see pipeline/tone.ts): a bespoke hue here would be wrong on both
+// bases (light, dark) at once. Deliberately NOT a second copy
// of those maps — flow/OverviewPanel already made one, and three would be a
// guarantee they drift.
export const LANE_DOT: Record<LaneState, string> = {
diff --git a/common/views/pipeline/tone.ts b/common/views/pipeline/tone.ts
@@ -1,7 +1,7 @@
import type { StageTone } from "./stageStatus";
// The line invents no colours. Every value below is one of the semantic tokens
-// the repo already carries across the three bases, light, sepia and dark
+// the repo already carries across the two bases, light and dark
// (common/styles/tokens.css); a bespoke hue here would be wrong on every base
// at once.
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -6,8 +6,10 @@
- **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.
## [0.10.0] - 2026-09-28
- **The homepage can be built and deployed from `/sites`.** Under a new **Homepage** section, after Hub, there is **Build homepage** (tick **Deploy after build** to ship it in the same job, only if the build succeeds) and **Deploy homepage**, which ships the build already in `homepage/out`. A **Preview branch** box beside them sends either deploy to a Cloudflare Pages preview of the `archilyzer` project instead of production, and shows the preview's address as you type; a name Cloudflare would refuse or rewrite, or `main`, greys the deploy buttons out and says why. A line under the buttons says what a deploy would ship: when `homepage/out` was built (or that it holds no build yet), and where it goes, with the live URL. Deploy homepage with nothing built is refused before any job starts. The homepage reads the search index as it stands, so run **Build index** first when its numbers should move. The jobs run the same code as `archilyzer build homepage` / `deploy homepage`, and show on `/jobs` as `build-homepage`, `deploy-homepage` and `build-deploy-homepage`. The Hub section no longer describes the homepage.
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/app/globals.css b/editor/app/globals.css
@@ -2,7 +2,7 @@
@import "../../common/styles/tokens.css";
@source "../../common/components";
-/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and
+/* Design tokens, the `dark` variant, the two bases (light / dark) and
the accents live in common/styles/tokens.css; the faces in
common/styles/fonts.ts. The editor follows the operator's system base with
the Signal accent, as a compact, command-first cockpit (shell + dashboard +
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -11,7 +11,6 @@ import { fontVars } from "yt-dlp-transcript-common/styles/fonts";
import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript";
import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
-import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand";
import { AppFrame } from "./components/AppFrame";
@@ -153,7 +152,6 @@ export default async function RootLayout({
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
- <ThemeMenu />
<ThemeToggle />
</div>
</div>
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -45,7 +45,7 @@ export function SettingsForm({ initial }: Props) {
name="homepageUrl"
defaultValue={initial.homepageUrl}
type="url"
- hint="Absolute URL of the family hub (e.g. https://archilyzer-hub.pages.dev). Every export site links back to it. Leave blank for no hub link."
+ hint="Absolute URL of the family hub (e.g. https://archilyzer-hub.pages.dev). Each site that names no hub of its own publishes it in its /site.json and /corpus.json, so the hub can tell its member sites; no page links to it. Leave blank to publish none."
/>
<Field
label="Max transcript page bytes"
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -253,9 +253,9 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="font-medium">Brand accent</legend>
<p className="text-xs text-muted-foreground">
- This site's default accent (its icon and first paint); a reader
- can pick another. A custom colour is darkened or lightened on each
- base (light, sepia, dark) until it reads at 4.5:1.
+ This site's accent (its icon and every page). A custom colour
+ is darkened or lightened on each base (light, dark) until it reads
+ at 4.5:1.
</p>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{ACCENT_IDS.map((id) => (
@@ -331,7 +331,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
label="Hub URL"
name="hubUrl"
defaultValue={initial.hubUrl ?? ""}
- hint="The hub this site belongs under (e.g. https://archilyzer-hub.pages.dev). Shows a 'Hub' backlink and lets the hub recognize this site as a member. Leave blank to inherit the family default from Settings."
+ hint="The hub this site belongs under (e.g. https://archilyzer-hub.pages.dev), published in this site's /site.json and /corpus.json so the hub can tell it is a member; the header does not link to it. Leave blank to inherit the family default from Settings."
/>
<label className="flex items-center gap-2 text-sm">
<input
diff --git a/editor/e2e/export-search.spec.ts b/editor/e2e/export-search.spec.ts
@@ -588,7 +588,7 @@ test.describe("export footer", () => {
const builtWith = footer.getByRole("link", { name: "Archilyzer" });
await expect(builtWith).toBeVisible();
await expect(builtWith).toHaveAttribute("href", PROJECT_URL);
- // Navigation, not a social link: same tab, matching the header's hub link.
+ // Navigation, not a social link: same tab, as the header's Archilyzer link.
await expect(builtWith).not.toHaveAttribute("target", "_blank");
// The accessible name is exactly the product, with "Built with" outside it.
await expect(footer).toContainText("Built with Archilyzer");
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/editor/e2e/theme.spec.ts b/editor/e2e/theme.spec.ts
@@ -1,5 +1,6 @@
import { test, expect, type Page } from "@playwright/test";
import { resetData } from "./helpers";
+import { RETIRED_BASE } from "../../common/components/themeConfig";
// Regression: refreshing the editor must honor the persisted theme. The
// pre-paint <ThemeScript> sets `data-base` and `.dark` on <html>, but <html> is
@@ -9,7 +10,8 @@ import { resetData } from "./helpers";
// persisted base on mount.
//
// And the one-time migration: the retired `ytdlp-tb:theme` / `ytdlp-tb:mode`
-// keys become a base (archive + light → sepia) and are deleted.
+// keys become a base (archive + light → light) and are deleted; a stored
+// retired third ground becomes light.
const BASE_KEY = "ytdlp-tb:base";
const LEGACY_THEME_KEY = "ytdlp-tb:theme";
const LEGACY_MODE_KEY = "ytdlp-tb:mode";
@@ -55,14 +57,46 @@ test("explicit light base loads light", async ({ page }) => {
await expect(html).toHaveAttribute("data-base", "light");
});
-test("sepia loads as a light ground (no .dark) and survives a reload", async ({
- page,
-}) => {
- await seed(page, { [BASE_KEY]: "sepia" });
- await page.reload();
- const html = page.locator("html");
- await expect(html).toHaveAttribute("data-base", "sepia");
- await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
+// The retired third ground: a reader who chose it gets Light, before first
+// paint, with no other ground on the way, and the stored value becomes
+// "light" once. Every value `data-base` holds is recorded by a
+// MutationObserver installed before the page's first script.
+test.describe("a stored retired base, with the OS dark", () => {
+ test.use({ colorScheme: "dark" });
+
+ test("renders Light with no other ground on the way, and is rewritten", async ({ page }) => {
+ await page.addInitScript(
+ ([key, retired]) => {
+ try {
+ localStorage.setItem(key, retired);
+ } catch {}
+ const held: (string | null)[] = [];
+ (window as unknown as { __bases: (string | null)[] }).__bases = held;
+ new MutationObserver((records) => {
+ for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue);
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-base"],
+ attributeOldValue: true,
+ });
+ },
+ [BASE_KEY, RETIRED_BASE],
+ );
+ await page.goto("/", { waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ const held = await page.evaluate(() => [
+ ...(window as unknown as { __bases: (string | null)[] }).__bases,
+ document.documentElement.getAttribute("data-base"),
+ ]);
+ expect(held.filter((v) => v !== null && v !== "light"), JSON.stringify(held)).toEqual([]);
+ expect(held.at(-1)).toBe("light");
+ const html = page.locator("html");
+ await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
+ expect(await storage(page)).toEqual(["light", null, null]);
+ });
});
test.describe("system base with OS dark", () => {
@@ -89,13 +123,13 @@ test("migration: a stored selenized + dark becomes the dark base; the old keys g
expect(await storage(page)).toEqual(["dark", null, null]);
});
-test("migration: a stored archive + light (the paper look) becomes sepia", async ({
+test("migration: a stored archive + light (the paper look) becomes light", async ({
page,
}) => {
await seed(page, { [LEGACY_MODE_KEY]: "light", [LEGACY_THEME_KEY]: "archive" });
await page.reload();
const html = page.locator("html");
- await expect(html).toHaveAttribute("data-base", "sepia");
+ await expect(html).toHaveAttribute("data-base", "light");
await expect(html).not.toHaveClass(/(^|\s)dark(\s|$)/);
- expect(await storage(page)).toEqual(["sepia", null, null]);
+ expect(await storage(page)).toEqual(["light", null, null]);
});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,6 +3,9 @@
## [Unreleased]
- **The charts count every transcript, once the site is rebuilt.** A transcript that arrived after its video was first indexed was missing from the charts' transcript and cue counts and from "Transcribed over time", and a video with YouTube captions alone had no transcription date. Both are counted now, and a captioned video is dated by when its captions arrived.
- **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, 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 switch is 32.5rem, so at a larger text size it comes later. With one marked link, every current site's name shows in full from 360 px wide on a touch screen. 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 never with its last letter cut off) 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/changelog/page.tsx b/export/app/changelog/page.tsx
@@ -7,7 +7,7 @@ export const metadata: Metadata = { title: "Changelog" };
function loadChangelog(): string {
return readFileSync(
- path.join(process.cwd(), "CHANGELOG.md"),
+ path.join(/* turbopackIgnore: true */ process.cwd(), "CHANGELOG.md"),
"utf8",
);
}
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -1,5 +1,4 @@
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import { safeSocialSvg, sizeSocialSvg } from "yt-dlp-transcript-common/lib/socialLinks";
import {
listSites,
resolveRelatedSites,
@@ -11,6 +10,7 @@ import {
} from "yt-dlp-transcript-common/lib/project";
import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand";
import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
import { currentSite } from "../lib/site";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
@@ -61,22 +61,34 @@ export default function Footer() {
Offline
</a>
)}
- <span aria-hidden="true" className="text-muted-foreground/50">
- ·
- </span>
+ {/* The dot parts the downloads from the rest, so it shows only when
+ there is something before it. */}
+ {(hasArchives() || site.pwa) && (
+ <span aria-hidden="true" className="text-muted-foreground/50">
+ ·
+ </span>
+ )}
<a
href="/use-with-ai"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
Use with AI
</a>
+ {/* Changelog lives here, not in the header (release 14). */}
+ <a
+ href="/changelog"
+ className="underline underline-offset-2 hover:text-foreground transition-colors"
+ >
+ Changelog
+ </a>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* "Built with Archilyzer" — deliberately NOT gated on instanceMode()
(being identical on every deployment is the point), NOT dependent
on a configured hubUrl (that is the operator's family link, a
- different thing), and NOT target="_blank": the header's hub link
- navigates in the same tab, and only the social icons open new ones.
+ different thing), and NOT target="_blank": the header's Archilyzer
+ link navigates in the same tab, and only the social icons open new
+ ones.
"Built with" sits OUTSIDE the anchor so the accessible name is
exactly "Archilyzer" — and so does the parent mark before it,
which is decorative (aria-hidden) and the same on every site. */}
@@ -92,40 +104,9 @@ export default function Footer() {
</a>
</span>
</span>
- {socialLinks.length > 0 && (
- <ul className="flex items-center gap-3 list-none">
- {socialLinks.map((link, i) => {
- // Inlined only if the stored icon passes the save-time check
- // again (lib/socialLinks.ts safeSocialSvg); otherwise the label.
- const svg = safeSocialSvg(link.svg);
- return (
- <li key={`${link.url}-${i}`}>
- {svg ? (
- <a
- href={link.url}
- title={link.label}
- aria-label={link.label}
- target="_blank"
- rel="noopener noreferrer"
- className="inline-block w-5 h-5 overflow-hidden [contain:paint] text-muted-foreground hover:text-brand transition-colors [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: sizeSocialSvg(svg) }}
- />
- ) : (
- <a
- href={link.url}
- target="_blank"
- rel="noopener noreferrer"
- title={link.label}
- className="inline-block max-w-40 truncate align-bottom text-muted-foreground hover:text-brand transition-colors"
- >
- {link.label}
- </a>
- )}
- </li>
- );
- })}
- </ul>
- )}
+ {/* The operator's social links, every one of them (the header shows
+ at most four): the shared row, its keys and focus ring. */}
+ <SocialLinks links={socialLinks} placement="footer" />
</div>
</div>
{related.length > 0 && (
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -1,50 +1,85 @@
import Link from "next/link";
-import { ArrowUpLeft } from "lucide-react";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import {
- listSites,
- resolveHubUrl,
- resolveRelatedSites,
-} from "yt-dlp-transcript-common/lib/site";
+import { resolveSocialLinks } from "yt-dlp-transcript-common/lib/site";
+import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks";
+import { INSTANCES_URL } from "yt-dlp-transcript-common/lib/project";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
-import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
+import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll";
import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
+import { wordmarkWidthEm } from "yt-dlp-transcript-common/lib/wordmarkWidth";
import { currentSite } from "../lib/site";
import { headerMarkPalette } from "../lib/brand";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
import { hasDuplicates } from "../lib/duplicates";
-import SiblingSwitcher from "./SiblingSwitcher";
import MobileMenu from "./MobileMenu";
-// The export site's masthead: the Found-line mark + the split wordmark, calm
-// sans nav, and the cross-site "family" chrome (hub backlink + sibling
-// switcher). The mark's lit line follows the reader's accent (lib/brand.ts
-// headerMarkPalette); the hub wears the parent mark. The link's accessible name
-// is the header title exactly — the mark is decorative and the wordmark's two
-// spans join without a space. All cross-site data is resolved at build time
-// from the pool, so single-site installs simply render neither.
+// The export site's masthead (every site, and the hub): the Found-line mark +
+// the split wordmark, the nav, a link to the Archilyzer home's official
+// instances, and the homepage's group — the operator's social links
+// (common/components/SocialLinks.tsx) and the theme toggle
+// (common/components/ThemeToggle.tsx, `variant="bare"`) as the group's last
+// key, their boxes touching, every glyph 16 px from the next. The mark's lit
+// line follows the site's accent (lib/brand.ts headerMarkPalette); the hub
+// wears the parent mark. The brand link's accessible name is the header title
+// exactly — the mark is decorative and the wordmark's two spans join without a
+// space.
+//
+// ≥ lg brand · nav · Archilyzer · group
+// < lg brand · group · menu trigger (the nav, and the Archilyzer link, in
+// the slide-out menu). The nav, the link and a long title with four
+// icons do not fit a 768 px bar, and the nav gives way before any
+// icon scrolls.
+//
+// THE HEADER KEEPS THE SITE'S NAME ON A NARROW SCREEN (the ruling of
+// 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
+// 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 — 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.
+//
+// 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
+// clipped. The text keeps a MINIMUM WIDTH reserved from the display face's
+// own metrics (lib/wordmarkWidth.ts, in em, so it scales with the reader's
+// text size): the decision is the same in the fallback face, which is
+// narrower, and after Archivo loads — a title never shows and then vanishes.
+// A minimum, not a width: where the text renders wider than the reservation,
+// its box grows with it instead of clipping the last letter. The text stays
+// in the DOM, so the link keeps its name.
+// Below `lg` the brand link takes the bar's free space (basis 0, grow 1) and
+// can shrink to the mark alone; only then does the group give way, and its
+// social row scrolls in SocialScroll's box, the last resort, its END shown
+// first, the toggle outside it. From `lg` the link is sized by its content,
+// so it shrinks first by weight (`shrink-[999]`): with a very long title the
+// text drops before any icon scrolls there too.
export default function Header() {
const site = currentSite();
- const settings = getSettings();
- // The hub this site points visitors toward: its own hubUrl override, else the
- // family default (resolveHubUrl). Only a plain site shows the backlink — the
- // hub itself never links to itself — and never when the parent is this very
- // deployment.
const isSite = instanceMode() === "site";
- const resolvedHub = resolveHubUrl(site, settings);
- const hubUrl =
- isSite && resolvedHub && resolvedHub !== site.siteUrl
- ? resolvedHub
- : undefined;
- // The sibling switcher is build-time family navigation — it belongs on a
- // single site, not on the hub (whose "family" is the runtime shelf).
- const related = isSite ? resolveRelatedSites(site, listSites()) : [];
+ const socialLinks = resolveSocialLinks(site, getSettings());
+ const narrow = headerSocialLinks(socialLinks, "narrow").length;
+ const wide = headerSocialLinks(socialLinks, "wide").length;
const showDownloads = hasArchives();
const showDuplicates = hasDuplicates();
- // The inline nav from md. Ask AI is new: the chat was only reachable from
+ // The inline nav from lg. Ask AI is new: the chat was only reachable from
// the workspace control or from Use with AI, which is not where anyone looks
// for it.
const navLinks = [
@@ -54,14 +89,12 @@ export default function Header() {
...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []),
{ href: "/use-with-ai", label: "Use with AI" },
];
- // The sheet also takes the two links the wide header keeps in its right
- // cluster or its footer: Changelog, and Offline on a PWA-shipping site —
- // gated exactly as Footer.tsx gates it, so the two never disagree about
- // whether this instance has an offline mode.
+ // The sheet also takes Offline on a PWA-shipping site — gated exactly as
+ // Footer.tsx gates it, so the two never disagree about whether this instance
+ // has an offline mode.
const menuLinks = [
...navLinks,
...(site.pwa ? [{ href: "/offline/", label: "Offline" }] : []),
- { href: "/changelog", label: "Changelog" },
];
return (
@@ -69,17 +102,25 @@ 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-x-5 gap-y-1 flex-nowrap">
- <Link href="/" className="flex min-w-0 items-center gap-2.5">
+ <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=""
+ className="flex min-w-7 flex-1 basis-0 items-center lg:grow-0 lg:basis-auto lg:shrink-[999]"
+ >
<BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" />
- <Wordmark
- title={site.headerTitle}
- lead={site.wordmarkLead}
- className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]"
- />
+ <span className="flex h-7 min-w-0 flex-1 flex-wrap items-center overflow-hidden">
+ <span aria-hidden="true" className="h-7 w-0" />
+ <Wordmark
+ title={site.headerTitle}
+ lead={site.wordmarkLead}
+ className="ml-2.5 shrink-0 whitespace-nowrap text-[1.35rem] leading-none tracking-[-0.01em]"
+ style={{ minWidth: `${wordmarkWidthEm(site.headerTitle, site.wordmarkLead, -0.01)}em` }}
+ />
+ </span>
</Link>
- <nav className="hidden md:flex items-center gap-4 text-sm font-medium">
+ <nav className="hidden lg:flex shrink-0 items-center gap-4 text-sm font-medium">
{navLinks.map((l) => (
<Link
key={l.href}
@@ -91,32 +132,41 @@ export default function Header() {
))}
</nav>
- <div className="ml-auto flex items-center gap-2 sm:gap-3 shrink-0">
- <div className="hidden md:flex items-center gap-2 sm:gap-3">
- <SiblingSwitcher groups={related} />
- {hubUrl && (
- <a
- href={hubUrl}
- rel="noopener noreferrer"
- className="inline-flex items-center gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground hover:text-brand transition-colors"
- >
- <ArrowUpLeft className="size-3.5" aria-hidden="true" />
- Hub
- </a>
- )}
- <Link
- href="/changelog"
- className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+ <div className="flex min-w-0 items-center gap-5 lg:ml-auto">
+ {isSite && (
+ <a
+ href={INSTANCES_URL}
+ aria-label="Archilyzer — official instances"
+ className="hidden lg:inline shrink-0 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
- Changelog
- </Link>
- <ThemeMenu />
+ Archilyzer
+ </a>
+ )}
+ <div data-header-group="" className="flex min-w-0 items-center">
+ {narrow > 0 && (
+ <SocialScroll className="min-[32.5rem]:hidden">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="narrow"
+ className="w-max px-1 [direction:ltr]"
+ />
+ </SocialScroll>
+ )}
+ {wide > 0 && (
+ <SocialScroll className="hidden min-[32.5rem]:block">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="wide"
+ className="w-max px-1 [direction:ltr]"
+ />
+ </SocialScroll>
+ )}
+ <ThemeToggle variant="bare" />
</div>
- {/* The one control that stays at every width: theme.spec resolves it
- by /switch to/i, and one tap to flip light/dark is worth a slot. */}
- <ThemeToggle />
- <MobileMenu links={menuLinks} sites={related} hubUrl={hubUrl} />
</div>
+ <MobileMenu links={menuLinks} instancesUrl={isSite ? INSTANCES_URL : undefined} />
</div>
</header>
);
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import Link from "next/link";
-import { ArrowUpLeft, MenuIcon } from "lucide-react";
+import { MenuIcon } from "lucide-react";
import { Button } from "yt-dlp-transcript-common/components/ui/button";
import {
Sheet,
@@ -11,31 +11,25 @@ import {
SheetTitle,
SheetTrigger,
} from "yt-dlp-transcript-common/components/ui/sheet";
-import { ThemeRadios } from "yt-dlp-transcript-common/components/ThemeRadios";
-import type { SwitcherGroup } from "./SiblingSwitcher";
-// The phone half of the masthead. Below `md` the header keeps only the brand,
-// the base toggle and this trigger; everything else the header offers — the
-// nav, the sibling sites, the hub backlink, the base and accent pickers —
-// moves in here, which is also how the Hub backlink and the sites list become
-// reachable on a phone at all (they were `hidden sm:*` and a dropdown too small
-// to hit).
+// The narrow half of the masthead: the NAV. Below `lg` the header's bar keeps
+// the brand, the social row, the theme toggle and this trigger; the nav's
+// links (five or more do not fit a phone's or a tablet's bar beside the group)
+// move in here, with the link to the Archilyzer home's official instances
+// after them on a site (the bar shows both inline from `lg`). Nothing else:
+// the theme is the bar's toggle, the sibling sites are the footer's, and
+// Changelog is in the footer.
//
-// Header is a server component, so the link groups arrive as plain serialisable
-// props. The base and accent lists are read from the client ThemeProvider and
-// rendered as NATIVE radio groups ("Base", "Accent"; ThemeRadios, shared with
-// the Options dialog), not by nesting
-// ThemeMenu's DropdownMenu inside this dialog — one popover layer inside
-// another is a focus-trap fight, and the dropdown's `menuitemradio`s are a spec
-// hook that belongs to the md+ header.
+// Header is a server component, so the links arrive as plain serialisable
+// props.
export default function MobileMenu({
links,
- sites,
- hubUrl,
+ instancesUrl,
}: {
links: { href: string; label: string }[];
- sites: SwitcherGroup[];
- hubUrl?: string;
+ // The Archilyzer home's Official Instances (common/lib/project.ts
+ // INSTANCES_URL); absent on the hub, which lists the instances itself.
+ instancesUrl?: string;
}) {
const [open, setOpen] = useState(false);
@@ -46,7 +40,7 @@ export default function MobileMenu({
variant="ghost"
size="icon"
aria-label="Open menu"
- className="md:hidden"
+ className="shrink-0 lg:hidden"
>
<MenuIcon aria-hidden="true" />
</Button>
@@ -74,56 +68,17 @@ export default function MobileMenu({
</Link>
</SheetClose>
))}
- {hubUrl && (
+ {instancesUrl && (
<a
- href={hubUrl}
- rel="noopener noreferrer"
- className="flex items-center gap-1.5 rounded-md px-2 py-3 text-base font-medium text-muted-foreground transition-colors hover:bg-accent"
+ href={instancesUrl}
+ aria-label="Archilyzer — official instances"
+ className="rounded-md px-2 py-3 text-base font-medium text-muted-foreground transition-colors hover:bg-accent"
>
- <ArrowUpLeft className="size-4" aria-hidden="true" />
- Hub
+ Archilyzer
</a>
)}
</nav>
-
- {sites.length > 0 && (
- <div className="mt-4 px-2">
- <MenuHeading>Sites</MenuHeading>
- {sites.map((group, gi) => (
- <div key={group.label ?? `group-${gi}`} className="mt-1">
- {group.label && (
- <p className="px-2 py-1 font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground/70">
- {group.label}
- </p>
- )}
- <ul className="list-none">
- {group.sites.map((s) => (
- <li key={s.siteId}>
- <a
- href={s.url}
- rel="noopener noreferrer"
- className="block rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent"
- >
- {s.title}
- </a>
- </li>
- ))}
- </ul>
- </div>
- ))}
- </div>
- )}
-
- <ThemeRadios namePrefix="mobile-theme" groupClassName="mt-4 px-2" />
</SheetContent>
</Sheet>
);
}
-
-function MenuHeading({ children }: { children: React.ReactNode }) {
- return (
- <p className="px-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
- {children}
- </p>
- );
-}
diff --git a/export/app/components/SiblingSwitcher.tsx b/export/app/components/SiblingSwitcher.tsx
@@ -1,62 +0,0 @@
-"use client";
-
-import { ChevronDown } from "lucide-react";
-import { Button } from "yt-dlp-transcript-common/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "yt-dlp-transcript-common/components/ui/dropdown-menu";
-
-// A sibling-site switcher for the export header: the "family" navigation across
-// the other public sites in the pool. Fed by the same resolveRelatedSites() data
-// the footer uses (groups of { siteId, title, url }), resolved server-side and
-// passed in as plain props so this stays a thin client shell over the kit.
-export type SwitcherSite = { siteId: string; title: string; url: string };
-export type SwitcherGroup = { label?: string; sites: SwitcherSite[] };
-
-export default function SiblingSwitcher({
- groups,
-}: {
- groups: SwitcherGroup[];
-}) {
- const total = groups.reduce((n, g) => n + g.sites.length, 0);
- if (total === 0) return null;
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground"
- >
- Sites
- <ChevronDown className="size-3.5" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="min-w-48">
- {groups.map((group, gi) => (
- <div key={group.label ?? `group-${gi}`}>
- {gi > 0 && <DropdownMenuSeparator />}
- {group.label && (
- <DropdownMenuLabel className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
- {group.label}
- </DropdownMenuLabel>
- )}
- {group.sites.map((s) => (
- <DropdownMenuItem key={s.siteId} asChild>
- <a href={s.url} rel="noopener noreferrer">
- {s.title}
- </a>
- </DropdownMenuItem>
- ))}
- </div>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- );
-}
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -15,7 +15,7 @@
// each in its own accent or none: the hex its /site.json published (a named
// accent's on-dark value, or the site's own), FITTED to each base like any
// custom hex (lib/siteColor.ts fittedHex, release 11 O2b) — so a pale one reads
-// on light and sepia on its card, chip and result stripe. A value that is not
+// on light on its card, chip and result stripe. A value that is not
// a hex is dropped, never passed into a style.
//
// `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -2,11 +2,11 @@
@import "../../common/styles/tokens.css";
@source "../../common/components";
-/* Design tokens, the `dark` variant, the three bases (light / sepia / dark) and
+/* Design tokens, the `dark` variant, the two bases (light / dark) and
the accents live in common/styles/tokens.css; the faces in
common/styles/fonts.ts. A site opens on the reader's system base in its own
accent (site.json, rendered as `html[data-accent]`); the hub opens dark in
- Signal. The ThemeMenu picks any base and any accent. */
+ Signal. The header's toggle cycles the base; the accent is the site's. */
body {
background: var(--background);
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -25,12 +25,13 @@ import "./globals.css";
// id, or a custom hex fitted to each ground). The hub is the TOOL, not an
// archive: it opens dark in Signal, the family's own accent, exactly as the
// homepage does (homepage/app/layout.tsx), so the two read as one product.
-// Either way the reader can pick any base and any accent (ThemeMenu).
+// Either way the reader cycles the base (ThemeToggle); the accent is the
+// site's, and a reader's stored pick from before is ignored.
// instanceMode() is server-only; this whole file is a server component.
function themeDefaults(): {
defaultBase: ThemeBase;
accent: ThemeAccent;
- // The inline `--accent-custom-light|sepia|dark` a custom-hex site needs
+ // The inline `--accent-custom-light|dark` a custom-hex site needs
// (tokens.css `--swatch-custom` reads them); null for a named accent.
accentVars: Record<string, string> | null;
} {
@@ -100,8 +101,8 @@ export default async function RootLayout({
}>) {
// The accent is baked onto <html> at prerender, so it is right on first
// paint before any script: `data-accent` selects the tokens.css rule, and a
- // custom hex adds its fitted per-base values inline. A reader's stored pick
- // replaces `data-accent` pre-paint (ThemeScript). The hub's dark base is
+ // custom hex adds its fitted per-base values inline. Nothing replaces it: a
+ // reader does not pick an accent. The hub's dark base is
// server-rendered too (`.dark` + data-base), as on the homepage; a site's
// "system" base cannot be resolved on the server, so it renders none and
// the pre-paint script sets it.
diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts
@@ -14,10 +14,9 @@ export function iconPalette(): IconPalette {
: siteIconPalette(currentSite().accent);
}
-// The header mark's palette. On a site it is the icon's ink tile, but its lit
-// line follows the READER's accent — `--brand-mark` is the picked accent's
-// on-dark value (the tile is always ink) — while the favicon keeps the site's
-// default. tokens.css defines `--brand-mark` on every page (a Signal default on
+// The header mark's palette. On a site it is the icon's ink tile, its lit line
+// the accent in force — `--brand-mark` is that accent's on-dark value (the
+// tile is always ink), the same the favicon wears. tokens.css defines `--brand-mark` on every page (a Signal default on
// `:root`, then each `html[data-accent]` rule); the `--brand` fallback is only a
// guard. The hub's mark is the parent mark and does not follow.
export function headerMarkPalette(): BrandMarkPalette {
diff --git a/export/app/manifest.ts b/export/app/manifest.ts
@@ -23,7 +23,8 @@ export default function manifest(): MetadataRoute.Manifest {
// right shape for the player view.
// The splash is the icon's own ground, so the mark sits on its tile's
// colour; the installed app's chrome is the dark base's ground. Neither is
- // the accent: a reader may pick another, and the manifest cannot follow.
+ // the accent: the chrome is the ground a dark reader sees, whatever the
+ // site's accent.
background_color: iconPalette().ground,
theme_color: BASE_GROUNDS.dark,
icons: [
diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts
@@ -52,10 +52,10 @@ test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => {
// The ring on dark (release 10, slice MR): on the dark base the tile has no
// edge, so every mark's tile gets a 1px ring outside it in its palette's dim,
// following the ground's corner (rx 112 of 512). A box-shadow, so the mark's
-// box is the same on every base; light and sepia draw none.
+// box is the same on every base; light draws none.
const BASE_KEY = "ytdlp-tb:base";
-async function onBase(page: Page, base: "light" | "sepia" | "dark") {
+async function onBase(page: Page, base: "light" | "dark") {
await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]);
await page.reload({ waitUntil: "commit" });
await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
@@ -79,7 +79,7 @@ async function markRing(mark: Locator) {
});
}
-test("on dark the hub's header and footer marks have a 1px ring in the slate's dim; on light and sepia none", async ({
+test("on dark the hub's header and footer marks have a 1px ring in the slate's dim; on light none", async ({
page,
}) => {
await page.route("**/hub-sites.json", (r) => fulfillJson(r, []));
@@ -93,7 +93,7 @@ test("on dark the hub's header and footer marks have a 1px ring in the slate's d
expect(await markRing(header)).toEqual({ ring, radius: "21.875%", size: [28, 28] });
expect(await markRing(footer)).toEqual({ ring, radius: "21.875%", size: [16, 16] });
- for (const base of ["light", "sepia"] as const) {
+ for (const base of ["light"] as const) {
await onBase(page, base);
expect(await markRing(header), base).toEqual({ ring: [], radius: "21.875%", size: [28, 28] });
expect(await markRing(footer), base).toEqual({ ring: [], radius: "21.875%", size: [16, 16] });
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";
@@ -119,6 +122,69 @@ test.describe("hub official instances", () => {
).toHaveCount(0);
});
+ // The hub lists the instances itself: its header has the group (the theme
+ // toggle; the social row when there are links) and no link to the
+ // Archilyzer home's list, no sites menu and no hub link.
+ test("the hub's header has no Archilyzer link, sites menu or theme menu", async ({ page }) => {
+ await stubMember(page);
+ await page.route("**/hub-summary.json", (r) =>
+ r.fulfill({ status: 404, body: "" }),
+ );
+ await page.goto("/");
+ const banner = page.getByRole("banner");
+ await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible();
+ await expect(banner.getByRole("link", { name: /official instances/i })).toHaveCount(0);
+ await expect(banner.getByRole("button", { name: /sites/i })).toHaveCount(0);
+ await expect(banner.getByRole("button", { name: "Choose theme" })).toHaveCount(0);
+ 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. Both pointers: under
+ // touch the keys are 44 px, and the name still shows at 360 px.
+ for (const touch of [false, true]) {
+ test.describe(touch ? "under a coarse pointer" : "with a mouse", () => {
+ test.use({ hasTouch: touch });
+ test("the hub's header keeps its name at 360 and 390 px and shows only the marked link", async ({ page }) => {
+ const file = path.resolve(process.cwd(), "test-settings.hub.json");
+ const pristine = fs.readFileSync(file, "utf8");
+ try {
+ const icon = (d: string) => normalizeSocialSvg(`<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`)!;
+ const links = [
+ { label: "Square", url: "https://square.example/", svg: icon("M4 4h16v16H4z") },
+ { label: "Bar", url: "https://bar.example/", svg: icon("M3 10h18v4H3z") },
+ { label: "Post", url: "https://post.example/", svg: icon("M10 2h4v20h-4z"), featured: true },
+ ];
+ fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(pristine), socialLinks: links }, null, 2));
+ await stubMember(page);
+ await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }));
+ const banner = page.getByRole("banner");
+ const row = page.locator('header [data-social-links="header"]:visible');
+ for (const width of [360, 390]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(banner.getByRole("button", { name: /^switch to /i })).toBeVisible();
+ const text = await page.locator("header [data-header-brand] span.flex-wrap").evaluate(
+ (box) => (box.lastElementChild as HTMLElement).offsetTop < box.clientHeight - 1,
+ );
+ expect(text, `${width} px: the name`).toBe(true);
+ await expect(row.getByRole("link")).toHaveCount(1);
+ await expect(banner.getByRole("link", { name: "Post", exact: true })).toHaveCount(1);
+ await expect(banner.getByRole("link", { name: "Square", exact: true })).toHaveCount(0);
+ }
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto("/");
+ await expect(row.getByRole("link")).toHaveCount(3);
+ await expect(page.locator('footer [data-social-links="footer"]').getByRole("link")).toHaveCount(3);
+ } finally {
+ fs.writeFileSync(file, pristine);
+ }
+ });
+ });
+ }
+
test("the hub has no link to ko-fi.com", async ({ page }) => {
await stubMember(page);
await page.route("**/hub-summary.json", (r) =>
@@ -179,20 +245,16 @@ test.describe("hub official instances", () => {
await expect(page.locator("html")).toHaveAttribute("data-base", "dark");
await expect.poll(colour).toBe(rgb(ACCENTS.brass.onDark));
- for (const base of ["light", "sepia"] as const) {
- await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base);
- await page.reload();
- await expect(page.locator("html")).toHaveAttribute("data-base", base);
- await expect
- .poll(colour)
- .toBe(rgb(base === "light" ? ACCENTS.brass.onLight : ACCENTS.brass.onSepia));
- }
+ await page.evaluate(() => localStorage.setItem("ytdlp-tb:base", "light"));
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-base", "light");
+ await expect.poll(colour).toBe(rgb(ACCENTS.brass.onLight));
});
// Release 11 (O2b): an archive the VISITOR added wears the hex its
// /site.json published, fitted to the base in force — on its card and on its
// scope chip — like any custom hex. A pale one used to be painted as is:
- // ~1.4:1 on the light and sepia grounds.
+ // ~1.4:1 on the light ground.
test("an added archive's own hex is fitted to each base, on its card and its chip", async ({
page,
}) => {
@@ -240,13 +302,12 @@ test.describe("hub official instances", () => {
const fitted = resolveAccent(PALE);
// Fitted, not as published, where the ground needs it.
expect(fitted.light).not.toBe(PALE);
- expect(fitted.sepia).not.toBe(PALE);
await page.goto("/");
await expect(
page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }),
).toBeVisible();
- for (const base of ["dark", "light", "sepia"] as const) {
+ for (const base of ["dark", "light"] as const) {
if (base !== "dark") {
await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base);
await page.reload();
diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts
@@ -9,7 +9,7 @@ import { installRoutes } from "./helpers";
// site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is
// headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366.
-// The computed fill of each part of a BrandMark, plus what the reader's accent
+// The computed fill of each part of a BrandMark, plus what the site's accent
// resolves to — so the lit line can be checked against the token it follows
// without pinning a colour the themes slice will change.
async function markFills(mark: Locator) {
@@ -59,7 +59,7 @@ test("the header link is the mark + the split wordmark, named by the header titl
await expect(mark).toBeVisible();
});
-test("the header mark is the ink tile, its lit line following the reader's accent", async ({
+test("the header mark is the ink tile, its lit line following the site's accent", async ({
page,
}) => {
await installRoutes(page);
@@ -75,10 +75,10 @@ test("the header mark is the ink tile, its lit line following the reader's accen
// The ring on dark (release 10, slice MR): on the dark base a site's ink tile
// IS the page, so every mark's tile gets a 1px ring outside it in its own
// palette's dim, following the ground's corner (rx 112 of 512). A box-shadow,
-// so the mark's box is the same on every base; light and sepia draw none.
+// so the mark's box is the same on every base; light draws none.
const BASE_KEY = "ytdlp-tb:base";
-async function onBase(page: Page, base: "light" | "sepia" | "dark") {
+async function onBase(page: Page, base: "light" | "dark") {
await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]);
await page.reload({ waitUntil: "commit" });
await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
@@ -102,7 +102,7 @@ async function markRing(mark: Locator) {
});
}
-test("on dark the header and footer marks' tiles have a 1px ring in their dim; on light and sepia none", async ({
+test("on dark the header and footer marks' tiles have a 1px ring in their dim; on light none", async ({
page,
}) => {
await installRoutes(page);
@@ -122,7 +122,7 @@ test("on dark the header and footer marks' tiles have a 1px ring in their dim; o
size: [16, 16],
});
- for (const base of ["light", "sepia"] as const) {
+ for (const base of ["light"] as const) {
await onBase(page, base);
expect(await markRing(header), base).toEqual({ ring: [], radius: "21.875%", size: [28, 28] });
expect(await markRing(footer), base).toEqual({ ring: [], radius: "21.875%", size: [16, 16] });
diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { installChartRoutes, urlParams } from "./helpers";
+import { over, painted, rgbOf } from "../../common/testing/chartPixels";
// Charts are now a VIEW MODE of the search page: a "Results | Chart" toggle
// plots the current search/filters as a single chart. Stats, summaries and
@@ -16,6 +17,17 @@ async function runSearch(page: Page, term: string) {
await expect(page).toHaveURL(/[?&]qt=/);
}
+// The chart card's colour: what is behind the plot.
+function cardColour(page: Page) {
+ return page.locator(".recharts-surface").first().evaluate((el) => {
+ for (let n: Element | null = el; n; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg;
+ }
+ return "";
+ });
+}
+
function chartTab(page: Page) {
return page.getByTestId("view-toggle").getByRole("button", { name: "Chart" });
}
@@ -210,4 +222,108 @@ test.describe("charts (search view mode)", () => {
page.getByTestId("chart-options").locator("select").first(),
).toHaveValue("bar");
});
+ // THE SURFACE GAP (common/components/charts/surfaceGap.ts): the segments of
+ // a stacked bar are parted by a 2 px stroke in the colour behind the plot —
+ // the chart card — never by a line of their own. Stacked AREAS keep their
+ // series-coloured top edge (a surface stroke there erased small values and
+ // cut peaks).
+ test("stacked bars are parted by a gap in the card's colour; stacked areas keep their own edge", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await expect(page.getByTestId("results-summary")).toContainText("All videos");
+ await openChart(page);
+ const opts = page.getByTestId("chart-options");
+ await opts.locator('label:has-text("Chart type") select').selectOption("stackedBar");
+ await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType");
+ const bars = page.locator(".recharts-bar-rectangle path");
+ await expect(bars.first()).toBeVisible({ timeout: 20_000 });
+ const surface = await cardColour(page);
+ const text = await page.evaluate(() => getComputedStyle(document.body).color);
+ expect(surface).not.toBe(text);
+ const strokes = (sel: string) =>
+ page.locator(sel).evaluateAll((els) =>
+ els.map((e) => [getComputedStyle(e).stroke, getComputedStyle(e).strokeWidth]),
+ );
+ const barStrokes = await strokes(".recharts-bar-rectangle path");
+ expect(barStrokes.length).toBeGreaterThan(0);
+ for (const s of barStrokes) expect(s).toEqual([surface, "2px"]);
+
+ await opts.locator('label:has-text("Chart type") select').selectOption("area");
+ await expect(page.locator(".recharts-area-curve").first()).toBeAttached({ timeout: 20_000 });
+ const edges = await page.locator(".recharts-area").evaluateAll((els) =>
+ els.map((g) => [
+ getComputedStyle(g.querySelector(".recharts-area-curve")!).stroke,
+ getComputedStyle(g.querySelector(".recharts-area-area")!).fill,
+ ]),
+ );
+ expect(edges.length).toBeGreaterThan(1);
+ for (const [stroke, fill] of edges) {
+ expect(stroke).toBe(fill);
+ expect(stroke).not.toBe(surface);
+ }
+ });
+
+ // THE DATA IS WHAT IS PAINTED. Read back from a screenshot: at each month
+ // the stack's topmost painted row is within 1 px of the value scale's y for
+ // the month's true total (three fixture videos, one per month, so a total of
+ // 1 each), and every series with data shows pixels of its own fill.
+ for (const width of [390, 1280]) {
+ test(`${width} px: a stacked area paints its true total and every band`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 1000 });
+ await page.goto("/");
+ await expect(page.getByTestId("results-summary")).toContainText("All videos");
+ await openChart(page);
+ const opts = page.getByTestId("chart-options");
+ // Collapsed on a phone.
+ if (!(await opts.evaluate((el) => (el as HTMLDetailsElement).open))) {
+ await opts.locator("summary").click();
+ }
+ await opts.locator('label:has-text("Chart type") select').selectOption("area");
+ await opts.locator('label:has-text("Group into series by") select').selectOption("mediaType");
+ const svg = page.locator(".recharts-surface").first();
+ await expect(page.locator(".recharts-area")).toHaveCount(3, { timeout: 20_000 });
+ // No tooltip or active dot over the plot, and recharts' entry animation done.
+ await page.mouse.move(0, 0);
+ await expect(page.locator(".recharts-tooltip-wrapper")).toBeHidden();
+ await page.waitForTimeout(1_600);
+ const geo = await svg.evaluate((el) => {
+ const num = (t: Element) => Number((t.textContent ?? "").replace(/[^\d.-]/g, ""));
+ // The value scale: each tick's value, at its gridline's y (a tick
+ // label sits a pixel off its line).
+ const values = [...el.querySelectorAll(".recharts-yAxis .recharts-cartesian-axis-tick-value")]
+ .map(num)
+ .sort((p, q) => p - q);
+ const lines = [...el.querySelectorAll(".recharts-cartesian-grid-horizontal line")]
+ .map((l) => Number(l.getAttribute("y1")))
+ .sort((p, q) => q - p);
+ const yTicks = values.map((v, i) => ({ v, y: lines[i] }));
+ const xTicks = [...el.querySelectorAll(".recharts-xAxis .recharts-cartesian-axis-tick-value")].map((t) =>
+ Number(t.getAttribute("x")),
+ );
+ const fills = [...el.querySelectorAll(".recharts-area-area")].map((p) => {
+ const cs = getComputedStyle(p);
+ return { fill: cs.fill, opacity: Number(cs.fillOpacity) };
+ });
+ return { yTicks, xTicks, fills };
+ });
+ const [a, b] = [geo.yTicks[0], geo.yTicks[geo.yTicks.length - 1]];
+ const yOf = (v: number) => a.y + ((v - a.v) * (b.y - a.y)) / (b.v - a.v);
+ const ground = rgbOf(await cardColour(page));
+ const shot = await painted(page, svg, {
+ columns: geo.xTicks,
+ ground,
+ colours: geo.fills.map((f) => over(rgbOf(f.fill), f.opacity, ground)),
+ tol: 10,
+ });
+ expect(geo.xTicks.length).toBe(3);
+ for (const [i, top] of shot.tops.entries()) {
+ expect(top, `month ${i}: nothing painted`).not.toBeNull();
+ expect(Math.abs(top! - yOf(1)), `month ${i}: top ${top} vs ${yOf(1).toFixed(1)}`).toBeLessThanOrEqual(1);
+ }
+ for (const [i, c] of shot.counts.entries()) {
+ expect(c.columns, `series ${i}'s own colour`).toBeGreaterThan(0);
+ }
+ });
+ }
});
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
@@ -0,0 +1,586 @@
+import fs from "node:fs";
+import path from "node:path";
+import { test, expect, type Locator, type Page } from "@playwright/test";
+import { INSTANCES_URL } from "../../common/lib/project";
+import { normalizeSocialSvg } from "../../common/lib/settingsSchema";
+import { ADVERSARIAL, EVIL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors";
+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. 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
+// social links and puts the file back after. The original rides along in
+// `_e2ePristine`, which playwright.config.ts restores if a run dies mid-test.
+// The icons are synthetic, put through the save path's normalizer.
+
+const SITE_FILE = path.resolve(process.cwd(), "e2e", "fixtures", "sites", "testsite", "site.json");
+let pristine = "";
+
+test.describe.configure({ mode: "serial" });
+
+test.beforeAll(() => {
+ pristine = fs.readFileSync(SITE_FILE, "utf8");
+});
+
+test.afterEach(() => {
+ fs.writeFileSync(SITE_FILE, pristine);
+});
+
+type RawLink = { label: string; url: string; svg: string; featured?: boolean };
+
+function writeSite(patch: { headerTitle?: string; wordmarkLead?: string; socialLinks?: RawLink[] }) {
+ const site = JSON.parse(pristine) as Record<string, unknown>;
+ fs.writeFileSync(
+ SITE_FILE,
+ `${JSON.stringify({ ...site, ...patch, _e2ePristine: pristine }, null, 2)}\n`,
+ );
+}
+
+const icon = (d: string) => `<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`;
+const link = (label: string, d: string, featured = false): RawLink => ({
+ label,
+ url: `https://${label.toLowerCase()}.example/fixture`,
+ svg: normalizeSocialSvg(icon(d))!,
+ ...(featured ? { featured: true } : {}),
+});
+const SIX = [
+ link("Square", "M4 4h16v16H4z"),
+ link("Triangle", "M12 3l10 18H2z"),
+ link("Bar", "M3 10h18v4H3z"),
+ link("Diamond", "M12 2l10 10-10 10L2 12z"),
+ link("Post", "M10 2h4v20h-4z"),
+ link("Chevron", "M4 8l8 8 8-8-3-3-5 5-5-5z"),
+];
+const TITLES = {
+ short: { headerTitle: "Shortlyzer", wordmarkLead: "Short" },
+ long: { headerTitle: "Longestfixturealyzer", wordmarkLead: "Longestfixture" },
+} as const;
+
+const banner = (page: Page) => page.getByRole("banner");
+const brand = (page: Page) => page.locator("header [data-header-brand]");
+// 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) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(toggle(page)).toBeVisible();
+}
+
+// What the narrow header is doing: is the wordmark's text on its visible line,
+// does the row's box scroll, does anything overflow.
+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.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,
+ scrolls: scroll ? scroll.scrollWidth > scroll.clientWidth + 1 : false,
+ page: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ row: row.scrollWidth - row.clientWidth,
+ };
+ });
+
+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, 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(150_000);
+ await installRoutes(page);
+ 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} 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);
+ await expect(brand(page).locator("svg[data-brand-mark]"), at).toBeInViewport({ ratio: 1 });
+ await expect(toggle(page), at).toBeInViewport({ ratio: 1 });
+ // The name is the title whether or not the text shows.
+ await expect(brand(page), at).toHaveAccessibleName(title.headerTitle);
+ }
+ }
+ });
+ }
+ });
+}
+
+// From lg the brand link is sized by its content; with a very long title it
+// must shrink first (`shrink-[999]`), so its text drops before any icon
+// scrolls — as below lg.
+test.describe("from lg, under a coarse pointer, a very long title", () => {
+ test.use({ hasTouch: true });
+ test("1024–1050 px: the text drops before the icons scroll, and nothing scrolls the page", async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ writeSite({
+ headerTitle: "Longestfixturearchivealyzer",
+ wordmarkLead: "Longestfixturearchive",
+ socialLinks: lastMarked(SIX.slice(0, 4)),
+ });
+ await open(page, 1024);
+ for (let w = 1024; w <= 1050; w += 2) {
+ await page.setViewportSize({ width: w, height: 800 });
+ const l = await layout(page);
+ expect(l.scrolls, `${w} px: the icons scroll`).toBe(false);
+ expect(l.page, `${w} px: the page scrolls sideways`).toBeLessThanOrEqual(0);
+ expect(l.row, `${w} px: the header row overflows`).toBeLessThanOrEqual(0);
+ }
+ });
+});
+
+// At twice the text size the header's fixed parts (the mark, the toggle, the
+// menu trigger) are twice as wide; the social row's box takes up the rest and
+// scrolls, so the header itself never widens the page. (The page's own
+// content below is not this header's.)
+for (const touch of [false, true]) {
+ test.describe(touch ? "at 200 % text, under a coarse pointer" : "at 200 % text, with a mouse", () => {
+ 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: marked(SIX.slice(0, 4)) });
+ await page.addInitScript(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ });
+ for (const w of [320, 360]) {
+ await open(page, w);
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ const header = await page.locator("header").first().evaluate((el) => ({
+ over: el.scrollWidth - el.clientWidth,
+ right: Math.max(...[...el.querySelectorAll("a, button")].filter((e) => (e as HTMLElement).offsetParent && !e.closest("[data-social-scroll]")).map((e) => e.getBoundingClientRect().right)),
+ }));
+ expect(header.over, `${w} px: the header overflows`).toBeLessThanOrEqual(0);
+ expect(header.right, `${w} px: a control past the edge`).toBeLessThanOrEqual(w);
+ await expect(toggle(page)).toBeInViewport({ ratio: 1 });
+ await expect(page.getByRole("button", { name: "Open menu" })).toBeInViewport({ ratio: 1 });
+ // The last key is at the box's end, in view, wherever the box has room
+ // 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]:visible").evaluate((el) => el.clientWidth);
+ if (room >= (touch ? 88 : 72)) {
+ await expect(headerRow(page).getByRole("link").last()).toBeInViewport();
+ }
+ expect(w === 360 || !touch ? room : 1, `${w} px: the row's box`).toBeGreaterThan(0);
+ }
+ });
+ });
+}
+
+// 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 });
+ test("320 px: the footer's row wraps, and nothing scrolls the page", async ({ page }) => {
+ await installRoutes(page);
+ const eight = [...SIX, link("Ring", "M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18z"), link("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z")];
+ writeSite({ ...TITLES.short, socialLinks: eight });
+ await open(page, 320);
+ const row = page.locator('footer [data-social-links="footer"]');
+ await expect(row.getByRole("link")).toHaveCount(8);
+ const boxes = await row.getByRole("link").evaluateAll((els) => els.map((e) => e.getBoundingClientRect()).map((r) => ({ right: r.right, top: r.top })));
+ for (const b of boxes) expect(b.right).toBeLessThanOrEqual(320);
+ expect(new Set(boxes.map((b) => Math.round(b.top))).size, "one row of eight").toBeGreaterThan(1);
+ expect((await layout(page)).page).toBeLessThanOrEqual(0);
+ });
+});
+
+// The title's fit does not wait on the web font: its width is reserved from
+// the display face's metrics (lib/wordmarkWidth.ts), so in the fallback face
+// (narrower) and in Archivo the text is shown, or not, at the same widths.
+test("the title shows at the same widths in the fallback face and in Archivo", async ({ page }) => {
+ test.setTimeout(120_000);
+ await installRoutes(page);
+ const widths = [360, 375, 390, 412, 430];
+ const fits = async () => {
+ const out: boolean[] = [];
+ for (const w of widths) {
+ await open(page, w);
+ await page.evaluate(() => document.fonts.ready);
+ out.push((await layout(page)).text);
+ }
+ return out;
+ };
+ 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: 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");
+ await page.unroute(/\.woff2(\?|$)/);
+ const loaded = await fits();
+ expect(await archivo(), "the web font loaded").toContain("loaded");
+ expect(loaded, `${title.headerTitle} at ${widths.join(", ")} px`).toEqual(fallback);
+ }
+});
+
+// The reservation is a MINIMUM width: where the text renders wider than the
+// display face's metrics say (here forced wider by letter-spacing), its box
+// grows with it and the text drops sooner; its last letter is never clipped.
+test("a wordmark that renders wider than its reservation widens its box and is never clipped", async ({ page }) => {
+ await installRoutes(page);
+ writeSite({ headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta", socialLinks: lastMarked(SIX.slice(0, 4)) });
+ await open(page, 519);
+ await page.addStyleTag({ content: "header [data-wordmark] { letter-spacing: 0.08em !important; }" });
+ await page.evaluate(() => document.fonts.ready);
+ let shown = 0;
+ let hidden = 0;
+ for (let w = 519; w >= 280; w--) {
+ await page.setViewportSize({ width: w, height: 800 });
+ const r = await page.evaluate(() => {
+ const box = document.querySelector("header [data-header-brand] span.flex-wrap") as HTMLElement;
+ const wm = box.lastElementChild as HTMLElement;
+ return {
+ shown: wm.offsetTop < box.clientHeight - 1,
+ textRight: (wm.lastElementChild as HTMLElement).getBoundingClientRect().right,
+ boxRight: box.getBoundingClientRect().right,
+ over: wm.scrollWidth - wm.clientWidth,
+ };
+ });
+ if (!r.shown) {
+ hidden++;
+ continue;
+ }
+ shown++;
+ expect(r.over, `${w} px: the text overflows its box`).toBeLessThanOrEqual(1);
+ expect(r.textRight, `${w} px: the last letter is clipped`).toBeLessThanOrEqual(r.boxRight + 0.5);
+ }
+ expect(shown, "widths with the name").toBeGreaterThan(0);
+ expect(hidden, "widths without it").toBeGreaterThan(0);
+});
+
+// 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. Every real site title shows in full at 360 px
+// with one marked link, even under touch: below 520 px the bar's two gaps are
+// 8 px. Rekietalyzer and Hasanalyzer are the longest.
+const REAL_TITLES = [
+ { headerTitle: "Jeralyzer", wordmarkLead: "Jer" },
+ { headerTitle: "Anilyzer", wordmarkLead: "Ani" },
+ { headerTitle: "Bonnellyzer", wordmarkLead: "Bonnell" },
+ { headerTitle: "Hasanalyzer", wordmarkLead: "Hasan" },
+ { headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta" },
+ { headerTitle: "Jasolyzer", wordmarkLead: "Jaso" },
+] as const;
+const LONGEST = REAL_TITLES[4];
+const NARROW_TITLES = {
+ short: TITLES.short,
+ Bonnellyzer: REAL_TITLES[2],
+ Hasanalyzer: REAL_TITLES[3],
+ Rekietalyzer: LONGEST,
+};
+
+test.describe("the narrow header, touch, every real title", () => {
+ test.use({ hasTouch: true });
+ test("360 px, one marked link: the full name, the link, and nothing overflows", async ({ page }) => {
+ await installRoutes(page);
+ for (const title of REAL_TITLES) {
+ writeSite({ ...title, socialLinks: lastMarked(SIX.slice(0, 4)) });
+ await open(page, 360);
+ await page.evaluate(() => document.fonts.ready);
+ const at = `${title.headerTitle} at 360 px`;
+ const l = await layout(page);
+ expect(l.text, `${at}: the name`).toBe(true);
+ expect(l.scrolls, `${at}: the row scrolls`).toBe(false);
+ expect(l.page, `${at}: the page scrolls sideways`).toBeLessThanOrEqual(0);
+ expect(l.row, `${at}: the header row overflows`).toBeLessThanOrEqual(0);
+ expect(await labelsOf(headerRow(page)), at).toEqual(["Diamond"]);
+ await expect(page.getByRole("button", { name: "Open menu" }), at).toBeInViewport({ ratio: 1 });
+ }
+ });
+});
+
+for (const touch of [false, true]) {
+ test.describe(touch ? "the narrow header, touch" : "the narrow header, a mouse", () => {
+ test.use({ hasTouch: touch });
+ 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({ ...LONGEST, socialLinks: SIX.slice(0, 4) });
+ for (const w of [320, 360, 390]) {
+ await open(page, w);
+ expect((await layout(page)).text, `${w} px: the name`).toBe(true);
+ 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({ ...LONGEST, socialLinks: lastMarked(SIX.slice(0, 4)) });
+ for (const w of [280, 320, 360, 390, 430, 519, 520, 640, 768, 1023, 1024, 1280, 1400]) {
+ await open(page, w);
+ expect(await labelsOf(headerRow(page)), `${w} px`).toEqual(
+ 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: marked(SIX.slice(0, 4)) });
+ await open(page, 390);
+ expect(await layout(page)).toMatchObject({ text: false, scrolls: false });
+ await open(page, 1280);
+ expect((await layout(page)).text).toBe(true);
+});
+
+for (const width of [390, 1280]) {
+ test(`${width} px: the group's keys are 36 px, the toggle is the last, their boxes touch`, async ({
+ page,
+ }) => {
+ await installRoutes(page);
+ 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++) {
+ expect(Math.abs(boxes[i]!.x - (boxes[i - 1]!.x + boxes[i - 1]!.width))).toBeLessThanOrEqual(0.5);
+ }
+ });
+}
+
+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: marked(SIX.slice(0, 2)) });
+ await open(page, 390);
+ 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]);
+ }
+ });
+});
+
+test("keyboard focus draws the ring on a social key", async ({ page }) => {
+ await installRoutes(page);
+ writeSite({ ...TITLES.short, socialLinks: SIX.slice(0, 2) });
+ await open(page, 1280);
+ const first = headerRow(page).getByRole("link").first();
+ for (let i = 0; i < 30; i++) {
+ await page.keyboard.press("Tab");
+ if (await first.evaluate((el) => el === document.activeElement)) break;
+ }
+ const s = await first.evaluate((el) => {
+ const cs = getComputedStyle(el);
+ return { outline: cs.outlineStyle, shadow: cs.boxShadow };
+ });
+ expect(s.outline).toBe("none");
+ expect(s.shadow).toMatch(/0px 0px 0px 2px/);
+});
+
+test("the Archilyzer link goes to the official instances; no sites menu, hub link or theme menu", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await open(page, 1280);
+ const archilyzer = banner(page).getByRole("link", {
+ name: "Archilyzer — official instances",
+ exact: true,
+ });
+ await expect(archilyzer).toBeVisible();
+ await expect(archilyzer).toHaveText("Archilyzer");
+ await expect(archilyzer).toHaveAttribute("href", INSTANCES_URL);
+ await expect(archilyzer).not.toHaveAttribute("target", /.+/);
+ await expect(banner(page).getByRole("button", { name: /sites/i })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0);
+ await expect(banner(page).getByRole("link", { name: "Hub", exact: true })).toHaveCount(0);
+ // Below lg it is in the menu instead (responsive.spec), not the bar.
+ await open(page, 390);
+ await expect(archilyzer).toBeHidden();
+});
+
+test("Changelog is in the footer, not the header", async ({ page }) => {
+ await installRoutes(page);
+ await open(page, 1280);
+ await expect(banner(page).getByRole("link", { name: "Changelog" })).toHaveCount(0);
+ const footer = page.locator("footer");
+ const changelog = footer.getByRole("link", { name: "Changelog", exact: true });
+ await expect(changelog).toHaveAttribute("href", "/changelog");
+ // After Use with AI, in the same row.
+ const order = await footer
+ .getByRole("link")
+ .evaluateAll((els) => els.map((e) => e.textContent?.trim() ?? ""));
+ expect(order.indexOf("Changelog")).toBe(order.indexOf("Use with AI") + 1);
+});
+
+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);
+ writeSite({ ...TITLES.short, socialLinks: SIX });
+ await open(page, 1280);
+ 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 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)
+// inline an icon only if it passes the save path's check again; the rest show
+// their label, run nothing and fetch nothing from anywhere else.
+test("a hostile icon in a site's socialLinks is its label: nothing runs, nothing is fetched elsewhere", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ const hostile: RawLink[] = [
+ { label: "Hostile 1", url: "https://h1.example", svg: `<svg/onload="window.__hdrHostile=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>` },
+ { label: "Hostile 2", url: "https://h2.example", svg: `<svg viewBox="0 0 8 8"><img src="x:" onerror="window.__hdrHostile=1"></svg>` },
+ ...LOADS_ELSEWHERE.slice(0, 2).map((name, i) => ({
+ label: `Hostile ${i + 3}`,
+ url: `https://h${i + 3}.example`,
+ svg: ADVERSARIAL[name],
+ })),
+ ];
+ writeSite({ ...TITLES.short, socialLinks: hostile });
+ const elsewhere: string[] = [];
+ page.on("request", (r) => {
+ const u = new URL(r.url());
+ if (u.protocol.startsWith("http") && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") {
+ elsewhere.push(r.url());
+ }
+ });
+ await open(page, 1280);
+ await page.waitForLoadState("networkidle");
+ for (const l of hostile) {
+ await expect(headerRow(page).getByRole("link", { name: l.label, exact: true })).toHaveText(l.label);
+ }
+ expect(await page.evaluate(() => (window as unknown as { __hdrHostile?: number }).__hdrHostile)).toBeUndefined();
+ expect(elsewhere.filter((u) => u.startsWith(EVIL)), "requests to the icons' origin").toEqual([]);
+ expect(elsewhere, "requests to any other origin").toEqual([]);
+});
diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts
@@ -1,6 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
import { expectModalOpen, installRoutes } from "./helpers";
+import { INSTANCES_URL } from "../../common/lib/project";
// The phone. Every other spec in this suite runs at the project's 1440×1200
// desktop viewport, which is exactly why the export site could ship a header
@@ -82,18 +83,26 @@ test.describe("phone layout", () => {
});
}
- test("the header menu carries the nav that the wide header shows inline", async ({
+ test("the header menu carries the nav that the wide header shows inline, and nothing else", async ({
page,
}) => {
await page.goto("/");
+ // The theme is the bar's toggle, beside the trigger.
+ await expect(page.getByRole("banner").getByRole("button", { name: /^switch to /i })).toBeVisible();
await page.getByRole("button", { name: "Open menu" }).click();
const menu = page.getByRole("dialog");
await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible();
await expect(menu.getByRole("link", { name: "Search" })).toBeVisible();
- // The theme picker is a dropdown in the wide header; here it is two plain
- // radio lists (Base, Accent), because a popover inside a dialog is a
- // focus-trap fight.
- await expect(menu.getByRole("radio", { name: "Sepia" })).toBeVisible();
+ // The Archilyzer home's official instances, where the sites list was.
+ await expect(
+ menu.getByRole("link", { name: "Archilyzer — official instances", exact: true }),
+ ).toHaveAttribute("href", INSTANCES_URL);
+ // No theme radios; Changelog, the sibling sites and the hub are not here.
+ await expect(menu.getByRole("radiogroup")).toHaveCount(0);
+ await expect(menu.getByRole("radio")).toHaveCount(0);
+ await expect(menu.getByRole("link", { name: "Changelog" })).toHaveCount(0);
+ await expect(menu.getByRole("link", { name: "Hub" })).toHaveCount(0);
+ await expect(menu.getByText("Sites", { exact: true })).toHaveCount(0);
});
test("the filters sheet applies a filter and the results change", async ({
diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts
@@ -26,7 +26,7 @@ test("footer renders the site's social links", async ({ page }) => {
await installRoutes(page);
await page.goto("/");
await expect(
- page.getByRole("link", { name: "GitHub" }),
+ page.locator("footer").getByRole("link", { name: "GitHub" }),
).toHaveAttribute("href", "https://github.com/example");
});
diff --git a/export/e2e/theme-accent.spec.ts b/export/e2e/theme-accent.spec.ts
@@ -1,17 +1,19 @@
+import fs from "node:fs";
+import path from "node:path";
import { test, expect, type Page } from "@playwright/test";
import { ACCENTS, MIN_ACCENT_CONTRAST, contrastRatio } from "../../common/lib/brand";
import { resolveAccent } from "../../common/lib/accent";
import { installRoutes } from "./helpers";
-// The ThemeMenu's two radio groups. A site opens in its OWN accent (the
-// fixture's site.json sets the custom hex #cc3366, so the layout renders
-// data-accent="custom" and the menu offers "Site colour" first, tagged
-// "default"). A reader's pick of a named accent persists and is applied before
-// paint; picking the site's own again REMOVES the stored key, so the reader
-// follows the site from then on. The base group sits in the same menu.
+// A site shows its OWN accent, and a reader does not pick one. The fixture's
+// site.json sets the custom hex #cc3366, so the layout renders
+// data-accent="custom" with the hex fitted to each ground inline; `--brand`
+// follows the base in force. A reader's stored pick from an earlier build is
+// ignored — before paint and after hydration — and left where it is.
const ACCENT_KEY = "ytdlp-tb:accent";
-const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, sepia, dark}
+const BASE_KEY = "ytdlp-tb:base";
+const FIXTURE = resolveAccent("#cc3366"); // {id: "custom", light, dark}
async function state(page: Page) {
return page.evaluate((key) => {
@@ -27,136 +29,82 @@ async function state(page: Page) {
}, ACCENT_KEY);
}
-// Open the menu, absorbing a pre-hydration lost click on the trigger.
-async function openMenu(page: Page) {
- const trigger = page.getByRole("button", { name: "Choose theme" });
- const menu = page.getByRole("menu");
- await expect(async () => {
- if (!(await menu.isVisible())) await trigger.click();
- await expect(menu).toBeVisible({ timeout: 1_000 });
- }).toPass({ timeout: 10_000 });
- return menu;
-}
-
-// An item's accessible name is its label, plus "default" on the site's own
-// accent (the visible tag) — so match the label at the start.
-async function pick(page: Page, label: string) {
- const menu = await openMenu(page);
- await menu.getByRole("menuitemradio", { name: new RegExp(`^${label}\\b`) }).click();
- await expect(menu).toBeHidden();
+async function onBase(page: Page, base: "light" | "dark") {
+ await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]);
+ await page.reload({ waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ await page.waitForLoadState("load");
}
-test("the menu offers four bases and the site's colour first, then the seven accents", async ({
- page,
-}) => {
- await page.emulateMedia({ colorScheme: "light" });
- await page.goto("/");
- const menu = await openMenu(page);
-
- const base = menu.getByRole("group", { name: "Base" });
- for (const name of ["System", "Light", "Sepia", "Dark"]) {
- await expect(base.getByRole("menuitemradio", { name, exact: true })).toBeVisible();
- }
- await expect(base.getByRole("menuitemradio", { name: "System", exact: true })).toHaveAttribute(
- "aria-checked",
- "true",
- );
-
- const accent = menu.getByRole("group", { name: "Accent" });
- const items = accent.getByRole("menuitemradio");
- await expect(items).toHaveCount(8);
- // The custom-hex site's own colour comes first, checked, with its tag.
- await expect(items.first()).toHaveText(/Site colour\s*default/);
- await expect(items.first()).toHaveAttribute("aria-checked", "true");
- for (const a of Object.values(ACCENTS)) {
- await expect(accent.getByRole("menuitemradio", { name: a.name, exact: true })).toBeVisible();
- }
-});
-
-test("pick Violet: it persists before paint, and --brand is violet on each base", async ({
+test("a site wears its own accent, fitted to each base, and offers no accent picker", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
await expect.poll(() => state(page)).toMatchObject({
accent: "custom",
+ base: "light",
stored: null,
brand: FIXTURE.light,
});
+ await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0);
+ await expect(page.getByRole("menuitemradio")).toHaveCount(0);
- await pick(page, "Violet");
- await expect.poll(() => state(page)).toMatchObject({
- accent: "violet",
- stored: "violet",
- base: "light",
- brand: ACCENTS.violet.onLight,
- });
-
- // The reload applies the pick on the first commit, before React hydrates —
- // and hydration does not put the site's own accent back.
- await page.reload({ waitUntil: "commit" });
- await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
- expect((await state(page)).accent).toBe("violet");
- await page.waitForLoadState("load");
- await expect.poll(() => state(page)).toMatchObject({ accent: "violet", brand: ACCENTS.violet.onLight });
-
- // Sepia, from the same menu: the ground changes and the accent keeps its
- // on-sepia value.
- await pick(page, "Sepia");
- await expect.poll(() => state(page)).toMatchObject({
- base: "sepia",
- accent: "violet",
- background: "#f4ecd8",
- brand: ACCENTS.violet.onSepia,
- });
- expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:base"))).toBe("sepia");
-
- await pick(page, "Dark");
+ await onBase(page, "dark");
await expect.poll(() => state(page)).toMatchObject({
+ accent: "custom",
base: "dark",
background: "#0c0a08",
- brand: ACCENTS.violet.onDark,
+ brand: FIXTURE.dark,
});
});
-test("picking the site's own colour again removes the stored accent", async ({
+// Every value `data-accent` ever holds is recorded by a MutationObserver
+// installed before the page's first script, so a flash between two samples
+// cannot pass.
+test("a stored accent from before is ignored, with no flash, and left in place", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "light" });
- await page.goto("/");
- await pick(page, "Brass");
- await expect.poll(() => state(page)).toMatchObject({ accent: "brass", stored: "brass" });
-
- await pick(page, "Site colour");
- await expect.poll(() => state(page)).toMatchObject({
- accent: "custom",
- stored: null,
- brand: FIXTURE.light,
- });
-
- // With nothing stored, the site's colour is fitted to each ground.
- await pick(page, "Sepia");
- await expect.poll(() => state(page)).toMatchObject({ base: "sepia", brand: FIXTURE.sepia });
- await pick(page, "Dark");
- await expect.poll(() => state(page)).toMatchObject({ base: "dark", brand: FIXTURE.dark });
+ await page.addInitScript((key) => {
+ try {
+ localStorage.setItem(key, "violet");
+ } catch {}
+ const held: (string | null)[] = [];
+ (window as unknown as { __accents: (string | null)[] }).__accents = held;
+ new MutationObserver((records) => {
+ for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue);
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-accent"],
+ attributeOldValue: true,
+ });
+ }, ACCENT_KEY);
+ await page.goto("/", { waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ expect((await state(page)).accent).toBe("custom");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ const held = await page.evaluate(() => [
+ ...(window as unknown as { __accents: (string | null)[] }).__accents,
+ document.documentElement.getAttribute("data-accent"),
+ ]);
+ expect(held.filter((v) => v !== null && v !== "custom"), JSON.stringify(held)).toEqual([]);
+ expect(await state(page)).toMatchObject({ accent: "custom", stored: "violet", brand: FIXTURE.light });
});
// The search bar's "Press Enter or click Search to apply" wears the site's
// accent (release 10). It was `text-warning`, the same yellow on every site.
-// It follows the reader's accent and base like any `text-brand`, and stays
-// readable on the page ground it sits on.
-test("the unapplied-search hint wears the accent, readable on each base", async ({
+// It follows the base like any `text-brand`, and stays readable on the page
+// ground it sits on.
+test("the unapplied-search hint wears the site's accent, readable on each base", async ({
page,
}) => {
await installRoutes(page);
await page.emulateMedia({ colorScheme: "light" });
await page.goto("/");
const hint = page.getByText("Press Enter or click Search to apply");
- // Typing is an unapplied edit (the input mounts after hydration).
- await page.locator('input[data-testid^="leaf-query-"]').first().fill("alpha");
- await expect(hint).toBeVisible();
- await expect(hint).toHaveClass(/(^|\s)text-brand(\s|$)/);
- await expect(hint).not.toHaveClass(/text-warning/);
// The hint's colour and the ground under it, as "#rrggbb".
const paint = () =>
@@ -173,19 +121,65 @@ test("the unapplied-search hint wears the accent, readable on each base", async
};
});
const expectAccent = async (value: string) => {
+ // Typing is an unapplied edit (the input mounts after hydration).
+ await page.locator('input[data-testid^="leaf-query-"]').first().fill("alpha");
+ await expect(hint).toBeVisible();
+ await expect(hint).toHaveClass(/(^|\s)text-brand(\s|$)/);
+ await expect(hint).not.toHaveClass(/text-warning/);
await expect.poll(async () => (await paint()).color).toBe(value);
const { color, ground } = await paint();
expect(contrastRatio(color, ground)).toBeGreaterThanOrEqual(MIN_ACCENT_CONTRAST);
};
- // The fixture site's own (custom) colour, fitted to the light ground…
+ // The fixture site's own (custom) colour, fitted to each ground.
await expectAccent(FIXTURE.light);
- // …a reader's pick…
- await pick(page, "Brass");
- await expectAccent(ACCENTS.brass.onLight);
- // …and each base's own value of it.
- await pick(page, "Sepia");
- await expectAccent(ACCENTS.brass.onSepia);
- await pick(page, "Dark");
- await expectAccent(ACCENTS.brass.onDark);
+ await onBase(page, "dark");
+ await expectAccent(FIXTURE.dark);
+});
+
+// A site with a NAMED accent (the fixture's is a custom hex): its accent's own
+// value on each base, and a reader's stored pick from before does not override
+// it. The fixture site is rewritten for the test and put back after; the
+// original rides along in `_e2ePristine`, which playwright.config.ts restores
+// if a run dies mid-test.
+test.describe("a site with a named accent", () => {
+ const SITE_FILE = path.resolve(process.cwd(), "e2e", "fixtures", "sites", "testsite", "site.json");
+ let pristine = "";
+ test.describe.configure({ mode: "serial" });
+ test.beforeAll(() => {
+ pristine = fs.readFileSync(SITE_FILE, "utf8");
+ });
+ test.afterEach(() => {
+ fs.writeFileSync(SITE_FILE, pristine);
+ });
+
+ test("wears its accent's value on Light and on Dark, and a stored pick does not override it", async ({
+ page,
+ }) => {
+ const site = JSON.parse(pristine) as Record<string, unknown>;
+ fs.writeFileSync(
+ SITE_FILE,
+ `${JSON.stringify({ ...site, accent: "violet", _e2ePristine: pristine }, null, 2)}\n`,
+ );
+ await page.addInitScript((key) => {
+ try {
+ localStorage.setItem(key, "brass");
+ } catch {}
+ }, ACCENT_KEY);
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "violet",
+ base: "light",
+ stored: "brass",
+ brand: ACCENTS.violet.onLight,
+ });
+ await onBase(page, "dark");
+ await expect.poll(() => state(page)).toMatchObject({
+ accent: "violet",
+ base: "dark",
+ stored: "brass",
+ brand: ACCENTS.violet.onDark,
+ });
+ });
});
diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts
@@ -1,8 +1,9 @@
import { test, expect, type Locator, type Page } from "@playwright/test";
+import { RETIRED_BASE } from "../../common/components/themeConfig";
// A published site opens on the reader's SYSTEM base (tokens.css resolves it
// to the light or dark block) and follows the OS live. The base toggle cycles
-// system → light → sepia → dark → system; every choice persists across a
+// system → light → dark → system; every choice persists across a
// reload and is applied by the pre-paint ThemeScript before hydration — the
// reload waits on `data-theme-ready`, which the script sets last.
@@ -59,7 +60,7 @@ test("a site opens on the system base and follows the OS live", async ({
await expect.poll(async () => (await state(page)).base).toBe("light");
});
-test("the toggle cycles the four bases; each persists across a reload with no flash", async ({
+test("the toggle cycles the three bases; each persists across a reload with no flash", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "light" });
@@ -68,8 +69,7 @@ test("the toggle cycles the four bases; each persists across a reload with no fl
await expect(toggle).toBeVisible();
const steps = [
- { stored: "light", base: "light", dark: false, background: "#f3f6f7", next: "Switch to sepia" },
- { stored: "sepia", base: "sepia", dark: false, background: "#f4ecd8", next: "Switch to dark" },
+ { stored: "light", base: "light", dark: false, background: "#f3f6f7", next: "Switch to dark" },
{ stored: "dark", base: "dark", dark: true, background: "#0c0a08", next: "Switch to system" },
// Back to system: stored as "system", resolved against the (light) OS.
{ stored: "system", base: "light", dark: false, background: "#f3f6f7", next: "Switch to light" },
@@ -110,6 +110,49 @@ test("the retired theme/mode keys migrate once, before paint", async ({
theme: localStorage.getItem("ytdlp-tb:theme"),
mode: localStorage.getItem("ytdlp-tb:mode"),
}));
- // archive + light was the paper look: it becomes sepia, and the old keys go.
- expect(s).toEqual({ base: "sepia", stored: "sepia", theme: null, mode: null });
+ // archive + light was the paper look, the retired third ground: it becomes
+ // light, and the old keys go.
+ expect(s).toEqual({ base: "light", stored: "light", theme: null, mode: null });
+});
+
+// The retired third ground: a reader who chose it gets Light, before first
+// paint, with no other ground on the way, and the stored value becomes
+// "light" once. Every value `data-base` holds is recorded by a
+// MutationObserver installed before the page's first script.
+test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.addInitScript(
+ ([key, retired]) => {
+ try {
+ localStorage.setItem(key, retired);
+ } catch {}
+ const held: (string | null)[] = [];
+ (window as unknown as { __bases: (string | null)[] }).__bases = held;
+ new MutationObserver((records) => {
+ for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue);
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-base"],
+ attributeOldValue: true,
+ });
+ },
+ [BASE_KEY, RETIRED_BASE],
+ );
+ await page.goto("/", { waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ const held = await page.evaluate(() => [
+ ...(window as unknown as { __bases: (string | null)[] }).__bases,
+ document.documentElement.getAttribute("data-base"),
+ ]);
+ // None (a site's server markup has no base: it is the reader's system), then
+ // light — never the retired ground, never dark from the OS.
+ expect(held.filter((v) => v !== null && v !== "light"), JSON.stringify(held)).toEqual([]);
+ expect(held.at(-1)).toBe("light");
+ expect(await state(page)).toMatchObject({ base: "light", dark: false, stored: "light" });
});
diff --git a/export/playwright.config.ts b/export/playwright.config.ts
@@ -28,11 +28,16 @@ buildFixtureSettings(TEST_SETTINGS_FILE);
// fixture site for one test and restores it afterwards. A run killed inside that
// test leaves the key behind, and every modal spec before it would then fail on
// the missing controls — so strip it here, before any spec runs.
+// header.spec rewrites the fixture's title and social links for a test and
+// carries the original file in `_e2ePristine` (parseSite drops the key): a run
+// killed inside it is put back here the same way.
const FIXTURE_SITE = path.join(TEST_SITES_DIR, "testsite", "site.json");
{
const raw = fs.readFileSync(FIXTURE_SITE, "utf8");
const site = JSON.parse(raw) as Record<string, unknown>;
- if ("transcriptDownloads" in site) {
+ if (typeof site._e2ePristine === "string") {
+ fs.writeFileSync(FIXTURE_SITE, site._e2ePristine);
+ } else if ("transcriptDownloads" in site) {
delete site.transcriptDownloads;
fs.writeFileSync(FIXTURE_SITE, `${JSON.stringify(site, null, 2)}\n`);
}
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -1,32 +1,35 @@
# Homepage Changelog
## [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.
-- **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, Sepia, 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.
+- **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; the switch is 32.5rem, so at a larger text size it comes later), 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.
-- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light, Sepia and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5).
-- **The growth chart's strata are parted by lines in the text colour.** The line along each stratum's upper edge was drawn in the page's background colour, so it was light on Light and dark on Dark; it is now a 1 px line in the ground's foreground (dark on Light and Sepia, light on Dark, the system's text colour in high-contrast mode). The gridlines are unchanged.
+- **Each Official Instances card names its site with the site's wordmark.** The first part of the name is set heavy in the site's own accent and the rest light, as the site's own header sets it (Jer·alyzer, Hasan·alyzer, …), at the card title's size; the accent is fitted to the ground in force and reads above 4:1 on the card on Light and Dark. A site with no configured lead, or a summary built before this, shows its title plain as before. `homepage-summary.json` gains an optional `wordmarkLead` per site (still version 5).
+- **The growth chart's bands are separated by a 2 px gap in the ground's colour, where both bands can spare it.** The gap runs along a band's upper edge where another band sits on it, the same 2 px at every width (it was a 1.25 px line in the page colour). It is drawn only where both bands keep at least a pixel of their own colour, measured at right angles to the edge at the narrowest width the chart is drawn at: on a steep month, and beside the thinnest instances, the bands touch instead, so no band is ever covered, and the gap shows in stretches rather than all along. In high-contrast mode the gap is the system's background colour. The gridlines are unchanged. The `/stats` charts' stacked bars get the same 2 px gap in the chart panel's colour; their stacked areas keep their coloured top lines.
- **Larger social links, with a focus ring.** Each icon, in the header and the footer, is a 36 px target around its 20 px glyph (44 px on a touch screen), in the muted text colour and the text colour on hover; the footer's were 20 px, in the faint colour, with no ring. Keyboard focus draws a 2 px ring in the accent, and in high-contrast mode the browser's own focus outline. An icon of two or more colours keeps its colours, and every icon paints inside its own box. A stored icon that fails the check a save runs is shown as its label (at most 10rem, with an ellipsis) instead.
-- **The e2e no longer reads the checkout's `settings.json` or `homepage.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient with an outline, one colour, and a two-colour disc pasted with only its size), put through the same check a save runs; and `SITES_DIR` points at an empty directory. `e2e/social.spec.ts` covers the header and footer rows (at every width, with 1, 3 and 4 links, both pointers; the scroll fallback; hostile stored icons that must neither run nor fetch), `e2e/toggle.spec.ts` the theme toggle and the pinned accent, `e2e/svg-vectors.spec.ts` that every accepted icon stays inside its `<svg>` in a real parse, `e2e/growth-chart.spec.ts` the chart's lines, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the ground through one helper, `chooseTheme` (`e2e/helpers.ts`).
+- **Tab no longer stops on the header's scrolling boxes in Firefox.** When the social icons' box or the nav's rule under the header overflows (a screen under about 300 px, or a large text size), Firefox made it a tab stop with no name of its own; the icons and links inside are the stops now, and each scrolls into view as it takes focus.
+- **The e2e no longer reads the checkout's `settings.json` or `homepage.json`.** Its dev server reads `e2e/.e2e-settings.json` (`SETTINGS_FILE`), written by `e2e/fixture-social.ts`: three synthetic icons (a gradient with an outline, one colour, and a two-colour disc pasted with only its size), put through the same check a save runs; and `SITES_DIR` points at an empty directory. `e2e/social.spec.ts` covers the header and footer rows (at every width, with 1, 3 and 4 links, both pointers; the scroll fallback; hostile stored icons that must neither run nor fetch), `e2e/toggle.spec.ts` the theme toggle and that a stored accent is ignored, `e2e/svg-vectors.spec.ts` that every accepted icon stays inside its `<svg>` in a real parse, `e2e/growth-chart.spec.ts` the chart's gaps and that it paints its true peak and every band, and `e2e/instance-wordmark.spec.ts` the cards' names; specs change the ground through one helper, `chooseTheme` (`e2e/helpers.ts`).
- **A site's card counts every transcript, and never shows 0 channels while it serves recordings.** A transcript that arrived after its video was first indexed, or a video with YouTube captions alone, could be left out of the family's numbers: one site served 1,889 recordings and its card said 0 transcripts, 0 channels and 0 hours. Such transcripts are counted now — in the card, the family totals and the archive-growth chart — and one with no transcription date is left off only what is placed by that date: the charts by transcription date, "this month" and the recent list. The official-instance figures on the hub move with them.
- **The source is on the site, with its history: `/source/`.** A new **Source** page (and nav entry) gives `git clone https://archilyzer.pages.dev/source/archilyzer.git`, a read-only mirror of the main branch regenerated with every deploy, with its head, the private commit it reflects, a link to browse every file raw at `/source/tree/`, and the tarball with its size and sha256. Commit ids differ from the private repository's, because machine paths are scrubbed on the way out, and the page says so. A build without a published source says "No source published in this build." instead of offering a clone. The Downloads tarball is now regenerated by every build (its commit is the mirror's), and the page points at the mirror for history. The docs that said there is no public repository (*Install*, the FAQ, *What is Archilyzer*) now say how to clone. Below `md` the header's nav drops to its own row, as it did below `sm`, because five labels no longer fit beside the wordmark. `_headers` serves the raw tree as plain text.
- **The docs' *Building several sites at once* page says what Build all does.** It called the container pipeline opt-in, turned on in the settings. Build all sites builds every site in parallel in containers whenever a container engine is available, and one after another when none is; there is nothing to switch on.
- **A single-colour social icon shows on every ground.** The footer's social icons are the operator's (`homepage.json`'s, else `settings.socialLinks`), normalized when they are saved (`normalizeSocialSvg`, release 11 slice O1). An icon drawn in one colour now takes the footer's colour throughout; before, a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted. "No fill", gradients, masks, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma follows the footer too. It applies when the settings are next saved, then needs a rebuild and deploy of the homepage.
- **In high-contrast mode the header mark's tile keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the slate tile, whose ring is only drawn on Dark. In that mode the tile gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode.
-- **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light and Sepia, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on all three grounds for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too.
-- **A custom-hex accent reads on every ground.** An Official Instances card whose site sets its own hex painted it exactly as set, so a pale one was all but invisible on Light and Sepia. It is now fitted to each ground the way the site's own pages fit it (4.5:1, `resolveAccent`). No live site uses one.
+- **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on each ground for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too.
+- **A custom-hex accent reads on every ground.** An Official Instances card whose site sets its own hex painted it exactly as set, so a pale one was all but invisible on Light. It is now fitted to each ground the way the site's own pages fit it (4.5:1, `resolveAccent`). No live site uses one.
- **The e2e no longer needs the operator's data.** It reads a synthetic summary built by the real summary builder (`e2e/fixture-summary.ts`, six sites, deterministic) instead of `public/homepage-summary.json`, so a fresh clone runs every spec instead of skipping eight. `e2e/fixture-accents.ts` is gone.
-- **On the Dark ground the header mark's tile has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light and Sepia are unchanged, and so are the icons.
+- **On the Dark ground the header mark's tile has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light is unchanged, and so are the icons.
- **The lines inside the Archilyzer mark are easier to see.** The header mark's and the icons' three unlit lines now read at 3:1 against the slate tile instead of 2:1.
-- **The homepage opens on the Dark ground in Signal, even with JavaScript off, and a reader can pick another ground or accent.**
- The theme menu has **Base** (System, Light, Sepia, Dark) and **Accent** (the seven named
- accents, Signal tagged *default*); the toggle cycles System → Light → Sepia → Dark. The old
- Archilyzer theme family is gone, and so are the other four: the dark ground is now the warm ink
- the archives used. Type is Archivo, IBM Plex Sans and IBM Plex Mono. The chart's third colour
- is a violet, well clear of the "gone" red, and the chart stacks its instances in palette order,
- so no two touching layers are hard to tell apart for a colour-blind reader. A stored theme from
- before carries over once. The docs' *Operate* page describes the accent and the reader's menu.
+- **The homepage opens on the Dark ground in Signal, even with JavaScript off, and a reader can pick another ground.**
+ The header's toggle cycles System → Light → Dark; the accent is the homepage's own,
+ Signal. The old Archilyzer theme family is gone, and so are the other four: the dark ground is
+ now the warm ink the archives used. Type is Archivo, IBM Plex Sans and IBM Plex Mono. The
+ chart's third colour is a violet, well clear of the "gone" red, and the chart stacks its
+ instances in palette order, so no two touching layers are hard to tell apart for a colour-blind
+ reader. A stored theme from before carries over once. The docs' *Operate* page describes the
+ accent.
- **The Found-line mark.** The header's CSS triangle is now the family's parent mark (bone
on slate) and "ARCHILYZER" is the split wordmark "Archi|lyzer" — heavy lead, light
suffix, no longer tracked uppercase; the link is still named "Archilyzer home". The
diff --git a/homepage/app/changelog/page.tsx b/homepage/app/changelog/page.tsx
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
function loadChangelog(): string | null {
try {
return readFileSync(
- path.join(process.cwd(), "..", "export", "CHANGELOG.md"),
+ path.join(/* turbopackIgnore: true */ process.cwd(), "..", "export", "CHANGELOG.md"),
"utf8",
);
} catch {
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -15,7 +15,7 @@ import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
// siteColor() — a named accent as `var(--swatch-<id>)`, its value on the base
// in force, from the summary's `accentId`; a custom hex fitted to each base as
// the site's own pages fit it (release 11: a pale hex published as is was
-// ~1.4:1 on light and sepia); a site with no accent its chart colour. The
+// ~1.4:1 on light); a site with no accent its chart colour. The
// hub's official cards wear the same (lib/hubSummary.ts officialInstances).
//
// A CARD STILL READS AS ITS LAYER'S LEGEND, BY HUE. The growth chart draws
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -4,6 +4,7 @@ import type {
} from "yt-dlp-transcript-common/lib/homepageSummary";
import { monthLabel } from "yt-dlp-transcript-common/lib/homepageChart";
import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
+import { PLOT_SIZES, gapSegments, growthStack, runsOf } from "../lib/growthGaps";
// The front page's showpiece: every official instance's back catalogue as
// stacked strata, one month per step, from the oldest upload to the last
@@ -40,39 +41,23 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// (Jeralyzer Brass, Anilyzer Sakura, Bonnellyzer Blue, Hasanalyzer Violet,
// Rekietalyzer Green; Jasolyzer Vermilion once it is published):
// • adjacent — the stack — in today's order (Jeralyzer, Anilyzer,
-// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on every base, worst CVD
-// ΔE 13.1 / 11.1 / 11.0 and normal 17.7 / 15.3 / 16.7 (light / sepia /
-// dark; today's mapping — release 10's slice MC, re-run in release 11);
+// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on both bases, worst CVD
+// ΔE 13.1 / 11.0 and normal 17.7 / 16.7 (light / dark; today's mapping —
+// release 10's slice MC, re-run in release 11);
// with Jasolyzer at any place in it: PASS, worst CVD
-// 12.5 / 9.1 / 11.0, normal 16.6 / 15.3 / 16.3 (release 11);
+// 12.5 / 11.0, normal 16.6 / 16.3 (release 11);
// • all pairs, all six: the palette's own borderline in any order — CVD 6.2
-// / 6.1 / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with
-// the legend, the surface-gap edges and the table), normal ≥ 15.3. The
-// sixth slot adds no pair under the target: its worst is CVD 9.1 (sepia,
-// against green), normal 16.3 (dark, against amber).
+// / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with the
+// legend, the surface-gap edges and the table), normal ≥ 15.3. The sixth
+// slot adds no pair under the target (measured on three bases, before the
+// third was retired): its worst normal pair is 16.3 (dark, against
+// amber).
// The accents' own values fail as a chart palette (Brass↔Vermilion ΔE 1.0
// deutan, Blue↔Violet 8.5 normal), which is why the chart wears their hue
// families rather than the accents.
const W = 1000;
const H = 300;
-const STACK_ORDER = [0, 1, 2, 3, 4];
-
-function stackOrder(n: number): number[] {
- const head = STACK_ORDER.filter((i) => i < n);
- const tail = Array.from({ length: Math.max(0, n - 5) }, (_, k) => k + 5);
- return [...head, ...tail];
-}
-
-// A clean tick step giving three or four gridlines under `max`.
-function niceStep(max: number): number {
- const raw = max / 3.5;
- const pow = 10 ** Math.floor(Math.log10(raw));
- for (const m of [1, 2, 2.5, 5, 10]) {
- if (m * pow >= raw) return m * pow;
- }
- return 10 * pow;
-}
export function ArchiveGrowthChart({
months,
@@ -84,15 +69,8 @@ export function ArchiveGrowthChart({
const n = months.length;
if (n === 0 || sites.length === 0) return null;
- const totals = months.map((m) =>
- sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0),
- );
- const peak = Math.max(...totals);
+ const { totals, peak, yMax, ticks, order, stack: stacked } = growthStack(months, sites);
if (peak === 0) return null;
- const step = niceStep(peak);
- const yMax = Math.ceil(peak / step) * step;
- const ticks: number[] = [];
- for (let t = step; t <= yMax; t += step) ticks.push(t);
const x = (i: number) => (n === 1 ? W / 2 : (i / (n - 1)) * W);
const y = (v: number) => H - (v / yMax) * H;
@@ -101,22 +79,29 @@ export function ArchiveGrowthChart({
// One colour per site, in `sites` order (the legend's too).
const colors = siteChartColors(sites);
- // Layers bottom-up, each with its lower and upper edge per month.
- const order = stackOrder(sites.length);
- const base = new Array<number>(n).fill(0);
- const layers = order.map((si) => {
- const site = sites[si];
- const lo = base.slice();
- const hi = months.map((m, i) => (base[i] += m.bySite[site.siteId] ?? 0));
+ const layers = stacked.map(({ lo, hi }, k) => {
+ const si = order[k];
const top = hi.map((v, i) => `${r(x(i))},${r(y(v))}`);
const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse();
return {
- site,
+ site: sites[si],
color: colors[si],
+ top,
area: `M${top.join("L")}L${bottom.join("L")}Z`,
- edge: `M${top.join("L")}`,
};
});
+ // The gaps, one set per plot height (lib/growthGaps.ts says where a gap is
+ // drawn, and why a thin band gets none).
+ const gapSets = PLOT_SIZES.map((size) => {
+ const segments = gapSegments(stacked, yMax, size);
+ const paths = layers.map((l, k) => ({
+ key: l.site.siteId,
+ d: runsOf(segments[k])
+ .map((run) => `M${[...run, run.at(-1)! + 1].map((i) => l.top[i]).join("L")}`)
+ .join(""),
+ }));
+ return { px: size.px, className: size.className, paths: paths.filter((p) => p.d) };
+ });
// Year ticks at each January. Every second year from sm up, every fourth on a
// phone; the ends are skipped so a label never hangs off the plot.
@@ -180,26 +165,32 @@ export function ArchiveGrowthChart({
{layers.map((l) => (
<path key={l.site.siteId} d={l.area} fill={l.color} />
))}
- {/* The separators: each layer's upper edge is a 1 px hairline in
- the ground's FOREGROUND (the page text's colour) at full
- strength — dark on Light and Sepia, light on Dark. Colour and
- width are `.growth-sep` in globals.css (CanvasText in forced
- colours), so they follow the theme with no script. Full
- strength because only it reaches 3:1 against any band (60 %
- and 35 % reach it against none); 1 px, not the 1.25 px the
- old page-coloured gap was, so a 2–3 px stratum keeps its
- colour between two lines. Against some bands even the
- foreground stays under 3:1 (Light: violet, rust; Sepia:
- green, violet, magenta, rust; Dark: green, violet, amber). */}
- {layers.map((l) => (
- <path
- key={`${l.site.siteId}-edge`}
- d={l.edge}
- className="growth-sep"
- fill="none"
- strokeLinejoin="round"
- vectorEffect="non-scaling-stroke"
- />
+ {/* THE SURFACE GAP (the marks spec): touching bands are parted by
+ a 2 px gap in the colour behind the plot — the page ground, as
+ the chart sits on it — never by a line of their own. It runs
+ along each band's upper edge, centred, so each neighbour gives
+ 1 px. Where nothing sits on a band (the stack's top meets the
+ surface itself), or where either band is too thin, measured at
+ right angles to the edge at the narrowest plot of that height,
+ to give its pixel and keep one of its own colour (one set of
+ gaps per height, shown by its class; lib/growthGaps.ts), there
+ is no gap: thin bands touch rather than vanish. Colour and
+ width are
+ `.growth-gap` in globals.css (Canvas in forced colours), so
+ they follow the theme with no script. */}
+ {gapSets.map((set) => (
+ <g key={set.px} data-plot-height={set.px} className={set.className}>
+ {set.paths.map((p) => (
+ <path
+ key={p.key}
+ d={p.d}
+ className="growth-gap"
+ fill="none"
+ strokeLinejoin="round"
+ vectorEffect="non-scaling-stroke"
+ />
+ ))}
+ </g>
))}
{months.map((m, i) => {
const w = W / n;
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -45,33 +45,38 @@ function NavList({ className }: { className?: string }) {
// the operator's links (common/components/SocialLinks.tsx) and then the toggle
// 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. The homepage offers no accent control: its accent is pinned
-// (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):
+// 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
+// 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
-// four links fit a 320 px rule, so nothing scrolls).
-// ON A VERY SMALL SCREEN THE HEADER MAKES ROOM FIRST: the wordmark's TEXT is
-// hidden, and the mark stays (the link keeps its name, "Archilyzer home"), when
-// the bar — a size container, `@container/bar` — is narrower than the full
-// wordmark, a 12 px gap and the group need. That depends on how many links the
-// header shows and on the pointer, so the class is chosen by count from
-// WORDMARK_FITS (the bar's content width in px at the default text size,
-// 152 + 12 + (n + 1) keys; the classes say it in rem):
-// links mouse (36 px keys) touch (44 px keys) viewport, below 640 px
+// < 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 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
// 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
@@ -79,18 +84,28 @@ 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() {
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 +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[shown] ?? 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">
@@ -111,9 +126,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-[32.5rem]:hidden">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="narrow"
+ className="w-max px-1 [direction:ltr]"
+ />
+ </SocialScroll>
+ )}
+ {wide > 0 && (
+ <SocialScroll className="hidden min-[32.5rem]:block">
+ <SocialLinks
+ links={socialLinks}
+ placement="header"
+ width="wide"
+ className="w-max px-1 [direction:ltr]"
+ />
</SocialScroll>
)}
<ThemeToggle variant="bare" />
@@ -125,10 +155,13 @@ export default function Header() {
fit a 320 px rule. Only one of the two navs is ever in the
accessibility tree (the other is display:none), but they carry
distinct labels so a test or a screen reader can never conflate them.
- `overflow-x-auto` only guards a larger text setting; nothing scrolls
- at the default size. */}
+ `overflow-x-auto` only guards a larger text setting or a screen
+ under 320 px; nothing scrolls at the default size from 320 px.
+ `tabIndex={-1}`: Firefox makes a scroll container that overflows a
+ tab stop of its own; the links are the stops (as SocialScroll). */}
<nav
aria-label="Main, compact"
+ tabIndex={-1}
className="md:hidden border-t border-[var(--border)] overflow-x-auto"
>
<NavList className="gap-5 px-5 sm:px-6 h-10" />
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -106,17 +106,16 @@ body {
fill: var(--chart-grid);
}
-/* The growth chart's separators: each stratum's upper edge, a 1 px line in
- the ground's foreground at full strength — dark on Light and Sepia, light on
- Dark (ArchiveGrowthChart.tsx says why this strength). */
-.growth-sep {
- stroke: var(--foreground);
- stroke-opacity: 1;
- stroke-width: 1px;
+/* The growth chart's surface gap (ArchiveGrowthChart.tsx): 2 px along a
+ band's upper edge, in the colour behind the plot — the page ground — so
+ touching bands read apart by a gap, never by a line drawn around them.
+ Forced colours: the reader's Canvas. */
+.growth-gap {
+ stroke: var(--background);
+ stroke-width: 2px;
}
@media (forced-colors: active) {
- .growth-sep {
- stroke: CanvasText;
- stroke-opacity: 1;
+ .growth-gap {
+ stroke: Canvas;
}
}
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -72,10 +72,9 @@ export default function RootLayout({
<body className="min-h-full flex flex-col bg-[var(--background)] text-[var(--foreground)] font-sans selection:bg-[var(--brand-soft)] selection:text-[var(--foreground)]">
{/* The project's own site opens on the dark base in Signal, the
family's accent. A reader cycles the base with the header's toggle;
- the accent is pinned to Signal (the header offers no accent
- control), whatever this origin's storage holds. */}
- <ThemeScript defaultBase="dark" pinAccent />
- <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT} pinAccent>
+ the accent is always Signal, whatever this origin's storage holds. */}
+ <ThemeScript defaultBase="dark" />
+ <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}>
<Header />
<main className="flex-1 w-full">{children}</main>
<Footer />
diff --git a/homepage/app/lib/docs.ts b/homepage/app/lib/docs.ts
@@ -13,8 +13,11 @@ export { DOC_GROUP_ORDER };
// Build-time only. `output: "export"` means every one of these reads happens
// during `next build`; nothing here runs per request. Mirrors the
// readFileSync-from-cwd approach in export/app/changelog/page.tsx.
+//
+// Every path op on a cwd-derived path opts out of Turbopack's asset tracing
+// (plans/FACTS.md, "A path joined from `process.cwd()` …").
function docPath(entry: DocEntry): string {
- return path.join(process.cwd(), "content", "docs", entry.file);
+ return path.join(/* turbopackIgnore: true */ process.cwd(), "content", "docs", entry.file);
}
// The manifest entries whose file actually exists.
@@ -27,7 +30,7 @@ function docPath(entry: DocEntry): string {
export function listDocs(): DocEntry[] {
return DOCS.filter((entry) => {
try {
- return fs.statSync(docPath(entry)).isFile();
+ return fs.statSync(/* turbopackIgnore: true */ docPath(entry)).isFile();
} catch {
return false;
}
@@ -42,7 +45,7 @@ export function findDoc(slug: string): DocEntry | null {
// heading so it stands alone as a document; the page renders its own <h1> from
// the manifest, and two titles in a row reads as a mistake.
export function readDoc(entry: DocEntry): string {
- const raw = fs.readFileSync(docPath(entry), "utf8");
+ const raw = fs.readFileSync(/* turbopackIgnore: true */ docPath(entry), "utf8");
return raw.replace(/^?\s*#[^\n#][^\n]*\n+/, "");
}
diff --git a/homepage/app/lib/growthGaps.test.ts b/homepage/app/lib/growthGaps.test.ts
@@ -0,0 +1,128 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildFixtureSummary } from "../../e2e/fixture-summary";
+import {
+ GAP_PX,
+ MIN_KEEP_PX,
+ PLOT_SIZES,
+ bandAbove,
+ gapSegments,
+ growthStack,
+ type Band,
+ type PlotSize,
+} from "./growthGaps";
+
+// Run with:
+// pnpm --filter homepage test
+//
+// THE PROMISE: wherever the growth chart draws a gap, both bands it parts keep
+// at least MIN_KEEP_PX of their own colour, measured at right angles to the
+// edge, at the narrowest plot of each height — so no band is ever fully
+// covered. Checked here independently of gapSegments' own test: each band's
+// extent under a gap segment is the distance from its FAR edge's two points to
+// the gapped edge's line, in px, less every gap that touches the band there.
+
+type Pt = [number, number];
+
+function px(size: PlotSize, yMax: number, n: number) {
+ const dx = size.minWidth / Math.max(1, n - 1);
+ return (i: number, v: number): Pt => [i * dx, (v / yMax) * size.px];
+}
+
+// Distance from p to the line through a and b.
+function dist(p: Pt, a: Pt, b: Pt): number {
+ const [x, y] = p;
+ const [x1, y1] = a;
+ const [x2, y2] = b;
+ return Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / Math.hypot(x2 - x1, y2 - y1);
+}
+
+// Every band-segment a gap leaves with less than MIN_KEEP_PX of its colour.
+function covered(stack: readonly Band[], yMax: number, size: PlotSize): string[] {
+ const n = stack[0].hi.length;
+ const at = px(size, yMax, n);
+ const segs = gapSegments(stack, yMax, size).map((s) => new Set(s));
+ // Band j loses GAP_PX / 2 on its lower edge at segment i when the band
+ // beneath it has a gap there whose band above is j.
+ const gapBelow = (j: number, i: number) =>
+ stack.some((_, k) => k < j && segs[k].has(i) && bandAbove(stack, k, i) === j);
+ const bad: string[] = [];
+ stack.forEach((band, k) => {
+ for (const i of segs[k]) {
+ const a = at(i, band.hi[i]);
+ const b = at(i + 1, band.hi[i + 1]);
+ // The band below the edge: its lower edge's points.
+ const below = Math.min(dist(at(i, band.lo[i]), a, b), dist(at(i + 1, band.lo[i + 1]), a, b));
+ const keepBelow = below - GAP_PX / 2 - (gapBelow(k, i) ? GAP_PX / 2 : 0);
+ // The band above: its upper edge's points.
+ const m = bandAbove(stack, k, i)!;
+ const above = Math.min(dist(at(i, stack[m].hi[i]), a, b), dist(at(i + 1, stack[m].hi[i + 1]), a, b));
+ const keepAbove = above - GAP_PX / 2 - (segs[m].has(i) ? GAP_PX / 2 : 0);
+ if (keepBelow < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${k} @${i}: ${keepBelow.toFixed(2)}`);
+ if (keepAbove < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${m} @${i}: ${keepAbove.toFixed(2)}`);
+ }
+ });
+ return bad;
+}
+
+// A deterministic stand-in for the published chart's shape: one large band
+// with one-month spikes and dips, one medium band, and four slivers of 0–12
+// that start late.
+function slivers(): { stack: Band[]; yMax: number } {
+ let seed = 7;
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31);
+ const n = 204;
+ const months = Array.from({ length: n }, (_, i) => {
+ const big = i < 60 ? rnd() * 20 : 80 + i * 3 + (i % 17 === 0 ? 900 : 0) - (i % 23 === 0 ? 70 : 0);
+ const mid = i < 120 ? rnd() * 10 : 60 + rnd() * 120 + (i % 29 === 0 ? 600 : 0);
+ const s = (from: number) => (i < from ? 0 : Math.round(rnd() * 12));
+ return { bySite: { a: Math.max(0, big), b: mid, c: s(90), d: s(130), e: s(150), f: s(190) } };
+ });
+ const sites = ["a", "b", "c", "d", "e", "f"].map((siteId) => ({ siteId }));
+ const { stack, yMax } = growthStack(months, sites);
+ return { stack, yMax };
+}
+
+test("the fixture summary: gaps are drawn, and no band is ever covered, at every height", () => {
+ const summary = buildFixtureSummary();
+ const { stack, yMax } = growthStack(summary.monthly ?? [], summary.sites);
+ let drawn = 0;
+ for (const size of PLOT_SIZES) {
+ drawn += gapSegments(stack, yMax, size).flat().length;
+ assert.deepEqual(covered(stack, yMax, size), [], `${size.px}px`);
+ }
+ assert.ok(drawn > 0, "no gap drawn at all");
+});
+
+test("slivers beside large bands, with one-month spikes: no band is ever covered", () => {
+ const { stack, yMax } = slivers();
+ for (const size of PLOT_SIZES) {
+ const bad = covered(stack, yMax, size);
+ assert.equal(bad.length, 0, bad.slice(0, 5).join("; "));
+ }
+});
+
+test("a one-month spike: its steep flanks get no gap at a phone's width, however tall the bands vertically", () => {
+ const n = 40;
+ const months = Array.from({ length: n }, (_, i) => ({
+ bySite: { a: i === 20 ? 3000 : 1000, b: 400 },
+ }));
+ const sites = [{ siteId: "a" }, { siteId: "b" }];
+ const { stack, yMax } = growthStack(months, sites);
+ const phone = gapSegments(stack, yMax, PLOT_SIZES[0])[0];
+ // b is 20 px tall vertically on the flanks, but about 1 px at right angles.
+ assert.ok(!phone.includes(19) && !phone.includes(20), `flank gaps: ${phone.join(",")}`);
+ // The flat stretches either side are parted.
+ assert.ok(phone.includes(5) && phone.includes(30), `flat gaps: ${phone.join(",")}`);
+ assert.deepEqual(covered(stack, yMax, PLOT_SIZES[0]), []);
+});
+
+test("no gap along the stack's top, and none under the run length", () => {
+ const n = 10;
+ const months = Array.from({ length: n }, (_, i) => ({ bySite: { a: 500, b: i === 4 ? 500 : 0 } }));
+ const { stack, yMax } = growthStack(months, [{ siteId: "a" }, { siteId: "b" }]);
+ for (const size of PLOT_SIZES) {
+ // b sits on a for one month only: a run of one (two segments) is dropped.
+ assert.deepEqual(gapSegments(stack, yMax, size), [[], []], `${size.px}px`);
+ }
+});
diff --git a/homepage/app/lib/growthGaps.ts b/homepage/app/lib/growthGaps.ts
@@ -0,0 +1,148 @@
+// THE GROWTH CHART'S SURFACE GAPS, worked out (ArchiveGrowthChart.tsx draws
+// them). Pure: no React, no DOM, so a unit test can check every segment.
+//
+// The marks spec parts touching bands by a 2 px gap in the colour behind the
+// plot. The chart draws it centred on each band's upper edge, so each of the
+// two bands gives 1 px. A band too thin to give its pixel and keep one of its
+// own colour must touch its neighbour instead, or the gap erases it. "Thin" is
+// measured AT RIGHT ANGLES to the edge, where the stroke's 2 px are measured:
+// on a steep edge a band's perpendicular thickness is its vertical height ×
+// cos θ, and on a one-month dip at a phone's width it is nearly nothing. And it
+// is measured at the NARROWEST plot each of the chart's three heights is drawn
+// at, where the edges are steepest. A gap is drawn along a segment only where
+// both bands keep at least MIN_KEEP_PX there after every gap that touches them.
+
+export const GAP_PX = 2;
+export const MIN_KEEP_PX = 1;
+// A band that gives half a gap on each of its edges gives GAP_PX in all.
+const MIN_BAND_PX = GAP_PX + MIN_KEEP_PX;
+// A run of gap shorter than this many months is dropped: a speck of gap reads
+// as noise, not as a parting.
+export const MIN_GAP_RUN = 3;
+
+// The plot's three heights (the box is `h-[200px] sm:h-[260px] lg:h-[300px]`),
+// the narrowest plot width each is drawn at (the narrowest viewport of its
+// breakpoint less the container's padding: 280 − 2 × 20, 640 − 2 × 24,
+// 1024 − 2 × 24), and the class that shows its set of gaps.
+export const PLOT_SIZES = [
+ { px: 200, minWidth: 240, className: "sm:hidden" },
+ { px: 260, minWidth: 592, className: "hidden sm:inline lg:hidden" },
+ { px: 300, minWidth: 976, className: "hidden lg:inline" },
+] as const;
+export type PlotSize = (typeof PLOT_SIZES)[number];
+
+// The stack, bottom-up: each band's lower and upper value per month.
+export type Band = { lo: readonly number[]; hi: readonly number[] };
+
+// The layers stack in the summary's order (the first five as they come, any
+// more after them).
+const STACK_ORDER = [0, 1, 2, 3, 4];
+function stackOrder(n: number): number[] {
+ const head = STACK_ORDER.filter((i) => i < n);
+ const tail = Array.from({ length: Math.max(0, n - 5) }, (_, k) => k + 5);
+ return [...head, ...tail];
+}
+
+// A clean tick step giving three or four gridlines under `max`.
+function niceStep(max: number): number {
+ const raw = max / 3.5;
+ const pow = 10 ** Math.floor(Math.log10(raw));
+ for (const m of [1, 2, 2.5, 5, 10]) {
+ if (m * pow >= raw) return m * pow;
+ }
+ return 10 * pow;
+}
+
+// The chart's numbers: each month's total, the peak, the value scale (yMax
+// and its gridlines), and the bands bottom-up (`order[k]` is band k's index in
+// `sites`).
+export function growthStack(
+ months: readonly { bySite: Record<string, number | undefined> }[],
+ sites: readonly { siteId: string }[],
+) {
+ const n = months.length;
+ const totals = months.map((m) => sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0));
+ const peak = totals.length ? Math.max(...totals) : 0;
+ const step = peak > 0 ? niceStep(peak) : 1;
+ const yMax = Math.ceil(peak / step) * step;
+ const ticks: number[] = [];
+ if (peak > 0) for (let t = step; t <= yMax; t += step) ticks.push(t);
+ const order = stackOrder(sites.length);
+ const base = new Array<number>(n).fill(0);
+ const stack: Band[] = order.map((si) => {
+ const lo = base.slice();
+ const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0));
+ return { lo, hi };
+ });
+ return { totals, peak, yMax, ticks, order, stack };
+}
+
+const height = (b: Band, i: number) => b.hi[i] - b.lo[i];
+
+// The band a gap along band k's upper edge would share at month i: the next
+// band up with a height there (a band of height 0 lies on the edge), or none —
+// the stack's top meets the surface itself.
+export function bandAbove(stack: readonly Band[], k: number, i: number): number | null {
+ for (let m = k + 1; m < stack.length; m++) if (height(stack[m], i) > 0) return m;
+ return null;
+}
+
+// A band's thickness at right angles to band k's upper edge over the segment
+// i → i + 1, in px at `size`: the smaller of its two vertical heights there
+// × cos θ of the edge.
+function perpendicular(
+ stack: readonly Band[],
+ k: number,
+ band: number,
+ i: number,
+ yMax: number,
+ size: PlotSize,
+ n: number,
+): number {
+ const sy = size.px / yMax;
+ const dx = size.minWidth / Math.max(1, n - 1);
+ const dy = (stack[k].hi[i + 1] - stack[k].hi[i]) * sy;
+ const cos = dx / Math.hypot(dx, dy);
+ return Math.min(height(stack[band], i), height(stack[band], i + 1)) * sy * cos;
+}
+
+// For each band, the month segments (their start index i, for i → i + 1)
+// along its upper edge where a gap is drawn at `size`.
+export function gapSegments(stack: readonly Band[], yMax: number, size: PlotSize): number[][] {
+ const n = stack[0]?.hi.length ?? 0;
+ return stack.map((band, k) => {
+ const parted = (i: number) => {
+ if (height(band, i) <= 0 || height(band, i + 1) <= 0) return false;
+ const up = bandAbove(stack, k, i);
+ if (up === null || up !== bandAbove(stack, k, i + 1)) return false;
+ return (
+ perpendicular(stack, k, k, i, yMax, size, n) >= MIN_BAND_PX &&
+ perpendicular(stack, k, up, i, yMax, size, n) >= MIN_BAND_PX
+ );
+ };
+ const out: number[] = [];
+ let run: number[] = [];
+ const close = () => {
+ if (run.length >= MIN_GAP_RUN) out.push(...run);
+ run = [];
+ };
+ for (let i = 0; i < n - 1; i++) {
+ if (parted(i)) run.push(i);
+ else close();
+ }
+ close();
+ return out;
+ });
+}
+
+// The segments grouped into runs of consecutive months, for drawing each run
+// as one polyline.
+export function runsOf(segments: readonly number[]): number[][] {
+ const runs: number[][] = [];
+ for (const i of segments) {
+ const last = runs.at(-1);
+ if (last && last.at(-1) === i - 1) last.push(i);
+ else runs.push([i]);
+ }
+ return runs;
+}
diff --git a/homepage/app/lib/snapshot.ts b/homepage/app/lib/snapshot.ts
@@ -29,24 +29,24 @@ export const SNAPSHOT_HREF = "/downloads/archilyzer-source.tar.gz";
export function loadSnapshot(): Snapshot | null {
try {
const file = path.join(
- process.cwd(),
+ /* turbopackIgnore: true */ process.cwd(),
"public",
"downloads",
"snapshot.json",
);
- const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Snapshot;
+ const parsed = JSON.parse(fs.readFileSync(/* turbopackIgnore: true */ file, "utf8")) as Snapshot;
if (!parsed || typeof parsed.bytes !== "number" || !parsed.sha256) {
return null;
}
// Believe the sidecar only if the file it describes is actually present —
// they are written together but deployed as separate assets.
const tarball = path.join(
- process.cwd(),
+ /* turbopackIgnore: true */ process.cwd(),
"public",
"downloads",
path.basename(SNAPSHOT_HREF),
);
- if (!fs.statSync(tarball).isFile()) return null;
+ if (!fs.statSync(/* turbopackIgnore: true */ tarball).isFile()) return null;
return parsed;
} catch {
return null;
diff --git a/homepage/app/lib/source.ts b/homepage/app/lib/source.ts
@@ -19,8 +19,13 @@ import {
// manifest is null too (parseSourceManifest checks every number the page
// reads), so a bad file is the empty state, never a crash in `next build`.
// `pub` is the test's seam.
+//
+// The default is a DIRECTORY join on `process.cwd()`, which Turbopack would
+// trace as every file under `public/` (the source mirror among them, and it
+// grows with every publish): the opt-out keeps it a plain run-time path
+// (plans/FACTS.md, "A path joined from `process.cwd()` …").
export function loadSourceManifest(
- pub: string = path.join(process.cwd(), "public"),
+ pub: string = path.join(/* turbopackIgnore: true */ process.cwd(), "public"),
): SourceManifest | null {
try {
const manifest = parseSourceManifest(
diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts
@@ -24,10 +24,10 @@ export function loadSummary(): HomepageSummary | null {
try {
const file = summaryFile(
process.env,
- path.join(process.cwd(), "public", "homepage-summary.json"),
+ path.join(/* turbopackIgnore: true */ process.cwd(), "public", "homepage-summary.json"),
);
const parsed = JSON.parse(
- fs.readFileSync(file, "utf8"),
+ fs.readFileSync(/* turbopackIgnore: true */ file, "utf8"),
) as HomepageSummary;
// A file that exists but carries no sites/totals is as uninformative as no
// file at all; treat it the same rather than rendering an empty dashboard.
diff --git a/homepage/app/page.tsx b/homepage/app/page.tsx
@@ -110,8 +110,13 @@ export default function Home() {
)}
{/* ── Official Instances ───────────────────────────────────────────── */}
+ {/* `#instances` is where every archive's header links (common/lib/
+ project.ts INSTANCES_URL); `scroll-mt` is the sticky header's height
+ (the bar, and below md the nav's rule under it), so the section's top
+ edge lands just under it. With no sites the section is absent and the
+ link lands on the top of the page. */}
{sites.length > 0 && (
- <section className="border-t border-[var(--border)]">
+ <section id="instances" className="scroll-mt-24 md:scroll-mt-14 border-t border-[var(--border)]">
<div className={`${CONTAINER} py-14 sm:py-20`}>
<h2 className="mb-8 font-display text-2xl font-semibold text-[var(--foreground)]">
Official Instances
diff --git a/homepage/content/docs/operate.md b/homepage/content/docs/operate.md
@@ -82,9 +82,9 @@ editor. See [Deploy to Cloudflare](/docs/deploy-cloudflare/) for hosting, and
Channels live in a single shared pool. A **site** is a selection of them with its
own title, description, accent colour and domain. The accent is one of seven
-named colours or a hex of your own, and each site opens in it; a reader can
-still pick another accent, and a light, sepia or dark ground, from the site's
-theme menu. One corpus can therefore publish several public archives without any
+named colours or a hex of your own, and every page of the site wears it; a
+reader picks a light or dark ground (or the system's) with the header's theme
+toggle. One corpus can therefore publish several public archives without any
data being duplicated — and a channel can appear on more than one.
Within a site, channels can be arranged into named groups, which is what drives
diff --git a/homepage/e2e/brand.spec.ts b/homepage/e2e/brand.spec.ts
@@ -40,10 +40,10 @@ test("the icon set is rendered from the parent mark", async ({ request }) => {
// The ring on dark (release 10, slice MR): on the dark base the tile has no
// edge, so every mark's tile gets a 1px ring outside it in its palette's dim,
// following the ground's corner (rx 112 of 512). A box-shadow, so the mark's
-// box is the same on every base; light and sepia draw none.
+// box is the same on every base; light draws none.
const BASE_KEY = "ytdlp-tb:base";
-async function onBase(page: Page, base: "light" | "sepia" | "dark") {
+async function onBase(page: Page, base: "light" | "dark") {
await page.evaluate(([k, b]) => localStorage.setItem(k, b), [BASE_KEY, base]);
await page.reload({ waitUntil: "commit" });
await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
@@ -69,7 +69,7 @@ async function markRing(mark: Locator) {
});
}
-test("on dark the header mark has a 1px ring in the slate's dim; on light and sepia none", async ({
+test("on dark the header mark has a 1px ring in the slate's dim; on light none", async ({
page,
}) => {
await page.goto("/");
@@ -85,7 +85,7 @@ test("on dark the header mark has a 1px ring in the slate's dim; on light and se
size: [28, 28],
});
- for (const base of ["light", "sepia"] as const) {
+ for (const base of ["light"] as const) {
await onBase(page, base);
expect(await markRing(mark), base).toEqual({
ring: [],
@@ -110,7 +110,7 @@ test.describe("under forced colours", () => {
const mark = page
.getByRole("link", { name: "Archilyzer home", exact: true })
.locator("svg[data-brand-mark]");
- for (const base of ["dark", "light", "sepia"] as const) {
+ for (const base of ["dark", "light"] as const) {
await onBase(page, base);
expect(await page.evaluate(() => matchMedia("(forced-colors: active)").matches)).toBe(true);
const got = await mark.evaluate((svg) => {
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/fixture-summary.ts b/homepage/e2e/fixture-summary.ts
@@ -34,7 +34,7 @@ export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json";
// not charted; the week and day buckets end here.
export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z");
-// A pale custom hex: published as is it would be a ghost on light and sepia.
+// A pale custom hex: published as is it would be a ghost on light.
export const FIXTURE_PALE_HEX = "#f4c2d7";
// The six sites, in summary order. `accent` is the site.json setting (an
diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts
@@ -1,10 +1,13 @@
import { test, expect, type Page } from "@playwright/test";
+import { buildFixtureSummary } from "./fixture-summary";
+import { painted, rgbOf } from "../../common/testing/chartPixels";
-// The growth chart's separators — each stratum's upper edge — are the ground's
-// FOREGROUND at full strength, 1 px: dark on Light and Sepia, light on Dark
-// (ArchiveGrowthChart.tsx, globals.css `.growth-sep`), and CanvasText in forced
-// colours. The fixture summary (fixture-summary.ts) has six sites with monthly
-// data, so the chart and its six separators render.
+// The growth chart's bands are parted by the marks spec's SURFACE GAP: 2 px
+// along each band's upper edge, in the colour behind the plot — the page
+// ground, which the chart sits on — never a line in the text colour
+// (ArchiveGrowthChart.tsx, globals.css `.growth-gap`), and the reader's Canvas
+// in forced colours. The fixture summary (fixture-summary.ts) has six sites
+// with monthly data, so the chart and its gaps render.
const BASE_KEY = "ytdlp-tb:base";
@@ -18,38 +21,111 @@ const resolveColor = (page: Page, css: string) =>
return out;
}, css);
-const separators = (page: Page) =>
- page.locator("figure svg path.growth-sep").evaluateAll((els) =>
+const gaps = (page: Page) =>
+ page.locator("figure svg path.growth-gap").evaluateAll((els) =>
els.map((el) => {
const s = getComputedStyle(el);
- return { stroke: s.stroke, opacity: s.strokeOpacity, width: s.strokeWidth };
+ return { stroke: s.stroke, width: s.strokeWidth, scaling: s.vectorEffect };
}),
);
-test("the separators are the ground's foreground at full strength, never the page background", async ({
+test("the bands are parted by a 2 px gap in the ground's colour, never the text colour", async ({
page,
}) => {
await page.goto("/");
- for (const base of ["light", "sepia", "dark"] as const) {
+ for (const base of ["light", "dark"] as const) {
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-base", base);
- const fg = await resolveColor(page, "var(--foreground)");
- const bg = await resolveColor(page, "var(--background)");
- expect(fg).not.toBe(bg);
- const seps = await separators(page);
- expect(seps.length, base).toBeGreaterThan(0);
- for (const s of seps) {
- expect(s, base).toEqual({ stroke: fg, opacity: "1", width: "1px" });
+ // The chart sits on the page ground: nothing between them paints.
+ const holder = await page.locator("figure").evaluate((el) => {
+ for (let n: Element | null = el; n && n !== document.body; n = n.parentElement) {
+ const bg = getComputedStyle(n).backgroundColor;
+ if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") return bg;
+ }
+ return getComputedStyle(document.body).backgroundColor;
+ });
+ const ground = await resolveColor(page, "var(--background)");
+ const text = await resolveColor(page, "var(--foreground)");
+ expect(holder, `${base}: what the chart sits on`).toBe(ground);
+ expect(ground).not.toBe(text);
+ const seen = await gaps(page);
+ expect(seen.length, base).toBeGreaterThan(0);
+ for (const g of seen) {
+ expect(g, base).toEqual({ stroke: ground, width: "2px", scaling: "non-scaling-stroke" });
}
}
});
-test("in forced colours the separators are CanvasText", async ({ page }) => {
+test("in forced colours the gap is the reader's Canvas", async ({ page }) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto("/");
- const canvasText = await resolveColor(page, "CanvasText");
- const seps = await separators(page);
- expect(seps.length).toBeGreaterThan(0);
- for (const s of seps) expect(s.stroke).toBe(canvasText);
+ const canvas = await resolveColor(page, "Canvas");
+ const seen = await gaps(page);
+ expect(seen.length).toBeGreaterThan(0);
+ for (const g of seen) expect(g.stroke).toBe(canvas);
});
+
+// One set of gaps per plot height (lib/growthGaps.ts), each shown only at its
+// own breakpoint: the set worked out for the height the plot is drawn at.
+for (const [width, shown] of [
+ [390, "200"],
+ [768, "260"],
+ [1280, "300"],
+] as const) {
+ test(`${width} px: only the ${shown} px plot's gaps are shown`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto("/");
+ const plot = page.locator('figure [role="img"]');
+ expect(Math.round((await plot.boundingBox())!.height)).toBe(Number(shown));
+ const sets = await page.locator("figure svg g[data-plot-height]").evaluateAll((els) =>
+ els.map((g) => [g.getAttribute("data-plot-height"), getComputedStyle(g).display !== "none"]),
+ );
+ expect(sets.filter(([, on]) => on).map(([h]) => h)).toEqual([shown]);
+ });
+}
+
+// THE DATA IS WHAT IS PAINTED. Read back from a screenshot of the plot: at the
+// busiest month the stack's topmost painted row is within 1 px of where the
+// month's true total sits on the value scale, and every site with data shows
+// pixels of its own colour — the gaps take no band away.
+for (const width of [390, 1280]) {
+ test(`${width} px: the chart paints its peak at its true height and every band`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto("/");
+ const summary = buildFixtureSummary();
+ const months = summary.monthly ?? [];
+ const sites = summary.sites;
+ const totals = months.map((m) => sites.reduce((a, x) => a + (m.bySite[x.siteId] ?? 0), 0));
+ const peak = Math.max(...totals);
+ const peakAt = totals.indexOf(peak);
+ const plot = page.locator('figure [role="img"]');
+ const box = (await plot.boundingBox())!;
+ // The value scale, from the labels on its gridlines (a label's `top` is
+ // its gridline's place, 1 − t / yMax of the plot's height).
+ const scale = await plot.evaluate((el) => {
+ const s = el.querySelector("span.tabular") as HTMLElement;
+ return { t: Number(s.textContent!.replace(/[^\d.]/g, "")), top: parseFloat(s.style.top) / 100 };
+ });
+ const yMax = scale.t / (1 - scale.top);
+ const expected = (1 - peak / yMax) * box.height;
+ const x = (peakAt / (months.length - 1)) * box.width;
+ const ground = rgbOf(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
+ const swatches = await page
+ .locator("figure ul li span")
+ .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor));
+ const withData = sites.map((st) => months.some((m) => (m.bySite[st.siteId] ?? 0) > 0));
+ const shot = await painted(page, plot, {
+ columns: [x - 1, x, x + 1],
+ ground,
+ colours: swatches.map(rgbOf),
+ tol: 12,
+ run: 2,
+ });
+ const top = Math.min(...shot.tops.filter((t): t is number => t !== null));
+ expect(Math.abs(top - expected), `top ${top} vs ${expected.toFixed(1)}`).toBeLessThanOrEqual(1);
+ sites.forEach((st, i) => {
+ if (withData[i]) expect(shot.counts[i].columns, `${st.siteTitle}'s colour`).toBeGreaterThan(0);
+ });
+ });
+}
diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts
@@ -5,8 +5,8 @@ import { THEME_BASES, nextBase, type ThemeBase } from "../../common/components/t
// toggle (common/components/ThemeToggle.tsx, `variant="bare"`), which cycles
// the base in nextBase's order, shows the base in force as `data-theme-base`,
// and is named "Switch to {next}". Specs that only need a ground set
-// `localStorage` instead. The homepage has no accent control (its accent is
-// pinned), so there is no accent to choose here.
+// `localStorage` instead. No app has an accent control (each shows its own),
+// so there is no accent to choose here.
export const themeToggle = (page: Page) =>
page.locator("header").getByRole("button", { name: /^switch to /i });
diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts
@@ -67,7 +67,7 @@ async function colours(page: Page) {
return { stripes, legend };
}
-async function useBase(page: Page, base: "dark" | "light" | "sepia") {
+async function useBase(page: Page, base: "dark" | "light") {
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-base", base);
@@ -91,9 +91,9 @@ test("each instance card wears its site's accent on every base, in its chart lay
expect([...chart].sort()).toEqual([1, 2, 3, 4, 5, 6].map((k) => `var(--chart-${k})`));
await page.goto("/");
- const on = { dark: "onDark", light: "onLight", sepia: "onSepia" } as const;
+ const on = { dark: "onDark", light: "onLight" } as const;
const pale = resolveAccent(FIXTURE_PALE_HEX);
- for (const base of ["dark", "light", "sepia"] as const) {
+ for (const base of ["dark", "light"] as const) {
await useBase(page, base);
const { stripes, legend } = await colours(page);
expect(stripes).toHaveLength(n);
@@ -114,7 +114,7 @@ test("each instance card wears its site's accent on every base, in its chart lay
expect(await resolve(page, chart[0])).toBe(await resolve(page, "var(--chart-4)"));
expect(hueGap(stripes[0], legend[0]), "brass vs its layer").toBeLessThan(25);
- // Site 1: the pale custom hex, FITTED to this base — on light and sepia
+ // Site 1: the pale custom hex, FITTED to this base — on light
// darkened to 4.5:1 (as published it is ~1.4:1 there), on dark as is.
expect(stripes[1], "the pale hex, fitted").toBe(await resolve(page, pale[base]));
if (base !== "dark") {
diff --git a/homepage/e2e/instance-wordmark.spec.ts b/homepage/e2e/instance-wordmark.spec.ts
@@ -74,7 +74,7 @@ test("each card's name: the wordmark where there is a lead, tinted in the site's
await expect(section(page).getByRole("link", { name: s.siteTitle, exact: true })).toHaveCount(1);
}
const pale = resolveAccent(FIXTURE_PALE_HEX);
- for (const base of ["light", "sepia", "dark"] as const) {
+ for (const base of ["light", "dark"] as const) {
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-base", base);
diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "@playwright/test";
import { FIXTURE_SUMMARY_NAME } from "./fixture-summary";
+import { INSTANCES_URL, PROJECT_URL } from "../../common/lib/project";
// The home page's job is to say what Archilyzer is and offer the download.
// Nothing asserted here names a count, a site or a headline number. The numeric
@@ -148,6 +149,25 @@ test("official instances link out, and the numbers carry their build date", asyn
}
});
+// Every archive's header links here (common/lib/project.ts INSTANCES_URL):
+// `/#instances` brings the section's heading into view, clear of the sticky
+// header.
+test("/#instances brings Official Instances into view below the sticky header", async ({
+ page,
+}) => {
+ expect(INSTANCES_URL).toBe(`${PROJECT_URL}/#instances`);
+ await page.setViewportSize({ width: 390, height: 700 });
+ await page.goto("/#instances");
+ const heading = page.getByRole("heading", { level: 2, name: "Official Instances", exact: true });
+ await expect(heading).toBeInViewport();
+ const [h, bar] = await Promise.all([
+ heading.boundingBox(),
+ page.locator("header").first().boundingBox(),
+ ]);
+ expect(h!.y).toBeGreaterThanOrEqual(bar!.y + bar!.height);
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
+});
+
test("the headings after the H1 are in Title Case", async ({ page }) => {
await expect(
page.getByRole("heading", { level: 2, name: "What It Does", exact: true }),
diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts
@@ -6,14 +6,17 @@ 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";
+import { headerSocialLinks } from "../../common/lib/socialLinks";
-// 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 +33,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 +66,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 +105,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 +208,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 +242,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,14 +256,18 @@ 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);
expect(await box.evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(true);
+ // Not tab stops of their own (Firefox makes an overflowing scroll box one):
+ // the row's box, and the compact nav's rule, which overflows here too.
+ await expect(box).toHaveAttribute("tabindex", "-1");
+ await expect(page.getByRole("navigation", { name: "Main, compact" })).toHaveAttribute("tabindex", "-1");
await noHorizontalOverflow(page);
await expect(themeToggle(page)).toBeInViewport({ ratio: 1 });
const inBox = (key: import("@playwright/test").Locator) =>
@@ -264,7 +287,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%";
@@ -335,20 +358,47 @@ 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);
}
});
+// The footer shows every link: with eight under touch at 320 px its row wraps
+// rather than widen the page.
+test.describe("under a coarse pointer, eight links", () => {
+ test.use({ hasTouch: true });
+ test("320 px: the footer's row wraps, and nothing scrolls the page", async ({ page }) => {
+ const extra = (label: string, d: string) => ({
+ label,
+ url: `https://${label.toLowerCase()}.example/fixture`,
+ svg: `<svg viewBox="0 0 24 24"><path d="${d}"/></svg>`,
+ });
+ writeFixtureSettings(settingsFile(), [
+ ...FIXTURE_SOCIAL_SIX,
+ extra("Bar", "M3 10h18v4H3z"),
+ extra("Cross", "M10 3h4v7h7v4h-7v7h-4v-7H3v-4h7z"),
+ ]);
+ await page.setViewportSize({ width: 320, height: 800 });
+ await page.goto("/");
+ await expect(footerRow(page).getByRole("link")).toHaveCount(8);
+ const boxes = await footerRow(page)
+ .getByRole("link")
+ .evaluateAll((els) => els.map((e) => e.getBoundingClientRect()).map((r) => ({ right: r.right, top: r.top })));
+ for (const b of boxes) expect(b.right).toBeLessThanOrEqual(320);
+ expect(new Set(boxes.map((b) => Math.round(b.top))).size, "one row of eight").toBeGreaterThan(1);
+ await noHorizontalOverflow(page);
+ });
+});
+
// The toggle sits in the row's rhythm: its box directly after the last link's,
// as the links' boxes sit after each other, so glyph to glyph is the same all
// along; the nav keeps a larger gap before the group.
@@ -373,13 +423,153 @@ 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);
+ }
+});
+
+// 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,
}) => {
diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts
@@ -1,5 +1,5 @@
import { test, expect, type Page } from "@playwright/test";
-import { REQUIRED_TOKENS } from "../../common/components/themeConfig";
+import { REQUIRED_TOKENS, RETIRED_BASE } from "../../common/components/themeConfig";
import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand";
import { chooseTheme } from "./helpers";
@@ -24,7 +24,7 @@ async function htmlState(page: Page) {
}, BASE_KEY);
}
-test("dark + Signal by default; a base chosen in Options persists with no FOUC", async ({
+test("dark + Signal by default; a base chosen with the toggle persists with no FOUC", async ({
page,
}) => {
// The OS says light: the default is still dark (it is the site's, not the
@@ -99,7 +99,7 @@ test("every base declares a complete palette, and the grounds differ", async ({
await page.goto("/");
- const readOn = (base: "light" | "sepia" | "dark") =>
+ const readOn = (base: "light" | "dark") =>
page.evaluate(
({ base, tokens }) => {
const d = document.documentElement;
@@ -113,7 +113,7 @@ test("every base declares a complete palette, and the grounds differ", async ({
{ base, tokens: TOKENS },
);
- const bases = ["light", "sepia", "dark"] as const;
+ const bases = ["light", "dark"] as const;
const palettes = {} as Record<(typeof bases)[number], Record<string, string>>;
for (const b of bases) palettes[b] = await readOn(b);
@@ -123,9 +123,49 @@ test("every base declares a complete palette, and the grounds differ", async ({
}
expect(palettes[b]["--background"]).toBe(BASE_GROUNDS[b]);
}
- // …and the three must actually differ, or a base is a label on another's
+ // …and the two must actually differ, or a base is a label on the other's
// palette.
- expect(new Set(bases.map((b) => palettes[b]["--background"])).size).toBe(3);
- expect(new Set(bases.map((b) => palettes[b]["--foreground"])).size).toBe(3);
- expect(new Set(bases.map((b) => palettes[b]["--chart-1"])).size).toBe(3);
+ expect(new Set(bases.map((b) => palettes[b]["--background"])).size).toBe(2);
+ expect(new Set(bases.map((b) => palettes[b]["--foreground"])).size).toBe(2);
+ expect(new Set(bases.map((b) => palettes[b]["--chart-1"])).size).toBe(2);
+});
+
+// The retired third ground: a reader who chose it gets Light, before first
+// paint, with no other ground on the way, and the stored value becomes
+// "light" once. Every value `data-base` holds is recorded by a
+// MutationObserver installed before the page's first script.
+test("a stored retired base renders Light, with no other ground on the way, and is rewritten", async ({
+ page,
+}) => {
+ await page.addInitScript(
+ ([key, retired]) => {
+ try {
+ localStorage.setItem(key, retired);
+ } catch {}
+ const held: (string | null)[] = [];
+ (window as unknown as { __bases: (string | null)[] }).__bases = held;
+ new MutationObserver((records) => {
+ for (const r of records) if (r.target === document.documentElement) held.push(r.oldValue);
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-base"],
+ attributeOldValue: true,
+ });
+ },
+ [BASE_KEY, RETIRED_BASE],
+ );
+ await page.goto("/", { waitUntil: "commit" });
+ await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
+ expect(await page.evaluate(() => document.documentElement.getAttribute("data-base"))).toBe("light");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ const held = await page.evaluate(() => [
+ ...(window as unknown as { __bases: (string | null)[] }).__bases,
+ document.documentElement.getAttribute("data-base"),
+ ]);
+ // The server's dark (the markup, before the pre-paint script), then light.
+ expect(held.filter((v) => v !== "dark" && v !== "light"), JSON.stringify(held)).toEqual([]);
+ expect(held.slice(1).every((v) => v === "light"), JSON.stringify(held)).toBe(true);
+ expect(await htmlState(page)).toMatchObject({ base: "light", dark: false, stored: "light" });
});
diff --git a/homepage/e2e/toggle.spec.ts b/homepage/e2e/toggle.spec.ts
@@ -4,8 +4,8 @@ import { baseLabel, themeCycle, themeToggle } from "./helpers";
// THE HOMEPAGE'S THEME CONTROL is one toggle that cycles the base
// (ThemeToggle, `variant="bare"`), the last key of the header's group; there is
-// no options dialog and no accent control, and the accent is pinned to the
-// homepage's own. The expected cycle is derived from THEME_BASES / nextBase, so
+// no options dialog and no accent control: the accent is the homepage's own.
+// The expected cycle is derived from THEME_BASES / nextBase, so
// removing a base there needs no change here.
const htmlState = (page: Page) =>
@@ -101,9 +101,11 @@ test("the toggle's focus: the ring, and in forced colours the browser's own outl
expect(forced.width).toBeGreaterThan(0);
});
-// With no accent control on the homepage, a stored accent (set on this origin
-// by an earlier build that had one) must not tint it: the pre-paint script
-// and the provider both ignore it, and it stays in storage untouched.
+// With no accent control, a stored accent (set on this origin by an earlier
+// build that had one) must not tint the page: the pre-paint script
+// and the provider both ignore it, and it stays in storage untouched. Every
+// value `data-accent` ever holds is recorded by a MutationObserver installed
+// before the page's first script, so a flash between two samples cannot pass.
test("a stored accent is ignored: the homepage keeps its own, with no flash, and the stored value stays", async ({
page,
}) => {
@@ -111,14 +113,30 @@ test("a stored accent is ignored: the homepage keeps its own, with no flash, and
try {
localStorage.setItem("ytdlp-tb:accent", "violet");
} catch {}
+ const held: (string | null)[] = [];
+ (window as unknown as { __accents: (string | null)[] }).__accents = held;
+ new MutationObserver((records) => {
+ for (const r of records) {
+ if (r.target === document.documentElement) held.push(r.oldValue);
+ }
+ }).observe(document, {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-accent"],
+ attributeOldValue: true,
+ });
});
await page.goto("/", { waitUntil: "commit" });
await page.waitForFunction(() => document.documentElement?.dataset.themeReady === "1");
- // Before hydration: the pre-paint script's work only.
- expect((await htmlState(page)).accent).toBe("signal");
await page.waitForLoadState("load");
await expect.poll(async () => (await htmlState(page)).accent).toBe("signal");
await page.waitForTimeout(300);
- expect((await htmlState(page)).accent).toBe("signal");
+ const held = await page.evaluate(() => [
+ ...(window as unknown as { __accents: (string | null)[] }).__accents,
+ document.documentElement.getAttribute("data-accent"),
+ ]);
+ // Every value it held before each change, and the one it holds now.
+ expect(held.filter((v) => v !== null && v !== "signal"), JSON.stringify(held)).toEqual([]);
+ expect(held.at(-1)).toBe("signal");
expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:accent"))).toBe("violet");
});
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -7352,24 +7352,26 @@ Slices Q (`4855f70b`) and R (`ffdeb2cd`): [`release-12.md`](release-12.md), the
a synthetic home inside the project, full of out-of-root symlinks under `reports/`,
`.local/share/archilyzer/song` and `.cache/`, succeeded. So `~/reports` and the XDG song path are
safe as `path.join(os.homedir(), …)`.
-- **The guard is `scripts/umtool-build-trace.test.mjs`** (in `test:scripts`). It scans umtool's
- app, components, lib, `report-to-video/*.mjs` and `song/paths.mjs`, per module. A path or fs call
- carrying a value derived in that file from `process.cwd()`, `import.meta.url|dirname|filename` or
- `__dirname` must open with the opt-out.
+- **The guard is `scripts/next-build-trace.test.mjs`** (in `test:scripts`; it was
+ `umtool-build-trace.test.mjs` until release 14 widened it). It scans umtool's app, components,
+ lib, `report-to-video/*.mjs` and `song/paths.mjs`, and, since release 14 (F8), `homepage/app`,
+ `export/app`, `editor/app`, `editor/lib`, `editor/instrumentation.ts` and every `common/` module
+ but `bin/`, per module. A path or fs call carrying a value derived in that file from
+ `process.cwd()`, `import.meta.url|dirname|filename`, `__dirname`, or a call to a function
+ declared in the file whose body carries one, must open with the opt-out.
- It is static and per module, as Turbopack's value analysis is: an imported binding is opaque to
it.
- With the slice Q `paths.mjs` it fails on the defect's line.
- **The build gate** is run with the corpus visible and under a memory cap (the command is in
`plans/tools/implementer-rules.md`). Linking `<primary>/transcripts` into a worktree is for a
BUILD only. Remove the link afterwards: never run an app, an index or a fixture builder through it.
-- **The other apps are safe by accident, not by rule:**
- - `common/lib/paths.ts`' `findMonorepoRoot()` falls back to `process.cwd()` (the app's own
- directory, which has no `transcripts/`). An editor build with the corpus present is 39 s today.
- A fallback that evaluated to the repo root would make `path.join(monorepoRoot, "transcripts")`
- this same bug.
- - `homepage/app/lib/source.ts` joins `process.cwd()` + `public`, which holds the source mirror.
- The homepage builds in about 20 s today.
- - Neither has a guard.
+- **The other apps were safe by accident; since release 14 (F8) they are by rule:**
+ - `common/lib/paths.ts` builds every path on the repo root through one opted-out `under()`, and
+ `findMonorepoRoot()`'s walk and its `process.cwd()` fallback are opted out.
+ - `homepage/app/lib/source.ts`' directory join on `public` (the source mirror) and the homepage's
+ and export's other cwd joins carry the opt-out.
+ - The guard covers them. A homepage build with the published source measured the same with and
+ without it (`release-14.md`, "The final review's Lows").
## The stats cache key (verified 2026-09-28, branch `fix/stats-cache-key`)
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -171,32 +171,48 @@ changed at integration. Nothing was deployed, cut, pushed or restarted; :3001 st
8. Optional: rebuild + restart umtool on :3050. Its app changed only in `lib/tools.mjs` (the
shared tool probe) and two test-only variable names; O5's render changes already reach it from
disk (the live :3050 spawns `report-to-video/*`), and an unbranded render is byte-identical.
-- **Release 14 (2026-09-28): slice HP built, not merged** — the social icons in the export header,
- one theme toggle, the Sites dropdown replaced by a link to the Archilyzer home, and a clear screen
- until the first Search (`plans/export-header-first-search.md`; slices HP, H3, H1, H2, S1, T1
- planned, and S2 as a candidate). It waits on nothing: a link the operator adds is an entry in
- `settings.json` `socialLinks`, and the earlier tip-link branch is parked, not merged. **HP**
- (`homepage/social-visible`, record in `release-14.md`):
- - puts the social row and one theme toggle (`ThemeToggle variant="bare"`) in the homepage's
- header at every width, through the shared `common/components/SocialLinks.tsx`; no icon is
- hidden by width. On a very small screen the wordmark's text drops first (a container query per
- link count and pointer), and the row scrolls in `SocialScroll.tsx`, end first, only when even
- the mark does not leave room;
- - pins the homepage's accent (`pinAccent`). The Options dialog was built and then deleted by
- ruling; `ThemeRadios` stays for the export's menu;
- - moves Changelog to the footer and names each Official Instances card with the site's wordmark
- (the lead tinted in its accent; `wordmarkLead` in the summary);
- - draws the growth chart's separators in the ground's foreground;
- - checks a social icon by an allowlist (`common/lib/socialSvg.ts`) on a new or edited icon and at
- render. An unchanged stored icon never blocks a save, and `archilyzer doctor` names the failing
- ones;
- - gives a sized SVG with no viewBox its viewBox, and adds `featured` ("Show in header") to a
- social link.
-
- Reviewed SHIP AFTER FIXES, then re-reviewed SHIP AFTER FIXES. Both sets of fixes are in, and
- `main` (`10cefd15`) is merged in. The history was rewritten once, so no vendor file entered it.
- The next review covers everything after `afc642fd`. **T1** ("two grounds", Sepia and the accent
- picker removed) is planned and unassigned. Merge order: HP → H3 → H1+H2 → S1.
+- **Release 14 (2026-09-28): HP merged (`bfa1ff3c`, final review SHIP); T1 and H1/H2 built on
+ `r14/two-grounds-headers`, not merged** — `plans/export-header-first-search.md`, record in
+ `release-14.md`. S1 (a clear screen until the first Search) is next, and S2 is a candidate.
+ - **HP** (merged): the homepage's header carries the social row and one theme toggle at every
+ width, through the shared `SocialLinks`; its wordmark's text drops first on a very small
+ screen; Changelog is in the footer; the cards wear the site's wordmark; a social icon is
+ checked by an allowlist on save and at render; `featured` on a social link.
+ - **`r14/two-grounds-headers`** (built, `0f358ee7` + the records):
+ - the final review's Lows (F1, F3, F4, F5, F8);
+ - F8 opts every cwd-derived path op in the three Next apps out of Turbopack's tracing, with
+ the guard widened to `scripts/next-build-trace.test.mjs`;
+ - the charts' foreground separators are withdrawn for a 2 px gap in the surface's colour,
+ the shared charts included;
+ - **T1:** two grounds, Light and Dark, in every app; a stored Sepia reads as Light before
+ paint and is rewritten; each site wears its own accent, and the accent picker is gone;
+ - **H1/H2:** every site's header and the hub's carry the social row and the toggle as one
+ group; an Archilyzer link to the homepage's new `#instances` replaces the sites dropdown
+ and the hub link; Changelog is in the footer; the wordmark's text drops exactly when it
+ does not fit, for any title; the inline nav starts at `lg`.
+ - **Reviewed SHIP AFTER FIXES; the fixes are in:**
+ - stacked areas in the shared charts keep their coloured edge, with the surface gap on
+ stacked bars only (H1);
+ - the growth chart measures thickness at right angles before it takes a gap, so no band is
+ covered; the gaps now read as dashes, and folding the small sites into "Other" or small
+ multiples is the operator's call (M1);
+ - the hub URL texts say what the setting does now (M2);
+ - the Lows (L1, L2, L5, L6, L9, L10); L3 and L7 are left, and L4 needed nothing.
+ - **Owed:** the parent's merge, then the rollout in `release-14.md` ("## Rollout"): the editor,
+ then the homepage (with `#instances`) before any site, then the hub, then the six sites.
+ - **The narrow header, as ruled after the review, is built** (`447ded9a`). Below 520 px every
+ header shows the name and only the links marked **Keep in header on small screens** (none
+ marked → none); from 520 px it shows every link, up to four.
+ - **Re-reviewed SHIP; its findings are in** (`1a2e3342`, `4ea1c495`, `11a33f7a`):
+ - below the switch the export bar's two gaps are 8 px (ruled 2026-09-29): every real title
+ shows in full at 360 px under touch with one marked link (Rekietalyzer at exactly 360);
+ - the wordmark's reservation is a minimum width, so wider-rendering text is never clipped;
+ - the switch is `32.5rem`, so it moves with the reader's text size, and the homepage's
+ wordmark fit is keyed by the wide row's count from it;
+ - a marked link keeps its configured place (ruled as built).
+ - **The operator's settings:** the link to keep in the header is marked in Settings BEFORE any
+ build; the parent does this when the branch lands (`release-14.md`, "## Rollout",
+ precondition 5).
- **Next candidates:** one-core Phase 5 (projects join the core, `plans/one-core.md`); the Diagnostics
cards keeping their retry log (O3's found-and-left); `ChartView.tsx`'s five-slot cycle reaching
`--chart-6` (O2); O5's two wording lows in `svg-faces.mjs` / the README (kerning is not
diff --git a/plans/export-header-first-search.md b/plans/export-header-first-search.md
@@ -39,6 +39,24 @@ main...<branch>` is empty for the search and header components on all five).
- 2026-09-28: the options menu is dropped for now in favour of a three-way toggle (slice HP on the
homepage; slice H2 in the export). A later slice drops the Sepia base in every app and removes
the accent picker from the export and editor headers (slice T1, below).
+- 2026-09-28, on the charts: the chart follows standard practice for light and dark grounds; the
+ foreground-coloured separator lines are withdrawn (a 2 px gap in the surface's colour).
+- 2026-09-28 (T1): Sepia is dropped in every app, the editor included; a reader who chose Sepia
+ gets Light; readers no longer pick an accent — each site shows its own, and stored accent choices
+ are ignored.
+- 2026-09-28 (H1/H2): every site's header and the hub's carry the social row with the cycling
+ toggle as the last item of the same group, the homepage's; the Sites dropdown becomes a text
+ link to the Archilyzer home's Official Instances (not on the hub); the header's Hub link goes;
+ Changelog joins the footer after Use with AI; narrow widths behave as the homepage's; the
+ slide-out menu keeps the nav; the editor's header keeps the toggle, with no accent picker.
+- 2026-09-28 (after the branch's review): 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.
+ `featured` now means "keep in the header on small screens" (below 520 px only those; from
+ 520 px every link up to four, those kept first). Built on `r14/two-grounds-headers`.
+- 2026-09-29 (after the re-review): below the switch the header bar's two gaps go from 12 to 8 px;
+ type, the mark–name gap and the reserved width's margin stay as they are. The switch is written
+ in rem (32.5rem), so it moves with the reader's text size, and the wordmark's reserved width is a
+ minimum. A marked link keeps its configured place in the wide row (as built).
- Standing: **no copy** beside any social link: icons with accessible names only.
## Decisions and assumptions
@@ -63,17 +81,20 @@ ASSUMED by the planner (2026-09-28) — each is one line to reverse, and the ope
## Dependency graph
```
-HP (homepage social row, toggle, cards + schema, BUILT) ──► H1 ──► H2 ──► T1
-H3 (homepage anchor) ── independent; H1's link needs it DEPLOYED to land on the list
+HP (homepage social row, toggle, cards + schema, BUILT, merged)
+ ──► T1 ──► H1 (with H3 folded in) ──► H2 BUILT on r14/two-grounds-headers
S1 (clear screen) ── independent of H*; touches no header file
S2 (defer summaries) ── after S1, only on the operator's word
-T1 (two grounds) ── after HP merges, before the production deploy
```
-H1 then H2 are stacked on one branch (both rewrite `Header.tsx` and `MobileMenu.tsx`), branched
-after HP merges: H1 adopts HP's `SocialLinks` component. S1 and H3 run in parallel with them on
-their own branches. Merge order: HP → H3 → H1+H2 → S1, and T1 after HP and before the production
-deploy. HP and H3 share only `homepage/CHANGELOG.md` (`[Unreleased]`).
+As built (2026-09-28): T1, H1 and H2 are one branch, `r14/two-grounds-headers`, off `main`
+`c6b8fc70`, in that order, after the final review's Lows and the chart's gap; H3 is folded into
+H1 (the homepage's anchor ships with the link to it). The record is `release-14.md`, "Branch
+`r14/two-grounds-headers`".
+
+As planned: H1 then H2 stacked on one branch (both rewrite `Header.tsx` and `MobileMenu.tsx`),
+branched after HP merges, with H3 and S1 on their own branches, T1 after HP and before the
+production deploy. As built: the one branch above; S1 is next, on its own.
## Verified facts the implementer must not re-derive
@@ -206,7 +227,7 @@ shipped". What it built, so H1 does not re-derive it:
(`.growth-sep`; `CanvasText` in forced colours), no longer the page background. Homepage-only:
the shared charts have no such separator.
-## Slice H3 — the homepage's instances anchor (branch `r14/home-anchor`)
+## Slice H3 — the homepage's instances anchor (FOLDED INTO H1, built)
Owns `homepage/app/page.tsx`, `homepage/e2e/marketing.spec.ts`, `common/lib/project.ts` (one
constant), `homepage/CHANGELOG.md`.
@@ -217,7 +238,14 @@ constant), `homepage/CHANGELOG.md`.
link lands on the top of the page; that is accepted and said in the record.
4. Gates: tsc, homepage unit, homepage e2e, `archilyzer build homepage --no-source`.
-## Slice H1 — the header carries the social row (branch `r14/header`, after HP merges)
+## Slice H1 — the header carries the social row (BUILT on `r14/two-grounds-headers`)
+
+As built, where it differs from the steps below: the inline nav and the Archilyzer link start at
+`lg`, not `md` (at 768 px they, a long title and four icons did not fit); the Archilyzer link is in
+the slide-out menu below `lg`; the wordmark's text drops by a pure-CSS wrap in a one-line clipped
+box, exact for any title, with no measured threshold, its width reserved from Archivo's own metrics
+so the decision is the same before and after the web font loads (review L2); from `lg` the brand
+shrinks first (L1); the footer's row wraps (L6); the footer's `-mx` inset is the shared row's. `header.spec.ts` covers 280–430 px with a short and a long title, 1–4 links, both pointers.
Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`,
`export/e2e/{helpers.ts,site-branding.spec.ts,brand.spec.ts,responsive.spec.ts}`, a NEW
@@ -258,7 +286,10 @@ Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`,
`related-sites.spec.ts`; hub e2e `official-instances.spec.ts`; screenshots of the header at
360 / 390 / 768 / 1280 px on Light, Sepia and Dark to `~/reports/release-14/shots/`.
-## Slice H2 — one theme toggle (same branch, stacked on H1)
+## Slice H2 — one theme toggle (BUILT on `r14/two-grounds-headers`)
+
+As built: T1 went first, so the slide-out menu lost its Base radios here with `ThemeRadios`
+(deleted), rather than keeping them until T1.
Owns `export/app/components/{Header,MobileMenu}.tsx`, `export/e2e/{theme,theme-accent,responsive,
brand}.spec.ts`, `export/CHANGELOG.md`. The editor keeps its own header controls until T1.
@@ -278,7 +309,10 @@ brand}.spec.ts`, `export/CHANGELOG.md`. The editor keeps its own header controls
untouched.
4. Gates: as H1, plus the homepage e2e `toggle.spec.ts` and `theme.spec.ts`.
-## Slice T1 — two grounds (planned; after HP merges, before the production deploy)
+## Slice T1 — two grounds (BUILT on `r14/two-grounds-headers`, before H1)
+
+As built: the retired value is spelled once, `RETIRED_BASE` in `themeConfig.ts`; `ThemeMenu` and
+the `pinAccent` option are deleted, and `ThemeRadios` kept the base alone until H2 deleted it.
Owns `common/components/theme*` (`themeConfig.ts`, `ThemeProvider.tsx`, `ThemeScript.tsx`,
`ThemeToggle.tsx`, `ThemeMenu.tsx`, `ThemeRadios.tsx`), `common/styles/tokens.css`, the three apps'
diff --git a/plans/export-responsive-redesign.md b/plans/export-responsive-redesign.md
@@ -258,6 +258,10 @@ in `globals.css:3` means `@utility`s defined there are usable from `common/`.
- `export/app/components/Header.tsx:43`: `h-14 … flex-wrap` → `min-h-14 flex-nowrap`. Nav `:51-77` → `hidden md:flex` + add "Ask AI" → `/ask/`. Right cluster `:79-99`: SiblingSwitcher, Hub `<a>`, Changelog, ThemeMenu → `hidden md:…`; `ThemeToggle` stays visible at every width (theme.spec `/switch to/i`; theme-family.spec "Choose theme" is served by the inline ThemeMenu at 1440).
- New client `export/app/components/MobileMenu.tsx`: props `{ links:{href,label}[]; sites: SwitcherGroup[] (type from SiblingSwitcher.tsx:18-19); hubUrl?: string }`. `<Sheet>` + `<SheetTrigger asChild><Button variant="ghost" size="icon-sm" aria-label="Open menu" className="md:hidden">`, `<SheetContent side="right">` with a `<SheetTitle>` (Radix warns without one); every `<Link>` wrapped in `<SheetClose asChild>` (Header lives in the root layout and never remounts on client nav). Theme lists via `useTheme()` (`ThemeProvider.tsx:35-38`) + `THEME_FAMILIES`/`THEME_MODES` from `themeConfig` as native radio groups — do not nest `ThemeMenu`'s DropdownMenu in the dialog. Offline link gated like `Footer.tsx:56` (`site.pwa`).
- `export/app/components/Footer.tsx:29` → `flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-safe`; `:82` drop `whitespace-nowrap`. Leave the anchor naming (`"Built with"` outside the `<a>`) and `nav aria-label="Related sites"` untouched.
+- **Superseded, release 14 (H1/H2, `r14/two-grounds-headers`):** the header's right cluster is now
+ the social row and the theme toggle as one group at every width; the SiblingSwitcher, the Hub
+ link, Changelog and ThemeMenu are gone from it (Changelog is in the footer); the inline nav and
+ an Archilyzer link start at `lg`; MobileMenu holds only the nav and that link.
### Slice 4 — Results (M, 1 spec edit)
- `common/components/SearchResults.tsx` card header `:470-538`: `flex items-stretch` → `flex flex-col sm:flex-row`; title `:504` `truncate` → `line-clamp-2 sm:line-clamp-none sm:truncate`; meta `:521-526` → `basis-full sm:basis-auto`; Ask `:528-537` `border-l` → `max-sm:border-t`. Preserve `data-result-slug`/`data-card-header` (`:460-461`), `data-card-open` (`:487`), checkbox aria-label (`:480`).
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -19,10 +19,11 @@ slice HP added to it on the operator's ruling of the same day. Rules:
| Slice | Branch | What | Owns |
|---|---|---|---|
| HP | `homepage/social-visible` | The homepage's social row and one theme toggle in the header at every width, the wordmark's text dropped first on a very small screen and the row scrolling only as the last resort, one shared `SocialLinks` component, larger keys with a focus ring; Changelog in the footer only; the instance cards' names as the site's wordmark; the social icon checked by an allowlist on save and at render; a sized SVG with no viewBox gets one; `featured` ("Show in header") on a social link | `common/components/{SocialLinks,SocialScroll,ThemeRadios,ThemeToggle,ThemeScript,ThemeProvider,Wordmark}.tsx` + `themeConfig.ts`, `common/bin/doctor.ts`, the growth chart, `common/lib/{socialSvg,socialLinks}.ts` + tests, `common/lib/settingsSchema.ts` (the social-link type, parser, docs; the normalizer moved to `socialSvg.ts`), `common/lib/normalizeSocialSvg.test.ts`, `common/lib/{settings,site,homepage}.ts` (the save errors), `common/lib/{homepageSummary,siteColor}.ts`, `homepage/app/components/{Header,Footer,ArchiveCards}.tsx`, `homepage/app/lib/{nav,summary}.ts`, `homepage/app/not-found.tsx`, `homepage/e2e/**` (the fixtures, `helpers.ts`, the new and the rewritten specs), `homepage/playwright.config.ts`, `export/app/components/{MobileMenu,Footer}.tsx` (the `ThemeRadios` swap; the footer's read path), `editor/app/components/SocialLinksField.tsx` + `socialLinksJson{,.test}.ts`, `editor/app/{settings,sites}/actions.ts` (the save errors), `editor/e2e/settings.spec.ts`, `SETTINGS.md`, `SITE.md` |
-| H3, H1, H2, S1 | per the plan | per the plan | per the plan |
+| Lows, chart gap, T1, H1 (H3 folded in), H2 | `r14/two-grounds-headers` | The final review's Lows; the charts' surface gap; two grounds and each site in its own accent; the export and hub headers carry the social row and the toggle as one group, with an Archilyzer link to the homepage's `#instances` in place of the sites dropdown and the hub link; Changelog to the footer; after its review, the narrow header keeps the name and shows only the marked links (every header) | `common/components/{ThemeProvider,ThemeScript,ThemeToggle,SocialScroll}.tsx` + `themeConfig.ts` (and the deleted `ThemeMenu`, `ThemeRadios`), `common/components/charts/{ChartView,CrossSiteChart,surfaceGap}`, `common/styles/tokens.css`, `common/lib/{brand,accent,siteColor,paths,project,socialSvg,siteSchema,settingsSchema}.ts` + tests, `scripts/next-build-trace.test.mjs`, `export/app/components/{Header,MobileMenu,Footer}.tsx` (and the deleted `SiblingSwitcher`), `export/app/{layout.tsx,globals.css,changelog/page.tsx,lib/brand.ts}`, `export/e2e{,-hub}/**` (the theme, header and branding specs), `export/playwright.config.ts`, `editor/app/{layout.tsx,globals.css,sites/components/SiteForm.tsx}`, `editor/e2e/theme.spec.ts`, `homepage/app/{page.tsx,layout.tsx,globals.css,lib/*,changelog/page.tsx,components/{Header,ArchiveGrowthChart,ArchiveCards}.tsx}`, `homepage/e2e/**`, `homepage/content/docs/operate.md`, `SETTINGS.md`, `SITE.md` |
+| S1 | per the plan | per the plan | per the plan |
-**Order:** HP → H3 → H1+H2 → S1. The shared files are `editor/CHANGELOG.md`,
-`homepage/CHANGELOG.md` (`[Unreleased]`) and this record.
+**Order:** HP → `r14/two-grounds-headers` → S1. The shared files are the three changelogs'
+`[Unreleased]` sections and this record.
## Record
@@ -48,6 +49,8 @@ branch:
8. The options menu is dropped for now in favour of a three-way toggle.
9. (The re-review, ruled by the parent.) A title or desc can no longer reach a page; an icon loads
nothing from elsewhere; a stored icon the checker refuses does not block an unrelated save.
+10. (After the merge, 2026-09-28.) The chart follows standard practice for light and dark grounds;
+ the foreground-coloured separator lines are withdrawn.
**The branch's history was rewritten once** (after the review, ruling 5): its first nine commits,
one of which added a vendor file as a test fixture, were replaced by the five commits below,
@@ -250,6 +253,9 @@ colours. The gridlines are unchanged.
(`common/components/charts/`, the homepage's `/stats`, the export sites and the hub) draw each
series' edge in its own colour and have no page-coloured separator. Whether they take a
foreground separator is left for the operator to rule on.
+- **Withdrawn (ruling 10).** The separators were first drawn in the foreground colour, then
+ withdrawn for a 2 px surface gap on the operator's ruling; that change is on
+ `r14/two-grounds-headers` (its record, below).
**Beyond the prompt.**
- **Icon ids are scoped per copy (`scopeSvgIds`).** An id resolves to the first element carrying
@@ -446,4 +452,608 @@ choosing, kept, and said beside the checkbox. The touch rule was kept, then supe
- `export/CHANGELOG.md` `[Unreleased]` (created by `fix/stats-cache-key`): an icon that fails the
check is shown as its label, bounded, and every icon paints inside its box.
+### Branch `r14/two-grounds-headers` — the final review's Lows, the chart's gap, T1 and H1/H2 (2026-09-28)
+
+Branch `r14/two-grounds-headers` off `main` `c6b8fc70` (slice HP merged), worktree
+`~/Projects/homepage-social-visible` (block #3: export dev 3300, export e2e 3320, hub e2e 3341,
+homepage e2e 3340, editor test 3311), one Opus implementer. Scratch files `t-*` in the job's
+`tmp`. The rulings, all 2026-09-28:
+1. (The chart, operator.) The chart follows standard practice for light and dark grounds; the
+ foreground-coloured separator lines are withdrawn.
+2. (T1, operator.) Sepia is dropped in every app, the editor included; a reader who chose Sepia
+ gets Light; readers no longer pick an accent — each site shows its own, and stored accent
+ choices are ignored.
+3. (H1/H2, operator.) Every site's header and the hub's carry the social row with the cycling
+ toggle as the last item of the same group, the homepage's; the Sites dropdown is replaced by a
+ text link to the Archilyzer home's Official Instances (omitted on the hub); the header's Hub
+ link is removed; Changelog joins the footer after Use with AI; on narrow widths the wordmark's
+ text drops first and the scroll box is the last resort; the slide-out menu keeps the nav; the
+ editor's header keeps the toggle, with no accent picker and nothing else restyled.
+4. (The final review's Lows, parent.) F1, F3, F4, F5 and F8 fixed; F2, F6 and F7 left.
+5. (After the review, operator.) 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.
+
+| sha | what |
+|---|---|
+| `d2b040fc` | `homepage:` the social row's scroll box and the compact nav are not tab stops of their own (F1); the toggle's no-flash test records every accent change (F5) |
+| `ae5d535c` | `common:` the how-to-export hint only on a drawing program's leftovers (F3); stale text: `MobileMenu`'s comment, the older homepage changelog bullet, `featured`'s doc (F4) |
+| `ec64011f` | `common, homepage, export, scripts:` every cwd-derived path op the three Next apps bundle opts out of Turbopack's tracing; the guard, renamed `scripts/next-build-trace.test.mjs`, covers them (F8) |
+| `80228398` | `homepage, common:` chart bands are parted by a 2 px gap in the surface's colour; the foreground separators are withdrawn (ruling 1) |
+| `3d0d1a46` | `common, export, editor, homepage:` two grounds, Light and Dark; each site wears its own accent (T1, ruling 2) |
+| `46bd173c` | `homepage, common:` `id="instances"` on Official Instances and `INSTANCES_URL` (H1; the planned H3 folded in) |
+| `04a1cfac` | `export:` the header's group, the Archilyzer link, no sites menu or hub link, Changelog in the footer, the narrow header; `header.spec.ts` (H1, H2) |
+| `0f358ee7` | `export:` the footer's dot shows only after a downloads link; the social row keeps the shared row's inset |
+| _this_ | `plans:` this record; the plan (H3 folded into H1, T1 and H1/H2 as built); STATE; FACTS' guard entry; the changelogs |
+
+#### The final review's Lows
+
+- **F1:** `SocialScroll`'s box has `tabIndex={-1}`, and so has the homepage's compact nav, which
+ overflows below 320 px. Firefox 146 (the installed build, by its path) made both a tab stop of
+ their own when they overflow. At 220 px with four links, without the fix Tab went home → the box
+ → the icons; with it, home → the icons → the toggle → the nav's links. The suite is Chromium
+ (Playwright's own Firefox revision is not installed), so it asserts the attribute.
+- **F3:** the hint ("export it with presentation attributes rather than a style block (in
+ Inkscape, save as Plain SVG)") follows a `style`, `<metadata>` and an element or attribute in an
+ editor's namespace. An `<a>`, `<image>`, `<title>` with markup, `<foreignObject>` or `src` gets
+ the reason alone. There is a unit test both ways.
+- **F4:** `MobileMenu`'s comment, the older homepage changelog bullet that let readers pick an
+ accent (reworded to the end state), and `featured`'s doc, which said "a narrow header shows
+ fewer" (`SETTINGS.md` and `SITE.md` regenerated).
+- **F5:** the stored-accent test installs a MutationObserver from an init script and records every
+ value `data-accent` holds; a flash between two samples fails it.
+- **F8:**
+ - `/* turbopackIgnore: true */` is on the homepage's `source.ts` directory join and on the
+ `docs.ts`, `snapshot.ts`, `summary.ts` and both changelog pages' cwd joins.
+ - In `common/lib/paths.ts`, every join on a path built from the repo root goes through one
+ opted-out `under()`, and `findMonorepoRoot()`'s walk and fallback are opted out.
+ `getPaths()` returns the same 58 values as before (diffed).
+ - The guard is `scripts/next-build-trace.test.mjs`, in `test:scripts`, 6 tests.
+ - It scans umtool as before, and adds `homepage/app`, `export/app`, `editor/app`,
+ `editor/lib`, `editor/instrumentation.ts` and every `common/` module but `bin/` (over 500
+ modules).
+ - A function declared in the file whose body carries a source is a source.
+ - Brackets inside a regex literal no longer end a call.
+ - On `main`'s files it lists 57 findings. With `main`'s `source.ts` put back, it fails on
+ `source.ts:23`.
+ - **The homepage build, capped, with and without the published source** (`homepage/public/source`,
+ 2,893 files, 70 MB), a clean `.next` each time:
+
+ | Build | Time | Max RSS |
+ |---|---|---|
+ | with the source | 14.55 s, 15.15 s | 807,332 KB, 801,948 KB |
+ | without it | 15.11 s | 797,744 KB |
+ | `main`'s join, with the source | 13.99 s | 837,144 KB |
+
+ No measurable difference: the opt-out is by rule, not a measured fix today.
+- **Left:** F2 (an animation of `style` or `class` is not held to the style allowlist; the key's
+ clip contains it). F6 (the form round-trip trims a stored SVG, so a hand-edited icon with
+ surrounding whitespace is re-checked on a form save). F7 (at 390 px the chart's thinnest upper
+ strata; see the gap below, which leaves a thin band its colour).
+
+#### The chart's surface gap (ruling 1)
+
+- **The growth chart** (`ArchiveGrowthChart.tsx`, `.growth-gap` in `homepage/app/globals.css`):
+ - a 2 px gap along each band's upper edge, in `var(--background)` (the chart sits on the page
+ ground, as the e2e checks); non-scaling, round joins; `Canvas` in forced colours;
+ - drawn centred after every fill, so each neighbour gives 1 px;
+ - no gap at the stack's top (the surface is already there);
+ - one set of gaps per plot height (200, 260, 300 px), each in a `<g>` shown by its class: a gap
+ is drawn only where both bands are at least 3 px tall at that height, so a band that gives
+ one keeps at least 1 px of its colour and a thinner band touches its neighbour instead
+ (after the review, measured at right angles to the edge: M1 in "Review" below);
+ - a run under three months is dropped.
+- **Measured on the published summary** (the first, vertical rule; after M1 see "Review"):
+
+ | Plot height | Smallest band that gives a gap keeps | Smallest band with no gap | Month boundaries parted / touching |
+ |---|---|---|---|
+ | 200 px (a phone, 390 px) | 1.00 px | 0.10 px | 182 / 277 |
+ | 300 px (1280 px) | 1.00 px | 0.15 px | 302 / 157 |
+
+ At 390 px the 2016–2020 bands are 0.1–5.5 px tall.
+- **The shared charts** (`common/components/charts/surfaceGap.ts`, `--chart-gap` in
+ `tokens.css`: the chart surface, `Canvas` in forced colours):
+ - stacked bars get a 2 px stroke per segment;
+ - stacked areas got a 4 px stroke along the band's top edge, half under the band above, so
+ 2 px showed — withdrawn after the review (H1): they keep their series-coloured edge;
+ - single-series charts, lines and side-by-side bars are unchanged.
+ - This covers `ChartView` (the export's charts) and `CrossSiteChart` (the homepage's `/stats`
+ customize view).
+- **Screenshots:** `~/reports/release-14/shots/chart/gap2-{390,1280}-{light,sepia,dark}{,-thin}.png`
+ (taken before T1 removed Sepia).
+- **Departures for the operator to rule on, not changed:**
+ - `ChartView` and `CrossSiteChart` fill stacked areas translucent (0.2 and 0.25); the standard
+ is opaque fills parted by the gap.
+ - `CrossSiteChart`, `LeaderboardChart` and `MomentumChart` draw dashed gridlines
+ (`strokeDasharray="3 3"`); the standard is solid hairlines.
+ - `ChartCard`'s surface is `--card`, equal to `--chart-surface` on both bases today.
+
+### Slice T1, as shipped — two grounds (2026-09-28)
+
+- **Two grounds.**
+ - `THEME_BASES` is System, Light, Dark.
+ - `nextBase` walks `THEME_BASES` (system → light → dark → system).
+ - `ResolvedBase` is light | dark; `isThemeBase` takes the three.
+- **Deleted:**
+ - the Sepia block in `common/styles/tokens.css` and its `--base-sepia` flag;
+ - `onSepia` in every accent, `BASE_GROUNDS.sepia`, `ACCENT_INK.sepia`, the fitted
+ `--accent-custom-sepia`;
+ - the BookOpen icon;
+ - Sepia from every spec, fixture and comment;
+ - `ThemeMenu.tsx` (nothing renders it);
+ - `accentOptions` and `isThemeAccent`;
+ - the `pinAccent` option (it is the only behaviour now).
+- **Kept:**
+ - `ACCENT_KEY`, documented as a pick nothing reads or deletes.
+ - `ThemeRadios`, base-only in this commit; H2 deleted it with its caller.
+- **Migration, no flash:**
+ - A stored `ytdlp-tb:base` of the retired value (`RETIRED_BASE` in `themeConfig.ts`, the one
+ place its name is spelled) is `light` in the pre-paint script and in `ThemeProvider`, and is
+ rewritten to `light` once.
+ - The legacy `archive` + `light` pair maps to `light`.
+ - Any other unknown value falls back to the app's default, as before.
+ - Unit tests cover the whole matrix (the retired value, `archive` + `light`, garbage, a storage
+ that will not take the write).
+ - Each app has an e2e for a stored retired base: an init-script MutationObserver sees no ground
+ but the server's and Light, `data-theme-ready` is set, and storage reads `light`.
+- **The accent is the site's:**
+ - `ThemeScript` and `ThemeProvider` never read `ytdlp-tb:accent` and never remove it.
+ - The export's layout and header, and the editor's header, have no accent picker; the
+ slide-out menu's accent list is gone.
+ - The site form's accent control is config and is unchanged; its hint no longer says a reader
+ can pick another.
+ - `theme-accent.spec.ts` keeps the site's own accent (fitted per base, no picker), a stored
+ accent ignored with no flash and left in place, and the hint in the site's accent on each
+ base. The four-bases menu, "pick Violet" and "pick the site's colour again" went with the
+ picker.
+- **Docs:** `operate.md` (no theme menu; a light or dark ground with the header's toggle),
+ `siteSchema`'s accent text (`SITE.md` regenerated), the site form's hint.
+- **Counts** (`git grep -i sepia -- . ':!plans/' ':!*CHANGELOG.md'`): 47 files, 177 lines at the
+ branch's start (with HP merged). After T1: one line, `RETIRED_BASE = "sepia"`, which the
+ migration needs in order to recognise a stored value. The changelogs' `[Unreleased]` sections
+ name it nowhere; released entries keep 9 lines.
+
+### Slice H1/H2, as shipped — the export and editor headers (2026-09-28)
+
+- **The export header** (every site and the hub):
+ - From `lg` (1024 px): brand · nav · **Archilyzer** · the group.
+ - Below `lg`: brand · the group · the menu trigger.
+ - The group is the homepage's: `SocialLinks` (header, at most four: `featured`, else the last
+ four) in `SocialScroll`, then `ThemeToggle variant="bare"`, 36 px keys (44 px under a coarse
+ pointer) touching.
+ - The footer's row is `SocialLinks` (footer) too: all the links, the same keys, the text colour
+ on hover where it was the accent.
+- **The Archilyzer link** replaces `SiblingSwitcher` (deleted):
+ - visible text "Archilyzer", accessible name "Archilyzer — official instances";
+ - `INSTANCES_URL`, same tab;
+ - not on the hub.
+ - The homepage's Official Instances section has `id="instances"`, with `scroll-mt` equal to the
+ sticky header's height. The planned H3 is folded in here, with a homepage e2e for
+ `/#instances`.
+- **The header's Hub link** is gone from the bar and the menu; `hubUrl` and `resolveHubUrl` still
+ parse. **Changelog** is in the footer after Use with AI, and not in the bar or the menu.
+- **The slide-out menu** holds only the nav, plus the Archilyzer link on a site. It is kept:
+ five-plus nav links do not fit a phone's bar. `ThemeRadios` is deleted.
+- **The nav's breakpoint moved from `md` to `lg`:**
+ - At 768 px the nav, the Archilyzer link and a long title with four icons did not fit, so the
+ wordmark hid and an icon scrolled while the nav was still inline.
+ - With the nav in the menu below `lg`, the icons never scroll at 768.
+- **Narrow widths, for any title** — the approach, and why (superseded in part by ruling 5:
+ below 520 px the header now shows only the marked links, and the wrap and the scroll box are
+ last resorts; see "The narrow header, as ruled"):
+ - The wordmark's text sits in a one-line box (`h-7`, `overflow-hidden`, `flex-wrap`) behind a
+ zero-width strut. The text is one flex item: when it does not fit beside the strut, it wraps
+ to the second line, which is clipped.
+ - So the text drops exactly when it does not fit, measured by the browser in the site's own
+ font at the reader's text size. There is no per-site threshold and no build-time estimate.
+ Titles differ in length, and a threshold written down would be wrong for some title or some
+ text size.
+ - The text stays in the DOM, so the link's name stays the title.
+ - Below `lg` the brand link takes the bar's free space (basis 0, grow 1, minimum the mark), so
+ the group gives way only once the link is the mark alone. The row's box then scrolls, end
+ first, as on the homepage.
+- **Measured** on a dev server (synthetic titles, the viewport width):
+
+ | Title | Links | Full wordmark from (mouse / touch) | Row scrolls below (mouse / touch) |
+ |---|---|---|---|
+ | "Shortlyzer" | 1 | 321 / 337 | never ≥ 240 |
+ | | 2 | 357 / 381 | never / 251 |
+ | | 3 | 393 / 425 | 263 / 295 |
+ | | 4 | 429 / 469 | 299 / 339 |
+ | "Longestfixturealyzer" | 1 | 449 / 465 | never ≥ 240 |
+ | | 2 | 485 / 509 | never / 251 |
+ | | 3 | 521 / 553 | 263 / 295 |
+ | | 4 | 557 / 597 | 299 / 339 |
+
+ The page never scrolls sideways from 240 to 1023 px. The row scrolls at 320 px only with four
+ links under touch (by 19 px): the export bar also carries the menu trigger, which the
+ homepage's does not.
+- **The editor's header:**
+ - `ThemeToggle` (its default variant, the editor's existing chrome) cycles System, Light and
+ Dark.
+ - `ThemeMenu` is gone.
+ - Nothing else is restyled.
+ - There is no social row.
+- **Tests:**
+ - `header.spec.ts`, new:
+ - a short and a long title × 1–4 links × 280–430 px × mouse and touch, checking that the page
+ and the header never overflow, every link shows, the text drops before the row scrolls, and
+ the mark, the toggle and the link's name hold;
+ - a short title in full at 390 px, a long one giving way;
+ - key sizes (36 and 44 px) and touching boxes;
+ - the focus ring;
+ - the Archilyzer link's href, name and text; no sites button, hub link or "Choose theme";
+ - Changelog in the footer after Use with AI and not in the header;
+ - six links and two featured;
+ - a hostile icon in `socialLinks` as its label, running nothing and requesting no other
+ origin.
+ - The fixture site is rewritten per test and restored. It carries its original in
+ `_e2ePristine`, which `playwright.config.ts` restores if a run dies.
+ - `responsive.spec` covers the menu's contents; `site-branding` scopes its footer link;
+ `official-instances` (hub) checks the hub's header has no Archilyzer link, sites menu or theme
+ menu.
+- **Screenshots** (`~/reports/release-14/shots/h1/`, synthetic icons, six configured so the header
+ shows four):
+ - `{short,long,hub}-header-{320,360,390,768,1280}-{light,dark}.png`;
+ - `{short,long,hub}-footer-{…}.png`;
+ - `editor-header-{1280,390}-{light,dark}.png`.
+
+#### Gates (at `0f358ee7`; logs `$T/t-g-*.log`)
+
+- **tsc** was clean before every commit, and at the tip (49 s).
+- **Unit:**
+
+ | Suite | Result |
+ |---|---|
+ | common | 2,204/2,204 |
+ | editor unit | 87/87 |
+ | homepage unit | 8/8 |
+ | `test:scripts` | 191 passed, 1 skipped (192) |
+ | mcp | 271/271 |
+
+- **Docs:** `settings example --check`, `docs files --check` and `docs env --check` all exit **0**.
+- **Builds**, each capped at 5 GB with no swap, one at a time, from a clean `.next` (time, max RSS):
+
+ | Build | Time | Max RSS |
+ |---|---|---|
+ | homepage (fixture icons) | 17 s | 796 MB |
+ | export, site (the worktree's default) | 25 s | 982 MB |
+ | export, the fixture site | 29 s | 990 MB |
+ | export, hub | 26 s | 1,004 MB |
+ | editor, with the corpus visible | 44 s | 1,645 MB |
+ | umtool, with the corpus visible | 19 s | 784 MB |
+
+ For the editor and umtool builds the primary's `transcripts/` was linked in, and the worktree's
+ own set aside. Both were put back after the builds; nothing ran through the link.
+- **e2e**, each detached and queued:
+
+ | Suite | Passed | Failed | Time |
+ |---|---|---|---|
+ | homepage, full | 77 | 0 | 1.8 min |
+ | export, full | 220 | 0 | 8.6 min |
+ | hub, full | 34 | 0 | 1.1 min |
+ | editor: `theme`, `sites-crud`, `settings`, `export-search` (the specs on the theme controls, the site form's accent hint, the social-links field, and the export's header and footer as the editor's export server shows them) | 51 | 0 | 1.7 min |
+
+- **Along the way:**
+ - the chart commit: tsc; homepage unit 8/8; a capped homepage build 16 s; homepage e2e 75/75
+ (2.2 min); export `charts.spec` 9/9;
+ - T1: homepage theme/toggle/brand/instance specs 21, export theme/branding specs 30, hub 8,
+ editor 20;
+ - H1/H2: export header/responsive/branding specs 43 (one assertion order fixed), hub 9,
+ homepage `marketing` 11.
+- **Numbers tool:** none.
+
+#### Found and left
+
+- **The export footer's `-mx` inset** keeps the shared row's value; on a wide screen the last
+ key's box reaches 8 px into the container's padding.
+- **At 320 px with four links under touch** the export header's row scrolls by 19 px. This is the
+ ruled last resort; the menu trigger is what the homepage does not have.
+- **`resolveHubUrl`** has no caller in the export app now. It still parses, as ruled.
+- **Playwright's own Firefox and WebKit are not installed** at this Playwright's revisions; F1 was
+ checked by hand in the installed Firefox 146.
+- **The chart departures** above (translucent stacked areas, dashed gridlines) are for the
+ operator.
+
+#### Decisions the operator could overturn
+
+| What I assumed | The alternative |
+|---|---|
+| The export's inline nav and the Archilyzer link start at `lg` (1024 px), with the menu below | keep `md` and let the wordmark hide and an icon scroll at 768–1023 px |
+| The Archilyzer link is in the slide-out menu below `lg` | leave it out of the menu, so a phone reaches it only by the footer's "Built with Archilyzer" |
+| The link is styled as the old Changelog link (small, muted), with the text "Archilyzer" | mono uppercase like the old Hub link, or the nav's style |
+| The wordmark's text hides by a pure-CSS wrap, exact for any title | a per-site threshold computed at build time from the title's estimated width |
+| The export footer's icons take the shared keys (36 px, hover to the text colour) | keep the footer's 20 px accent-hover icons |
+| The chart's gap is skipped where either band is under 3 px at the drawn height, and runs under three months are dropped | a gap on every boundary, erasing the thinnest bands |
+| Stacked areas in the shared charts get the surface gap over their translucent fills | leave their series-coloured top lines until the fill opacity is ruled on |
+| `RETIRED_BASE = "sepia"` stays in `themeConfig.ts` for the migration | spell it indirectly so the grep reads zero |
+| The export footer's dot shows only after a downloads link | leave the dot unconditional, as before |
+
+#### Review (verdict SHIP AFTER FIXES; `$T/t-review.md`)
+
+| Finding | Fix |
+|---|---|
+| H1: the stacked-area surface gap erased small values and cut peaks (every site's multi-series area charts, `/stats`) | `92dc4084`: stacked areas keep `main`'s series-coloured edge (ChartView 1 px, CrossSiteChart 1.5 px); `BAR_GAP` stays on stacked bars. `charts.spec` reads the chart back from a screenshot (`common/testing/chartPixels.ts`): at 390 and 1280 px the stack's topmost painted row at each month is within 1 px of the value scale's y for the true total, and each series shows pixels of its own fill; with the old gap the tops land 2–4 px low |
+| M1: the growth chart's thin-band rule was vertical, so on steep edges the gap covered whole bands | `f455d7da`: `homepage/app/lib/growthGaps.ts` (pure): thickness at right angles to the edge (min vertical height × cos θ) at the narrowest plot of each height (240, 592, 976 px); a gap only where both bands keep ≥ 1 px after every gap on them. `growthGaps.test.ts` checks every drawn segment independently: 0 covered band-segments on the fixture, a slivers-and-spikes stand-in and a one-month spike (two of the four fail under the old rule). `growth-chart.spec`: only the drawn height's set shows; the peak's painted top is within 1 px of the true total; every site with data shows its colour |
+| M2: four texts still promised the Hub link | `706f62bf`: Settings' Family hub URL and a site's Hub URL hints, `homepageUrl`'s and `hubUrl`'s docs: the value is published as `hubUrl` in the site's `/site.json` and `/corpus.json` so the hub can tell member sites; no page links to it. `SETTINGS.md`, `SITE.md` regenerated; labels and field names unchanged |
+| L1: from 1024 to 1046 px a very long title dropped its text and scrolled the icons at once | `2a5a81c0`: `lg:shrink-[999]` on the brand link; `header.spec` sweeps 1024–1050 px under touch with a 27-character title (scrolls without the fix) |
+| L2: a title could show in the fallback face and vanish when Archivo loaded | `2a5a81c0`: the wordmark's width is reserved from Archivo's metrics at its two instances (`common/lib/wordmarkMetrics.ts`, generated by `common/bin/gen-wordmark-metrics.py` from the vendored font; `lib/wordmarkWidth.ts`, +3 %: the served font renders 0.2–1.7 % wider than the file's advances). `header.spec` blocks the font file and compares the title's visibility at 360, 375, 390, 412 and 430 px: the same before and after Archivo loads (the short title differs without the reservation) |
+| L3: the provider and the script disagree when storage reads but refuses writes | left: reachable only with a stored retired value that can no longer be rewritten |
+| L4: `common/components/ui/` was not untouched | nothing to change: `alert.tsx`, `badge.tsx` and `sonner.tsx` have comment-only edits naming the bases; the palette, `dialog.tsx` and `command.tsx` are untouched |
+| L5: stale text | `689a835f`: `export/globals.css`, `export/manifest.ts`, `brand.test.ts`; the export changelog's T1 bullet no longer names a Base list; the homepage changelog's e2e bullet |
+| L6: the footer's social row did not wrap | `2a5a81c0`: `flex-wrap` on the footer placement; eight links under touch at 320 px, export and homepage specs |
+| L7: the guard's blind spots (`fs.promises.*`, `accessSync`/`open`/`readlink`, a relative literal to fs, a cwd default parameter, a class method as the source; false positives on a string naming `process.cwd()`) | left: none of these shapes is in the covered code today |
+| L8: the rollout order was implied | this commit: "## Rollout" below |
+| L9: 200 % text | `2a5a81c0`: the header itself never overflows at 200 % text (tested at 320 and 360 px, both pointers). The 43 px the review measured at 320 px are the search page's own controls (a label and the view toggle), not the header. At 320 px under touch the mark, the 88 px toggle and the 72 px menu button leave the social row's box no room: the phone header is the operator's to rule on |
+| L10: named-accent coverage | `689a835f`: `theme-accent.spec` rewrites the fixture to a named accent (violet): its value on Light and on Dark, a stored pick not applied and left in place |
+
+**The growth chart's gaps after M1**, on the published summary:
+
+| Plot height | Gap segments | Runs | Covered band-segments |
+|---|---|---|---|
+| 200 px | 25 | 5 | 0 |
+| 260 px | 64 | 11 | 0 |
+| 300 px | 96 | 14 | 0 |
+
+**The gaps now read as dashes.** On a phone the chart has two short dashes, both along
+Jeralyzer's top. At 1280 px they are stretches along Jeralyzer's and Anilyzer's tops, with
+short pieces elsewhere. The rest of the bands touch. This is the underlying issue the operator
+has been told about: four sliver bands beside two large ones. Folding the small sites into
+"Other", or small multiples, is the operator's call and is not made here.
+Screenshots: `~/reports/release-14/shots/chart2/growth-{390,1280}-{light,dark}{,-2019-2022}.png`.
+
+**Screenshots:**
+- `chart2/site-stacked-{area,bar}-{390,1280}-{light,dark}.png`: a site's charts. The fixture has
+ one video a month, so its stacked bars have no touching segments.
+- `chart2/stats-stacked-{area,bar}-{390,1280}-{light,dark}.png`: `/stats` on the published
+ summary. The area has its coloured edges back, and the bars show the 2 px gaps between
+ segments.
+
+**The nine decisions, as ruled:**
+1. The nav and the Archilyzer link are inline from `lg` → keep, with L1.
+2. The Archilyzer link is in the slide-out menu below `lg` → keep.
+3. The link is small and muted, with the text "Archilyzer" → keep.
+4. The pure-CSS wrap → keep, with L1 and L2.
+5. The export footer takes the shared 36 px keys → keep, with L6.
+6. The growth-chart gap is skipped under 3 px, and runs under three months are dropped → keep
+ the rule, measured at right angles (M1).
+7. The surface gap over translucent stacked areas → do not keep (H1).
+8. `RETIRED_BASE = "sepia"` stays → keep.
+9. The footer's dot only after a downloads link → keep.
+
+| sha | what |
+|---|---|
+| `92dc4084` | H1 |
+| `f455d7da` | M1 |
+| `706f62bf` | M2 |
+| `2a5a81c0` | L1, L2, L6, L9 |
+| `689a835f` | L5, L10 |
+| `447ded9a` | the narrow-header ruling (below) |
+| _this_ | `plans:` the Review, the ruling's record, the Rollout, the plan, STATE |
+
+**Gates**, run once after the review fixes and the ruling (at `447ded9a`; logs `$T/t-g3-*.log`):
+
+- **tsc** was clean before every commit, and at the tip (37 s).
+- **Unit:**
+
+ | Suite | Result |
+ |---|---|
+ | common | 2,210/2,210 |
+ | editor unit | 87/87 |
+ | homepage unit | 12/12 |
+ | `test:scripts` | 191 passed, 1 skipped |
+
+- **Docs:** the three `--check` commands exit **0**.
+- **Builds**, each capped at 5 GB with no swap, one at a time:
+
+ | Build | Time | Max RSS |
+ |---|---|---|
+ | homepage | 16 s | 800 MB |
+ | export site | 25 s | 995 MB |
+ | export hub | 23 s | 1,021 MB |
+ | editor | 43 s | 1,650 MB |
+
+- **e2e**, each detached and queued:
+
+ | Suite | Passed | Failed | Time |
+ |---|---|---|---|
+ | homepage, full | 90 | 0 | 2.1 min |
+ | export, full | 235 | 0 | 10.9 min |
+ | hub, full | 35 | 0 | 1.3 min |
+ | editor (`settings`, `sites-crud`, `theme`) | 32 | 0 | 1.3 min |
+
+- **An earlier gate run** at `689a835f` (the review fixes alone) was stopped when the ruling
+ arrived, to run the gates once for both. Before it stopped, it had passed: tsc; common
+ 2,208/2,208; editor unit 87/87; homepage unit 12/12; `test:scripts` 191 + 1 skipped; the docs
+ checks; all four builds; and the homepage e2e, 83/83.
+
+#### The narrow header, as ruled (2026-09-28)
+
+**The ruling** (operator): 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.
+
+**`featured`, new meaning** (`common/lib/socialLinks.ts` `headerSocialLinks(links, width)`):
+- **Wide:** every link, up to four. With more configured, the `featured` ones are kept first, then
+ the last of the rest, shown in configured order.
+- **Narrow:** only the `featured` ones, up to four (the last four when more are marked). With none
+ marked, the narrow header shows no social link: the name wins, and the footer has them all.
+- **The footer** always shows every link.
+- The key is still `featured`, stored only when true. The editor's checkbox is **Keep in header
+ on small screens**, with the hint "On small screens the header shows only these; the rest stay
+ in the footer." `SETTINGS.md` and `SITE.md` are regenerated.
+
+**Layout** (the homepage, every site and the hub, one behaviour):
+- Both rows are rendered. CSS shows the narrow one below **32.5rem** and the wide one from
+ 32.5rem: 520 px at the default text size, 1040 px at a 32 px browser text size (`11a33f7a`, N5).
+ The other is `display: none`, so exactly one is focusable and in the accessibility tree.
+- Each copy scopes its icons' ids.
+- The narrow bar is mark + name · the marked link(s) · toggle, then the menu button on the export.
+- The wordmark's text drop (the export's reserved-width wrap, the homepage's container
+ thresholds, now keyed by the narrow row's count) and the scroll box stay as last resorts.
+
+**Why 520 px:** every link (up to four, 44 px touch keys), the toggle, the menu button and the
+longest real title (Rekietalyzer, Hasanalyzer) fit the export's bar from about 500 px wide under
+touch. At 519 px the narrow row shows and at 520 px the wide one, and the name shows at both
+(measured on Rekietalyzer and Hasanalyzer, both pointers, 519–1280 px). The homepage's bar needs
+about 424 px.
+
+**The gap ruling (2026-09-29):** below the switch the export bar's two gaps (name → row, toggle →
+menu) are **8 px**, not 12 (`1a2e3342`, N1). Type, the mark–name gap and the reserved width's
+3 % margin are unchanged. It is one header, so every site and the hub. The homepage's bar has one
+gap and keeps 12 px.
+
+**The smallest viewport width at which the full name shows** (a dev server at `11a33f7a`, four
+links configured, re-measured after the gap ruling; before it, each site's and the hub's widths
+were 8 px more):
+
+| Title | One marked link: mouse / touch | None marked: mouse / touch |
+|---|---|---|
+| Jeralyzer | 302 / 318 | 266 / 274 |
+| Anilyzer | 288 / 304 | 252 / 260 |
+| Bonnellyzer | 336 / 352 | 300 / 308 |
+| Hasanalyzer | 343 / 359 | 307 / 315 |
+| Rekietalyzer | 344 / **360** | 308 / 316 |
+| Jasolyzer | 308 / 324 | 272 / 280 |
+| Archilyzer (homepage) | 276 / 292 | ≤ 240 / 248 |
+| Archilyzer (hub) | 313 / 329 | 277 / 285 |
+
+**The target** (every title in full at 360 px, one marked link, touch) is **met by every title**.
+Rekietalyzer meets it at exactly 360 px.
+
+**The wordmark's reservation is a minimum width** (`4ea1c495`, N2). The header sets
+`wordmarkWidthEm` as `min-width`. Where the text renders wider than the reservation, its box grows
+with it and the text drops sooner; its last letter is never clipped. The re-review measured 0.6 to
+0.8 % spare in Chromium. The table above is unchanged by it.
+
+**`featured` keeps its configured place** (N4, ruled as built). With more than four links, a
+marked link is guaranteed a place in the wide row, and the row keeps configured order: `[A★, B,
+C, D★, E, F]` shows `A D E F`.
+
+**Tests:**
+- `socialLinks.test.ts` covers both widths: none marked, one, several, more than four with and
+ without marks, and order kept.
+- The homepage (`social.spec`), a site (`header.spec`: a short title, Bonnellyzer, Hasanalyzer and
+ Rekietalyzer) and the hub (`official-instances.spec`) are checked at 360 and 390 px, both
+ pointers:
+ - the full name shows;
+ - only the marked link is in the header, in the accessibility tree too;
+ - with none marked, the header has no row and the name shows;
+ - from 520 px every link shows;
+ - the footer has every link at every width;
+ - nothing scrolls the page sideways from 280 to 1400 px;
+ - focus goes brand → marked link → toggle, then menu on a site.
+- `header.spec` checks **all six real titles at 360 px under touch with one marked link**: the
+ full name, the link, no scrolling row, no overflow. Hasanalyzer and Rekietalyzer fail it with
+ 12 px gaps.
+- `header.spec` forces the wordmark 8 % wider (letter-spacing) and sweeps 519 to 280 px:
+ wherever the name shows, it does not overflow its box or the clip box. With a fixed width it
+ is clipped from 519 to 344 px.
+- **At 200 % text**, both pointers:
+ - **The browser's text size** (32 px, set through CDP `Page.setFontSizes`; Chromium):
+ - the narrow row shows until 1040 px, and the wide one from there;
+ - the row never scrolls while the name shows, and the header never overflows;
+ - the name shows with one marked link from 720 px on the homepage and from 767 px on
+ Rekietalyzer.
+ - **The page's root at 200 %** (which moves the page's rem but not a media query's):
+ - on the homepage from 520 to 767 px, the wide row never scrolls while the name shows;
+ - on a site the switch stays at 520 px and the row never scrolls while the name shows.
+- The e2e fixtures mark their last link.
+
+**Screenshots:** `~/reports/release-14/shots/h2/`, four synthetic icons, the last marked:
+- `{jeralyzer,anilyzer,bonnellyzer,hasanalyzer,rekietalyzer,jasolyzer,homepage}-{320,360,390}-{light,dark}.png`
+ (a mouse) and `…-{320,360,390}-touch-{light,dark}.png` (touch), re-taken at `11a33f7a`;
+- `…-{768,1280}-{light,dark}.png` from `447ded9a` (the header is unchanged at those widths).
+
+At 360 px every title shows in full under touch. At 320 px Bonnellyzer, Hasanalyzer and
+Rekietalyzer drop their text under both pointers, and Jasolyzer does under touch.
+
+#### Re-review (verdict SHIP; `$T/h-review2.md`)
+
+| Finding | Fix |
+|---|---|
+| N1: at 360 px under touch, with one marked link, Hasanalyzer and Rekietalyzer lost the name | `1a2e3342`: the gap ruling (above). All six titles, the homepage and the hub re-measured; the e2e checks all six |
+| N2: the reserved wordmark width had 0.6–0.8 % spare in Chromium | `4ea1c495`: the reservation is a `min-width` (above) |
+| N3: the Rollout had no step for marking the link, and a live check expected every icon at 390 px | this commit: a precondition before any build, and the 390 px check reads "the marked link" (## Rollout) |
+| N4: "kept first" is priority, not position | ruled as built: a marked link keeps its configured place (above) |
+| N5: on the homepage at 150–200 % text, from 520 to 767 px, the wide row scrolled while the name showed | `11a33f7a`: the switch is `32.5rem` in every header; the homepage's wordmark fit is keyed by the narrow row's count below the switch and by the wide row's from it |
+
+| sha | what |
+|---|---|
+| `1a2e3342` | `export:` the bar's two gaps are 8 px below the switch; `header.spec` all six real titles; the hub's name test under both pointers (N1) |
+| `4ea1c495` | `export, common:` the wordmark's reservation is a `min-width`; the wider-text e2e (N2) |
+| `11a33f7a` | `homepage, export:` the switch in rem; the homepage's fit by the wide row's count from the switch; the 200 % text e2e (N5) |
+| _this_ | `plans:` the ruling's record re-measured, the re-review, the Rollout's marked-link precondition (N3), STATE, the plan; the changelogs |
+
+**Gates** at `11a33f7a` (logs `$T/t-g4*.log`):
+- **tsc** was clean before every commit, and at the tip (32 s).
+- **Unit:** common 2,210/2,210; homepage 12/12.
+- **e2e**, the header specs only, detached and queued:
+
+ | Suite | Specs | Passed | Failed |
+ |---|---|---|---|
+ | homepage | `social`, `toggle`, `svg-vectors`, `brand` | 50 | 0 |
+ | export | `header`, `responsive`, `site-branding` | 50 | 0 |
+ | hub | `official-instances` | 8 | 0 |
+
+- **Builds**, capped at 5 GB with no swap, one at a time: the homepage (13 s, 824 MB) and the
+ export site (21 s, 1,029 MB). Both builds' CSS carry the `32.5rem` switch, and the homepage's
+ carries both fit sets.
+
+**Found and left:** with the page's root at 150–200 % (not a browser setting), from 768 px the
+homepage's nav joins the bar at `md`, whose media query does not follow a page-set root size.
+There the bar overflows and the wide row scrolls while the name shows: measured from 779 to
+1100 px at 150 % and from 779 to 1300 px at 200 %. With the browser's own text size `md` moves too
+and nothing overflows. The `md` layout is slice HP's and unchanged here.
+
## Rollout
+
+Release 14 is slice HP (merged, `bfa1ff3c`) and `r14/two-grounds-headers` (after the parent's
+merge). Every command below is typed **from the primary checkout's root**. There is no
+`archilyzer` on PATH, so it is `pnpm archilyzer …`. The command forms are the ones verified in
+`plans/stats-cache-key.md`'s rollout.
+
+**Preconditions.**
+1. `main` carries `r14/two-grounds-headers`.
+2. **The homepage build runs release 12's source publish.** So the homepage waits on release 12's
+ rollout step 0: the denylist is complete and `pnpm archilyzer source publish --check` is clean.
+3. **No other index, stats or site build is running.** `/jobs` shows none running or queued.
+4. **If the stats-cache-key rollout has not run yet, run it first.** Its steps 5–7 build the
+ homepage, the hub and the sites in the same order as below, and then cover this release's too.
+5. **The link to keep in the header on small screens is marked, before any build.** The parent
+ marks it in the editor's Settings (**Keep in header on small screens**) and saves. The builds
+ are static: a link marked after them shows only after another build. With none marked, no
+ header shows a social link below 520 px.
+
+**The order, and why.** The homepage goes first, because every site's header links to
+`https://archilyzer.pages.dev/#instances` and that anchor exists only in the new homepage. The
+hub goes before the sites, because in basic mode they share `export/out`. The six sites go last.
+
+1. **The editor**, for its own header (no theme menu; the toggle cycles System, Light and Dark):
+ `pnpm --filter editor build`, then restart :3001 the way it is normally run.
+2. **The homepage.** Use exactly ONE of:
+ - `pnpm archilyzer build homepage && pnpm archilyzer deploy homepage`;
+ - `pnpm ops build-homepage --json '{"deploy":true}' --wait`.
+3. **The hub, before the sites.** Build it, check its log, and only then deploy it. Use exactly
+ ONE pair:
+ - `pnpm archilyzer build hub`, then `pnpm archilyzer deploy hub`;
+ - `pnpm ops build-hub --wait`, then `pnpm ops deploy-hub --wait`.
+
+ **Between the two,** the build's `compose-hub: …` line must end with `hub-summary.json covers
+ N official instance(s)`, where N is the number of public sites. If it says `hub-summary.json
+ skipped: …`, stop and fix what it names.
+4. **The six sites:** `pnpm ops build-deploy --json '{"all":true}' --wait`.
+
+**Live checks.**
+- `https://archilyzer.pages.dev/#instances` lands on Official Instances, below the header.
+- One site at 390 px wide:
+ - the header shows the mark and the site's name, then the marked link and the theme toggle as
+ one group, then the menu button;
+ - the menu holds the nav and "Archilyzer".
+- The same site at 520 px wide and more: every social link, up to four.
+- At 1280 px wide, the header's **Archilyzer** link goes to the homepage's Official Instances, in
+ the same tab. There is no Sites menu and no Hub link.
+- **Changelog** is in the footer, after Use with AI, and not in the header.
+- **A stored Sepia renders Light.** In a site's console, run
+ `localStorage.setItem("ytdlp-tb:base","sepia")` and reload. `<html data-base>` reads `light`,
+ and `localStorage.getItem("ytdlp-tb:base")` now reads `"light"`.
+- The homepage's growth chart has no slash in the page colour through any band. `/stats` in
+ Area mode has coloured top lines.
diff --git a/scripts/next-build-trace.test.mjs b/scripts/next-build-trace.test.mjs
@@ -0,0 +1,310 @@
+// No module a Next app bundles may hand Turbopack a directory to trace: umtool,
+// the editor, the export and the homepage, and the common/ modules they import.
+//
+// Turbopack evaluates `process.cwd()` (and a module's own `import.meta.url` /
+// `__dirname`) statically, as a path in the project, and a `path.join` /
+// `path.resolve` / fs call on such a value becomes an ASSET REFERENCE: to a
+// file, or, when the joined path is a directory, to EVERY file under it. Release
+// 12 slice Q wrote `path.join(REPO_ROOT, "transcripts", "channels")` with
+// `REPO_ROOT = findRepoRoot(process.cwd())`, and `next build` in the primary
+// checkout walked the whole corpus (hundreds of GB, `data/` symlinked to another
+// drive) until the kernel killed it -- while a worktree, which has no
+// `transcripts/`, built in 30 s. So no build-in-a-worktree gate can see this.
+//
+// The rule, checked statically and per module (Turbopack's value analysis is
+// per module; an imported binding is opaque to it): every path or fs call whose
+// arguments carry a value derived IN THAT FILE from `process.cwd()`,
+// `import.meta.url`, `import.meta.dirname|filename` or `__dirname` must open its
+// argument list with the documented opt-out, `/* turbopackIgnore: true */`.
+// The comment changes nothing at run time. A function declared in the file whose
+// body carries a source is a source too (`const ROOT = findMonorepoRoot()`).
+// `os.homedir()` is NOT a source: the
+// tracer does not follow it (a build with HOME pointed at a synthetic home full
+// of out-of-root symlinks inside the project succeeds), and neither is
+// `process.env.*`.
+//
+// Run with: pnpm test:scripts
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const UMTOOL = path.join(REPO, "umtool");
+
+const SOURCE = /process\.cwd\(\)|import\.meta\.(?:url|dirname|filename)|\b__dirname\b/;
+const MARK = "__TURBOPACK_IGNORE__";
+const IGNORE_COMMENT = /\/\*\s*turbopackIgnore\s*:\s*true\s*\*\//g;
+
+// path ops, and fs calls either bare (`existsSync(`) or on a namespace
+// (`fs.readdir(`, `fsp.stat(`). A method on anything else (`obj.stat(`) is not
+// one.
+const SINK =
+ /(?:\bpath\.(?:join|resolve|dirname|relative)|(?<![\w$.])(?:fs\.|fsp\.|promises\.)?(?:existsSync|readFileSync|readdirSync|statSync|lstatSync|realpathSync|opendirSync|readFile|readdir|stat|lstat|opendir|createReadStream))\s*\(/g;
+
+/** Comments out, except the opt-out, which becomes a marker. Strings stay. */
+function prepare(text) {
+ let s = text.replace(IGNORE_COMMENT, ` ${MARK} `);
+ s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
+ // A line comment: `//` not preceded by `:` (URLs, `file://` templates).
+ s = s.replace(/(^|[^:\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
+ return s;
+}
+
+/** Index just past the regex literal whose opening `/` is at `i`, or -1 when
+ * the `/` there is a division. A `/` opens a regex after an operator, an
+ * opening bracket, a separator or `return` — good enough for this repo. */
+function regexEnd(s, i) {
+ let j = i - 1;
+ while (j >= 0 && /\s/.test(s[j])) j -= 1;
+ const before = j < 0 ? "" : s[j];
+ const word = s.slice(0, j + 1).match(/[A-Za-z_$][\w$]*$/)?.[0];
+ if (!(before === "" || "(,=:[!&|?{};+-*%<>~^".includes(before) || word === "return" || word === "typeof")) return -1;
+ if (s[i + 1] === "/" || s[i + 1] === "*") return -1;
+ let inClass = false;
+ for (let k = i + 1; k < s.length; k += 1) {
+ const c = s[k];
+ if (c === "\n") return -1;
+ if (c === "\\") k += 1;
+ else if (c === "[") inClass = true;
+ else if (c === "]") inClass = false;
+ else if (c === "/" && !inClass) return k + 1;
+ }
+ return -1;
+}
+
+/** Index just past the bracket that closes the one at `open`. Strings and
+ * regex literals are skipped whole. */
+function closeOf(s, open) {
+ let depth = 0;
+ let quote = null;
+ for (let i = open; i < s.length; i += 1) {
+ const c = s[i];
+ if (quote) {
+ if (c === "\\") i += 1;
+ else if (c === quote) quote = null;
+ continue;
+ }
+ if (c === "/") {
+ const end = regexEnd(s, i);
+ if (end !== -1) {
+ i = end - 1;
+ continue;
+ }
+ }
+ if (c === '"' || c === "'" || c === "`") quote = c;
+ else if (c === "(" || c === "[" || c === "{") depth += 1;
+ else if (c === ")" || c === "]" || c === "}") {
+ depth -= 1;
+ if (depth === 0) return i + 1;
+ }
+ }
+ return s.length;
+}
+
+/** Names assigned, in this file, from a source or from another such name. */
+function taintedNames(s) {
+ const decls = [];
+ const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
+ for (let m; (m = re.exec(s)); ) {
+ // The right-hand side runs to the first `;` or newline at depth 0 -- good
+ // enough for this repo's formatter, which ends statements with `;`.
+ let depth = 0;
+ let end = s.length;
+ for (let i = re.lastIndex; i < s.length; i += 1) {
+ const c = s[i];
+ if (c === "(" || c === "[" || c === "{") depth += 1;
+ else if (c === ")" || c === "]" || c === "}") depth -= 1;
+ else if (c === ";" && depth <= 0) {
+ end = i;
+ break;
+ }
+ }
+ decls.push({ name: m[1], rhs: s.slice(re.lastIndex, end) });
+ }
+ // A function declared in this file whose body carries a source: a call to it
+ // is a source too (`const ROOT = findMonorepoRoot()`, where the function
+ // walks up from `process.cwd()` and falls back to it).
+ const fnRe = /\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g;
+ for (let m; (m = fnRe.exec(s)); ) {
+ const params = fnRe.lastIndex - 1;
+ const bodyOpen = s.indexOf("{", closeOf(s, params));
+ if (bodyOpen === -1) continue;
+ decls.push({ name: m[1], rhs: s.slice(params, closeOf(s, bodyOpen)) });
+ }
+ const names = new Set();
+ for (let grew = true; grew; ) {
+ grew = false;
+ for (const { name, rhs } of decls) {
+ if (names.has(name)) continue;
+ if (SOURCE.test(rhs) || [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b`).test(rhs))) {
+ names.add(name);
+ grew = true;
+ }
+ }
+ }
+ return names;
+}
+
+/** Every path/fs call on a cwd-derived value that does not opt out. */
+export function untracedCalls(text) {
+ const s = prepare(text);
+ const names = taintedNames(s);
+ const carries = (args) =>
+ SOURCE.test(args) ||
+ [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b(?!\\s*:)`).test(args));
+ // A call nested in these arguments that opts out is opaque to this one too:
+ // `readFileSync(path.join(/* turbopackIgnore: true */ HERE, "a.json"))`. Its
+ // own arguments are cut out before asking whether this call carries a source.
+ const withoutOptedOut = (args) => {
+ let out = args;
+ for (let i = out.search(new RegExp(`\\(\\s*${MARK}`)); i !== -1; i = out.search(new RegExp(`\\(\\s*${MARK}`))) {
+ out = out.slice(0, i) + out.slice(closeOf(out, i));
+ }
+ return out;
+ };
+ const out = [];
+ for (let m; (m = SINK.exec(s)); ) {
+ const open = SINK.lastIndex - 1;
+ const args = s.slice(open + 1, closeOf(s, open) - 1);
+ if (args.trimStart().startsWith(MARK)) continue;
+ if (!carries(withoutOptedOut(args))) continue;
+ const line = s.slice(0, m.index).split("\n").length;
+ out.push({ line, call: text.split("\n")[line - 1].trim() });
+ }
+ return out;
+}
+
+/** Every module under `dir`, tests and declarations aside. */
+function modulesUnder(dir, out = []) {
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) modulesUnder(p, out);
+ else if (/\.(?:mjs|cjs|js|ts|tsx)$/.test(e.name) && !/\.test\./.test(e.name) && !e.name.endsWith(".d.ts")) out.push(p);
+ }
+ return out;
+}
+
+/** umtool's modules that a Next build can reach: the app, its libs, the pipeline. */
+function umtoolModules() {
+ const out = [];
+ for (const d of ["app", "components", "lib"]) modulesUnder(path.join(UMTOOL, d), out);
+ for (const e of readdirSync(path.join(UMTOOL, "report-to-video"))) {
+ if (e.endsWith(".mjs") && !e.includes(".test.")) out.push(path.join(UMTOOL, "report-to-video", e));
+ }
+ // song/paths.mjs is imported by lib/paths.mjs; the other song scripts are CLIs.
+ out.push(path.join(UMTOOL, "song", "paths.mjs"));
+ return out;
+}
+
+/**
+ * The editor's, the export's and the homepage's modules a Next build can
+ * reach: each app's `app/` (the editor's `lib/` and `instrumentation.ts` too),
+ * and every module of common/ but its CLIs (`bin/`, which no app imports). The
+ * common set is wider than what the apps import today, on purpose: a module
+ * that starts being imported is already covered.
+ */
+function nextAppModules() {
+ const out = [];
+ for (const d of ["homepage/app", "export/app", "editor/app", "editor/lib"]) modulesUnder(path.join(REPO, d), out);
+ out.push(path.join(REPO, "editor", "instrumentation.ts"));
+ for (const e of readdirSync(path.join(REPO, "common"), { withFileTypes: true })) {
+ if (!e.isDirectory() || e.name === "bin" || e.name === "node_modules" || e.name.startsWith(".")) continue;
+ modulesUnder(path.join(REPO, "common", e.name), out);
+ }
+ return out;
+}
+
+function untracedIn(files) {
+ const bad = [];
+ for (const file of files) {
+ for (const f of untracedCalls(readFileSync(file, "utf8"))) {
+ bad.push(`${path.relative(REPO, file)}:${f.line} ${f.call}`);
+ }
+ }
+ return bad;
+}
+
+test("the check flags slice Q's join and passes the opted-out form", () => {
+ const bad = [
+ 'const REPO_ROOT = findRepoRoot(process.cwd());',
+ 'export const CHANNELS_DIR = path.resolve(',
+ ' process.env.CHANNELS_DIR ?? path.join(REPO_ROOT, "transcripts", "channels"),',
+ ');',
+ ].join("\n");
+ const found = untracedCalls(bad);
+ assert.ok(found.some((f) => f.call.includes('path.join(REPO_ROOT, "transcripts"')), JSON.stringify(found));
+
+ const good = bad
+ .replace("path.resolve(", "path.resolve(/* turbopackIgnore: true */")
+ .replace("path.join(REPO_ROOT", "path.join(/* turbopackIgnore: true */ REPO_ROOT");
+ assert.deepEqual(untracedCalls(good), []);
+});
+
+test("sources: cwd, import.meta, __dirname; not homedir, env, or a comment", () => {
+ assert.equal(untracedCalls('const X = path.join(process.cwd(), "song");').length, 1);
+ assert.equal(untracedCalls("const H = path.dirname(fileURLToPath(import.meta.url));").length, 1);
+ assert.equal(untracedCalls('readFileSync(path.join(__dirname, "a.json"));').length, 2);
+ assert.equal(untracedCalls('const R = path.join(os.homedir(), "reports");').length, 0);
+ assert.equal(untracedCalls('const R = path.join(process.env.X, "channels");').length, 0);
+ assert.equal(untracedCalls('// path.join(process.cwd(), "x")\nconst y = 1;').length, 0);
+ // An object key named like a tainted value is not a use of it.
+ assert.equal(untracedCalls('const cwd = path.join(/* turbopackIgnore: true */ process.cwd(), "s");\nf(path.join(a, { cwd: 1 }));').length, 0);
+});
+
+test("the check flags the homepage's source.ts and paths.ts' walk as main had them", () => {
+ // homepage/app/lib/source.ts before the opt-out: a DIRECTORY join on the cwd.
+ const source = [
+ "export function loadSourceManifest(",
+ ' pub: string = path.join(process.cwd(), "public"),',
+ "): SourceManifest | null {",
+ ' return fs.readFileSync(path.join(pub, "source", "manifest.json"), "utf8");',
+ "}",
+ ].join("\n");
+ assert.ok(untracedCalls(source).some((f) => f.call.includes('path.join(process.cwd(), "public")')));
+ // common/lib/paths.ts before: the walk, and a join on the root it returns.
+ const walk = [
+ "function findMonorepoRoot(): string {",
+ " let dir = process.cwd();",
+ ' if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;',
+ " return process.cwd();",
+ "}",
+ "const monorepoRoot = findMonorepoRoot();",
+ 'const transcriptsDir = process.env.TRANSCRIPTS_DIR ?? path.join(monorepoRoot, "transcripts");',
+ ].join("\n");
+ const found = untracedCalls(walk).map((f) => f.call);
+ assert.ok(found.some((c) => c.includes('path.join(dir, "pnpm-workspace.yaml")')), JSON.stringify(found));
+ assert.ok(found.some((c) => c.includes('path.join(monorepoRoot, "transcripts")')), JSON.stringify(found));
+});
+
+test("brackets inside a regex literal or a string do not end a call early", () => {
+ // A regex holding a quote used to swallow the rest of the file as one body.
+ const text = [
+ "function esc(s) { return s.replace(/['\"&]/g, \"\"); }",
+ "const out = path.join(dir, esc(x));",
+ "if (import.meta.url === `file://${process.argv[1]}`) main();",
+ ].join("\n");
+ assert.deepEqual(untracedCalls(text), []);
+});
+
+test("no umtool module the app can import joins a cwd-derived path without opting out", () => {
+ const bad = untracedIn(umtoolModules());
+ assert.deepEqual(
+ bad,
+ [],
+ "add /* turbopackIgnore: true */ as the first argument (see this file's header):\n" + bad.join("\n"),
+ );
+});
+
+test("no module the editor, the export or the homepage can bundle joins a cwd-derived path without opting out", () => {
+ const files = nextAppModules();
+ assert.ok(files.length > 500, `only ${files.length} modules found`);
+ const bad = untracedIn(files);
+ assert.deepEqual(
+ bad,
+ [],
+ "add /* turbopackIgnore: true */ as the first argument (see this file's header):\n" + bad.join("\n"),
+ );
+});
diff --git a/scripts/umtool-build-trace.test.mjs b/scripts/umtool-build-trace.test.mjs
@@ -1,195 +0,0 @@
-// umtool's modules must not hand Turbopack a directory to bundle.
-//
-// Turbopack evaluates `process.cwd()` (and a module's own `import.meta.url` /
-// `__dirname`) statically, as a path in the project, and a `path.join` /
-// `path.resolve` / fs call on such a value becomes an ASSET REFERENCE: to a
-// file, or, when the joined path is a directory, to EVERY file under it. Release
-// 12 slice Q wrote `path.join(REPO_ROOT, "transcripts", "channels")` with
-// `REPO_ROOT = findRepoRoot(process.cwd())`, and `next build` in the primary
-// checkout walked the whole corpus (hundreds of GB, `data/` symlinked to another
-// drive) until the kernel killed it -- while a worktree, which has no
-// `transcripts/`, built in 30 s. So no build-in-a-worktree gate can see this.
-//
-// The rule, checked statically and per module (Turbopack's value analysis is
-// per module; an imported binding is opaque to it): every path or fs call whose
-// arguments carry a value derived IN THAT FILE from `process.cwd()`,
-// `import.meta.url`, `import.meta.dirname|filename` or `__dirname` must open its
-// argument list with the documented opt-out, `/* turbopackIgnore: true */`.
-// The comment changes nothing at run time. `os.homedir()` is NOT a source: the
-// tracer does not follow it (a build with HOME pointed at a synthetic home full
-// of out-of-root symlinks inside the project succeeds), and neither is
-// `process.env.*`.
-//
-// Run with: pnpm test:scripts
-import assert from "node:assert/strict";
-import { readdirSync, readFileSync } from "node:fs";
-import path from "node:path";
-import test from "node:test";
-import { fileURLToPath } from "node:url";
-
-const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
-const UMTOOL = path.join(REPO, "umtool");
-
-const SOURCE = /process\.cwd\(\)|import\.meta\.(?:url|dirname|filename)|\b__dirname\b/;
-const MARK = "__TURBOPACK_IGNORE__";
-const IGNORE_COMMENT = /\/\*\s*turbopackIgnore\s*:\s*true\s*\*\//g;
-
-// path ops, and fs calls either bare (`existsSync(`) or on a namespace
-// (`fs.readdir(`, `fsp.stat(`). A method on anything else (`obj.stat(`) is not
-// one.
-const SINK =
- /(?:\bpath\.(?:join|resolve|dirname|relative)|(?<![\w$.])(?:fs\.|fsp\.|promises\.)?(?:existsSync|readFileSync|readdirSync|statSync|lstatSync|realpathSync|opendirSync|readFile|readdir|stat|lstat|opendir|createReadStream))\s*\(/g;
-
-/** Comments out, except the opt-out, which becomes a marker. Strings stay. */
-function prepare(text) {
- let s = text.replace(IGNORE_COMMENT, ` ${MARK} `);
- s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
- // A line comment: `//` not preceded by `:` (URLs, `file://` templates).
- s = s.replace(/(^|[^:\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
- return s;
-}
-
-/** Index just past the bracket that closes the one at `open`. */
-function closeOf(s, open) {
- let depth = 0;
- let quote = null;
- for (let i = open; i < s.length; i += 1) {
- const c = s[i];
- if (quote) {
- if (c === "\\") i += 1;
- else if (c === quote) quote = null;
- continue;
- }
- if (c === '"' || c === "'" || c === "`") quote = c;
- else if (c === "(" || c === "[" || c === "{") depth += 1;
- else if (c === ")" || c === "]" || c === "}") {
- depth -= 1;
- if (depth === 0) return i + 1;
- }
- }
- return s.length;
-}
-
-/** Names assigned, in this file, from a source or from another such name. */
-function taintedNames(s) {
- const decls = [];
- const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g;
- for (let m; (m = re.exec(s)); ) {
- // The right-hand side runs to the first `;` or newline at depth 0 -- good
- // enough for this repo's formatter, which ends statements with `;`.
- let depth = 0;
- let end = s.length;
- for (let i = re.lastIndex; i < s.length; i += 1) {
- const c = s[i];
- if (c === "(" || c === "[" || c === "{") depth += 1;
- else if (c === ")" || c === "]" || c === "}") depth -= 1;
- else if (c === ";" && depth <= 0) {
- end = i;
- break;
- }
- }
- decls.push({ name: m[1], rhs: s.slice(re.lastIndex, end) });
- }
- const names = new Set();
- for (let grew = true; grew; ) {
- grew = false;
- for (const { name, rhs } of decls) {
- if (names.has(name)) continue;
- if (SOURCE.test(rhs) || [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b`).test(rhs))) {
- names.add(name);
- grew = true;
- }
- }
- }
- return names;
-}
-
-/** Every path/fs call on a cwd-derived value that does not opt out. */
-export function untracedCalls(text) {
- const s = prepare(text);
- const names = taintedNames(s);
- const carries = (args) =>
- SOURCE.test(args) ||
- [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b(?!\\s*:)`).test(args));
- // A call nested in these arguments that opts out is opaque to this one too:
- // `readFileSync(path.join(/* turbopackIgnore: true */ HERE, "a.json"))`. Its
- // own arguments are cut out before asking whether this call carries a source.
- const withoutOptedOut = (args) => {
- let out = args;
- for (let i = out.search(new RegExp(`\\(\\s*${MARK}`)); i !== -1; i = out.search(new RegExp(`\\(\\s*${MARK}`))) {
- out = out.slice(0, i) + out.slice(closeOf(out, i));
- }
- return out;
- };
- const out = [];
- for (let m; (m = SINK.exec(s)); ) {
- const open = SINK.lastIndex - 1;
- const args = s.slice(open + 1, closeOf(s, open) - 1);
- if (args.trimStart().startsWith(MARK)) continue;
- if (!carries(withoutOptedOut(args))) continue;
- const line = s.slice(0, m.index).split("\n").length;
- out.push({ line, call: text.split("\n")[line - 1].trim() });
- }
- return out;
-}
-
-/** umtool's modules that a Next build can reach: the app, its libs, the pipeline. */
-function appModules() {
- const out = [];
- const walk = (dir) => {
- for (const e of readdirSync(dir, { withFileTypes: true })) {
- if (e.name === "node_modules" || e.name.startsWith(".")) continue;
- const p = path.join(dir, e.name);
- if (e.isDirectory()) walk(p);
- else if (/\.(?:mjs|js|ts|tsx)$/.test(e.name) && !/\.test\./.test(e.name) && !e.name.endsWith(".d.ts")) out.push(p);
- }
- };
- for (const d of ["app", "components", "lib"]) walk(path.join(UMTOOL, d));
- for (const e of readdirSync(path.join(UMTOOL, "report-to-video"))) {
- if (e.endsWith(".mjs") && !e.includes(".test.")) out.push(path.join(UMTOOL, "report-to-video", e));
- }
- // song/paths.mjs is imported by lib/paths.mjs; the other song scripts are CLIs.
- out.push(path.join(UMTOOL, "song", "paths.mjs"));
- return out;
-}
-
-test("the check flags slice Q's join and passes the opted-out form", () => {
- const bad = [
- 'const REPO_ROOT = findRepoRoot(process.cwd());',
- 'export const CHANNELS_DIR = path.resolve(',
- ' process.env.CHANNELS_DIR ?? path.join(REPO_ROOT, "transcripts", "channels"),',
- ');',
- ].join("\n");
- const found = untracedCalls(bad);
- assert.ok(found.some((f) => f.call.includes('path.join(REPO_ROOT, "transcripts"')), JSON.stringify(found));
-
- const good = bad
- .replace("path.resolve(", "path.resolve(/* turbopackIgnore: true */")
- .replace("path.join(REPO_ROOT", "path.join(/* turbopackIgnore: true */ REPO_ROOT");
- assert.deepEqual(untracedCalls(good), []);
-});
-
-test("sources: cwd, import.meta, __dirname; not homedir, env, or a comment", () => {
- assert.equal(untracedCalls('const X = path.join(process.cwd(), "song");').length, 1);
- assert.equal(untracedCalls("const H = path.dirname(fileURLToPath(import.meta.url));").length, 1);
- assert.equal(untracedCalls('readFileSync(path.join(__dirname, "a.json"));').length, 2);
- assert.equal(untracedCalls('const R = path.join(os.homedir(), "reports");').length, 0);
- assert.equal(untracedCalls('const R = path.join(process.env.X, "channels");').length, 0);
- assert.equal(untracedCalls('// path.join(process.cwd(), "x")\nconst y = 1;').length, 0);
- // An object key named like a tainted value is not a use of it.
- assert.equal(untracedCalls('const cwd = path.join(/* turbopackIgnore: true */ process.cwd(), "s");\nf(path.join(a, { cwd: 1 }));').length, 0);
-});
-
-test("no umtool module the app can import joins a cwd-derived path without opting out", () => {
- const bad = [];
- for (const file of appModules()) {
- for (const f of untracedCalls(readFileSync(file, "utf8"))) {
- bad.push(`${path.relative(REPO, file)}:${f.line} ${f.call}`);
- }
- }
- assert.deepEqual(
- bad,
- [],
- "add /* turbopackIgnore: true */ as the first argument (see this file's header):\n" + bad.join("\n"),
- );
-});
diff --git a/umtool/lib/paths.mjs b/umtool/lib/paths.mjs
@@ -106,7 +106,7 @@ const dedupe = (list) => [...new Set(list.map((p) => path.resolve(p)))];
* symlinked to another drive) and was OOM-killed, or died on the first symlink
* out of the root. A worktree with no transcripts/ builds fine, which is how it
* shipped. The comment is the documented per-expression opt-out; the values at
- * run time are unchanged. scripts/umtool-build-trace.test.mjs holds the line.
+ * run time are unchanged. scripts/next-build-trace.test.mjs holds the line.
*/
export function findRepoRoot(start) {
let dir = path.resolve(/* turbopackIgnore: true */ start);