commit c5b5c6b354c57f05d48a8f06c333f6f32aa7ef6b
parent 54cb4f2c228c680e023c141aa4b4e972c3ffc089
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 02:47:20 -0400
common: a sixth validated chart slot (--chart-6, Vermilion's rust) and a custom hex fitted per base on the family's cards
- tokens.css: --chart-6 per base (light/sepia #823c10, dark #a54a08), the
only hue family that clears every pair with --chart-1..5 on all three bases
(validate_palette.js, all pairs, chart surface and page ground; its worst
pair CVD 9.1, normal 16.3). seriesColor(5) is var(--chart-6), not the golden
angle's hue 328 beside --chart-5's magenta; vermilion → slot 6 in
ACCENT_CHART_SLOT, so any six sites wear the six validated slots.
- --base-light|sepia|dark (1 on their own base, 0 elsewhere) and
perBaseColor: one CSS value that is a different colour per base. siteColor
paints a custom hex through resolveAccent's per-base fit (4.5:1 on the
ground and with its ink), as the site's own pages do, on the homepage cards
and the hub's official cards alike.
- REQUIRED_TOKENS gains --chart-6 (50); tests: six-slot uniqueness, the
vermilion slot at every rank, the fitted pale hex per base, one-hot flags.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 259 insertions(+), 74 deletions(-)
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -147,6 +147,7 @@ export const REQUIRED_TOKENS = [
"--chart-3",
"--chart-4",
"--chart-5",
+ "--chart-6",
"--chart-surface",
"--chart-grid",
"--chart-axis",
diff --git a/common/components/themeTokens.test.ts b/common/components/themeTokens.test.ts
@@ -92,8 +92,19 @@ test("every base declares every required token, color-scheme and every swatch",
}
});
-test("REQUIRED_TOKENS is the 49 colour tokens, each once", () => {
- assert.equal(REQUIRED_TOKENS.length, 49);
+test("--base-light|sepia|dark are 1 on their own base and 0 on the others", () => {
+ // lib/siteColor.ts perBaseColor multiplies each base's channel by these, so
+ // exactly one may be 1 — two would add two colours' channels together.
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const b of BASE_GROUND_IDS) {
+ assert.equal(r.decls.get(`--base-${b}`), b === base ? "1" : "0", `${base} --base-${b}`);
+ }
+ }
+});
+
+test("REQUIRED_TOKENS is the 50 colour tokens, each once", () => {
+ assert.equal(REQUIRED_TOKENS.length, 50);
assert.equal(new Set(REQUIRED_TOKENS).size, REQUIRED_TOKENS.length);
});
@@ -188,7 +199,12 @@ test("no retired theme family is left in the sheet", () => {
test("charts stay off the accent and off the gone colour", () => {
for (const base of BASE_GROUND_IDS) {
const r = rule(BASE_SELECTOR[base]);
- for (let n = 1; n <= 5; n++) assert.match(r.decls.get(`--chart-${n}`) ?? "", /^#[0-9a-f]{6}$/);
+ for (let n = 1; n <= 6; n++) assert.match(r.decls.get(`--chart-${n}`) ?? "", /^#[0-9a-f]{6}$/);
+ // Six different colours on every base (the sixth, release 11, is a
+ // validated slot of its own, not a repeat or the golden angle).
+ const six = [1, 2, 3, 4, 5, 6].map((n) => r.decls.get(`--chart-${n}`));
+ assert.equal(new Set(six).size, 6, `${base}: ${six.join(" ")}`);
+ assert.notEqual(r.decls.get("--chart-6"), r.decls.get("--state-gone"));
// FACTS.md ~6442: chart-3 once WAS the gone hex; the palette itself was
// re-validated with the dataviz validator (plans/brand-and-themes.md, S2).
assert.notEqual(r.decls.get("--chart-3"), r.decls.get("--state-gone"));
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -14,11 +14,14 @@ export type Range = "2w" | "6w" | "90d" | "12mo" | "all";
export type Metric = MetricKey; // "transcribed" | "downloaded"
export type Display = "share" | "counts";
-// Stable series color. The first five use the globals.css palette
-// (--chart-1..5); beyond that we spread hues by the golden angle so many
-// channels / >5 sites stay visually distinct (and stable across renders).
+// Stable series color. The first six are the validated palette in
+// common/styles/tokens.css (--chart-1..6, fixed per base); beyond that we
+// spread hues by the golden angle so many channels / >6 sites stay distinct
+// (and stable across renders). The sixth was the golden angle's hue 328 until
+// release 11 — within a degree of --chart-5's magenta.
+export const CHART_SLOTS = 6;
export function seriesColor(index: number): string {
- if (index < 5) return `var(--chart-${index + 1})`;
+ if (index < CHART_SLOTS) return `var(--chart-${index + 1})`;
const hue = Math.round((index * 137.508) % 360);
return `hsl(${hue} 64% 55%)`;
}
diff --git a/common/lib/hubSummary.test.ts b/common/lib/hubSummary.test.ts
@@ -10,6 +10,11 @@ import {
} from "./hubSummary";
import { seriesColor } from "./homepageChart";
import type { HomepageSummary } from "./homepageSummary";
+import { resolveAccent } from "./accent";
+import { perBaseColor } from "./siteColor";
+
+// A custom hex as a card wears it: fitted to each base (lib/siteColor.ts).
+const fitted = (hex: string) => perBaseColor(resolveAccent(hex));
// Run with:
// pnpm --filter yt-dlp-transcript-common test
@@ -204,8 +209,8 @@ test("officialInstances: an accent the site sets wins; its place still follows t
out.map((o) => [o.site.siteId, o.accent]),
[
["jeralyzer", seriesColor(0)],
- ["anilyzer", "#e6a1c0"], // hub-sites.json's own
- ["hasanalyzer", "#b49cf2"], // the summary's, when hub-sites.json has none
+ ["anilyzer", fitted("#e6a1c0")], // hub-sites.json's own, fitted per base
+ ["hasanalyzer", fitted("#b49cf2")], // the summary's, when hub-sites.json has none
],
);
});
@@ -285,7 +290,7 @@ test("officialInstances: a named accent is painted per base; a site's own differ
out.map((o) => [o.site.siteId, o.accent]),
[
["jeralyzer", "var(--swatch-brass)"],
- ["anilyzer", "#cc3366"], // the site's own, which disagrees: no id
+ ["anilyzer", fitted("#cc3366")], // the site's own, which disagrees: no id
["hasanalyzer", seriesColor(2)],
],
);
diff --git a/common/lib/hubSummary.ts b/common/lib/hubSummary.ts
@@ -194,7 +194,8 @@ export type OfficialInstance<T> = {
// 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,
+// from the summary's `accentId`, and a custom hex is fitted to each base
+// (perBaseColor, release 11); 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
diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts
@@ -1,8 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { ACCENT_CHART_SLOT, siteChartColors, siteColor } from "./siteColor";
-import { seriesColor } from "./homepageChart";
-import { ACCENTS, ACCENT_IDS } from "./brand";
+import { ACCENT_CHART_SLOT, perBaseColor, siteChartColors, siteColor } from "./siteColor";
+import { CHART_SLOTS, seriesColor } from "./homepageChart";
+import { resolveAccent } from "./accent";
+import { ACCENTS, ACCENT_IDS, BASE_GROUNDS, BASE_GROUND_IDS, contrastRatio, type BaseGround } from "./brand";
+
+// What the browser paints for a perBaseColor value on `base`: tokens.css sets
+// `--base-<base>` to 1 and the other two to 0 (`null`: no token sheet at all,
+// so every var() takes its fallback). Returns "#rrggbb".
+function paint(css: string, base: BaseGround | null): string {
+ const flags = css.replace(/var\(--base-(light|sepia|dark), ([01])\)/g, (_, b, fallback) =>
+ base === null ? fallback : b === base ? "1" : "0",
+ );
+ const channels = [...flags.matchAll(/calc\(([^()]*)\)/g)].map((m) =>
+ m[1].split("+").reduce((sum, term) => {
+ const [a, b] = term.split("*").map(Number);
+ return sum + a * b;
+ }, 0),
+ );
+ assert.match(flags, /^rgb\(calc\([^()]*\) calc\([^()]*\) calc\([^()]*\)\)$/, css);
+ assert.equal(channels.length, 3, css);
+ return `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
+}
// ── siteColor: the mark colour ───────────────────────────────────────────────
@@ -17,9 +36,35 @@ test("siteColor: a named accent is its per-base swatch, whatever hex rides along
assert.equal(siteColor({ accentId: "brass" }, seriesColor(0)), "var(--swatch-brass)");
});
-test("siteColor: a custom hex is that hex, normalized", () => {
- assert.equal(siteColor({ accent: "#CC3366" }, seriesColor(0)), "#cc3366");
- assert.equal(siteColor({ accent: "#cc3366", accentId: undefined }, seriesColor(4)), "#cc3366");
+test("siteColor: a custom hex is fitted to each base, as the site's own pages fit it", () => {
+ // A pale hex: 1.43:1 on the light ground as published, so the old card
+ // painted it nearly invisible on light and sepia.
+ const pale = "#f4c2d7";
+ const fitted = resolveAccent(pale);
+ const css = siteColor({ accent: "#F4C2D7" }, seriesColor(0));
+ assert.equal(css, perBaseColor(fitted));
+ for (const base of BASE_GROUND_IDS) {
+ assert.equal(paint(css, base), fitted[base], base);
+ assert.ok(contrastRatio(paint(css, base), BASE_GROUNDS[base]) >= 4.5, base);
+ }
+ // Fitted, not as published, where the ground needs it; kept where it reads.
+ assert.deepEqual(
+ { light: paint(css, "light"), sepia: paint(css, "sepia"), dark: paint(css, "dark") },
+ { light: "#846974", sepia: "#7c636e", dark: pale },
+ );
+ // A hex that already reads on a ground is kept there exactly (#cc3366 on
+ // light), and the chart colour is never used for one.
+ assert.equal(paint(siteColor({ accent: "#cc3366" }, seriesColor(4)), "light"), "#cc3366");
+ assert.equal(paint(siteColor({ accent: "#cc3366" }, seriesColor(4)), "dark"), resolveAccent("#cc3366").dark);
+});
+
+test("perBaseColor: one value, each base's colour; the light one with no token sheet", () => {
+ const v = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" };
+ const css = perBaseColor(v);
+ for (const base of BASE_GROUND_IDS) assert.equal(paint(css, base), v[base]);
+ assert.equal(paint(css, null), v.light);
+ // Plain text a style attribute takes as is: no quotes, no url(), no `;`.
+ assert.doesNotMatch(css, /["';]|url\(/);
});
test("siteColor: no accent is the site's chart colour", () => {
@@ -29,8 +74,11 @@ test("siteColor: no accent is the site's chart colour", () => {
});
test("siteColor: anything unrecognised falls through, never into the style", () => {
- // Not an accent id: the hex (or the chart colour) stands.
- assert.equal(siteColor({ accentId: "gold", accent: "#112233" }, seriesColor(0)), "#112233");
+ // Not an accent id: the hex, fitted (or the chart colour), stands.
+ assert.equal(
+ siteColor({ accentId: "gold", accent: "#112233" }, seriesColor(0)),
+ perBaseColor(resolveAccent("#112233")),
+ );
assert.equal(siteColor({ accentId: "red);x:y" }, seriesColor(1)), seriesColor(1));
// Not a #rrggbb.
assert.equal(siteColor({ accent: "red" }, seriesColor(1)), seriesColor(1));
@@ -42,9 +90,16 @@ test("siteColor: anything unrecognised falls through, never into the style", ()
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 });
+test("siteChartColors: each family takes its hue's slot of --chart-1..6", () => {
+ // --chart-1..6 is blue, green, violet, amber, magenta, rust on every base.
+ assert.deepEqual(ACCENT_CHART_SLOT, {
+ blue: 0,
+ green: 1,
+ violet: 2,
+ brass: 3,
+ sakura: 4,
+ vermilion: 5,
+ });
// Today's homepage order with the operator's mapping (2026-09-27).
assert.deepEqual(
siteChartColors(["brass", "sakura", "violet", "blue", "green"].map(named)),
@@ -56,6 +111,23 @@ test("siteChartColors: each family takes its hue's slot of --chart-1..5", () =>
siteChartColors(["brass", "sakura", "violet", "green", "blue"].map(named)),
["var(--chart-4)", "var(--chart-5)", "var(--chart-3)", "var(--chart-2)", "var(--chart-1)"],
);
+ // Jasolyzer (Vermilion) published beside them, wherever it ranks: the rust
+ // slot, not the golden angle's hue 328 beside Anilyzer's magenta.
+ const five = ["brass", "sakura", "blue", "violet", "green"];
+ for (let at = 0; at <= five.length; at++) {
+ const ids = [...five.slice(0, at), "vermilion", ...five.slice(at)];
+ const out = siteChartColors(ids.map(named));
+ assert.equal(out[at], "var(--chart-6)", `vermilion at ${at}`);
+ }
+});
+
+test("seriesColor: six validated slots, then the golden angle", () => {
+ assert.equal(CHART_SLOTS, 6);
+ assert.deepEqual(
+ Array.from({ length: 6 }, (_, i) => seriesColor(i)),
+ [1, 2, 3, 4, 5, 6].map((n) => `var(--chart-${n})`),
+ );
+ assert.match(seriesColor(6), /^hsl\(/);
});
test("siteChartColors: no accents is seriesColor(0..n-1), as before", () => {
@@ -64,10 +136,10 @@ test("siteChartColors: no accents is seriesColor(0..n-1), as before", () => {
assert.deepEqual(siteChartColors([]), []);
});
-test("siteChartColors: no family (vermilion, signal, a custom hex, an unknown id) falls back to its index", () => {
+test("siteChartColors: no family (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)],
+ siteChartColors([named("signal"), { accentId: undefined }, named("gold")]),
+ [seriesColor(0), seriesColor(1), seriesColor(2)],
);
});
@@ -92,11 +164,34 @@ test("siteChartColors: a fallback whose index a family holds takes the lowest fr
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([
+ // A sixth site with no free family takes the sixth slot; a seventh, past
+ // the palette, the golden-angle hues, never one already worn.
+ const seven = siteChartColors([
named("blue"), named("green"), named("violet"), named("brass"), named("sakura"), named("blue"),
+ named("vermilion"),
]);
- assert.equal(six[5], seriesColor(5));
+ assert.equal(seven[6], "var(--chart-6)");
+ assert.equal(seven[5], seriesColor(6));
+});
+
+test("siteChartColors: any six sites wear the six validated slots, each once", () => {
+ // Whatever the accents — families, duplicates, Signal, a custom hex, none —
+ // up to six published sites never fall past --chart-6 to the golden angle
+ // (whose sixth hue was within a degree of --chart-5's magenta).
+ const ids = [...ACCENT_IDS, undefined];
+ const validated = [1, 2, 3, 4, 5, 6].map((n) => `var(--chart-${n})`);
+ let seed = 11;
+ const rand = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
+ for (let run = 0; run < 500; run++) {
+ const n = 1 + Math.floor(rand() * 6);
+ const sites = Array.from({ length: n }, () => ({
+ accentId: ids[Math.floor(rand() * ids.length)],
+ }));
+ const out = siteChartColors(sites);
+ assert.equal(new Set(out).size, n, JSON.stringify(sites));
+ for (const c of out) assert.ok(validated.includes(c), `${c} for ${JSON.stringify(sites)}`);
+ if (n === 6) assert.deepEqual([...out].sort(), validated);
+ }
});
test("siteChartColors: never two sites in one colour", () => {
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -4,36 +4,38 @@
// TWO COLOURS PER SITE, ONE HUE.
//
// 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:
+// 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, Signal, a custom hex and no accent have no family and take
-// seriesColor(i). No two sites share a colour (below).
+// 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-<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.
+// 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, 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
+// 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 numbers are in
-// ArchiveGrowthChart.tsx.
+// 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 } from "./accent";
-import { isAccentId, type AccentId } from "./brand";
+import { parseAccent, resolveAccent } from "./accent";
+import { BASE_GROUND_IDS, isAccentId, type AccentId, type BaseGround } from "./brand";
import { seriesColor } from "./homepageChart";
export type SiteColorSource = {
@@ -42,12 +44,14 @@ export type SiteColorSource = {
};
// 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<Partial<Record<AccentId, number>>> = {
blue: 0,
green: 1,
violet: 2,
brass: 3,
sakura: 4,
+ vermilion: 5,
};
// One chart colour per site, in `sites` order, all different.
@@ -56,7 +60,8 @@ export const ACCENT_CHART_SLOT: Readonly<Partial<Record<AccentId, number>>> = {
// 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).
+// 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<number>();
@@ -81,8 +86,29 @@ export function siteChartColors(sites: readonly { accentId?: string }[]): string
return slot.map((k) => seriesColor(k));
}
+// ONE CSS colour that is `values.light` on the light base, `values.sepia` on
+// sepia 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|sepia|dark` to 1 on their own base and 0 on the others, so each
+// channel is a calc() over the three 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.
+export function perBaseColor(values: Readonly<Record<BaseGround, string>>): string {
+ const rgb = BASE_GROUND_IDS.map((b) => {
+ 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 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) ?? chart;
+ const hex = parseAccent(site.accent);
+ return hex ? perBaseColor(resolveAccent(hex)) : chart;
}
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -32,6 +32,11 @@
• `--swatch-<id>` is each named accent's value ON THIS BASE (lib/brand.ts
ACCENTS — themeTokens.test.ts keeps the two equal), so a picker can
show the colour a choice will actually paint.
+ • `--base-light`, `--base-sepia`, `--base-dark` are 1 on their own base
+ and 0 on the others. They let a colour that differs per base but is
+ known only to a component be ONE CSS value: each channel a calc() over
+ the three (lib/siteColor.ts perBaseColor — a custom-hex site's card on
+ the homepage and the hub, fitted to each ground).
========================================================================== */
@import "tw-animate-css";
@@ -93,6 +98,7 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
+ --color-chart-6: var(--chart-6);
/* Type system */
--font-display: var(--font-display);
@@ -207,6 +213,11 @@ html[data-base="light"] {
customAccentVars, inline on <html>); Signal if it is missing. */
--swatch-custom: var(--accent-custom-light, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 1;
+ --base-sepia: 0;
+ --base-dark: 0;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, black);
--brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
@@ -229,12 +240,19 @@ html[data-base="light"] {
with a secondary encoding, a legend or direct labels. chart-3 is a
violet, well apart from --state-gone (ΔE 24.3 normal, 21.7 protan): it
once WAS the gone hex, so the third archive in a chart read as "gone".
- chart-1 is a blue, not Signal (ΔE 17.3 from it). */
+ chart-1 is a blue, not Signal (ΔE 17.3 from it).
+ chart-6 (release 11) is a rust, Vermilion's family: the sixth site's
+ colour. The only hue family that clears every pair with chart-1..5 on
+ all three bases is the red-browns (OKLCH h ≈ 15–60); this one's worst
+ pair with them is CVD ΔE 12.5 protan and normal 16.6, 8.1:1 on the chart
+ surface and 7.4:1 on the ground. It sits near --state-gone (ΔE 8.4
+ normal), which is only ever labelled text, never a chart mark. */
--chart-1: #3a7de0;
--chart-2: #2f8a57;
--chart-3: #5e3aa8;
--chart-4: #a8741a;
--chart-5: #c24a8a;
+ --chart-6: #823c10;
--chart-surface: #ffffff;
--chart-grid: rgba(22, 28, 33, 0.09);
--chart-axis: #55646e;
@@ -297,6 +315,11 @@ html[data-base="sepia"] {
--swatch-green: #3d772b;
--swatch-custom: var(--accent-custom-sepia, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 0;
+ --base-sepia: 1;
+ --base-dark: 0;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, black);
--brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
@@ -307,12 +330,14 @@ html[data-base="sepia"] {
/* Same hue order as light, stepped for the paper surface. Adjacent pairs
pass every check; all pairs pass with green ↔ amber in the CVD floor
- band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. */
+ band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. chart-6, the
+ rust, is light's: worst pair CVD 9.1 protan, normal 16.6. */
--chart-1: #3574d6;
--chart-2: #2a7d4f;
--chart-3: #5e3aa8;
--chart-4: #a8741a;
--chart-5: #bb4585;
+ --chart-6: #823c10;
--chart-surface: #faf4e6;
--chart-grid: rgba(51, 40, 26, 0.09);
--chart-axis: #6b5c43;
@@ -373,6 +398,11 @@ html[data-base="dark"] {
--swatch-green: #7cc46a;
--swatch-custom: var(--accent-custom-dark, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 0;
+ --base-sepia: 0;
+ --base-dark: 1;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, white);
--brand-soft: color-mix(in srgb, var(--brand) 16%, transparent);
@@ -383,12 +413,15 @@ html[data-base="dark"] {
/* Same hue order, stepped into the validator's dark lightness band.
Adjacent pairs pass every check; all pairs pass with green ↔ magenta in
- the CVD floor band (6.9). chart-3 vs --state-gone: ΔE 22.9 normal. */
+ the CVD floor band (6.9). chart-3 vs --state-gone: ΔE 22.9 normal.
+ chart-6, the rust stepped into the band: worst pair CVD 13.9, normal
+ 16.3, 3.2:1 on the surface. */
--chart-1: #3561c8;
--chart-2: #3fa577;
--chart-3: #9a7ee6;
--chart-4: #b98a2a;
--chart-5: #c24c8a;
+ --chart-6: #a54a08;
--chart-surface: #16110a;
--chart-grid: rgba(233, 220, 197, 0.08);
--chart-axis: #8a8170;
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -8,18 +8,19 @@ import { siteChartColors, siteColor } from "yt-dlp-transcript-common/lib/siteCol
//
// 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 its chart colour. The hub's official cards wear the same
-// (lib/hubSummary.ts officialInstances).
+// in force, from the summary's `accentId`; a custom hex fitted to each base as
+// the site's own pages fit it (release 11: a pale hex published as is was
+// ~1.4:1 on light and sepia); a site with no accent its chart colour. The
+// hub's official cards wear the same (lib/hubSummary.ts officialInstances).
//
// 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;
+// green, Sakura → magenta, Vermilion → rust), 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. 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
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -25,26 +25,30 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// the stack reads as strata, which is what it is.
//
// 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).
+// (common/lib/siteColor.ts) gives each site a slot of the per-base --chart-1..6
+// palette (tokens.css: blue, green, violet, amber, magenta, rust) by the hue
+// family of its accent — blue → blue, green → green, violet → violet, brass →
+// amber, sakura → magenta, vermilion → rust — so a layer shares its hue with
+// the instance card below (ArchiveCards, the accent's own value). A site with
+// no family (Signal, a custom hex, no accent) takes seriesColor(i), and no two
+// layers ever share a colour; up to six sites wear only the validated slots.
+// The legend above the plot wears the same colours. The layers stack in the
+// summary's order (STACK_ORDER, unchanged).
//
-// 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):
+// VALIDATED (the dataviz skill's validator, each base's values on its
+// --chart-surface and on the page ground), with the operator's accents
+// (Jeralyzer Brass, Anilyzer Sakura, Bonnellyzer Blue, Hasanalyzer Violet,
+// Rekietalyzer Green; Jasolyzer Vermilion once it is published):
// • 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.
+// ΔE 13.1 / 11.1 / 11.0 and normal 17.7 / 15.3 / 16.7 (light / sepia /
+// dark; release 10); with Jasolyzer at any place in it: PASS, worst CVD
+// 12.5 / 9.1 / 11.0, normal 16.6 / 15.3 / 16.3 (release 11);
+// • all pairs, all six: 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
+// sixth slot adds no pair under the target: its worst is CVD 9.1 (sepia,
+// against green), normal 16.3 (dark, against amber).
// 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.