commit c68f007e1f3e28fb95de068b1f43a0aaa15a3764
parent 5770fd6a060a692ffcdb5d5ba6aee8a2e9daafe8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 27 Sep 2026 00:45:04 -0400
homepage: the growth chart and /stats wear each site's hue family; a card matches its layer by hue
ArchiveGrowthChart's layers and legend, /stats' site series (homepageChartData)
and its SiteGrid cards all read siteChartColors. The leaderboard keeps a site's
own colour in the By-site breakdown instead of re-keying by rank (channels
still rank-keyed): colour follows the entity, and the cards are the legend.
STACK_ORDER is unchanged.
Validated on the layer colours the code resolves, with the operator's accents:
adjacent in today's order PASS on every base (CVD 12.3 / 11.1 / 11.8, normal
17.7 / 15.3 / 16.6, light / sepia / dark), and with Hasanalyzer and
Rekietalyzer swapped (CVD 12.3 / 11.3 / 13.7, normal 19.6 / 19.7 / 16.6); all
pairs is the palette's own borderline (CVD 6.2 / 6.1 / 6.9, normal >= 15.3).
instance-colours.spec: the legend wears siteChartColors; the Brass card's
legend swatch is --chart-4, within 25 degrees of hue of the stripe; a card
with no accent equals its legend swatch (site 3 of five takes the lowest free
slot, Brass holding its own).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 101 insertions(+), 59 deletions(-)
diff --git a/common/lib/homepageChartData.ts b/common/lib/homepageChartData.ts
@@ -12,6 +12,7 @@ import {
monthLabel,
type ChartState,
} from "./homepageChart";
+import { siteChartColors } from "./siteColor";
type Gran = "day" | "week" | "month";
@@ -79,6 +80,9 @@ function sourceAndWindow(summary: HomepageSummary, state: ChartState) {
type Entry = { id: string; label: string; color: string; full: number[] };
// One entry per site (in summary order) or per channel (sorted by window total).
+// A site wears its chart colour (siteChartColors: its accent's hue family in
+// the validated palette, else seriesColor at its index) — the colour its
+// SiteGrid card and the homepage's growth chart give it.
function entries(
summary: HomepageSummary,
state: ChartState,
@@ -87,10 +91,11 @@ function entries(
start: number,
): Entry[] {
if (state.breakdown === "site") {
+ const colors = siteChartColors(summary.sites);
return summary.sites.map((s, i) => ({
id: s.siteId,
label: s.siteTitle,
- color: seriesColor(i),
+ color: colors[i],
full: pad(src.bySite[s.siteId], len),
}));
}
@@ -183,8 +188,10 @@ export function buildTimeSeries(
return { labels, series, max };
}
-// Aggregate ranking (window totals) for the horizontal leaderboard. Colors are
-// re-keyed by rank so the leader is chart-1, and the long tail is truncated.
+// Aggregate ranking (window totals) for the horizontal leaderboard, the long
+// tail truncated. Channels are re-keyed by rank so the leader is chart-1; a
+// SITE keeps its own colour (release 10), the one its card and every other
+// chart give it — colour follows the entity, and the cards are the legend.
export function buildRanking(
summary: HomepageSummary,
state: ChartState,
@@ -195,12 +202,14 @@ export function buildRanking(
.map((e) => ({
id: e.id,
label: e.label,
+ color: e.color,
value: windowTotal(e.full, len, start),
}))
.sort((a, b) => b.value - a.value || a.label.localeCompare(b.label));
+ const bySite = state.breakdown === "site";
const rows: RankRow[] = list
.slice(0, topN)
- .map((r, i) => ({ ...r, color: seriesColor(i) }));
+ .map((r, i) => ({ ...r, color: bySite ? r.color : seriesColor(i) }));
const max = rows.reduce((m, r) => Math.max(m, r.value), 0);
return { rows, max };
}
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -2,10 +2,8 @@ import type {
HomepageMonth,
HomepageSummarySite,
} from "yt-dlp-transcript-common/lib/homepageSummary";
-import {
- monthLabel,
- seriesColor,
-} from "yt-dlp-transcript-common/lib/homepageChart";
+import { monthLabel } from "yt-dlp-transcript-common/lib/homepageChart";
+import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// The front page's showpiece: every official instance's back catalogue as
// stacked strata, one month per step, from the oldest upload to the last
@@ -26,26 +24,30 @@ import {
// with the 2 px gap the marks spec asks for would be half gap. At this density
// the stack reads as strata, which is what it is.
//
-// COLOUR follows the instance, never its rank on this chart: seriesColor(i) with
-// i = the site's position in `sites`, and the legend above the plot uses the
-// same. The layers stack in that same order (0..4): the per-base --chart-1..5
-// palette (tokens.css: blue, green, violet, amber, magenta) is validated for
-// ADJACENT pairs in exactly this order, so every touching pair clears the
-// dataviz validator on every base. It used to be interleaved (0, 2, 1, 4, 3)
-// for the old family palette, whose first two slots were too close; with the
-// new order that interleave put green against magenta, in the CVD floor band.
+// COLOUR follows the instance, never its rank on this chart: siteChartColors
+// (common/lib/siteColor.ts) gives each site a slot of the per-base --chart-1..5
+// palette (tokens.css: blue, green, violet, amber, magenta) by the hue family
+// of its accent — blue → blue, green → green, violet → violet, brass → amber,
+// sakura → magenta — so a layer shares its hue with the instance card below
+// (ArchiveCards, the accent's own value). A site with no family (Vermilion,
+// Signal, a custom hex, no accent) takes seriesColor(i), and no two layers ever
+// share a colour. The legend above the plot wears the same colours. The layers
+// stack in the summary's order (STACK_ORDER, unchanged).
//
-// NOT THE SITES' ACCENTS, although the instance cards below now wear them
-// (ArchiveCards, release 10) — so this legend, not a card, says which layer is
-// which. The accents were validated as this chart's palette first (the dataviz
-// skill's validator, ALL pairs, since the order moves with transcript counts;
-// each base's own swatch values on its --chart-surface): with the operator's
-// Brass (Jeralyzer) and Blue (Hasanalyzer) fixed, no five pass. Brass fails
-// with Vermilion (ΔE 1.0 deutan), Green (1.8 protan) and Signal; Blue with
-// Violet (8.5 normal, 1.3 protan on dark) and Signal; only Sakura passes with
-// both. The least-bad set still fails three pairs, and on dark every accent is
-// above the band (L 0.70–0.79 > 0.67): they are text colours, not a categorical
-// palette.
+// VALIDATED (release 10, the dataviz skill's validator, each base's values on
+// its --chart-surface), with the operator's accents (Jeralyzer Brass, Anilyzer
+// Sakura, Bonnellyzer Violet, Hasanalyzer Blue, Rekietalyzer Green):
+// • adjacent — the stack — in today's order (Jeralyzer, Anilyzer,
+// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on every base, worst CVD
+// ΔE 12.3 / 11.1 / 11.8 and normal 17.7 / 15.3 / 16.6 (light / sepia /
+// dark); with Hasanalyzer and Rekietalyzer swapped (3,048 vs 2,855
+// transcripts): PASS, CVD 12.3 / 11.3 / 13.7, normal 19.6 / 19.7 / 16.6;
+// • all pairs: the palette's own borderline in any order — CVD 6.2 / 6.1 /
+// 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with the
+// legend, the surface-gap edges and the table), normal ≥ 15.3.
+// The accents' own values fail as a chart palette (Brass↔Vermilion ΔE 1.0
+// deutan, Blue↔Violet 8.5 normal), which is why the chart wears their hue
+// families rather than the accents.
const W = 1000;
const H = 300;
@@ -91,6 +93,9 @@ export function ArchiveGrowthChart({
const y = (v: number) => H - (v / yMax) * H;
const r = (v: number) => Math.round(v * 10) / 10;
+ // One colour per site, in `sites` order (the legend's too).
+ const colors = siteChartColors(sites);
+
// Layers bottom-up, each with its lower and upper edge per month.
const order = stackOrder(sites.length);
const base = new Array<number>(n).fill(0);
@@ -102,7 +107,7 @@ export function ArchiveGrowthChart({
const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse();
return {
site,
- color: seriesColor(si),
+ color: colors[si],
area: `M${top.join("L")}L${bottom.join("L")}Z`,
edge: `M${top.join("L")}`,
};
@@ -137,7 +142,7 @@ export function ArchiveGrowthChart({
<ul className="flex flex-wrap gap-x-5 gap-y-2 list-none" aria-hidden="true">
{sites.map((s, i) => (
<li key={s.siteId} className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
- <span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(i) }} />
+ <span className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: colors[i] }} />
{s.siteTitle}
</li>
))}
diff --git a/homepage/app/components/SiteGrid.tsx b/homepage/app/components/SiteGrid.tsx
@@ -1,11 +1,8 @@
"use client";
import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
-import {
- seriesColor,
- type Breakdown,
- type Metric,
-} from "yt-dlp-transcript-common/lib/homepageChart";
+import type { Breakdown, Metric } from "yt-dlp-transcript-common/lib/homepageChart";
+import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// Tiny dependency-free sparkline: a soft area fill, the trend line, and a dot on
// the latest point. Avoids mounting a Recharts instance per card (mobile perf).
@@ -152,8 +149,10 @@ function SiteCard({
// Responsive grid of public content sites. Card stats follow the active metric
// and the grid sorts by that metric's "this month" count. In the By-site
// breakdown the cards double as the chart legend (colored swatch + Show/Hide
-// toggle that filters the matching band); colors key on each site's index in
-// the unsorted `sites` list so they stay aligned with the chart.
+// toggle that filters the matching band); colors key on each site's place in
+// the unsorted `sites` list so they stay aligned with the chart — its chart
+// colour (siteChartColors: its accent's hue family in the validated palette,
+// else seriesColor at its index), the one homepageChartData gives its series.
export function SiteGrid({
sites,
metric,
@@ -169,7 +168,8 @@ export function SiteGrid({
}) {
if (sites.length === 0) return null;
const bySite = breakdown === "site";
- const colorIndex = new Map(sites.map((s, i) => [s.siteId, i]));
+ const chart = siteChartColors(sites);
+ const colorOf = new Map(sites.map((s, i) => [s.siteId, chart[i]]));
const ordered = [...sites].sort(
(a, b) =>
@@ -190,7 +190,7 @@ export function SiteGrid({
key={site.siteId}
site={site}
metric={metric}
- color={seriesColor(colorIndex.get(site.siteId) ?? 0)}
+ color={colorOf.get(site.siteId) ?? chart[0]}
isLeader={site.siteId === leaderId}
bySite={bySite}
hidden={hidden.has(site.siteId)}
diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts
@@ -2,19 +2,23 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect, type Page } from "@playwright/test";
import { ACCENTS } from "../../common/lib/brand";
+import { ACCENT_CHART_SLOT, siteChartColors } from "../../common/lib/siteColor";
import { FIXTURE_ACCENTS, FIXTURE_SUMMARY_NAME } from "./fixture-accents";
// The Official Instances cards wear their site's accent (release 10): a named
// accent at its value on the base in force (`var(--swatch-<id>)`), a custom hex
-// as published, and a site with no accent its growth-chart colour. The chart
-// itself keeps its validated palette (seriesColor), so only an unaccented card
-// still matches its layer's legend swatch. The dev server reads the summary on
-// disk with fixture accents laid over its first sites (fixture-accents.ts):
-// site 0 Brass, site 1 the custom #cc3366, the rest none.
+// as published, and a site with no accent its chart colour. The growth chart
+// draws each site in the validated chart slot of its accent's HUE FAMILY
+// (siteChartColors: Brass → amber, --chart-4), so a Brass card and its legend
+// swatch are one hue; a card with no accent equals its legend swatch. The dev
+// server reads the summary on disk with fixture accents laid over its first
+// sites (fixture-accents.ts): site 0 Brass, site 1 the custom #cc3366, the rest
+// none — so with five sites, site 3's own slot (chart-4) is Brass's and it
+// takes the lowest free one.
const BASE_KEY = "ytdlp-tb:base";
-function fixtureSites(): unknown[] | null {
+function fixtureSites(): Array<{ accentId?: string }> | null {
try {
const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME);
const s = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -35,6 +39,21 @@ const resolve = (page: Page, css: string) =>
return out;
}, css);
+// OKLCH hue, in degrees, of an "rgb(r, g, b)".
+function hue(rgb: string): number {
+ const [r, g, b] = (rgb.match(/\d+/g) ?? []).slice(0, 3).map((n) => {
+ const c = Number(n) / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
+ return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
+}
+const hueGap = (a: string, b: string) => Math.abs(((hue(a) - hue(b) + 540) % 360) - 180);
+
async function colours(page: Page) {
const section = page.locator("section").filter({
has: page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }),
@@ -54,12 +73,17 @@ async function useBase(page: Page, base: "dark" | "light" | "sepia") {
await expect(page.locator("html")).toHaveAttribute("data-base", base);
}
-test("each instance card wears its site's accent on every base; the chart keeps its palette", async ({
+test("each instance card wears its site's accent on every base, in its chart layer's hue", async ({
page,
}) => {
const sites = fixtureSites();
test.skip(!sites, "no summary on disk — the page has no instance cards");
const n = sites!.length;
+ expect(FIXTURE_ACCENTS[0].accentId).toBe("brass");
+ // The chart colours the page must wear, as the helper assigns them.
+ const chart = siteChartColors(sites!);
+ expect(chart[0]).toBe(`var(--chart-${ACCENT_CHART_SLOT.brass! + 1})`);
+ expect(new Set(chart).size).toBe(n);
await page.goto("/");
const onBase = {
@@ -72,26 +96,30 @@ test("each instance card wears its site's accent on every base; the chart keeps
const { stripes, legend } = await colours(page);
expect(stripes).toHaveLength(n);
- // Site 0: the NAMED accent, at this base's value — not the published
- // on-dark hex on every base.
- expect(FIXTURE_ACCENTS[0].accentId).toBe("brass");
+ // The legend (and so each layer) wears each site's chart colour.
+ const hasChart = legend.length > 0;
+ if (hasChart) {
+ expect(legend).toHaveLength(n);
+ for (let i = 0; i < n; i++) {
+ expect(legend[i], `legend ${i}`).toBe(await resolve(page, chart[i]));
+ }
+ }
+
+ // Site 0: the NAMED accent at this base's value — not the published
+ // on-dark hex on every base — and its legend swatch is the amber slot,
+ // the same hue family (a few degrees apart), at a different value.
expect(stripes[0]).toBe(await resolve(page, onBase[base]));
+ if (hasChart) {
+ expect(legend[0]).toBe(await resolve(page, "var(--chart-4)"));
+ expect(hueGap(stripes[0], legend[0]), "brass vs its layer").toBeLessThan(25);
+ }
// Site 1 (when there is one): the custom hex as published.
if (n > 1) expect(stripes[1]).toBe(await resolve(page, FIXTURE_ACCENTS[1].accent));
// Every other site has no accent: its chart colour, the same as its
// legend swatch.
for (let i = 2; i < n; i++) {
- expect(stripes[i], `site ${i}`).toBe(await resolve(page, `var(--chart-${i + 1})`));
- if (legend.length) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]);
- }
-
- // The chart's legend stays on the validated palette, accent or not.
- if (legend.length) {
- expect(legend).toHaveLength(n);
- for (let i = 0; i < Math.min(n, 5); i++) {
- expect(legend[i], `legend ${i}`).toBe(await resolve(page, `var(--chart-${i + 1})`));
- }
- expect(legend[0]).not.toBe(stripes[0]);
+ expect(stripes[i], `site ${i}`).toBe(await resolve(page, chart[i]));
+ if (hasChart) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]);
}
}
});