Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Acommon/lib/accent.test.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/accent.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acommon/lib/brand.test.ts | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/brand.ts | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/project.ts | 6++++++
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