commit 5770fd6a060a692ffcdb5d5ba6aee8a2e9daafe8
parent 5446f83b25e10ab61faffa0d857f7ca5a7a4d1bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 27 Sep 2026 00:45:04 -0400
common: siteChartColors — a site's chart colour is its accent's hue family in the validated palette
The coordinator took the proposed accents and asked for the hue-matched chart.
siteChartColors(sites) gives each site a slot of --chart-1..5 (blue, green,
violet, amber, magenta on every base) by its named accent's hue family:
blue -> 1, green -> 2, violet -> 3, brass -> 4, sakura -> 5. Vermilion, Signal,
a custom hex and no accent have none and take seriesColor(i). No two sites
share a colour: the first of a family keeps its slot; the rest take their own
index when free, else the lowest free slot. With no accents it is exactly
seriesColor(0..n-1), as before.
siteColor(site, chart) now falls back to that chart colour, so a card with no
accent equals its layer and a family accent's card shares its layer's hue
(1-21 degrees apart; the nearest other slot is >= 31). ArchiveCards and the
hub's officialInstances pass it (the hub over the summary's sites, unnamed
members after them). 8 more unit tests, including 500 random mixes: never two
sites in one colour, and the first of each family always on its slot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 231 insertions(+), 57 deletions(-)
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -1,7 +1,9 @@
// Pure (fs-free, React-free) presentation helpers + control types shared by the
// hub landing's cross-site chart (common/components/charts/CrossSiteChart) and
// its site cards (homepage SiteGrid). Color is keyed on a series' index so the
-// chart and a card's legend swatch always agree.
+// chart and a card's legend swatch always agree; a SITE's index into this
+// palette is chosen by lib/siteColor.ts siteChartColors (its accent's hue
+// family).
import type { MetricKey } from "./homepageSummary";
diff --git a/common/lib/hubSummary.test.ts b/common/lib/hubSummary.test.ts
@@ -294,3 +294,20 @@ test("officialInstances: a named accent is painted per base; a site's own differ
assert.equal(bare[0].accent, "var(--swatch-brass)");
assert.equal(bare[1].accent, "var(--swatch-sakura)");
});
+
+test("officialInstances: a site with no accent wears its homepage chart colour, never a family's slot", () => {
+ const summary = homepageOrder();
+ // anilyzer is Blue, so its chart layer holds --chart-1 — the slot jeralyzer
+ // (index 0, no accent) would have worn. jeralyzer takes the lowest free one,
+ // as the homepage's chart and card do (siteChartColors).
+ summary.sites[1] = { ...summary.sites[1], accent: "#74a9f2", accentId: "blue" };
+ const out = officialInstances(pool(), summary);
+ assert.deepEqual(
+ out.map((o) => [o.site.siteId, o.accent]),
+ [
+ ["jeralyzer", "var(--chart-2)"],
+ ["anilyzer", "var(--swatch-blue)"],
+ ["hasanalyzer", "var(--chart-3)"],
+ ],
+ );
+});
diff --git a/common/lib/hubSummary.ts b/common/lib/hubSummary.ts
@@ -22,7 +22,7 @@ import type {
} from "./homepageSummary";
import { parseAccent } from "./accent";
import { isAccentId, type AccentId } from "./brand";
-import { siteColor } from "./siteColor";
+import { siteChartColors, siteColor } from "./siteColor";
export const HUB_SUMMARY_FILE = "hub-summary.json";
export const HUB_SUMMARY_VERSION = 1;
@@ -190,14 +190,16 @@ 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 (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.
+// COLOUR (lib/siteColor.ts): the colour its homepage card wears — the site's
+// own accent, else the summary's, else its chart colour (siteChartColors over
+// the summary's sites: its accent family's slot, or seriesColor() at its index
+// when that is free). 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 chart colours after the summary's, so it
+// never wears one a named instance's chart wears. With no summary that is its
+// place in the list.
function colourOf(
site: { accent?: string },
figures: HubSummarySite | null,
@@ -221,16 +223,20 @@ export function officialInstances<
.sort((a, b) => a.at - b.at || a.given - b.given);
const unnamed = placed.filter((p) => p.at < 0);
const listed = summary?.sites.length ?? 0;
+ const chart = siteChartColors([
+ ...(summary?.sites ?? []),
+ ...unnamed.map(() => ({})),
+ ]);
return [
...named.map((p) => ({
site: p.site,
figures: p.figures,
- accent: siteColor(colourOf(p.site, p.figures), p.at),
+ accent: siteColor(colourOf(p.site, p.figures), chart[p.at]),
})),
...unnamed.map((p, k) => ({
site: p.site,
figures: null,
- accent: siteColor(colourOf(p.site, null), listed + k),
+ accent: siteColor(colourOf(p.site, null), chart[listed + k]),
})),
];
}
diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts
@@ -1,36 +1,126 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { siteColor } from "./siteColor";
+import { ACCENT_CHART_SLOT, siteChartColors, siteColor } from "./siteColor";
import { seriesColor } from "./homepageChart";
import { ACCENTS, ACCENT_IDS } from "./brand";
+// ── siteColor: the mark colour ───────────────────────────────────────────────
+
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),
+ siteColor({ accentId: id, accent: ACCENTS[id].onDark }, seriesColor(3)),
`var(--swatch-${id})`,
);
}
// The id wins even without the hex (a hand-built summary).
- assert.equal(siteColor({ accentId: "brass" }, 0), "var(--swatch-brass)");
+ assert.equal(siteColor({ accentId: "brass" }, seriesColor(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");
+ assert.equal(siteColor({ accent: "#CC3366" }, seriesColor(0)), "#cc3366");
+ assert.equal(siteColor({ accent: "#cc3366", accentId: undefined }, seriesColor(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: no accent is the site's chart colour", () => {
+ assert.equal(siteColor({}, seriesColor(0)), "var(--chart-1)");
+ assert.equal(siteColor({}, "var(--chart-4)"), "var(--chart-4)");
+ assert.equal(siteColor({}, seriesColor(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 an accent id: the hex (or the chart colour) stands.
+ assert.equal(siteColor({ accentId: "gold", accent: "#112233" }, seriesColor(0)), "#112233");
+ assert.equal(siteColor({ accentId: "red);x:y" }, seriesColor(1)), seriesColor(1));
// Not a #rrggbb.
- assert.equal(siteColor({ accent: "red" }, 1), seriesColor(1));
- assert.equal(siteColor({ accent: "var(--brand)" }, 0), seriesColor(0));
+ assert.equal(siteColor({ accent: "red" }, seriesColor(1)), seriesColor(1));
+ assert.equal(siteColor({ accent: "var(--brand)" }, seriesColor(0)), seriesColor(0));
+});
+
+// ── siteChartColors: the chart colour ────────────────────────────────────────
+
+const named = (accentId: string) => ({ accentId });
+const none = {};
+
+test("siteChartColors: each family takes its hue's slot of --chart-1..5", () => {
+ // --chart-1..5 is blue, green, violet, amber, magenta on every base.
+ assert.deepEqual(ACCENT_CHART_SLOT, { blue: 0, green: 1, violet: 2, brass: 3, sakura: 4 });
+ // Today's homepage order with the operator's mapping (2026-09-27).
+ assert.deepEqual(
+ siteChartColors(["brass", "sakura", "violet", "blue", "green"].map(named)),
+ ["var(--chart-4)", "var(--chart-5)", "var(--chart-3)", "var(--chart-1)", "var(--chart-2)"],
+ );
+ // The colour follows the site, not its place: Hasanalyzer and Rekietalyzer
+ // swapped keep their colours.
+ assert.deepEqual(
+ siteChartColors(["brass", "sakura", "violet", "green", "blue"].map(named)),
+ ["var(--chart-4)", "var(--chart-5)", "var(--chart-3)", "var(--chart-2)", "var(--chart-1)"],
+ );
+});
+
+test("siteChartColors: no accents is seriesColor(0..n-1), as before", () => {
+ const sites = Array.from({ length: 8 }, () => none);
+ assert.deepEqual(siteChartColors(sites), sites.map((_, i) => seriesColor(i)));
+ assert.deepEqual(siteChartColors([]), []);
+});
+
+test("siteChartColors: no family (vermilion, signal, a custom hex, an unknown id) falls back to its index", () => {
+ assert.deepEqual(
+ siteChartColors([named("vermilion"), named("signal"), { accentId: undefined }, named("gold")]),
+ [seriesColor(0), seriesColor(1), seriesColor(2), seriesColor(3)],
+ );
+});
+
+test("siteChartColors: two sites in one family — the later takes a free slot", () => {
+ // Both blue: the first keeps chart-1; the second's own index (1) is free.
+ assert.deepEqual(siteChartColors([named("blue"), named("blue")]), [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ ]);
+ // Both brass, and the later one's own index (3) is brass's: the lowest free.
+ assert.deepEqual(
+ siteChartColors([named("brass"), none, none, named("brass")]),
+ ["var(--chart-4)", "var(--chart-2)", "var(--chart-3)", "var(--chart-1)"],
+ );
+});
+
+test("siteChartColors: a fallback whose index a family holds takes the lowest free slot", () => {
+ // Site 0 has no accent; site 1 is blue and holds chart-1 (index 0).
+ assert.deepEqual(siteChartColors([none, named("blue")]), ["var(--chart-2)", "var(--chart-1)"]);
+ // Five sites: 0 none (chart-1 held by blue), 3 none (chart-4 held by brass).
+ assert.deepEqual(
+ siteChartColors([none, named("brass"), named("blue"), none, named("sakura")]),
+ ["var(--chart-2)", "var(--chart-4)", "var(--chart-1)", "var(--chart-3)", "var(--chart-5)"],
+ );
+ // Past the palette, the golden-angle hues, never one already worn.
+ const six = siteChartColors([
+ named("blue"), named("green"), named("violet"), named("brass"), named("sakura"), named("blue"),
+ ]);
+ assert.equal(six[5], seriesColor(5));
+});
+
+test("siteChartColors: never two sites in one colour", () => {
+ const ids = [...ACCENT_IDS, undefined];
+ // A deterministic walk over many mixes of families, duplicates and none.
+ let seed = 7;
+ const rand = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
+ for (let run = 0; run < 500; run++) {
+ const n = 1 + Math.floor(rand() * 9);
+ const sites = Array.from({ length: n }, () => ({
+ accentId: ids[Math.floor(rand() * ids.length)],
+ }));
+ const out = siteChartColors(sites);
+ assert.equal(out.length, n);
+ assert.equal(new Set(out).size, n, JSON.stringify(sites));
+ // The FIRST site of each family wears its family's slot.
+ const seen = new Set<string>();
+ sites.forEach((s, i) => {
+ const slot = s.accentId
+ ? ACCENT_CHART_SLOT[s.accentId as keyof typeof ACCENT_CHART_SLOT]
+ : undefined;
+ if (slot === undefined || seen.has(s.accentId!)) return;
+ seen.add(s.accentId!);
+ assert.equal(out[i], seriesColor(slot));
+ });
+ }
});
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -1,24 +1,39 @@
-// 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 site's colours, as CSS values, for the family's own surfaces: the homepage
+// (its instance cards, its growth chart, /stats) and the hub.
//
-// • 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.
+// TWO COLOURS PER SITE, ONE HUE.
//
-// 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).
+// siteChartColors(sites) — the CHART colour: a slot of the validated chart
+// palette (`--chart-1..5`, tokens.css: blue, green, violet, amber,
+// magenta), 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, Signal, a custom hex and no accent have no family and take
+// seriesColor(i). No two sites share a colour (below).
+//
+// siteColor(site, chart) — the MARK colour (a card's stripe, the hub's chip
+// and result edge): a named accent is `var(--swatch-<id>)`, its value on
+// the base in force; a custom hex is that hex, as published; 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, in today's homepage order
+// (jeralyzer brass, anilyzer sakura, bonnellyzer violet, hasanalyzer blue,
+// rekietalyzer green) and with the two closest sites swapped, pass every
+// ADJACENT pair on every base; 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 numbers are in
+// ArchiveGrowthChart.tsx.
//
// Pure and client-safe: the hub resolves it in the browser.
import { parseAccent } from "./accent";
-import { isAccentId } from "./brand";
+import { isAccentId, type AccentId } from "./brand";
import { seriesColor } from "./homepageChart";
export type SiteColorSource = {
@@ -26,7 +41,48 @@ export type SiteColorSource = {
accentId?: string;
};
-export function siteColor(site: SiteColorSource, index: number): string {
+// The chart slot (seriesColor index, 0-based) of each accent's hue family.
+export const ACCENT_CHART_SLOT: Readonly<Partial<Record<AccentId, number>>> = {
+ blue: 0,
+ green: 1,
+ violet: 2,
+ brass: 3,
+ sakura: 4,
+};
+
+// 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 fifth, seriesColor's golden-angle hues).
+export function siteChartColors(sites: readonly { accentId?: string }[]): string[] {
+ const slot: number[] = new Array(sites.length).fill(-1);
+ const taken = new Set<number>();
+ 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));
+}
+
+// A site's mark colour; `chart` is its siteChartColors entry.
+export function siteColor(site: SiteColorSource, chart: string): string {
if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`;
- return parseAccent(site.accent) ?? seriesColor(index);
+ return parseAccent(site.accent) ?? chart;
}
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -219,7 +219,9 @@ html[data-base="light"] {
--state-gone-soft: rgba(168, 65, 45, 0.12);
/* Charts do not follow the accent: a fixed categorical order per base, in
- one hue order everywhere (blue, green, violet, amber, magenta). Validated
+ one hue order everywhere (blue, green, violet, amber, magenta). A site's
+ SLOT is its accent's hue family (lib/siteColor.ts siteChartColors), the
+ values stay these. Validated
with the dataviz skill's validate_palette.js on this base's
--chart-surface: adjacent pairs pass every check (lightness band, chroma,
CVD ΔE ≥ 8, normal-vision ΔE ≥ 15, ≥ 3:1). All pairs pass too, with the
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -1,5 +1,5 @@
import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
-import { siteColor } from "yt-dlp-transcript-common/lib/siteColor";
+import { siteChartColors, siteColor } from "yt-dlp-transcript-common/lib/siteColor";
// The official instances: one card per public archive, its title linking out,
// its own numbers underneath. No description line under the title — the
@@ -9,18 +9,18 @@ import { siteColor } from "yt-dlp-transcript-common/lib/siteColor";
// THE STRIPE IS THE SITE'S OWN ACCENT (release 10, the operator's ask):
// siteColor() — a named accent as `var(--swatch-<id>)`, its value on the base
// in force, from the summary's `accentId`; a custom hex as published; a site
-// with no accent seriesColor(i), its chart layer's colour. The hub's official
-// cards wear the same (lib/hubSummary.ts officialInstances).
+// with no accent its chart colour. The hub's official cards wear the same
+// (lib/hubSummary.ts officialInstances).
//
-// SO A CARD IS NO LONGER THE GROWTH CHART'S LEGEND. The chart stays on
-// seriesColor and carries its own legend (ArchiveGrowthChart), because the
-// accents fail as a chart palette: validated with the dataviz skill's
-// validator over every pair on every base, no set of five that includes the
-// operator's Brass (Jeralyzer) and Blue (Hasanalyzer) passes — Brass↔Vermilion
-// ΔE 1.0 (deutan), Blue↔Violet ΔE 8.5 normal / 1.3 protan on dark, Brass↔Green
-// 1.8 (protan); the least-bad set still fails three pairs. Here each card names
-// its site in text, so the colour only has to be the site's, not tell the
-// sites apart.
+// A CARD STILL READS AS ITS LAYER'S LEGEND, BY HUE. The growth chart draws
+// each site in the validated chart slot of its accent's hue family
+// (siteChartColors: Brass → amber, Blue → blue, Violet → violet, Green →
+// green, Sakura → magenta), not in the accent itself — the accents fail as a
+// chart palette (Brass↔Vermilion ΔE 1.0 deutan, Blue↔Violet 8.5 normal). So a
+// family accent's stripe and its layer are one hue at two values, and a site
+// with no accent matches its layer exactly. Vermilion, Signal and a custom hex
+// have no family, so their layer is a palette colour of its own;
+// ArchiveGrowthChart's legend names every layer either way.
//
// Numbers are v5 fields; a pre-v5 summary lacks them and the card shows only
// the transcript count it always had.
@@ -39,6 +39,7 @@ function Figure({ value, unit }: { value: number | undefined; unit: string }) {
export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) {
if (sites.length === 0) return null;
+ const chart = siteChartColors(sites);
return (
<ul className="grid grid-cols-1 border-t border-l border-[var(--border)] sm:grid-cols-2 lg:grid-cols-3 list-none">
{sites.map((site, i) => (
@@ -49,7 +50,7 @@ export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) {
<span
aria-hidden="true"
className="absolute left-0 top-0 h-full w-[3px]"
- style={{ backgroundColor: siteColor(site, i) }}
+ style={{ backgroundColor: siteColor(site, chart[i]) }}
/>
<h3 className="font-display text-lg font-semibold text-[var(--foreground)]">
<a