commit e5b783d46c528ffcbd4f22366fd78fc0c01f23c4
parent 88db32b91d8013ed571cb27f6b70cdf80bade40f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 20:33:03 -0400
Merge homepage/social-visible (release 14 slice HP) — the homepage shows the social links in its header at every width, with the theme toggle as one group; Changelog in the footer; the instance cards name each site with its wordmark; the chart's separators contrast with the ground; social icons are config, checked by an allowlist on save and again at render; reviewed SHIP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
63 files changed, 3887 insertions(+), 484 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -141,6 +141,10 @@ yarn-error.log*
/homepage/out/
# the e2e's synthetic summary, and a killed run's temp copy (homepage/e2e/fixture-summary.ts)
/homepage/e2e/.e2e-summary.json*
+# the e2e's settings.json (social links only), and its temp copy (homepage/e2e/fixture-social.ts)
+/homepage/e2e/.e2e-settings.json*
+# the e2e's empty sites directory (no homepage.json; homepage/playwright.config.ts)
+/homepage/e2e/.e2e-sites/
# site config
/settings.json
diff --git a/SETTINGS.md b/SETTINGS.md
@@ -471,9 +471,10 @@ Per entry — each entry spells its own values.
| Key | Description |
|---|---|
-| `label` | Visible name, also the accessible label of the icon. |
+| `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. Normalized on save (width/height stripped, fill="currentColor", aria-hidden) and rejected when unsafe (script, foreignObject, event handlers, javascript: URLs) or when it has no viewBox. |
+| `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. |
Default:
diff --git a/SITE.md b/SITE.md
@@ -77,9 +77,10 @@ Per entry — each entry spells its own values.
| Key | Description |
|---|---|
-| `label` | Visible name, also the accessible label of the icon. |
+| `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. Normalized on save (width/height stripped, fill="currentColor", aria-hidden) and rejected when unsafe (script, foreignObject, event handlers, javascript: URLs) or when it has no viewBox. |
+| `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. |
## `groups`
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -285,3 +285,37 @@ test("the source publish block: the tools, the operator files by count and mode
r = await collectDoctorReport(deps({ filterRepo: { via: "git", version: "a40bce548d2c" }, gitleaks: null }));
assert.equal(status(r, "filter-repo"), "ok");
});
+
+// A stored icon the checker refuses is named by file and label with its
+// reason — never its markup — and the doctor still exits 0 (a WARN).
+test("social icons: a refused stored icon is named by file and label, not its markup", async () => {
+ const c = checkout();
+ const sitesDir = path.join(c.paths.transcriptsDir, "sites");
+ mkdirSync(path.join(sitesDir, "one"), { recursive: true });
+ const secret = "SECRET-MARKUP-XYZ";
+ writeFileSync(c.paths.settingsFile, JSON.stringify({
+ socialLinks: [
+ { label: "Good", url: "https://good.example", svg: `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>` },
+ { label: "Old", url: "https://old.example", svg: `<svg viewBox="0 0 8 8"><style>.${secret}{}</style></svg>` },
+ ],
+ }));
+ writeFileSync(path.join(sitesDir, "one", "site.json"), JSON.stringify({
+ socialLinks: [{ label: "Site icon", url: "https://s.example", svg: `<svg/onload="x()" viewBox="0 0 8 8"></svg>` }],
+ }));
+ const report = await collectDoctorReport({
+ env: {},
+ paths: { ...c.paths, sitesDir, homepageConfigFile: path.join(sitesDir, "_homepage", "homepage.json") },
+ probe: async (t) => ({ id: t.id, bin: t.bin ?? "", present: true, version: "1", neededBy: t.neededBy, required: t.required }) as never,
+ portInUse: async () => false,
+ portBlock: async () => null,
+ umtoolTools: async () => null,
+ sourceTools: async () => ({ filterRepo: null, gitleaks: null }),
+ });
+ const line = report.checks.find((x) => x.section === "social icons")!;
+ assert.equal(line.status, "warn");
+ assert.match(line.detail, /2 of 3 fail/);
+ assert.match(line.detail, /settings\.json: "Old" — it has an element an icon has no use for \(style\)/);
+ assert.match(line.detail, /site\.json: "Site icon" — it is not one well-formed <svg> element/);
+ assert.ok(!line.detail.includes(secret), "no markup");
+ assert.ok(report.ok, "a warning, not a failure");
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -169,6 +169,25 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
add(S, "schema", "fail", `settings do not load: ${(err as Error).message}`);
}
+ // ── social icons ─────────────────────────────────────────────────────────
+ // Every stored social link's icon — settings.json, each site.json,
+ // homepage.json — through the check a page runs before it inlines one
+ // (lib/socialSvg.ts). One that fails renders as its label on every page;
+ // editing its SVG in the editor is the fix. Named by file and label with the
+ // reason class — never the markup.
+ const SI = "social icons";
+ const icons = await storedSocialIcons(paths);
+ if (icons.checked === 0) {
+ add(SI, "stored icons", "info", "no stored social icons");
+ } else if (icons.refused.length === 0) {
+ add(SI, "stored icons", "ok",
+ `${icons.checked} icon${icons.checked === 1 ? "" : "s"} in ${icons.files} file${icons.files === 1 ? "" : "s"} pass the check`);
+ } else {
+ add(SI, "stored icons", "warn",
+ `${icons.refused.length} of ${icons.checked} fail the check and show as their label; edit each one's SVG:\n` +
+ icons.refused.map((r) => `${path.relative(root, r.file) || r.file}: "${r.label}" — ${r.problem}`).join("\n"));
+ }
+
// ── tools ────────────────────────────────────────────────────────────────
const T = "tools";
const hasCorpus = channelSlugs.length > 0;
@@ -354,6 +373,53 @@ export async function main(opts: { json?: boolean; env?: NodeJS.ProcessEnv } = {
// ── helpers ────────────────────────────────────────────────────────────────
+// The social links stored in settings.json, every sites/<id>/site.json and
+// sites/_homepage/homepage.json, each checked. Read-only; a missing or
+// unreadable file is skipped.
+async function storedSocialIcons(paths: Paths): Promise<{
+ files: number;
+ checked: number;
+ refused: { file: string; label: string; problem: string }[];
+}> {
+ const { parseSocialLinks } = await import("../lib/settingsSchema");
+ const { socialSvgProblem } = await import("../lib/socialSvg");
+ const candidates = [paths.settingsFile];
+ if (paths.sitesDir) {
+ try {
+ for (const e of await readdir(paths.sitesDir, { withFileTypes: true })) {
+ if (e.isDirectory() && !e.name.startsWith("_")) {
+ candidates.push(path.join(paths.sitesDir, e.name, "site.json"));
+ }
+ }
+ } catch {
+ /* no sites directory */
+ }
+ }
+ if (paths.homepageConfigFile) candidates.push(paths.homepageConfigFile);
+ let files = 0;
+ let checked = 0;
+ const refused: { file: string; label: string; problem: string }[] = [];
+ for (const file of candidates) {
+ const text = file ? readOrNull(file) : null;
+ if (text === null) continue;
+ let raw: unknown;
+ try {
+ raw = JSON.parse(text);
+ } catch {
+ continue;
+ }
+ const links = parseSocialLinks((raw as { socialLinks?: unknown } | null)?.socialLinks);
+ if (links.length === 0) continue;
+ files += 1;
+ for (const link of links) {
+ checked += 1;
+ const problem = socialSvgProblem(link.svg);
+ if (problem) refused.push({ file, label: link.label, problem });
+ }
+ }
+ return { files, checked, refused };
+}
+
function versionAtLeast(v: string, min: readonly [number, number, number]): boolean {
const parts = v.split(".").map((n) => Number.parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) {
diff --git a/common/components/SocialLinks.tsx b/common/components/SocialLinks.tsx
@@ -0,0 +1,102 @@
+import { useId } from "react";
+import type { SocialLink } from "../lib/settingsSchema";
+import {
+ headerSocialLinks,
+ safeSocialSvg,
+ scopeSvgIds,
+ sizeSocialSvg,
+} from "../lib/socialLinks";
+import { cn } from "../lib/utils";
+
+export type SocialLinksPlacement = "header" | "footer";
+
+// THE SOCIAL ROW, for any header or footer. The links are the operator's
+// (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.
+// - 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
+// more colours keeps its own (normalizeSocialSvg).
+// - Focus: the ring colour's 2 px ring. In forced colours a box-shadow is not
+// drawn, so the browser's own focus outline is left in place there.
+// - The ids inside each inlined icon are scoped to this row and this link
+// (scopeSvgIds): a page inlines the same icon more than once, and a gradient
+// defined in a copy that is `display: none` would not paint in the others.
+// - An icon is inlined only if it passes the save-time check again
+// (safeSocialSvg). One that does not is not injected: the link shows its
+// label as text instead, so a bad file costs the icon, never the page.
+//
+// Server-safe and client-safe: no state, no effects; `useId` works in both.
+// Renders nothing when there is nothing to show, so a caller can drop the
+// column or the group around it on the same condition.
+// Complete literal class strings (Tailwind v4 scans them as written).
+// A key clips what it holds (`overflow-hidden`, `contain: paint`): an icon
+// paints inside its 36 px box and nowhere else. The focus ring is the key's
+// own box-shadow, outside that clip. A refused icon's text fallback is capped
+// at 10rem and ends in an ellipsis, its full label in `title`.
+const KEY =
+ "inline-flex size-9 shrink-0 items-center justify-center overflow-hidden [contain:paint] rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11 [&_svg]:size-5 [&_svg]:shrink-0";
+const TEXT_KEY =
+ "inline-block h-9 max-w-40 shrink-0 truncate rounded-md px-2 text-sm leading-9 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:h-11 pointer-coarse:leading-[2.75rem]";
+
+export function SocialLinks({
+ links,
+ placement,
+ className,
+}: {
+ links: readonly SocialLink[];
+ placement: SocialLinksPlacement;
+ className?: string;
+}) {
+ const scope = `sl${useId().replace(/[^A-Za-z0-9_-]/g, "")}`;
+ const shown = placement === "header" ? headerSocialLinks(links) : [...links];
+ if (shown.length === 0) return null;
+ return (
+ <ul
+ data-social-links={placement}
+ 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",
+ className,
+ )}
+ >
+ {shown.map((link, i) => {
+ const svg = safeSocialSvg(link.svg);
+ return (
+ <li key={`${link.url}-${i}`} className="flex">
+ {svg ? (
+ <a
+ href={link.url}
+ title={link.label}
+ aria-label={link.label}
+ target="_blank"
+ rel="noopener noreferrer"
+ className={KEY}
+ dangerouslySetInnerHTML={{
+ __html: sizeSocialSvg(scopeSvgIds(svg, `${scope}-${i}`)),
+ }}
+ />
+ ) : (
+ <a
+ href={link.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ data-social-icon-refused=""
+ title={link.label}
+ className={TEXT_KEY}
+ >
+ {link.label}
+ </a>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ );
+}
diff --git a/common/components/SocialScroll.tsx b/common/components/SocialScroll.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { cn } from "../lib/utils";
+
+// THE SOCIAL ROW'S LAST RESORT: a box that scrolls the row sideways when it
+// still cannot fit (a header that has already made room). The row's END is in
+// view first, with no script and no change to the DOM or tab order: the box is
+// `direction: rtl`, whose first scroll position is its right edge, and the row
+// inside it is `ltr` and `w-max` (as wide as its links, so it overflows to the
+// left, where an rtl box can scroll). No scrollbar is drawn; touch, a
+// trackpad, shift + wheel and the keyboard still scroll it. When the row fits,
+// nothing about it changes.
+//
+// The one piece of script: a link that takes focus is scrolled into view
+// (`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.
+//
+// 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
+// theme control) still meets the last link.
+export function SocialScroll({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+ <div
+ data-social-scroll=""
+ 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,
+ )}
+ onFocus={(event) => {
+ const target = event.target as HTMLElement;
+ if (target !== event.currentTarget) {
+ target.scrollIntoView({ block: "nearest", inline: "nearest" });
+ }
+ }}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/common/components/ThemeProvider.tsx b/common/components/ThemeProvider.tsx
@@ -116,13 +116,18 @@ function store(key: string, value: string | null) {
}
}
+// `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
@@ -137,11 +142,11 @@ export function ThemeProvider({
useLayoutEffect(() => {
const stored = readStored();
setBaseState(stored.base ?? defaultBase);
- setAccentState(stored.accent ?? siteAccent);
+ setAccentState(pinAccent ? siteAccent : (stored.accent ?? siteAccent));
setSystemDark(systemPrefersDark());
setAdopted(true);
// Effectively once: the defaults are stable props from the layout.
- }, [defaultBase, siteAccent]);
+ }, [defaultBase, siteAccent, pinAccent]);
// A LIVE OS preference: "system" follows a change made while the page is
// open (the pre-paint script can only read it once).
diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx
@@ -0,0 +1,119 @@
+"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
@@ -15,14 +15,19 @@ import { buildThemeScript, type ThemeBase } from "./themeConfig";
// 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;
}) {
return (
<script
- dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase }) }}
+ dangerouslySetInnerHTML={{ __html: buildThemeScript({ defaultBase, pinAccent }) }}
suppressHydrationWarning
/>
);
diff --git a/common/components/ThemeToggle.tsx b/common/components/ThemeToggle.tsx
@@ -17,7 +17,21 @@ const LABEL: Record<ThemeBase, string> = {
dark: "dark",
};
-export function ThemeToggle({ className }: { className?: string }) {
+// `variant="bare"` (the homepage): dressed as a social link's key
+// (SocialLinks.tsx) — a 36 px box (44 px under a coarse pointer), no border, the
+// muted foreground and the faint hover square, a 20 px glyph, the ring
+// colour's 2 px focus ring and, in forced colours, the browser's own outline —
+// so it reads as the last key of the row it follows. The default is unchanged.
+const BARE =
+ "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11";
+
+export function ThemeToggle({
+ className,
+ variant = "default",
+}: {
+ className?: string;
+ variant?: "default" | "bare";
+}) {
const { base, cycleBase } = useTheme();
const Icon = ICON[base];
@@ -29,11 +43,13 @@ export function ThemeToggle({ className }: { className?: string }) {
title={`Theme: ${LABEL[base]}`}
data-theme-base={base}
className={cn(
- "inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]",
+ variant === "bare"
+ ? BARE
+ : "inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)] hover:border-[var(--border-strong,var(--border))]",
className,
)}
>
- <Icon className="h-4 w-4" aria-hidden="true" />
+ <Icon className={variant === "bare" ? "size-5" : "h-4 w-4"} aria-hidden="true" />
</button>
);
}
diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx
@@ -13,15 +13,18 @@ import { splitWordmark } from "../lib/brand";
//
// Size, leading and truncation come from `className`. The face is Archivo,
// the display face on every base (common/styles/fonts.ts `--font-display`,
-// with the `wdth` axis the stretch needs).
+// with the `wdth` axis the stretch needs). `leadStyle` is laid over the lead's
+// own style (the homepage's instance cards tint it in the site's accent).
export function Wordmark({
title,
lead,
className,
+ leadStyle,
}: {
title: string;
lead?: string;
className?: string;
+ leadStyle?: React.CSSProperties;
}) {
const parts = splitWordmark(title, lead);
return (
@@ -35,7 +38,7 @@ export function Wordmark({
>
<span
data-wordmark-lead=""
- style={{ fontWeight: 720, color: "var(--foreground)" }}
+ style={{ fontWeight: 720, color: "var(--foreground)", ...leadStyle }}
>
{parts.lead}
</span>
diff --git a/common/components/themeConfig.test.ts b/common/components/themeConfig.test.ts
@@ -217,3 +217,14 @@ test("isThemeAccent takes the seven ids and custom only", () => {
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));
+ }
+});
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -194,9 +194,15 @@ export function migrateLegacy({
// 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",
-}: { defaultBase?: ThemeBase } = {}): string {
+ pinAccent = false,
+}: { defaultBase?: ThemeBase; pinAccent?: boolean } = {}): string {
const fallback: ThemeBase = isThemeBase(defaultBase) ? defaultBase : "system";
const q = JSON.stringify;
return (
@@ -212,7 +218,7 @@ export function buildThemeScript({
"}" +
`s.removeItem(${q(LEGACY_THEME_KEY)});s.removeItem(${q(LEGACY_MODE_KEY)});` +
"}" +
- `a=s.getItem(${q(ACCENT_KEY)});` +
+ (pinAccent ? "" : `a=s.getItem(${q(ACCENT_KEY)});`) +
"}catch(e){}" +
`if(b!=='light'&&b!=='sepia'&&b!=='dark'&&b!=='system')b=${q(fallback)};` +
"var r=b;" +
diff --git a/common/lib/homepage.test.ts b/common/lib/homepage.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtemp, readFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Paths } from "./paths";
@@ -44,3 +44,17 @@ test("writeHomepageConfig persists transcriptDownloads only when false, and it r
disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8"));
assert.equal("transcriptDownloads" in disk, false);
});
+
+test("writeHomepageConfig keeps an unchanged stored icon the checker now refuses, and checks an edited one", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "homepage-")));
+ const old = { label: "Old", url: "https://old.example", svg: `<svg viewBox="0 0 8 8"><metadata/></svg>` };
+ await mkdir(path.dirname(paths.homepageConfigFile), { recursive: true });
+ await writeFile(paths.homepageConfigFile, JSON.stringify({ socialLinks: [old] }));
+ const base = getHomepageConfig(paths);
+ await writeHomepageConfig({ ...base, siteTitle: "Renamed" }, paths);
+ assert.deepEqual(JSON.parse(await readFile(paths.homepageConfigFile, "utf8")).socialLinks, [old]);
+ await assert.rejects(
+ writeHomepageConfig({ ...base, socialLinks: [{ ...old, svg: `<svg viewBox="0 0 8 8"><script/></svg>` }] }, paths),
+ /Social link "Old" has an invalid SVG: it has a script/,
+ );
+});
diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts
@@ -4,11 +4,11 @@ import { getPaths, type Paths } from "./paths";
import { PROJECT_NAME, PROJECT_TAGLINE } from "./project";
import {
getSettings,
- normalizeSocialSvg,
parseSocialLinks,
type SiteSettings,
type SocialLink,
} from "./settings";
+import { socialLinksForSave } from "./socialLinks";
// The Archilyzer hub/homepage is a SINGLE, instance-level landing site (the
// `homepage` SSG package) that sits above the per-content sites. Unlike a Site,
@@ -108,16 +108,18 @@ export async function writeHomepageConfig(
config: HomepageConfig,
paths: Paths = getPaths(),
): Promise<void> {
+ // A link whose SVG is unchanged from homepage.json on disk is kept as it is;
+ // a new or edited one is checked (lib/socialLinks.ts socialLinksForSave).
let socialLinks: SocialLink[] | undefined;
if (config.socialLinks !== undefined) {
- socialLinks = [];
- for (const link of parseSocialLinks(config.socialLinks)) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- throw new Error(`Social link "${link.label}" has an invalid SVG`);
- }
- socialLinks.push({ ...link, svg });
+ const r = socialLinksForSave(
+ parseSocialLinks(config.socialLinks),
+ getHomepageConfig(paths).socialLinks,
+ );
+ if ("refused" in r) {
+ throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`);
}
+ socialLinks = r.links;
}
const merged: HomepageConfig = {
siteTitle: config.siteTitle,
diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts
@@ -254,3 +254,26 @@ test("a named accent also travels as its id; a custom hex and no accent carry no
const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW);
assert.ok(plain.sites.every((x) => !("accentId" in x)));
});
+
+test("a site's wordmark lead travels when it is a proper prefix of the title; otherwise no key", () => {
+ const sites = [
+ { ...site("alpha", ["a1", "a2"], "https://alpha.example"), siteTitle: "Jeralyzer", wordmarkLead: " Jer " },
+ { ...site("beta", ["b1"], "https://beta.example"), siteTitle: "Anilyzer", wordmarkLead: "Anilyzer" },
+ ] as Site[];
+ const s = buildHomepageSummary(STATS, CHANNEL_SITES, sites, NOW);
+ assert.equal(s.sites.find((x) => x.siteId === "alpha")!.wordmarkLead, "Jer");
+ // The whole title is no split (lib/brand.ts wordmarkLeadFor): no key.
+ assert.ok(!("wordmarkLead" in s.sites.find((x) => x.siteId === "beta")!));
+ // Resolved against the title the card shows, case-sensitive.
+ const other = buildHomepageSummary(
+ STATS,
+ CHANNEL_SITES,
+ [{ ...sites[0], wordmarkLead: "jer" }] as Site[],
+ NOW,
+ );
+ assert.ok(!("wordmarkLead" in other.sites[0]));
+ // No lead configured: no key, as before.
+ const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW);
+ assert.ok(plain.sites.every((x) => !("wordmarkLead" in x)));
+ assert.equal(s.version, HOMEPAGE_SUMMARY_VERSION);
+});
diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts
@@ -2,7 +2,7 @@ import type { Platform } from "./platform";
import type { VideoStat } from "./stats";
import type { Site } from "./site";
import { accentHex, accentIdOf } from "./accent";
-import type { AccentId } from "./brand";
+import { wordmarkLeadFor, type AccentId } from "./brand";
import { VIDEO_STATES, type VideoState } from "./availability";
// Pre-computed, lightweight cross-site summary for the hub (homepage) landing.
@@ -30,7 +30,9 @@ import { VIDEO_STATES, type VideoState } from "./availability";
// the same reason: a v4 summary on disk must still render (the numbers hide).
//
// Still v5: the per-site `accentId` (release 10) is additive and optional in
-// the same way — a summary without it paints its sites' hex, as before.
+// the same way — a summary without it paints its sites' hex, as before. So is
+// the per-site `wordmarkLead` (release 14): a summary without it shows each
+// card's title plain, as before. Nothing reads this number to accept a file.
export const HOMEPAGE_SUMMARY_VERSION = 5;
// Day buckets are capped to this many trailing days so the embedded summary stays
@@ -93,6 +95,13 @@ export type HomepageSummarySite = {
// base (`var(--swatch-<id>)`, lib/siteColor.ts). Optional, additive
// (release 10): absent for a custom hex, no accent, or an older summary.
accentId?: AccentId;
+ // The site's wordmark lead ("Jer" of "Jeralyzer"): site.json `wordmarkLead`
+ // resolved against `siteTitle` by lib/brand.ts wordmarkLeadFor, the resolver
+ // the sites' own header and site.json use — a proper prefix of the title, or
+ // absent. The homepage's card sets the title as the site's wordmark with it.
+ // Optional, additive (release 14): absent for a site with no lead, or an
+ // older summary.
+ wordmarkLead?: string;
};
export type HomepageChannelMeta = { slug: string; name: string };
@@ -470,6 +479,9 @@ export function buildHomepageSummary(
// travels as its id too.
...(accentHex(s.accent) ? { accent: accentHex(s.accent) } : {}),
...(accentIdOf(s.accent) ? { accentId: accentIdOf(s.accent) } : {}),
+ ...(wordmarkLeadFor(s.siteTitle, s.wordmarkLead)
+ ? { wordmarkLead: wordmarkLeadFor(s.siteTitle, s.wordmarkLead) }
+ : {}),
}))
// Keep a public site only if it has any activity in either metric.
.filter((s) => s.transcribed.total > 0 || s.downloaded.total > 0)
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -1,6 +1,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { normalizeSocialSvg } from "./settingsSchema";
+import { normalizeSocialSvg, socialSvgProblem } from "./settingsSchema";
+import { SVG_PROBLEM } from "./socialSvg";
+import { scopeSvgIds, sizeSocialSvg } from "./socialLinks";
+import { ADVERSARIAL, LOADS_ELSEWHERE, REAL_SHAPES } from "./socialSvg.vectors";
// normalizeSocialSvg runs on every save of a social link (Settings, a site's
// form, the homepage config). It used to theme only the ROOT <svg>'s fill, so a
@@ -190,3 +193,357 @@ test("still refuses what is unsafe to inline", () => {
assert.equal(normalizeSocialSvg(`<svg viewBox="0 0 1 1" onload="x()"></svg>`), null);
assert.equal(normalizeSocialSvg(`<svg><path fill="white"/></svg>`), null, "no viewBox");
});
+
+// A ROOT WITH A SIZE AND NO viewBox. A vendor's file often carries only its
+// width and height; it used to be refused as pasted, and the operator had to
+// add a viewBox by hand. Its size now becomes `viewBox="0 0 W H"` before the
+// size is stripped. A synthetic two-colour disc with a letter, shaped like such
+// a file: a dark offset disc, a light disc with a dark outline, a dark glyph,
+// `fill="none"` on the root.
+const SIZED_DISC =
+ `<svg xmlns="http://www.w3.org/2000/svg" width="81" height="81" fill="none">` +
+ `<circle cx="44" cy="43" r="37" fill="#1a1a1a"/>` +
+ `<circle cx="39" cy="39" r="38" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.5"/>` +
+ `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`;
+
+test("a disc pasted with only its size: accepted, its size made the viewBox, both colours kept", () => {
+ const out = normalized(SIZED_DISC);
+ assert.ok(
+ out.startsWith('<svg aria-hidden="true" viewBox="0 0 81 81" xmlns="http://www.w3.org/2000/svg" fill="none">'),
+ out.slice(0, 120),
+ );
+ assert.ok(!/\s(width|height)\s*=\s*"81"/.test(out.slice(0, out.indexOf(">"))), "size stripped");
+ assert.deepEqual(fills(out), ["none", "#1a1a1a", "#f4c542", "#1a1a1a"]);
+ // Nothing below the root moved.
+ assert.equal(out.slice(out.indexOf(">")), SIZED_DISC.slice(SIZED_DISC.indexOf(">")));
+});
+
+test("a size in px or unitless, either quote, decimals: the viewBox is the numbers", () => {
+ const box = (open: string) => {
+ const out = normalized(`${open}<path fill="#fff" d="M0 0"/></svg>`);
+ return /\sviewBox="([^"]*)"/.exec(out)?.[1];
+ };
+ assert.equal(box(`<svg width="24" height="24">`), "0 0 24 24");
+ assert.equal(box(`<svg width="24px" height='16PX'>`), "0 0 24 16");
+ assert.equal(box(`<svg height=" 12.5 " width="30">`), "0 0 30 12.5");
+ assert.equal(box(`<svg width="048" height=".5">`), "0 0 48 0.5");
+ // stroke-width on the root is not a width.
+ assert.equal(box(`<svg stroke-width="2" width="10" height="20">`), "0 0 10 20");
+ // A size spelled inside another attribute's value is not the root's size.
+ assert.equal(
+ normalizeSocialSvg(`<svg data-a=' width="7" height="9"'><path d="M0 0"/></svg>`),
+ null,
+ );
+});
+
+test("a size that is not a pixel size is still refused", () => {
+ for (const open of [
+ `<svg width="100%" height="100%">`,
+ `<svg width="2em" height="2em">`,
+ `<svg width="24">`,
+ `<svg height="24">`,
+ `<svg width="0" height="24">`,
+ `<svg width="24" height="0">`,
+ `<svg width="" height="24">`,
+ `<svg width="-24" height="24">`,
+ `<svg width="auto" height="24">`,
+ `<svg stroke-width="2">`,
+ ]) {
+ assert.equal(normalizeSocialSvg(`${open}<path d="M0 0"/></svg>`), null, open);
+ assert.equal(socialSvgProblem(`${open}<path d="M0 0"/></svg>`), SVG_PROBLEM.viewBox, open);
+ }
+});
+
+test("a viewBox already there is never replaced by the size", () => {
+ const raw = `<svg width="81" height="81" viewBox="0 0 24 24"><path fill="#fff" d="M0 0"/></svg>`;
+ assert.equal(
+ normalized(raw),
+ `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path fill="currentColor" d="M0 0"/></svg>`,
+ );
+ // A single-quoted viewBox was refused before this change and still is: the
+ // size never adds a second one.
+ assert.equal(
+ normalizeSocialSvg(`<svg width="8" height="8" viewBox='0 0 8 8'><path d="M0 0"/></svg>`),
+ null,
+ );
+});
+
+test("idempotent with a synthesized viewBox", () => {
+ const once = normalized(SIZED_DISC);
+ assert.equal(normalized(once), once);
+ const sized = normalized(`<svg width="24" height="24"><path fill="#fff" d="M0 0"/></svg>`);
+ assert.equal(normalized(sized), sized);
+});
+
+// ── WHAT AN ICON MAY CONTAIN ────────────────────────────────────────────────
+// The review's five inputs, each accepted by the old text denylist and each
+// running script in Chromium, then one case per class the allowlist refuses.
+
+const REVIEW_BYPASSES: Record<string, string> = {
+ slash_handler: `<svg/onload="window.__x=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ deletion_join: `<svg viewBox="0 0 8 8" o width="1"nload="window.__x=1"><path d="M0 0"/></svg>`,
+ image_slash: `<svg viewBox="0 0 8 8"><image href="x:"/onerror="window.__x=1"/></svg>`,
+ charref_js: `<svg viewBox="0 0 8 8"><a href="javascript:window.__x=1"><rect width="8" height="8"/></a></svg>`,
+ breakout_img: `<svg viewBox="0 0 8 8"><img src="x:"/onerror="window.__x=1"></svg>`,
+};
+
+test("the review's five bypasses are refused", () => {
+ for (const [name, raw] of Object.entries(REVIEW_BYPASSES)) {
+ assert.equal(normalizeSocialSvg(raw), null, name);
+ assert.ok(socialSvgProblem(raw), name);
+ }
+});
+
+const OK = (inner: string, open = `<svg viewBox="0 0 8 8">`) => `${open}${inner}</svg>`;
+
+test("an event handler is refused wherever an attribute can start", () => {
+ for (const raw of [
+ `<svg viewBox="0 0 8 8" onload="x()"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"\tonload="x()"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"\nONLOAD="x()"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"\fonclick="x()"><path d="M0 0"/></svg>`,
+ OK(`<path d="M0 0" onmouseover="x()"/>`),
+ OK(`<animate attributeName="onclick" to="x()"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.handler, raw);
+ }
+ // A `/` between attributes (which HTML takes as a separator) is not markup
+ // this reads.
+ assert.equal(socialSvgProblem(`<svg/onload="x()" viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.markup);
+});
+
+test("a script, or a link that runs one, in any spelling", () => {
+ for (const raw of [
+ OK(`<script>x()</script>`),
+ OK(`<rect fill="url(#a)" style="fill:javascript:x()"/>`),
+ OK(`<rect data-x="javascript:x()"/>`),
+ OK(`<rect data-x="java	script:x()"/>`),
+ OK(`<rect data-x="java script:x()"/>`),
+ OK(`<rect data-x="javascript:x()"/>`),
+ OK(`<rect data-x="VBScript:x()"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.script, raw);
+ }
+});
+
+test("a link to anything but a fragment of this icon", () => {
+ for (const raw of [
+ OK(`<use href="https://x.example/i.svg#a"/>`),
+ OK(`<use xlink:href="data:image/svg+xml,<svg/>"/>`),
+ OK(`<use href="#a""/>`),
+ OK(`<linearGradient id="g" href="//x.example/g"/>`),
+ OK(`<rect fill="url(https://x.example/p.svg#a)"/>`),
+ OK(`<rect style="fill:url(data:image/png;base64,AAAA)"/>`),
+ OK(`<rect mask="url( '//x.example' )"/>`),
+ OK(`<set attributeName="href" to="#a"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.external, raw);
+ }
+ // A fragment, in each spelling a reference takes, is fine.
+ for (const raw of [
+ OK(`<defs><linearGradient id="g"/></defs><use href="#g"/><use xlink:href="#g"/>`),
+ OK(`<rect fill="url(#g)" style="fill:url('#g')" mask="url("#g")"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), null, raw);
+ }
+});
+
+test("elements an icon has no use for, named", () => {
+ for (const [inner, name] of [
+ [`<foreignObject><div/></foreignObject>`, "foreignObject"],
+ [`<style>@import "x";</style>`, "style"],
+ [`<a href="#a"><rect/></a>`, "a"],
+ [`<image href="#a"/>`, "image"],
+ [`<iframe/>`, "iframe"],
+ [`<object/>`, "object"],
+ [`<embed/>`, "embed"],
+ [`<audio/>`, "audio"],
+ [`<video/>`, "video"],
+ [`<img/>`, "img"],
+ [`<p>x</p>`, "p"],
+ [`<sodipodi:namedview/>`, "sodipodi:namedview"],
+ ] as const) {
+ assert.equal(socialSvgProblem(OK(inner)), SVG_PROBLEM.element(name), 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"));
+ assert.equal(socialSvgProblem(OK(`<rect inkscape:label="x"/>`)), SVG_PROBLEM.attribute("inkscape:label"));
+ assert.equal(socialSvgProblem(OK(`<rect style="fill:u\\72l(x)"/>`)), SVG_PROBLEM.style);
+ assert.equal(socialSvgProblem(OK(`<rect style="@import 'x'"/>`)), SVG_PROBLEM.external);
+});
+
+test("markup that is not one well-formed <svg>: declarations, CDATA, instructions, strays", () => {
+ for (const raw of [
+ OK(`<![CDATA[x]]>`),
+ OK(`<!ENTITY x "y">`),
+ OK(`<?php x ?>`),
+ OK(`<path d="M0 0">`), // never closed
+ OK(`</g>`), // closed, never opened
+ `<svg viewBox="0 0 8 8"></svg><svg viewBox="0 0 8 8"></svg>`,
+ `<svg viewBox="0 0 8 8"></svg>text<svg></svg>`,
+ OK(`<path d=M0/>`), // an unquoted value
+ OK(`<path d="M0 0"/ >`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.markup, raw);
+ }
+});
+
+test("comments are removed first; an XML declaration and a plain DOCTYPE at the start are too", () => {
+ const out = normalized(
+ `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n` +
+ `<!-- Generator: a drawing app --><svg viewBox="0 0 8 8"><!-- a --><path d="M0 0"/></svg>`,
+ );
+ assert.equal(out, `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>`);
+ // A comment cannot hide a handler: what is left after removal is checked.
+ assert.equal(socialSvgProblem(`<svg viewBox="0 0 8 8" on<!-- -->load="x()"></svg>`), SVG_PROBLEM.handler);
+ // A DOCTYPE with an internal subset is not removed, and is refused with
+ // what to do about it.
+ assert.equal(socialSvgProblem(`<!DOCTYPE svg [<!ENTITY x "y">]><svg viewBox="0 0 8 8"></svg>`), SVG_PROBLEM.doctype);
+});
+
+test("an id a rendered copy cannot prefix safely is refused", () => {
+ for (const id of ["->", "-!>", "1a", "a b", "a&b", ""]) {
+ assert.equal(socialSvgProblem(OK(`<path id="${id}" d="M0 0"/>`)), SVG_PROBLEM.id, id);
+ }
+ assert.equal(socialSvgProblem(OK(`<path id="a.b:c-d_1" d="M0 0"/>`)), null);
+});
+
+// The shapes an operator's icons take keep passing: a gradient body with a
+// solid part, a single path drawn white, a two-colour disc.
+test("the shapes real icons take are accepted", () => {
+ const gradient =
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs>` +
+ `<linearGradient id="grad_1" x1="0" y1="0" x2="0" y2="1" gradientUnits="objectBoundingBox">` +
+ `<stop offset="0" stop-color="#e0a030"/><stop offset="1" style="stop-color:#b04020"/></linearGradient></defs>` +
+ `<path fill="url(#grad_1)" style="fill:url(#grad_1)" d="M12 2 20 20H4z"/><circle fill="#333" cx="12" cy="15" r="2"/></svg>`;
+ const single = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 2h20v20H2z" fill="white"/></svg>`;
+ for (const raw of [gradient, single, SIZED_DISC]) {
+ assert.equal(socialSvgProblem(raw), null, raw.slice(0, 60));
+ const out = normalized(raw);
+ assert.equal(socialSvgProblem(out), null);
+ }
+});
+
+// ── THE RE-REVIEW (R1, R2, R5, R6, the style properties) ────────────────────
+
+test("R1: a title or desc with text only is removed; with a child element it is refused", () => {
+ assert.equal(
+ normalized(OK(`<title>A name</title><desc/><path d="M0 0"/>`)),
+ `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ );
+ for (const raw of [
+ OK(`<title><path d="M0 0"/></title>`),
+ OK(`<desc><g></g></desc>`),
+ OK(`<title><svg viewBox="0 0 1 1"><title>t</title></svg></title>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.element(raw.includes("<desc>") ? "desc" : "title"), raw);
+ }
+});
+
+test("R2: the eight inputs that loaded from another origin are refused", () => {
+ for (const name of LOADS_ELSEWHERE) {
+ assert.equal(normalizeSocialSvg(ADVERSARIAL[name]), null, name);
+ }
+ for (const raw of [
+ OK(`<rect fill="\\75 rl(#a)"/>`),
+ OK(`<rect style="fill:url/**/(#a)"/>`),
+ OK(`<rect mask="cross-fade(url(#a), url(#b))"/>`),
+ OK(`<rect mask="-webkit-cross-fade(url(#a), url(#b))"/>`),
+ OK(`<rect style="fill:element(#a)"/>`),
+ OK(`<rect style="fill:-moz-element(#a)"/>`),
+ OK(`<rect style="fill:image(#a)"/>`),
+ OK(`<rect style="fill:src(#a)"/>`),
+ OK(`<rect style="fill:paint(x)"/>`),
+ OK(`<rect style="fill:expression(x)"/>`),
+ OK(`<rect><set attributeName="fill" to="image-set('${"https://x.example"}/a.png' 1x)"/></rect>`),
+ ]) {
+ assert.ok(socialSvgProblem(raw), raw);
+ }
+});
+
+test("a style holds presentation properties only", () => {
+ for (const decl of ["position:fixed", "inset:0", "z-index:9", "top:0", "width:100vw", "background:red", "cursor:pointer", "transform:scale(9)"]) {
+ assert.equal(socialSvgProblem(OK(`<rect style="${decl}"/>`)), SVG_PROBLEM.style, decl);
+ }
+ assert.equal(
+ socialSvgProblem(OK(`<rect style="fill:#fff; stroke:#000;stroke-width:2;opacity:.5;paint-order:stroke;display:inline"/>`)),
+ null,
+ );
+});
+
+test("R5: an animation targets nothing named href and no handler, in any spelling", () => {
+ for (const attr of ["x:href", " HREF ", "xlink:href", "href"]) {
+ assert.equal(socialSvgProblem(OK(`<set attributeName="${attr}" to="#a"/>`)), SVG_PROBLEM.external, attr);
+ }
+ for (const attr of ["onclick", "xlink:onclick", "x:onload"]) {
+ assert.equal(socialSvgProblem(OK(`<set attributeName="${attr}" to="1"/>`)), SVG_PROBLEM.handler, attr);
+ }
+});
+
+test("R6: a reference the render cannot scope is refused: an encoded or padded fragment", () => {
+ for (const raw of [
+ OK(`<defs><g id="a"/></defs><use href="#a"/>`),
+ OK(`<defs><g id="a"/></defs><use href=" #a "/>`),
+ OK(`<defs><g id="a"/></defs><rect fill="url(#a)"/>`),
+ OK(`<defs><g id="a"/></defs><rect fill="url(#a)"/>`),
+ ]) {
+ assert.equal(socialSvgProblem(raw), SVG_PROBLEM.external, raw);
+ }
+ // …and each accepted spelling is one scopeSvgIds rewrites.
+ const ok = normalized(OK(`<defs><g id="a"/></defs><use href="#a"/><rect fill="url(#a)" mask="url("#a")" style="fill:url('#a')"/>`));
+ const scoped = scopeSvgIds(ok, "s");
+ assert.ok(!/#a\b/.test(scoped.replace(/#s-a/g, "")), scoped);
+});
+
+test("the shapes real icons take pass, stay stable and survive the render", () => {
+ for (const [name, raw] of Object.entries(REAL_SHAPES)) {
+ const out = normalizeSocialSvg(raw);
+ assert.ok(out, `${name}: ${socialSvgProblem(raw)}`);
+ assert.equal(normalizeSocialSvg(out), out, `${name} idempotent`);
+ assert.ok(normalizeSocialSvg(sizeSocialSvg(scopeSvgIds(out, "sl_S_1_-0"))), `${name} rendered`);
+ }
+ assert.ok(normalized(REAL_SHAPES.gradient_outlined).includes('viewBox="-3 -3 76.3 80.8"'));
+});
+
+// The review's whole battery: an input is refused, or its output is stable,
+// survives the render's scoping and sizing, and — the structural half of R1's
+// invariant — holds only SVG elements the HTML parser keeps in foreign content
+// (no title, desc, foreignObject or HTML element can reach a page). The
+// browser half (a real parser, #after outside the icon) is the homepage e2e's
+// svg-vectors.spec.ts: the repo has no HTML parser to run it here.
+const FOREIGN_SAFE = new Set([
+ "svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line",
+ "polyline", "polygon", "text", "tspan", "lineargradient", "radialgradient", "stop",
+ "pattern", "clippath", "mask", "filter", "feblend", "fecolormatrix",
+ "fecomponenttransfer", "fecomposite", "fedropshadow", "feflood", "fefunca", "fefuncb",
+ "fefuncg", "fefuncr", "fegaussianblur", "femerge", "femergenode", "femorphology",
+ "feoffset", "animate", "animatetransform", "set",
+]);
+
+test("every adversarial input is refused, or accepted in a form that stays inside its <svg>", () => {
+ let accepted = 0;
+ for (const [name, raw] of Object.entries(ADVERSARIAL)) {
+ const out = normalizeSocialSvg(raw);
+ if (out === null) {
+ assert.ok(socialSvgProblem(raw), name);
+ continue;
+ }
+ accepted += 1;
+ assert.equal(normalizeSocialSvg(out), out, `${name} idempotent`);
+ const rendered = sizeSocialSvg(scopeSvgIds(out, "sl_S_1_-0"));
+ assert.ok(normalizeSocialSvg(rendered), `${name} rendered`);
+ // Tag names outside attribute values (a quoted `<img>` is a value, and inert).
+ const markup = rendered.replace(/"[^"]*"|'[^']*'/g, '""');
+ for (const m of markup.matchAll(/<\/?([A-Za-z][\w:.-]*)/g)) {
+ assert.ok(FOREIGN_SAFE.has(m[1].toLowerCase()), `${name}: <${m[1]}>`);
+ }
+ assert.ok(!/<!|<\?/.test(markup), `${name}: markup`);
+ }
+ assert.ok(accepted > 10, `${accepted} accepted`);
+ for (const name of ["title_child_el", "desc_child_el", "title_title", "title_svg_title", "overlay_style", ...LOADS_ELSEWHERE]) {
+ assert.equal(normalizeSocialSvg(ADVERSARIAL[name]), null, name);
+ }
+});
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -38,10 +38,10 @@ import {
} from "./workers";
import { migrateSweepsToLanes } from "./laneMigration";
import { migrateMediaRootToLocations } from "./storageLocations";
+import { socialLinksForSave } from "./socialLinks";
import {
clampParallelTranscriptions,
defaultStorage,
- normalizeSocialSvg,
parseSocialLinks,
sanitizeTranscriptionApps,
siteSettingsSchema,
@@ -220,19 +220,18 @@ function deriveWorkerShadow(next: SiteSettings): SiteSettings {
return { ...next, workers, transcriptionApp, transcriptionApps };
}
-// Every social link's SVG normalized for inline use, or a THROW naming the
-// first one that is not safe to inline. The schema's own `parseSocialLinks`
-// only checks shape; this is the write-side half.
-function validatedSocialLinks(value: unknown): SocialLink[] {
- const out: SocialLink[] = [];
- for (const link of parseSocialLinks(value)) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- throw new Error(`Social link "${link.label}" has an invalid SVG`);
- }
- out.push({ ...link, svg });
+// The social links to store: each new or edited SVG normalized for inline use,
+// or a THROW naming the first that is refused; a link whose SVG is unchanged
+// from the file is kept as it is (lib/socialLinks.ts socialLinksForSave). The
+// schema's own `parseSocialLinks` only checks shape; this is the write-side
+// half.
+function validatedSocialLinks(value: unknown, file: string): SocialLink[] {
+ const stored = parseSocialLinks(rawObject(readRawSettings(file)).socialLinks);
+ const r = socialLinksForSave(parseSocialLinks(value), stored);
+ if ("refused" in r) {
+ throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`);
}
- return out;
+ return r.links;
}
export async function writeSettings(next: SiteSettings): Promise<void> {
@@ -242,10 +241,10 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
// (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, the
// inverted `backfill.enabled`) loses them on this write. The gate is
// `autoQueue[lane].held` and nothing else — see lib/pauseGates.ts.
+ const file = getPaths().settingsFile;
const merged = siteSettingsSchema.parse({
...deriveWorkerShadow(next),
- socialLinks: validatedSocialLinks(next.socialLinks),
+ socialLinks: validatedSocialLinks(next.socialLinks, file),
});
- const file = getPaths().settingsFile;
await writeJsonAtomic(file, merged);
}
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -551,18 +551,43 @@ export type SocialLink = {
label: string;
url: string;
svg: string;
+ // Stored only when true. What a header does with it: lib/socialLinks.ts.
+ featured?: boolean;
};
export const SOCIAL_LINK_FIELD_DOCS: FieldDocs<SocialLink> = {
label:
- "Visible name, also the accessible label of the icon.",
+ "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. Normalized on save (width/height stripped, " +
- "fill=\"currentColor\", aria-hidden) and rejected when unsafe (script, " +
- "foreignObject, event handlers, javascript: URLs) or when it has no " +
- "viewBox.",
+ "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.",
};
// Each field is documented in ARCHIVE_STORAGE_SETTINGS_FIELD_DOCS below (rendered into SETTINGS.md).
@@ -1281,143 +1306,23 @@ export function parseSocialLinks(input: unknown): SocialLink[] {
const svg = typeof r.svg === "string" ? r.svg : "";
if (!label || !url || !svg) continue;
if (!SOCIAL_URL_RE.test(url)) continue;
- out.push({ label, url, svg });
+ // `featured` only when it is exactly `true`: absent and false read the
+ // same, and a file that never marked a link parses as it always did.
+ out.push({ label, url, svg, ...(r.featured === true ? { featured: true } : {}) });
}
return out;
}
-// A paint that is not a colour: no paint (`none`, `transparent`), a paint
-// server (a gradient or pattern, `url(#…)`, with or without a fallback), or
-// what the element takes from its parent already. Never counted, never swapped.
-const KEPT_PAINT = /^(?:none|transparent|currentcolor|inherit)$|^url\(/i;
-
-// One solid colour however it is spelled, so `#FFF`, `#ffffff`, `white` and
-// `rgb(255, 255, 255)` count as ONE colour of an icon, not four.
-function paintKey(value: string): string {
- const v = value.trim().toLowerCase().replace(/\s+/g, "");
- if (v === "white") return "#ffffff";
- if (v === "black") return "#000000";
- const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
- if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
- const rgb = /^rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/.exec(v);
- if (rgb) {
- return `#${rgb
- .slice(1, 4)
- .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0"))
- .join("")}`;
- }
- return v;
-}
-
-// Every fill of one tag, mapped: its `fill` attribute and any `fill:`
-// declaration in its `style` (which beats the attribute). `fill-rule`,
-// `fill-opacity`, strokes and gradient stops are not fills and are left alone.
-function mapTagFills(tag: string, paint: (value: string) => string): string {
- return tag
- .replace(
- /(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
- (_m, pre: string, dq?: string, sq?: string) =>
- dq !== undefined ? `${pre}"${paint(dq)}"` : `${pre}'${paint(sq ?? "")}'`,
- )
- .replace(
- /(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
- (_m, pre: string, dq?: string, sq?: string) => {
- const css = (dq ?? sq ?? "").replace(
- /(^|;)(\s*fill\s*:\s*)([^;]*)/gi,
- (_d, lead: string, prop: string, v: string) => `${lead}${prop}${paint(v)}`,
- );
- return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`;
- },
- );
-}
+// The icon's SVG — what it may contain, and its normalized form — is
+// lib/socialSvg.ts (pure, so the render path runs it too). Re-exported here for
+// the importers that reach it through lib/settings.
+export { normalizeSocialSvg, socialSvgProblem } from "./socialSvg";
-// The children, tag by tag. Passed over whole, never read or changed:
-// - a <mask> (its white and black say how much shows through, not what
-// colour) and a <clipPath> (a clip never paints: only its shape counts —
-// Figma exports almost every icon as a path clipped by a
-// `<clipPath><rect fill="white"/></clipPath>`, release 11, O2b) —
-// self-closing first, so an empty `<mask …/>` or `<clipPath …/>` cannot
-// swallow everything up to the next closing tag;
-// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing.
-const CHILD_TAG =
- /<(?:mask|clipPath)\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<clipPath\b[\s\S]*?<\/clipPath\s*>|<[a-zA-Z][^>]*>/gi;
-const PASSED_OVER = /^<(?:mask|clipPath|animate\w*|set)\b/i;
-
-function mapChildFills(body: string, paint: (value: string) => string): string {
- return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint)));
-}
-
-// Normalize an admin-provided SVG snippet for inline use in the export
-// footer. Returns null on anything that looks unsafe or unrenderable.
-// Steps: trim, allowlist-check, strip width/height, force fill="currentColor"
-// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
-//
-// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its
-// children's together, outside a <mask> or <clipPath> — hold at most one solid colour, each
-// becomes `currentColor`, the footer link's colour. Drawn for one background,
-// such an icon vanishes on another (X's official logo is a `<path
-// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours
-// draws its shape with them (YouTube's mark is a red rounded rectangle with a
-// white play triangle; flattened, it is a blank rectangle), carries its own
-// contrast, and keeps every colour as pasted. Paints that are not colours
-// (`none`, `url(…)`, `inherit`) are never counted or changed. Idempotent: a
-// themed icon has no solid colour left.
-export function normalizeSocialSvg(raw: string): string | null {
- if (typeof raw !== "string") return null;
- const trimmed = raw.trim();
- if (!trimmed.startsWith("<svg") || !trimmed.endsWith("</svg>")) return null;
- if (/<script\b/i.test(trimmed)) return null;
- if (/<foreignObject\b/i.test(trimmed)) return null;
- if (/<iframe\b/i.test(trimmed)) return null;
- if (/javascript:/i.test(trimmed)) return null;
- if (/\son[a-z]+\s*=/i.test(trimmed)) return null;
- if (/<\?|<!ENTITY/i.test(trimmed)) return null;
-
- const openEnd = trimmed.indexOf(">");
- if (openEnd < 0) return null;
- let opening = trimmed.slice(0, openEnd);
- let body = trimmed.slice(openEnd);
-
- if (!/\sviewBox\s*=\s*"/i.test(opening)) return null;
-
- opening = opening.replace(/\s(width|height)\s*=\s*"[^"]*"/gi, "");
- opening = opening.replace(/\s(width|height)\s*=\s*'[^']*'/gi, "");
-
- const colours = new Set<string>();
- const count = (v: string) => {
- if (!KEPT_PAINT.test(v.trim())) colours.add(paintKey(v));
- return v;
- };
- mapTagFills(opening, count);
- mapChildFills(body, count);
- if (colours.size <= 1) {
- const themed = (v: string) => (KEPT_PAINT.test(v.trim()) ? v : "currentColor");
- opening = mapTagFills(opening, themed);
- body = mapChildFills(body, themed);
- }
-
- if (!/\sfill\s*=/i.test(opening)) {
- opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
- }
- if (!/\saria-hidden\s*=/i.test(opening)) {
- opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
- }
- return opening + body;
-}
-
-// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its
-// wrapper. The cost is that a viewBox-only <svg> has no intrinsic size, so before
-// the stylesheet loads on a static host it paints at the replaced-element default
-// (huge) — the "flash of giant social icons" FOUC. sizeSocialSvg() re-injects an
-// intrinsic pixel size at RENDER time (existing site.json files already have the
-// attributes stripped, so this must run on read, not just on write). The size is
-// an *attribute*, not inline style, so a wrapper's `w-*`/`h-*` utilities still win
-// once CSS loads — it only governs the pre-CSS first paint.
-export function sizeSocialSvg(svg: string, px = 20): string {
- if (typeof svg !== "string") return svg;
- if (/^<svg[^>]*\swidth\s*=/i.test(svg)) return svg; // already sized
- return svg.replace(/^<svg\b/i, `<svg width="${px}" height="${px}"`);
-}
+// The render-time half (sizing, id scoping, the header's selection, the
+// render-time check) lives in lib/socialLinks.ts, which imports only the pure
+// socialSvg.ts, so a client tree can use it. Re-exported here for the importers
+// that reach it through lib/settings.
+export { sizeSocialSvg } from "./socialLinks";
export function clampSleepBetweenDownloadsSeconds(value: unknown): number {
const n =
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -6,11 +6,11 @@ import { TAGS_FILENAME } from "./curatedTags";
import type { SiteChannelIndex } from "./channelPriority";
import {
getSettings,
- normalizeSocialSvg,
parseSocialLinks,
type SiteSettings,
type SocialLink,
} from "./settings";
+import { socialLinksForSave } from "./socialLinks";
import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server";
import {
isValidSiteId,
@@ -211,6 +211,16 @@ export function defaultSiteId(paths: Paths = getPaths()): string | null {
return ids.length === 1 ? ids[0] : null;
}
+// The social links a JSON file on disk holds now (shape-checked only), or none.
+function storedSocialLinks(file: string): SocialLink[] {
+ try {
+ const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { socialLinks?: unknown };
+ return parseSocialLinks(raw?.socialLinks);
+ } catch {
+ return [];
+ }
+}
+
export async function writeSite(
site: Site,
paths: Paths = getPaths(),
@@ -230,16 +240,18 @@ export async function writeSite(
}
// undefined socialLinks = inherit the global default; only validate/persist a
// key when the site explicitly overrides (an array, even empty).
+ // A link whose SVG is unchanged from the file on disk is kept as it is; a new
+ // or edited one is checked (lib/socialLinks.ts socialLinksForSave).
let socialLinks: SocialLink[] | undefined;
if (site.socialLinks !== undefined) {
- socialLinks = [];
- for (const link of parseSocialLinks(site.socialLinks)) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- throw new Error(`Social link "${link.label}" has an invalid SVG`);
- }
- socialLinks.push({ ...link, svg });
+ const r = socialLinksForSave(
+ parseSocialLinks(site.socialLinks),
+ storedSocialLinks(siteConfigFile(paths, site.siteId)),
+ );
+ if ("refused" in r) {
+ throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`);
}
+ socialLinks = r.links;
}
await writeJsonAtomic(
siteConfigFile(paths, site.siteId),
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -124,8 +124,17 @@ export function fittedHex(accent: unknown): string | undefined {
return hex ? perBaseColor(resolveAccent(hex)) : undefined;
}
+// A site's OWN accent as a CSS colour on the base in force — a named accent's
+// swatch, or a custom hex fitted to each base — or undefined for a site with
+// none. Both are text colours: ≥ 4.5:1 on each ground, and above 4:1 on the
+// homepage's card surface, whose instance cards tint the wordmark's lead with
+// it.
+export function siteAccentColor(site: SiteColorSource): string | undefined {
+ if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`;
+ return fittedHex(site.accent);
+}
+
// A site's mark colour; `chart` is its siteChartColors entry.
export function siteColor(site: SiteColorSource, chart: string): string {
- if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`;
- return fittedHex(site.accent) ?? chart;
+ return siteAccentColor(site) ?? chart;
}
diff --git a/common/lib/socialLinks.test.ts b/common/lib/socialLinks.test.ts
@@ -0,0 +1,174 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ HEADER_SOCIAL_LINKS_MAX,
+ headerSocialLinks,
+ safeSocialSvg,
+ scopeSvgIds,
+ sizeSocialSvg,
+ socialLinksForSave,
+} from "./socialLinks";
+import { normalizeSocialSvg, parseSocialLinks, type SocialLink } from "./settingsSchema";
+
+const SVG = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24"><path d="M1 1h2"/></svg>`;
+const link = (label: string, featured?: boolean): SocialLink => ({
+ label,
+ url: `https://${label.toLowerCase()}.example`,
+ svg: SVG,
+ ...(featured ? { featured: true } : {}),
+});
+const labels = (links: SocialLink[]) => links.map((l) => l.label);
+
+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"]);
+ assert.deepEqual(
+ labels(headerSocialLinks([link("A"), link("B"), link("C"), link("D")])),
+ ["A", "B", "C", "D"],
+ );
+});
+
+test("header: 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"]);
+});
+
+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("header: 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"]);
+});
+
+test("header: the input is not changed", () => {
+ const six = ["A", "B", "C", "D", "E", "F"].map((l) => link(l));
+ headerSocialLinks(six);
+ assert.equal(six.length, 6);
+});
+
+test("parseSocialLinks: featured is kept only when exactly true", () => {
+ const parsed = parseSocialLinks([
+ { label: "A", url: "https://a.example", svg: SVG, featured: true },
+ { label: "B", url: "https://b.example", svg: SVG, featured: false },
+ { label: "C", url: "https://c.example", svg: SVG, featured: "yes" },
+ { label: "D", url: "https://d.example", svg: SVG },
+ ]);
+ assert.deepEqual(parsed[0], { label: "A", url: "https://a.example", svg: SVG, featured: true });
+ for (const l of parsed.slice(1)) assert.ok(!("featured" in l), l.label);
+ // An old file — no link marked — parses exactly as it did before the key.
+ const old = [{ label: "A", url: "https://a.example", svg: SVG }];
+ assert.deepEqual(parseSocialLinks(old), old);
+ assert.deepEqual(JSON.parse(JSON.stringify(parseSocialLinks(old))), old);
+});
+
+test("sizeSocialSvg: an intrinsic size on an unsized root, never a second one", () => {
+ assert.ok(sizeSocialSvg(SVG).startsWith('<svg width="20" height="20" aria-hidden'));
+ const sized = `<svg width="8" height="8" viewBox="0 0 8 8"></svg>`;
+ assert.equal(sizeSocialSvg(sized), sized);
+ assert.ok(sizeSocialSvg(SVG, 36).startsWith('<svg width="36" height="36"'));
+});
+
+// A gradient, a clip and a mask, referenced every way an icon references one.
+const REFS =
+ `<svg id="root" viewBox="0 0 8 8"><defs>` +
+ `<linearGradient id="g"><stop offset="0" style="stop-color:#D7DF23"/></linearGradient>` +
+ `<linearGradient id="g2" xlink:href="#g"/>` +
+ `<clipPath id='c'><rect/></clipPath><mask id="m"><rect fill="white"/></mask></defs>` +
+ `<path fill="url(#g)" style="fill:url( '#g2' )" clip-path="url(#c)" mask="url(#m)"/>` +
+ `<use href="#g2"/><a href="#top"/><path fill="url(#gx)"/></svg>`;
+
+test("scopeSvgIds: every id and every reference to one gains the scope", () => {
+ const out = scopeSvgIds(REFS, "s1-0");
+ for (const id of ["root", "g", "g2", "m"]) assert.ok(out.includes(`id="s1-0-${id}"`), id);
+ assert.ok(out.includes(`id='s1-0-c'`));
+ assert.ok(out.includes(`fill="url(#s1-0-g)"`));
+ assert.ok(out.includes(`style="fill:url('#s1-0-g2')"`));
+ assert.ok(out.includes(`clip-path="url(#s1-0-c)"`));
+ assert.ok(out.includes(`mask="url(#s1-0-m)"`));
+ assert.ok(out.includes(`xlink:href="#s1-0-g"`));
+ assert.ok(out.includes(`<use href="#s1-0-g2"/>`));
+ // Not an id of this icon: left alone. So is a colour that looks like one.
+ assert.ok(out.includes(`<a href="#top"/>`));
+ assert.ok(out.includes(`fill="url(#gx)"`));
+ assert.ok(out.includes(`stop-color:#D7DF23`));
+});
+
+test("scopeSvgIds: two scopes of one icon share no id; no ids → unchanged", () => {
+ const ids = (svg: string) => [...svg.matchAll(/\sid=["']([^"']+)["']/g)].map((m) => m[1]);
+ const a = ids(scopeSvgIds(REFS, "a"));
+ const b = ids(scopeSvgIds(REFS, "b"));
+ assert.equal(a.length, 5);
+ assert.ok(a.every((id) => !b.includes(id)));
+ assert.equal(scopeSvgIds(SVG, "a"), SVG);
+});
+
+test("scopeSvgIds: entity-quoted and case-varied references are scoped with their id", () => {
+ const svg =
+ `<svg viewBox="0 0 8 8"><defs><linearGradient ID="g"/></defs>` +
+ `<rect mask="url("#g")" fill="url('#g')"/><use HREF="#g"/></svg>`;
+ const out = scopeSvgIds(svg, "s");
+ assert.ok(out.includes(`ID="s-g"`));
+ assert.ok(out.includes(`mask="url("#s-g")"`));
+ assert.ok(out.includes(`fill="url('#s-g')"`));
+ assert.ok(out.includes(`HREF="#s-g"`));
+});
+
+test("scopeSvgIds: an id that is not a plain name is left alone, so no comment can close early", () => {
+ for (const id of ["->", "-!>"]) {
+ const svg = `<svg viewBox="0 0 8 8"><!-- <path id="${id}"/> <image href="x"/> --></svg>`;
+ assert.equal(scopeSvgIds(svg, "s"), svg, id);
+ }
+});
+
+// Every icon the checker accepts, scoped as a page renders it, still passes the
+// checker: the render never makes markup the save would have refused.
+test("a scoped and sized icon still passes the checker", () => {
+ for (const raw of [
+ `<svg viewBox="0 0 24 24"><defs><linearGradient id="g_1"><stop offset="0" stop-color="#e0a030"/></linearGradient></defs><path fill="url(#g_1)" style="fill:url("#g_1")" d="M0 0"/><use href="#g_1"/></svg>`,
+ `<svg viewBox="0 0 8 8"><clipPath id="c.1"><rect/></clipPath><g clip-path="url(#c.1)"><path fill="#fff" d="M0 0"/></g></svg>`,
+ ]) {
+ const stored = normalizeSocialSvg(raw);
+ assert.ok(stored, raw.slice(0, 60));
+ const rendered = sizeSocialSvg(scopeSvgIds(stored, "sl_S_1_-0"));
+ assert.equal(normalizeSocialSvg(rendered)?.includes("sl_S_1_-0-"), true);
+ }
+});
+
+test("safeSocialSvg: a stored icon that fails the check is not rendered", () => {
+ assert.equal(safeSocialSvg(SVG), SVG);
+ for (const bad of [
+ `<svg/onload="window.__x=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"><a href="javascript:x()"><rect/></a></svg>`,
+ `<svg viewBox="0 0 8 8"><script>x()</script></svg>`,
+ "not an svg",
+ 42,
+ undefined,
+ ]) {
+ assert.equal(safeSocialSvg(bad), null, String(bad).slice(0, 40));
+ }
+});
+
+test("socialLinksForSave: an unchanged stored icon is kept as it is; a new or edited one is checked", () => {
+ const refusedByNow = `<svg viewBox="0 0 8 8"><style>*{}</style></svg>`;
+ const stored: SocialLink[] = [{ label: "Old", url: "https://old.example", svg: refusedByNow }];
+ // Unchanged (a label or `featured` may change): kept byte-identical.
+ const same = socialLinksForSave([{ ...stored[0], label: "Renamed", featured: true }], stored);
+ assert.deepEqual(same, { links: [{ ...stored[0], label: "Renamed", featured: true }] });
+ // Edited to something refused: the label and the reason, never the markup.
+ const bad = socialLinksForSave([{ ...stored[0], svg: `<svg viewBox="0 0 8 8" onload="x()"></svg>` }], stored);
+ assert.ok("refused" in bad && bad.refused.label === "Old" && /event handler/.test(bad.refused.problem));
+ // New and good: normalized.
+ const fresh = socialLinksForSave([{ label: "New", url: "https://new.example", svg: `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>` }], stored);
+ assert.ok("links" in fresh && fresh.links[0].svg.startsWith('<svg aria-hidden="true" fill="currentColor"'));
+ // Nothing stored: everything is checked.
+ assert.ok("refused" in socialLinksForSave(stored, undefined));
+});
diff --git a/common/lib/socialLinks.ts b/common/lib/socialLinks.ts
@@ -0,0 +1,121 @@
+// THE SOCIAL ROW'S RENDER-TIME RULES — pure (a type, and the pure icon checker
+// socialSvg.ts), so the shared component (common/components/SocialLinks.tsx) is
+// safe in a server or a client tree. The stored shape is in settingsSchema.ts
+// (`SocialLink`, `parseSocialLinks`).
+
+import type { SocialLink } from "./settingsSchema";
+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.
+export function headerSocialLinks<T extends Pick<SocialLink, "featured">>(
+ links: readonly T[],
+): T[] {
+ const featured = links.filter((link) => link.featured === true);
+ const pool = featured.length > 0 ? featured : links;
+ return pool.slice(-HEADER_SOCIAL_LINKS_MAX);
+}
+
+// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its
+// wrapper. The cost is that a viewBox-only <svg> has no intrinsic size, so before
+// the stylesheet loads on a static host it paints at the replaced-element default
+// (huge) — the "flash of giant social icons" FOUC. sizeSocialSvg() re-injects an
+// intrinsic pixel size at RENDER time (existing site.json files already have the
+// attributes stripped, so this must run on read, not just on write). The size is
+// an *attribute*, not inline style, so a wrapper's `w-*`/`h-*` utilities still win
+// once CSS loads — it only governs the pre-CSS first paint.
+export function sizeSocialSvg(svg: string, px = 20): string {
+ if (typeof svg !== "string") return svg;
+ if (/^<svg[^>]*\swidth\s*=/i.test(svg)) return svg; // already sized
+ return svg.replace(/^<svg\b/i, `<svg width="${px}" height="${px}"`);
+}
+
+// THE READ PATH. A stored icon is inlined only if it passes the save-time
+// check AGAIN (socialSvg.ts): a file edited by hand, written by an older build,
+// or read from another checkout never reaches a page unchecked. The result is
+// the normalized SVG, or null — and a caller then shows the link's label as
+// text instead of an icon. Every inlining goes through here.
+export function safeSocialSvg(svg: unknown): string | null {
+ return typeof svg === "string" ? normalizeSocialSvg(svg) : null;
+}
+
+// THE WRITE PATH, for every writer of a social-link list (settings.json, a
+// site.json, homepage.json, and the editor's two forms). A link whose `svg` is
+// byte-identical to a link already stored is kept exactly as it is — whatever
+// the checker now thinks of it — so a save that did not touch the links (a
+// lane pause, a priority, a title) never fails on an icon an older build
+// stored; the render re-checks it and shows the label if it fails, and
+// `archilyzer doctor` names it. Every new or edited icon is normalized, or the
+// save is refused with the link's label and the reason.
+export function socialLinksForSave(
+ next: readonly SocialLink[],
+ stored: readonly SocialLink[] | undefined,
+): { links: SocialLink[] } | { refused: { label: string; problem: string } } {
+ const kept = new Set((stored ?? []).map((l) => l.svg));
+ const links: SocialLink[] = [];
+ for (const link of next) {
+ if (kept.has(link.svg)) {
+ links.push({ ...link });
+ continue;
+ }
+ const svg = normalizeSocialSvg(link.svg);
+ if (svg === null) {
+ return { refused: { label: link.label, problem: socialSvgProblem(link.svg) ?? "it is refused" } };
+ }
+ links.push({ ...link, svg });
+ }
+ return { links };
+}
+
+const ID_ATTR = /\sid\s*=\s*(?:"([^"]+)"|'([^']+)')/gi;
+
+function escapeRegExp(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// Every `id` inside one inlined icon, and every reference to one (`url(#…)` in
+// an attribute or a style, `href="#…"`, `xlink:href="#…"`), prefixed with
+// `scope`. The same icon is inlined more than once on a page (a header and a
+// footer, and a header renders one row per breakpoint), and an id resolves to
+// the FIRST element that carries it: when that copy is `display: none`, a
+// gradient, mask or clip defined in it does not paint, and every visible copy
+// that points at it loses that part of the icon. Scoping each copy makes each
+// self-contained. The stored SVG is not changed; this runs at render.
+export function scopeSvgIds(svg: string, scope: string): string {
+ if (typeof svg !== "string") return svg;
+ // Only a plain name is prefixed (the checker refuses any other id), so the
+ // prefix can never complete a `-->` or change the markup's structure.
+ const ids = new Set<string>();
+ for (const m of svg.matchAll(ID_ATTR)) {
+ const id = m[1] ?? m[2];
+ if (SVG_ID_RE.test(id)) ids.add(id);
+ }
+ if (ids.size === 0) return svg;
+ // Longest first, so an id that is a prefix of another never wins its match.
+ const alt = [...ids]
+ .sort((a, b) => b.length - a.length)
+ .map(escapeRegExp)
+ .join("|");
+ // A reference's quote may be a character reference (`url("#a")`).
+ const Q = `["']|"|'|"|'|"|'`;
+ return svg
+ .replace(
+ new RegExp(`(\\sid\\s*=\\s*)(["'])(${alt})\\2`, "gi"),
+ (_m, pre: string, q: string, id: string) => `${pre}${q}${scope}-${id}${q}`,
+ )
+ .replace(
+ new RegExp(`url\\(\\s*(${Q})?#(${alt})\\1\\s*\\)`, "g"),
+ (_m, q: string | undefined, id: string) => `url(${q ?? ""}#${scope}-${id}${q ?? ""})`,
+ )
+ .replace(
+ new RegExp(`(\\s(?:xlink:)?href\\s*=\\s*)(["'])#(${alt})\\2`, "gi"),
+ (_m, pre: string, q: string, id: string) => `${pre}${q}#${scope}-${id}${q}`,
+ );
+}
diff --git a/common/lib/socialSvg.ts b/common/lib/socialSvg.ts
@@ -0,0 +1,481 @@
+// THE SOCIAL ICON'S SVG: what one may contain, and its normalized form.
+//
+// Pure, no imports: settingsSchema.ts (every save of a social link — Settings,
+// a site's form, the homepage config) and socialLinks.ts (every render) both run
+// it, so a stored icon is checked again each time it is inlined into a page.
+//
+// AN ALLOWLIST, TOKENIZED. An icon is inlined into every header and footer, and
+// an operator pastes it from anywhere, so a text denylist is not enough: `/`
+// separates attributes as well as whitespace does, a character reference spells
+// `javascript:`, and a transform can join two fragments into a handler. The
+// input is read once, tag by tag, and refused unless:
+// - it is ONE well-formed <svg> element: every tag either self-closes or is
+// closed in order, attributes are separated by HTML whitespace and quoted,
+// and there is no other markup (no <!…> declaration, CDATA or processing
+// instruction; an XML declaration and a DOCTYPE with no internal subset at
+// the very start, and every comment, are removed first);
+// - every element is on ELEMENTS (shapes, groups, gradients, clips, masks,
+// filters, text, and the three animation elements) — so no script,
+// foreignObject, style, a, image, iframe, object, embed, audio or video,
+// none of the HTML elements that break out of SVG, and no title or desc:
+// those two are HTML integration points, where the HTML parser reads a
+// child as HTML and can leave the icon unclosed around the rest of the
+// page. A title or desc holding text only is removed before the check
+// (the link's aria-label names the icon; the root is aria-hidden); one
+// with a child element is refused;
+// - every attribute is on ATTRIBUTES (the SVG presentation, geometry, filter
+// and animation set, plus aria-*, data-* and xmlns:*), and none is an event
+// handler (a name starting with "on");
+// - after decoding character references (numeric and named, with or without
+// the `;`) and dropping the whitespace a browser ignores in a URL, no value
+// holds `javascript:` or `vbscript:`, a backslash (a CSS escape), a CSS
+// comment, `@import`, `expression(`, or a function that can load
+// something (`image-set(`, `-webkit-image-set(`, `image(`, `cross-fade(`,
+// `-webkit-cross-fade(`, `element(`, `-moz-element(`, `src(`, `paint(`);
+// every `url(…)` points at a fragment of this icon, spelled plainly (a
+// quote may be a character reference); an `href` / `xlink:href` is a
+// plain fragment (`#id`, no reference, no space) — so every reference is
+// one scopeSvgIds rewrites; a `style` holds only presentation properties
+// (STYLE_PROPERTIES); an animation never targets anything named `href` or
+// a handler. The same rules cover an animation's to/from/values/by;
+// - every id is a plain name (`^[A-Za-z_][\w.:-]*$`), so a rendered copy can
+// prefix it safely (socialLinks.ts scopeSvgIds).
+// The OUTPUT of the normalization below is checked again the same way, so no
+// transform can assemble what the input check refused.
+
+const ELEMENTS = new Set(
+ [
+ "svg", "g", "defs", "symbol", "use",
+ "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
+ "text", "tspan",
+ "linearGradient", "radialGradient", "stop", "pattern", "clipPath", "mask",
+ "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite",
+ "feDropShadow", "feFlood", "feFuncA", "feFuncB", "feFuncG", "feFuncR",
+ "feGaussianBlur", "feMerge", "feMergeNode", "feMorphology", "feOffset",
+ "animate", "animateTransform", "set",
+ ].map((e) => e.toLowerCase()),
+);
+
+const ANIMATION = new Set(["animate", "animatetransform", "set"]);
+
+const ATTRIBUTES = new Set(
+ [
+ // core
+ "id", "class", "style", "lang", "xml:lang", "xml:space", "xmlns", "version",
+ "baseProfile", "role", "focusable",
+ // geometry
+ "viewBox", "preserveAspectRatio", "width", "height", "x", "y", "x1", "y1",
+ "x2", "y2", "cx", "cy", "r", "rx", "ry", "fx", "fy", "fr", "d", "points",
+ "pathLength", "transform", "transform-origin",
+ // paint and presentation
+ "fill", "fill-opacity", "fill-rule", "clip-rule", "clip-path", "clipPathUnits",
+ "mask", "maskUnits", "maskContentUnits", "filter", "filterUnits",
+ "primitiveUnits", "stroke", "stroke-width", "stroke-linecap",
+ "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
+ "stroke-dashoffset", "stroke-opacity", "opacity", "color", "display",
+ "visibility", "overflow", "shape-rendering", "text-rendering",
+ "image-rendering", "color-interpolation", "color-interpolation-filters",
+ "color-rendering", "vector-effect", "paint-order", "mix-blend-mode",
+ "isolation", "enable-background",
+ // gradients and patterns
+ "stop-color", "stop-opacity", "offset", "gradientUnits", "gradientTransform",
+ "spreadMethod", "patternUnits", "patternContentUnits", "patternTransform",
+ // text
+ "font-family", "font-size", "font-weight", "font-style", "font-variant",
+ "font-stretch", "text-anchor", "dominant-baseline", "alignment-baseline",
+ "baseline-shift", "letter-spacing", "word-spacing", "text-decoration",
+ "writing-mode", "dx", "dy", "rotate", "textLength", "lengthAdjust",
+ // filter primitives
+ "in", "in2", "result", "stdDeviation", "flood-color", "flood-opacity",
+ "lighting-color", "operator", "k1", "k2", "k3", "k4", "mode", "values",
+ "type", "tableValues", "slope", "intercept", "amplitude", "exponent",
+ "radius", "edgeMode",
+ // animation
+ "attributeName", "attributeType", "from", "to", "by", "dur", "begin", "end",
+ "repeatCount", "repeatDur", "calcMode", "keyTimes", "keySplines",
+ "additive", "accumulate", "restart", "min", "max",
+ // references — a fragment of this icon only (checked below)
+ "href", "xlink:href",
+ ].map((a) => a.toLowerCase()),
+);
+
+const ATTRIBUTE_PATTERNS = [/^aria-[a-z-]+$/, /^data-[a-z0-9_.-]+$/, /^xmlns:[a-z][a-z0-9_.-]*$/];
+
+// An id a rendered copy can prefix (socialLinks.ts): a plain name.
+export const SVG_ID_RE = /^[A-Za-z_][\w.:-]*$/;
+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.
+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)";
+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",
+ handler: "it has an event handler attribute",
+ script: "it has a script, or a link that runs one",
+ external: "it links to something outside the icon",
+ 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}`,
+} as const;
+
+// The only properties a `style` attribute may set: presentation, never layout
+// or position (an icon that paints outside its key, or over the page, is
+// refused).
+const STYLE_PROPERTIES = new Set([
+ "fill", "stroke", "stop-color", "stop-opacity", "opacity", "fill-opacity",
+ "stroke-opacity", "stroke-width", "stroke-linecap", "stroke-linejoin",
+ "fill-rule", "clip-rule", "display", "visibility", "paint-order",
+]);
+
+// CSS functions that can load something, whatever attribute or style holds them.
+const LOADING_FUNCTIONS =
+ /(?:-webkit-)?image-set\(|(?:^|[^a-z-])image\(|(?:-webkit-)?cross-fade\(|(?:-moz-)?element\(|(?:^|[^a-z-])src\(|paint\(|@import|expression\(/i;
+
+// A url(…) as scopeSvgIds rewrites it: a plain fragment, its quote literal or a
+// character reference.
+const RAW_URL_FRAGMENT = /url\(\s*(["']|"|'|"|'|"|')?#[A-Za-z_][\w.:-]*\1\s*\)/gi;
+
+// HTML whitespace, the only attribute separator this reads (a `/` between
+// attributes, which HTML also takes, is refused as markup).
+const WS = "[\\t\\n\\f\\r ]";
+const TAG_RE = new RegExp(
+ `<(\\/?)([A-Za-z][A-Za-z0-9_.:-]*)((?:${WS}+[^\\t\\n\\f\\r "'<>\\/=]+(?:${WS}*=${WS}*(?:"[^"]*"|'[^']*'))?)*)(${WS}*)(\\/?)>`,
+ "y",
+);
+const ATTR_RE = new RegExp(
+ `(${WS}+)([^\\t\\n\\f\\r "'<>\\/=]+)(?:${WS}*=${WS}*(?:"([^"]*)"|'([^']*)'))?`,
+ "g",
+);
+
+type Attr = { name: string; value: string; raw: string };
+type Tag = {
+ close: boolean;
+ name: string;
+ attrs: Attr[];
+ trailing: string; // the whitespace before `>` / `/>`
+ selfClose: boolean;
+ start: number;
+ end: number; // index just past `>`
+};
+
+// Character references a browser decodes in an attribute value, so that the
+// checks see what the browser will: numeric (decimal, hex) and named, with or
+// without the `;`. Named ones are matched case-insensitively — decoding MORE
+// than a browser would only makes the checks stricter.
+const NAMED_REFS: Record<string, string> = {
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", colon: ":", tab: "\t",
+ newline: "\n", nbsp: "\u00a0", lpar: "(", rpar: ")", sol: "/", bsol: "\\",
+ num: "#", period: ".", excl: "!", semi: ";", comma: ",", equals: "=",
+ plus: "+", dollar: "$", percnt: "%", ast: "*", lowbar: "_", hyphen: "-",
+ dash: "-", quest: "?", commat: "@", lsqb: "[", rsqb: "]", lcub: "{",
+ rcub: "}", verbar: "|", grave: "`", hat: "^",
+};
+
+export function decodeCharRefs(value: string): string {
+ return value.replace(
+ /&(#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);?/g,
+ (m, ref: string) => {
+ if (ref[0] === "#") {
+ const hex = ref[1] === "x" || ref[1] === "X";
+ const cp = hex ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
+ return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "\ufffd";
+ }
+ return NAMED_REFS[ref.toLowerCase()] ?? m;
+ },
+ );
+}
+
+// Everything a browser ignores inside a URL, and case.
+const squash = (v: string) => v.replace(/[\u0000-\u0020\u007f-\u00a0]+/g, "");
+
+function attrProblem(element: string, a: Attr): string | null {
+ const name = a.name.toLowerCase();
+ if (name.startsWith("on")) return SVG_PROBLEM.handler;
+ if (!ATTRIBUTES.has(name) && !ATTRIBUTE_PATTERNS.some((p) => p.test(name))) {
+ return SVG_PROBLEM.attribute(a.name);
+ }
+ const decoded = decodeCharRefs(a.value);
+ const tight = squash(decoded);
+ const lower = tight.toLowerCase();
+ if (lower.includes("javascript:") || lower.includes("vbscript:") || lower.includes("livescript:")) {
+ return SVG_PROBLEM.script;
+ }
+ // A CSS escape can spell `url(` so no check sees it; a comment can split a
+ // function name. Neither has a place in an icon's value.
+ if (decoded.includes("\\") || decoded.includes("/*")) {
+ return name === "style" ? SVG_PROBLEM.style : SVG_PROBLEM.external;
+ }
+ if (LOADING_FUNCTIONS.test(tight)) return SVG_PROBLEM.external;
+ if (name === "href" || name === "xlink:href") {
+ // Plain, as written: `#a` or ` #a ` would name an id the render's
+ // scoping cannot see.
+ if (!FRAGMENT_RE.test(a.value)) return SVG_PROBLEM.external;
+ }
+ // Every url(…), wherever it is (a fill, a clip, a style): a fragment here,
+ // and every one of them spelled so scopeSvgIds rewrites it.
+ const urls = [...tight.matchAll(/url\(/gi)];
+ for (const m of urls) {
+ const rest = tight.slice(m.index);
+ if (!/^url\((["']?)#[A-Za-z_][\w.:-]*\1\)/i.test(rest)) return SVG_PROBLEM.external;
+ }
+ if (urls.length > 0 && [...a.value.matchAll(RAW_URL_FRAGMENT)].length !== urls.length) {
+ return SVG_PROBLEM.external;
+ }
+ if (name === "style") {
+ for (const decl of decoded.split(";")) {
+ if (!decl.trim()) continue;
+ const colon = decl.indexOf(":");
+ const prop = (colon < 0 ? decl : decl.slice(0, colon)).trim().toLowerCase();
+ if (colon < 0 || !STYLE_PROPERTIES.has(prop)) return SVG_PROBLEM.style;
+ }
+ }
+ if (name === "id" && !SVG_ID_RE.test(a.value)) return SVG_PROBLEM.id;
+ if (ANIMATION.has(element) && name === "attributename") {
+ const target = lower.trim();
+ if (target.includes("href")) return SVG_PROBLEM.external;
+ if (/(^|:)on/.test(target)) return SVG_PROBLEM.handler;
+ }
+ return null;
+}
+
+// One pass over the markup: its tags, or the first problem.
+function scan(src: string): { tags: Tag[] } | { problem: string } {
+ const tags: Tag[] = [];
+ const stack: string[] = [];
+ let i = 0;
+ while (i < src.length) {
+ const lt = src.indexOf("<", i);
+ if (lt < 0) {
+ // Only whitespace may follow the root's closing tag.
+ return stack.length === 0 && tags.length > 0 && !src.slice(i).trim()
+ ? { tags }
+ : { problem: SVG_PROBLEM.markup };
+ }
+ if (stack.length === 0 && tags.length > 0) return { problem: SVG_PROBLEM.markup };
+ if (stack.length === 0 && src.slice(i, lt).trim()) return { problem: SVG_PROBLEM.markup };
+ if (src.startsWith("<!", lt) || src.startsWith("<?", lt)) return { problem: SVG_PROBLEM.markup };
+ TAG_RE.lastIndex = lt;
+ const m = TAG_RE.exec(src);
+ if (!m) return { problem: SVG_PROBLEM.markup };
+ const [whole, slash, name, attrText, trailing, selfSlash] = m;
+ const close = slash === "/";
+ const lname = name.toLowerCase();
+ if (close && (attrText || selfSlash)) return { problem: SVG_PROBLEM.markup };
+ if (!close) {
+ if (lname === "script") return { problem: SVG_PROBLEM.script };
+ if (!ELEMENTS.has(lname)) return { problem: SVG_PROBLEM.element(name) };
+ if (tags.length === 0 && lname !== "svg") return { problem: SVG_PROBLEM.markup };
+ }
+ const attrs: Attr[] = [];
+ for (const a of attrText.matchAll(ATTR_RE)) {
+ attrs.push({ name: a[2], value: a[3] ?? a[4] ?? "", raw: a[0] });
+ }
+ for (const a of attrs) {
+ const p = attrProblem(lname, a);
+ if (p) return { problem: p };
+ }
+ if (close) {
+ if (stack.pop() !== lname) return { problem: SVG_PROBLEM.markup };
+ } else if (selfSlash !== "/") {
+ stack.push(lname);
+ }
+ tags.push({
+ close,
+ name,
+ attrs,
+ trailing,
+ selfClose: selfSlash === "/",
+ start: lt,
+ end: lt + whole.length,
+ });
+ i = lt + whole.length;
+ }
+ return stack.length === 0 && tags.length > 0 ? { tags } : { problem: SVG_PROBLEM.markup };
+}
+
+// What a pasted icon looks like before it is read: trimmed, with an XML
+// declaration and a DOCTYPE (with no internal subset) removed from the very
+// start, and every comment removed.
+function prepare(raw: string): string {
+ return raw
+ .trim()
+ .replace(/^<\?xml\b[^>]*\?>\s*/i, "")
+ .replace(/^<!DOCTYPE\s+svg\b[^>[]*>\s*/i, "")
+ .replace(/<!--[\s\S]*?-->/g, "")
+ .replace(TEXT_ONLY_TITLE, "")
+ .trim();
+}
+
+// A <title> or <desc> holding text only (or nothing), removed before the
+// check: see the header. One with a child element stays, and is refused.
+const TEXT_ONLY_TITLE = /<(title|desc)\b[^<>]*?(?:\/>|>[^<]*<\/\1\s*>)/gi;
+
+// A paint that is not a colour: no paint (`none`, `transparent`), a paint
+// server (a gradient or pattern, `url(#…)`, with or without a fallback), or
+// what the element takes from its parent already. Never counted, never swapped.
+const KEPT_PAINT = /^(?:none|transparent|currentcolor|inherit)$|^url\(/i;
+
+// One solid colour however it is spelled, so `#FFF`, `#ffffff`, `white` and
+// `rgb(255, 255, 255)` count as ONE colour of an icon, not four.
+function paintKey(value: string): string {
+ const v = value.trim().toLowerCase().replace(/\s+/g, "");
+ if (v === "white") return "#ffffff";
+ if (v === "black") return "#000000";
+ const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(v);
+ if (short) return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
+ const rgb = /^rgb\((\d{1,3}),(\d{1,3}),(\d{1,3})\)$/.exec(v);
+ if (rgb) {
+ return `#${rgb
+ .slice(1, 4)
+ .map((n) => Math.min(255, Number(n)).toString(16).padStart(2, "0"))
+ .join("")}`;
+ }
+ return v;
+}
+
+// Every fill of one tag, mapped: its `fill` attribute and any `fill:`
+// declaration in its `style` (which beats the attribute). `fill-rule`,
+// `fill-opacity`, strokes and gradient stops are not fills and are left alone.
+function mapTagFills(tag: string, paint: (value: string) => string): string {
+ return tag
+ .replace(
+ /(\sfill\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) =>
+ dq !== undefined ? `${pre}"${paint(dq)}"` : `${pre}'${paint(sq ?? "")}'`,
+ )
+ .replace(
+ /(\sstyle\s*=\s*)(?:"([^"]*)"|'([^']*)')/gi,
+ (_m, pre: string, dq?: string, sq?: string) => {
+ const css = (dq ?? sq ?? "").replace(
+ /(^|;)(\s*fill\s*:\s*)([^;]*)/gi,
+ (_d, lead: string, prop: string, v: string) => `${lead}${prop}${paint(v)}`,
+ );
+ return dq !== undefined ? `${pre}"${css}"` : `${pre}'${css}'`;
+ },
+ );
+}
+
+// The children, tag by tag. Passed over whole, never read or changed:
+// - a <mask> (its white and black say how much shows through, not what
+// colour) and a <clipPath> (a clip never paints: only its shape counts —
+// Figma exports almost every icon as a path clipped by a
+// `<clipPath><rect fill="white"/></clipPath>`, release 11, O2b) —
+// self-closing first, so an empty `<mask …/>` or `<clipPath …/>` cannot
+// swallow everything up to the next closing tag;
+// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing.
+const CHILD_TAG =
+ /<(?:mask|clipPath)\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<clipPath\b[\s\S]*?<\/clipPath\s*>|<[a-zA-Z][^>]*>/gi;
+const PASSED_OVER = /^<(?:mask|clipPath|animate\w*|set)\b/i;
+
+function mapChildFills(body: string, paint: (value: string) => string): string {
+ return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint)));
+}
+
+// A root's own size as a viewBox — `0 0 W H` — when its `width` and `height`
+// attributes are both positive numbers, unitless or in px. Anything else (a
+// percentage, `em`, a missing or zero side) is no size.
+const SVG_LENGTH_PX = /^\s*(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?\s*$/i;
+
+function viewBoxFromSize(attrs: readonly Attr[]): string | null {
+ const side = (name: "width" | "height"): string | null => {
+ const a = attrs.find((x) => x.name.toLowerCase() === name);
+ const len = a ? SVG_LENGTH_PX.exec(a.value) : null;
+ const n = len ? Number(len[1]) : NaN;
+ return Number.isFinite(n) && n > 0 ? String(n) : null;
+ };
+ const w = side("width");
+ const h = side("height");
+ return w && h ? `0 0 ${w} ${h}` : null;
+}
+
+// Why a pasted icon cannot be stored, as one of SVG_PROBLEM's sentences, or
+// null when normalizeSocialSvg accepts it.
+export function socialSvgProblem(raw: unknown): string | null {
+ if (typeof raw !== "string") return SVG_PROBLEM.markup;
+ return normalize(raw).problem;
+}
+
+// Normalize an admin-provided SVG snippet for inline use in a header or a
+// footer. Returns null on anything the checks above refuse, or that has no
+// viewBox to scale by. Steps: read and check (above); give a root with no
+// viewBox, but a numeric width and height (unitless or px), `viewBox="0 0 W
+// H"` from them — a vendor's file pasted as downloaded often carries only its
+// size — then strip width/height; theme a single-colour icon; add
+// aria-hidden; check the result again.
+//
+// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its
+// children's together, outside a <mask> or <clipPath> — hold at most one solid
+// colour, each becomes `currentColor`, the link's colour. Drawn for one
+// background, such an icon vanishes on another (X's official logo is a `<path
+// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours
+// draws its shape with them (YouTube's mark is a red rounded rectangle with a
+// white play triangle; flattened, it is a blank rectangle), carries its own
+// contrast, and keeps every colour as pasted. Paints that are not colours
+// (`none`, `url(…)`, `inherit`) are never counted or changed. Idempotent: a
+// themed icon has no solid colour left.
+export function normalizeSocialSvg(raw: string): string | null {
+ if (typeof raw !== "string") return null;
+ return normalize(raw).svg;
+}
+
+function normalize(raw: string): { svg: string | null; problem: string | null } {
+ const refuse = (problem: string) => ({ svg: null, problem });
+ const src = prepare(raw);
+ if (/^<!DOCTYPE\b/i.test(src)) return refuse(SVG_PROBLEM.doctype);
+ if (!src.startsWith("<svg") || !src.endsWith("</svg>")) return refuse(SVG_PROBLEM.markup);
+ const scanned = scan(src);
+ if ("problem" in scanned) return refuse(scanned.problem);
+ const root = scanned.tags[0];
+
+ // The root's opening tag, rebuilt from its own attributes (each exactly as
+ // written), so a size read from inside another attribute's value can never
+ // count, and stripping width/height removes those attributes and nothing else.
+ let attrs = root.attrs;
+ if (!attrs.some((a) => a.name.toLowerCase() === "viewbox")) {
+ const box = viewBoxFromSize(attrs);
+ if (box) attrs = [{ name: "viewBox", value: box, raw: ` viewBox="${box}"` }, ...attrs];
+ }
+ // A viewBox in single quotes has always been refused; it still is.
+ if (!attrs.some((a) => a.name.toLowerCase() === "viewbox" && /^\s+viewBox\s*=\s*"/i.test(a.raw))) {
+ return refuse(SVG_PROBLEM.viewBox);
+ }
+ attrs = attrs.filter((a) => !/^(width|height)$/i.test(a.name));
+ let opening = `<${root.name}${attrs.map((a) => a.raw).join("")}${root.trailing}`;
+ let body = src.slice(root.end - 1); // from the root's `>`
+
+ const colours = new Set<string>();
+ const count = (v: string) => {
+ if (!KEPT_PAINT.test(v.trim())) colours.add(paintKey(v));
+ return v;
+ };
+ mapTagFills(opening, count);
+ mapChildFills(body, count);
+ if (colours.size <= 1) {
+ const themed = (v: string) => (KEPT_PAINT.test(v.trim()) ? v : "currentColor");
+ opening = mapTagFills(opening, themed);
+ body = mapChildFills(body, themed);
+ }
+
+ if (!/\sfill\s*=/i.test(opening)) {
+ opening = opening.replace(/^<svg/i, '<svg fill="currentColor"');
+ }
+ if (!/\saria-hidden\s*=/i.test(opening)) {
+ opening = opening.replace(/^<svg/i, '<svg aria-hidden="true"');
+ }
+ const out = opening + body;
+ // The result is read and checked again: no transform above may assemble
+ // what the input check refused.
+ const again = scan(out);
+ if ("problem" in again) return refuse(again.problem);
+ return { svg: out, problem: null };
+}
diff --git a/common/lib/socialSvg.vectors.ts b/common/lib/socialSvg.vectors.ts
@@ -0,0 +1,140 @@
+// TEST VECTORS for the social icon checker (socialSvg.ts) and its render path —
+// test data only, imported by common/lib/normalizeSocialSvg.test.ts and the
+// homepage e2e (svg-vectors.spec.ts, social.spec.ts). Synthetic throughout.
+//
+// ADVERSARIAL: the review's battery (release 14, slice HP): every input the
+// checker must either refuse or render inertly — no script, no request to
+// another origin, and the page parsed around the icon unchanged.
+// LOADS_ELSEWHERE: the eight the first allowlist accepted that made Chromium
+// fetch from another origin (R2); all refused now.
+// REAL_SHAPES: the shapes an operator's pasted icons take; each must pass, or
+// that icon turns into a text label on every site.
+
+const V = `viewBox="0 0 8 8"`;
+const W = (inner: string, open = `<svg ${V}>`) => `${open}${inner}</svg>`;
+export const EVIL = "https://evil.example";
+
+export const ADVERSARIAL: Record<string, string> = {
+ // the five originals
+ slash_handler: `<svg/onload="window.__x=1" ${V}><path d="M0 0"/></svg>`,
+ deletion_join: `<svg ${V} o width="1"nload="window.__x=1"><path d="M0 0"/></svg>`,
+ image_slash: W(`<image href="x:"/onerror="window.__x=1"/>`),
+ charref_js: W(`<a href="javascript:window.__x=1"><rect width="8" height="8"/></a>`),
+ breakout_img: W(`<img src="x:"/onerror="window.__x=1">`),
+ // tokenizer
+ unterminated_tag: `<svg ${V}><path d="M0 0"</svg>`,
+ unterminated_quote: `<svg ${V}><path d="M0 0/></svg>`,
+ attr_no_value: `<svg ${V} focusable><path d="M0 0"/></svg>`,
+ handler_no_value: `<svg ${V} onload><path d="M0 0"/></svg>`,
+ dup_href_first_ok: W(`<defs><linearGradient id="g"/></defs><use href="#g" href="${EVIL}/x.svg#g"/>`),
+ dup_href_first_bad: W(`<defs><linearGradient id="g"/></defs><use href="${EVIL}/x.svg#g" href="#g"/>`),
+ dup_fill: W(`<rect fill="url(#a)" fill="url(${EVIL}/p.svg#a)"/>`),
+ upper_xlink: W(`<use XLINK:HREF="javascript:window.__x=1"/>`),
+ upper_xlink_frag: W(`<defs><g id="a"/></defs><use XLINK:HREF="#a"/>`),
+ xml_base: `<svg ${V} xml:base="${EVIL}/"><use href="#a"/></svg>`,
+ xmlns_redef: `<svg ${V} xmlns:xlink="${EVIL}/ns" xmlns:foo="http://www.w3.org/1999/xlink"><defs><g id="a"/></defs><use foo:href="${EVIL}/x"/></svg>`,
+ xmlns_js: `<svg ${V} xmlns:xlink="javascript:window.__x=1"><path d="M0 0"/></svg>`,
+ nul_in_name: `<svg ${V} on\u0000load="window.__x=1"><path d="M0 0"/></svg>`,
+ nul_in_value: `<svg ${V} data-x="a\u0000b"><path d="M0 0"/></svg>`,
+ vt_separator: `<svg ${V}\u000bonload="window.__x=1"><path d="M0 0"/></svg>`,
+ cr_separator: `<svg ${V}\ronload="window.__x=1"><path d="M0 0"/></svg>`,
+ no_ws_between_attrs: `<svg ${V} data-a="1"onload="window.__x=1"><path d="M0 0"/></svg>`,
+ backtick_value: `<svg ${V} data-x=\`a\`><path d="M0 0"/></svg>`,
+ unquoted_value: `<svg ${V} data-x=a><path d="M0 0"/></svg>`,
+ gt_in_dq_value: `<svg ${V} data-x="><img src=x onerror=window.__x=1>"><path d="M0 0"/></svg>`,
+ gt_in_sq_value: `<svg ${V} data-x='"><img src=x onerror=window.__x=1>'><path d="M0 0"/></svg>`,
+ sq_value_with_fill: `<svg ${V} data-x=' fill="#fff" style="fill:#000"'><path fill="#f00" d="M0 0"/></svg>`,
+ // entities
+ ent_leading_zeros: W(`<rect data-x="javascript:window.__x=1"/>`),
+ ent_hex_nosemi: W(`<rect data-x="ڪvascript:x"/>`),
+ ent_colon_tab_nl: W(`<rect data-x="java	scr
ipt:x"/>`),
+ ent_colon_nosemi: W(`<rect data-x="javascript&colonx"/>`),
+ href_ent_hash: W(`<defs><g id="a"/></defs><use href="#a"/>`),
+ href_ws_frag: W(`<defs><g id="a"/></defs><use href=" #a "/>`),
+ // css url forms
+ style_url_escape: W(`<rect style="fill:\\75 rl(${EVIL}/p.svg#a)"/>`),
+ style_comment_url: W(`<rect style="fill:url/**/(${EVIL}/p.svg#a)"/>`),
+ pres_url_escape_fill: W(`<rect width="8" height="8" fill="\\75 rl(${EVIL}/fill.svg#a)"/>`),
+ pres_url_escape_filter: W(`<rect width="8" height="8" filter="\\75 rl(${EVIL}/filter.svg#a)"/>`),
+ pres_url_escape_mask: W(`<rect width="8" height="8" mask="\\75 rl(${EVIL}/mask.svg#a)"/>`),
+ pres_url_escape_clip: W(`<rect width="8" height="8" clip-path="\\75 rl(${EVIL}/clip.svg#a)"/>`),
+ pres_mask_imageset: W(`<rect width="8" height="8" mask="image-set('${EVIL}/mask-is.png' 1x)"/>`),
+ style_bg_imageset_root: `<svg ${V} style="background-image:image-set('${EVIL}/bg-is.png' 1x)"><path d="M0 0"/></svg>`,
+ style_mask_imageset: W(`<rect width="8" height="8" style="mask-image:image-set('${EVIL}/mimg-is.png' 1x)"/>`),
+ style_cursor_imageset: `<svg ${V} style="cursor:image-set('${EVIL}/cur-is.png' 1x),auto"><path d="M0 0"/></svg>`,
+ style_webkit_imageset: `<svg ${V} style="background-image:-webkit-image-set('${EVIL}/wk-is.png' 1x)"><path d="M0 0"/></svg>`,
+ anim_fill_escape: W(`<rect width="8" height="8"><set attributeName="fill" to="\\75 rl(${EVIL}/anim.svg#a)"/></rect>`),
+ anim_mask_imageset: W(`<rect width="8" height="8"><set attributeName="mask" to="image-set('${EVIL}/anim-is.png' 1x)"/></rect>`),
+ anim_style: W(`<rect width="8" height="8"><set attributeName="style" to="background:red"/></rect>`),
+ // references
+ use_external: W(`<use href="${EVIL}/x.svg#a"/>`),
+ use_data: W(`<use href="data:image/svg+xml,%3Csvg%3E"/>`),
+ feimage: W(`<filter id="f"><feImage href="${EVIL}/i.png"/></filter>`),
+ anchor: W(`<a href="#a"><rect/></a>`),
+ anim_href: W(`<set attributeName="href" to="javascript:x"/>`),
+ anim_href_ws: W(`<set attributeName=" HREF " to="#a"/>`),
+ anim_xlink_prefix: `<svg ${V} xmlns:x="http://www.w3.org/1999/xlink"><defs><g id="a"/></defs><use href="#a"><set attributeName="x:href" to="${EVIL}/x.svg#a" begin="0s"/></use></svg>`,
+ anim_onclick: W(`<set attributeName="onclick" to="window.__x=1"/>`),
+ anim_xlink_onclick: W(`<rect width="8" height="8"><set attributeName="xlink:onclick" to="window.__x=1"/></rect>`),
+ // elements
+ style_el: W(`<style>*{}</style>`),
+ nested_svg: W(`<svg viewBox="0 0 1 1"><path d="M0 0"/></svg>`),
+ title_markup_text: W(`<title><img src=x onerror=window.__x=1></title>`),
+ title_child_el: W(`<title><path d="M0 0"/></title>`),
+ desc_child_el: W(`<desc><g></g></desc>`),
+ title_title: W(`<title><title>x</title></title>`),
+ title_svg_title: W(`<title><svg viewBox="0 0 1 1"><title>t</title></svg></title>`),
+ math: W(`<math><mi>x</mi></math>`),
+ table: W(`<table><tr><td>x</td></tr></table>`),
+ after_root_html: `<svg ${V}></svg><img src=x onerror=window.__x=1>`,
+ after_root_ws: `<svg ${V}><path d="M0 0"/></svg>\n `,
+ doctype_subset: `<!DOCTYPE svg [<!ENTITY x "y">]><svg ${V}></svg>`,
+ doctype_plain: `<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "x"><svg ${V}><path d="M0 0"/></svg>`,
+ doctype_html: `<!DOCTYPE html><svg ${V}><path d="M0 0"/></svg>`,
+ xmldecl_gt: `<?xml version="1.0" encoding="a>b"?><svg ${V}><path d="M0 0"/></svg>`,
+ comment_join: `<svg ${V}><scr<!-- -->ipt>window.__x=1</script></svg>`,
+ comment_bang_close: `<svg ${V}><!-- a --!><img src=x onerror=window.__x=1> --><path d="M0 0"/></svg>`,
+ comment_abrupt: `<svg ${V}><!--><img src=x onerror=window.__x=1>--><path d="M0 0"/></svg>`,
+ cdata: W(`<![CDATA[<img src=x onerror=window.__x=1>]]>`),
+ pi: W(`<?x y?>`),
+ upper_close: `<svg ${V}><path d="M0 0"/></SVG>`,
+ close_ws: `<svg ${V}><path d="M0 0"/></svg >`,
+ close_attr: `<svg ${V}><path d="M0 0"/></svg foo="1">`,
+ svg_ns_script: W(`<svg:script>window.__x=1</svg:script>`),
+ // layout / redress (not script)
+ overlay_style: `<svg ${V} style="position:fixed;inset:0;width:100vw;height:100vh;z-index:2147483647"><path d="M0 0"/></svg>`,
+ class_redress: `<svg ${V} class="fixed inset-0 z-50"><path d="M0 0"/></svg>`,
+ id_clobber: W(`<g id="__next_f"/>`),
+ role_aria: `<svg ${V} aria-hidden="false" role="img" aria-label="Pay here"><path d="M0 0"/></svg>`,
+};
+
+export const LOADS_ELSEWHERE: readonly string[] = [
+ "pres_url_escape_mask",
+ "pres_url_escape_clip",
+ "pres_mask_imageset",
+ "style_bg_imageset_root",
+ "style_mask_imageset",
+ "style_cursor_imageset",
+ "style_webkit_imageset",
+ "anim_mask_imageset",
+];
+
+export const REAL_SHAPES: Record<string, string> = {
+ // A gradient body with a dark outline under it, the root as a drawing
+ // program writes it.
+ gradient_outlined:
+ `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="-3 -3 76.3 80.8" enable-background="new 0 0 198.7 74.8" xml:space="preserve">` +
+ `<defs><linearGradient id="grad_1" gradientUnits="userSpaceOnUse" x1="35" y1="0" x2="35" y2="75" gradientTransform="matrix(1,0,0,-1,0,75)">` +
+ `<stop offset="0" style="stop-color:#E0A030"/><stop offset="1" style="stop-color:#1C9A5B"/></linearGradient></defs>` +
+ `<path fill="url(#grad_1)" style="fill:url(#grad_1)" stroke="#161c21" stroke-width="6" stroke-linejoin="round" paint-order="stroke" d="M38 75C50 64 70 50 70 30 70 12 55 0 38 0S5 12 5 30c0 20 21 34 33 45z"/>` +
+ `<circle fill="#333333" cx="31" cy="10" r="2.1"/></svg>`,
+ // One path in the link's colour, `fill="none"` on the root.
+ single_path:
+ `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2 2h20v20H2z" fill="currentColor"/></svg>`,
+ // A two-colour disc with a stroke on the disc and a decimal viewBox.
+ disc_stroked:
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80.5 80.5" fill="none">` +
+ `<circle cx="42" cy="42" r="36" fill="#1a1a1a"/>` +
+ `<circle cx="38.5" cy="38.5" r="37" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.557"/>` +
+ `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`,
+};
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -6,6 +6,8 @@
- **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 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.
## [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
@@ -1,11 +1,17 @@
"use client";
import type { SocialLink } from "yt-dlp-transcript-common/lib/settings";
+import { socialLinksJson, type SocialRow } from "./socialLinksJson";
-export type SocialRow = { label: string; url: string; svg: string };
+export type { SocialRow };
export function toSocialRow(s: SocialLink): SocialRow {
- return { label: s.label, url: s.url, svg: s.svg };
+ return {
+ label: s.label,
+ url: s.url,
+ svg: s.svg,
+ ...(s.featured ? { featured: true } : {}),
+ };
}
type Props = {
@@ -32,13 +38,7 @@ export function SocialLinksField({ value, onChange, name }: Props) {
const update = (idx: number, patch: Partial<SocialRow>) =>
onChange(value.map((row, i) => (i === idx ? { ...row, ...patch } : row)));
- const json = JSON.stringify(
- value.map((s) => ({
- label: s.label.trim(),
- url: s.url.trim(),
- svg: s.svg.trim(),
- })),
- );
+ const json = socialLinksJson(value);
return (
<>
@@ -96,6 +96,22 @@ 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). */}
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={s.featured === true}
+ onChange={(e) => update(idx, { featured: e.target.checked })}
+ className="accent-brand"
+ />
+ Show in header
+ </label>
+ <span className="text-xs text-muted-foreground">
+ With none checked, the header shows the last four.
+ </span>
+ </div>
</div>
))}
<div>
diff --git a/editor/app/components/socialLinksJson.test.ts b/editor/app/components/socialLinksJson.test.ts
@@ -0,0 +1,23 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { parseSocialLinks } from "yt-dlp-transcript-common/lib/settingsSchema";
+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", () => {
+ const posted = JSON.parse(
+ socialLinksJson([
+ { label: " A ", url: " https://a.example ", svg: ` ${SVG} `, featured: true },
+ { label: "B", url: "https://b.example", svg: SVG, featured: false },
+ { label: "C", url: "https://c.example", svg: SVG },
+ ]),
+ );
+ assert.deepEqual(posted, [
+ { label: "A", url: "https://a.example", svg: SVG, featured: true },
+ { label: "B", url: "https://b.example", svg: SVG },
+ { label: "C", url: "https://c.example", svg: SVG },
+ ]);
+ // …and the actions' parser keeps exactly that.
+ assert.deepEqual(parseSocialLinks(posted), posted);
+});
diff --git a/editor/app/components/socialLinksJson.ts b/editor/app/components/socialLinksJson.ts
@@ -0,0 +1,22 @@
+// What the social-links editor posts: its rows, trimmed, as the JSON both the
+// settings and the site actions parse with parseSocialLinks. Pure, so it has a
+// unit test (socialLinksJson.test.ts) without a DOM.
+
+export type SocialRow = {
+ label: string;
+ url: string;
+ svg: string;
+ // "Show in header". Posted only when checked, the way the schema stores it.
+ featured?: boolean;
+};
+
+export function socialLinksJson(rows: readonly SocialRow[]): string {
+ return JSON.stringify(
+ rows.map((s) => ({
+ label: s.label.trim(),
+ url: s.url.trim(),
+ svg: s.svg.trim(),
+ ...(s.featured ? { featured: true } : {}),
+ })),
+ );
+}
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache";
import {
AUTO_REFRESH_INTERVAL_MAX_SECONDS,
+ getSettings,
AUTO_REFRESH_INTERVAL_MIN_SECONDS,
DEFAULT_REPORT_DEBOUNCE_PRESET,
defaultBuildPipeline,
@@ -10,7 +11,6 @@ import {
MIN_FREE_DISK_GB_MAX,
RESUME_MARGIN_GB_DEFAULT,
RESUME_MARGIN_GB_MAX,
- normalizeSocialSvg,
parseSocialLinks,
PARALLEL_TRANSCRIPTIONS_DEFAULT,
SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS,
@@ -20,6 +20,7 @@ import {
type SocialLink,
} from "yt-dlp-transcript-common/lib/settings";
import { saveSettings } from "./saveSettings";
+import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks";
import {
DEFAULT_COOKIE_MODE,
isCookieMode,
@@ -166,17 +167,16 @@ export async function saveSettingsAction(
"Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.",
};
}
- const socialLinks: SocialLink[] = [];
- for (const link of socialParsed) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- return {
- ok: false,
- error: `Social link "${link.label}" has an invalid SVG.`,
- };
- }
- socialLinks.push({ ...link, svg });
+ // A link whose SVG is unchanged from settings.json is kept as it is; a new
+ // or edited one is checked (lib/socialLinks.ts socialLinksForSave).
+ const checked = socialLinksForSave(socialParsed, getSettings().socialLinks);
+ if ("refused" in checked) {
+ return {
+ ok: false,
+ error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`,
+ };
}
+ const socialLinks: SocialLink[] = checked.links;
// Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside
// the settings schema on save, so we only read the form here (NaN/blank → default).
diff --git a/editor/app/settings/saveSettings.test.ts b/editor/app/settings/saveSettings.test.ts
@@ -76,3 +76,39 @@ test("saveSettings writes the merged result and touches nothing else", async ()
const onDisk = JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8"));
assert.equal(onDisk.adminTitle, "Kept");
});
+
+// A STORED ICON THE CHECKER NOW REFUSES does not block an unrelated save: an
+// older build stored it, and a lane pause or a title must still save. The link
+// is written back byte-identical; only a NEW or EDITED icon is checked.
+test("an icon stored by an older build survives unrelated saves; editing it is checked", async () => {
+ const old = `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8" inkscape:version="1.0"><defs><style>.a{fill:#f00}</style></defs><metadata/><path class="a" d="M0 0"/></svg>`;
+ const good = `<svg viewBox="0 0 8 8"><path d="M0 0"/></svg>`;
+ const stored = { label: "Old", url: "https://old.example", svg: old };
+ writeFileSync(process.env.SETTINGS_FILE!, JSON.stringify({ socialLinks: [stored] }));
+ const linkOnDisk = () =>
+ (JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8")) as { socialLinks: unknown[] }).socialLinks;
+
+ await saveSettings({ adminTitle: "Renamed" });
+ await saveSettings({ minFreeDiskGB: 2 });
+ const s = getSettings();
+ // A lane pause, the way the editor's pause control writes it.
+ const lane = Object.keys(s.autoQueue)[0] as keyof typeof s.autoQueue;
+ await saveSettings({ autoQueue: { ...s.autoQueue, [lane]: { ...s.autoQueue[lane], held: true } } });
+ await saveSettings({
+ channelPriority: { ...s.channelPriority, channels: { some: { tier: "paused" } } },
+ });
+ assert.deepEqual(linkOnDisk(), [stored], "byte-identical after four unrelated saves");
+
+ // Edited to something still refused: the save is refused, with the reason.
+ await assert.rejects(
+ saveSettings({ socialLinks: [{ ...stored, svg: `<svg viewBox="0 0 8 8"><style>*{}</style></svg>` }] }),
+ /Social link "Old" has an invalid SVG: it has an element an icon has no use for \(style\)/,
+ );
+ assert.deepEqual(linkOnDisk(), [stored]);
+
+ // Edited to a good icon: saved, normalized.
+ await saveSettings({ socialLinks: [{ ...stored, svg: good }] });
+ assert.deepEqual(linkOnDisk(), [
+ { ...stored, svg: `<svg aria-hidden="true" fill="currentColor" viewBox="0 0 8 8"><path d="M0 0"/></svg>` },
+ ]);
+});
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -11,6 +11,7 @@ import {
wordmarkLeadFor,
} from "yt-dlp-transcript-common/lib/brand";
import {
+ getSite,
writeSite,
deleteSite,
isValidSiteId,
@@ -20,7 +21,6 @@ import {
type Site,
} from "yt-dlp-transcript-common/lib/site";
import {
- normalizeSocialSvg,
parseSocialLinks,
type SocialLink,
} from "yt-dlp-transcript-common/lib/settings";
@@ -29,6 +29,7 @@ import {
resolveDefaultGroupId,
} from "yt-dlp-transcript-common/lib/channelGroups";
import { migrateToSites } from "yt-dlp-transcript-common/controller/migrateToSites";
+import { socialLinksForSave } from "yt-dlp-transcript-common/lib/socialLinks";
export type SaveResult = { ok: true; siteId: string } | { ok: false; error: string };
@@ -176,17 +177,22 @@ export async function saveSiteAction(
"Each social link needs a label, URL (http(s)://, mailto:, or /), and SVG.",
};
}
- socialLinks = [];
- for (const link of socialParsed) {
- const svg = normalizeSocialSvg(link.svg);
- if (svg === null) {
- return {
- ok: false,
- error: `Social link "${link.label}" has an invalid SVG.`,
- };
- }
- socialLinks.push({ ...link, svg });
+ // A link whose SVG is unchanged from the site's file is kept as it is; a
+ // new or edited one is checked (lib/socialLinks.ts socialLinksForSave).
+ let stored: SocialLink[] = [];
+ try {
+ stored = getSite(siteId, getPaths()).socialLinks ?? [];
+ } catch {
+ stored = [];
+ }
+ const checked = socialLinksForSave(socialParsed, stored);
+ if ("refused" in checked) {
+ return {
+ ok: false,
+ error: `Social link "${checked.refused.label}" has an invalid SVG: ${checked.refused.problem}.`,
+ };
}
+ socialLinks = checked.links;
}
let channelsInput: unknown;
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -42,6 +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("button", { name: /save settings/i }).click();
await expect(
@@ -49,10 +50,12 @@ test("saves global default social links", async ({ page }) => {
).toBeVisible();
const saved = await readJson<{
- socialLinks?: { label: string; url: string; svg: string }[];
+ socialLinks?: { label: string; url: string; svg: string; featured?: boolean }[];
}>("test-settings.json");
expect(saved.socialLinks).toHaveLength(1);
expect(saved.socialLinks?.[0].label).toBe("GitHub");
+ // "Show in header" is stored as featured: true.
+ expect(saved.socialLinks?.[0].featured).toBe(true);
// SVG was normalized on save (fill="currentColor" injected).
expect(saved.socialLinks?.[0].svg).toContain("currentColor");
});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -2,6 +2,7 @@
## [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.
## [0.10.0] - 2026-09-28
- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site.
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -1,7 +1,5 @@
-import {
- getSettings,
- sizeSocialSvg,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { safeSocialSvg, sizeSocialSvg } from "yt-dlp-transcript-common/lib/socialLinks";
import {
listSites,
resolveRelatedSites,
@@ -96,19 +94,36 @@ export default function Footer() {
</span>
{socialLinks.length > 0 && (
<ul className="flex items-center gap-3 list-none">
- {socialLinks.map((link, i) => (
- <li key={`${link.url}-${i}`}>
- <a
- href={link.url}
- title={link.label}
- aria-label={link.label}
- target="_blank"
- rel="noopener noreferrer"
- className="inline-block w-5 h-5 text-muted-foreground hover:text-brand transition-colors [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }}
- />
- </li>
- ))}
+ {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>
)}
</div>
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -11,13 +11,7 @@ import {
SheetTitle,
SheetTrigger,
} from "yt-dlp-transcript-common/components/ui/sheet";
-import { useTheme } from "yt-dlp-transcript-common/components/ThemeProvider";
-import {
- THEME_BASES,
- accentOptions,
- isThemeAccent,
- isThemeBase,
-} from "yt-dlp-transcript-common/components/themeConfig";
+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,
@@ -29,7 +23,8 @@ import type { SwitcherGroup } from "./SiblingSwitcher";
//
// 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"), not by nesting
+// 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.
@@ -43,7 +38,6 @@ export default function MobileMenu({
hubUrl?: string;
}) {
const [open, setOpen] = useState(false);
- const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -120,31 +114,7 @@ export default function MobileMenu({
</div>
)}
- <div className="mt-4 px-2">
- <MenuHeading>Base</MenuHeading>
- <RadioList
- name="mobile-theme-base"
- label="Base"
- value={base}
- options={THEME_BASES}
- onPick={(v) => {
- if (isThemeBase(v)) setBase(v);
- }}
- />
- </div>
-
- <div className="mt-4 px-2">
- <MenuHeading>Accent</MenuHeading>
- <RadioList
- name="mobile-theme-accent"
- label="Accent"
- value={accent}
- options={accentOptions(siteAccent)}
- onPick={(v) => {
- if (isThemeAccent(v)) setAccent(v);
- }}
- />
- </div>
+ <ThemeRadios namePrefix="mobile-theme" groupClassName="mt-4 px-2" />
</SheetContent>
</Sheet>
);
@@ -157,56 +127,3 @@ function MenuHeading({ children }: { children: React.ReactNode }) {
</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/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -2,6 +2,13 @@
## [Unreleased]
+- **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.
+- **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.
+- **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`).
- **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.
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -1,5 +1,10 @@
import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
-import { siteChartColors, siteColor } from "yt-dlp-transcript-common/lib/siteColor";
+import {
+ siteAccentColor,
+ siteChartColors,
+ siteColor,
+} from "yt-dlp-transcript-common/lib/siteColor";
+import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
// The official instances: one card per public archive, its title linking out,
// its own numbers underneath. No description line under the title — the
@@ -38,6 +43,28 @@ function Figure({ value, unit }: { value: number | undefined; unit: string }) {
);
}
+// THE NAME IS THE SITE'S WORDMARK when the summary carries its lead: the lead
+// heavy, the rest light (common/components/Wordmark.tsx, as the site's own
+// header sets it), at the card title's size. The lead is TINTED in the site's
+// own accent (siteAccentColor: a named accent's swatch, a custom hex fitted to
+// the base in force — above 4:1 on the card on every base); a site with no
+// accent keeps the foreground. The two spans are adjacent inline text, so the
+// link's accessible name stays the plain title. With no lead (none configured,
+// or a summary from before release 14) the title is plain, as it was. In forced
+// colours the tint is the system's text colour, like any text.
+function SiteName({ site }: { site: HomepageSummarySite }) {
+ if (!site.wordmarkLead) return <>{site.siteTitle}</>;
+ const accent = siteAccentColor(site);
+ return (
+ <Wordmark
+ title={site.siteTitle}
+ lead={site.wordmarkLead}
+ className="tracking-[-0.01em]"
+ leadStyle={accent ? { color: accent } : undefined}
+ />
+ );
+}
+
export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) {
if (sites.length === 0) return null;
const chart = siteChartColors(sites);
@@ -60,7 +87,7 @@ export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) {
rel="noopener noreferrer"
className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-[var(--border-strong)]"
>
- {site.siteTitle}
+ <SiteName site={site} />
<span aria-hidden="true" className="ml-1.5 text-sm text-[var(--faint)]">↗</span>
</a>
</h3>
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -180,15 +180,23 @@ export function ArchiveGrowthChart({
{layers.map((l) => (
<path key={l.site.siteId} d={l.area} fill={l.color} />
))}
- {/* The surface gap: each layer's upper edge is drawn in the page
- colour, so touching strata separate by negative space. */}
+ {/* 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"
- stroke="var(--background)"
- strokeWidth={1.25}
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx
@@ -1,15 +1,13 @@
import Link from "next/link";
-import {
- getSettings,
- sizeSocialSvg,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
import {
PROJECT_NAME,
PROJECT_TAGLINE,
} from "yt-dlp-transcript-common/lib/project";
import { currentHomepage } from "../lib/homepage";
-import { NAV } from "../lib/nav";
+import { FOOTER_NAV } from "../lib/nav";
// The project site's footer. Note the identity split it embodies: the wordmark
// and tagline are PRODUCT strings (the same on every install), while the social
@@ -36,7 +34,7 @@ export default function Footer() {
<nav aria-label="Footer" className="flex flex-col gap-3">
<span className="label-machine">Sections</span>
<ul className="flex flex-col gap-2 list-none">
- {NAV.map((item) => (
+ {FOOTER_NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
@@ -52,21 +50,7 @@ export default function Footer() {
{socialLinks.length > 0 && (
<div className="flex flex-col gap-3">
<span className="label-machine">Elsewhere</span>
- <ul className="flex items-center gap-4 list-none">
- {socialLinks.map((link, i) => (
- <li key={`${link.url}-${i}`}>
- <a
- href={link.url}
- title={link.label}
- aria-label={link.label}
- target="_blank"
- rel="noopener noreferrer"
- className="inline-block w-5 h-5 text-[var(--faint)] hover:text-[var(--foreground)] transition-colors [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }}
- />
- </li>
- ))}
- </ul>
+ <SocialLinks links={socialLinks} placement="footer" />
</div>
)}
</div>
diff --git a/homepage/app/components/Header.tsx b/homepage/app/components/Header.tsx
@@ -7,13 +7,18 @@ import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand";
import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
-import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
-import { NAV } from "../lib/nav";
+import { SocialLinks } from "yt-dlp-transcript-common/components/SocialLinks";
+import { SocialScroll } from "yt-dlp-transcript-common/components/SocialScroll";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
+import { headerSocialLinks } from "yt-dlp-transcript-common/lib/socialLinks";
+import { currentHomepage } from "../lib/homepage";
+import { HEADER_NAV } from "../lib/nav";
function NavList({ className }: { className?: string }) {
return (
<ul className={`flex items-center list-none ${className ?? ""}`}>
- {NAV.map((item) => (
+ {HEADER_NAV.map((item) => (
<li key={item.href}>
<Link
href={item.href}
@@ -27,23 +32,68 @@ function NavList({ className }: { className?: string }) {
);
}
-// The project site's header: a hairline bar with the wordmark and the five
-// destinations. It carried NO links at all before this — the page it sat above
-// was the whole site.
+// The project site's header: a hairline bar with the wordmark and the four
+// destinations (Changelog is in the footer only; lib/nav.ts). It carried NO
+// links at all before this — the page it sat above was the whole site.
//
// The wordmark is PRODUCT identity (common/lib/project.ts), not the operator's
// editable homepage config: this bar says what the software is called, and that
// is the same string on every install. The operator's own naming still governs
-// /stats/ and the social links in the footer.
+// /stats/ and the social links, which this bar carries as well as the footer.
+//
+// THE SOCIAL ROW AND THE THEME TOGGLE ARE ONE GROUP, in the bar at every width:
+// 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):
+// ≥ 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
+// 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.
//
// 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
// wordmark splits on the subject ("Archi" + "lyzer"); the link keeps its
// 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",
+};
+
export default function Header() {
+ const socialLinks = resolveHomepageSocialLinks(currentHomepage(), getSettings());
+ const shown = headerSocialLinks(socialLinks).length;
return (
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md">
- <div className="max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-6">
+ <div className="@container/bar max-w-6xl mx-auto px-5 sm:px-6 flex h-14 items-center gap-3 md:gap-6">
<Link
href="/"
className="flex items-center gap-2.5 shrink-0"
@@ -53,28 +103,35 @@ export default function Header() {
<Wordmark
title={PROJECT_NAME}
lead={PROJECT_WORDMARK_LEAD}
- className="text-[1.3rem] leading-none tracking-[-0.01em]"
+ className={`text-[1.3rem] leading-none tracking-[-0.01em] ${WORDMARK_FITS[shown] ?? WORDMARK_FITS[4]}`}
/>
</Link>
- <nav aria-label="Main" className="ml-auto hidden md:block">
- <NavList className="gap-6" />
- </nav>
- <div className="ml-auto md:ml-0 flex items-center gap-2">
- <ThemeMenu />
- <ThemeToggle />
+ <div className="ml-auto flex min-w-0 items-center gap-8">
+ <nav aria-label="Main" className="hidden md:block">
+ <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]" />
+ </SocialScroll>
+ )}
+ <ThemeToggle variant="bare" />
+ </div>
</div>
</div>
- {/* Below `md` the bar has no room for five labels beside the wordmark and
- the theme controls (at `sm` four fitted; Source made it five), so the
- nav drops to its own scrollable rule rather than collapsing behind a
- menu button — five links do not earn a disclosure widget. Only one of the two 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. */}
+ {/* Below `md` the nav drops to its own rule rather than collapsing behind
+ a menu button — four links do not earn a disclosure widget, and they
+ 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. */}
<nav
aria-label="Main, compact"
className="md:hidden border-t border-[var(--border)] overflow-x-auto"
>
- <NavList className="gap-5 px-5 h-10" />
+ <NavList className="gap-5 px-5 sm:px-6 h-10" />
</nav>
</header>
);
diff --git a/homepage/app/globals.css b/homepage/app/globals.css
@@ -105,3 +105,18 @@ body {
.growth-hit:hover {
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;
+}
+@media (forced-colors: active) {
+ .growth-sep {
+ stroke: CanvasText;
+ stroke-opacity: 1;
+ }
+}
diff --git a/homepage/app/layout.tsx b/homepage/app/layout.tsx
@@ -71,9 +71,11 @@ 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 can pick any other. */}
- <ThemeScript defaultBase="dark" />
- <ThemeProvider defaultBase="dark" siteAccent={DEFAULT_ACCENT}>
+ 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>
<Header />
<main className="flex-1 w-full">{children}</main>
<Footer />
diff --git a/homepage/app/lib/nav.ts b/homepage/app/lib/nav.ts
@@ -1,16 +1,24 @@
-// The site's navigation, declared once and rendered by both the header and the
-// footer. Order is editorial: what the software is, where its code lives, how
-// to get it, what this deployment has done, what changed.
+// The site's navigation, declared once. Order is editorial: what the software
+// is, where its code lives, how to get it, what this deployment has done, what
+// changed.
+//
+// HEADER_NAV the header's links;
+// FOOTER_NAV the footer's "Sections" and the 404 page: the header's links,
+// then Changelog, which is in the footer only.
//
// Every entry must resolve on a `build:nodata` tree — /stats/ renders an honest
// no-data panel and /source/ an honest "nothing published" rather than 404ing —
// so the nav never has to be conditional.
export type NavItem = { href: string; label: string };
-export const NAV: NavItem[] = [
+export const HEADER_NAV: readonly NavItem[] = [
{ href: "/docs/", label: "Docs" },
{ href: "/source/", label: "Source" },
{ href: "/downloads/", label: "Downloads" },
{ href: "/stats/", label: "Stats" },
+];
+
+export const FOOTER_NAV: readonly NavItem[] = [
+ ...HEADER_NAV,
{ href: "/changelog/", label: "Changelog" },
];
diff --git a/homepage/app/lib/summary.test.ts b/homepage/app/lib/summary.test.ts
@@ -1,6 +1,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { summaryFile } from "./summary";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { loadSummary, summaryFile } from "./summary";
// Run with:
// pnpm --filter homepage test
@@ -22,3 +25,36 @@ test("outside production (the e2e's next dev) it names the file to read", () =>
assert.equal(summaryFile({ NODE_ENV: "development" }, PUBLIC), PUBLIC);
assert.equal(summaryFile({ NODE_ENV: "development", E2E_HOMEPAGE_SUMMARY_FILE: "" }, PUBLIC), PUBLIC);
});
+
+// A summary written before release 14 carries no `wordmarkLead` on its sites;
+// it must still load, and its cards show the plain title (ArchiveCards). A lead
+// that is not a proper prefix of the title (hand-edited, or not a string) is
+// dropped on read, so the card never splits a title it does not start.
+test("the loader keeps a wordmarkLead only when it is a proper prefix of the title", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hp-summary-"));
+ const file = path.join(dir, "homepage-summary.json");
+ const site = { siteId: "a", siteTitle: "Jeralyzer", siteDescription: "", siteUrl: "https://a.example" };
+ // Next types NODE_ENV as read-only; the test writes through a plain view.
+ const env = process.env as Record<string, string | undefined>;
+ const prev = { NODE_ENV: env.NODE_ENV, E2E: env.E2E_HOMEPAGE_SUMMARY_FILE };
+ const write = (sites: unknown[]) =>
+ fs.writeFileSync(file, JSON.stringify({ version: 5, totals: { transcripts: 1 }, sites }));
+ try {
+ delete env.NODE_ENV;
+ env.E2E_HOMEPAGE_SUMMARY_FILE = file;
+ write([site]);
+ assert.ok(!("wordmarkLead" in loadSummary()!.sites[0]), "an older summary: no lead");
+ write([{ ...site, wordmarkLead: "Jer" }]);
+ assert.equal(loadSummary()?.sites[0].wordmarkLead, "Jer");
+ for (const bad of ["Jeralyzer", "jer", "Xyz", "", 7, null]) {
+ write([{ ...site, wordmarkLead: bad }]);
+ assert.ok(!("wordmarkLead" in loadSummary()!.sites[0]), JSON.stringify(bad));
+ }
+ } finally {
+ if (prev.NODE_ENV === undefined) delete env.NODE_ENV;
+ else env.NODE_ENV = prev.NODE_ENV;
+ if (prev.E2E === undefined) delete env.E2E_HOMEPAGE_SUMMARY_FILE;
+ else env.E2E_HOMEPAGE_SUMMARY_FILE = prev.E2E;
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts
@@ -1,6 +1,10 @@
import fs from "node:fs";
import path from "node:path";
-import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary";
+import type {
+ HomepageSummary,
+ HomepageSummarySite,
+} from "yt-dlp-transcript-common/lib/homepageSummary";
+import { wordmarkLeadFor } from "yt-dlp-transcript-common/lib/brand";
// The build-time cross-site summary, or null when this build shipped without
// corpus data (`build:nodata`, or a source-only checkout that has never run
@@ -28,7 +32,7 @@ export function loadSummary(): HomepageSummary | null {
// 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.
if (!parsed || typeof parsed.totals?.transcripts !== "number") return null;
- return parsed;
+ return { ...parsed, sites: (parsed.sites ?? []).map(withCheckedLead) };
} catch {
return null;
}
@@ -47,3 +51,13 @@ export function summaryFile(
const override = env.NODE_ENV !== "production" ? env.E2E_HOMEPAGE_SUMMARY_FILE : undefined;
return override || publicFile;
}
+
+// A site's `wordmarkLead` (release 14) is used only when it is what compose
+// would have written: a proper prefix of its title (lib/brand.ts
+// wordmarkLeadFor). A summary from before release 14 has none, and one edited
+// by hand may hold anything; either way the card then shows the plain title.
+export function withCheckedLead(site: HomepageSummarySite): HomepageSummarySite {
+ const { wordmarkLead, ...rest } = site;
+ const lead = wordmarkLeadFor(site.siteTitle, wordmarkLead);
+ return lead ? { ...rest, wordmarkLead: lead } : rest;
+}
diff --git a/homepage/app/not-found.tsx b/homepage/app/not-found.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { PageShell, PageHeading } from "./components/PageShell";
-import { NAV } from "./lib/nav";
+import { FOOTER_NAV } from "./lib/nav";
// Branded 404. Static export renders this to out/404.html, which Cloudflare
// Pages serves for unmatched paths.
@@ -15,7 +15,7 @@ export default function NotFound() {
<nav aria-label="Site sections" className="flex flex-col gap-3">
<span className="label-machine">Try one of these</span>
<ul className="flex flex-wrap gap-x-6 gap-y-2 list-none">
- {[{ href: "/", label: "Home" }, ...NAV].map((item) => (
+ {[{ href: "/", label: "Home" }, ...FOOTER_NAV].map((item) => (
<li key={item.href}>
<Link
href={item.href}
diff --git a/homepage/e2e/fixture-social.ts b/homepage/e2e/fixture-social.ts
@@ -0,0 +1,112 @@
+import fs from "node:fs";
+import {
+ normalizeSocialSvg,
+ type SocialLink,
+} from "../../common/lib/settingsSchema";
+import { REAL_SHAPES } from "../../common/lib/socialSvg.vectors";
+
+// THE HOMEPAGE E2E'S SOCIAL LINKS, never the operator's.
+//
+// The header and the footer render `socialLinks` from settings.json. A checkout's
+// settings.json is the operator's own (a worktree seeds it from the primary), so
+// the e2e dev server reads THIS instead: `SETTINGS_FILE` (common/lib/paths.ts)
+// points it at e2e/.e2e-settings.json (gitignored), written by
+// playwright.config.ts before the server starts. Only the social links are set;
+// every other key reads as its default, and nothing else on the homepage reads
+// settings. (`SITES_DIR` points at an empty directory too, so no homepage.json
+// can override these links.)
+//
+// Each icon is given here in its SOURCE form — as an operator would paste it —
+// and goes through normalizeSocialSvg, the function every save path runs
+// (Settings, a site's form, the homepage config), so the page renders what a
+// save would have stored. The default trio is shaped like the icons operators
+// paste, all synthetic:
+// • Leaf — a gradient (an id, a url(#…) reference) and one solid colour,
+// which the normalizer themes to currentColor;
+// • Bubble — one colour, drawn white, as vendors ship them for dark grounds;
+// themed to currentColor;
+// • Disc — a two-colour disc with a letter, pasted with only width/height and
+// NO viewBox, so the page proves the normalizer made its viewBox.
+//
+// A spec that needs another list (six links, none, a hostile icon) rewrites the
+// file with writeFixtureSettings (or writeRawFixtureSettings, which skips the
+// normalizer the way a hand-edited file would) and restores FIXTURE_SOCIAL_TRIO
+// after: `next dev` reads settings on every request, and the suite runs one
+// worker.
+
+export const FIXTURE_SETTINGS_NAME = ".e2e-settings.json";
+
+// The gradient icon is the checker's regression shape (socialSvg.vectors.ts
+// REAL_SHAPES): a drawing program's root, a gradient with `style` stops, a dark
+// outline under the body (`paint-order`), a negative-origin viewBox.
+const LEAF = REAL_SHAPES.gradient_outlined;
+
+const BUBBLE =
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">` +
+ `<path fill="white" d="M4 4h16a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-7l-5 4v-4H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>`;
+
+// A two-colour disc with a letter, sized and with no viewBox (the normalizer
+// makes `viewBox="0 0 81 81"`): a dark offset disc, a light disc with a dark
+// outline, a dark "F".
+export const FIXTURE_DISC_SVG =
+ `<svg xmlns="http://www.w3.org/2000/svg" width="81" height="81" fill="none">` +
+ `<circle cx="44" cy="43" r="37" fill="#1a1a1a"/>` +
+ `<circle cx="39" cy="39" r="38" fill="#f4c542" stroke="#1a1a1a" stroke-width="1.5"/>` +
+ `<path fill="#1a1a1a" d="M30 22h20v8H38v6h10v8H38v14h-8z"/></svg>`;
+
+// Icons that must never run: each sets window.__hpHostile if it does. Written
+// raw (writeRawFixtureSettings), as a hand-edited settings.json could hold them.
+export const FIXTURE_HOSTILE_SVGS = [
+ `<svg/onload="window.__hpHostile=1" viewBox="0 0 8 8"><path d="M0 0"/></svg>`,
+ `<svg viewBox="0 0 8 8"><img src="x:" onerror="window.__hpHostile=1"></svg>`,
+ `<svg viewBox="0 0 8 8"><image href="x:"/onerror="window.__hpHostile=1"/></svg>`,
+];
+
+const SQUARE = `<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/></svg>`;
+const RING = `<svg viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18zm0 4a5 5 0 1 0 0 10 5 5 0 0 0 0-10z"/></svg>`;
+const TRIANGLE = `<svg viewBox="0 0 24 24"><path d="M12 3l10 18H2z"/></svg>`;
+
+const raw = (label: string, svg: string, featured = false) => ({
+ label,
+ url: `https://${label.toLowerCase()}.example/fixture`,
+ svg,
+ ...(featured ? { featured: true } : {}),
+});
+
+export const FIXTURE_SOCIAL_TRIO = [
+ raw("Leaf", LEAF),
+ raw("Bubble", BUBBLE),
+ raw("Disc", FIXTURE_DISC_SVG),
+];
+
+// Six, none marked: the header takes the last four.
+export const FIXTURE_SOCIAL_SIX = [
+ raw("Square", SQUARE),
+ raw("Ring", RING),
+ ...FIXTURE_SOCIAL_TRIO.slice(0, 1),
+ raw("Triangle", TRIANGLE),
+ ...FIXTURE_SOCIAL_TRIO.slice(1),
+];
+
+// Six, two marked: the header takes those two.
+export const FIXTURE_SOCIAL_SIX_FEATURED = FIXTURE_SOCIAL_SIX.map((l) =>
+ l.label === "Square" || l.label === "Bubble" ? { ...l, featured: true } : l,
+);
+
+type RawLink = { label: string; url: string; svg: string; featured?: boolean };
+
+export function writeFixtureSettings(dest: string, links: RawLink[]): void {
+ const socialLinks: SocialLink[] = links.map((link) => {
+ const svg = normalizeSocialSvg(link.svg);
+ if (svg === null) throw new Error(`fixture icon "${link.label}" was refused`);
+ return { ...link, svg };
+ });
+ writeRawFixtureSettings(dest, socialLinks);
+}
+
+// The links as given, NOT normalized: what a hand-edited settings.json holds.
+export function writeRawFixtureSettings(dest: string, links: RawLink[]): void {
+ const tmp = `${dest}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify({ socialLinks: links }, null, 2));
+ fs.renameSync(tmp, dest);
+}
diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts
@@ -38,16 +38,28 @@ export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z");
export const FIXTURE_PALE_HEX = "#f4c2d7";
// The six sites, in summary order. `accent` is the site.json setting (an
-// accent id or a custom hex), as compose reads it; `daily` how many
+// accent id or a custom hex), as compose reads it; `wordmarkLead` the
+// site.json one (a named accent's site, the custom hex's and a site with no
+// accent have one — the last ends MID-WORD, "Fix" + "ture Three", as
+// "Jer" + "alyzer" does; the rest show their title plain); `daily` how many
// recordings each channel transcribes a day.
-export const FIXTURE_SITES = [
- { siteId: "fixture-one", siteTitle: "Fixture One", accent: "brass", channels: 4, daily: 15 },
- { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, channels: 3, daily: 9 },
- { siteId: "fixture-three", siteTitle: "Fixture Three", accent: undefined, channels: 3, daily: 7 },
- { siteId: "fixture-four", siteTitle: "Fixture Four", accent: undefined, channels: 2, daily: 8 },
- { siteId: "fixture-five", siteTitle: "Fixture Five", accent: undefined, channels: 2, daily: 5 },
+export type FixtureSite = {
+ siteId: string;
+ siteTitle: string;
+ accent?: string;
+ wordmarkLead?: string;
+ channels: number;
+ daily: number;
+};
+
+export const FIXTURE_SITES: readonly FixtureSite[] = [
+ { siteId: "fixture-one", siteTitle: "Fixture One", accent: "brass", wordmarkLead: "Fixture", channels: 4, daily: 15 },
+ { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, wordmarkLead: "Fixture", channels: 3, daily: 9 },
+ { siteId: "fixture-three", siteTitle: "Fixture Three", wordmarkLead: "Fix", channels: 3, daily: 7 },
+ { siteId: "fixture-four", siteTitle: "Fixture Four", channels: 2, daily: 8 },
+ { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 5 },
{ siteId: "fixture-six", siteTitle: "Fixture Six", accent: "vermilion", channels: 2, daily: 3 },
-] as const;
+];
const DAY = 86_400_000;
const SPIKE = { site: 0, start: Date.UTC(2026, 4, 11), days: 7, count: 12_000 };
@@ -64,7 +76,7 @@ function uploadDate(n: number): string {
return ymd(d.getTime());
}
-export function buildFixtureSummary() {
+export function buildFixtureSummary(fixtureSites: readonly FixtureSite[] = FIXTURE_SITES) {
const stats: VideoStat[] = [];
const channelSites: Record<string, string[]> = {};
const sites: Site[] = [];
@@ -100,7 +112,7 @@ export function buildFixtureSummary() {
});
};
const today = Date.UTC(2026, 8, 15);
- FIXTURE_SITES.forEach((s, i) => {
+ fixtureSites.forEach((s, i) => {
const slugs = Array.from({ length: s.channels }, (_, c) => `${s.siteId}-ch${c + 1}`);
for (const slug of slugs) channelSites[slug] = [s.siteId];
sites.push({
@@ -110,6 +122,7 @@ export function buildFixtureSummary() {
siteUrl: `https://${s.siteId}.example`,
channels: slugs.map((slug) => ({ slug })),
...(s.accent ? { accent: s.accent } : {}),
+ ...(s.wordmarkLead ? { wordmarkLead: s.wordmarkLead } : {}),
} as unknown as Site);
slugs.forEach((slug, c) => {
const name = `${s.siteTitle} Channel ${c + 1}`;
@@ -123,7 +136,7 @@ export function buildFixtureSummary() {
});
// The megaspike: one week of bulk transcription on the first site's first
// channel.
- const spikeSite = FIXTURE_SITES[SPIKE.site];
+ const spikeSite = fixtureSites[SPIKE.site];
for (let k = 0; k < SPIKE.count; k++) {
record(SPIKE.site, `${spikeSite.siteId}-ch1`, `${spikeSite.siteTitle} Channel 1`, SPIKE.start + (k % SPIKE.days) * DAY);
}
diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts
@@ -0,0 +1,55 @@
+import { test, expect, type Page } from "@playwright/test";
+
+// 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.
+
+const BASE_KEY = "ytdlp-tb:base";
+
+const resolveColor = (page: Page, css: string) =>
+ page.evaluate((c) => {
+ const el = document.createElement("span");
+ el.style.color = c;
+ document.body.append(el);
+ const out = getComputedStyle(el).color;
+ el.remove();
+ return out;
+ }, css);
+
+const separators = (page: Page) =>
+ page.locator("figure svg path.growth-sep").evaluateAll((els) =>
+ els.map((el) => {
+ const s = getComputedStyle(el);
+ return { stroke: s.stroke, opacity: s.strokeOpacity, width: s.strokeWidth };
+ }),
+ );
+
+test("the separators are the ground's foreground at full strength, never the page background", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const base of ["light", "sepia", "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" });
+ }
+ }
+});
+
+test("in forced colours the separators are CanvasText", 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);
+});
diff --git a/homepage/e2e/helpers.ts b/homepage/e2e/helpers.ts
@@ -0,0 +1,41 @@
+import { expect, type Page } from "@playwright/test";
+import { THEME_BASES, nextBase, type ThemeBase } from "../../common/components/themeConfig";
+
+// THE ONE WAY a homepage spec changes the theme through the UI: the header's
+// 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.
+
+export const themeToggle = (page: Page) =>
+ page.locator("header").getByRole("button", { name: /^switch to /i });
+
+// The cycle as the toggle walks it, from `start`: every base once. Derived
+// from THEME_BASES and nextBase, so a base added or removed there needs no
+// change here.
+export function themeCycle(start: ThemeBase): ThemeBase[] {
+ const out: ThemeBase[] = [start];
+ for (let b = nextBase(start); b !== start && out.length <= THEME_BASES.length; b = nextBase(b)) {
+ out.push(b);
+ }
+ return out;
+}
+
+export const baseLabel = (b: ThemeBase) =>
+ THEME_BASES.find((x) => x.id === b)!.label.toLowerCase();
+
+// Click the toggle until the base in force is `base`. A click before
+// hydration is lost, so each step waits for the attribute to move.
+export async function chooseTheme(page: Page, { base }: { base: ThemeBase }) {
+ const toggle = themeToggle(page);
+ for (let i = 0; i <= THEME_BASES.length; i++) {
+ const current = (await toggle.getAttribute("data-theme-base")) as ThemeBase;
+ if (current === base) return;
+ await expect(async () => {
+ if ((await toggle.getAttribute("data-theme-base")) === current) await toggle.click();
+ expect(await toggle.getAttribute("data-theme-base")).not.toBe(current);
+ }).toPass({ timeout: 10_000 });
+ }
+ expect(await toggle.getAttribute("data-theme-base")).toBe(base);
+}
diff --git a/homepage/e2e/instance-wordmark.spec.ts b/homepage/e2e/instance-wordmark.spec.ts
@@ -0,0 +1,114 @@
+import { test, expect, type Page } from "@playwright/test";
+import { resolveAccent } from "../../common/lib/accent";
+import { FIXTURE_PALE_HEX, FIXTURE_SITES } from "./fixture-summary";
+
+// Each Official Instances card names its site with the site's WORDMARK when
+// the summary carries its lead: the lead heavy and TINTED in the site's own
+// accent (a named accent's swatch on the base in force, a custom hex fitted to
+// it), the rest light. A site with a lead and no accent keeps the foreground;
+// a site with no lead shows its title plain, as before. The link's accessible
+// name is the plain title throughout.
+//
+// The fixture (fixture-summary.ts): Fixture One (Brass, lead "Fixture"),
+// Fixture Two (a pale custom hex, lead "Fixture"), Fixture Three (no accent,
+// lead "Fix" — mid-word, so a split name would read "Fix ture Three"), Four and
+// Five (neither), Six (Vermilion, no lead).
+
+const BASE_KEY = "ytdlp-tb:base";
+
+const section = (page: Page) =>
+ page.locator("section").filter({
+ has: page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }),
+ });
+
+const card = (page: Page, title: string) =>
+ section(page)
+ .locator("li")
+ .filter({ has: page.getByRole("link", { name: title, exact: true }) });
+
+// The colour a CSS colour resolves to on this page.
+const resolve = (page: Page, css: string) =>
+ page.evaluate((c) => {
+ const el = document.createElement("span");
+ el.style.color = c;
+ document.body.append(el);
+ const out = getComputedStyle(el).color;
+ el.remove();
+ return out;
+ }, css);
+
+function luminance(rgb: string): number {
+ const [r, g, b] = (rgb.match(/[\d.]+/g) ?? []).slice(0, 3).map((n) => {
+ const c = Number(n) / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+const contrast = (a: string, b: string) => {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+};
+
+async function wordmarkOf(page: Page, title: string) {
+ return card(page, title).evaluate((li) => {
+ const lead = li.querySelector("[data-wordmark-lead]");
+ const suffix = li.querySelector("[data-wordmark-suffix]");
+ const cs = (el: Element | null) => (el ? getComputedStyle(el) : null);
+ return {
+ lead: lead?.textContent ?? null,
+ suffix: suffix?.textContent ?? null,
+ leadWeight: Number(cs(lead)?.fontWeight ?? 0),
+ suffixWeight: Number(cs(suffix)?.fontWeight ?? 0),
+ leadColor: cs(lead)?.color ?? null,
+ foreground: getComputedStyle(document.documentElement).getPropertyValue("--foreground").trim(),
+ cardBg: getComputedStyle(li).backgroundColor,
+ };
+ });
+}
+
+test("each card's name: the wordmark where there is a lead, tinted in the site's accent at ≥ 3:1 on every base, and the plain title as its name", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const s of FIXTURE_SITES) {
+ 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) {
+ 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 want: Record<string, { lead: string; colour: string }> = {
+ "Fixture One": { lead: "Fixture", colour: await resolve(page, "var(--swatch-brass)") },
+ "Fixture Two": { lead: "Fixture", colour: await resolve(page, pale[base]) },
+ "Fixture Three": { lead: "Fix", colour: await resolve(page, "var(--foreground)") },
+ };
+ for (const [title, { lead, colour }] of Object.entries(want)) {
+ const w = await wordmarkOf(page, title);
+ expect(w.lead, `${title} lead`).toBe(lead);
+ expect(w.suffix, `${title} suffix`).toBe(title.slice(lead.length));
+ expect(w.leadWeight, `${title}: the lead is heavier`).toBeGreaterThan(w.suffixWeight);
+ expect(w.leadColor, `${title} on ${base}`).toBe(colour);
+ expect(
+ contrast(w.leadColor!, w.cardBg),
+ `${title}'s lead against its card on ${base}`,
+ ).toBeGreaterThanOrEqual(3);
+ }
+ // The named accent and the custom hex are tinted, not the foreground.
+ const fg = await resolve(page, "var(--foreground)");
+ expect(want["Fixture One"].colour).not.toBe(fg);
+ expect(want["Fixture Two"].colour).not.toBe(fg);
+ // A lead that ends mid-word leaves the name whole: one accessible name,
+ // and the text as a reader sees it, with no space inserted.
+ const three = card(page, "Fixture Three").getByRole("link");
+ await expect(three).toHaveAccessibleName("Fixture Three");
+ expect((await three.innerText()).replace("↗", "").trim()).toBe("Fixture Three");
+
+ // No lead: the plain title, no wordmark — accent or not.
+ for (const title of ["Fixture Four", "Fixture Five", "Fixture Six"]) {
+ await expect(card(page, title).locator("[data-wordmark]"), title).toHaveCount(0);
+ await expect(card(page, title).getByRole("link")).toHaveText(new RegExp(`^${title}`));
+ }
+ }
+});
diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts
@@ -37,6 +37,38 @@ test("the hub link, when there is one, leaves for the hub", async ({ page }) =>
await expect(hub).toHaveAttribute("href", /^https?:\/\/[^/]/);
});
+// The header carries four destinations; the footer's "Sections" carries the
+// same four and Changelog, which is in the footer only (app/lib/nav.ts).
+const HEADER_LINKS = ["Docs", "Source", "Downloads", "Stats"];
+const FOOTER_LINKS = [...HEADER_LINKS, "Changelog"];
+
+const linkNames = (nav: import("@playwright/test").Locator) =>
+ nav.getByRole("link").evaluateAll((els) => els.map((e) => e.textContent?.trim()));
+
+test("the header nav has four links and no Changelog; the footer's has five", async ({
+ page,
+}) => {
+ for (const width of [360, 1280]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const main = page.getByRole("navigation", { name: width < 768 ? "Main, compact" : "Main", exact: true });
+ expect(await linkNames(main)).toEqual(HEADER_LINKS);
+ await expect(page.locator("header").getByRole("link", { name: "Changelog" })).toHaveCount(0);
+ expect(await linkNames(page.getByRole("navigation", { name: "Footer" }))).toEqual(FOOTER_LINKS);
+ }
+});
+
+test("Changelog is reachable from the footer on every page", async ({ page }) => {
+ for (const path of ["/", "/docs/", "/source/", "/downloads/", "/stats/", "/changelog/", "/no-such-page/"]) {
+ await page.goto(path);
+ await expect(
+ page.getByRole("navigation", { name: "Footer" }).getByRole("link", { name: "Changelog" }),
+ path,
+ ).toHaveAttribute("href", "/changelog/");
+ await expect(page.locator("header").getByRole("link", { name: "Changelog" }), path).toHaveCount(0);
+ }
+});
+
test("every nav destination resolves", async ({ page }) => {
// The nav must never point at a 404, including on a build with no corpus
// data — which is why /stats/ always exists and degrades in place.
diff --git a/homepage/e2e/social.spec.ts b/homepage/e2e/social.spec.ts
@@ -0,0 +1,430 @@
+import path from "node:path";
+import { test, expect, type Locator, type Page } from "@playwright/test";
+import {
+ FIXTURE_SETTINGS_NAME,
+ FIXTURE_SOCIAL_SIX,
+ FIXTURE_SOCIAL_SIX_FEATURED,
+ FIXTURE_HOSTILE_SVGS,
+ FIXTURE_SOCIAL_TRIO,
+ writeFixtureSettings,
+ writeRawFixtureSettings,
+} from "./fixture-social";
+import { themeToggle } from "./helpers";
+import { ADVERSARIAL, LOADS_ELSEWHERE } from "../../common/lib/socialSvg.vectors";
+
+// THE SOCIAL ROW: in the header at every width (at most four links), and in the
+// footer (every link). The links are the e2e's own (fixture-social.ts): Leaf (a
+// 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.
+//
+// Specs that need another list rewrite the dev server's settings file and the
+// afterEach restores the trio; `next dev` reads it on every request.
+
+const settingsFile = () =>
+ path.resolve(test.info().project.testDir, FIXTURE_SETTINGS_NAME);
+
+test.afterEach(() => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_TRIO);
+});
+
+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).
+const headerRow = (page: Page) =>
+ page.locator('header [data-social-links="header"]');
+const scrollBox = (page: Page) => page.locator("header [data-social-scroll]");
+const footerRow = (page: Page) => page.locator('footer [data-social-links="footer"]');
+
+const labelsOf = (row: Locator) =>
+ row.getByRole("link").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label")));
+
+async function noHorizontalOverflow(page: Page) {
+ const { scroll, client } = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ client: document.documentElement.clientWidth,
+ }));
+ expect(scroll, "the page scrolls sideways").toBeLessThanOrEqual(client);
+}
+
+async function tabTo(page: Page, target: Locator) {
+ for (let i = 0; i < 25; i++) {
+ await page.keyboard.press("Tab");
+ if (await target.evaluate((el) => el === document.activeElement)) return;
+ }
+ throw new Error("Tab never reached the link");
+}
+
+// The bar's content width (in px, default text) below which the wordmark's
+// text is hidden, by how many links the header shows and the pointer: the
+// 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 },
+} as const;
+
+const LINK_SETS = {
+ 1: FIXTURE_SOCIAL_TRIO.slice(-1),
+ 3: FIXTURE_SOCIAL_TRIO,
+ 4: FIXTURE_SOCIAL_SIX, // the header shows its last four
+} as const;
+
+async function narrowHeader(page: Page, width: number, count: 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);
+
+ // Nothing is hidden but (maybe) the wordmark's text, and nothing scrolls.
+ const home = page.getByRole("link", { name: "Archilyzer home", exact: true });
+ const textShown = await home.locator("[data-wordmark]").isVisible();
+ expect(textShown, `${pointer} ${width} px, ${count}: wordmark text`).toBe(
+ width - 40 >= WORDMARK_NEEDS[pointer][count],
+ );
+ if (textShown) {
+ expect(await home.evaluate((el) => el.scrollWidth <= el.clientWidth + 1), "the wordmark is clipped").toBe(true);
+ expect((await home.boundingBox())!.height).toBeLessThanOrEqual(32);
+ }
+ 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);
+ 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 ({
+ page,
+ }) => {
+ for (const count of [1, 3, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ await narrowHeader(page, width, count, "mouse");
+ }
+ });
+}
+
+test("768 and 1280 px: the full wordmark, the nav, the row and the toggle in one bar", async ({ page }) => {
+ for (const width of [768, 1280]) {
+ 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();
+ await expect(page.getByRole("navigation", { name: "Main", exact: true })).toBeVisible();
+ expect(await labelsOf(headerRow(page))).toEqual(TRIO);
+ expect(await scrollBox(page).evaluate((el) => el.scrollWidth > el.clientWidth + 1)).toBe(false);
+ await expect(themeToggle(page)).toBeInViewport();
+ await noHorizontalOverflow(page);
+ }
+});
+
+test("every link: its href, its label as its name and title, a new tab with no opener, and no text", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const row of [headerRow(page), footerRow(page)]) {
+ for (const want of FIXTURE_SOCIAL_TRIO) {
+ const link = row.getByRole("link", { name: want.label, exact: true });
+ await expect(link).toHaveAttribute("href", want.url);
+ await expect(link).toHaveAttribute("title", want.label);
+ await expect(link).toHaveAttribute("target", "_blank");
+ await expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ // No copy: the link's only content is its icon.
+ await expect(link).toHaveText("");
+ await expect(link.locator("svg")).toHaveAttribute("aria-hidden", "true");
+ }
+ }
+});
+
+test("header icons are the muted foreground; a two-colour icon keeps its colours", async ({
+ page,
+}) => {
+ await page.goto("/");
+ // The glyph itself: Bubble was drawn white and themed to currentColor, so
+ // its path paints the link's colour, the muted foreground.
+ const { glyph, muted } = await page.evaluate(() => {
+ const probe = document.createElement("span");
+ probe.style.color = "var(--muted-foreground)";
+ document.body.append(probe);
+ const muted = getComputedStyle(probe).color;
+ probe.remove();
+ const path = document.querySelector(
+ 'header [data-social-links="header"] a[aria-label="Bubble"] path',
+ )!;
+ return { glyph: getComputedStyle(path).fill, muted };
+ });
+ expect(glyph).toBe(muted);
+ const disc = headerRow(page).getByRole("link", { name: "Disc" });
+ expect(await disc.innerHTML()).toContain('fill="#f4c542"');
+});
+
+// End to end for the normalizer's size rule: the fixture's Disc is pasted with
+// only width="81" height="81"; the page must carry the viewBox the normalizer
+// made, and the render size instead of 81.
+test("an icon pasted with a size and no viewBox renders with the viewBox made from its size", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const row of [headerRow(page), footerRow(page)]) {
+ const svg = row.getByRole("link", { name: "Disc" }).locator("svg");
+ await expect(svg).toHaveAttribute("viewBox", "0 0 81 81");
+ await expect(svg).toHaveAttribute("width", "20");
+ await expect(svg).toHaveAttribute("height", "20");
+ const box = (await svg.boundingBox())!;
+ expect(Math.round(box.width)).toBe(20);
+ }
+});
+
+// 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.
+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.goto("/");
+ const dupes = await page.evaluate(() => {
+ const seen = new Map<string, number>();
+ for (const el of document.querySelectorAll("[data-social-links] [id]")) {
+ seen.set(el.id, (seen.get(el.id) ?? 0) + 1);
+ }
+ return [...seen].filter(([, n]) => n > 1).map(([id]) => id);
+ });
+ expect(dupes).toEqual([]);
+ const own = await headerRow(page)
+ .getByRole("link", { name: "Leaf" })
+ .evaluate((a) => {
+ const svg = a.querySelector("svg")!;
+ const ref = /url\(#([^)]+)\)/.exec(
+ svg.querySelector("path")!.getAttribute("fill") ?? "",
+ )?.[1];
+ const target = ref ? document.getElementById(ref) : null;
+ return { ref, inside: !!target && svg.contains(target) };
+ });
+ expect(own.ref).toBeTruthy();
+ expect(own.inside).toBe(true);
+});
+
+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 ({
+ page,
+ }) => {
+ for (const count of [1, 3, 4] as const) {
+ writeFixtureSettings(settingsFile(), [...LINK_SETS[count]]);
+ await narrowHeader(page, width, count, "touch");
+ }
+ });
+ }
+
+ // THE LAST RESORT: at 280 px, four 44 px keys, the toggle and the mark do not
+ // 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 ({
+ page,
+ }) => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX);
+ 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);
+ await noHorizontalOverflow(page);
+ await expect(themeToggle(page)).toBeInViewport({ ratio: 1 });
+ const inBox = (key: import("@playwright/test").Locator) =>
+ Promise.all([box.boundingBox(), key.boundingBox()]).then(([b, k]) =>
+ k!.x - 2 >= b!.x - 0.5 && k!.x + k!.width + 2 <= b!.x + b!.width + 0.5,
+ );
+ const keys = await headerRow(page).getByRole("link").all();
+ expect(await inBox(keys[keys.length - 1]), "the last link, on load").toBe(true);
+ expect(await inBox(keys[0]), "the first link, on load").toBe(false);
+ for (const key of keys) {
+ await tabTo(page, key);
+ await page.waitForTimeout(100);
+ expect(await inBox(key), (await key.getAttribute("aria-label")) ?? "a link").toBe(true);
+ }
+ });
+
+ 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);
+ await page.addInitScript(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ });
+ await page.setViewportSize({ width: 320, height: 800 });
+ await page.goto("/");
+ await expect(page.locator("html")).toHaveCSS("font-size", "32px");
+ // The header only: at twice the text size the page's own content is not
+ // this slice's to reflow.
+ expect(await page.locator("header").evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
+ await expect(themeToggle(page)).toBeInViewport({ ratio: 1 });
+ // The box may be narrower than one 88 px key here: the last link is the
+ // one at its end, in view as far as the box allows.
+ const [b, k] = await Promise.all([
+ scrollBox(page).boundingBox(),
+ headerRow(page).getByRole("link").last().boundingBox(),
+ ]);
+ expect(k!.x + k!.width).toBeLessThanOrEqual(b!.x + b!.width + 0.5);
+ expect(k!.x + k!.width).toBeGreaterThan(b!.x + b!.width / 2);
+ });
+
+ for (const width of [360, 1280]) {
+ test(`${width} px: every key is at least 44 px, and the header row still fits`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ expect(await page.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true);
+ for (const row of [headerRow(page), footerRow(page)]) {
+ for (const key of await row.getByRole("link").all()) {
+ const k = (await key.boundingBox())!;
+ expect(k.width).toBeGreaterThanOrEqual(44);
+ expect(k.height).toBeGreaterThanOrEqual(44);
+ }
+ }
+ const box = (await headerRow(page).boundingBox())!;
+ expect(box.x + box.width).toBeLessThanOrEqual(width);
+ await noHorizontalOverflow(page);
+ });
+ }
+});
+
+test("keyboard focus shows the ring; in forced colours the browser's own outline, and none at rest", async ({
+ page,
+}) => {
+ await page.goto("/");
+ const link = headerRow(page).getByRole("link").first();
+ await tabTo(page, link);
+ const normal = await link.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { outline: s.outlineStyle, shadow: s.boxShadow };
+ });
+ expect(normal.outline).toBe("none");
+ expect(normal.shadow).toMatch(/0px 0px 0px 2px/);
+
+ await page.emulateMedia({ forcedColors: "active" });
+ await page.goto("/");
+ const forced = headerRow(page).getByRole("link").first();
+ const rest = await forced.evaluate((el) => getComputedStyle(el).outlineStyle);
+ expect(rest, "a permanent outline in forced colours").toBe("none");
+ await tabTo(page, forced);
+ const focused = await forced.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) };
+ });
+ expect(focused.style).not.toBe("none");
+ expect(focused.width).toBeGreaterThan(0);
+});
+
+test("six links: the header shows the last four at every width, 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]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page)), `${width} px`).toEqual(six.slice(-4));
+ expect(await labelsOf(footerRow(page))).toEqual(six);
+ 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.
+for (const width of [1024, 1280]) {
+ test(`${width} px: the toggle is spaced like a fourth key, and the nav stands apart`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const boxes: { x: number; y: number; width: number; height: number }[] = [];
+ for (const key of await headerRow(page).getByRole("link").all()) boxes.push((await key.boundingBox())!);
+ boxes.push((await themeToggle(page).boundingBox())!);
+ expect(boxes).toHaveLength(TRIO.length + 1);
+ const gaps = boxes.slice(1).map((b, i) => b.x - (boxes[i].x + boxes[i].width));
+ for (const g of gaps) {
+ expect(g, "boxes overlap").toBeGreaterThanOrEqual(-0.5);
+ expect(Math.abs(g - gaps[0]), `gaps ${gaps.join(", ")}`).toBeLessThanOrEqual(1);
+ }
+ const lastNav = page.getByRole("navigation", { name: "Main", exact: true }).getByRole("link").last();
+ const nav = (await lastNav.boundingBox())!;
+ expect(boxes[0].x - (nav.x + nav.width), "the nav's gap before the group").toBeGreaterThan(gaps[0] + 16);
+ });
+}
+
+test("six links, two marked for the header: the header shows those two", async ({ page }) => {
+ writeFixtureSettings(settingsFile(), FIXTURE_SOCIAL_SIX_FEATURED);
+ await page.goto("/");
+ expect(await labelsOf(headerRow(page))).toEqual(["Square", "Bubble"]);
+ expect(await labelsOf(footerRow(page))).toEqual(FIXTURE_SOCIAL_SIX.map((l) => l.label));
+});
+
+test("no links: no row in the header, and no Elsewhere column in the footer", async ({
+ page,
+}) => {
+ writeFixtureSettings(settingsFile(), []);
+ for (const width of [360, 1280]) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ await expect(page.locator("header [data-social-links]")).toHaveCount(0);
+ await expect(page.locator("footer [data-social-links]")).toHaveCount(0);
+ await expect(page.locator("footer").getByText("Elsewhere")).toHaveCount(0);
+ await expect(page.getByRole("link", { name: "Archilyzer home" })).toBeVisible();
+ }
+});
+
+// A settings.json edited by hand (never through a save) holding icons that
+// would run script: none is inlined, none runs, and each link still renders —
+// as its label — while the good icons beside them render as icons.
+test("a stored icon that would run script or load from elsewhere is not inlined: nothing runs, nothing is fetched, the link shows its label", async ({
+ page,
+}) => {
+ const hostile = [...FIXTURE_HOSTILE_SVGS, ...LOADS_ELSEWHERE.map((n) => ADVERSARIAL[n])].map((svg, i) => ({
+ label: `Hostile ${i + 1}`,
+ url: `https://hostile-${i + 1}.example/`,
+ svg,
+ }));
+ writeRawFixtureSettings(settingsFile(), [...FIXTURE_SOCIAL_TRIO.slice(0, 1), ...hostile]);
+ const elsewhere: string[] = [];
+ page.on("request", (r) => {
+ if (new URL(r.url()).origin !== new URL(page.url() || "http://localhost").origin && !r.url().startsWith("data:")) {
+ elsewhere.push(r.url());
+ }
+ });
+ await page.goto("/");
+ await page.waitForLoadState("load");
+ await page.waitForTimeout(300);
+ expect(await page.evaluate(() => (window as unknown as { __hpHostile?: unknown }).__hpHostile)).toBeUndefined();
+ const footer = footerRow(page);
+ for (const h of hostile) {
+ const link = footer.getByRole("link", { name: h.label, exact: true });
+ await expect(link).toHaveAttribute("href", h.url);
+ await expect(link).toHaveText(h.label);
+ await expect(link.locator("svg, img, image")).toHaveCount(0);
+ }
+ // The good icon beside them is still an icon.
+ await expect(footer.getByRole("link", { name: "Leaf", exact: true }).locator("svg")).toHaveCount(1);
+ expect(await page.evaluate(() => document.querySelectorAll("[data-social-links] img, [data-social-links] image").length)).toBe(0);
+ expect(elsewhere.filter((u) => !u.startsWith(new URL(page.url()).origin)), "requests to another origin").toEqual([]);
+});
diff --git a/homepage/e2e/svg-vectors.spec.ts b/homepage/e2e/svg-vectors.spec.ts
@@ -0,0 +1,42 @@
+import { test, expect } from "@playwright/test";
+import { ADVERSARIAL, REAL_SHAPES } from "../../common/lib/socialSvg.vectors";
+import { safeSocialSvg, scopeSvgIds, sizeSocialSvg } from "../../common/lib/socialLinks";
+
+// THE INVARIANT THE READ PATH DEPENDS ON, checked with Chromium's own HTML
+// parser: for every icon the checker accepts (the review's adversarial battery
+// and the shapes real icons take), rendered as a page renders it and parsed as
+// a whole document the way the static HTML carries it, the element after the
+// link stays OUTSIDE the icon, no script runs, and nothing is requested from
+// anywhere.
+
+const accepted = Object.entries({ ...ADVERSARIAL, ...REAL_SHAPES })
+ .map(([name, raw]) => {
+ const safe = safeSocialSvg(raw);
+ return safe ? { name, html: sizeSocialSvg(scopeSvgIds(safe, "sl_S_1_-0")) } : null;
+ })
+ .filter((x): x is { name: string; html: string } => x !== null);
+
+test("every accepted icon leaves the page after it outside the icon, runs nothing and fetches nothing", async ({
+ page,
+}) => {
+ expect(accepted.length).toBeGreaterThan(10);
+ const requests: string[] = [];
+ await page.route("**/*", (route) => {
+ requests.push(route.request().url());
+ return route.abort();
+ });
+ for (const { name, html } of accepted) {
+ await page.setContent(
+ `<!doctype html><html><body><header><a id="l" href="#x">${html}</a><b id="after">after</b></header><main id="main">m</main></body></html>`,
+ );
+ await page.waitForTimeout(50);
+ const r = await page.evaluate(() => ({
+ afterInSvg: !!document.getElementById("after")?.closest("svg"),
+ afterHolder: document.getElementById("after")?.parentElement?.tagName ?? null,
+ mainInSvg: !!document.getElementById("main")?.closest("svg"),
+ x: (window as unknown as { __x?: unknown }).__x ?? null,
+ }));
+ expect(r, name).toEqual({ afterInSvg: false, afterHolder: "HEADER", mainInSvg: false, x: null });
+ }
+ expect(requests, "requests").toEqual([]);
+});
diff --git a/homepage/e2e/theme.spec.ts b/homepage/e2e/theme.spec.ts
@@ -1,9 +1,10 @@
import { test, expect, type Page } from "@playwright/test";
import { REQUIRED_TOKENS } from "../../common/components/themeConfig";
import { ACCENTS, BASE_GROUNDS } from "../../common/lib/brand";
+import { chooseTheme } from "./helpers";
// The shared theme system (common/styles/tokens.css + ThemeScript +
-// ThemeProvider + ThemeToggle/ThemeMenu) as the project's own site uses it: it
+// ThemeProvider + the header's toggle) as the project's own site uses it: it
// opens on the DARK base in Signal, the family's accent. A reader's base
// persists across reloads and is applied before hydration (no flash of the
// wrong theme).
@@ -23,7 +24,7 @@ async function htmlState(page: Page) {
}, BASE_KEY);
}
-test("dark + Signal by default; the base toggle persists with no FOUC", async ({
+test("dark + Signal by default; a base chosen in Options persists with no FOUC", async ({
page,
}) => {
// The OS says light: the default is still dark (it is the site's, not the
@@ -39,16 +40,10 @@ test("dark + Signal by default; the base toggle persists with no FOUC", async ({
brand: ACCENTS.signal.onDark,
});
- const toggle = page.getByRole("button", { name: /switch to/i });
- await expect(toggle).toBeVisible();
-
- // Cycle to an explicit light base (dark → system → light). A click before
- // hydration is lost, so click until it is stored; a click React took commits
- // synchronously, so the check never races it into a second one.
- await expect(async () => {
- if ((await htmlState(page)).stored !== "light") await toggle.click();
- expect((await htmlState(page)).stored).toBe("light");
- }).toPass({ timeout: 10_000 });
+ // An explicit Light base, through the header's toggle (chooseTheme retries
+ // a click until hydration has made it live).
+ await chooseTheme(page, { base: "light" });
+ await expect.poll(async () => (await htmlState(page)).stored).toBe("light");
let s = await htmlState(page);
expect(s.base).toBe("light");
expect(s.dark).toBe(false);
diff --git a/homepage/e2e/toggle.spec.ts b/homepage/e2e/toggle.spec.ts
@@ -0,0 +1,124 @@
+import { test, expect, type Page } from "@playwright/test";
+import { nextBase, type ThemeBase } from "../../common/components/themeConfig";
+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
+// removing a base there needs no change here.
+
+const htmlState = (page: Page) =>
+ page.evaluate(() => ({
+ base: document.documentElement.getAttribute("data-base"),
+ accent: document.documentElement.getAttribute("data-accent"),
+ ready: document.documentElement.getAttribute("data-theme-ready"),
+ }));
+
+for (const width of [320, 360, 390, 768, 1280]) {
+ test(`${width} px: the toggle is in the header, a 36 px key, and there is no other theme control`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto("/");
+ const toggle = themeToggle(page);
+ await expect(toggle).toBeInViewport({ ratio: 1 });
+ const box = (await toggle.boundingBox())!;
+ expect([box.width, box.height]).toEqual([36, 36]);
+ await expect(toggle).toHaveText("");
+ await expect(page.getByRole("button", { name: "Options" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Choose theme" })).toHaveCount(0);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ });
+}
+
+test.describe("under a coarse pointer", () => {
+ test.use({ hasTouch: true });
+ test("the toggle is a 44 px key", async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.goto("/");
+ const box = (await themeToggle(page).boundingBox())!;
+ expect([box.width, box.height]).toEqual([44, 44]);
+ });
+});
+
+test("each click moves to the next base: the page, the icon and the name follow, round the whole cycle", async ({
+ page,
+}) => {
+ // The OS says light, so "system" resolves to light.
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const toggle = themeToggle(page);
+ const start = (await toggle.getAttribute("data-theme-base")) as ThemeBase;
+ expect(start).toBe("dark"); // the homepage's own default
+ const cycle = themeCycle(start);
+ let current = start;
+ for (let i = 0; i < cycle.length; i++) {
+ const next = nextBase(current);
+ await expect(toggle).toHaveAccessibleName(`Switch to ${baseLabel(next)}`);
+ const icon = await toggle.locator("svg").getAttribute("class");
+ await expect(async () => {
+ if ((await toggle.getAttribute("data-theme-base")) === current) await toggle.click();
+ expect(await toggle.getAttribute("data-theme-base")).toBe(next);
+ }).toPass({ timeout: 10_000 });
+ expect(await toggle.locator("svg").getAttribute("class"), `${current} → ${next}: the icon`).not.toBe(icon);
+ const resolved = next === "system" ? "light" : next;
+ await expect.poll(async () => (await htmlState(page)).base).toBe(resolved);
+ current = next;
+ }
+ expect(current, "the cycle comes back to its start").toBe(start);
+});
+
+test("the toggle's focus: the ring, and in forced colours the browser's own outline, none at rest", async ({
+ page,
+}) => {
+ await page.goto("/");
+ const toggle = themeToggle(page);
+ const tabTo = async () => {
+ for (let i = 0; i < 25; i++) {
+ await page.keyboard.press("Tab");
+ if (await toggle.evaluate((el) => el === document.activeElement)) return;
+ }
+ throw new Error("Tab never reached the toggle");
+ };
+ await tabTo();
+ const normal = await toggle.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { outline: s.outlineStyle, shadow: s.boxShadow };
+ });
+ expect(normal.outline).toBe("none");
+ expect(normal.shadow).toMatch(/0px 0px 0px 2px/);
+
+ await page.emulateMedia({ forcedColors: "active" });
+ await page.goto("/");
+ expect(await toggle.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("none");
+ await tabTo();
+ const forced = await toggle.evaluate((el) => {
+ const s = getComputedStyle(el);
+ return { style: s.outlineStyle, width: parseFloat(s.outlineWidth) };
+ });
+ expect(forced.style).not.toBe("none");
+ 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.
+test("a stored accent is ignored: the homepage keeps its own, with no flash, and the stored value stays", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ try {
+ localStorage.setItem("ytdlp-tb:accent", "violet");
+ } catch {}
+ });
+ 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");
+ expect(await page.evaluate(() => localStorage.getItem("ytdlp-tb:accent"))).toBe("violet");
+});
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs";
@@ -5,6 +6,11 @@ import {
FIXTURE_SUMMARY_NAME,
writeFixtureSummary,
} from "./e2e/fixture-summary";
+import {
+ FIXTURE_SETTINGS_NAME,
+ FIXTURE_SOCIAL_TRIO,
+ writeFixtureSettings,
+} from "./e2e/fixture-social";
// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted
// source. Kill any stale dev server on the port between runs.
@@ -20,6 +26,14 @@ import {
// summary it reads instead of
// public/homepage-summary.json (app/lib/summary.ts,
// ignored by a production build)
+// SETTINGS_FILE set below on the dev server: a settings.json
+// holding only the fixture's social links
+// (e2e/fixture-social.ts), never the checkout's
+// own — the header and footer render them
+// SITES_DIR set below on the dev server: an EMPTY
+// directory, so no homepage.json (whose
+// socialLinks would win over settings.json's)
+// is read from the checkout
// E2E_EXPECT_SOURCE `1` (set by whoever runs the suite, after
// `archilyzer source publish`): e2e/source.spec.ts
// FAILS on the /source/ page's empty state instead
@@ -35,6 +49,13 @@ const baseURL = `http://localhost:${PORT}`;
const FIXTURE_SUMMARY = path.resolve(process.cwd(), "e2e", FIXTURE_SUMMARY_NAME);
writeFixtureSummary(FIXTURE_SUMMARY);
+// The same for settings.json: the operator's social links move, and a worktree's
+// settings.json is a copy of theirs. The e2e reads the fixture's (the trio).
+const FIXTURE_SETTINGS = path.resolve(process.cwd(), "e2e", FIXTURE_SETTINGS_NAME);
+writeFixtureSettings(FIXTURE_SETTINGS, FIXTURE_SOCIAL_TRIO);
+const FIXTURE_SITES_DIR = path.resolve(process.cwd(), "e2e", ".e2e-sites");
+fs.mkdirSync(FIXTURE_SITES_DIR, { recursive: true });
+
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
@@ -48,7 +69,11 @@ export default defineConfig({
url: baseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
- env: { E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY },
+ env: {
+ E2E_HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY,
+ SETTINGS_FILE: FIXTURE_SETTINGS,
+ SITES_DIR: FIXTURE_SITES_DIR,
+ },
},
use: {
baseURL,
diff --git a/plans/STATE.md b/plans/STATE.md
@@ -18,8 +18,8 @@ holds the record, the review and the rollout. FACTS has "The stats cache key".
:3001 (until then, never press "Build stats dataset"). Then index, then one full stats pass of
10–30 min, then the homepage, the hub and the sites. The homepage deploy waits on release 12's
step 0: it runs the source publish.
-- **Merge note:** against `homepage/social-visible` (tip `afc642fd`) the only conflict is
- `homepage/CHANGELOG.md`'s `[Unreleased]`. Keep both sides. Against `main` there is none.
+- **Merge note:** `homepage/social-visible` merged `main` (`10cefd15`) at `4d11542c`; the one
+ conflict, `homepage/CHANGELOG.md`'s `[Unreleased]`, kept both sides.
- **FOLLOW-UP, its own slice: the index build still treats an unmounted drive as an empty
channel.** It drops that channel's index records, and the next site build publishes the channel
as gone.
@@ -171,10 +171,32 @@ 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.
-- **Planned, not started (2026-09-28): release 14** — the social icons in the export header, one
- Options button for the theme, 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 H3, H1, H2, S1,
- and S2 as a candidate). Waits on `export/gumroad-tip` merging.
+- **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.
- **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
@@ -24,7 +24,22 @@ main...<branch>` is empty for the search and header components on all five).
- 2026-09-28: "I want the social icons (particularly gumroad) to be more accessible, I'm thinking
we move changelog link to footer, theme and dark/light behind a single options modal button, and
instead of sites dropdown just link to archilyzer home which lists official instances."
-- Standing: **no copy** beside the tip link; the mark is vendored unmodified (`gumroad-tip-link.md`).
+- 2026-09-28: a link the operator adds is an entry in `settings.json` `socialLinks`, not code; no
+ vendor file goes into the repository, not even as a test fixture.
+- 2026-09-28: the homepage's social links more visible; a change to the social-link schema is
+ allowed where it has a reason (built as slice HP, below).
+- 2026-09-28, on the homepage (slice HP): the Changelog link moves to the footer; Base and Accent
+ move behind a single options button with a gear icon that opens a modal; the gear sits in the
+ social icons' rhythm, and the narrow header carries the icons and the gear in its bar; on
+ Official Instances each site's name uses the bold-lead effect of the sites' own headings, with a
+ slight tint or underline in the site's accent.
+- 2026-09-28, on the homepage chart: light mode has dark lines and dark mode has light lines.
+- 2026-09-28, on the homepage: on very small screens the header keeps its mark and drops the word;
+ the social icons scroll only when they still cannot fit.
+- 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).
+- Standing: **no copy** beside any social link: icons with accessible names only.
## Decisions and assumptions
@@ -41,21 +56,24 @@ ASSUMED by the planner (2026-09-28) — each is one line to reverse, and the ope
| A5 | The header's **Hub** link | Dropped with the Sites dropdown; the link to the Archilyzer home covers it. `hubUrl` still parses. |
| A6 | The footer's social row | Stays, as well as the header's — nothing disappears for a reader who looks there. |
| A7 | Phones | The icons are visible in the header at every width, not inside the slide-out menu. |
-| A8 | The options modal | Holds Base and Accent and nothing else. |
-| A9 | The homepage app's own header | Unchanged, except the anchor of H3. Its icons stay in the footer's Elsewhere row. |
+| A8 | The options modal | None: ruled 2026-09-28, a cycling toggle instead (H2); the accent picker is removed in T1. |
+| A9 | The homepage app's own header | Carries the social row and the theme toggle since slice HP (built), as well as the footer's Elsewhere row; H3 adds only the anchor. |
| A10 | Deferring the summaries fetch until the first Search | NOT in S1. Measured and written up as S2, a candidate, because `/ask` reads the same data. |
## Dependency graph
```
-export/gumroad-tip (built 1c76cd95, in review) ──► H1 ──► H2
+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
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`). S1 and H3
-run in parallel with them on their own branches. Merge order: gumroad → H3 → H1+H2 → S1.
+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]`).
## Verified facts the implementer must not re-derive
@@ -89,8 +107,7 @@ run in parallel with them on their own branches. Merge order: gumroad → H3 →
**The footer** (`export/app/components/Footer.tsx`): row 1 (`:32-75`) eyebrow links Downloads /
Offline / Use with AI (`:66-74`) — where Changelog goes; row 2 (`:76-114`) credit + social `<ul>`
(`:97-113`). Social links come from `resolveSocialLinks(site, getSettings())` (`:22`), inlined SVG
-strings sized by `sizeSocialSvg` (`:108`); the Gumroad branch appends its `<li>` last,
-unconditionally.
+strings sized by `sizeSocialSvg` (`:108`).
**Accessibility, measured in code:** social icons are `w-5 h-5` (20 px) with no padding on the
link (`Footer.tsx:107`) — under WCAG 2.5.8's 24 px minimum; the links have no focus ring of their
@@ -131,13 +148,64 @@ trigger, `ui/button.tsx:28`).
**Specs on the header controls:** `theme-accent.spec.ts` (`:32` the trigger by name, `:45-72`
`menuitemradio`), `theme.spec.ts` (`:64-73` the toggle by `/switch to/i`), `responsive.spec.ts:85-97`
(the Sheet's `radio` "Sepia"), `brand.spec.ts:81` (`onBase`). No spec opens the Sites dropdown; no
-spec names the Changelog link. On the Gumroad branch `expectGumroadMarkLast`
-(`export/e2e/helpers.ts`) asserts the mark is the last `<li>` of the FOOTER's row.
+spec names the Changelog link.
UNVERIFIED: whether `HubHome` renders `SearchResults` (`export/app/(workspace)/page.tsx:12-14`
branches to it and bypasses `SiteWorkspace`); Back-button scroll restoration into the virtualized
list; the summaries payload per site (FACTS and the 2026-09-25 session give different figures).
+## Slice HP — the homepage shows the social links where they are seen (branch `homepage/social-visible`, BUILT)
+
+Added 2026-09-28 on the operator's ruling above; the record is `release-14.md`, "Slice HP, as
+shipped". What it built, so H1 does not re-derive it:
+
+1. **`common/components/SocialLinks.tsx`** — one row for any header or footer. Props: `links`,
+ `placement: "header" | "footer"`, `className` (on the `<ul>`); nothing site-specific inside.
+ The `<ul>` carries `data-social-links="<placement>"`. Each link is a 36 px key (`size-9`) around
+ the 20 px glyph, 44 px under a coarse pointer (`pointer-coarse:size-11`, a built-in Tailwind v4
+ variant), `text-muted-foreground` → `hover:text-foreground`, a `focus-visible:ring-2
+ focus-visible:ring-ring` ring, and `not-forced-colors:focus-visible:outline-none`, so in forced
+ colours the browser's own outline stays (a box-shadow is not drawn there). `aria-label` and
+ `title` are the link's label; there is no text. Every icon passes the save-time check again
+ before it is inlined (`safeSocialSvg`); one that fails shows the link's label as text.
+2. **The header bound, `headerSocialLinks`** (`common/lib/socialLinks.ts`, pure): at most FOUR —
+ the links marked `featured` when any is marked, else all of them; of those, the last four. The
+ `"header"` placement applies it; the footer shows every link.
+3. **Ids are scoped per copy** (`scopeSvgIds`): an id resolves to the first element carrying it,
+ and a gradient defined inside a `display: none` copy of an icon does not paint in the visible
+ copy. Any header that renders the row twice (one copy per breakpoint) needs this; the
+ component does it, so H1 gets it for free.
+4. **Schema:** the icon's SVG is checked by an allowlist, tag by tag, on save AND at render
+ (`common/lib/socialSvg.ts`; `safeSocialSvg` in `lib/socialLinks.ts` is the render half, and the
+ export footer already goes through it). A refused save names the reason class. A root with a
+ numeric `width`/`height` (unitless or px) and no `viewBox` gets `viewBox="0 0 W H"`.
+ `SocialLink.featured?: boolean` (stored only when true), with a "Show in header" checkbox in the
+ editor's `SocialLinksField`. `sizeSocialSvg` moved to `lib/socialLinks.ts` (re-exported from
+ `settingsSchema`).
+5. **The theme control is one toggle**: `common/components/ThemeToggle.tsx` with `variant="bare"`
+ (dressed as a social key: 36 px, 44 px under a coarse pointer, no border, the same hover square
+ and ring, a 20 px glyph), cycling the base, named "Switch to {next}"; its default rendering is
+ unchanged. The homepage offers no accent control, so `ThemeScript` and `ThemeProvider` take
+ `pinAccent` there (the stored accent is neither read nor removed). An Options dialog was built
+ first and deleted on the operator's ruling; `common/components/ThemeRadios.tsx` stays (the
+ export's `MobileMenu` renders it).
+6. **The homepage's header:** one group — the social row, then the toggle, boxes touching (glyph
+ to glyph 16 px) — in the bar at every width. ≥ `md`: wordmark · nav · group, the nav 32 px
+ before the group's first box. < `md`: wordmark · group, and the four nav links alone on the
+ rule below (they fit 320 px). No link is hidden by width: when the bar (`@container/bar`) is
+ narrower than the full wordmark, a 12 px gap and the group need, the wordmark's text is hidden
+ and the mark stays — a rem threshold per link count and pointer (full wordmark from 348 px with
+ three links and a mouse, 380 px with touch; with four, 384 and 424 px). The last resort is
+ `common/components/SocialScroll.tsx`: the row scrolls in a box, its end first (rtl box, ltr
+ row), the toggle outside it, focus brings a link into view. Changelog is in the footer only
+ (`homepage/app/lib/nav.ts`: `HEADER_NAV`, `FOOTER_NAV`).
+7. **The Official Instances cards** set each site's title with the shared `Wordmark` when the
+ summary carries its `wordmarkLead` (new, optional, still v5), the lead tinted in the site's own
+ accent (`siteAccentColor`). The hub's cards are not touched.
+8. **The homepage's growth chart** parts its strata with 1 px lines in the ground's foreground
+ (`.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`)
Owns `homepage/app/page.tsx`, `homepage/e2e/marketing.spec.ts`, `common/lib/project.ts` (one
@@ -149,24 +217,27 @@ 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 gumroad merges)
-
-Owns `export/app/components/{Header,MobileMenu,Footer,SiblingSwitcher}.tsx`, a NEW
-`common/components/SocialRow.tsx`, `export/e2e/{helpers.ts,site-branding.spec.ts,brand.spec.ts,
-responsive.spec.ts}`, a NEW `export/e2e/header.spec.ts`, `export/e2e-hub/official-instances.spec.ts`,
-`export/CHANGELOG.md`, `plans/export-responsive-redesign.md` (the header contract, `:258-260`).
-
-1. **One component, two places.** `SocialRow` renders the operator's links and then the Gumroad
- mark, last. Props: `placement: "header" | "footer"`. The footer uses it as it renders today.
-2. **Tap area and focus.** Each link is a 36 px box (`size-9`, the menu trigger's size) around the
- 20 px glyph; on coarse pointers 44 px (`pointer-coarse:` literal classes). A
- `focus-visible:ring-2 focus-visible:ring-ring` ring. The Gumroad ring and forced-colours outline
- move with the mark, unchanged.
-3. **The header, wide:** brand · nav · `SocialRow` · the Archilyzer link · the theme controls (H2).
- **Narrow:** brand · `SocialRow` · the theme controls · the menu trigger. With three icons at
- 36 px the narrow header is about 108 px of icons; if the operator configures more links than fit
- at 360 px, the row keeps the LAST three (the mark is last) and the rest stay in the footer.
- State the rule in a comment and test it with five links.
+## Slice H1 — the header carries the social row (branch `r14/header`, after HP merges)
+
+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
+`export/e2e/header.spec.ts`, `export/e2e-hub/official-instances.spec.ts`, `export/CHANGELOG.md`,
+`plans/export-responsive-redesign.md` (the header contract, `:258-260`).
+
+1. **Adopt HP's `SocialLinks`** (`common/components/SocialLinks.tsx`) in the export header
+ (`placement="header"`) and footer (`placement="footer"`), replacing the footer's hand-written
+ `<ul>`. The component is used as it is; a change it needs goes into HP's component, with its
+ homepage spec re-run.
+2. **Tap area and focus** come with it: 36 px keys, 44 px under a coarse pointer, the ring in
+ `--ring`, the browser's outline in forced colours. The export footer's icons change from
+ `hover:text-brand` to the component's `hover:text-foreground`; say so in the changelog.
+3. **The header, wide:** brand · nav · `SocialLinks` · the Archilyzer link · the theme controls
+ (H2). **Narrow:** adopt HP's approach — no link hidden by width; the header first makes room
+ (for a site's header, whatever of the brand can give way while the mark stays), and HP's
+ `SocialScroll` box is the last resort, the row's end shown first. Measure this header (a long
+ `headerTitle`, the toggle, the menu trigger) and record the widths at which the brand collapses
+ and at which anything scrolls. The bound is HP's: at most four links, the `featured` ones when
+ any is marked, else the last four; the footer shows them all.
4. **Sites dropdown → one link.** `SiblingSwitcher` is deleted. In its place a text link
"Archilyzer" to `INSTANCES_URL`, opening in the same tab, with an accessible name that says
where it goes ("Archilyzer — official instances"). Also in the slide-out menu, replacing the
@@ -174,45 +245,54 @@ responsive.spec.ts}`, a NEW `export/e2e/header.spec.ts`, `export/e2e-hub/officia
5. **Hub backlink removed** from the header and the slide-out menu (A5). `hubUrl` and
`resolveHubUrl` stay; an old `site.json` with the key still loads.
6. **Changelog** leaves both header places and joins the footer's row-1 links after Use with AI.
-7. **Tests.** `expectGumroadMarkLast` takes the row as a parameter; assert it for the header row
- and the footer row on a site page and on the hub. New `header.spec.ts`: the header row is
- visible without scrolling at 360, 768 and 1280 px; each link's box is ≥ 24 px (and ≥ 44 px under
- a coarse pointer emulation); the focus ring shows; the Archilyzer link's `href`; no `Sites`
- button; Changelog is in the footer and not in the header; five configured links → three in the
- header, five in the footer. `responsive.spec.ts`: the slide-out menu's contents as changed.
-8. **No copy.** No text beside any icon; the accessible names are the services' names.
+7. **Tests.** New `header.spec.ts`: the header row is visible without scrolling at 360, 390, 768
+ and 1280 px; each link's box is ≥ 24 px (and ≥ 44 px under a coarse pointer emulation,
+ `hasTouch: true`); the focus ring shows; the Archilyzer link's `href`; no `Sites` button;
+ Changelog is in the footer and not in the header; six configured links → four in the header,
+ six in the footer; two marked `featured` → those two. The export e2e's social links come from
+ its own fixture, never the operator's settings. `responsive.spec.ts`: the slide-out menu's
+ contents as changed.
+8. **No copy.** No text beside any icon; the accessible names are the links' labels.
9. Gates: tsc; common tests; `pnpm --filter export exec next build` (site, fixture site, hub);
export e2e — the specs above plus `theme.spec.ts`, `theme-accent.spec.ts`,
`related-sites.spec.ts`; hub e2e `official-instances.spec.ts`; screenshots of the header at
- 360 / 768 / 1280 px on Light, Sepia and Dark to `~/reports/release-14/shots/`.
-
-## Slice H2 — one options button (same branch, stacked on H1)
-
-Owns `common/components/{ThemeMenu,ThemeToggle}.tsx`, a NEW `common/components/OptionsDialog.tsx`,
-`export/app/components/{Header,MobileMenu}.tsx`, `export/e2e/{theme,theme-accent,responsive,
-brand}.spec.ts`, `export/CHANGELOG.md`. **Check first who else renders `ThemeMenu`/`ThemeToggle`**
-(`homepage/app/components/Header.tsx:62-65` does; the editor may): those keep the old controls.
-The new dialog is the export header's only.
-
-1. One icon button, accessible name **"Options"**, 36 px, visible at every width, where the toggle
- is today. It opens `OptionsDialog` on `common/components/ui/dialog.tsx` (its first importer):
- title "Options", two native radiogroups — **Base** and **Accent** — the markup `MobileMenu`
- already uses (`:124-147`), extracted into one `ThemeRadios` component used by both.
-2. `ThemeMenu` and `ThemeToggle` leave the export header. The slide-out menu keeps its radiogroups
- (one tap there is cheaper than opening a dialog from inside a sheet).
-3. A choice applies at once and the dialog stays open; Escape, the close button and a click
- outside close it; focus returns to the trigger.
-4. **The cost, recorded:** changing the ground is two actions where the toggle made it one. The
- fallback, if the operator asks, is the toggle kept beside the Options button — one line in
- `Header.tsx`.
-5. **Labels are contracts.** `theme.spec.ts` and `brand.spec.ts` drive the toggle by
- `/switch to/i`; `theme-accent.spec.ts` drives `Choose theme` and `menuitemradio`. Rewrite them
- to open Options and pick a `radio` by name; keep one helper, `chooseTheme(page, {base, accent})`,
- in `export/e2e/helpers.ts`, and use it everywhere a spec changes theme through the UI. Specs that
- set `localStorage` directly are untouched. The no-flash assertions (`data-theme-ready`) are
+ 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)
+
+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.
+
+1. **The export header's theme control is the cycling toggle** (`ThemeToggle`, `variant="bare"`,
+ built by slice HP) in place of `ThemeMenu` + `ThemeToggle`: one key at the end of the header's
+ group, visible at every width. No options dialog. The accent picker is removed from the export
+ header (the export's `ThemeProvider`/`ThemeScript` take `pinAccent`, as the homepage's do).
+2. The slide-out menu keeps its Base radiogroup (`ThemeRadios`) until T1 drops Sepia and the accent
+ picker everywhere.
+3. **Labels are contracts.** `theme.spec.ts` and `brand.spec.ts` drive the toggle by
+ `/switch to/i`; `theme-accent.spec.ts` drives `Choose theme` and `menuitemradio` and changes
+ with the accent picker's removal. Keep one helper, `chooseTheme(page, { base })`, in
+ `export/e2e/helpers.ts` (the homepage's is `homepage/e2e/helpers.ts`: click the toggle until the
+ base is reached), deriving the cycle from `THEME_BASES` / `nextBase`. Specs that set
+ `localStorage` directly are untouched. The no-flash assertions (`data-theme-ready`) are
untouched.
-6. Gates: as H1, plus an axe-style check that the dialog traps focus and restores it, and the
- homepage e2e `theme.spec.ts` to prove the homepage's controls did not move.
+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)
+
+Owns `common/components/theme*` (`themeConfig.ts`, `ThemeProvider.tsx`, `ThemeScript.tsx`,
+`ThemeToggle.tsx`, `ThemeMenu.tsx`, `ThemeRadios.tsx`), `common/styles/tokens.css`, the three apps'
+`globals.css`, the three headers and the export's `MobileMenu`, and the specs that mention Sepia.
+
+1. **Drop the Sepia base in every app:** the tokens, `THEME_BASES`, `nextBase`, the no-flash
+ script; the legacy `archive` → sepia migration now maps to light, and a stored `sepia` migrates
+ to `light`.
+2. **Remove the accent picker** from the export and editor headers and the slide-out menu; a stored
+ accent is ignored (as the homepage's `pinAccent` does).
+3. **Specs updated.** The parent's count of what mentions Sepia: 40 files, 154 lines, 16
+ specs/tests. The homepage's `toggle.spec.ts` derives its cycle from `THEME_BASES`, so it needs
+ no change beyond the base's removal.
+4. Gates: tsc; common tests; the three apps' builds; the three e2e suites.
## Slice S1 — a clear screen until the first Search (branch `r14/first-search`)
@@ -271,9 +351,10 @@ until the user hits search") and needs the operator's word, because it changes `
Nothing here deploys inside a slice. After the merges: one export release cut, a rebuild and
deploy of every site and the hub (the header is in every build), and the homepage deployed FIRST
-so `#instances` exists before any site links to it. The homepage deploy runs the source publish
-step of release 12 — its denylist must be complete (`release-12.md`, "Rollout", step 0). Live
-checks: the header row visible at 390 px on one site; the mark's `href`; the Archilyzer link lands
+so `#instances` exists before any site links to it. HP adds an editor cut and a :3001 rebuild and
+restart, for the "Show in header" checkbox and the normalizer's viewBox rule, which are in the
+editor's save path. The homepage deploy runs the source publish step of release 12 — its
+denylist must be complete (`release-12.md`, "Rollout", step 0). Live checks: the header row visible at 390 px on one site; each icon's `href`; the Archilyzer link lands
on Official Instances; a plain visit shows the footer without scrolling; a `qt=` link still shows
results. An HTML runbook at `~/reports/release-14/RUNBOOK.html`.
@@ -283,9 +364,9 @@ results. An HTML runbook at `~/reports/release-14/RUNBOOK.html`.
- **A first-time visitor sees no videos.** The site's front page becomes a search box over a
count. That is the operator's choice; the hint line is the only instruction.
- **Icons in a 360 px header** compete with the brand's title: a long `headerTitle` wraps
- (`min-h-14` allows it). The screenshots at 360 px are the check; the rule of three keeps the row
+ (`min-h-14` allows it). The screenshots at 360 px are the check; the bound of four keeps the row
bounded.
-- **A trademark in the header** is more prominent than one in the footer. The mark stays
- unmodified, unlabelled and only a link.
+- **An operator's icons in the header** are more prominent than in the footer. Each stays as the
+ operator pasted it (normalized, never redrawn), unlabelled and only a link.
- **Specs outside the known three** may lean on the listing at load through a hydration wait; the
full-suite run in S1's gate is how they are found.
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -0,0 +1,449 @@
+# Release 14 — the social icons within reach, and a clear screen until the first Search
+
+`main` at `ac438bbc` (release 12 merged and not rolled out; release 13 is a parallel session's).
+Plan: [`export-header-first-search.md`](export-header-first-search.md), written 2026-09-28, with
+slice HP added to it on the operator's ruling of the same day. Rules:
+`plans/tools/implementer-rules.md`, with the commit trailer this release's prompts give.
+
+**The operator's standing choices** (the plan, "The operator's words"; not re-opened):
+- **No copy** beside any social link: icons with accessible names only.
+- **A link the operator adds is an entry in `settings.json` `socialLinks`, never code.** The earlier
+ tip-link branch is parked and not merged; nothing is taken from it.
+- **No vendor file in the repository**, not even as a test fixture: the tracked tree is published
+ by the source mirror.
+- **Nothing is edited in the primary checkout**; each slice has its own worktree, and the parent
+ merges with `git merge --no-ff` only on a clean tree.
+
+## The slices
+
+| 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 |
+
+**Order:** HP → H3 → H1+H2 → S1. The shared files are `editor/CHANGELOG.md`,
+`homepage/CHANGELOG.md` (`[Unreleased]`) and this record.
+
+## Record
+
+### Slice HP, as shipped — the homepage shows the social links where they are seen (2026-09-28)
+
+Branch `homepage/social-visible` off `main` `ac438bbc`, worktree `~/Projects/homepage-social-visible`
+(block #3: editor 3301, test 3311, homepage e2e 3340, homepage static 3331), one Opus implementer.
+Scratch files `hp-*` in the job's `tmp`. The rulings, all 2026-09-28, built in this order on one
+branch:
+1. The homepage's social links more visible; a change to the social-link schema where it has a
+ reason.
+2. The Changelog link moves to the footer; Base and Accent move behind a single options button with
+ a gear icon that opens a modal.
+3. The gear sits in the social icons' rhythm, with no gap of its own; the narrow header carries the
+ icons and the gear in its bar.
+4. On Official Instances, each site's name uses the bold-lead effect the sites' own headings use,
+ with a slight tint or underline in the site's accent colour.
+5. (The review, ruled by the parent.) No vendor file in the repository; the icon check hardened on
+ save and at render; the shared dialog wrapper unchanged.
+6. On the homepage chart, light mode has dark lines and dark mode has light lines.
+7. On very small screens the header keeps its mark and drops the word; the social icons scroll only
+ when they still cannot fit (rather than hiding icons on small screens).
+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.
+
+**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,
+re-committed from the same tree in the same logical steps, each tsc-clean. No vendor file is in
+any commit of `main..HEAD`. Everything after `afc642fd` is new commits on top, and `main`
+is merged in twice (`10cefd15`, then `918e5f85`).
+
+**The Options dialog was built, then replaced by the toggle** (ruling 8): `OptionsDialog` (a gear
+opening a modal with Base and Accent) shipped in `4d93dc84` and was deleted in `5da60518`, with
+`options.spec.ts` and its screenshots. `ThemeRadios` stays: the export's slide-out menu renders it.
+
+**What shipped.**
+- **One row, `common/components/SocialLinks.tsx`.**
+ - Props `links`, `placement: "header" | "footer"`, `className` (on the `<ul>`, which carries
+ `data-social-links="<placement>"`); nothing site-specific.
+ - Each link is a 36 px key (`size-9`) around the 20 px glyph, 44 px under a coarse pointer
+ (`pointer-coarse:size-11`, Tailwind v4's own variant). The glyph is `text-muted-foreground`,
+ `hover:text-foreground`, with a `hover:bg-muted` key, so a multi-colour icon has a hover state
+ too. Focus is `focus-visible:ring-2 focus-visible:ring-ring` with
+ `not-forced-colors:focus-visible:outline-none`: in forced colours a box-shadow is not drawn,
+ and the browser's own outline is left in place. Nothing is drawn at rest.
+ - `aria-label` and `title` are the label, `target="_blank" rel="noopener noreferrer"`, and the
+ link has no text.
+ - The header placement shows at most four (`headerSocialLinks`, below); the footer every link.
+ - Every icon passes the save-time check AGAIN before it is inlined (`safeSocialSvg`, below). One
+ that fails is not injected: the link shows its label as text.
+ - The ids inside each inlined icon are scoped per row and per link (`scopeSvgIds`, below).
+ - It uses only `useId`, so it works in a server or a client tree.
+- **The header bound, `headerSocialLinks`** (`common/lib/socialLinks.ts`, pure). At most four: the
+ links marked `featured` when any is marked, else all of them; of those, the last four. Marking is
+ choosing: one marked of three shows one.
+- **The homepage header** (`homepage/app/components/Header.tsx`): one group — the social row, then
+ the theme toggle — in the bar at every width. The toggle is dressed as a key and its box sits
+ directly after the last link's, so every glyph is 16 px from the next. From `md` (768 px) the bar
+ is wordmark · nav · group, the nav's last link 32 px before the group's first box (40 px before
+ its first glyph; the nav's own links are 24 px apart). Below `md` it is wordmark · group, and the
+ four nav links have the rule below to themselves, where they fit at 320 px. No link is hidden by
+ width (ruling 7): on a very small screen the wordmark's text goes first, and the row scrolls only
+ as the last resort (see "The header layout").
+- **Changelog is in the footer only** (ruling 2). `homepage/app/lib/nav.ts` declares `HEADER_NAV`
+ (Docs, Source, Downloads, Stats) and `FOOTER_NAV` (the same, then Changelog). The footer's
+ Sections and the 404 page read `FOOTER_NAV`.
+- **One theme toggle** (ruling 8, replacing ruling 2's dialog): `common/components/ThemeToggle.tsx`
+ with a new `variant="bare"` — dressed as a social key (36 px, 44 px under a coarse pointer, no
+ border, the hover square, the ring, a 20 px glyph), cycling the base in `nextBase`'s order and
+ named "Switch to {next}". Its default rendering, which the export and the editor use, is
+ unchanged.
+ - **The accent is pinned on the homepage**: with no accent control there, `ThemeScript` and
+ `ThemeProvider` take `pinAccent`, so the stored `ytdlp-tb:accent` is neither read nor removed
+ and the homepage keeps Signal, with no flash (the pre-paint script skips the read). Without the
+ prop both are unchanged (unit-tested: the script string is identical).
+- **The instance cards' names** (ruling 4).
+ - `homepage-summary.json` `sites[]` gains an optional `wordmarkLead`: site.json's, resolved
+ against `siteTitle` by `lib/brand.ts` `wordmarkLeadFor`, the resolver the sites' header and
+ `siteSchema` use. It is still version 5: nothing reads the version to accept a file.
+ - The homepage's loader keeps a lead only when it is a proper prefix of the title
+ (`withCheckedLead`), so an older or hand-edited summary shows the plain title.
+ - `ArchiveCards` sets the title with the shared `Wordmark` (lead 720, suffix 380) at the card
+ title's size, and tints the lead in the site's own accent: `siteAccentColor`, `siteColor`'s
+ accent half. A site with a lead and no accent keeps the foreground. `Wordmark` gains
+ `leadStyle`.
+ - `headerTitle` is not carried: the card's accessible name is `siteTitle`, and every live site
+ has the two equal.
+- **The social icon's SVG, checked by an allowlist** (`common/lib/socialSvg.ts`, pure;
+ `settingsSchema.ts` re-exports `normalizeSocialSvg` and `socialSvgProblem`). The allowlist was
+ chosen over the denylist: the element and attribute lists stay small, and every shape the
+ existing tests and icons use passes.
+ - The input is read tag by tag. It must be ONE well-formed `<svg>`: tags closed in order,
+ attributes separated by HTML whitespace and quoted, no `<!…>`, CDATA or processing
+ instruction. Comments, a leading XML declaration and a leading DOCTYPE with no internal subset
+ are removed first.
+ - Elements: shapes, groups, `defs`, `symbol`, `use`, gradients, `stop`, `pattern`, `clipPath`,
+ `mask`, filters, `text`/`tspan`, and `animate`/`animateTransform`/`set`. No `script`, `style`,
+ `foreignObject`, `a`, `image`, `title`, `desc` or HTML element (R1: a title or desc is an HTML
+ integration point, where a child element left the icon unclosed around the page; one holding
+ text only is removed first).
+ - Attributes: the SVG presentation, geometry, filter and animation set, plus `aria-*`, `data-*`
+ and `xmlns:*`. No event handler, whatever separates it.
+ - Values: character references (numeric and named, with or without `;`) are decoded and the
+ whitespace a browser ignores in a URL dropped before the checks. No `javascript:` or
+ `vbscript:`; no backslash (a CSS escape) or CSS comment; no function that loads anything
+ (`image-set(`, `-webkit-image-set(`, `image(`, `cross-fade(`, `element(`, `src(`, `paint(`,
+ `@import`, `expression(`) — an animation's `to`/`from`/`values`/`by` included (R2).
+ `href`/`xlink:href` only a plain `#id` as written; every `url(…)` only to `#id`, spelled so the
+ render's scoping rewrites it (R6). A `style` holds presentation properties only (fill, stroke,
+ stop-color, the opacities, stroke width/caps/joins, fill-rule, clip-rule, display, visibility,
+ paint-order). An animation never targets anything named `href` or a handler (R5). Ids are
+ plain names (`^[A-Za-z_][\w.:-]*$`).
+ - The normalized OUTPUT is checked again, so no transform can assemble what the input check
+ refused.
+ - `socialSvgProblem` names the reason class. The editor's save error and the writers' errors
+ append it ("… has an invalid SVG: it has an event handler attribute."); none echoes the markup.
+ - `SETTINGS.md` and `SITE.md` (generated) say what an icon may contain.
+- **The read path.** `safeSocialSvg` (`common/lib/socialLinks.ts`) runs the check again at render.
+ `SocialLinks` and the export footer inline only what passes; a link whose icon fails shows its
+ label as text, capped at 10rem with an ellipsis and the full label in `title` (R7). Every
+ `dangerouslySetInnerHTML` of a social SVG goes through it (there are two). Each key clips its
+ icon's paint (`overflow-hidden`, `contain: paint`; R4); the focus ring is the key's own shadow,
+ outside that clip.
+- **The write path** (R3): `socialLinksForSave` keeps a link whose `svg` is byte-identical to one
+ already stored exactly as it is, and checks only a new or edited icon — in `writeSettings`,
+ `writeSite`, `writeHomepageConfig` and the editor's settings and site actions. So a lane pause, a
+ priority or a title never fails on an icon an older build stored; the render shows such an icon
+ as its label, and `archilyzer doctor` names it ("social icons": file, label, reason class, never
+ the markup). In this worktree the doctor line reads "3 icons in 1 file pass the check".
+- **The size rule** (ruling 1): a root with no viewBox but a numeric `width` and `height`
+ (unitless or px, decimals, either quote) gets `viewBox="0 0 W H"`, read from the root's own
+ attributes (a size spelled inside another attribute's value does not count). A percentage,
+ `em`, `auto`, a negative, or a missing or zero side is still refused, and a viewBox already
+ there is never replaced.
+- **`featured`**: optional on `SocialLink`, parsed only when exactly `true` and stored only when
+ true, through `parseSocialLinks`, so `settings.json`, `site.json` and `homepage.json` all read
+ it. The editor's `SocialLinksField` has a **Show in header** checkbox per row (Settings and a
+ site's form), and beside it "With none checked, the header shows the last four." An old file
+ with neither change parses byte-identically. `featured` also reaches each site's public
+ `/site.json` (`buildSiteDescriptor` passes the link through): accepted, additive, and the hub
+ does not render federated sites' links.
+
+**The header layout** (built and screenshotted before each choice). Measured: the wordmark link is
+148 px (152 px at 1× device scale), the four-link nav 276 px, a key 36 px (44 px under a coarse
+pointer), the toggle the same.
+- **No link is hidden by width** (ruling 7): the header shows its (at most four) links at every
+ width. The bar is a size container (`@container/bar`), and when it is narrower than the full
+ wordmark, a 12 px gap and the group need, the wordmark's TEXT is hidden and the mark stays (the
+ link is still "Archilyzer home"). The threshold is a class per link count and pointer, in rem so
+ it follows the text size (`WORDMARK_FITS`, measured, not guessed):
+
+ | Links | Full wordmark with a mouse | Full wordmark with touch |
+ |---|---|---|
+ | 1 | from 276 px | from 292 px |
+ | 2 | from 312 px | from 336 px |
+ | 3 | from 348 px | from 380 px |
+ | 4 | from 384 px | from 424 px |
+
+ Measured at the threshold the gap is exactly 12 px; 1 px below, the text is hidden.
+- **With the mark alone** (the fixture; the gap between the mark and the group): with a mouse, 3
+ links — 320: 108 px, 340: 128; 4 links — 320: 72, 340: 92, 360: 112. With touch, 3 links — 320:
+ 76, 340: 96, 360: 116; 4 links — 320: 32, 340: 52, 360: 72. Four 44 px links, the toggle and the
+ mark fit a 320 px screen.
+- **The last resort** (`common/components/SocialScroll.tsx`): the row
+ scrolls inside a box, its END shown first (the box is `direction: rtl`, the row `ltr` and
+ `w-max`, so the first scroll position is the right edge — no script, no change to the DOM or tab
+ order), no scrollbar, the toggle outside it, the header never wider than the screen. A link that
+ takes focus is scrolled into view with its ring (the one bit of script: Chromium does not bring
+ a partly hidden link into view inside an rtl box). From 300 px up, with default text, nothing
+ scrolls; it takes 280 px with four 44 px links, or 320 px at a 200 % text size. Checked in
+ Chromium and in an installed Firefox build (end first, nothing sideways); WebKit UNVERIFIED (the
+ installed build does not match this Playwright).
+- **What a visitor sees, by width** (default text size): the full wordmark from the table's width
+ up; the mark alone below it, down to 300 px; the scroll fallback only below that, or at a much
+ larger text size.
+- **Two rows at 360 px** (a mouse, three links): the wordmark (20–168), 28 px, the group (196–340:
+ three keys and the toggle), the 20 px gutter; below, the four nav links (20–291), 49 px to spare.
+- **How it got here:**
+ - First build, with two 32 px theme buttons and five nav links: three keys in the bar made a
+ 360 and a 390 px page scroll sideways by 36 px, so the keys were pinned at the end of the nav's
+ rule, the nav in that rule below `lg`, and the row rendered twice.
+ - Rulings 2 and 3 freed the bar: one gear, four links, and the group in the bar at every width.
+ - The review found 320 px with a mouse scrolled sideways by 8 px; a width × pointer rule then hid
+ links step by step (`6c500818`).
+ - Ruling 7 replaced that rule with the collapsing wordmark and the scroll fallback (`0b3189fb`),
+ and ruling 8 the gear with the toggle, the same size (`5da60518`).
+
+**The cards' accent treatment** (ruling 4). Both treatments were built and screenshotted on the
+three grounds at 390 and 1280 px, from a family-like summary: the six live titles, their site.json
+leads (Jer, Hasan, Ani, Bonnell, Rekieta, Jaso) and their named accents; the numbers are synthetic.
+- **Shipped: the tint.** The lead is in the site's accent, the suffix in the muted foreground.
+ - The accent is one signal on the name and matches the card's stripe.
+ - The underline sat under the card link's own hover underline, and on hover the lead drew two
+ rules. An accent rule also reads as a link state.
+- **Contrast of the lead against the card** (`--surface`): every named accent is at least 4.24:1 on
+ Light, 4.21:1 on Sepia and 6.75:1 on Dark. A custom hex fitted to 4.5:1 on the ground is about
+ 4.1:1 on the card. The e2e checks ≥ 3:1 from the rendered colours.
+- **The swap**, if asked: replace `{ color: accent }` in `ArchiveCards`' `SiteName` with a 2 px
+ accent `text-decoration` on the lead. It is one line.
+
+**The chart's separators** (ruling 6; after the review, a new commit on top). The line along each
+stratum's upper edge in `homepage/app/components/ArchiveGrowthChart.tsx` was the page background
+(`var(--background)`, 1.25 px): light on Light, dark on Dark. It is now `.growth-sep` in
+`homepage/app/globals.css`: `var(--foreground)`, full strength, 1 px, and `CanvasText` in forced
+colours. The gridlines are unchanged.
+- **Three strengths built and screenshotted** (the foreground at 100 %, 60 % and 35 %, 1 px; the
+ chart at 1280 and 390 px and a 4× close-up of the thin strata, on the three grounds; the
+ published summary): `~/reports/release-14/shots/chart/sep-{100,60,35}-…`.
+- **Shipped: 100 %.** Only full strength reaches 3:1 against any band; 60 % reaches it against
+ none (at best 2.69:1) and 35 % against none (1.81:1). At 1 px, a 2–3 px stratum keeps a coloured
+ stripe between its two lines in the close-ups; the old gap was 1.25 px.
+- **Against the six bands, measured on the page** (the separator against each band's rendered
+ colour):
+ - Light: blue 4.25, amber 4.24, green 4.00, magenta 3.80, violet 2.17, rust 2.13 — minimum
+ **2.13**;
+ - Sepia: amber 3.55, blue 3.17, magenta 2.95, green 2.84, violet 1.81, rust 1.78 — minimum
+ **1.78**;
+ - Dark: rust 4.77, blue 4.63, magenta 3.64, violet 2.63, amber 2.54, green 2.49 — minimum
+ **2.49**.
+ With the ground's foreground, 3:1 is out of reach against those bands: Light violet and rust;
+ Sepia green, magenta, violet and rust; Dark green, violet and amber. The series colours are
+ unchanged, as ruled for this commit.
+- **Homepage-only.** The separator is in the homepage's own chart. The shared charts
+ (`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.
+
+**Beyond the prompt.**
+- **Icon ids are scoped per copy (`scopeSvgIds`).** An id resolves to the first element carrying
+ it, and a gradient defined inside a `display: none` copy does not paint. The first build rendered
+ the header's row twice; with the ids unscoped, the gradient icon at 360 px lost its body and kept
+ only its themed dot. The header's row is one element now, and the scoping stays: the footer
+ inlines the same icons, and the export's header (H1) may render a row per breakpoint. Only plain
+ ids are prefixed; references in `url(#…)`, `url('#…')`, `url("#…")`, `href` and
+ `xlink:href` are rewritten, case-insensitively for the attribute names. A scoped, sized icon
+ passes the checker again (unit-tested).
+- **`sizeSocialSvg` moved** to `lib/socialLinks.ts` and is re-exported from `settingsSchema`.
+- **The homepage e2e read the checkout's own `settings.json` and `homepage.json`.** The dev server
+ now reads `e2e/.e2e-settings.json` through `SETTINGS_FILE` and an empty `e2e/.e2e-sites/` through
+ `SITES_DIR` (both declared variables). The icons are synthetic: a gradient with one solid colour,
+ one colour drawn white, and a two-colour disc with a letter pasted with only its size. Specs that
+ need six links, none, or hostile icons rewrite the file (hostile ones raw, as a hand-edited file
+ holds them), and an `afterEach` restores the trio. `marketing.spec.ts`'s "no link to ko-fi.com"
+ runs against the fixture's links and still catches a link in code.
+- **The footer's icons are `--muted-foreground`, no longer `--faint`**, the same as the header's.
+ On the page ground that is 5.63 / 5.51 / 7.08:1 (Light / Sepia / Dark) against `--faint`'s
+ 3.45 / 3.59 / 3.53:1.
+
+**Corrections to the prompt.**
+- `NAV` had five entries at `ac438bbc`, not six.
+- The homepage e2e baseline at `ac438bbc` was 36 passed, 0 skipped (1.0 min) in the empty-source
+ state. The parent later copied a published source into the worktree, so the later runs are in
+ the published-source state.
+
+**What each icon looks like, per ground** (the fixture's synthetic icons, which behave as an
+operator's pasted icons do):
+- **The gradient icon:** the gradient keeps its colours on every ground. Its one solid part follows
+ the link colour: slate on Light, brown on Sepia, warm grey on Dark, and the foreground on hover.
+- **The one-colour icon:** the muted foreground on every ground (`#55646e` / `#6b5c43` /
+ `#a39a86`), and the foreground on hover.
+- **A two-colour disc with a dark outline and a dark offset disc:** on Light and Sepia the outline
+ and the offset crescent show; on Dark both fall into the `#0c0a08` ground, and the disc reads flat.
+ This is accepted, as ruled: no per-icon ring.
+- **In forced colours:** single-colour parts take the link colour; the disc and the gradient keep
+ theirs.
+
+| sha | what |
+|---|---|
+| `518dd272` | `common:` `lib/socialSvg.ts`, the allowlist checker on save and at render with reasons; the size rule; `featured`; `lib/socialLinks.ts` (header rule, `safeSocialSvg`, id scoping, `sizeSocialSvg`); the writers' errors; the tests; `SETTINGS.md` / `SITE.md` |
+| `a9dfa3de` | `editor:` "Show in header" with its hint; `socialLinksJson` + unit test; the save errors name the reason; the settings spec reads `featured` back |
+| `4d93dc84` | `common:` `SocialLinks`, `OptionsDialog` (later deleted), `ThemeRadios`; `export:` `MobileMenu` renders `ThemeRadios`, the footer inlines only through `safeSocialSvg` |
+| `6c500818` | `homepage:` the social row and the gear in the header at every width, the width × pointer rule (later replaced); Changelog in the footer only; the e2e's own settings and sites; the specs |
+| `88d61908` | `homepage:` the cards' names as the site's wordmark, the lead tinted; `wordmarkLead` in the summary and its check on read; `siteAccentColor`; `Wordmark` `leadStyle`; `instance-wordmark.spec.ts` |
+| `afc642fd` | `plans:` this record; the plan's slice HP, H1 and H2; STATE; the changelogs |
+| `031c2115` | `homepage:` the growth chart's separators in the ground's foreground, 1 px, full strength (ruling 6); `growth-chart.spec.ts`; the record, the plan, the changelog |
+| `0b3189fb` | `homepage:` the wordmark's text drops first on a very small screen (a container query per link count and pointer); `SocialScroll`, the last-resort scroll box, end first; no link hidden by width (ruling 7) |
+| `5da60518` | `homepage:` one toggle (`ThemeToggle variant="bare"`) in place of the Options dialog; the homepage's accent pinned (`pinAccent`); `OptionsDialog` and `options.spec.ts` deleted; `toggle.spec.ts` (ruling 8) |
+| `68ad1464` | `common:` the checker drops title/desc, refuses loading functions, escapes and comments, allows presentation styles only, plain references, reasons that say how to export; the review's vectors (re-review R1, R2, R5, R6, R8, R9) |
+| `0be068cf` | `common, editor:` `socialLinksForSave` — an unchanged stored icon never blocks a save; `archilyzer doctor` names stored icons that fail (R3) |
+| `4f7bc097` | `common, export, homepage:` each key clips its icon's paint; a refused icon's label bounded; `svg-vectors.spec.ts` (the parser invariant, nothing fetched); the hostile e2e checks no other origin (R1, R2, R4, R7) |
+| `4d11542c` | merge `main` (`10cefd15`, the stats fix); `homepage/CHANGELOG.md` both sides kept |
+| `fe9fb4b2` | merge `main` (`918e5f85`, slice Q's umtool build trace); `editor/CHANGELOG.md` both sides kept, `main`'s bullet under the one it refers to |
+| _this_ | `plans:` this record; the plan's slices HP, H1, H2 and the new T1; STATE; the changelogs |
+
+**Gates** (at `4d11542c`, after the first merge of `main`; logs `$T/hp11-*.log`. The second
+merge, `fe9fb4b2`, brings only umtool, `scripts/`, plans and an editor changelog bullet; tsc and
+`test:scripts` were run again after it: see the last line):
+- **tsc** was clean before each commit since `afc642fd`, and before this one.
+- **Unit:** common **2,202/2,202**; editor unit **87/87**; homepage unit **8/8**; `test:scripts`
+ **185 + 1 skipped**; mcp **271/271**.
+- **Docs:** `settings example --check`, `docs files --check` and `docs env --check` all exit **0**.
+- **Doctor:** the new section prints `social icons` / `ok stored icons 3 icons in 1 file pass the
+ check` (the worktree's `settings.json`, the parent's copy; no other file there holds icons).
+- **Builds:** homepage **ok** (22 s, `pnpm --filter homepage exec next build` with the fixture
+ icons), export **ok** (34 s), editor **ok** (59 s).
+- **Homepage e2e, full suite:** **75 passed, 0 failed, 0 skipped (1.7 min)**: `social` 24,
+ `marketing` 10, `toggle` 9, `stats` 7, `source` 5, `brand` 4, `docs` 4, `downloads` 3, `theme` 3,
+ `growth-chart` 2, and one each in `svg-vectors`, `instance-wordmark`, `instance-colours` and
+ `no-data`. `options.spec.ts` went with the dialog.
+- **Export e2e** (`site-branding brand related-sites theme theme-accent responsive archives-off`):
+ **35 passed, 0 failed (1.6 min)**. The theme specs are unchanged: `ThemeToggle`'s default
+ rendering is the export's as before.
+- **Editor e2e** (`settings sites-crud`): **26 passed, 0 failed (1.8 min)**.
+- **Browsers:** the scroll box's end-first start and focus scrolling are covered in Chromium by the
+ suite and were checked by hand in Firefox at 240 px. **WebKit is unverified**: the installed
+ revision does not match this Playwright's.
+- **Earlier rounds:** at `88d61908`, common 2,179, editor unit 86, mcp 269, homepage e2e 68, export
+ e2e 35, editor e2e 45 (with `export-search`); before the review, homepage e2e 36 → 50 → 59 → 63.
+- **Screenshots** (`~/reports/release-14/shots/`, at 2×):
+ - `hp4/` (64), the narrow header, from a dev server on 3330 reading synthetic settings
+ (`SETTINGS_FILE`: the fixture's three icons, or six so the header shows four) and an empty
+ `SITES_DIR`:
+ - `header-{3,4}icons-{320,340,360,390}-{fine,coarse}-{light,sepia,dark}.png`;
+ - `header-{3,4}icons-{768,1280}-{light,sepia,dark}.png`;
+ - the wordmark is collapsed in every 320 and 340 px shot, at 360 px in all but three icons with
+ a mouse, and at 390 px with four icons under touch;
+ - `fallback-280-coarse-rest-dark.png`: four icons under touch at 280 px, the box's end in view;
+ - `fallback-280-coarse-start-dark.png`: the same after Tab reaches the first icon, the box at
+ its start;
+ - `fallback-320-200pct-text-{rest,start}-dark.png`: the same pair at 320 px with the text at
+ 200 %.
+ - `hp5/` (19), the toggle: `header-{320,360,390,768,1280}-{light,sepia,dark}.png` (three icons,
+ the toggle last) and `forced-toggle-{unfocused,focused}-{light,dark}.png`.
+ - `chart/`: the separators at 100, 60 and 35 %, per ground (the chart's section above).
+ - `hp3/`: the cards' tint and underline; the header work does not reach them.
+ - `hp2/` (the gear header) and `hp/` (the first round) are superseded; `hp2`'s dialog shots are
+ deleted.
+ - **The worktree's `homepage/out` is a fixture build: never deploy it; rebuild first.**
+- **Numbers tool:** none.
+- **After the second merge** (`fe9fb4b2`; `$T/hp12-post.log`): tsc clean in every package (42 s);
+ `test:scripts` **188 + 1 skipped**, `main`'s new guard on umtool's build trace among them.
+
+**They bite** (each change made by hand in the worktree, the specs run, the change reverted):
+- The separators in `var(--background)`: `growth-chart.spec.ts`'s ground test fails (Light: the
+ stroke is the ground's own colour).
+- `WORDMARK_FITS` taken off the wordmark: 9 of `social.spec.ts` fail (320, 340 and 360 px with a
+ mouse; 320–390 px under touch; the 280 px fallback; the 200 % text).
+- `pinAccent` taken out of `layout.tsx`: `toggle.spec.ts`'s stored-accent test fails.
+- `title` and `desc` back in the allowlist: `svg-vectors.spec.ts` fails on `title_child_el` (the
+ page's `<main>` parsed inside the icon).
+- The loading-function check skipped: the hostile-icon test fails (an `image-set(` icon is inlined
+ instead of its label).
+- From the earlier rounds:
+ - `social.spec.ts` with the id scoping, the header bound and `pointer-coarse:size-11` removed:
+ 5 failed.
+ - The hostile-icon spec fails without the read path.
+ - The loader test fails without `withCheckedLead`.
+ - `instance-wordmark.spec.ts`'s "Fix" + "ture Three" fails if the wordmark's spans stop being
+ adjacent inline text.
+
+#### Review (verdict SHIP AFTER FIXES; `$T/hp-review.md`)
+
+| Finding | Fix |
+|---|---|
+| M1: 320 px with a mouse scrolled sideways by 8 px | `6c500818`: the width × pointer rule, since replaced by ruling 7 (`0b3189fb`) |
+| M2: the `ui/dialog.tsx` change restyled the editor's command palette | `4d93dc84`: the wrapper is `main`'s, byte for byte (the dialog it served is deleted since) |
+| M3: a vendor file as a test fixture would be published by the source mirror | ruled: no vendor file. The branch was rewritten so the file and its notice never entered it; D1 is tested with synthetic sized SVGs; the e2e disc is synthetic |
+| M4: five inputs passed the normalizer and ran script | `518dd272` (the allowlist checker, the output re-check, the reasons), `4d93dc84` (the read path in both renderers); the review's five inputs and each class tested at the normalizer, the read path and in the browser |
+| L1, L2: id scoping | `518dd272`: plain ids only (the checker refuses others; comments are removed), entity-quoted and case-varied references |
+| L3: tests that could pass broken | the loader checks the lead; a mid-word lead; keys 36/44 px exactly; the glyph's own colour; six links under touch at 360; 320 px |
+| L4: "below 390" vs `max-[389px]` | superseded with the width rule (ruling 7) |
+| L5: `homepage.json` could win in the e2e | `SITES_DIR` → an empty directory. The reused dev server is pre-existing and left |
+| L6: the dialog opened on the first radio | fixed in `4d93dc84`; the dialog is deleted since (ruling 8) |
+| L7: `hp3` at 390 showed an old header | retaken |
+| L8: `featured` in `/site.json` | accepted, recorded above |
+| L9: the fixture build in `homepage/out` | recorded above: never deploy it |
+| L10: the service's name in tracked files | gone from every file this branch adds or changes |
+| L11: the size read from another attribute's value | the size comes from the root's own attributes |
+| L12: the fixture echoed an operator icon's colours | the fixture's colours changed; the pre-existing test file is outside this branch |
+| L13: `label` said "Visible name" | reworded; `SETTINGS.md` / `SITE.md` regenerated |
+
+The answers, as ruled: the tint is kept; the footer colour `--muted-foreground` is kept; marking is
+choosing, kept, and said beside the checkbox. The touch rule was kept, then superseded by ruling 7.
+
+#### Re-review (verdict SHIP AFTER FIXES; `$T/hp-review.md`, "New findings")
+
+| Finding | Fix |
+|---|---|
+| R1: a `title` or `desc` with an element child left the page's parser inside the icon | `68ad1464`: both are out of the allowlist; one holding text only is removed before the check, any other is refused as an element. `4f7bc097`: `svg-vectors.spec.ts` parses every accepted input as the static page carries it and asserts the page after it stays outside the icon |
+| R2: an icon could load from another origin | `68ad1464`: `\` and `/*` refused in every value after decoding; `image-set(`, `image(`, `cross-fade(`, `element(`, `src(`, `paint(`, `@import` and `expression(` refused in every value; `style` holds presentation properties only (an allowlist); a `url(…)` count that differs raw and decoded is refused; the reviewer's eight inputs in the unit tests (`socialSvg.vectors.ts`). `4f7bc097`: the hostile e2e and `svg-vectors.spec.ts` fail on any request to another origin |
+| R3: a stored icon the allowlist refuses blocked every save | `0be068cf`: `socialLinksForSave` checks only a new or edited icon, and keeps an unchanged stored one byte for byte, in the settings, site and homepage writers; `archilyzer doctor`'s "social icons" line names each failing icon by file and label, with the reason; the editor changelog's upgrade text |
+| R4: an icon could paint and catch clicks outside its key | `4f7bc097`: each key, in the header and both footers, is `overflow-hidden` with `contain: paint`, so a `class` or `overflow` stays inside it; `68ad1464`'s style allowlist refuses `position`, `inset` and `z-index` |
+| R5: an `attributeName` naming an href | `68ad1464`: an `attributeName` containing `href`, or starting `on` after any prefix, is refused |
+| R6: an encoded or padded fragment passed the check but was not scoped | `68ad1464`: the raw value must be a plain fragment |
+| R7: a refused icon's label had no width bound | `4f7bc097`: at most 10rem (`max-w-40`) with an ellipsis, the whole label as its title, in the header and both footers |
+| R8: the comment on the echoed name | `68ad1464`: the echoed name is stripped to letters, digits and `_.:-`, at most 40 characters |
+| R9: "remove the style block" drops the colours; a DOCTYPE's reason | `68ad1464`: the reason says to export with presentation attributes rather than a style block (in Inkscape, save as Plain SVG); a DOCTYPE with an internal subset has its own reason; the editor changelog says the same |
+| R10: the branch moved during the review | nothing to fix: every commit after `afc642fd` is for the next review |
+
+**Found and left:**
+- **The export's header and the rest of its footer** are slices H1 and H2; **T1** ("two grounds")
+ is planned, not built.
+- **The shared charts' separators** (`/stats`, the export's charts) are still drawn in the page's
+ background colour; ruling 6 names the homepage chart. A follow-up for the operator to rule on.
+- **WebKit** is unverified for the scroll box (see Gates).
+- **The Settings form's hint** still says the default links show "in every site's footer"; true of
+ the sites until H1. Operator-facing; left alone.
+- **`export-search.spec.ts`'s "a site's own social links win over the global default" depends on
+ the order of the specs.** It reads `editor/test-settings.json`, which only a settings save
+ creates, and fails when it runs first in a fresh worktree. The fix is a `.catch(() => ({}))` on
+ the read, as `auto-queue.spec.ts` does.
+- **`next dev` (16.2.3) refuses a second dev server in the same app directory.** A hand-started
+ homepage dev server must be stopped before the suite runs; `reuseExistingServer` would otherwise
+ reuse one started without the fixture's environment (pre-existing).
+- **A pasted Illustrator or Inkscape file** with a `<style>` block, `<metadata>` or `inkscape:*`
+ attributes is refused, with the element or attribute named and the export setting to use.
+- **The hub's official cards** still show plain titles; ruling 4 names the homepage only.
+- **At a 200 % text size the homepage's own content** is wider than a 320 px screen; the header is
+ not (the e2e checks the header only).
+
+**Changelog.**
+- `homepage/CHANGELOG.md` `[Unreleased]`, worded as the end state, after `main`'s stats bullet: the
+ icons in the header at every width beside the toggle (the wordmark's text drops first, the scroll
+ box is the last resort); one theme toggle and the homepage's own accent; Changelog in the footer
+ only; the cards' wordmark names; the chart's separators; the larger keys with a focus ring; the
+ e2e's fixtures and specs.
+- `editor/CHANGELOG.md` `[Unreleased]`, at the end of the list: the size rule and "Show in header";
+ the icon check with reasons, on a new or edited icon and at render, and the upgrade note (an
+ unchanged stored icon is kept; `archilyzer doctor` names those that fail).
+- `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.
+
+## Rollout