commit cb2b761a92fb7dd1f3d9e248d2a8e91793cf8e67
parent 1ffa2f839d2d523c352ce573c473cd61c955bb76
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 27 Sep 2026 01:55:16 -0400
Merge r10/accent-colors — release 10 slice AC: the search hint in the accent; the homepage cards in each site's accent (per base), the chart and /stats in its accent's hue family; accentId in the homepage and hub summaries; the hub's site colours per base
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
32 files changed, 1081 insertions(+), 68 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -126,6 +126,8 @@ yarn-error.log*
/homepage/test-results/
/homepage/playwright-report/
/homepage/out/
+# the e2e's copy of the summary with fixture accents (homepage/e2e/fixture-accents.ts)
+/homepage/e2e/.e2e-summary.json
# site config
/settings.json
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -222,8 +222,11 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
more than the input row plus the chips. */}
{(queryDirty || filtersDirty || hasSubs) && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
+ {/* The site's accent, not the warning hue: an unapplied edit is
+ the next step, not a fault. `--brand` clears 4.5:1 on every
+ base's page ground (lib/brand.ts MIN_ACCENT_CONTRAST). */}
{(queryDirty || filtersDirty) && (
- <span className="text-warning">
+ <span className="text-brand">
Press Enter or click Search to apply
</span>
)}
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/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/homepageChartData.test.ts b/common/lib/homepageChartData.test.ts
@@ -0,0 +1,127 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildRanking, buildTimeSeries } from "./homepageChartData";
+import { seriesColor, type ChartState } from "./homepageChart";
+import type { BucketSeries, HomepageSummary } from "./homepageSummary";
+import { siteChartColors } from "./siteColor";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common test
+//
+// The /stats colours (release 10): a SITE wears its chart colour
+// (siteChartColors — its accent's hue family, else its index) on every chart
+// and keeps it through the leaderboard's rank sort; a CHANNEL is still
+// coloured by rank.
+
+const stat = { total: 0, thisMonth: 0, last12: [] };
+
+function series(bySite: Record<string, number[]>, byChannel: Record<string, number[]>): BucketSeries {
+ return {
+ buckets: ["2026-07", "2026-08"],
+ total: [0, 0],
+ bySite,
+ byChannel,
+ };
+}
+
+function summary(): HomepageSummary {
+ const month = series(
+ { alpha: [1, 1], beta: [5, 5], gamma: [3, 3] },
+ { c1: [1, 0], c2: [9, 9], c3: [2, 2] },
+ );
+ const metric = { day: month, week: month, month };
+ const site = (siteId: string, accentId?: "brass" | "blue") => ({
+ siteId,
+ siteTitle: siteId,
+ siteDescription: "",
+ siteUrl: `https://${siteId}.example`,
+ transcribed: stat,
+ downloaded: stat,
+ ...(accentId ? { accentId } : {}),
+ });
+ return {
+ version: 5,
+ generatedAt: "2026-09-27T00:00:00.000Z",
+ totals: {} as HomepageSummary["totals"],
+ channels: [
+ { slug: "c1", name: "Channel 1" },
+ { slug: "c2", name: "Channel 2" },
+ { slug: "c3", name: "Channel 3" },
+ ],
+ series: { transcribed: metric, downloaded: metric },
+ // Summary order: alpha Brass, beta no accent, gamma Blue.
+ sites: [site("alpha", "brass"), site("beta"), site("gamma", "blue")],
+ recent: [],
+ };
+}
+
+function state(breakdown: ChartState["breakdown"], over: Partial<ChartState> = {}): ChartState {
+ return {
+ preset: "custom",
+ metric: "transcribed",
+ breakdown,
+ chartType: "line",
+ bucket: "month",
+ range: "all",
+ scale: "linear",
+ valueMode: "counts",
+ hidden: new Set(),
+ soloId: null,
+ excludeTop: false,
+ ...over,
+ };
+}
+
+test("a site series wears its chart colour (siteChartColors), not its index's", () => {
+ const s = summary();
+ const want = siteChartColors(s.sites);
+ // alpha Brass → amber (chart-4); beta's own index is free (chart-2);
+ // gamma Blue → blue (chart-1).
+ assert.deepEqual(want, ["var(--chart-4)", "var(--chart-2)", "var(--chart-1)"]);
+ const { series: out } = buildTimeSeries(s, state("site"));
+ assert.deepEqual(
+ out.map((x) => [x.id, x.color]),
+ [
+ ["alpha", want[0]],
+ ["beta", want[1]],
+ ["gamma", want[2]],
+ ],
+ );
+ // Dropping the top site does not repaint the survivors.
+ const { series: rest } = buildTimeSeries(s, state("site", { excludeTop: true }));
+ assert.deepEqual(
+ rest.map((x) => [x.id, x.color]),
+ [
+ ["alpha", want[0]],
+ ["gamma", want[2]],
+ ],
+ );
+});
+
+test("the site leaderboard keeps each site's own colour after the rank sort", () => {
+ const s = summary();
+ const want = new Map(s.sites.map((x, i) => [x.siteId, siteChartColors(s.sites)[i]]));
+ const { rows } = buildRanking(s, state("site", { chartType: "barH" }), 12);
+ // Ranked by window total: beta 10, gamma 6, alpha 2.
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.value, r.color]),
+ [
+ ["beta", 10, want.get("beta")],
+ ["gamma", 6, want.get("gamma")],
+ ["alpha", 2, want.get("alpha")],
+ ],
+ );
+});
+
+test("the channel leaderboard is still coloured by rank", () => {
+ const { rows } = buildRanking(summary(), state("channel", { chartType: "barH" }), 12);
+ // Ranked: c2 18, c3 4, c1 1 — chart-1, chart-2, chart-3 by place.
+ assert.deepEqual(
+ rows.map((r) => [r.id, r.value, r.color]),
+ [
+ ["c2", 18, seriesColor(0)],
+ ["c3", 4, seriesColor(1)],
+ ["c1", 1, seriesColor(2)],
+ ],
+ );
+});
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/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,71 @@ 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)");
+});
+
+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
@@ -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 { siteChartColors, 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,26 @@ 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 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,
+): { 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>[] {
@@ -200,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: p.site.accent || p.figures?.accent || seriesColor(p.at),
+ accent: siteColor(colourOf(p.site, p.figures), chart[p.at]),
})),
...unnamed.map((p, k) => ({
site: p.site,
figures: null,
- accent: p.site.accent || seriesColor(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
@@ -0,0 +1,126 @@
+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";
+
+// ── 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 }, seriesColor(3)),
+ `var(--swatch-${id})`,
+ );
+ }
+ // The id wins even without the hex (a hand-built summary).
+ 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: 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 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" }, 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
@@ -0,0 +1,88 @@
+// 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.
+//
+// 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:
+// 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, type AccentId } from "./brand";
+import { seriesColor } from "./homepageChart";
+
+export type SiteColorSource = {
+ accent?: string;
+ accentId?: 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) ?? 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/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **The homepage's Official Instances cards, and the hub's official instances, wear each site's own accent on every ground.** A card's left stripe is the accent set on the site's form, at that accent's value for the reader's ground (Light, Sepia or Dark) rather than its dark value everywhere. A site with a custom hex wears the hex, and a site with none keeps the chart colour it wore. On the hub the same colour marks the card, the scope chip, the result cards' left edge and the channel group. The homepage's growth chart, its legend and the `/stats` charts and cards draw each site in the chart colour of its accent's hue family: Brass in the chart's amber, Blue in its blue, Violet in its violet, Green in its green and Sakura in its magenta. They do not use the accents themselves, because the seven accents cannot tell five sites apart on a chart: Brass and Vermilion look alike to a red-green colour-blind reader, and Blue and Violet to nearly everyone. So a card and its chart layer are the same hue. A site with no accent keeps its chart colour on both. Vermilion, Signal or a custom hex have no chart family, so such a site gets a chart colour no other site wears. On `/stats` the By-site leaderboard now keeps each site's colour instead of colouring by rank. `homepage-summary.json` and `hub-summary.json` gain an optional `accentId` per site beside the published hex (still versions 5 and 1). Needs the sites' accents set, then a rebuild and deploy of the homepage and the hub.
+
## [0.9.1] - 2026-09-26
- **umtool's report videos can wear the Archilyzer Media brand, and the channel's YouTube picture, watermark and banner are generated.** A report video whose manifest says `"render": { "brand": "archilyzer-media" }` (umtool's **new project** form now has a brand choice, and `umtool new` takes `--brand archilyzer-media`) is drawn in the channel's slate-and-teal palette and its three faces, with a title card that carries the Archilyzer Media lockup and the found line, the mark at the head of every clip's citation line, a 20-second end card whose right half is left empty for YouTube's end-screen videos (`render.endCard` changes its length or, with `false`, drops it), and a 1280 × 720 thumbnail from a clip still and a headline (`thumbnail` in the manifest; `build-video.mjs --thumbnail` makes it alone). A manifest without the key renders exactly as before, byte for byte. `pnpm --filter yt-dlp-transcript-common exec tsx bin/brand-media.ts` writes the channel picture, the video watermark, the banner and a preview of its phone crop to `~/reports/archilyzer-media/brand/`, with an `INDEX.html` that walks through the YouTube Studio upload. The fonts (Archivo, IBM Plex Sans, IBM Plex Mono) ship in `umtool/report-to-video/fonts/` under the SIL Open Font License; nothing is installed system-wide.
- **YouTube's "try again later" block now backs off instead of reading as a deleted video.** When YouTube rate-limits a session it answers "This content isn't available, try again later." The editor read that as a removed video: the download moved straight on to the next video (into the same block), no cooldown was recorded, and the video was set aside as deleted, so later download runs skipped it. It is now handled like an HTTP 429. A download stops its batch and records the platform cooldown that the auto-download runner and Sync honour, and the runner defers the video for 6 hours. A metadata scan stops (retrying once with cookies when the channel has them) instead of recording the video as gone. The availability check records a temporary error instead of "deleted", and stops probing for the rest of that run. Every rate limit also ends things sooner now: a download whose metadata request is refused no longer goes on to try the download itself, and the availability check stops at the first refused probe instead of asking about every remaining video. When the check that runs before cleaning audio is cut short this way, or has no link to check a video by, the cleanup keeps the audio of every video it did not reach, rather than trusting what an older check said about it. Videos that really are gone ("Video unavailable", removed by the uploader, a terminated account, Rumble's 410) are still recorded as deleted.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **The search bar's "Press Enter or click Search to apply" is in the site's accent.** It was the warning yellow on every site. It now follows the site's accent, or the one the reader picked, at that accent's value for the reader's ground (Light, Sepia or Dark), like the site's links, and reads at 4.5:1 or better on each. The words are unchanged. Needs a rebuild and deploy of every export site and the hub.
+
## [0.9.1] - 2026-09-26
- **The hub lists its official instances in the homepage's order, and each wears one colour everywhere.** The cards and the scope chips follow the homepage (most transcripts first) instead of `hub-sites.json`'s alphabetical order. An archive whose site sets no accent wears its homepage card's colour, and now on every surface: its card, its chip's dot, the left edge of its search results and its channel group. Before, it had a card stripe and nothing else. The hub waits for `/hub-summary.json`, which is requested together with the list, before it lists the official instances, so they never reorder a moment later.
- **The hub's Ask AI searches only the archives the scope chips leave in.** An archive switched off on the hub's front page is not fetched on `/ask` either, so the chat answers from what the search showed. It still waits for every archive in scope before it answers. With every archive switched off, the question box is disabled and one line under it says why. Before, a question went out over no records at all.
diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -15,14 +15,16 @@
// descriptor gave. The live federation total is HubStats, under the shelf.
//
// The official cards come in the homepage's order (the summary's `sites`, the
-// order its ArchiveCards and growth chart draw in), and each wears its
-// archive's OWN accent when the site sets one (the tool has no colour, the
-// archives do); one that sets none wears seriesColor() at its index in the
-// summary, the colour of its homepage card. Order and colour are useHubSites'
-// (officialInstances), shared with the scope chips, the results' stripes and
-// /ask, so an archive is one colour on every surface. With no summary the order
-// is hub-sites.json's and the colour the card's place. An added archive with
-// no accent wears the family's signal colour.
+// order its ArchiveCards and growth chart draw in), and each wears the colour
+// its homepage card wears (lib/siteColor.ts): its archive's OWN accent when the
+// site sets one (the tool has no colour, the archives do) — a named accent at
+// its value on the base in force — and its homepage chart colour when it sets
+// none (siteChartColors: the palette slot at its index in the summary, or the
+// lowest free one when an accent's family holds that). Order and colour are
+// useHubSites' (officialInstances), shared with the scope chips, the results'
+// stripes and /ask, so an archive is one colour on every surface. With no
+// summary the order is hub-sites.json's and the colour the card's place. An
+// added archive with no accent wears the family's signal colour.
import { X } from "lucide-react";
import { Badge } from "yt-dlp-transcript-common/components/ui/badge";
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -4,14 +4,15 @@
// the scope chips, the search (its result stripes, chip dots and channel groups)
// and /ask: ONE order and ONE colour per archive, decided here.
//
-// The official instances come in the homepage's order, in the homepage's
-// colours unless a site sets its own accent (officialInstances,
-// common/lib/hubSummary.ts). That order is /hub-summary.json's, so they are
-// listed only once that file has settled — found, missing or unreadable —
-// rather than in hub-sites.json's order first and reordered a moment later.
-// Both files are small, same-origin and requested together, so the wait is the
-// gap between two requests already in flight. The archives a visitor added
-// follow, in the order they were added, each in its own accent or none.
+// The official instances come in the homepage's order, each in the colour its
+// homepage card wears — its own accent (a named one per base), else its chart
+// colour (officialInstances, common/lib/hubSummary.ts). That order is
+// /hub-summary.json's, so they are listed only once that file has settled —
+// found, missing or unreadable — rather than in hub-sites.json's order first
+// and reordered a moment later. Both files are small, same-origin and
+// requested together, so the wait is the gap between two requests already in
+// flight. The archives a visitor added follow, in the order they were added,
+// each in its own accent or none.
import { useMemo } from "react";
import {
diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { ACCENTS } from "../../common/lib/brand";
// The hub's "Official Instances": one card per built-in member
// (hub-sites.json), carrying the homepage's own figures for it when the hub was
@@ -21,13 +22,14 @@ async function fulfillJson(route: Route, body: unknown) {
});
}
-async function stubMember(page: Page) {
+async function stubMember(page: Page, { accent }: { accent?: string } = {}) {
await page.route("**/hub-sites.json", (r) =>
fulfillJson(r, [
{
siteId: "member",
siteTitle: "Member Archive",
siteUrl: MEMBER,
+ ...(accent ? { accent } : {}),
pwa: false,
contract: 1,
},
@@ -148,4 +150,41 @@ test.describe("hub official instances", () => {
await expect(card.getByText("Data-only")).toBeVisible();
await expect(card.locator("dl")).toHaveCount(0);
});
+
+ // Release 10: a NAMED accent is painted at its value on the base in force.
+ // hub-sites.json carries only its on-dark hex; hub-summary.json names the
+ // accent (`accentId`), and the card wears `var(--swatch-<id>)`.
+ test("a named accent wears its own value on each base", async ({ page }) => {
+ await stubMember(page, { accent: ACCENTS.brass.onDark });
+ await page.route("**/hub-summary.json", (r) =>
+ fulfillJson(r, {
+ ...SUMMARY,
+ sites: [
+ { ...SUMMARY.sites[0], accent: ACCENTS.brass.onDark, accentId: "brass" },
+ ],
+ }),
+ );
+ const stripe = page
+ .getByTestId("shelf-spine")
+ .filter({ hasText: "Member Archive" })
+ .locator(":scope > span[aria-hidden='true']");
+ const colour = () =>
+ stripe.evaluate((el) => getComputedStyle(el).backgroundColor);
+ const rgb = (hex: string) =>
+ `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
+
+ // The hub opens dark.
+ await page.goto("/");
+ await expect(page.locator("html")).toHaveAttribute("data-base", "dark");
+ await expect.poll(colour).toBe(rgb(ACCENTS.brass.onDark));
+
+ for (const base of ["light", "sepia"] as const) {
+ await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base);
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-base", base);
+ await expect
+ .poll(colour)
+ .toBe(rgb(base === "light" ? ACCENTS.brass.onLight : ACCENTS.brass.onSepia));
+ }
+ });
});
diff --git a/export/e2e/theme-accent.spec.ts b/export/e2e/theme-accent.spec.ts
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "@playwright/test";
-import { ACCENTS } from "../../common/lib/brand";
+import { ACCENTS, MIN_ACCENT_CONTRAST, contrastRatio } from "../../common/lib/brand";
import { resolveAccent } from "../../common/lib/accent";
+import { installRoutes } from "./helpers";
// The ThemeMenu's two radio groups. A site opens in its OWN accent (the
// fixture's site.json sets the custom hex #cc3366, so the layout renders
@@ -139,3 +140,52 @@ test("picking the site's own colour again removes the stored accent", async ({
await pick(page, "Dark");
await expect.poll(() => state(page)).toMatchObject({ base: "dark", brand: FIXTURE.dark });
});
+
+// The search bar's "Press Enter or click Search to apply" wears the site's
+// accent (release 10). It was `text-warning`, the same yellow on every site.
+// It follows the reader's accent and base like any `text-brand`, and stays
+// readable on the page ground it sits on.
+test("the unapplied-search hint wears the accent, readable on each base", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await page.emulateMedia({ colorScheme: "light" });
+ await page.goto("/");
+ const hint = page.getByText("Press Enter or click Search to apply");
+ // Typing is an unapplied edit (the input mounts after hydration).
+ await page.locator('input[data-testid^="leaf-query-"]').first().fill("alpha");
+ await expect(hint).toBeVisible();
+ await expect(hint).toHaveClass(/(^|\s)text-brand(\s|$)/);
+ await expect(hint).not.toHaveClass(/text-warning/);
+
+ // The hint's colour and the ground under it, as "#rrggbb".
+ const paint = () =>
+ hint.evaluate((el) => {
+ const hex = (rgb: string) =>
+ "#" +
+ (rgb.match(/\d+/g) ?? [])
+ .slice(0, 3)
+ .map((n) => Number(n).toString(16).padStart(2, "0"))
+ .join("");
+ return {
+ color: hex(getComputedStyle(el).color),
+ ground: hex(getComputedStyle(document.body).backgroundColor),
+ };
+ });
+ const expectAccent = async (value: string) => {
+ await expect.poll(async () => (await paint()).color).toBe(value);
+ const { color, ground } = await paint();
+ expect(contrastRatio(color, ground)).toBeGreaterThanOrEqual(MIN_ACCENT_CONTRAST);
+ };
+
+ // The fixture site's own (custom) colour, fitted to the light ground…
+ await expectAccent(FIXTURE.light);
+ // …a reader's pick…
+ await pick(page, "Brass");
+ await expectAccent(ACCENTS.brass.onLight);
+ // …and each base's own value of it.
+ await pick(page, "Sepia");
+ await expectAccent(ACCENTS.brass.onSepia);
+ await pick(page, "Dark");
+ await expectAccent(ACCENTS.brass.onDark);
+});
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -18,6 +18,15 @@
`<head>` gains `icon-32.png`. The OG image is still `/icons/icon-512.png`.
- **The footer no longer links to Ko-fi.** The "Elsewhere" column is the operator's social
icons again, and a homepage with none shows no column.
+- **The Official Instances cards wear each site's accent, and the chart wears its hue.** A
+ card's left stripe is its site's accent at that accent's value for the reader's ground; a
+ custom hex is shown as is, and a site with no accent keeps its chart colour. The growth
+ chart, its legend and `/stats` draw each site in the validated chart colour of its accent's
+ hue family (`siteChartColors`, `common/lib/siteColor.ts`: Brass → amber, Blue → blue,
+ Violet → violet, Green → green, Sakura → magenta), since the accents themselves fail as a
+ chart palette. No two sites share a colour. The `/stats` By-site leaderboard keeps each
+ site's colour instead of colouring by rank. The e2e reads a copy of the summary with fixture
+ accents (`HOMEPAGE_SUMMARY_FILE`, `e2e/fixture-accents.ts`).
## 2026-08-12
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -1,15 +1,26 @@
import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary";
-import { seriesColor } from "yt-dlp-transcript-common/lib/homepageChart";
+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
// page's copy rule is no subtitles unless needed, and the name plus the
-// numbers say what the archive is. The stripe is seriesColor(i), the same index the
-// growth chart colours that instance's layer with, so a card is its own legend.
+// numbers say what the archive is.
//
-// A site's `accent` is deliberately NOT used here any more: the chart cannot
-// wear a per-site brand hex (it would break the validated order and dark mode),
-// and a card whose stripe disagrees with its layer is worse than no stripe.
+// 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).
+//
+// 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.
@@ -28,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) => (
@@ -38,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: seriesColor(i) }}
+ style={{ backgroundColor: siteColor(site, chart[i]) }}
/>
<h3 className="font-display text-lg font-semibold text-[var(--foreground)]">
<a
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,14 +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`, the same index the instance cards use.
-// 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).
+//
+// 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;
@@ -79,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);
@@ -90,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")}`,
};
@@ -125,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/app/lib/summary.test.ts b/homepage/app/lib/summary.test.ts
@@ -0,0 +1,24 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { summaryFile } from "./summary";
+
+// Run with:
+// pnpm --filter homepage test
+
+const PUBLIC = "/repo/homepage/public/homepage-summary.json";
+
+test("a production build never reads HOMEPAGE_SUMMARY_FILE", () => {
+ assert.equal(
+ summaryFile({ NODE_ENV: "production", HOMEPAGE_SUMMARY_FILE: "/tmp/fixture.json" }, PUBLIC),
+ PUBLIC,
+ );
+});
+
+test("outside production (the e2e's next dev) it names the file to read", () => {
+ assert.equal(
+ summaryFile({ NODE_ENV: "development", HOMEPAGE_SUMMARY_FILE: "/x/.e2e-summary.json" }, PUBLIC),
+ "/x/.e2e-summary.json",
+ );
+ assert.equal(summaryFile({ NODE_ENV: "development" }, PUBLIC), PUBLIC);
+ assert.equal(summaryFile({ NODE_ENV: "development", HOMEPAGE_SUMMARY_FILE: "" }, PUBLIC), PUBLIC);
+});
diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts
@@ -18,7 +18,10 @@ import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSumma
// never at request time.
export function loadSummary(): HomepageSummary | null {
try {
- const file = path.join(process.cwd(), "public", "homepage-summary.json");
+ const file = summaryFile(
+ process.env,
+ path.join(process.cwd(), "public", "homepage-summary.json"),
+ );
const parsed = JSON.parse(
fs.readFileSync(file, "utf8"),
) as HomepageSummary;
@@ -30,3 +33,16 @@ export function loadSummary(): HomepageSummary | null {
return null;
}
}
+
+// The file loadSummary reads: `public/homepage-summary.json`, or the file
+// HOMEPAGE_SUMMARY_FILE names — but ONLY outside a production build. The e2e's
+// `next dev` points it at a copy with fixture accents (playwright.config.ts);
+// `next build` runs with NODE_ENV=production and ignores it, so a variable left
+// in a build shell can never ship a fixture as the homepage's numbers.
+export function summaryFile(
+ env: Readonly<Record<string, string | undefined>>,
+ publicFile: string,
+): string {
+ const override = env.NODE_ENV !== "production" ? env.HOMEPAGE_SUMMARY_FILE : undefined;
+ return override || publicFile;
+}
diff --git a/homepage/e2e/fixture-accents.ts b/homepage/e2e/fixture-accents.ts
@@ -0,0 +1,43 @@
+import fs from "node:fs";
+import { ACCENTS } from "../../common/lib/brand";
+
+// The instance cards' colours (release 10) need sites with known accents, and
+// the summary on disk is the operator's: its sites may set none at all. So the
+// e2e dev server reads a COPY of it (HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts)
+// with these accents laid over its first sites — a named accent, a custom hex,
+// then no accent — and every other number untouched. playwright.config.ts
+// writes it before the server starts; the specs import the same constants.
+
+// In the summary's order: site 0, site 1; every later site has none.
+export const FIXTURE_ACCENTS = [
+ { accent: ACCENTS.brass.onDark, accentId: "brass" as const },
+ { accent: "#cc3366" },
+] as const;
+
+export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json";
+
+// Write the fixture copy of `source` to `dest`. False (and any stale copy
+// removed) when there is no readable summary with sites: a source-only
+// checkout, whose data specs skip. The write is a rename, because Playwright
+// evaluates its config again in every worker it starts, while the dev server
+// may be reading the file.
+export function writeFixtureSummary(source: string, dest: string): boolean {
+ let summary: { sites?: Array<Record<string, unknown>> } | null = null;
+ try {
+ summary = JSON.parse(fs.readFileSync(source, "utf8"));
+ } catch {
+ summary = null;
+ }
+ if (!Array.isArray(summary?.sites) || summary.sites.length === 0) {
+ fs.rmSync(dest, { force: true });
+ return false;
+ }
+ summary.sites = summary.sites.map((site, i) => {
+ const { accent: _a, accentId: _id, ...rest } = site;
+ return { ...rest, ...(FIXTURE_ACCENTS[i] ?? {}) };
+ });
+ const tmp = `${dest}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(summary));
+ fs.renameSync(tmp, dest);
+ return true;
+}
diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts
@@ -0,0 +1,129 @@
+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 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";
+
+// The fixture copy the dev server reads: its sites, and whether it has the
+// monthly series the growth chart (and its legend) is drawn from.
+function fixture(): { sites: Array<{ accentId?: string }>; chart: boolean } | null {
+ try {
+ const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME);
+ const s = JSON.parse(fs.readFileSync(file, "utf8"));
+ if (!Array.isArray(s?.sites) || s.sites.length === 0) return null;
+ return { sites: s.sites, chart: Array.isArray(s.monthly) && s.monthly.length > 0 };
+ } catch {
+ return null;
+ }
+}
+
+// The colour a CSS colour resolves to on this page, as the browser reports it.
+const resolve = (page: Page, css: string) =>
+ page.evaluate((c) => {
+ const el = document.createElement("span");
+ el.style.backgroundColor = c;
+ document.body.append(el);
+ const out = getComputedStyle(el).backgroundColor;
+ el.remove();
+ 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 }),
+ });
+ const stripes = await section
+ .locator("li > span[aria-hidden='true']")
+ .evaluateAll((els) => els.map((el) => getComputedStyle(el).backgroundColor));
+ const legend = await page
+ .locator("figure ul[aria-hidden='true'] li > span")
+ .evaluateAll((els) => els.map((el) => getComputedStyle(el).backgroundColor));
+ return { stripes, legend };
+}
+
+async function useBase(page: Page, base: "dark" | "light" | "sepia") {
+ await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]);
+ await page.reload();
+ await expect(page.locator("html")).toHaveAttribute("data-base", base);
+}
+
+test("each instance card wears its site's accent on every base, in its chart layer's hue", async ({
+ page,
+}) => {
+ const f = fixture();
+ test.skip(!f, "no summary on disk — the page has no instance cards");
+ const { sites, chart: hasChart } = f!;
+ 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 = {
+ dark: ACCENTS.brass.onDark,
+ light: ACCENTS.brass.onLight,
+ sepia: ACCENTS.brass.onSepia,
+ } as const;
+ for (const base of ["dark", "light", "sepia"] as const) {
+ await useBase(page, base);
+ const { stripes, legend } = await colours(page);
+ expect(stripes).toHaveLength(n);
+
+ // The summary's monthly series draws the chart, legend and all: the
+ // legend (and so each layer) wears each site's chart colour. A summary
+ // with none draws no chart at all.
+ await expect(
+ page.getByRole("img", { name: /transcripts by the month/i }),
+ ).toHaveCount(hasChart ? 1 : 0);
+ expect(legend, "legend swatches").toHaveLength(hasChart ? n : 0);
+ for (let i = 0; i < legend.length; 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]));
+ expect(await resolve(page, chart[0])).toBe(await resolve(page, "var(--chart-4)"));
+ expect(hueGap(stripes[0], await resolve(page, chart[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, chart[i]));
+ if (hasChart) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]);
+ }
+ }
+});
diff --git a/homepage/package.json b/homepage/package.json
@@ -13,6 +13,7 @@
"build:nodata": "next build",
"start": "serve out -l ${HOMEPAGE_PORT:-3031}",
"lint": "eslint",
+ "test": "tsx --test \"app/**/*.test.ts\"",
"e2e": "node ../scripts/queue-lock.mjs --ports HOMEPAGE_E2E_PORT:3040 -- playwright test",
"e2e:ui": "playwright test --ui",
"deploy": "tsx ../common/bin/archilyzer.ts deploy homepage"
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -1,4 +1,9 @@
+import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
+import {
+ FIXTURE_SUMMARY_NAME,
+ writeFixtureSummary,
+} from "./e2e/fixture-accents";
// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted
// source — /stats/ reads its build-time summary from public/ on disk, which is
@@ -11,6 +16,16 @@ import { defineConfig, devices } from "@playwright/test";
const PORT = Number(process.env.HOMEPAGE_E2E_PORT ?? 3040);
const baseURL = `http://localhost:${PORT}`;
+// The dev server reads a copy of the summary on disk with fixture accents on
+// its first sites (e2e/fixture-accents.ts), so the instance cards' colours are
+// exact whatever the operator's sites set. Gitignored; rewritten every run. No
+// summary on disk, no copy: the server reads public/ as before and finds none.
+const FIXTURE_SUMMARY = path.resolve(process.cwd(), "e2e", FIXTURE_SUMMARY_NAME);
+const hasFixtureSummary = writeFixtureSummary(
+ path.resolve(process.cwd(), "public", "homepage-summary.json"),
+ FIXTURE_SUMMARY,
+);
+
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
@@ -24,6 +39,7 @@ export default defineConfig({
url: baseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
+ env: hasFixtureSummary ? { HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY } : {},
},
use: {
baseURL,
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -6701,7 +6701,16 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
by their index in `summary.sites` (tiebreak: the `hub-sites.json` order), then appends any member
the summary does not name. Colour: the site's accent, else the summary's, else
`seriesColor(index in the summary)` — the homepage card's colour; an unnamed member takes
- `seriesColor(listed + k)`, so it never shares a named one's. `useHubSites` lists the official
+ `seriesColor(listed + k)`, so it never shares a named one's. Since release 10's accent slice
+ it goes through `siteColor` (`common/lib/siteColor.ts`): a NAMED accent is
+ `var(--swatch-<id>)` (per base) from the summary's `accentId`, used only when `hub-sites.json`'s
+ hex (on-dark only) is absent or equal to the summary's; the homepage's `ArchiveCards` use the
+ same helper. With no accent the fallback is the site's CHART colour, `siteChartColors(sites)`:
+ the `--chart-N` of its accent's hue family (blue 1, green 2, violet 3, brass 4, sakura 5), else
+ `seriesColor(i)` when free, else the lowest free slot — never two sites in one colour. The growth
+ chart, its legend and `/stats` (`SiteGrid`, `homepageChartData`, the By-site leaderboard) wear
+ it; the accents themselves fail the dataviz validator as a chart palette (no five with Brass
+ and Blue pass). `useHubSites` lists the official
instances only once `/hub-summary.json` has SETTLED (found, missing or unreadable;
`useHubSummary.ts:45`, `networkMode: "always"`), so they never reorder a moment later. The order
is applied in the browser: `hub-sites.json` on disk is unchanged.
diff --git a/plans/release-10.md b/plans/release-10.md
@@ -1849,6 +1849,140 @@ least C1b's text link) until they are rebuilt and redeployed; no site was ever d
so their release 10 rebuild simply does not add it. An installed PWA picks up the worker change on
its next update check, and no data cache moves.
+### Slice AC, as shipped — accent colours (2026-09-27)
+
+The operator's asks (2026-09-27): every site gets a unique accent (Archilyzer's bone mark is not one;
+**Jeralyzer = Brass**, **Hasanalyzer = Blue**, the rest the parent's pick); the search bar's
+"Press Enter or click Search to apply" in the site's accent instead of `text-warning`; the
+homepage's official-instance cards show each site's accent in their left stripe. Branch
+`r10/accent-colors` off `main` `154817f6`, worktree `/home/user/Projects/r10-accent-colors`
+(block #11), one Opus implementer. Scratch files `ac-*` in the job's `tmp/brand`.
+
+**The palette: no assignment of the accents passes, so the chart wears their hue families.** Validated first with the
+dataviz skill's `validate_palette.js`, `--pairs all` (the homepage order moves with transcript
+counts), each base's own swatch values against its `--chart-surface`, contrast also against the
+page ground; all ten sets {Brass, Blue} + three of {Vermilion, Violet, Sakura, Green, Signal}.
+None passes. Brass fails with Vermilion (ΔE **1.0** deutan on light/sepia), Green (1.8 protan on
+dark) and Signal; Blue with Violet (normal **8.5–9.0**, CVD 1.3–1.9) and Signal. Only Sakura passes
+with both, so no passing set has more than three colours. On dark every accent is also above the
+validator's band (L 0.70–0.79 > 0.67): they are text colours, not a categorical palette. The
+least-bad set is {Brass, Blue, Sakura, Violet, Green}: three failing pairs (Brass–Green, Blue–Violet,
+Violet–Sakura on dark only) and one floor pair (Sakura–Green). The proposed mapping, which the
+coordinator took (2026-09-27) and sets through the site form:
+
+```
+jeralyzer=brass
+hasanalyzer=blue
+anilyzer=sakura
+bonnellyzer=violet
+rekietalyzer=green
+jasolyzer=vermilion (unpublished: on neither the chart nor the hub; unique)
+```
+
+The planned Rekietalyzer = Vermilion with Bonnellyzer = Green passes every ADJACENT pair in
+today's order, but fails Brass–Vermilion and Vermilion–Green over all pairs and goes red if
+Hasanalyzer and Rekietalyzer (3,048 vs 2,855 transcripts) swap places.
+
+The slice's fallback first kept the growth chart on `seriesColor`. That made a card disagree with
+its layer: Jeralyzer's card was gold and its layer blue, Hasanalyzer's card blue and its layer amber
+(`ac-cards-*.png`, `ac-chart-*.png`). **Hue-matched chart (the coordinator's follow-up, same day):**
+the proposed set is exactly the chart palette's five hues, so `siteChartColors` draws each site in
+the `--chart-N` of its accent's hue family (blue 1, green 2, violet 3, brass → amber 4, sakura →
+magenta 5) and keeps the validated values. Each accent is 1–21° of hue from its slot, and ≥ 31° from
+any other slot. The validator on the layer colours the code resolves (`ac-layers.log`):
+
+| order | adjacent (the stack), light / sepia / dark | all pairs |
+|---|---|---|
+| today (Jer, Ani, Bon, Has, Rek) | PASS; CVD 12.3 / 11.1 / 11.8, normal 17.7 / 15.3 / 16.6 | CVD WARN 6.2 / 6.1 / 6.9, normal ≥ 15.3 |
+| Has and Rek swapped | PASS; CVD 12.3 / 11.3 / 13.7, normal 19.6 / 19.7 / 16.6 | the same set |
+
+The all-pairs WARN (6–8 floor band) is `--chart-1..5`'s own borderline in any order, legal with the
+legend, the surface-gap edges and the table. A site with Vermilion, Signal, a custom hex or no
+accent takes `seriesColor(i)` when no one holds it, else the lowest free slot: never two sites in
+one colour.
+
+**What shipped**
+- **The hint** (`common/components/SearchBar.tsx`) is `text-brand`: the site's accent, or the
+ reader's, at its value for the base, ≥ 4.5:1 on each page ground by construction
+ (`MIN_ACCENT_CONTRAST`). The editor renders no `SearchBar`; the hub's accent is Signal.
+- **`accentIdOf`** (`common/lib/accent.ts`) and an optional per-site **`accentId`** in
+ `homepage-summary.json` (still v5) and `hub-summary.json` (still v1; `parseHubSummary` keeps only
+ a known id). The published hex is unchanged.
+- **`siteChartColors(sites)`** and **`siteColor(site, chart)`** (`common/lib/siteColor.ts`): the chart
+ colour above; the mark colour is a named accent → `var(--swatch-<id>)`, a custom hex → that hex,
+ neither → its chart colour. So a family accent's card and layer share a hue, and a card with no
+ accent equals its layer.
+- **Homepage:** `ArchiveCards` stripes use `siteColor`; `ArchiveGrowthChart`'s layers and legend,
+ `/stats`' site series (`homepageChartData`) and its `SiteGrid` cards use `siteChartColors`
+ (`STACK_ORDER` unchanged). The By-site leaderboard keeps each site's colour instead of re-keying by
+ rank; channels are still rank-keyed. The header comments state the rule and the numbers.
+- **Hub:** `officialInstances` colours through `siteColor`, so the card, chip, result edge and
+ channel group wear a named accent per base. `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.
+- **e2e fixture:** the homepage dev server reads a copy of the summary on disk with fixture accents
+ on its first sites (`HOMEPAGE_SUMMARY_FILE`, set only by `homepage/playwright.config.ts`;
+ `homepage/e2e/fixture-accents.ts`, gitignored output `homepage/e2e/.e2e-summary.json`): Brass, a
+ custom hex, then none. Every other number is the real summary's.
+
+| sha | what |
+|---|---|
+| `b9b010f7` | `search:` the hint is `text-brand`; `theme-accent.spec` checks class, colour and contrast on light, sepia and dark |
+| `69f5b251` | `common:` `accentIdOf`, `accentId` in both summaries, `siteColor`, `officialInstances` through it; 8 unit tests |
+| `7667e8d5` | `homepage:` `ArchiveCards` stripes through `siteColor`; header comments; the fixture summary; `instance-colours.spec` |
+| `152ade49` | `hub:` comments; `official-instances.spec` checks a Brass member on dark, light and sepia |
+| `e39eb7ea` | `changelogs:` export, editor and homepage `[Unreleased]` (new sections above `[0.9.1]` in export and editor) |
+| `7b1e109b` | `plans:` this record; `FACTS.md`'s official-instance colour line |
+| `4719b333` | `common:` `siteChartColors`; `siteColor` falls back to the chart colour; `ArchiveCards` and the hub pass it; 7 unit tests (its commit message says 8; 7 is right) |
+| `0ce35eab` | `homepage:` the growth chart, its legend and `/stats` wear `siteChartColors`; `instance-colours.spec` checks legend = slot, Brass card within 25° of hue of its layer |
+| `18023e54` | `changelogs, plans:` the `[Unreleased]` bullets, this record and FACTS for the hue-matched chart |
+| `228f0eb8` | `homepage:` review fix — `HOMEPAGE_SUMMARY_FILE` only outside a production build (`summaryFile`); `summary.test.ts` and the homepage's `test` script |
+| `76881374` | `common:` review fix — `homepageChartData.test.ts`: a site series wears `siteChartColors`; the leaderboard keeps a site's colour by site, ranks by channel |
+| `136aa0ec` | `hub, homepage(e2e):` review nits — `ArchiveShelf`/`useHubSites` comments; `instance-colours.spec` requires the chart when the fixture has `monthly` |
+| _this_ | `plans:` the review fixes in this record |
+
+**Gates** (logs `ac-*.log`):
+- **tsc** clean on the code tree (33 s) before the four code commits, which were staged from it
+ with no type dependency on the unstaged rest; the later commits are Markdown only.
+- **common 2,021/2,021** (2,013 + 8), editor unit **85/85**, `test:scripts` **174 + 1 skipped**,
+ mcp **269/269**.
+- **Builds:** editor ok (42 s); export site ok (25 s); export hub ok (28 s, `out/ask` present);
+ homepage ok (14 s; its `public/` data copied from the primary; with the real summary, which sets
+ no accents, the stripes are `var(--chart-1..5)`, as before). `export/public` linked per path; the
+ primary's `export/public` byte-identical before and after every run.
+- **e2e** (no queue wait): export full **205 passed** (8.2 min), `e2e:hub` **25 passed** (1.1 min),
+ homepage **28 passed** (46 s). **They bite:** with the three guarded files at `154817f6`, each new
+ test fails for its reason (the card stripe `rgb(53, 97, 200)` = `--chart-1`, not Brass; the hint
+ `text-warning`; the hub stripe on light still Brass's on-dark hex).
+- **Visual:** the homepage built with the five real sites carrying the proposed accents, on dark,
+ light and sepia at 1280 (`ac-cards-*.png`, `ac-chart-*.png`): each stripe is the accent's value
+ for its base, and the chart is unchanged.
+- **Hue-matched chart, on `0ce35eab`:** tsc clean (66 s); common **2,028/2,028** (+7); homepage build
+ ok (15 s); homepage e2e full **28 passed** (54.7 s); `e2e:hub` **25 passed** (57.8 s; the hub's
+ fallback now reads the chart colours). `instance-colours.spec` bites: with `ArchiveGrowthChart` at
+ `7b1e109b` the legend's first swatch is `--chart-1` (`rgb(53, 97, 200)`), not the amber slot.
+ Screenshots `ac2-cards-*.png`, `ac2-chart-*.png`: every card and its layer are one hue on dark,
+ light and sepia.
+- **Review fixes, on `136aa0ec`** (review `ac-review.md`: SHIP AFTER FIXES): tsc clean (35 s); common
+ **2,031/2,031** (+3, the `homepageChartData` cases; the first two fail on `7b1e109b`'s file);
+ homepage unit **2/2** (new script); homepage build ok (16 s), and a build with a stray
+ `HOMEPAGE_SUMMARY_FILE` pointing at the proposed-accents summary still renders the real summary
+ (stripes `var(--chart-1..5)`); homepage e2e full **28 passed** (38.6 s); `e2e:hub` **25 passed** (57.7 s; hub
+ comments changed).
+
+**Found and left** (review lows, recorded, not fixed):
+- **A sixth published site would nearly repeat Anilyzer's magenta.** Every chart slot is now held by
+ a family, so a sixth site falls past `--chart-5` to `seriesColor(5)` = `hsl(328 64% 55%)`
+ (`rgb(214, 67, 145)`), within a degree of hue of `--chart-5` (`#c24a8a`). The strings differ, so
+ `siteChartColors`' uniqueness holds, but the colours do not read apart. Owed before Jasolyzer
+ (Vermilion) gets a `siteUrl`: a sixth validated slot, or a vermilion chart family.
+- **A custom hex is painted unfitted on every base** on the homepage cards (and the hub, as before):
+ a pale hex would be faint on light and sepia. No live site uses one; `resolveAccent` already fits
+ one per base if it is ever needed here.
+
+**Left.** The accents themselves are the parent's (site form); until they are set, every stripe is
+its chart colour, as today. Rollout: every export site and the hub rebuilt for the hint; the
+homepage and the hub rebuilt after the accents are set.
+
## Rollout
Nothing is rolled out, except that **Jeralyzer is already on the brand, in Signal** (a build-deploy