commit 14ce4c3d69204dcf226174318058a369df646157
parent 35f88e34e939a041d77ae4d4f5bb86fb1d96959f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 21:30:59 -0400
export: the header is the mark + the split wordmark, the footer credit carries the parent mark (brand S1)
The rotated square becomes the Found-line mark: on a site an ink tile whose
lit line is var(--brand-mark, var(--brand)), so it follows the reader's accent
once the themes slice defines --brand-mark and the site's until then; the hub
wears the parent mark. The wordmark splits on site.wordmarkLead. The footer
puts a small parent mark before "Built with", outside the link, whose name
stays exactly "Archilyzer". The fixture site gains wordmarkLead "Fixture".
New brand.spec (header link name + spans + weights, mark fills, footer
credit, <head> icon links, the site-lit icon.svg); pwa.spec checks each
icon's type, PNG magic and IHDR size, the SVGs, favicon.ico's ICO directory,
a 404 outside the set, and the manifest's icon.svg / theme / background;
e2e-hub/brand.spec pins the hub's parent-mark icons and header.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 276 insertions(+), 32 deletions(-)
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -12,6 +12,8 @@ import {
PROJECT_NAME,
PROJECT_URL,
} from "yt-dlp-transcript-common/lib/project";
+import { ICON_PALETTES } from "yt-dlp-transcript-common/lib/brand";
+import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
import { currentSite } from "../lib/site";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
@@ -79,15 +81,19 @@ export default function Footer() {
different thing), and NOT target="_blank": the header's hub link
navigates in the same tab, and only the social icons open new ones.
"Built with" sits OUTSIDE the anchor so the accessible name is
- exactly "Archilyzer". */}
- <span className="text-xs">
- Built with{" "}
- <a
- href={PROJECT_URL}
- className="underline underline-offset-2 hover:text-foreground transition-colors"
- >
- {PROJECT_NAME}
- </a>
+ exactly "Archilyzer" — and so does the parent mark before it,
+ which is decorative (aria-hidden) and the same on every site. */}
+ <span className="inline-flex items-center gap-1.5 text-xs">
+ <BrandMark palette={ICON_PALETTES.archilyzer} className="size-4 shrink-0" />
+ <span>
+ Built with{" "}
+ <a
+ href={PROJECT_URL}
+ className="underline underline-offset-2 hover:text-foreground transition-colors"
+ >
+ {PROJECT_NAME}
+ </a>
+ </span>
</span>
{/* Ko-fi: a bare link, no copy — and on the HUB only. The hub is the
project's own deployment; a site build is an archive whoever runs
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -8,18 +8,23 @@ import {
} from "yt-dlp-transcript-common/lib/site";
import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
import { ThemeMenu } from "yt-dlp-transcript-common/components/ThemeMenu";
+import { BrandMark } from "yt-dlp-transcript-common/components/BrandMark";
+import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark";
import { currentSite } from "../lib/site";
+import { headerMarkPalette } from "../lib/brand";
import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
import { hasDuplicates } from "../lib/duplicates";
import SiblingSwitcher from "./SiblingSwitcher";
import MobileMenu from "./MobileMenu";
-// The export site's masthead: a brand-colored mark + wordmark, calm sans nav,
-// and the cross-site "family" chrome (hub backlink + sibling switcher). The
-// wordmark and mark track the active theme — sans + blue under the default base
-// family, serif + brass under Archive. All cross-site data is resolved at build
-// time from the pool, so single-site installs simply render neither.
+// The export site's masthead: the Found-line mark + the split wordmark, calm
+// sans nav, and the cross-site "family" chrome (hub backlink + sibling
+// switcher). The mark's lit line follows the reader's accent (lib/brand.ts
+// headerMarkPalette); the hub wears the parent mark. The link's accessible name
+// is the header title exactly — the mark is decorative and the wordmark's two
+// spans join without a space. All cross-site data is resolved at build time
+// from the pool, so single-site installs simply render neither.
export default function Header() {
const site = currentSite();
const settings = getSettings();
@@ -65,11 +70,13 @@ export default function Header() {
put the overflow rows OUTSIDE the sticky header's background at phone
widths, and the page scrolled through them. */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap">
- <Link href="/" className="group flex min-w-0 items-center gap-2.5">
- <span className="h-2.5 w-2.5 shrink-0 rotate-45 rounded-[3px] bg-brand shadow-[0_0_14px_var(--brand)] transition-colors group-hover:bg-brand-strong" />
- <span className="truncate font-display text-[1.35rem] font-semibold leading-none tracking-tight text-foreground">
- {site.headerTitle}
- </span>
+ <Link href="/" className="flex min-w-0 items-center gap-2.5">
+ <BrandMark palette={headerMarkPalette()} className="size-7 shrink-0" />
+ <Wordmark
+ title={site.headerTitle}
+ lead={site.wordmarkLead}
+ className="min-w-0 truncate text-[1.35rem] leading-none tracking-[-0.01em]"
+ />
</Link>
<nav className="hidden md:flex items-center gap-4 text-sm font-medium">
diff --git a/export/app/lib/brand.ts b/export/app/lib/brand.ts
@@ -1,15 +1,26 @@
import { ICON_PALETTES, type IconPalette } from "yt-dlp-transcript-common/lib/brand";
import { siteIconPalette } from "yt-dlp-transcript-common/lib/brandIcons";
+import type { BrandMarkPalette } from "yt-dlp-transcript-common/components/BrandMark";
import { currentSite } from "./site";
import { instanceMode } from "./mode";
-// The palette this build's icons and header mark are drawn in. The hub is the
-// family's own tool, so it wears the parent mark — achromatic, like the
-// homepage and the editor. Every archive site is lit with its own accent's
-// on-dark value (the favicon keeps the SITE default; the header mark follows
-// the reader's pick through --brand-mark). Server-only, like currentSite().
+// The palette this build's icons are drawn in. The hub is the family's own
+// tool, so it wears the parent mark — achromatic, like the homepage and the
+// editor. Every archive site is lit with its own accent's on-dark value.
+// Server-only, like currentSite().
export function iconPalette(): IconPalette {
return instanceMode() === "hub"
? ICON_PALETTES.archilyzer
: siteIconPalette(currentSite().accent);
}
+
+// The header mark's palette. On a site it is the icon's ink tile, but its lit
+// line follows the READER's accent — `--brand-mark` is the picked accent's
+// on-dark value (the tile is always ink) — while the favicon keeps the site's
+// default. Until the base × accent tokens define `--brand-mark`, it falls back
+// to `--brand`. The hub's mark is the parent mark and does not follow.
+export function headerMarkPalette(): BrandMarkPalette {
+ return instanceMode() === "hub"
+ ? ICON_PALETTES.archilyzer
+ : { ...ICON_PALETTES.child, lit: "var(--brand-mark, var(--brand))" };
+}
diff --git a/export/e2e-hub/brand.spec.ts b/export/e2e-hub/brand.spec.ts
@@ -0,0 +1,50 @@
+import { expect, test, type Route } from "@playwright/test";
+import { BASE_GROUNDS, ICON_PALETTES, markSvg } from "../../common/lib/brand";
+
+// The hub is the family's own tool, so it wears the PARENT mark — bone on
+// slate, achromatic — in its icons and its header, like the homepage and the
+// editor; never an archive's accent (plans/brand-and-themes.md, slice S1).
+
+async function fulfillJson(route: Route, body: unknown) {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify(body),
+ });
+}
+
+test("the hub's icons are the parent mark", async ({ request }) => {
+ const svg = await (await request.get("/icons/icon.svg")).text();
+ expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.ground}"`); // #151b20
+ expect(svg).toContain(`fill="${ICON_PALETTES.archilyzer.lit}"`); // #e7edf1
+ expect(svg).toBe(markSvg(ICON_PALETTES.archilyzer));
+
+ const png = await request.get("/icons/icon-192.png");
+ expect(png.headers()["content-type"]).toBe("image/png");
+ const ico = await request.get("/favicon.ico");
+ expect([...(await ico.body()).subarray(0, 4)]).toEqual([0, 0, 1, 0]);
+
+ const m = await (await request.get("/manifest.webmanifest")).json();
+ expect(m.background_color).toBe(ICON_PALETTES.archilyzer.ground);
+ expect(m.theme_color).toBe(BASE_GROUNDS.dark);
+});
+
+test("the hub header is the parent mark + Archi|lyzer", async ({ page }) => {
+ // No members: the shelf stays empty and nothing reaches a real origin.
+ await page.route("**/hub-sites.json", (r) => fulfillJson(r, []));
+ await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }));
+ await page.goto("/");
+ const home = page
+ .getByRole("banner")
+ .getByRole("link", { name: "Archilyzer", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home.locator("[data-wordmark-lead]")).toHaveText("Archi");
+ await expect(home.locator("[data-wordmark-suffix]")).toHaveText("lyzer");
+ const fills = await home.locator("svg[data-brand-mark]").evaluate((svg) =>
+ ["ground", "dim", "lit"].map(
+ (t) => getComputedStyle(svg.querySelector(`[data-tone="${t}"]`) as Element).fill,
+ ),
+ );
+ expect(fills).toEqual(["rgb(21, 27, 32)", "rgb(63, 76, 86)", "rgb(231, 237, 241)"]);
+});
diff --git a/export/e2e/brand.spec.ts b/export/e2e/brand.spec.ts
@@ -0,0 +1,133 @@
+import { test, expect, type Locator } from "@playwright/test";
+import { ICON_PALETTES, childIconPalette, markSvg } from "../../common/lib/brand";
+import { resolveAccent } from "../../common/lib/accent";
+import { installRoutes } from "./helpers";
+
+// The family brand on an archive site (plans/brand-and-themes.md, slice S1):
+// the Found-line mark and the two-weight wordmark in the header, the parent
+// mark beside the footer credit, and the icon set rendered per build from the
+// site's accent. The fixture site (e2e/fixtures/sites/testsite/site.json) is
+// headerTitle "Fixture Header", wordmarkLead "Fixture", accent #cc3366.
+
+// The computed fill of each part of a BrandMark, plus what the reader's accent
+// resolves to — so the lit line can be checked against the token it follows
+// without pinning a colour the themes slice will change.
+async function markFills(mark: Locator) {
+ return mark.evaluate((svg) => {
+ const fill = (tone: string) =>
+ getComputedStyle(svg.querySelector(`[data-tone="${tone}"]`) as Element).fill;
+ const probe = document.createElement("span");
+ probe.style.color = "var(--brand-mark, var(--brand))";
+ document.body.append(probe);
+ const accent = getComputedStyle(probe).color;
+ probe.remove();
+ return { ground: fill("ground"), dim: fill("dim"), lit: fill("lit"), accent };
+ });
+}
+
+test("the header link is the mark + the split wordmark, named by the header title", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await page.goto("/");
+ const home = page
+ .getByRole("banner")
+ .getByRole("link", { name: "Fixture Header", exact: true });
+ await expect(home).toBeVisible();
+ await expect(home).toHaveAttribute("href", "/");
+
+ // Two adjacent spans, no whitespace node between them: the lead is the
+ // configured prefix, the suffix is the rest (its leading space included).
+ const wordmark = home.locator("[data-wordmark]");
+ const parts = await wordmark.evaluate((w) =>
+ [...w.childNodes].map((n) => ({
+ tag: n.nodeName,
+ text: n.textContent,
+ weight: n instanceof Element ? getComputedStyle(n).fontWeight : null,
+ display: n instanceof Element ? getComputedStyle(n).display : null,
+ })),
+ );
+ expect(parts).toEqual([
+ { tag: "SPAN", text: "Fixture", weight: "720", display: "inline" },
+ { tag: "SPAN", text: " Header", weight: "380", display: "inline" },
+ ]);
+
+ const mark = home.locator("svg[data-brand-mark]");
+ await expect(mark).toHaveCount(1);
+ await expect(mark).toHaveAttribute("aria-hidden", "true");
+ await expect(mark).toHaveAttribute("focusable", "false");
+ await expect(mark).toBeVisible();
+});
+
+test("the header mark is the ink tile, its lit line following the reader's accent", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await page.goto("/");
+ const mark = page.getByRole("banner").locator("svg[data-brand-mark]");
+ const f = await markFills(mark);
+ expect(f.ground).toBe("rgb(12, 10, 8)"); // ICON_PALETTES.child.ground
+ expect(f.dim).toBe("rgb(59, 51, 39)"); // ICON_PALETTES.child.dim
+ expect(f.lit).toBe(f.accent);
+ expect(f.lit).not.toBe(f.dim);
+});
+
+test("the footer carries the parent mark before the credit, outside the link", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await page.goto("/");
+ const footer = page.locator("footer");
+ const credit = footer.getByRole("link", { name: "Archilyzer", exact: true });
+ await expect(credit).toBeVisible();
+ await expect(credit.locator("svg")).toHaveCount(0);
+
+ const mark = footer.locator("svg[data-brand-mark]");
+ await expect(mark).toHaveCount(1);
+ await expect(mark).toHaveAttribute("aria-hidden", "true");
+ // The mark's next sibling holds "Built with" and the credit.
+ await expect(
+ footer.locator("svg[data-brand-mark] + span").getByRole("link", {
+ name: "Archilyzer",
+ exact: true,
+ }),
+ ).toHaveCount(1);
+ const f = await markFills(mark);
+ expect(f.ground).toBe("rgb(21, 27, 32)"); // #151b20
+ expect(f.lit).toBe("rgb(231, 237, 241)"); // #e7edf1, bone: the parent mark
+});
+
+test("<head> links the rendered icon set, SVG first", async ({ page }) => {
+ await installRoutes(page);
+ await page.goto("/");
+ const icons = await page
+ .locator('head link[rel="icon"]')
+ .evaluateAll((ls) =>
+ ls.map((l) => [l.getAttribute("href"), l.getAttribute("type"), l.getAttribute("sizes")]),
+ );
+ expect(icons).toEqual([
+ ["/icons/icon.svg", "image/svg+xml", null],
+ ["/icons/icon-32.png", "image/png", "32x32"],
+ ["/icons/icon-192.png", "image/png", "192x192"],
+ ["/icons/icon-512.png", "image/png", "512x512"],
+ ]);
+ await expect(page.locator('head link[rel="apple-touch-icon"]')).toHaveAttribute(
+ "href",
+ "/icons/apple-touch-icon.png",
+ );
+});
+
+test("the site's icons are lit with its own accent, fitted to the ink tile", async ({
+ request,
+}) => {
+ const lit = resolveAccent("#cc3366").dark;
+ const res = await request.get("/icons/icon.svg");
+ expect(res.ok()).toBeTruthy();
+ expect(await res.text()).toBe(markSvg(childIconPalette(lit)));
+ const maskable = await request.get("/icons/maskable.svg");
+ expect(await maskable.text()).toBe(
+ markSvg(childIconPalette(lit), { variant: "maskable" }),
+ );
+ // Not the parent mark: that is the hub's, the homepage's and the editor's.
+ expect(await res.text()).not.toContain(ICON_PALETTES.archilyzer.ground);
+});
diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json
@@ -3,6 +3,7 @@
"siteTitle": "Fixture Site Title",
"siteDescription": "Fixture site description",
"headerTitle": "Fixture Header",
+ "wordmarkLead": "Fixture",
"homeTagline": "Fixture tagline here",
"accent": "#cc3366",
"socialLinks": [
diff --git a/export/e2e/pwa.spec.ts b/export/e2e/pwa.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
+import { BASE_GROUNDS, ICON_PALETTES } from "../../common/lib/brand";
// PWA surface for the export (Archive) sites: an installable web app manifest,
// a registered service worker, and the /offline management page. The transcript
@@ -23,22 +24,57 @@ test("serves a valid web app manifest with per-site fields", async ({
expect(
m.icons.some((i: { purpose?: string }) => i.purpose === "maskable"),
).toBe(true);
- // theme_color is a hex string (from the site accent or the family brass).
- expect(m.theme_color).toMatch(/^#[0-9a-f]{6}$/i);
+ // The scalable mark, for launchers that take one.
+ expect(m.icons).toContainEqual({
+ src: "/icons/icon.svg",
+ sizes: "any",
+ type: "image/svg+xml",
+ purpose: "any",
+ });
+ // The chrome is the dark base's ground and the splash the icon's own tile —
+ // never the accent, which a reader may change and the manifest cannot follow.
+ expect(m.theme_color).toBe(BASE_GROUNDS.dark);
+ expect(m.background_color).toBe(ICON_PALETTES.child.ground);
});
+const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+
test("serves the service worker and the icon set", async ({ request }) => {
const sw = await request.get("/sw.js");
expect(sw.ok()).toBeTruthy();
- for (const icon of [
- "/icons/icon-192.png",
- "/icons/icon-512.png",
- "/icons/maskable-512.png",
- "/icons/apple-touch-icon.png",
- ]) {
+ // Rendered by app/icons/[file]/route.ts, not committed: each is a real PNG
+ // of its size (IHDR width/height at bytes 16 and 20).
+ for (const [icon, size] of [
+ ["/icons/icon-32.png", 32],
+ ["/icons/icon-192.png", 192],
+ ["/icons/icon-512.png", 512],
+ ["/icons/maskable-512.png", 512],
+ ["/icons/apple-touch-icon.png", 180],
+ ] as const) {
+ const r = await request.get(icon);
+ expect(r.ok(), icon).toBeTruthy();
+ expect(r.headers()["content-type"], icon).toBe("image/png");
+ const body = await r.body();
+ expect([...body.subarray(0, 8)], icon).toEqual(PNG_MAGIC);
+ expect([body.readUInt32BE(16), body.readUInt32BE(20)], icon).toEqual([size, size]);
+ }
+ for (const icon of ["/icons/icon.svg", "/icons/maskable.svg"]) {
const r = await request.get(icon);
expect(r.ok(), icon).toBeTruthy();
+ expect(r.headers()["content-type"], icon).toBe("image/svg+xml");
+ expect(await r.text(), icon).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
}
+ // /favicon.ico: an ICO directory (00 00 01 00) of PNG entries.
+ const ico = await request.get("/favicon.ico");
+ expect(ico.ok()).toBeTruthy();
+ expect(ico.headers()["content-type"]).toBe("image/x-icon");
+ const b = await ico.body();
+ expect([...b.subarray(0, 4)]).toEqual([0, 0, 1, 0]);
+ expect(b.readUInt16LE(4)).toBeGreaterThan(0);
+ const first = b.readUInt32LE(6 + 12);
+ expect([...b.subarray(first, first + 8)]).toEqual(PNG_MAGIC);
+ // Anything not in the set is not an icon.
+ expect((await request.get("/icons/icon-64.png")).status()).toBe(404);
});
// Note: the service worker registers in PRODUCTION builds only (it caches