Archilyzer · Source

archilyzer

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

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

homepage: the growth chart and /stats wear each site's hue family; a card matches its layer by hue

ArchiveGrowthChart's layers and legend, /stats' site series (homepageChartData)
and its SiteGrid cards all read siteChartColors. The leaderboard keeps a site's
own colour in the By-site breakdown instead of re-keying by rank (channels
still rank-keyed): colour follows the entity, and the cards are the legend.
STACK_ORDER is unchanged.

Validated on the layer colours the code resolves, with the operator's accents:
adjacent in today's order PASS on every base (CVD 12.3 / 11.1 / 11.8, normal
17.7 / 15.3 / 16.6, light / sepia / dark), and with Hasanalyzer and
Rekietalyzer swapped (CVD 12.3 / 11.3 / 13.7, normal 19.6 / 19.7 / 16.6); all
pairs is the palette's own borderline (CVD 6.2 / 6.1 / 6.9, normal >= 15.3).

instance-colours.spec: the legend wears siteChartColors; the Brass card's
legend swatch is --chart-4, within 25 degrees of hue of the stripe; a card
with no accent equals its legend swatch (site 3 of five takes the lowest free
slot, Brass holding its own).

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

Diffstat:
Mcommon/lib/homepageChartData.ts | 17+++++++++++++----
Mhomepage/app/components/ArchiveGrowthChart.tsx | 55++++++++++++++++++++++++++++++-------------------------
Mhomepage/app/components/SiteGrid.tsx | 18+++++++++---------
Mhomepage/e2e/instance-colours.spec.ts | 70+++++++++++++++++++++++++++++++++++++++++++++++++---------------------
4 files changed, 101 insertions(+), 59 deletions(-)

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