// THE BRAND, AS DATA — the Found-line mark, the accent palette, the two base // grounds and the wordmark split. plans/brand-and-themes.md "The design" is the // source of every value here; where the design canvas and the plan differ, the // plan wins (notably the contrast-corrected on-light 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; }; export const ACCENTS: Readonly> = { signal: { id: "signal", name: "Signal", onDark: "#5fa8a0", onLight: "#2e7b73" }, brass: { id: "brass", name: "Brass", onDark: "#e3b15c", onLight: "#95661a" }, vermilion: { id: "vermilion", name: "Vermilion", onDark: "#ec7a52", onLight: "#b3431f" }, violet: { id: "violet", name: "Violet", onDark: "#b49cf2", onLight: "#6a4bc4" }, sakura: { id: "sakura", name: "Sakura", onDark: "#ee8fb5", onLight: "#a83a6a" }, blue: { id: "blue", name: "Blue", onDark: "#74a9f2", onLight: "#2d5fb8" }, green: { id: "green", name: "Green", onDark: "#7cc46a", onLight: "#3f7a2c" }, }; // An absent accent reads as this one — on a child site AND on the family's own // 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", dark: "#0c0a08", } as const; export type BaseGround = keyof typeof BASE_GROUNDS; export const BASE_GROUND_IDS = ["light", "dark"] as const satisfies ReadonlyArray; // The text set ON an accent fill (a filled button, a badge): white on the light // base, the dark ground on the dark base. export const ACCENT_INK: Readonly> = { light: "#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; tone: MarkTone; }; export const MARK: ReadonlyArray = [ { 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 MARK'S TWO RULES, which brand.test.ts enforces on every icon palette: // // - a dim line reaches MIN_MARK_DIM_CONTRAST against its ground, the WCAG // 1.4.11 floor for a graphic that has to be seen at all (at 1.6:1 the three // dim lines vanished and the mark read as one lit line); // - every named lit value stands MIN_MARK_LIT_OVER_DIM above the dim, so the // found line still reads as THE found line. // // The two multiply to lit-on-ground, which fixes the second: with the dim at // 3:1, the darkest lit values (Vermilion, 7.0:1 on ink; Signal, 6.3:1 on the // Media slate) cannot sit more than ~2.3 and ~2.1 above any dim. 2:1 — the lit // line at least double the dim's luminance — is the most both palettes can // hold. A custom accent is fitted only to the 4.5:1 ground rule, so one at // that floor sits nearer its dim (~1.5:1): its hue carries the difference. export const MIN_MARK_DIM_CONTRAST = 3; export const MIN_MARK_LIT_OVER_DIM = 2; // 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. // Each dim keeps its ground's hue (warm ink, slate) and sits just over 3:1. export const ICON_PALETTES = { child: { ground: "#0c0a08", dim: "#6b5d47" }, archilyzer: { ground: "#151b20", dim: "#586977", lit: "#e7edf1" }, } as const satisfies { child: Omit; 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 ``; } const r = rx ?? s.rx; const pos = s.x || s.y ? `x="${s.x}" y="${s.y}" ` : ""; return ``; } // 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 = `${body}`; } return ( `` + groundSvg + body + `` ); } // ── 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: "" }; }