commit 70a4e9131fbdca8d1134be6f956395caeb17ec75
parent f5e8cc2fa223184ac96d355b206e45a6ab3e5718
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:52:50 -0400
common: the brand as data — accents, grounds, the Found-line mark, the wordmark split (brand S0)
New pure lib/brand.ts: ACCENT_IDS / ACCENTS (the plan's contrast-corrected
on-dark / on-light / on-sepia table), DEFAULT_ACCENT = signal, BASE_GROUNDS,
ACCENT_INK and the 4.5:1 rule, ICON_PALETTES (child + archilyzer), the MARK
shape list and markSvg(palette, {variant: any | maskable | apple}), and
splitWordmark(title, lead?) — a lead that is not a proper prefix makes the
whole title the lead; nothing is guessed. project.ts gains
PROJECT_WORDMARK_LEAD = "Archi".
lib/accent.ts: parseAccentSetting (an id or a #rrggbb), resolveAccent (a
custom hex fitted per ground to 4.5:1 against the ground and its ink),
accentHex (the published value: an id becomes its on-dark hex) and
customAccentVars. parseAccent stays hex-only; the legacy siteAccentVars now
routes an id through accentHex so an id-accent site still renders until S2
deletes it. brand.test.ts enforces the rule for 7 accents x 3 grounds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 678 insertions(+), 9 deletions(-)
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/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