Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 5770fd6a060a692ffcdb5d5ba6aee8a2e9daafe8
parent 5446f83b25e10ab61faffa0d857f7ca5a7a4d1bb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 27 Sep 2026 00:45:04 -0400

common: siteChartColors — a site's chart colour is its accent's hue family in the validated palette

The coordinator took the proposed accents and asked for the hue-matched chart.
siteChartColors(sites) gives each site a slot of --chart-1..5 (blue, green,
violet, amber, magenta on every base) by its named accent's hue family:
blue -> 1, green -> 2, violet -> 3, brass -> 4, sakura -> 5. Vermilion, Signal,
a custom hex and no accent have none and take seriesColor(i). No two sites
share a colour: the first of a family keeps its slot; the rest take their own
index when free, else the lowest free slot. With no accents it is exactly
seriesColor(0..n-1), as before.

siteColor(site, chart) now falls back to that chart colour, so a card with no
accent equals its layer and a family accent's card shares its layer's hue
(1-21 degrees apart; the nearest other slot is >= 31). ArchiveCards and the
hub's officialInstances pass it (the hub over the summary's sites, unnamed
members after them). 8 more unit tests, including 500 random mixes: never two
sites in one colour, and the first of each family always on its slot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/lib/homepageChart.ts | 4+++-
Mcommon/lib/hubSummary.test.ts | 17+++++++++++++++++
Mcommon/lib/hubSummary.ts | 28+++++++++++++++++-----------
Mcommon/lib/siteColor.test.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcommon/lib/siteColor.ts | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcommon/styles/tokens.css | 4+++-
Mhomepage/app/components/ArchiveCards.tsx | 27++++++++++++++-------------
7 files changed, 231 insertions(+), 57 deletions(-)

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