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:
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);
+}