Archilyzer · Source

archilyzer

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

commit 5c1e38ab0ebbf8986154c398162ec3c6643cafca
parent c0d301e4d4d8a2ce3693f2ad342ce1cf63a3b2c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 27 Sep 2026 00:19:11 -0400

common: siteColor — a site's colour per base; the summaries carry a named accent's id

- accentIdOf(setting): the named accent a stored setting picks; never a hex's.
- homepage-summary.json: each site carries `accentId` beside the published
  hex when its accent is named. Additive and optional, so still v5; a summary
  without it paints the hex as before. hub-summary.json passes it through
  (still v1; parseHubSummary keeps only a known id).
- siteColor(site, i): a named accent → `var(--swatch-<id>)`, its value on the
  base in force; a custom hex → that hex; neither → seriesColor(i).
- officialInstances colours through siteColor. hub-sites.json carries only the
  on-dark hex, so the summary's id is used when the two name the same colour;
  a site's own different hex still wins.

Not for charts: validated as a chart palette (dataviz validator, all pairs,
every base), no five accents that include Brass and Blue pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/accent.test.ts | 11+++++++++++
Mcommon/lib/accent.ts | 13++++++++++++-
Mcommon/lib/homepageSummary.test.ts | 17+++++++++++++++++
Mcommon/lib/homepageSummary.ts | 16++++++++++++++--
Mcommon/lib/hubSummary.test.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/hubSummary.ts | 37+++++++++++++++++++++++++++++--------
Acommon/lib/siteColor.test.ts | 36++++++++++++++++++++++++++++++++++++
Acommon/lib/siteColor.ts | 32++++++++++++++++++++++++++++++++
8 files changed, 202 insertions(+), 11 deletions(-)

diff --git a/common/lib/accent.test.ts b/common/lib/accent.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { accentHex, + accentIdOf, customAccentVars, parseAccent, parseAccentSetting, @@ -106,6 +107,16 @@ test("accentHex: the published value is always a hex", () => { assert.equal(accentHex("gold"), undefined); }); +test("accentIdOf: the named accent a setting picks, never a hex's", () => { + for (const id of ACCENT_IDS) assert.equal(accentIdOf(id), id); + assert.equal(accentIdOf(" Violet "), "violet"); + // A custom hex has no id, even one equal to a named accent's value. + assert.equal(accentIdOf(ACCENTS.brass.onDark), undefined); + assert.equal(accentIdOf("#cc3366"), undefined); + assert.equal(accentIdOf(undefined), undefined); + assert.equal(accentIdOf("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); diff --git a/common/lib/accent.ts b/common/lib/accent.ts @@ -8,7 +8,10 @@ // `/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. +// it. The family's OWN summaries (homepage-summary.json, hub-summary.json) also +// carry a named accent's id beside the hex (accentIdOf), so the homepage and +// the hub can paint it per base (lib/siteColor.ts); the hex stays for every +// other reader. import { ACCENTS, @@ -110,6 +113,14 @@ export function accentHex(input: unknown): string | undefined { return isAccentId(setting) ? ACCENTS[setting].onDark : setting; } +// The named accent a stored setting picks, or undefined for a custom hex and +// for no (or a malformed) accent. The id the family's own summaries publish +// beside the hex. +export function accentIdOf(input: unknown): AccentId | undefined { + const setting = parseAccentSetting(input); + return setting && isAccentId(setting) ? setting : undefined; +} + // The inline `<html style>` a CUSTOM-hex site needs: its fitted value per base, // which the token sheet's `--swatch-custom` reads (export/app/layout.tsx puts it // on <html> beside `data-accent="custom"`). Null for a named accent or no diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts @@ -197,3 +197,20 @@ test("a site's accent is published as a hex: an id becomes its on-dark value", ( const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); assert.ok(plain.sites.every((x) => !("accent" in x))); }); + +test("a named accent also travels as its id; a custom hex and no accent carry none", () => { + const sites = [ + { ...site("alpha", ["a1", "a2"], "https://alpha.example"), accent: " Brass " }, + { ...site("beta", ["b1"], "https://beta.example"), accent: "#e3b15c" }, + ] as Site[]; + const s = buildHomepageSummary(STATS, CHANNEL_SITES, sites, NOW); + const alpha = s.sites.find((x) => x.siteId === "alpha")!; + assert.equal(alpha.accentId, "brass"); + assert.equal(alpha.accent, "#e3b15c"); // the hex is unchanged + // A custom hex is never mapped back to an id, even Brass's own on-dark value. + const beta = s.sites.find((x) => x.siteId === "beta")!; + assert.equal(beta.accent, "#e3b15c"); + assert.ok(!("accentId" in beta)); + const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); + assert.ok(plain.sites.every((x) => !("accentId" in x))); +}); diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts @@ -1,7 +1,8 @@ import type { Platform } from "./platform"; import type { VideoStat } from "./stats"; import type { Site } from "./site"; -import { accentHex } from "./accent"; +import { accentHex, accentIdOf } from "./accent"; +import type { AccentId } from "./brand"; import { VIDEO_STATES, type VideoState } from "./availability"; // Pre-computed, lightweight cross-site summary for the hub (homepage) landing. @@ -27,6 +28,9 @@ import { VIDEO_STATES, type VideoState } from "./availability"; // v5 adds `monthly`, `official` and the per-site `channels` / `recordings` / // `hoursArchived` / `gone` numbers — all additive, all optional on the type for // the same reason: a v4 summary on disk must still render (the numbers hide). +// +// Still v5: the per-site `accentId` (release 10) is additive and optional in +// the same way — a summary without it paints its sites' hex, as before. export const HOMEPAGE_SUMMARY_VERSION = 5; // Day buckets are capped to this many trailing days so the embedded summary stays @@ -80,6 +84,12 @@ export type HomepageSummarySite = { // site that hasn't set one — callers fall back to the chart palette rather // than inventing a brand colour. Added in v4. accent?: string; + // The named accent behind `accent`, when the site picked one rather than a + // custom hex (lib/accent.ts accentIdOf). `accent` is then that accent's + // ON-DARK value only; the id lets the family's own pages paint it on every + // base (`var(--swatch-<id>)`, lib/siteColor.ts). Optional, additive + // (release 10): absent for a custom hex, no accent, or an older summary. + accentId?: AccentId; }; export type HomepageChannelMeta = { slug: string; name: string }; @@ -429,8 +439,10 @@ 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, - // A published colour: an accent id becomes its hex (lib/accent.ts). + // A published colour: an accent id becomes its hex (lib/accent.ts), and + // travels as its id too. ...(accentHex(s.accent) ? { accent: accentHex(s.accent) } : {}), + ...(accentIdOf(s.accent) ? { accentId: accentIdOf(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/hubSummary.test.ts b/common/lib/hubSummary.test.ts @@ -243,3 +243,54 @@ test("officialInstances: a member the summary does not name follows, in colours assert.equal(renamed[0].figures?.siteId, "jeralyzer"); assert.equal(renamed[0].accent, seriesColor(0)); }); + +test("a named accent's id rides through toHubSummary and parseHubSummary", () => { + const s = homepageSummary(); + s.sites[1] = { ...s.sites[1], accent: "#e3b15c", accentId: "brass" }; + const hub = toHubSummary(s); + assert.equal(hub.sites[1].accent, "#e3b15c"); + assert.equal(hub.sites[1].accentId, "brass"); + assert.ok(!("accentId" in hub.sites[0])); // alpha's is a custom hex + assert.deepEqual(parseHubSummary(JSON.parse(JSON.stringify(hub))), hub); + // An id the palette does not know is dropped; the hex stays. + const odd = parseHubSummary({ + version: HUB_SUMMARY_VERSION, + sites: [ + { siteId: "x", siteTitle: "X", siteUrl: "https://x.example", accent: "#112233", accentId: "gold" }, + ], + }); + assert.deepEqual(odd?.sites[0], { + siteId: "x", + siteTitle: "X", + siteUrl: "https://x.example", + accent: "#112233", + }); +}); + +test("officialInstances: a named accent is painted per base; a site's own different hex still wins", () => { + const summary = homepageOrder(); + // jeralyzer: Brass, published as its on-dark hex plus the id. + summary.sites[0] = { ...summary.sites[0], accent: "#e3b15c", accentId: "brass" }; + // anilyzer: Sakura in the summary, but hub-sites.json says something else. + summary.sites[1] = { ...summary.sites[1], accent: "#ee8fb5", accentId: "sakura" }; + const members = pool().map((m) => + m.siteId === "jeralyzer" + ? { ...m, accent: "#E3B15C" } // the same colour, as compose-hub writes it + : m.siteId === "anilyzer" + ? { ...m, accent: "#cc3366" } + : m, + ); + const out = officialInstances(members, summary); + assert.deepEqual( + out.map((o) => [o.site.siteId, o.accent]), + [ + ["jeralyzer", "var(--swatch-brass)"], + ["anilyzer", "#cc3366"], // the site's own, which disagrees: no id + ["hasanalyzer", seriesColor(2)], + ], + ); + // With no accent in hub-sites.json, the summary's id is used. + const bare = officialInstances(pool(), summary); + assert.equal(bare[0].accent, "var(--swatch-brass)"); + assert.equal(bare[1].accent, "var(--swatch-sakura)"); +}); diff --git a/common/lib/hubSummary.ts b/common/lib/hubSummary.ts @@ -20,7 +20,9 @@ import type { HomepageOfficialTotals, HomepageSummary, } from "./homepageSummary"; -import { seriesColor } from "./homepageChart"; +import { parseAccent } from "./accent"; +import { isAccentId, type AccentId } from "./brand"; +import { siteColor } from "./siteColor"; export const HUB_SUMMARY_FILE = "hub-summary.json"; export const HUB_SUMMARY_VERSION = 1; @@ -38,6 +40,10 @@ export type HubSummarySite = { hoursArchived?: number; gone?: number; accent?: string; + // The named accent behind `accent` (homepageSummary.ts `accentId`): the hub + // paints it per base. Additive and optional, so still version 1 — a reader + // that predates it ignores it, and a file without it paints the hex. + accentId?: AccentId; }; export type HubSummary = { @@ -67,6 +73,7 @@ export function toHubSummary(summary: HomepageSummary): HubSummary { : {}), ...(s.gone !== undefined ? { gone: s.gone } : {}), ...(s.accent ? { accent: s.accent } : {}), + ...(s.accentId ? { accentId: s.accentId } : {}), })), }; } @@ -123,6 +130,7 @@ function parseSite(v: unknown): HubSummarySite | null { } const accent = text(v.accent); if (accent) out.accent = accent; + if (isAccentId(v.accentId)) out.accentId = v.accentId; return out; } @@ -182,11 +190,24 @@ export type OfficialInstance<T> = { // summary does not name follows, in the order given. With no summary the order // given (hub-sites.json's) stands. // -// COLOUR: the site's own accent, else the summary's, else seriesColor() at its -// index in the summary — the colour its homepage card and chart layer wear. A -// member the summary does not name takes the colours after the summary's, so it -// never wears one a named instance wears. With no summary that is its place in -// the list. +// COLOUR (lib/siteColor.ts): the site's own accent, else the summary's, else +// seriesColor() at its index in the summary — the colour its homepage card +// wears. A NAMED accent is painted per base (`var(--swatch-<id>)`) from the +// summary's `accentId`; hub-sites.json carries only the on-dark hex, so the id +// is used only when the two files name the same colour (one compose writes +// both, so they agree unless a site's own is newer). A member the summary does +// not name takes the colours after the summary's, so it never wears one a named +// instance wears. With no summary that is its place in the list. +function colourOf( + site: { accent?: string }, + figures: HubSummarySite | null, +): { accent?: string; accentId?: AccentId } { + const own = parseAccent(site.accent); + const summary = parseAccent(figures?.accent); + if (own && own !== summary) return { accent: own }; + return { accent: own ?? summary, accentId: figures?.accentId }; +} + export function officialInstances< T extends { siteId: string; origin?: string; accent?: string }, >(members: readonly T[], summary: HubSummary | null): OfficialInstance<T>[] { @@ -204,12 +225,12 @@ export function officialInstances< ...named.map((p) => ({ site: p.site, figures: p.figures, - accent: p.site.accent || p.figures?.accent || seriesColor(p.at), + accent: siteColor(colourOf(p.site, p.figures), p.at), })), ...unnamed.map((p, k) => ({ site: p.site, figures: null, - accent: p.site.accent || seriesColor(listed + k), + accent: siteColor(colourOf(p.site, null), listed + k), })), ]; } diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { siteColor } from "./siteColor"; +import { seriesColor } from "./homepageChart"; +import { ACCENTS, ACCENT_IDS } from "./brand"; + +test("siteColor: a named accent is its per-base swatch, whatever hex rides along", () => { + for (const id of ACCENT_IDS) { + assert.equal( + siteColor({ accentId: id, accent: ACCENTS[id].onDark }, 3), + `var(--swatch-${id})`, + ); + } + // The id wins even without the hex (a hand-built summary). + assert.equal(siteColor({ accentId: "brass" }, 0), "var(--swatch-brass)"); +}); + +test("siteColor: a custom hex is that hex, normalized", () => { + assert.equal(siteColor({ accent: "#CC3366" }, 0), "#cc3366"); + assert.equal(siteColor({ accent: "#cc3366", accentId: undefined }, 4), "#cc3366"); +}); + +test("siteColor: no accent is the chart palette at the site's index", () => { + assert.equal(siteColor({}, 0), seriesColor(0)); + assert.equal(siteColor({}, 2), "var(--chart-3)"); + assert.equal(siteColor({}, 7), seriesColor(7)); +}); + +test("siteColor: anything unrecognised falls through, never into the style", () => { + // Not an accent id: the hex (or the palette) stands. + assert.equal(siteColor({ accentId: "gold", accent: "#112233" }, 0), "#112233"); + assert.equal(siteColor({ accentId: "red);x:y" }, 1), seriesColor(1)); + // Not a #rrggbb. + assert.equal(siteColor({ accent: "red" }, 1), seriesColor(1)); + assert.equal(siteColor({ accent: "var(--brand)" }, 0), seriesColor(0)); +}); diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts @@ -0,0 +1,32 @@ +// A site's colour as a CSS value, for the family's own surfaces that mark a +// site by colour beside its name: the homepage's instance cards and the hub's +// official instances (card stripe, scope chip, result edge, channel group). +// +// • a NAMED accent (`accentId`) → `var(--swatch-<id>)`: that accent's value +// on the base in force (common/styles/tokens.css), so it is right on light, +// sepia and dark alike. The published hex is its on-dark value only. +// • a custom hex (`accent`) → that hex, as published. +// • neither → seriesColor(index), the chart palette. +// +// NOT FOR CHARTS. 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 of them with Brass and Blue pass — +// Brass↔Vermilion ΔE 1.0 deutan, Blue↔Violet ΔE 8.5 normal / 1.3 protan on +// dark. The growth chart and the /stats chart stay on seriesColor, and so do +// the cards that are their legends (homepage SiteGrid). +// +// Pure and client-safe: the hub resolves it in the browser. + +import { parseAccent } from "./accent"; +import { isAccentId } from "./brand"; +import { seriesColor } from "./homepageChart"; + +export type SiteColorSource = { + accent?: string; + accentId?: string; +}; + +export function siteColor(site: SiteColorSource, index: number): string { + if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`; + return parseAccent(site.accent) ?? seriesColor(index); +}