// A site's colours, as CSS values, for the family's own surfaces: the homepage // (its instance cards, its growth chart, /stats) and the hub. // // TWO COLOURS PER SITE, ONE HUE. // // siteChartColors(sites) — the CHART colour: a slot of the validated chart // palette (`--chart-1..6`, tokens.css: blue, green, violet, amber, // magenta, rust), picked by the hue family of the site's named accent: // blue → blue (1), green → green (2), violet → violet (3), // brass → amber (4), sakura → magenta (5), vermilion → rust (6). // Signal, a custom hex and no accent have no family and take // seriesColor(i). No two sites share a colour (below), and any six sites // wear the six validated slots. // // siteColor(site, chart) — the MARK colour (a card's stripe, the hub's chip // and result edge): a named accent is `var(--swatch-)`, its value on // the base in force; a custom hex is FITTED to each base the way the site's // own pages fit it (lib/accent.ts resolveAccent: 4.5:1 on the ground and // with its ink), as one CSS value (perBaseColor); no accent is its chart // colour. So a card and its chart layer share a hue family, and a site with // no accent matches its layer exactly. // // WHY NOT THE ACCENTS THEMSELVES ON THE CHART. The accents are text colours // (≥ 4.5:1 on each ground), not a categorical palette. Validated as one // (release 10, the dataviz skill's validator, all pairs, every base): no five // that include Brass and Blue pass — Brass↔Vermilion ΔE 1.0 deutan, // Blue↔Violet ΔE 8.5 normal / 1.3 protan on dark — and on dark every accent is // above the band. The chart slots of the same hues pass every ADJACENT pair on // every base in the homepage's order; over ALL pairs they are the palette's own // borderline (CVD in the 6–8 floor band, normal ≥ 15.3), legal with the // chart's legend, its surface-gap edges and its table. The sixth slot (release // 11) adds no pair below the target: its worst is CVD 9.1 / normal 16.3. The // numbers are in ArchiveGrowthChart.tsx and tokens.css. // // Pure and client-safe: the hub resolves it in the browser. import { parseAccent, resolveAccent } from "./accent"; import { BASE_GROUND_IDS, isAccentId, type AccentId, type BaseGround } from "./brand"; import { seriesColor } from "./homepageChart"; export type SiteColorSource = { accent?: string; accentId?: string; }; // The chart slot (seriesColor index, 0-based) of each accent's hue family. // Signal has none: a teal slot would sit on the family's own UI colour. export const ACCENT_CHART_SLOT: Readonly>> = { blue: 0, green: 1, violet: 2, brass: 3, sakura: 4, vermilion: 5, }; // One chart colour per site, in `sites` order, all different. // 1. A named accent with a family claims its family's slot; when two sites // want the same one, the earlier keeps it. // 2. Every other site (no family, or the later of two) takes its own index's // colour, seriesColor(i), when no one holds it — so a list with no accents // is exactly seriesColor(0..n-1), as before — and otherwise the lowest // free slot (past the sixth, seriesColor's golden-angle hues). So up to // six sites only ever wear the validated --chart-1..6. export function siteChartColors(sites: readonly { accentId?: string }[]): string[] { const slot: number[] = new Array(sites.length).fill(-1); const taken = new Set(); sites.forEach((s, i) => { const want = isAccentId(s.accentId) ? ACCENT_CHART_SLOT[s.accentId] : undefined; if (want !== undefined && !taken.has(want)) { slot[i] = want; taken.add(want); } }); let free = 0; sites.forEach((_, i) => { if (slot[i] >= 0) return; let k = i; if (taken.has(k)) { while (taken.has(free)) free++; k = free; } slot[i] = k; taken.add(k); }); return slot.map((k) => seriesColor(k)); } // ONE CSS colour that is `values.light` on the light base and `values.dark` on // dark — for a colour a component knows and the token sheet cannot (a custom // hex is per site). tokens.css sets `--base-light|dark` to 1 on their own base // and 0 on the other, so each channel is a calc() over the two and the browser // resolves it to a plain rgb() for whichever base is in force, switching with // it. It goes anywhere a colour goes (a background, a border, color-mix()); // the fallbacks paint the light value on a page with no token sheet, as // `:root` does. // // The value is CSS only: never parse it or compare it as a hex. Each value // must be a `#rrggbb` (resolveAccent's output); anything else THROWS rather // than paint `rgb(NaN …)`, which a browser drops without a word. A third // base needs its own flag in tokens.css (themeTokens.test.ts holds exactly one // 1 per base, over the same BASE_GROUND_IDS this walks). const RRGGBB = /^#[0-9a-f]{6}$/i; export function perBaseColor(values: Readonly>): string { const rgb = BASE_GROUND_IDS.map((b) => { if (!RRGGBB.test(values[b] ?? "")) { throw new RangeError(`perBaseColor: ${b} is ${JSON.stringify(values[b])}, not a #rrggbb`); } const n = parseInt(values[b].slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }); const channel = (c: number) => `calc(${BASE_GROUND_IDS.map( (b, i) => `${rgb[i][c]} * var(--base-${b}, ${b === "light" ? 1 : 0})`, ).join(" + ")})`; return `rgb(${channel(0)} ${channel(1)} ${channel(2)})`; } // A published hex (a site's own, or a named accent's on-dark value when its // id is not known) fitted to each base, as one CSS value; undefined for // anything that is not a `#rrggbb`, which never reaches a style. export function fittedHex(accent: unknown): string | undefined { const hex = parseAccent(accent); return hex ? perBaseColor(resolveAccent(hex)) : undefined; } // A site's OWN accent as a CSS colour on the base in force — a named accent's // swatch, or a custom hex fitted to each base — or undefined for a site with // none. Both are text colours: ≥ 4.5:1 on each ground, and above 4:1 on the // homepage's card surface, whose instance cards tint the wordmark's lead with // it. export function siteAccentColor(site: SiteColorSource): string | undefined { if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`; return fittedHex(site.accent); } // A site's mark colour; `chart` is its siteChartColors entry. export function siteColor(site: SiteColorSource, chart: string): string { return siteAccentColor(site) ?? chart; }