commit 35417b701bbe6465cdd7dbe6efcca58427fada1f
parent 1db66f06f6cdfb7549e8ebf01eb753ce567473b6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 22:24:50 -0400
Merge brand/found-line — brand slice S0: the brand as data (seven named accents, base grounds, the Found-line mark, the subject-split wordmark), site.json accent id + wordmarkLead, the published accent stays a hex, the site form's swatches; the plan and the Archilyzer Media proposal
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
20 files changed, 1701 insertions(+), 34 deletions(-)
diff --git a/SITE.md b/SITE.md
@@ -14,6 +14,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| [`siteTitle`](#sitetitle) | `"Transcript Browser"` |
| [`siteDescription`](#sitedescription) | `"Browse and search video transcripts"` |
| [`headerTitle`](#headertitle) | `"Transcript Browser"` |
+| [`wordmarkLead`](#wordmarklead) | absent |
| [`homeTagline`](#hometagline) | `""` |
| [`socialLinks`](#sociallinks) | absent |
| [`groups`](#groups) | list — see below |
@@ -52,6 +53,12 @@ The title shown in the site header.
Default: `"Transcript Browser"`
+## `wordmarkLead`
+
+The heavy first part of the header wordmark: the subject's name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.
+
+Default: absent
+
## `homeTagline`
Tagline under the home page title. Empty = none.
@@ -139,7 +146,7 @@ Default: absent
## `accent`
-Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site's public build. Absent = inherit the family brass. Any other spelling is dropped.
+Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.
Default: absent
diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts
@@ -53,3 +53,31 @@ test("with no index, compose-hub writes the pool and no hub-summary.json, removi
rmSync(root, { recursive: true, force: true });
}
});
+
+test("hub-sites.json publishes every accent as a hex: an id becomes its on-dark value", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "compose-hub-"));
+ const log = console.log;
+ try {
+ const paths = fixturePaths(root);
+ const write = (id: string, site: Record<string, unknown>) => {
+ mkdirSync(path.join(paths.sitesDir, id), { recursive: true });
+ writeFileSync(path.join(paths.sitesDir, id, "site.json"), JSON.stringify(site));
+ };
+ write("named", { siteTitle: "Named", siteUrl: "https://named.example", accent: "violet" });
+ write("custom", { siteTitle: "Custom", siteUrl: "https://custom.example", accent: "#CC3366" });
+ write("plain", { siteTitle: "Plain", siteUrl: "https://plain.example" });
+ console.log = () => {};
+ await main({ paths });
+ console.log = log;
+ const pool = JSON.parse(
+ readFileSync(path.join(paths.exportPublicDir, "hub-sites.json"), "utf8"),
+ ) as Array<{ siteId: string; accent?: string }>;
+ const accentOf = (id: string) => pool.find((s) => s.siteId === id);
+ assert.equal(accentOf("named")?.accent, "#b49cf2");
+ assert.equal(accentOf("custom")?.accent, "#cc3366");
+ assert.ok(accentOf("plain") && !("accent" in accentOf("plain")!));
+ } finally {
+ console.log = log;
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -18,6 +18,7 @@ import path from "node:path";
import { existsSync } from "node:fs";
import { cp, rm, writeFile, access } from "node:fs/promises";
import { getPaths, type Paths } from "../lib/paths";
+import { accentHex } from "../lib/accent";
import { listSites, resolveHubUrl } from "../lib/site";
import { getHomepageConfig } from "../lib/homepage";
import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor";
@@ -91,7 +92,8 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> {
siteId: site.siteId,
siteTitle: site.siteTitle,
siteUrl: site.siteUrl,
- ...(site.accent ? { accent: site.accent } : {}),
+ // The hub draws it as a colour: an accent id is published as its hex.
+ ...(accentHex(site.accent) ? { accent: accentHex(site.accent) } : {}),
...(resolveHubUrl(site) ? { hubUrl: resolveHubUrl(site) } : {}),
pwa: site.pwa === true,
contract: SITE_DESCRIPTOR_VERSION,
diff --git a/common/lib/accent.test.ts b/common/lib/accent.test.ts
@@ -0,0 +1,132 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ accentHex,
+ customAccentVars,
+ parseAccent,
+ parseAccentSetting,
+ resolveAccent,
+ siteAccentVars,
+} from "./accent";
+import {
+ ACCENTS,
+ ACCENT_IDS,
+ ACCENT_INK,
+ BASE_GROUNDS,
+ MIN_ACCENT_CONTRAST,
+ contrastRatio,
+ type BaseGround,
+} from "./brand";
+
+const BASES = Object.keys(BASE_GROUNDS) as BaseGround[];
+
+test("parseAccent stays a hex-only parser", () => {
+ assert.equal(parseAccent("#ABCDEF"), "#abcdef");
+ assert.equal(parseAccent(" abcdef "), "#abcdef");
+ assert.equal(parseAccent("brass"), undefined);
+ assert.equal(parseAccent("#abc"), undefined);
+ assert.equal(parseAccent(42), undefined);
+});
+
+test("parseAccentSetting: an id, a #rrggbb, or undefined", () => {
+ for (const id of ACCENT_IDS) assert.equal(parseAccentSetting(id), id);
+ assert.equal(parseAccentSetting(" Brass "), "brass");
+ assert.equal(parseAccentSetting("#CC3366"), "#cc3366");
+ assert.equal(parseAccentSetting("cc3366"), "#cc3366");
+ // A hex equal to an accent's value stays a custom hex: never mapped to an id.
+ assert.equal(parseAccentSetting(ACCENTS.brass.onDark), ACCENTS.brass.onDark);
+ for (const bad of ["", " ", "custom", "gold", "#12345", "#1234567", null, undefined, 7, {}, ["brass"]]) {
+ assert.equal(parseAccentSetting(bad), undefined, JSON.stringify(bad));
+ }
+});
+
+test("resolveAccent: a named accent reads its table row", () => {
+ for (const id of ACCENT_IDS) {
+ assert.deepEqual(resolveAccent(id), {
+ id,
+ light: ACCENTS[id].onLight,
+ sepia: ACCENTS[id].onSepia,
+ dark: ACCENTS[id].onDark,
+ });
+ }
+});
+
+test("resolveAccent: absent or malformed is the default accent (Signal)", () => {
+ const signal = resolveAccent("signal");
+ for (const v of [undefined, null, "", "gold", "#xyz", 5]) {
+ assert.deepEqual(resolveAccent(v), signal, JSON.stringify(v));
+ }
+});
+
+// Custom hexes chosen to need every kind of fit: too light for light/sepia,
+// too dark for dark, already fine, and the extremes.
+const CUSTOMS = [
+ "#cc3366", "#ffff00", "#00ffff", "#ffffff", "#000000", "#808080", "#1e90ff",
+ "#95661a", "#e3b15c", "#3a0ca3", "#ff6600", "#7fff00", "#f0f0f0", "#101010",
+];
+
+test("resolveAccent: a custom hex is fitted to 4.5:1 on every ground and with its ink", () => {
+ for (const hex of CUSTOMS) {
+ const r = resolveAccent(hex);
+ assert.equal(r.id, "custom");
+ for (const base of BASES) {
+ const v = r[base];
+ assert.match(v, /^#[0-9a-f]{6}$/);
+ const ground = contrastRatio(v, BASE_GROUNDS[base]);
+ const ink = contrastRatio(v, ACCENT_INK[base]);
+ assert.ok(ground >= MIN_ACCENT_CONTRAST, `${hex} on ${base} → ${v}: ${ground.toFixed(2)} vs ground`);
+ assert.ok(ink >= MIN_ACCENT_CONTRAST, `${hex} on ${base} → ${v}: ${ink.toFixed(2)} vs ink`);
+ }
+ }
+});
+
+test("resolveAccent: a custom hex that already passes is kept exactly; others move the right way", () => {
+ // #cc3366 is 4.57:1 on light (kept), 4.21 on sepia (darkened), 3.98 on dark
+ // (lightened). The themes slice's site-branding spec expects #cc3366 on light.
+ const r = resolveAccent("#CC3366");
+ assert.equal(r.light, "#cc3366");
+ assert.notEqual(r.sepia, "#cc3366");
+ assert.notEqual(r.dark, "#cc3366");
+ const lum = (h: string) => contrastRatio(h, "#000000");
+ assert.ok(lum(r.sepia) < lum("#cc3366"), "sepia is darker");
+ assert.ok(lum(r.dark) > lum("#cc3366"), "dark is lighter");
+ // A dark-enough colour is kept on light and sepia, a light-enough on dark.
+ assert.equal(resolveAccent("#101010").light, "#101010");
+ assert.equal(resolveAccent("#f0f0f0").dark, "#f0f0f0");
+});
+
+test("accentHex: the published value is always a hex", () => {
+ for (const id of ACCENT_IDS) assert.equal(accentHex(id), ACCENTS[id].onDark);
+ assert.equal(accentHex("Violet"), ACCENTS.violet.onDark);
+ // A custom hex is published as stored (normalized), not fitted.
+ assert.equal(accentHex("#CC3366"), "#cc3366");
+ assert.equal(accentHex(undefined), undefined);
+ assert.equal(accentHex(""), undefined);
+ assert.equal(accentHex("gold"), undefined);
+});
+
+test("customAccentVars: only a custom hex carries inline vars", () => {
+ assert.equal(customAccentVars(undefined), null);
+ for (const id of ACCENT_IDS) assert.equal(customAccentVars(id), null);
+ const r = resolveAccent("#cc3366");
+ assert.deepEqual(customAccentVars("#cc3366"), {
+ "--accent-custom-light": r.light,
+ "--accent-custom-sepia": r.sepia,
+ "--accent-custom-dark": r.dark,
+ });
+});
+
+test("siteAccentVars (legacy, until S2): a hex as before, an id through its published hex", () => {
+ assert.deepEqual(siteAccentVars("#cc3366"), {
+ "--brand": "#cc3366",
+ "--brand-strong": "#cc3366",
+ "--brand-soft": "rgba(204, 51, 102, 0.16)",
+ });
+ assert.deepEqual(siteAccentVars("brass"), {
+ "--brand": "#e3b15c",
+ "--brand-strong": "#e3b15c",
+ "--brand-soft": "rgba(227, 177, 92, 0.16)",
+ });
+ assert.equal(siteAccentVars(undefined), null);
+ assert.equal(siteAccentVars("gold"), null);
+});
diff --git a/common/lib/accent.ts b/common/lib/accent.ts
@@ -1,22 +1,129 @@
-// Per-site accent: an optional brand color a site can set to override the family
-// brass. Stored on Site.accent as a "#rrggbb" string; applied at static-build
-// time as an inline `<html style>` on the export site (see export/app/layout),
-// which wins over the [data-theme] token rules for BOTH light and dark, so one
-// color replaces the brass everywhere. Unset → family brass (the tokens.css
-// default). The user/per-visit override in ThemeScript still wins over this.
+// Per-site accent: which of the family's named accents a site wears, or a
+// custom hex. Stored on Site.accent (site.json) as an accent id (`"brass"`) or
+// a `"#rrggbb"`; absent = DEFAULT_ACCENT (Signal). The palette itself — every
+// accent's value on every base ground, and the contrast rule — is lib/brand.ts;
+// this module turns a stored setting into colours.
+//
+// THE PUBLISHED ACCENT IS ALWAYS A HEX. Third-party hubs read other sites'
+// `/site.json`, `hub-sites.json` and the homepage summary, and draw the value as
+// a colour; an id means nothing to them. accentHex is the one mapping, and
+// every publisher (siteDescriptor, compose-hub, homepageSummary) goes through
+// it.
+
+import {
+ ACCENTS,
+ ACCENT_INK,
+ BASE_GROUNDS,
+ BASE_GROUND_IDS,
+ DEFAULT_ACCENT,
+ MIN_ACCENT_CONTRAST,
+ contrastRatio,
+ isAccentId,
+ type AccentId,
+ type BaseGround,
+} from "./brand";
const HEX_RE = /^#?([0-9a-f]{6})$/i;
// Normalize a raw accent into a lowercase "#rrggbb", or undefined if it isn't a
// 6-digit hex color. Mirrors parseSiteUrl/cloudflareProject optional handling.
+// A HEX parser only: an accent id is not a hex (parseAccentSetting takes both).
export function parseAccent(input: unknown): string | undefined {
if (typeof input !== "string") return undefined;
const m = HEX_RE.exec(input.trim());
return m ? `#${m[1].toLowerCase()}` : undefined;
}
-// Derive the `--brand*` CSS variables from a base accent hex. Returns null for
-// an invalid color (caller then renders no override → family brass).
+// A stored accent setting: an accent id (trimmed, any case → the lowercase id),
+// a custom "#rrggbb" (normalized by parseAccent), or undefined for anything
+// else. A hex is never mapped back to an id, even one equal to an accent's
+// value: the operator chose "custom".
+export function parseAccentSetting(input: unknown): string | undefined {
+ if (typeof input !== "string") return undefined;
+ const lower = input.trim().toLowerCase();
+ if (isAccentId(lower)) return lower;
+ return parseAccent(lower);
+}
+
+export type ResolvedAccent = {
+ id: AccentId | "custom";
+ light: string;
+ sepia: string;
+ dark: string;
+};
+
+function toHex(r: number, g: number, b: number): string {
+ return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`;
+}
+
+function meetsRule(hex: string, base: BaseGround): boolean {
+ return (
+ contrastRatio(hex, BASE_GROUNDS[base]) >= MIN_ACCENT_CONTRAST &&
+ contrastRatio(hex, ACCENT_INK[base]) >= MIN_ACCENT_CONTRAST
+ );
+}
+
+// Fit a custom hex to one base: unchanged when it already meets the rule
+// (brand.ts MIN_ACCENT_CONTRAST against the ground AND the ink), otherwise
+// mixed toward black (light/sepia) or white (dark) in 1 % steps until it does.
+// Mixing keeps the hue; full black/white always passes, so this terminates.
+function fitAccent(hex: string, base: BaseGround): string {
+ if (meetsRule(hex, base)) return hex;
+ const n = parseInt(hex.slice(1), 16);
+ const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+ const target = base === "dark" ? 255 : 0;
+ for (let step = 1; step <= 100; step++) {
+ const t = step / 100;
+ const [r, g, b] = rgb.map((c) => c + (target - c) * t);
+ const out = toHex(r, g, b);
+ if (meetsRule(out, base)) return out;
+ }
+ return base === "dark" ? "#ffffff" : "#000000";
+}
+
+// The colours a stored setting paints with, per base. A named accent reads its
+// row of the ACCENTS table; a custom hex is fitted to each ground; anything
+// else (absent, malformed) is DEFAULT_ACCENT.
+export function resolveAccent(input: unknown): ResolvedAccent {
+ const setting = parseAccentSetting(input);
+ if (setting && !isAccentId(setting)) {
+ return {
+ id: "custom",
+ light: fitAccent(setting, "light"),
+ sepia: fitAccent(setting, "sepia"),
+ dark: fitAccent(setting, "dark"),
+ };
+ }
+ const a = ACCENTS[setting && isAccentId(setting) ? setting : DEFAULT_ACCENT];
+ return { id: a.id, light: a.onLight, sepia: a.onSepia, dark: a.onDark };
+}
+
+// The PUBLISHED accent: always a hex. An id becomes its on-dark value (the
+// family's hub and homepage are dark, and the value is also the lit line of the
+// site's icon); a custom hex is published as stored. Absent or malformed →
+// undefined, so a publisher omits the key exactly as before.
+export function accentHex(input: unknown): string | undefined {
+ const setting = parseAccentSetting(input);
+ if (!setting) return undefined;
+ return isAccentId(setting) ? ACCENTS[setting].onDark : setting;
+}
+
+// The inline `<html style>` a CUSTOM-hex site needs: its fitted value per base,
+// which the token sheet's `--swatch-custom` reads. Null for a named accent or
+// no accent — those are pure CSS (`data-accent`).
+export function customAccentVars(input: unknown): Record<string, string> | null {
+ const r = resolveAccent(input);
+ if (r.id !== "custom") return null;
+ const vars: Record<string, string> = {};
+ for (const base of BASE_GROUND_IDS) vars[`--accent-custom-${base}`] = r[base];
+ return vars;
+}
+
+// LEGACY (deleted by the themes slice, S2): the pre-base×accent override — one
+// colour for both modes as an inline `--brand*` on <html>, applied by
+// export/app/layout.tsx. Takes an id OR a hex: an id paints with its published
+// hex (accentHex), so a site switched to a named accent keeps rendering until
+// the token sheet learns `data-accent`. Null when there is no accent.
// --brand the color itself (used as the main accent)
// --brand-strong same color (hover emphasis; a single override can't go both
// lighter-on-dark AND darker-on-light, so keep it stable)
@@ -24,7 +131,7 @@ export function parseAccent(input: unknown): string | undefined {
export function siteAccentVars(
accent: string | undefined,
): Record<string, string> | null {
- const hex = parseAccent(accent);
+ const hex = accentHex(accent);
if (!hex) return null;
const n = parseInt(hex.slice(1), 16);
const r = (n >> 16) & 255;
diff --git a/common/lib/brand.test.ts b/common/lib/brand.test.ts
@@ -0,0 +1,194 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ ACCENTS,
+ ACCENT_IDS,
+ ACCENT_INK,
+ BASE_GROUNDS,
+ DEFAULT_ACCENT,
+ ICON_PALETTES,
+ MARK,
+ MIN_ACCENT_CONTRAST,
+ childIconPalette,
+ contrastRatio,
+ isAccentId,
+ markSvg,
+ splitWordmark,
+ wordmarkLeadFor,
+ type BaseGround,
+} from "./brand";
+import { PROJECT_NAME, PROJECT_WORDMARK_LEAD } from "./project";
+
+const ON: Record<BaseGround, "onLight" | "onSepia" | "onDark"> = {
+ light: "onLight",
+ sepia: "onSepia",
+ dark: "onDark",
+};
+
+test("accents: the table is the plan's, keyed and ordered by ACCENT_IDS", () => {
+ assert.deepEqual(Object.keys(ACCENTS), [...ACCENT_IDS]);
+ for (const id of ACCENT_IDS) {
+ assert.equal(ACCENTS[id].id, id);
+ for (const k of ["onDark", "onLight", "onSepia"] as const) {
+ assert.match(ACCENTS[id][k], /^#[0-9a-f]{6}$/, `${id}.${k}`);
+ }
+ }
+ assert.equal(DEFAULT_ACCENT, "signal");
+ // Spot-check the table against plans/brand-and-themes.md "Accents".
+ assert.deepEqual(ACCENTS.signal, {
+ id: "signal",
+ name: "Signal",
+ onDark: "#5fa8a0",
+ onLight: "#2e7b73",
+ onSepia: "#2b756e",
+ });
+ assert.equal(ACCENTS.green.onSepia, "#3d772b");
+ assert.equal(isAccentId("brass"), true);
+ assert.equal(isAccentId("Brass"), false);
+ assert.equal(isAccentId("#e3b15c"), false);
+});
+
+// THE CONTRAST RULE: every accent's value on every base reaches 4.5:1 against
+// that base's ground AND against the ink set on it (white on light/sepia, the
+// dark ground on dark). 7 accents × 3 bases × 2 = 42 checks.
+test("accents: every value reaches 4.5:1 on its ground and with its ink", () => {
+ let checks = 0;
+ for (const id of ACCENT_IDS) {
+ for (const base of Object.keys(BASE_GROUNDS) as BaseGround[]) {
+ const hex = ACCENTS[id][ON[base]];
+ const ground = contrastRatio(hex, BASE_GROUNDS[base]);
+ const ink = contrastRatio(hex, ACCENT_INK[base]);
+ assert.ok(ground >= MIN_ACCENT_CONTRAST, `${id} on ${base}: ${ground.toFixed(2)} vs ground`);
+ assert.ok(ink >= MIN_ACCENT_CONTRAST, `${id} on ${base}: ${ink.toFixed(2)} vs ink`);
+ checks += 2;
+ }
+ }
+ assert.equal(checks, 42);
+ assert.equal(ACCENT_INK.light, "#ffffff");
+ assert.equal(ACCENT_INK.sepia, "#ffffff");
+ assert.equal(ACCENT_INK.dark, BASE_GROUNDS.dark);
+});
+
+test("contrastRatio: the WCAG endpoints", () => {
+ assert.equal(contrastRatio("#000000", "#ffffff").toFixed(2), "21.00");
+ assert.equal(contrastRatio("#777777", "#777777"), 1);
+ assert.equal(contrastRatio("#ffffff", "#000000"), contrastRatio("#000000", "#ffffff"));
+});
+
+test("bases and icon palettes are the plan's", () => {
+ assert.deepEqual(BASE_GROUNDS, { light: "#f3f6f7", sepia: "#f4ecd8", dark: "#0c0a08" });
+ assert.deepEqual(ICON_PALETTES.archilyzer, { ground: "#151b20", dim: "#3f4c56", lit: "#e7edf1" });
+ assert.deepEqual(childIconPalette(ACCENTS.brass.onDark), {
+ ground: "#0c0a08",
+ dim: "#3b3327",
+ lit: "#e3b15c",
+ });
+});
+
+test("MARK: the Found-line geometry, back to front", () => {
+ assert.deepEqual(
+ MARK.map((s) => [s.part, s.tone]),
+ [
+ ["ground", "ground"],
+ ["line 1", "dim"],
+ ["play head", "lit"],
+ ["line 2", "lit"],
+ ["line 3", "dim"],
+ ["line 4", "dim"],
+ ],
+ );
+ const rect = (i: number) => {
+ const s = MARK[i];
+ assert.equal(s.kind, "rect");
+ return s.kind === "rect" ? [s.x, s.y, s.width, s.height, s.rx] : [];
+ };
+ assert.deepEqual(rect(0), [0, 0, 512, 512, 112]);
+ assert.deepEqual(rect(1), [112, 128, 288, 44, 22]);
+ assert.deepEqual(rect(3), [188, 212, 212, 44, 22]);
+ assert.deepEqual(rect(4), [112, 296, 232, 44, 22]);
+ assert.deepEqual(rect(5), [112, 380, 152, 44, 22]);
+ const head = MARK[2];
+ assert.equal(head.kind, "polygon");
+ if (head.kind === "polygon") {
+ assert.deepEqual(head.points, [[112, 210], [172, 234], [112, 258]]);
+ }
+});
+
+const PARENT = ICON_PALETTES.archilyzer;
+const FG =
+ '<rect x="112" y="128" width="288" height="44" rx="22" fill="#3f4c56"/>' +
+ '<polygon points="112,210 172,234 112,258" fill="#e7edf1"/>' +
+ '<rect x="188" y="212" width="212" height="44" rx="22" fill="#e7edf1"/>' +
+ '<rect x="112" y="296" width="232" height="44" rx="22" fill="#3f4c56"/>' +
+ '<rect x="112" y="380" width="152" height="44" rx="22" fill="#3f4c56"/>';
+const OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">';
+
+test("markSvg any: the rounded square", () => {
+ assert.equal(
+ markSvg(PARENT),
+ `${OPEN}<rect width="512" height="512" rx="112" fill="#151b20"/>${FG}</svg>`,
+ );
+ assert.equal(markSvg(PARENT, { variant: "any" }), markSvg(PARENT));
+});
+
+test("markSvg maskable: full-bleed ground, the lines scaled 0.8 about the centre", () => {
+ assert.equal(
+ markSvg(PARENT, { variant: "maskable" }),
+ `${OPEN}<rect width="512" height="512" fill="#151b20"/>` +
+ `<g transform="translate(51.2 51.2) scale(0.8)">${FG}</g></svg>`,
+ );
+ // The transform fixes (256,256): 51.2 + 0.8 × 256 = 256.
+ assert.equal(51.2 + 0.8 * 256, 256);
+});
+
+test("markSvg apple: full-bleed ground at scale 1", () => {
+ assert.equal(
+ markSvg(PARENT, { variant: "apple" }),
+ `${OPEN}<rect width="512" height="512" fill="#151b20"/>${FG}</svg>`,
+ );
+});
+
+test("markSvg: a child site's lit line is its accent; colours are lowercased", () => {
+ const svg = markSvg(childIconPalette("#E3B15C"));
+ assert.match(svg, /<polygon points="112,210 172,234 112,258" fill="#e3b15c"\/>/);
+ assert.match(svg, /<rect x="188" y="212" width="212" height="44" rx="22" fill="#e3b15c"\/>/);
+ assert.match(svg, /rx="112" fill="#0c0a08"/);
+ assert.equal((svg.match(/fill="#3b3327"/g) ?? []).length, 3);
+});
+
+test("markSvg refuses a colour that is not #rrggbb", () => {
+ assert.throws(() => markSvg({ ...PARENT, lit: 'red"/><script>' }), /palette\.lit/);
+ assert.throws(() => markSvg({ ...PARENT, ground: "#fff" }), /palette\.ground/);
+});
+
+test("splitWordmark: every family title splits on its subject", () => {
+ const cases: Array<[string, string, string]> = [
+ [PROJECT_NAME, PROJECT_WORDMARK_LEAD, "lyzer"],
+ ["Jeralyzer", "Jer", "alyzer"],
+ ["Rekietalyzer", "Rekieta", "lyzer"],
+ ["Hasanalyzer", "Hasan", "alyzer"],
+ ["Anilyzer", "Ani", "lyzer"],
+ ["Bonnellyzer", "Bonnell", "yzer"],
+ ["Jasolyzer", "Jaso", "lyzer"],
+ ];
+ for (const [title, lead, suffix] of cases) {
+ assert.deepEqual(splitWordmark(title, lead), { lead, suffix }, title);
+ assert.equal(splitWordmark(title, lead).lead + splitWordmark(title, lead).suffix, title);
+ }
+});
+
+test("splitWordmark never guesses: a missing or non-prefix lead is the whole title", () => {
+ // No heuristic split on "lyzer"/"alyzer".
+ assert.deepEqual(splitWordmark("Rekietalyzer"), { lead: "Rekietalyzer", suffix: "" });
+ assert.deepEqual(splitWordmark("Rekietalyzer", ""), { lead: "Rekietalyzer", suffix: "" });
+ assert.deepEqual(splitWordmark("Rekietalyzer", "Rekiet"), { lead: "Rekiet", suffix: "alyzer" });
+ // Not a prefix, wrong case, the whole title, longer than it.
+ assert.deepEqual(splitWordmark("Jeralyzer", "Hasan"), { lead: "Jeralyzer", suffix: "" });
+ assert.deepEqual(splitWordmark("Jeralyzer", "jer"), { lead: "Jeralyzer", suffix: "" });
+ assert.deepEqual(splitWordmark("Jeralyzer", "Jeralyzer"), { lead: "Jeralyzer", suffix: "" });
+ assert.deepEqual(splitWordmark("Jer", "Jeralyzer"), { lead: "Jer", suffix: "" });
+ // Surrounding whitespace on the lead is not part of it.
+ assert.deepEqual(splitWordmark("Jeralyzer", " Jer "), { lead: "Jer", suffix: "alyzer" });
+ assert.equal(wordmarkLeadFor("Jeralyzer", 3), undefined);
+ assert.equal(wordmarkLeadFor("Jeralyzer", " "), undefined);
+});
diff --git a/common/lib/brand.ts b/common/lib/brand.ts
@@ -0,0 +1,230 @@
+// THE BRAND, AS DATA — the Found-line mark, the accent palette, the three base
+// grounds and the wordmark split. plans/brand-and-themes.md "The design" is the
+// source of every value here; where the design canvas and the plan differ, the
+// plan wins (notably the contrast-corrected on-light / on-sepia accents).
+//
+// PURE: zero imports, no I/O, no framework types, no zod. It is safe in server
+// components, `"use client"` trees (the editor's accent swatches), route
+// handlers that render icons, and plain `tsx` scripts. The resolver that turns
+// a site's stored accent into colours is lib/accent.ts, which imports this.
+
+// ── Accents ─────────────────────────────────────────────────────────────────
+
+// The named accents, in picker order. Signal is the family's own (Archilyzer:
+// homepage, hub, editor); every official child site defaults to its own.
+export const ACCENT_IDS = [
+ "signal",
+ "brass",
+ "vermilion",
+ "violet",
+ "sakura",
+ "blue",
+ "green",
+] as const;
+
+export type AccentId = (typeof ACCENT_IDS)[number];
+
+// One accent's value on each base ground. `onDark` is also the PUBLISHED hex
+// (lib/accent.ts accentHex) and the lit line of a child site's icon.
+export type Accent = {
+ id: AccentId;
+ name: string;
+ onDark: string;
+ onLight: string;
+ onSepia: string;
+};
+
+export const ACCENTS: Readonly<Record<AccentId, Accent>> = {
+ signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73", onSepia: "#2b756e" },
+ brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a", onSepia: "#8e6119" },
+ vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f", onSepia: "#b3431f" },
+ violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4", onSepia: "#6a4bc4" },
+ sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a", onSepia: "#a83a6a" },
+ blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8", onSepia: "#2d5fb8" },
+ green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c", onSepia: "#3d772b" },
+};
+
+// An absent accent reads as this one — on a child site AND on the family's own
+// surfaces.
+export const DEFAULT_ACCENT: AccentId = "signal";
+
+export function isAccentId(v: unknown): v is AccentId {
+ return typeof v === "string" && (ACCENT_IDS as readonly string[]).includes(v);
+}
+
+// ── Bases ───────────────────────────────────────────────────────────────────
+
+// The page ground of each reader base. The accent contrast rule is measured
+// against these, and the browser chrome colour is taken from them.
+export const BASE_GROUNDS = {
+ light: "#f3f6f7",
+ sepia: "#f4ecd8",
+ dark: "#0c0a08",
+} as const;
+
+export type BaseGround = keyof typeof BASE_GROUNDS;
+
+export const BASE_GROUND_IDS = ["light", "sepia", "dark"] as const satisfies ReadonlyArray<BaseGround>;
+
+// The text set ON an accent fill (a filled button, a badge): white on the light
+// and sepia bases, the dark ground on the dark base.
+export const ACCENT_INK: Readonly<Record<BaseGround, string>> = {
+ light: "#ffffff",
+ sepia: "#ffffff",
+ dark: BASE_GROUNDS.dark,
+};
+
+// THE CONTRAST RULE. An accent's value on a base must reach this ratio against
+// that base's ground AND against its ink. brand.test.ts enforces it for every
+// named accent on every base; lib/accent.ts resolveAccent fits a custom hex to
+// it.
+export const MIN_ACCENT_CONTRAST = 4.5;
+
+// WCAG 2.x relative luminance of a "#rrggbb".
+export function relativeLuminance(hex: string): number {
+ const n = parseInt(hex.slice(1, 7), 16);
+ const channel = (c: number): number => {
+ const s = c / 255;
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
+ };
+ return (
+ 0.2126 * channel((n >> 16) & 255) +
+ 0.7152 * channel((n >> 8) & 255) +
+ 0.0722 * channel(n & 255)
+ );
+}
+
+// WCAG 2.x contrast ratio of two "#rrggbb" colours, 1..21.
+export function contrastRatio(a: string, b: string): number {
+ const la = relativeLuminance(a);
+ const lb = relativeLuminance(b);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+}
+
+// ── The mark ────────────────────────────────────────────────────────────────
+
+// Mark D, "Found line": four transcript lines on a rounded square; the second
+// line is lit and carries a play head. Everything is in a 512 viewBox, listed
+// back to front. `tone` names the palette slot that fills the shape.
+export const MARK_VIEWBOX = 512;
+export const MARK_GROUND_RX = 112;
+// The maskable variant scales everything but the ground by this about the
+// centre, so the lines sit inside the 80 % safe zone.
+export const MARK_MASKABLE_SCALE = 0.8;
+
+export type MarkTone = "ground" | "dim" | "lit";
+
+export type MarkShape =
+ | {
+ part: string;
+ kind: "rect";
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ rx: number;
+ tone: MarkTone;
+ }
+ | {
+ part: string;
+ kind: "polygon";
+ points: ReadonlyArray<readonly [number, number]>;
+ tone: MarkTone;
+ };
+
+export const MARK: ReadonlyArray<MarkShape> = [
+ { part: "ground", kind: "rect", x: 0, y: 0, width: 512, height: 512, rx: MARK_GROUND_RX, tone: "ground" },
+ { part: "line 1", kind: "rect", x: 112, y: 128, width: 288, height: 44, rx: 22, tone: "dim" },
+ { part: "play head", kind: "polygon", points: [[112, 210], [172, 234], [112, 258]], tone: "lit" },
+ { part: "line 2", kind: "rect", x: 188, y: 212, width: 212, height: 44, rx: 22, tone: "lit" },
+ { part: "line 3", kind: "rect", x: 112, y: 296, width: 232, height: 44, rx: 22, tone: "dim" },
+ { part: "line 4", kind: "rect", x: 112, y: 380, width: 152, height: 44, rx: 22, tone: "dim" },
+];
+
+export type IconPalette = { ground: string; dim: string; lit: string };
+
+// The two icon palettes. A child site's lit line is its accent's on-dark value
+// (childIconPalette); the parent mark — homepage, hub, editor — is achromatic.
+export const ICON_PALETTES = {
+ child: { ground: "#0c0a08", dim: "#3b3327" },
+ archilyzer: { ground: "#151b20", dim: "#3f4c56", lit: "#e7edf1" },
+} as const satisfies {
+ child: Omit<IconPalette, "lit">;
+ archilyzer: IconPalette;
+};
+
+// A child site's icon palette, lit by its accent's on-dark value (from
+// lib/accent.ts `resolveAccent(site.accent).dark`).
+export function childIconPalette(lit: string): IconPalette {
+ return { ...ICON_PALETTES.child, lit };
+}
+
+// `any`: the rounded square. `maskable`: a full-bleed ground with the lines
+// scaled into the safe zone. `apple`: a full-bleed ground at scale 1 (iOS
+// rounds the corners itself).
+export type MarkVariant = "any" | "maskable" | "apple";
+
+const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
+
+function shapeSvg(s: MarkShape, fill: string, rx?: number): string {
+ if (s.kind === "polygon") {
+ return `<polygon points="${s.points.map(([x, y]) => `${x},${y}`).join(" ")}" fill="${fill}"/>`;
+ }
+ const r = rx ?? s.rx;
+ const pos = s.x || s.y ? `x="${s.x}" y="${s.y}" ` : "";
+ return `<rect ${pos}width="${s.width}" height="${s.height}"${r ? ` rx="${r}"` : ""} fill="${fill}"/>`;
+}
+
+// The mark as a standalone SVG document. Colours must be "#rrggbb" — anything
+// else throws, so a stored value can never inject markup into an icon.
+export function markSvg(
+ palette: IconPalette,
+ opts: { variant?: MarkVariant } = {},
+): string {
+ for (const [slot, colour] of Object.entries(palette)) {
+ if (!HEX_COLOR_RE.test(colour)) {
+ throw new Error(`markSvg: palette.${slot} must be #rrggbb, got ${JSON.stringify(colour)}`);
+ }
+ }
+ const variant = opts.variant ?? "any";
+ const fillOf = (t: MarkTone): string => palette[t].toLowerCase();
+ const [ground, ...rest] = MARK;
+ const groundSvg = shapeSvg(ground, fillOf(ground.tone), variant === "any" ? MARK_GROUND_RX : 0);
+ let body = rest.map((s) => shapeSvg(s, fillOf(s.tone))).join("");
+ if (variant === "maskable") {
+ // 256 × (1 − 0.8) = 51.2, rounded so float noise never reaches the SVG.
+ const offset = Number(((MARK_VIEWBOX / 2) * (1 - MARK_MASKABLE_SCALE)).toFixed(4));
+ body = `<g transform="translate(${offset} ${offset}) scale(${MARK_MASKABLE_SCALE})">${body}</g>`;
+ }
+ return (
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}">` +
+ groundSvg +
+ body +
+ `</svg>`
+ );
+}
+
+// ── The wordmark ────────────────────────────────────────────────────────────
+
+// The wordmark splits on the SUBJECT's name, not on "lyzer": the lead (heavy,
+// foreground) is the subject, the suffix (light, muted) the rest. The split is
+// configured per site (site.json `wordmarkLead`), never guessed — a heuristic
+// would turn "Rekietalyzer" into "Rekiet|alyzer".
+
+// The lead, trimmed, when it is a PROPER prefix of the title (non-empty and
+// shorter than it, case-sensitive); otherwise undefined.
+export function wordmarkLeadFor(title: string, lead: unknown): string | undefined {
+ if (typeof lead !== "string") return undefined;
+ const l = lead.trim();
+ return l && l.length < title.length && title.startsWith(l) ? l : undefined;
+}
+
+// Split a title for the two-weight wordmark. With no usable lead the whole
+// title is the lead and the suffix is empty.
+export function splitWordmark(
+ title: string,
+ lead?: string,
+): { lead: string; suffix: string } {
+ const l = wordmarkLeadFor(title, lead);
+ return l ? { lead: l, suffix: title.slice(l.length) } : { lead: title, suffix: "" };
+}
diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts
@@ -184,3 +184,16 @@ test("gone counts only a deleted record we hold (it has a downloadedDate)", () =
assert.equal(s.sites.find((x) => x.siteId === "beta")!.gone, 0);
assert.equal(s.official!.gone, 1);
});
+
+test("a site's accent is published as a hex: an id becomes its on-dark value", () => {
+ const sites = [
+ { ...site("alpha", ["a1", "a2"], "https://alpha.example"), accent: "brass" },
+ { ...site("beta", ["b1"], "https://beta.example"), accent: "#CC3366" },
+ ] as Site[];
+ const s = buildHomepageSummary(STATS, CHANNEL_SITES, sites, NOW);
+ assert.equal(s.sites.find((x) => x.siteId === "alpha")!.accent, "#e3b15c");
+ assert.equal(s.sites.find((x) => x.siteId === "beta")!.accent, "#cc3366");
+ // No accent: no key, as before.
+ const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW);
+ assert.ok(plain.sites.every((x) => !("accent" in x)));
+});
diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts
@@ -1,6 +1,7 @@
import type { Platform } from "./platform";
import type { VideoStat } from "./stats";
import type { Site } from "./site";
+import { accentHex } from "./accent";
import { VIDEO_STATES, type VideoState } from "./availability";
// Pre-computed, lightweight cross-site summary for the hub (homepage) landing.
@@ -428,7 +429,8 @@ export function buildHomepageSummary(
recordings: siteAcc.get(s.siteId)?.recordings ?? 0,
hoursArchived: Math.round((siteAcc.get(s.siteId)?.seconds ?? 0) / 3600),
gone: siteAcc.get(s.siteId)?.gone ?? 0,
- ...(s.accent ? { accent: s.accent } : {}),
+ // A published colour: an accent id becomes its hex (lib/accent.ts).
+ ...(accentHex(s.accent) ? { accent: accentHex(s.accent) } : {}),
}))
// 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/project.ts b/common/lib/project.ts
@@ -18,6 +18,12 @@
// not here.
export const PROJECT_NAME = "Archilyzer";
+// The wordmark's heavy lead: "Archi" + "lyzer" (lib/brand.ts splitWordmark).
+// The family's wordmark splits on the subject's name, and the project's subject
+// is the archive itself. The hub's synthesized Site carries it
+// (export/app/lib/site.ts hubSite).
+export const PROJECT_WORDMARK_LEAD = "Archi";
+
// The canonical public home of the project site. Baked into every export
// bundle's footer at build time, so changing it does not retroactively fix
// already-deployed archives. If a bespoke domain is ever acquired, DO NOT swap
diff --git a/common/lib/siteDescriptor.test.ts b/common/lib/siteDescriptor.test.ts
@@ -0,0 +1,40 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildSiteDescriptor } from "./siteDescriptor";
+import { parseSite } from "./siteSchema";
+import type { Manifest } from "./manifest";
+import { ACCENTS } from "./brand";
+
+// The public /site.json is read by OTHER hubs, which draw `accent` as a colour.
+// A site.json may now store an accent id; the descriptor must still publish a
+// hex (lib/accent.ts accentHex).
+
+const MANIFEST = {
+ version: 3,
+ totalCount: 0,
+ pageSize: 100,
+ pageCount: 0,
+ generatedAt: "2026-09-25T00:00:00.000Z",
+ channels: [],
+} as Manifest;
+
+function descriptorFor(raw: Record<string, unknown>) {
+ return buildSiteDescriptor(parseSite("s", raw), MANIFEST, [], { pwa: false });
+}
+
+test("an accent id is published as its on-dark hex", () => {
+ assert.equal(descriptorFor({ accent: "brass" }).accent, ACCENTS.brass.onDark);
+ assert.equal(descriptorFor({ accent: "sakura" }).accent, "#ee8fb5");
+});
+
+test("a custom hex is published as stored; no accent publishes no key", () => {
+ assert.equal(descriptorFor({ accent: "#CC3366" }).accent, "#cc3366");
+ assert.equal("accent" in descriptorFor({}), false);
+ assert.equal("accent" in descriptorFor({ accent: "gold" }), false);
+});
+
+test("the wordmark lead is not part of the public descriptor", () => {
+ const d = descriptorFor({ headerTitle: "Jeralyzer", wordmarkLead: "Jer" });
+ assert.equal("wordmarkLead" in d, false);
+ assert.equal(d.headerTitle, "Jeralyzer");
+});
diff --git a/common/lib/siteDescriptor.ts b/common/lib/siteDescriptor.ts
@@ -1,3 +1,4 @@
+import { accentHex } from "./accent";
import type { ChannelGroup } from "./channelGroups";
import { CONTRACT } from "./corpus";
import type { Manifest } from "./manifest";
@@ -84,7 +85,8 @@ export function buildSiteDescriptor(
siteDescription: site.siteDescription,
headerTitle: site.headerTitle,
homeTagline: site.homeTagline,
- ...(site.accent ? { accent: site.accent } : {}),
+ // Always a hex on the wire: an accent id means nothing to another hub.
+ ...(accentHex(site.accent) ? { accent: accentHex(site.accent) } : {}),
...(site.siteUrl ? { siteUrl: site.siteUrl } : {}),
...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
pwa: opts.pwa,
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -158,6 +158,56 @@ test("siteToDisk persists only the non-defaults", () => {
assert.deepEqual(full.socialLinks, []);
});
+test("accent: a named id or a custom hex is kept; anything else is dropped", () => {
+ assert.equal(parseSite("s", { accent: "brass" }).accent, "brass");
+ assert.equal(parseSite("s", { accent: " Sakura " }).accent, "sakura");
+ assert.equal(parseSite("s", { accent: "#CC3366" }).accent, "#cc3366");
+ for (const v of ["gold", "custom", "#abc", 7, null]) {
+ assert.equal(parseSite("s", { accent: v }).accent, undefined, String(v));
+ }
+ const disk = siteToDisk(parseSite("s", { accent: "Violet" })) as Record<string, unknown>;
+ assert.equal(disk.accent, "violet");
+ assert.equal("accent" in siteToDisk(parseSite("s", { accent: "gold" })), false);
+});
+
+test("wordmarkLead: kept only as a proper prefix of headerTitle, and written after it", () => {
+ const site = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: " Jer " });
+ assert.equal(site.wordmarkLead, "Jer");
+ // The key sits right after headerTitle, in the read AND on disk.
+ const keys = Object.keys(site);
+ assert.equal(keys[keys.indexOf("headerTitle") + 1], "wordmarkLead");
+ const disk = siteToDisk(site) as Record<string, unknown>;
+ const diskKeys = Object.keys(disk);
+ assert.equal(disk.wordmarkLead, "Jer");
+ assert.equal(diskKeys[diskKeys.indexOf("headerTitle") + 1], "wordmarkLead");
+ // Not a prefix, wrong case, the whole title, longer, blank, not a string:
+ // dropped on read, and never written.
+ for (const lead of ["Hasan", "jer", "Jeralyzer", "Jeralyzers", "", " ", 3]) {
+ const s2 = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: lead });
+ assert.equal(s2.wordmarkLead, undefined, JSON.stringify(lead));
+ assert.ok("wordmarkLead" in s2, "every key is emitted");
+ assert.equal("wordmarkLead" in siteToDisk(s2), false, JSON.stringify(lead));
+ }
+ // siteToDisk re-checks it: a caller's non-prefix lead is not persisted.
+ const edited = { ...site, headerTitle: "Hasanalyzer" };
+ assert.equal("wordmarkLead" in siteToDisk(edited), false);
+});
+
+test("writeSite → getSite round-trips a named accent and a wordmark lead", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ const site = parseSite("s", {
+ siteTitle: "Rekietalyzer",
+ headerTitle: "Rekietalyzer",
+ wordmarkLead: "Rekieta",
+ accent: "vermilion",
+ });
+ await writeSite(site, paths);
+ assert.deepEqual(getSite("s", paths), site);
+ const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8"));
+ assert.equal(disk.accent, "vermilion");
+ assert.equal(disk.wordmarkLead, "Rekieta");
+});
+
function fixtures(): Array<[string, unknown]> {
const out: Array<[string, unknown]> = [
["empty", {}],
@@ -192,6 +242,14 @@ function fixtures(): Array<[string, unknown]> {
},
],
];
+ out.push([
+ "named accent + wordmark lead",
+ { headerTitle: "Jeralyzer", wordmarkLead: "Jer", accent: "Brass" },
+ ]);
+ out.push([
+ "non-prefix lead + unknown accent",
+ { headerTitle: "Jeralyzer", wordmarkLead: "Hasan", accent: "gold" },
+ ]);
const e2e = path.join(HERE, "..", "..", "editor", "e2e", "fixtures", "sites", "testsite", "site.json");
out.push(["e2e testsite", JSON.parse(fs.readFileSync(e2e, "utf8"))]);
return out;
diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts
@@ -4,14 +4,15 @@
// one-core phase 3 slice 4b, on slice 4a's pattern (lib/settingsSchema.ts):
// every field is `settingsField(coerce)` over the parser that already existed —
// parseChannelGroups, resolveDefaultGroupId, parseSiteChannels,
-// parseSocialLinks, parseAccent, parseSiteUrl, parseRelatedSites — so no
+// parseSocialLinks, parseAccentSetting, parseSiteUrl, parseRelatedSites — so no
// boundary moved; zod supplies the key plumbing and strips unknown keys. No
// `.default()`, no `.passthrough()`.
//
// WHAT A READ PROMISES (parseSite): it never throws, and it emits EVERY key —
// an absent optional key is present with the value `undefined` — exactly as the
-// hand-written parseSite did. Two fields depend on a sibling (the default group
-// must be one of the groups; a membership's group must exist), so the schema is
+// hand-written parseSite did. Three fields depend on a sibling (the default
+// group must be one of the groups; a membership's group must exist; the
+// wordmark lead must be a proper prefix of the header title), so the schema is
// the per-key object followed by ONE object-level step that resolves them.
//
// WHAT A WRITE PROMISES (writeSite in lib/site.ts): the throwing validations,
@@ -35,7 +36,8 @@ import {
resolveDefaultGroupId,
type ChannelGroup,
} from "./channelGroups";
-import { parseAccent } from "./accent";
+import { parseAccentSetting } from "./accent";
+import { wordmarkLeadFor } from "./brand";
import { parseSocialLinks, type SocialLink } from "./settingsSchema";
import { settingsField } from "./settingsFieldSchemas";
import type { FieldDocs } from "./fieldDocs";
@@ -73,6 +75,7 @@ export type Site = {
siteTitle: string;
siteDescription: string;
headerTitle: string;
+ wordmarkLead?: string;
homeTagline: string;
socialLinks?: SocialLink[];
groups: ChannelGroup[];
@@ -96,6 +99,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
siteTitle: "The site's title (browser tab, manifest, headings).",
siteDescription: "One-line description (meta description, manifest).",
headerTitle: "The title shown in the site header.",
+ wordmarkLead:
+ 'The heavy first part of the header wordmark: the subject\'s name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.',
homeTagline: "Tagline under the home page title. Empty = none.",
socialLinks:
"Per-site social links. ABSENT means inherit the global default (`settings.json` `socialLinks`); an array — even an empty one — overrides it. Each link's SVG must be safe to inline or the save is refused.",
@@ -108,7 +113,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
cloudflareProject:
"Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.",
accent:
- 'Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site\'s public build. Absent = inherit the family brass. Any other spelling is dropped.',
+ 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.',
siteUrl:
"Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.",
relatedSites:
@@ -223,6 +228,10 @@ export const siteFieldsSchema = z.object({
d.siteDescription,
),
headerTitle: settingsField(stringOr(SITE_DEFAULT_TITLE)).describe(d.headerTitle),
+ // Checked against `headerTitle` in the object step below.
+ wordmarkLead: settingsField((v): string | undefined =>
+ typeof v === "string" && v.trim() ? v.trim() : undefined,
+ ).describe(d.wordmarkLead),
homeTagline: settingsField(stringOr("")).describe(d.homeTagline),
// Key present (array) = override; absent = inherit the global default.
socialLinks: settingsField((v): SocialLink[] | undefined =>
@@ -235,7 +244,7 @@ export const siteFieldsSchema = z.object({
cloudflareProject: settingsField((v): string | undefined =>
typeof v === "string" && v.trim() ? v.trim() : undefined,
).describe(d.cloudflareProject),
- accent: settingsField(parseAccent).describe(d.accent),
+ accent: settingsField(parseAccentSetting).describe(d.accent),
siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl),
relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites),
pwa: settingsField((v): boolean => v === true).describe(d.pwa),
@@ -249,7 +258,8 @@ export const siteFieldsSchema = z.object({
hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl),
});
-// The whole schema: the per-key object, then the two sibling-dependent fields.
+// The whole schema: the per-key object, then the three sibling-dependent
+// fields.
//
// THE OBJECT STEP ALSO RE-EMITS EVERY KEY, in order. zod 4 omits a key that was
// absent from the input when its transform returns `undefined`, so the per-key
@@ -261,6 +271,9 @@ export const siteSchema = siteFieldsSchema.transform((s): Site => {
const resolved: Partial<Record<keyof Site, unknown>> = {
...s,
defaultGroupId: resolveDefaultGroupId(s.defaultGroupId, groups),
+ // A lead that is not a proper prefix of the header title is dropped (the
+ // wordmark then sets the whole title heavy; lib/brand.ts splitWordmark).
+ wordmarkLead: wordmarkLeadFor(s.headerTitle, s.wordmarkLead),
// Drop a membership's groupId that names no configured group (it folds to
// the default at render time via resolveChannelGroupId); keep a valid one
// so the editor round-trips it.
@@ -299,7 +312,8 @@ export function siteToDisk(site: Site): Site {
(c) => !c.groupId || groups.some((g) => g.id === c.groupId),
);
const relatedSites = parseRelatedSites(site.relatedSites);
- const accent = parseAccent(site.accent);
+ const accent = parseAccentSetting(site.accent);
+ const wordmarkLead = wordmarkLeadFor(site.headerTitle, site.wordmarkLead);
const siteUrl = parseSiteUrl(site.siteUrl);
const hubUrl = parseSiteUrl(site.hubUrl);
const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes);
@@ -308,6 +322,7 @@ export function siteToDisk(site: Site): Site {
siteTitle: site.siteTitle,
siteDescription: site.siteDescription,
headerTitle: site.headerTitle,
+ ...(wordmarkLead ? { wordmarkLead } : {}),
homeTagline: site.homeTagline,
// undefined socialLinks = inherit the global default; only an explicit
// override (an array, even empty) is persisted.
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A site's accent is one of seven named colours or a custom one, and a site can split its wordmark.** The site form's **Brand accent** is now a row of swatches: Signal (the family default), Brass, Vermilion, Violet, Sakura, Blue and Green, plus **Custom**, whose colour goes in the **Custom hex** field (typing there picks Custom). `site.json` stores the accent's id (`"accent": "brass"`) or the hex. Picking Signal stores no key, because no key means Signal. A custom colour is darkened or lightened for each reading theme until it reads at 4.5:1; the seven named colours already do. A new **Wordmark lead** field names the heavy first part of the header wordmark, e.g. `Jer` for Jeralyzer. The form refuses a lead that is not how the header title starts, and `site.json` keeps `wordmarkLead` only when it is. What other hubs read does not change: `/site.json`, the hub's `hub-sites.json` and the homepage summary still carry a hex, and an id goes out as its colour on dark. `SITE.md` lists both keys. The header, icons and theme picker that use them come with the rest of the brand work; until then a site with a named accent is tinted with that colour, as a custom hex is today.
- **The hub's search shows each archive's state, lets you choose which archives to search, and no longer waits for the slowest.** Under the line "Searching N archives …" is a row of chips, one per archive on the hub (official and added). Each says whether that archive is loading, how many videos it has in the search once it is in, or that it failed, with a Retry beside it. Pressing a chip takes that archive out of the search: nothing more is fetched from it and its results disappear. The choice is kept in this browser, and an archive it has never seen is searched. The search now runs as soon as one archive has loaded and runs again as each further one arrives, where it used to wait for all of them. When an archive does not answer, a line above the results says so once the others have loaded ("4 of 5 archives answered. Hasanalyzer did not, so its videos are not in these results.") with a Retry, and the other archives' results show as usual. None of that archive's videos, posts or live chat are searched until a Retry succeeds; before, it dropped out silently or in part. Each result names its archive in text before the channel ("Jeralyzer · TheQuartering · 2026-09-25"). The hub now loads at most six summaries pages at a time from each archive, so a large archive does not hold up the small ones. The line under the archives now counts "videos", as the chips do. `/ask` on the hub is unchanged: it searches every archive and waits for all of them. Published sites are unchanged. Needs a rebuild and deploy of the hub.
- **The hub and the homepage drop their subtitles, and both carry a Ko-fi link.** The headings are in Title Case on both pages: **Official Instances**, **Archives You Added** (hub) and **What It Does** (homepage). The line "The archives I run. Anyone can run their own." is gone from both, and the official-instance cards no longer show the site's description under its name; the four figures stay. The footer of the homepage and of the hub has a plain link reading "Ko-fi" to `https://ko-fi.com/archilyzer`. A published site's footer does not: an archive someone else hosts never carries it. Needs a rebuild and deploy of the hub and the homepage.
- **A job that waited in a queue no longer ends `failed` after doing its work.** A sync, download or other job queued behind another on the same platform ran its final page refresh outside any request, where Next refuses it, so the job read `failed` and `pnpm ops … --wait` exited 1 even though the work was done (the teamrcn sync on 2026-09-25). The refresh is now skipped there with one warning in the server log; the pages re-read disk on their next load anyway.
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -2,7 +2,14 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { parseAccent } from "yt-dlp-transcript-common/lib/accent";
+import {
+ parseAccent,
+ parseAccentSetting,
+} from "yt-dlp-transcript-common/lib/accent";
+import {
+ DEFAULT_ACCENT,
+ wordmarkLeadFor,
+} from "yt-dlp-transcript-common/lib/brand";
import {
writeSite,
deleteSite,
@@ -42,13 +49,37 @@ export async function saveSiteAction(
if (!headerTitle) return { ok: false, error: "Header title is required" };
const siteDescription = String(formData.get("siteDescription") ?? "").trim();
const homeTagline = String(formData.get("homeTagline") ?? "").trim();
- const accentRaw = String(formData.get("accent") ?? "").trim();
- if (accentRaw && !parseAccent(accentRaw)) {
+ // The wordmark's heavy lead must be a proper prefix of the header title;
+ // lib/siteSchema.ts would silently drop anything else, so say so here.
+ const wordmarkLead = String(formData.get("wordmarkLead") ?? "").trim();
+ if (wordmarkLead && !wordmarkLeadFor(headerTitle, wordmarkLead)) {
return {
ok: false,
- error: "Brand accent must be a hex color like #95661a (or left blank).",
+ error: `Wordmark lead must be how the header title starts, same case, and shorter than it (e.g. "Jer" for "Jeralyzer") — or left blank.`,
};
}
+ // Brand accent: a named id from the radio group, or "custom" + its hex
+ // field. The default (Signal) is stored as absent — the two read the same.
+ // A bare hex in `accent` (a form from before the radio group) is taken as
+ // a custom colour.
+ const accentChoice = String(formData.get("accent") ?? "").trim();
+ const accentCustomRaw = String(formData.get("accentCustom") ?? "").trim();
+ let accent: string | undefined;
+ if (accentChoice === "custom") {
+ accent = parseAccent(accentCustomRaw);
+ if (!accent) {
+ return {
+ ok: false,
+ error: "Custom accent must be a hex color like #cc3366.",
+ };
+ }
+ } else if (accentChoice) {
+ accent = parseAccentSetting(accentChoice);
+ if (!accent) {
+ return { ok: false, error: `Unknown brand accent "${accentChoice}".` };
+ }
+ if (accent === DEFAULT_ACCENT) accent = undefined;
+ }
const cloudflareProject = String(
formData.get("cloudflareProject") ?? "",
).trim();
@@ -171,12 +202,13 @@ export async function saveSiteAction(
siteTitle,
siteDescription,
headerTitle,
+ ...(wordmarkLead ? { wordmarkLead } : {}),
homeTagline,
...(socialLinks !== undefined ? { socialLinks } : {}),
groups,
defaultGroupId,
channels,
- ...(parseAccent(accentRaw) ? { accent: parseAccent(accentRaw) } : {}),
+ ...(accent ? { accent } : {}),
...(cloudflareProject ? { cloudflareProject } : {}),
...(siteUrl ? { siteUrl } : {}),
...(hubUrl ? { hubUrl } : {}),
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -1,9 +1,15 @@
"use client";
-import { useState } from "react";
+import { useState, type CSSProperties } from "react";
import { useActionState } from "react";
import { saveSiteAction, type SaveResult } from "../actions";
import type { Site } from "yt-dlp-transcript-common/lib/site";
+import {
+ ACCENTS,
+ ACCENT_IDS,
+ DEFAULT_ACCENT,
+ isAccentId,
+} from "yt-dlp-transcript-common/lib/brand";
import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups";
import {
SocialLinksField,
@@ -62,6 +68,18 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
})),
);
const [defaultGroupId, setDefaultGroupId] = useState(initial.defaultGroupId);
+ // Brand accent: one of the named accents (absent reads as the default,
+ // Signal) or "custom" with its own hex. Controlled, so the choice survives
+ // the form reset React does after an action.
+ const initialCustom =
+ initial.accent && !isAccentId(initial.accent) ? initial.accent : "";
+ const [accentChoice, setAccentChoice] = useState<string>(() =>
+ initialCustom ? "custom" : (initial.accent ?? DEFAULT_ACCENT),
+ );
+ const [customHex, setCustomHex] = useState(initialCustom);
+ const customSwatch = /^#?[0-9a-f]{6}$/i.test(customHex.trim())
+ ? `#${customHex.trim().replace(/^#/, "")}`
+ : undefined;
// undefined socialLinks = inherit the global default (the default for new
// sites); an array (even empty) = this site supplies its own list.
const [inheritSocial, setInheritSocial] = useState(
@@ -217,6 +235,12 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
required
/>
<Field
+ label="Wordmark lead"
+ name="wordmarkLead"
+ defaultValue={initial.wordmarkLead ?? ""}
+ hint={'The heavy start of the wordmark — the subject\'s name, e.g. "Jer" for Jeralyzer; the rest is set light. It must be how the header text begins (same case) and shorter than it. Leave blank to set the whole name heavy.'}
+ />
+ <Field
label="Site description"
name="siteDescription"
defaultValue={initial.siteDescription}
@@ -226,12 +250,71 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
name="homeTagline"
defaultValue={initial.homeTagline}
/>
- <Field
- label="Brand accent"
- name="accent"
- defaultValue={initial.accent ?? ""}
- hint="Optional brand color as a hex (#rrggbb) — overrides the family brass on this site. Leave blank to inherit the family accent."
- />
+ <fieldset className="flex flex-col gap-2 text-sm">
+ <legend className="font-medium">Brand accent</legend>
+ <p className="text-xs text-muted-foreground">
+ This site's default accent (its icon and first paint); a reader
+ can pick another. A custom colour is darkened or lightened per theme
+ until it reads at 4.5:1.
+ </p>
+ <div className="flex flex-wrap gap-x-4 gap-y-2">
+ {ACCENT_IDS.map((id) => (
+ <label key={id} className="flex items-center gap-1.5">
+ <input
+ type="radio"
+ name="accent"
+ value={id}
+ checked={accentChoice === id}
+ onChange={() => setAccentChoice(id)}
+ className="accent-brand"
+ />
+ <span
+ aria-hidden="true"
+ className="size-3.5 rounded-full border border-border bg-[var(--swatch-on-light)] dark:bg-[var(--swatch-on-dark)]"
+ style={
+ {
+ "--swatch-on-light": ACCENTS[id].onLight,
+ "--swatch-on-dark": ACCENTS[id].onDark,
+ } as CSSProperties
+ }
+ />
+ {ACCENTS[id].name}
+ {id === DEFAULT_ACCENT ? " (default)" : ""}
+ </label>
+ ))}
+ <label className="flex items-center gap-1.5">
+ <input
+ type="radio"
+ name="accent"
+ value="custom"
+ checked={accentChoice === "custom"}
+ onChange={() => setAccentChoice("custom")}
+ className="accent-brand"
+ />
+ <span
+ aria-hidden="true"
+ className="size-3.5 rounded-full border border-border"
+ style={{ background: customSwatch ?? "transparent" }}
+ />
+ Custom
+ </label>
+ </div>
+ <label className="flex items-center gap-2">
+ <span className="font-medium">Custom hex</span>
+ <input
+ type="text"
+ name="accentCustom"
+ value={customHex}
+ onChange={(e) => {
+ setCustomHex(e.target.value);
+ setAccentChoice("custom");
+ }}
+ placeholder="#cc3366"
+ spellCheck={false}
+ className="w-28 rounded border border-border bg-card px-2 py-1 font-mono text-sm"
+ />
+ </label>
+ </fieldset>
<Field
label="Cloudflare Pages project"
name="cloudflareProject"
diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts
@@ -400,3 +400,75 @@ test("first-run migrate button appears only when no sites exist", async ({
page.getByRole("button", { name: /migrate existing settings/i }),
).toHaveCount(0);
});
+
+test("brand accent radio group + wordmark lead round-trip to site.json", async ({
+ page,
+}) => {
+ await resetData("empty");
+ await writeSite("brandy", { siteTitle: "Brandyalyzer" });
+
+ type BrandSiteFile = { accent?: string; wordmarkLead?: string };
+ const file = "test-transcripts/sites/brandy/site.json";
+ const accents = page.getByRole("group", { name: "Brand accent" });
+ const radio = (name: string | RegExp) =>
+ accents.getByRole("radio", { name, exact: typeof name === "string" });
+ const hex = page.getByRole("textbox", { name: "Custom hex" });
+ const lead = page.getByLabel(/wordmark lead/i);
+ const save = async () => {
+ await page.getByRole("button", { name: /save site/i }).click();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Saved" }),
+ ).toBeVisible();
+ };
+
+ // No accent on disk reads as the default, Signal; seven swatches + Custom.
+ await page.goto("/sites/brandy");
+ await expect(accents.getByRole("radio")).toHaveCount(8);
+ await expect(radio(/^Signal/)).toBeChecked();
+ await expect(lead).toHaveValue("");
+
+ await radio("Brass").check();
+ await lead.fill("Brandy");
+ await save();
+ await expect(async () => {
+ const site = await readJson<BrandSiteFile>(file);
+ expect(site.accent).toBe("brass");
+ expect(site.wordmarkLead).toBe("Brandy");
+ }).toPass({ timeout: 10_000 });
+
+ await page.goto("/sites/brandy");
+ await expect(radio("Brass")).toBeChecked();
+ await expect(lead).toHaveValue("Brandy");
+
+ // Typing a hex selects Custom; it is stored normalized.
+ await hex.fill("#CC3366");
+ await expect(radio("Custom")).toBeChecked();
+ await save();
+ await expect(async () => {
+ const site = await readJson<BrandSiteFile>(file);
+ expect(site.accent).toBe("#cc3366");
+ }).toPass({ timeout: 10_000 });
+
+ await page.goto("/sites/brandy");
+ await expect(radio("Custom")).toBeChecked();
+ await expect(hex).toHaveValue("#cc3366");
+
+ // A lead that is not how the header title starts is refused, not dropped.
+ await lead.fill("brandy");
+ await page.getByRole("button", { name: /save site/i }).click();
+ // Filtered: Next's route announcer is a second (empty) role=alert.
+ await expect(
+ page.getByRole("alert").filter({ hasText: /wordmark lead must be/i }),
+ ).toBeVisible();
+ expect((await readJson<BrandSiteFile>(file)).wordmarkLead).toBe("Brandy");
+
+ // Back to the default: stored as absent; a blank lead removes the key.
+ await lead.fill("");
+ await radio(/^Signal/).check();
+ await save();
+ await expect(async () => {
+ const site = await readJson<BrandSiteFile>(file);
+ expect("accent" in site).toBe(false);
+ expect("wordmarkLead" in site).toBe(false);
+ }).toPass({ timeout: 10_000 });
+});
diff --git a/export/app/lib/site.ts b/export/app/lib/site.ts
@@ -10,6 +10,7 @@ import {
getHomepageConfig,
resolveHomepageSocialLinks,
} from "yt-dlp-transcript-common/lib/homepage";
+import { PROJECT_WORDMARK_LEAD } from "yt-dlp-transcript-common/lib/project";
// The export app builds ONE site at a time, selected by the SITE_ID env var
// (set by the build:site script). At dev time SITE_ID may be unset; we then
@@ -24,7 +25,7 @@ import {
// In hub mode the app has no single Site — its branding is the instance-level
// HomepageConfig ("Archilyzer"). Synthesize a Site from it so layout/manifest/
// Header/Footer keep working unchanged (no channels/groups; the hub ships a PWA
-// by default). Read INSTANCE_MODE directly here rather than importing mode.ts,
+// by default; its wordmark splits as the project's own). Read INSTANCE_MODE directly here rather than importing mode.ts,
// which imports currentSite() (would be a cycle).
function hubSite(): Site {
const cfg = getHomepageConfig();
@@ -32,6 +33,10 @@ function hubSite(): Site {
siteTitle: cfg.siteTitle,
siteDescription: cfg.siteDescription,
headerTitle: cfg.headerTitle,
+ // "Archi" + "lyzer". Dropped by the parse (whole title heavy) if the
+ // operator's homepage.json retitles the header to something it does not
+ // start.
+ wordmarkLead: PROJECT_WORDMARK_LEAD,
homeTagline: cfg.homeTagline,
...(cfg.siteUrl ? { siteUrl: cfg.siteUrl } : {}),
socialLinks: resolveHomepageSocialLinks(cfg),
diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -0,0 +1,638 @@
+# Brand + themes: the Found-line mark, and base × accent
+
+Status: APPROVED 2026-09-25 — implementing on `brand/found-line` (worktree `../brand-found-line`,
+pnpm wt block #2). Slices S1 and S2 branch from S0's tip as `brand/mark` and `brand/themes`.
+
+Design canvas: https://claude.ai/artifact/UsUxwgRkP5a3m4jZXucAvG (boards *Family*, *In context*,
+*Themes · base × accent*, and the rejected directions A–C). Read it with Artifact
+`action:"read"`; republish by staging under the primary checkout's gitignored
+`node_modules/.brand-canvas/project/` (Artifact publish only accepts paths inside the repo).
+**Where this plan and the canvas differ, the plan wins** — notably the contrast-corrected
+on-light and on-sepia accent values below.
+
+## What the operator asked for (2026-09-25)
+
+1. One logo and brand for Archilyzer and every official child site.
+2. A reader theme made of two independent choices: a **base** (Light / Sepia / Dark, plus
+ System) and an **accent**. Every site defaults to its own accent; a reader can pick any other.
+
+## Decisions (final)
+
+- **Mark D · Found line.** Four transcript lines on a rounded square; the second is lit and
+ carries a play head.
+- **The wordmark splits on the subject's name,** not on "lyzer".
+- **The dark base is warm ink,** not graphite.
+- **Accent swaps:** Anilyzer takes Sakura and Jasolyzer takes Green, because Archilyzer keeps
+ its teal ("Signal").
+- **All five theme families retire:** base, archive, selenized, swiss and archilyzer.
+- **No new dependencies expected.** `rsvg-convert`, `magick` and `inkscape` are on the machine,
+ and `next/og` bundles satori and resvg. sharp's native build failed in the worktree; nothing
+ here needs it.
+
+## The design (source of truth)
+
+### Mark
+
+Everything is in a 512 viewBox. The shapes, back to front:
+
+| Part | Shape | Colour |
+|---|---|---|
+| ground | rect, `rx=112` | ground |
+| line 1 | rect x112 y128 w288 h44 rx22 | dim |
+| play head | polygon `112,210 172,234 112,258` | lit |
+| line 2 (the found line) | rect x188 y212 w212 h44 rx22 | lit |
+| line 3 | rect x112 y296 w232 h44 rx22 | dim |
+| line 4 | rect x112 y380 w152 h44 rx22 | dim |
+
+Icon palettes:
+- **Child site:** ground `#0c0a08`, dim `#3b3327`, lit = the site accent's on-dark value.
+- **Archilyzer** (homepage, hub, editor): ground `#151b20`, dim `#3f4c56`, lit `#e7edf1`
+ (bone). The parent mark is achromatic.
+
+Icon variants:
+- `any`: `rx=112`.
+- `maskable`: full-bleed ground, the mark scaled 0.8 about (256,256).
+- `apple`: full-bleed ground at scale 1.
+
+### Wordmark
+
+Archivo at `font-stretch:118%`. The lead is weight 720 in `--foreground`; the suffix is weight
+380 in `--muted-foreground`. Two adjacent spans with no whitespace between them; the link's
+accessible name is the full title.
+
+| Site | Lead | Suffix |
+|---|---|---|
+| Archilyzer | Archi | lyzer |
+| Jeralyzer | Jer | alyzer |
+| Rekietalyzer | Rekieta | lyzer |
+| Hasanalyzer | Hasan | alyzer |
+| Anilyzer | Ani | lyzer |
+| Bonnellyzer | Bonnell | yzer |
+| Jasolyzer | Jaso | lyzer |
+
+### Accents
+
+The on-light and on-sepia values are contrast-corrected: each must reach ≥4.5:1 against its
+ground and with white ink. A unit test enforces it.
+
+| id | Name | Site default | On dark | On light | On sepia |
+|---|---|---|---|---|---|
+| `signal` | Signal | Archilyzer (homepage, hub, editor) | `#5fa8a0` | `#2e7b73` | `#2b756e` |
+| `brass` | Brass | jeralyzer | `#e3b15c` | `#95661a` | `#8e6119` |
+| `vermilion` | Vermilion | rekietalyzer | `#ec7a52` | `#b3431f` | `#b3431f` |
+| `violet` | Violet | hasanalyzer | `#b49cf2` | `#6a4bc4` | `#6a4bc4` |
+| `sakura` | Sakura | anilyzer | `#ee8fb5` | `#a83a6a` | `#a83a6a` |
+| `blue` | Blue | bonnellyzer | `#74a9f2` | `#2d5fb8` | `#2d5fb8` |
+| `green` | Green | jasolyzer | `#7cc46a` | `#3f7a2c` | `#3d772b` |
+
+### Bases
+
+Each base declares all 48 colour tokens listed in `homepage/e2e/theme.spec.ts:63-112`, plus
+`color-scheme`.
+
+| Base | Source | Values |
+|---|---|---|
+| Light | today's `archilyzer` light block | bg `#f3f6f7`, surface `#e8edef`, fg `#161c21`, muted-fg `#55646e`, border `#d2dade`, border-strong `#aeb9c0`, faint `#78868f` |
+| Sepia | new | bg `#f4ecd8`, card `#faf4e6`, fg `#33281a`, muted-fg `#6b5c43`, border `#dccdaa` |
+| Dark | today's `archive` dark (ink) | bg `#0c0a08`, surface `#141009`, fg `#efe7d8`, muted-fg `#a39a86`, border `rgba(233,220,197,.1)` |
+
+- **Sepia** derives its status, panel and chart values from the archive paper block, darkened
+ for a light ground.
+- **Dark** adds the `--destructive-soft` and `--state-gone(-soft)` it lacks today.
+- **Charts do not follow the accent:** each base has its own fixed `--chart-1..5`. Re-validate
+ chart-3 against `--state-gone` with the dataviz skill's validator (FACTS.md ~6442 records a
+ real past collision), and keep chart-1 ≠ Signal.
+
+### Type and radius
+
+Three faces everywhere:
+- Archivo as `--font-display`, with the `wdth` axis. `.font-display` gets `font-stretch:112%`.
+- IBM Plex Sans as `--font-sans`, weights 400–700, normal + italic.
+- IBM Plex Mono as `--font-mono`.
+
+`--radius: 0.375rem` on `:root`.
+
+## Slices
+
+```
+S0 palette + data ──┬──> S1 brand (mark, icons, wordmark) ──┐
+ └──> S2 themes (tokens, runtime, picker) ┴──> S3 integrate + gates ──> operator rollout
+```
+
+Cadence: the planner/reviewer orchestrates; one Opus general-purpose implementer per slice
+writes the code, under [`tools/implementer-rules.md`](tools/implementer-rules.md). S1 and S2 run
+as two parallel implementers, each in its own worktree cut from S0's tip
+(`pnpm wt add brand/mark --from brand/found-line`, `pnpm wt add brand/themes --from brand/found-line`).
+Review fixes go back to the same agent. Sonnet or Haiku do exploration and extraction.
+
+**Conflict avoidance between S1 and S2:**
+- In `export/app/layout.tsx` and `homepage/app/layout.tsx`, S1 edits only the `icons` metadata;
+ S2 edits only the theme constants, `generateViewport` and the `RootLayout` `<html>`.
+- `Header.tsx` and `Footer.tsx` belong to S1; `ThemeMenu`, `MobileMenu` and `ThemeToggle` belong
+ to S2.
+- S1 colours the header mark with `var(--brand-mark, var(--brand))`, so it works before S2 lands.
+- S1 puts new assertions in a new `export/e2e/brand.spec.ts`; S2 owns
+ `site-branding.spec.ts:75-86`.
+- Whichever of S1/S2 merges second merges `main` and re-gates.
+- Release 9 slice C2 (`one-core/c2-federated-search`) is in flight on the hub search
+ components; neither slice touches `SearchResults.tsx`, `FiltersPanel.tsx` or
+ `ArchiveShelf.tsx` (they receive hexes and keep working unchanged).
+
+### S0 — palette + data (`brand/found-line`, alone)
+
+**New `common/lib/brand.ts` (pure):**
+- `ACCENT_IDS` and an `ACCENTS` table, `{id, name, onDark, onLight, onSepia}` per accent.
+- `DEFAULT_ACCENT = "signal"`.
+- `BASE_GROUNDS = {light: "#f3f6f7", sepia: "#f4ecd8", dark: "#0c0a08"}`.
+- `ICON_PALETTES` for child sites and for Archilyzer.
+- The `MARK` shape list and `markSvg(palette, {variant})`.
+- `splitWordmark(title, lead?)`.
+
+`common/lib/project.ts` gains `PROJECT_WORDMARK_LEAD = "Archi"`.
+
+**`common/lib/accent.ts`:**
+- Keep `parseAccent`.
+- Add `parseAccentSetting(v)`: an accent id, a `"#rrggbb"`, or undefined.
+- Add `resolveAccent(v)`: `{id | "custom", light, sepia, dark}`. A custom hex is darkened or
+ lightened per ground until it reaches 4.5:1.
+- Add `accentHex(v)`. The published value is always a hex: an id becomes its on-dark value.
+- Add `customAccentVars()`.
+- `siteAccentVars` is deleted in S2.
+
+**`common/lib/siteSchema.ts`:**
+- `accent` parses through `parseAccentSetting` (around :238, :302, :321), with new doc text
+ (:110).
+- New optional `wordmarkLead`, after `headerTitle`. Stored only when it is a proper prefix of
+ `headerTitle`; needs type, `SITE_FIELD_DOCS`, schema and `siteToDisk` entries.
+- Regenerate SITE.md: `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`,
+ checked with `--check`.
+
+**The published accent stays a hex.** Third-party hubs read other sites' `/site.json`, so these
+route through `accentHex`:
+- `common/lib/siteDescriptor.ts:~87`
+- `common/bin/compose-hub.ts:~94`
+- `common/lib/homepageSummary.ts:~431`
+
+**Editor form** (`editor/app/sites/components/SiteForm.tsx:229-234`,
+`editor/app/sites/actions.ts:~45-51,169-191`):
+- "Brand accent" becomes a native radio group of the 7 swatches plus "Custom", with a hex field.
+- A "Wordmark lead" field. The single writer is `writeSite` in `common/lib/site.ts`.
+- `Field` puts the hint inside the `<label>`, so the new labels and hints must not contain
+ "header title", "site title", "site id" or "public url" — `sites-crud`'s `getByLabel` would hit
+ strict-mode violations.
+- `hubSite()` (`export/app/lib/site.ts:~29`) sets `wordmarkLead: PROJECT_WORDMARK_LEAD`.
+
+**Tests:** `brand.test.ts` (contrast ≥4.5 everywhere, `markSvg` geometry); `accent.test.ts`; a
+siteSchema round-trip with an id and `wordmarkLead`; siteDescriptor publishes a hex for an id;
+`fileSchemaDocs.test.ts` passes on the regenerated SITE.md.
+
+**e2e:** editor `sites-crud` and `branding` (the root `pnpm e2e` is the editor suite); export
+`site-branding`; `pnpm --filter export run e2e:hub`.
+
+### S1 — mark, icons, wordmark (`brand/mark`)
+
+**Step 1 is a spike, timeboxed to about an hour:**
+1. Seed the `export/public` symlinks (implementer-rules.md, under `sh`).
+2. Build a PNG icon via `app/icons/[file]/route.ts`.
+3. `pnpm --filter export exec next build`.
+4. Check `out/icons/icon-192.png`: PNG magic, a 192×192 IHDR, and look at it.
+5. Check `out/favicon.ico` starts with `00 00 01 00`.
+6. Repeat for the homepage.
+
+**If the spike fails:**
+- The PNG route fails: call the same renderer from compose-site / compose-hub /
+ compose-homepage and write into `public/icons`. The last resort is `rsvg-convert`.
+- Only the ICO fails: check in a `public/favicon.ico` family mark generated once by
+ `common/bin/brand-icons.ts`. Next reserves `/favicon.ico` as a metadata route
+ (`is-metadata-route.js:114`).
+
+**Static route handlers are safe under static export.** Under `output:"export"` a GET route
+handler with `generateStaticParams` is copied byte-for-byte to its exact path
+(`next/dist/export/index.js:~722-737`). `app/icon.tsx` is unusable: its URLs are always hashed.
+
+**`common/lib/brandIcons.ts`:**
+- `ICON_FILES`: `icon.svg`, `maskable.svg`, `icon-32.png`, `icon-192.png`, `icon-512.png`,
+ `maskable-512.png`, `apple-touch-icon.png`.
+- `renderIconPng()` — `ImageResponse` from `next/og` on the node runtime, with an `<img>` SVG
+ data URI via `createElement`.
+- `pngToIco()` — a 6-byte header, 16-byte entries, then the PNG payloads.
+
+**Routes:** `export/app/icons/[file]/route.ts` and a homepage twin.
+- `dynamic = "force-static"`, `dynamicParams = false`; never read the request.
+- The palette is Archilyzer in hub mode (`instanceMode()`), otherwise the site's resolved accent
+ via `currentSite()`.
+- Delete `export/public/icons/*`, `homepage/public/icons/*` and both `app/favicon.ico`.
+- Metadata declares `icon.svg` (type `image/svg+xml`), `icon-32.png`, 192, 512 and apple.
+- The homepage OG image keeps `/icons/icon-512.png`.
+
+**Manifest** (`export/app/manifest.ts`): `theme_color = BASE_GROUNDS.dark`; `background_color` is
+the icon ground; add `icon.svg` with sizes "any". `pwa.spec` requires the 192/512/maskable
+entries and the four PNG paths.
+
+**Service workers:** in `export/service-worker/site-sw.js:~28` and `sw-hub.js:~20`, rename
+**only** the `SHELL` cache to `shell-v2`. Bumping `VERSION` would make activate delete readers'
+offline channel downloads.
+
+**Components:**
+- New `common/components/BrandMark.tsx`: inline SVG, `aria-hidden`, `focusable="false"`, colours
+ through `style`, not `fill="var()"`.
+- New `common/components/Wordmark.tsx`.
+- `export/app/components/Header.tsx:68-73`: the rotated square becomes an ink tile plus
+ `var(--brand-mark)` — the header follows the reader's accent while the favicon keeps the site
+ default. The Wordmark uses `site.wordmarkLead`. The hub reuses this header and gets the
+ Archilyzer mark.
+- `homepage/app/components/Header.tsx:39-51`: the CSS triangle becomes the bone mark, and
+ "Archilyzer" becomes the split wordmark, no longer tracked uppercase. Keep
+ `aria-label="Archilyzer home"`.
+- `export/app/components/Footer.tsx`: a small Archilyzer mark before the credit, outside the
+ link. The link's name stays exactly "Archilyzer".
+- Editor sidebar (`editor/app/layout.tsx:~133-144`): a mark beside `adminTitle`, not split.
+- Editor favicon: a static `editor/app/icon.svg`, with a parity test against `markSvg`.
+
+**Fixture:** `"wordmarkLead":"Fixture"` in `export/e2e/fixtures/sites/testsite/site.json`.
+
+**Tests:** new `export/e2e/brand.spec.ts` (split spans, link name, mark present, footer credit);
+`pwa.spec.ts:30-42` adds content-type, PNG magic, `icon.svg` and `favicon.ico`; the hub's
+`/icons/icon.svg` contains `#151b20` / `#e7edf1`; unit tests for the ICO header, and the PNG IHDR
+if `next/og` runs under tsx.
+
+### S2 — base × accent (`brand/themes`)
+
+**`common/styles/tokens.css`:**
+- Keep `@custom-variant dark (&:where(.dark, .dark *))` (:28). `.dark` is on `<html>` iff the
+ resolved base is dark, so the 47 `dark:` usages keep working and sepia styles as light.
+- Delete every `[data-theme=…]` block, the `.dark{}` token block and the per-family font/radius
+ rules (:99-141).
+- Base blocks, each declaring all 48 tokens: `:root, html[data-base="light"]`,
+ `html[data-base="sepia"]`, `html[data-base="dark"]`. Each also declares:
+ - `--swatch-<id>`: that ground's value for each of the 7 accents;
+ - `--swatch-custom: var(--accent-custom-<base>, var(--swatch-signal))`;
+ - `--brand: var(--swatch-signal)` as the default;
+ - `--brand-strong`: `color-mix` toward white on dark, toward black on light/sepia;
+ - `--brand-soft`: `color-mix` at 16% on dark, 12% on light/sepia;
+ - `--brand-ink`: `#0c0a08` on dark, `#fff` on light/sepia.
+- **After** the base blocks (same specificity, so later wins):
+ `html[data-accent="<id>"]{--brand:var(--swatch-<id>); --brand-mark:<onDark>}` for each of the
+ 7, and `html[data-accent="custom"]{--brand:var(--swatch-custom); --brand-mark:var(--accent-custom-dark)}`.
+- `--primary` stays **neutral**, not the accent: light `#202a31`, sepia `#33281a`, dark
+ `#efe7d8`, each with a ground-coloured foreground. `--ring: var(--brand)`.
+
+**`common/styles/fonts.ts`:** Archivo, Plex Sans and Plex Mono only — Source Serif, Source Sans,
+Source Code and JetBrains Mono go. Update the comments in both `globals.css` files.
+
+**`common/components/themeConfig.ts`:**
+- `BASE_KEY = "ytdlp-tb:base"`; `ACCENT_KEY` now stores an accent id; `LEGACY_THEME_KEY` /
+ `LEGACY_MODE_KEY` are the old theme/mode keys.
+- `ThemeBase = light | sepia | dark | system`.
+- `REQUIRED_TOKENS`, which the homepage spec imports.
+- A pure `migrateLegacy({theme, mode})`, after which both legacy keys are deleted:
+
+ | Stored mode | Result |
+ |---|---|
+ | light | `light`, or `sepia` when the old theme was `archive` |
+ | dark | `dark` |
+ | system | `system` |
+ | absent | nothing stored |
+
+- `buildThemeScript({defaultBase})`.
+
+**Pre-paint `ThemeScript.tsx`** (replacing :27-56), in this order:
+1. Migrate if needed.
+2. Validate the base, falling back to the default.
+3. Set `data-base` to the resolved light/sepia/dark and toggle `.dark`.
+4. Set `data-accent` only from a valid stored id; otherwise the server-rendered default stays.
+5. Update `meta[name=theme-color]`.
+6. Last: `data-theme-ready="1"`, the e2e no-flash marker.
+
+A unit test runs the script string in `node:vm` over the whole migration matrix.
+
+**`ThemeProvider.tsx`:**
+- Props `{defaultBase = "system", siteAccent = DEFAULT_ACCENT}`.
+- Context `{base, resolvedBase, isDark, accent, siteAccent, setBase, cycleBase, setAccent}`.
+- A `useLayoutEffect` re-asserts `data-base`, `.dark`, `data-accent` and theme-color. Hydration
+ can reset `<html>` attributes; without it the site default overwrites a reader's pick.
+- A live `systemDark` state from the `matchMedia` listener (today `isDark` goes stale after an
+ OS change).
+- `setAccent(<site default>)` **removes** the stored key, so the reader follows the site default.
+- `cycleBase`: system → light → sepia → dark.
+
+**Consumers:**
+- `ThemeMenu.tsx`: keeps `aria-label="Choose theme"`. A "Base" radio group of `menuitemradio`s
+ named exactly System, Light, Sepia and Dark. An "Accent" radio group: a swatch dot
+ (`background: var(--swatch-<id>)`), the name, and a visible "default" tag on the site's own
+ option. A custom-hex site adds a first "Site colour" option.
+- `ThemeToggle.tsx`: labels "Switch to …" (the `/switch to/i` selector keeps working); icons
+ Monitor / Sun / BookOpen / Moon.
+- `export/app/components/MobileMenu.tsx:~121-141`: native radio groups "Base" and "Accent".
+- `common/components/ui/sonner.tsx:17`: `theme={isDark ? "dark" : "light"}`.
+
+**Layout defaults:**
+
+| App | Base | Accent | Where |
+|---|---|---|---|
+| Export site | system | from site.json | `<html data-accent>` rendered server-side; a custom hex adds the inline `--accent-custom-*` style |
+| Hub | dark | signal | export layout |
+| Homepage | dark | signal | homepage layout |
+| Editor | system | signal | no layout change |
+
+**Browser chrome colour** (`generateViewport`): sites get
+`[{media: light, BASE_GROUNDS.light}, {media: dark, BASE_GROUNDS.dark}]`; the hub and homepage
+get `BASE_GROUNDS.dark`. `FALLBACK_THEME_COLOR`, `HUB_THEME_COLOR` and the homepage
+`THEME_COLOR` go.
+
+**Tests:**
+
+| Spec | Change |
+|---|---|
+| export `theme.spec.ts` | Rewrite: default `data-base`, live `emulateMedia`, cycling through 4 states, persistence across a `commit` reload plus `data-theme-ready`. |
+| export `theme-family.spec.ts` | Delete; new `theme-accent.spec.ts`. Pick Violet: it persists and the computed `--brand` is correct. Picking the default removes the key. Also pick Sepia. |
+| export `site-branding.spec.ts:75-86` | `data-accent="custom"`, and the computed `--brand` is `#cc3366` on light. |
+| export `responsive.spec.ts:~95` | "Archive" becomes "Sepia". |
+| homepage `theme.spec.ts` | Dark + signal defaults; `REQUIRED_TOKENS` complete across all 3 bases; the backgrounds differ. |
+| editor `theme.spec.ts` | Seed `BASE_KEY`; migration cases selenized+dark → dark (legacy keys removed) and archive+light → sepia. |
+| unit | Tokens completeness and parity: parse tokens.css; every base declares every token; `--swatch-*` / `--brand-mark` equal `ACCENTS`. |
+
+**Leftover copy:** `homepage/content/docs/operate.md:~84` and the comment at
+`export/playwright.config.ts:~29` still describe the old accent and the committed icons.
+
+### S3 — integrate (after S1 + S2 merge to `main`)
+
+- Full gates.
+- Verified facts appended to `plans/FACTS.md`; `plans/STATE.md` rewritten.
+- `export/CHANGELOG.md` and `homepage/CHANGELOG.md` `[Unreleased]` bullets.
+- The release recorded in the next `plans/release-N.md`.
+- The operator runbook as **generated HTML** (operator rule).
+
+## Verification
+
+**Gates, every slice:**
+- `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`.
+- `pnpm --filter yt-dlp-transcript-common test`.
+- Editor unit: `pnpm exec tsx --test "app/**/*.test.ts"` in `editor/`.
+- `pnpm test:scripts`; mcp tests.
+- `pnpm --filter export exec next build`, the homepage `next build` and the editor `next build`.
+- `ls out/icons` plus the PNG/ICO magic checks.
+
+**e2e** is queue-locked machine-wide; run it detached (`setsid nohup … &`). The parent session
+holds a Monitor; a subagent waits in foreground loops of ≤100 s.
+- `pnpm --filter export run e2e`, `e2e:hub`, `e2e:2origin`.
+- `pnpm --filter homepage run e2e`.
+- The root `pnpm e2e` (the editor suite).
+
+**Visual check:** build one site, serve `out/`, and screenshot the header, the picker open, and
+each base × two accents at 390 px and 1280 px (use `localhost`, not 127.0.0.1). Compare with the
+canvas's *In context* and *Themes* boards.
+
+**Caution:** `export/out` belongs to the checkout, and an e2e run can start a real deploy
+(release-7 lessons). Never deploy from a worktree without the operator.
+
+## As shipped
+
+(Each slice appends its "### Slice S<n>, as shipped" record here.)
+
+### Slice S0, as shipped — palette + data (2026-09-25)
+
+Branch `brand/found-line` off the plan commit `d8dd98b8`. S0 lays the data S1 (mark, icons, wordmark)
+and S2 (base × accent tokens, runtime, picker) build on: the palette and mark as pure data, the
+`site.json` keys, the one mapping that keeps every *published* accent a hex, and the editor form that
+writes both keys. No `common/styles/**`, `common/components/**`, layout, manifest, header, footer,
+icon, service-worker or theme-spec file was touched, and nothing a reader sees changes until an
+operator picks an accent or a lead.
+
+**`common/lib/brand.ts` (new, pure: zero imports).**
+- `ACCENT_IDS` (picker order) and `ACCENTS: Record<AccentId, {id, name, onDark, onLight, onSepia}>`,
+ the plan's table verbatim; `DEFAULT_ACCENT = "signal"`; `isAccentId`.
+- `BASE_GROUNDS` (+ `BASE_GROUND_IDS`), `ACCENT_INK` (`#ffffff` on light and sepia, the dark ground on
+ dark), `MIN_ACCENT_CONTRAST = 4.5`, and the WCAG `relativeLuminance` / `contrastRatio` the rule and
+ its tests use. **The rule as coded:** an accent's value on a base reaches 4.5:1 against that base's
+ ground AND its ink. On dark the ink is the ground, so it is one check there. Every value in the
+ plan's table passes: the tightest are Signal and Brass on light and on sepia (4.60–4.61) and Green on
+ sepia (4.61). `brand.test.ts` makes 42 checks (7 accents × 3 bases × 2).
+- `MARK`: six shapes back to front, the ground first (`part`, `kind`, geometry, `tone` =
+ ground | dim | lit), plus `MARK_VIEWBOX`, `MARK_GROUND_RX` and `MARK_MASKABLE_SCALE`.
+ `markSvg(palette, {variant})` returns a standalone SVG string:
+ - `any`: ground `rx=112`;
+ - `maskable`: full-bleed ground (no rx), the lines inside `<g transform="translate(51.2 51.2) scale(0.8)">`,
+ which is 0.8 about (256,256);
+ - `apple`: full-bleed ground at scale 1.
+
+ Every palette colour must be `#rrggbb` or it throws, so a stored value cannot inject markup into an
+ icon.
+- `ICON_PALETTES` has `child` (ground `#0c0a08`, dim `#3b3327`) and `archilyzer` (`#151b20` / `#3f4c56`
+ / `#e7edf1`). `childIconPalette(lit)` adds the lit value: **S1 passes
+ `resolveAccent(site.accent).dark`.** brand.ts cannot import accent.ts; accent.ts imports brand.ts.
+- `wordmarkLeadFor(title, lead)` returns the trimmed lead when it is a PROPER prefix of the title
+ (case-sensitive, non-empty, shorter than it), else undefined. `splitWordmark(title, lead?)` returns
+ `{lead, suffix}`, and without a usable lead that is `{lead: title, suffix: ""}`. There is no
+ heuristic: `splitWordmark("Rekietalyzer")` is the whole title, and the test pins that. The test
+ splits all seven family titles.
+- `common/lib/project.ts`: `PROJECT_WORDMARK_LEAD = "Archi"`.
+
+**`common/lib/accent.ts`.**
+- `parseAccent` is unchanged and stays hex-only.
+- `parseAccentSetting(v)` takes an id (trimmed, any case, stored lowercase) or `parseAccent`'s hex, else
+ undefined. A hex equal to a named accent's value stays a custom hex and is never mapped to an id.
+- `resolveAccent(v)` returns `{id | "custom", light, sepia, dark}`:
+ - a named id reads its row;
+ - absent or malformed reads Signal;
+ - a custom hex is fitted per base. It is kept if it already meets the rule; otherwise it is mixed in
+ sRGB toward black (light, sepia) or white (dark) in 1 % steps until it does. Full black or white
+ always passes, so the loop ends.
+
+ The fixture's `#cc3366` resolves to light `#cc3366` (kept, 4.57), sepia `#c43162` (4.50) and dark
+ `#d14574` (4.53). **So S2's `site-branding` expectation, `--brand` = `#cc3366` on light, holds.**
+- `accentHex(v)` is the published value: an id becomes its `onDark`, a custom hex goes out as stored
+ (normalized, not fitted), and absent or malformed gives undefined.
+- `customAccentVars(v)` returns `{--accent-custom-light|sepia|dark}` for a custom hex, and null for an
+ id or no accent. It takes the setting as its argument; the plan wrote `customAccentVars()`.
+- **`siteAccentVars` (legacy, which S2 deletes) now goes through `accentHex`.** A hex gives the same
+ output as before (the `site-branding` spec's `--brand: #cc3366` passes). An id paints its on-dark
+ hex in both modes, the old one-colour contract.
+
+**`site.json` (`common/lib/siteSchema.ts`).**
+- `accent` parses through `parseAccentSetting`; the doc text is new.
+- `wordmarkLead?` sits after `headerTitle` in the type, `SITE_FIELD_DOCS`, the schema (trim, else
+ undefined) and `siteToDisk`.
+- The object step resolves it with `wordmarkLeadFor(headerTitle, …)`, beside `defaultGroupId`, so a
+ non-prefix lead reads as undefined. `siteToDisk` re-checks it, so a caller's stale lead after a
+ retitle is not written.
+- `SITE.md` was regenerated and `--check` is clean.
+- A stored `"signal"` round-trips as `"signal"`. The schema keeps any valid setting; only the FORM
+ stores the default as absent.
+
+**Published accents stay hex.** `siteDescriptor.ts` (public `/site.json`), `compose-hub.ts`
+(`hub-sites.json`) and `homepageSummary.ts` (homepage summary, and through `toHubSummary` the hub's
+`hub-summary.json`) emit `accentHex(site.accent)`, omitting the key when there is no accent, as before.
+`wordmarkLead` is not added to the public descriptor, so the contract is unchanged. **Every reader of an
+`accent`, and what it now receives:**
+
+| Reader | Receives |
+|---|---|
+| `common/lib/siteDescriptor.ts:~89` | hex (`accentHex`) |
+| `common/bin/compose-hub.ts:~96` → `hub-sites.json` | hex (`accentHex`) |
+| `common/lib/homepageSummary.ts:~433` → `homepage-summary.json` | hex (`accentHex`) |
+| `common/lib/hubSummary.ts:67,122` | the homepage summary's value → hex |
+| `common/components/siteRegistry.ts:144,206,305` | `hub-sites.json` / a member's `/site.json` → hex |
+| `common/components/SearchDataContext.tsx:274,442`, `SearchSessionContext.tsx:899,926,960` | registry → hex |
+| `common/components/SearchResults.tsx:573`, `FiltersPanel.tsx:324` (C2's files, untouched) | `group.accent` from the registry → hex |
+| `export/app/components/hub/ArchiveShelf.tsx:69` (C2's, untouched), `HubHome.tsx:39`, `HubOfflineManager.tsx:97`, `export/app/ask/AskHub.tsx:33` | registry / hub summary → hex |
+| `export/app/layout.tsx:86` `siteAccentVars(currentSite().accent)` | id or hex → hex via `accentHex` (legacy, S2 deletes) |
+| **`export/app/layout.tsx:64`** `generateViewport` themeColor | `parseAccent(id)` = undefined → falls back to `#2563eb` (S2's file; S2 replaces it with `BASE_GROUNDS`) |
+| **`export/app/manifest.ts:19`** `theme_color` | `parseAccent(id)` = undefined → falls back to `#2563eb` (S1's file; S1 sets `BASE_GROUNDS.dark`) |
+| `editor/app/sites/actions.ts`, `SiteForm.tsx` | the setting (id or hex), by design |
+| `common/lib/channelGroups.ts:28` | a hub-mode group's provenance accent from the registry → hex; not a `site.json` read |
+| homepage `ArchiveCards.tsx` | does not read it (its comment says so) |
+| mcp, umtool | none (umtool's `pal.accent` is a video palette, unrelated) |
+
+**Editor form** (`editor/app/sites/components/SiteForm.tsx`, `actions.ts`):
+- **Brand accent** is a `<fieldset>`/`<legend>` group of 8 native radios named `accent`: the 7 named
+ (each with a dot of its on-light / on-dark value, and "Signal (default)") plus **Custom**. A
+ **Custom hex** text field (`accentCustom`) sits below; typing in it selects Custom. The radios are
+ controlled, so the choice survives the reset React does after the action.
+- The action stores the chosen id, but **Signal is stored as absent** (the two read the same). Custom
+ requires a valid hex. A bare hex posted in `accent` (an old tab from before the restart) is taken as
+ custom.
+- **Wordmark lead** is a `Field` after Header title. The action REFUSES a non-prefix lead with an error
+ rather than letting the schema drop it silently.
+- `writeSite` is still the one writer.
+- No new label or hint contains "header title", "site title", "site id" or "public url"; the lead's
+ hint says "header text".
+- Shots: `$T/s0-accent-{light,dark}.png`, `$T/s0-form-{light,dark}.png`.
+
+**Changed locators: none.** No editor spec referenced "Brand accent" or the accent field. New test
+`sites-crud.spec.ts` "brand accent radio group + wordmark lead round-trip to site.json" covers:
+- the default checked, and 8 radios in the "Brand accent" group;
+- Brass + lead `Brandy` → `"accent":"brass","wordmarkLead":"Brandy"`;
+- typing `#CC3366` selects Custom and stores `#cc3366`;
+- a lowercase lead is refused (alert) with the file unchanged;
+- Signal + blank lead → both keys absent.
+
+**`hubSite()`** (`export/app/lib/site.ts`) sets `wordmarkLead: PROJECT_WORDMARK_LEAD`. The live
+`homepage.json` header title is "Archilyzer", so it is kept.
+
+| sha | what |
+|---|---|
+| `e85c1fd2` | `common/lib/brand.ts` (+ test) and `accent.ts` (+ test): the palette, rule, mark, wordmark split; `parseAccentSetting` / `resolveAccent` / `accentHex` / `customAccentVars`; `siteAccentVars` through `accentHex`; `PROJECT_WORDMARK_LEAD` |
+| `3b634248` | `siteSchema.ts`: `accent` id-or-hex, `wordmarkLead` (proper prefix only) + tests; `SITE.md` regenerated |
+| `31ff873a` | `siteDescriptor` / `compose-hub` / `homepageSummary` publish `accentHex` + tests (new `siteDescriptor.test.ts`) |
+| `17c874ca` | `export/app/lib/site.ts` `hubSite()` carries `wordmarkLead: "Archi"` |
+| `4fce889d` | editor site form: the accent radio group + Custom hex, the Wordmark lead field, the action; the sites-crud test |
+| `9775bd22` | sites-crud's new test filters its alert from Next's route announcer |
+
+**Gates.**
+- tsc clean before each code commit (four runs), plus a fifth on the final tree, which covers `9775bd22`, a spec-only change.
+- Unit and script tests:
+ - common **1,874/1,874**: C1b's 1,845 + 29 new — brand 12, accent 9, siteSchema 3, siteDescriptor 3,
+ homepageSummary 1, compose-hub 1;
+ - editor unit **78/78**;
+ - `test:scripts` **162 + 1 skip**;
+ - mcp **219/219**.
+- `file-schemas-docs.ts --check` clean.
+- Builds:
+ - `pnpm --filter editor exec next build` ok (50 s);
+ - `pnpm --filter export exec next build` ok (31 s, the known Turbopack warning), with the
+ `export/public` links seeded, `sw.js` a plain copy, and no dangling links;
+ - `pnpm --filter homepage exec next build` ok (16 s), with `homepage/public`'s four data entries
+ COPIED from the primary.
+- e2e, no queue wait:
+ - editor `sites-crud.spec.ts branding.spec.ts`: run 1 **15 passed, 1 failed** (1.2 min). The failure
+ was the new test's own `getByRole("alert")`: Next's route announcer is a second, empty
+ `role=alert`. Run 2 on `9775bd22`: **16 passed, 0 failed**, 58.7 s.
+ - export `site-branding.spec.ts`: **7 passed**, 16.1 s.
+ - `e2e:hub`: **12 passed**, 28.4 s.
+- The primary's `export/public/sw.js` was untouched: 10,027 B, mtime 20:47:37, before and after.
+- Numbers tools: none.
+
+**Found and left.**
+- **The browser-chrome colour falls back for a named accent until S1/S2.**
+ - `export/app/layout.tsx:64` and `export/app/manifest.ts:19` call the hex-only `parseAccent`, so a
+ site set to an id gets `#2563eb` there instead of its colour.
+ - Both files belong to S1/S2, which replace those lines with `BASE_GROUNDS`. They were left alone.
+ - No live site sets an accent (all six read `null`), so nothing live changes.
+- **The legacy `siteAccentVars` paints an id's on-dark value in both modes.** On the light base that is
+ below 4.5:1 (Brass `#e3b15c` on `#f3f6f7` ≈ 1.9). This is transitional: S2 deletes it, and S0 is not
+ rolled out alone.
+- **"Signal (default)" is only literally true once S2 lands.** At S0's tip an absent accent is still
+ the base family's blue.
+- The worktree is port block **#1** (editor 3101, test 3111, export 3110), not #2:
+ `pnpm wt list` sorts, and `diet-series` is #2.
+
+## Operator rollout (after merge)
+
+1. Restart the live :3001 editor on the new `main`; it needs S0's form.
+2. In the editor's site form (the one writer; never hand-edit `transcripts/`), set accent and
+ wordmark lead:
+
+ | Site | Accent | Wordmark lead |
+ |---|---|---|
+ | jeralyzer | brass | Jer |
+ | rekietalyzer | vermilion | Rekieta |
+ | hasanalyzer | violet | Hasan |
+ | anilyzer | sakura | Ani |
+ | bonnellyzer | blue | Bonnell |
+ | jasolyzer | green | Jaso |
+
+3. `archilyzer deploy site anilyzer --preview brand` (or
+ `pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"brand"}' --wait`). On a phone,
+ check the favicon and installed-PWA icon update (SW `shell-v2`), the picker, and migration
+ from a stored old theme.
+4. Build and deploy every site: `pnpm ops build-deploy --json '{"siteIds":[…]}' --wait`; then the
+ hub (`pnpm ops build-hub --json '{"deploy":true}' --wait`) and the homepage
+ (`archilyzer deploy homepage`).
+
+## Proposed — Archilyzer Media, the YouTube channel (awaiting the operator's picks)
+
+Asked 2026-09-25: branding for **Archilyzer Media**, the operator's YouTube channel for the
+videos. It is a new row on the canvas, "Archilyzer Media · the YouTube channel" (canvas
+version 10), with three boards: *Media · mark + lockup*, *Media · the channel* and *Media · in
+the video*. The generators are `gen.py` and `gen_media.py` beside the staging dir
+(`node_modules/.brand-canvas/`); `gen_media.py` takes the live `canvas.json` it read as its
+argument.
+
+**Open choices** (recommendation first):
+- **Mark.**
+ - **M2 · Signal:** the parent mark, graphite and dim, with the found line and play head lit in
+ Signal `#5fa8a0`.
+ - **M1 · Bone:** the parent mark unchanged.
+
+ At 40 px in a subscription list, bone on graphite reads as a blank grey avatar. Signal is
+ Archilyzer's own accent, so the channel speaks as the project rather than as any one site,
+ and it does not fight YouTube's red.
+- **Lockup.**
+ - **Imprint:** the `Archi|lyzer` wordmark over a `MEDIA` tag (Plex Mono 500, tracked
+ 0.24em, in the accent).
+ - **Inline:** `Archi|lyzer Media`, with "Media" in the suffix weight.
+
+**The channel assets.** YouTube's specs were checked 2026-09-25.
+
+| Asset | Size | Design |
+|---|---|---|
+| Profile picture | 800×800 PNG, rendered as a 98 px circle | `markSvg` maskable (scale 0.8 keeps the farthest corner at 69% of the circle's radius) |
+| Banner | 2560×1440 PNG ≤ 6 MB. Every device shows only the centre 1546×423; desktop shows 2560×423, tablet 1855×423 | A field of dim transcript lines wrapping around a clear safe area, the lockup centred in it, and one lit found line in the desktop strip |
+| Watermark (Studio) | 150×150 PNG | `markSvg` any |
+| Thumbnail template | 1280×720 | The still on the left; the headline (Archivo 118%, 800) and a found line on the right; the mark top-left; the bottom-right kept clear for YouTube's duration badge |
+
+**In the video** (`umtool/report-to-video`).
+- **Today:**
+ - The cards are drawn with ImageMagick + Pango (`render-cards.mjs`), and the clip header
+ with ffmpeg `drawtext` (`build-video.mjs`).
+ - Colours come from the manifest's `render.palette`; fonts come from `render.fontRegular` /
+ `render.fontBold`.
+ - "Fira Sans" is the hardcoded default in `span()`.
+ - Nothing is burned in.
+- **Proposed:**
+ - A `brand` preset: ground `#151b20`, fg `#e7edf1`, muted `#8496a2`, accent Signal.
+ - The mark leads the existing `Channel · title · date @ time` header. It is burned in, so
+ reposts keep it.
+ - A title card and an end card that carry the lockup, the end card with slots for the
+ end-screen elements.
+- **Fonts:** this needs Archivo and IBM Plex installed as system fonts; neither is on this
+ machine. `ttf-ibm-plex` is in Arch's extra repo. Archivo's variable TTF comes from Google
+ Fonts, into `~/.local/share/fonts`. Pango takes `@wdth=118` variations.
+
+**Slice S4, after S1** (it reuses `markSvg` and S1's PNG renderer):
+- `common/bin/brand-icons.ts --youtube <dir>` writes the profile picture, banner and watermark.
+ The banner's text needs Archivo at exactly 118%: a static instance (fontTools `instancer`)
+ for satori, or installed fonts for `rsvg-convert`.
+- The umtool preset.
+- The operator claims the handle and uploads the assets.