Archilyzer · Source

archilyzer

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

commit 2ad584c08ea151347bf72161b6c1d4283d61686c
parent 1245a42a7769b4196a0331b9d11b9da5086cb23f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 23:07:25 -0400

Merge r14/chart-fold (release 14 slice CF) — the homepage growth chart draws the instances under 5 % of the total together as one Other band on top, in a dedicated neutral (--chart-other) that clears the colour-blind floor on both grounds; a lone small instance is not folded; the legend shows the kept instances and Other, the hover titles and the year table still name every instance; reviewed SHIP

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/styles/tokens.css | 17+++++++++++++++++
Mhomepage/CHANGELOG.md | 1+
Mhomepage/app/components/ArchiveGrowthChart.tsx | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mhomepage/app/lib/growthGaps.test.ts | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mhomepage/app/lib/growthGaps.ts | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mhomepage/e2e/fixture-summary.ts | 5++++-
Mhomepage/e2e/growth-chart.spec.ts | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mhomepage/e2e/instance-colours.spec.ts | 56+++++++++++++++++++++++++++++++++++++++++++++-----------
Mhomepage/e2e/marketing.spec.ts | 4+++-
Mplans/FACTS.md | 8++++++--
Mplans/release-14.md | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
11 files changed, 788 insertions(+), 63 deletions(-)

diff --git a/common/styles/tokens.css b/common/styles/tokens.css @@ -255,6 +255,15 @@ html[data-base="light"] { --chart-surface: #ffffff; --chart-grid: rgba(22, 28, 33, 0.09); --chart-axis: #55646e; + /* OTHER: the homepage growth chart's band for the sites it groups + (homepage/app/lib/growthGaps.ts), a near-neutral slate at the foot of + the palette's lightness band (OKLCH L 0.43, C 0.03). 7.36:1 on the + ground, 7.99:1 on the chart surface. Against each slot it can sit on + (the dataviz validator, normal / worst CVD): blue 21.9 / 21.4, green + 17.4 / 15.2, violet 16.2 / 13.4, amber 22.1 / 18.2, magenta 24.5 / + 9.1, rust 14.1 / 10.5 — every CVD pair clears 8; rust is the one + normal pair under 15. */ + --chart-other: #3e545c; --chart-tooltip-bg: #ffffff; } @@ -338,6 +347,14 @@ html[data-base="dark"] { --chart-surface: #16110a; --chart-grid: rgba(233, 220, 197, 0.08); --chart-axis: #8a8170; + /* OTHER, on this base: a near-neutral grey (OKLCH L 0.49, C 0.01), + the dimmest chart mark. 3.22:1 on the ground, 3.06:1 on the chart + surface (the slots: 3.37–6.46:1 on the ground). Against each slot (normal / worst CVD): blue 17.8 / 17.9, + green 19.2 / 15.7, violet 23.4 / 21.5, amber 20.3 / 18.1, magenta + 19.5 / 7.3, rust 13.5 / 9.3 — magenta in the CVD 6–8 floor band + (legal with the legend, the table and Other's place on top), rust the + one normal pair under 15. */ + --chart-other: #62625c; --chart-tooltip-bg: #1b150d; } diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md @@ -1,6 +1,7 @@ # Homepage Changelog ## [Unreleased] +- **The growth chart draws its smallest instances together as Other.** Two or more instances that each hold under 5% of the chart's total are one band, **Other**, on top of the stack, in a near-neutral grey of its own (`--chart-other`: 7.36:1 on the Light ground, 3.22:1 on the Dark one, and apart from every instance colour for colour-blind readers); an instance at exactly 5% keeps its band, and a single one under 5% is not grouped. The other instances keep their bands and their colours. The legend lists them and Other; the caption says what Other is and the chart's description names the instances in it; every month's hover title and the Numbers by year table still name every instance. At this release's numbers Hasanalyzer, Rekietalyzer and Jasolyzer are Other. The instance cards and `/stats` are unchanged. The e2e fixture's fifth site transcribes 4 a day rather than 5, so two of its six sites are grouped. - **An unlisted site is not on the homepage.** A site whose settings turn off **List on the Archilyzer homepage and hub** (`listed: false`) has no Official Instances card, chart series, `/stats` entry or recent item, is not in `channel-sites.json` or `stats/`, and the channels only it carries count in none of the numbers, the headline totals included. The summary's version is 6. The e2e fixture has a seventh, unlisted site that no page names. - **`/#instances` goes straight to Official Instances.** The section carries `id="instances"`, clear of the sticky header, and every archive's header now links there (`INSTANCES_URL` in `common/lib/project.ts`). With no sites the link lands on the top of the page. diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx @@ -3,8 +3,16 @@ import type { HomepageSummarySite, } from "yt-dlp-transcript-common/lib/homepageSummary"; import { monthLabel } from "yt-dlp-transcript-common/lib/homepageChart"; -import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; -import { PLOT_SIZES, gapSegments, growthStack, runsOf } from "../lib/growthGaps"; +import { + FOLD_PERCENT, + OTHER_LABEL, + PLOT_SIZES, + gapSegments, + growthLayers, + growthStack, + layerColors, + runsOf, +} from "../lib/growthGaps"; // 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 @@ -36,6 +44,28 @@ import { PLOT_SIZES, gapSegments, growthStack, runsOf } from "../lib/growthGaps" // The legend above the plot wears the same colours. The layers stack in the // summary's order (STACK_ORDER, unchanged). // +// OTHER (release 14, slice CF; lib/growthGaps.ts). Two or more sites each +// under 5 % of the chart's total fold into ONE band, "Other", on top of the +// stack. The kept sites keep their slots — siteChartColors runs over EVERY +// site, so a fold never repaints one — and Other wears its own near-neutral +// grey, `--chart-other` (tokens.css: Light #3e545c, Dark #62625c; 7.36 / +// 3.22:1 on the ground, 7.99 / 3.06:1 on the chart surface), not the axis +// labels' colour. The legend shows the kept sites and Other; the hover title +// and the table name every site, and the image's label and the caption say +// what Other holds. A grey is below the validator's chroma floor by +// definition (it is the de-emphasis role, not a categorical slot); against +// each slot it can sit on, Other is (normal ΔE / worst of protan and deutan, +// light · dark): +// blue 21.9 / 21.4 · 17.8 / 17.9 — the only neighbour in today's data +// (Bonnellyzer, the top kept site in every month Other has data); +// green 17.4 / 15.2 · 19.2 / 15.7; violet 16.2 / 13.4 · 23.4 / 21.5; +// amber 22.1 / 18.2 · 20.3 / 18.1; magenta 24.5 / 9.1 · 19.5 / 7.3; +// rust 14.1 / 10.5 · 13.5 / 9.3. +// Every CVD pair clears the floor (6), and on Light the target (8); Dark's +// magenta is in the 6–8 band and rust is under the normal-vision 15 on both +// bases: there the gap between the bands, Other's place on top, the legend +// and the table carry it. +// // VALIDATED (the dataviz skill's validator, each base's values on its // --chart-surface and on the page ground), with the operator's accents // (Jeralyzer Brass, Anilyzer Sakura, Bonnellyzer Blue, Hasanalyzer Violet, @@ -69,23 +99,32 @@ export function ArchiveGrowthChart({ const n = months.length; if (n === 0 || sites.length === 0) return null; - const { totals, peak, yMax, ticks, order, stack: stacked } = growthStack(months, sites); + // The bands, bottom-up: the kept sites, then Other when two or more fold. + const { + totals, + peak, + yMax, + ticks, + layers: bands, + stack: stacked, + } = growthStack(months, sites, growthLayers(months, sites)); if (peak === 0) return null; const x = (i: number) => (n === 1 ? W / 2 : (i / (n - 1)) * W); 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); + // One colour per band, in stack order (the legend's too). + const colors = layerColors(bands, sites); + const folded = bands.find((b) => b.other)?.sites.map((si) => sites[si].siteTitle) ?? []; const layers = stacked.map(({ lo, hi }, k) => { - const si = order[k]; const top = hi.map((v, i) => `${r(x(i))},${r(y(v))}`); const bottom = lo.map((v, i) => `${r(x(i))},${r(y(v))}`).reverse(); return { - site: sites[si], - color: colors[si], + key: bands[k].key, + label: bands[k].other ? OTHER_LABEL : sites[bands[k].sites[0]].siteTitle, + color: colors[k], top, area: `M${top.join("L")}L${bottom.join("L")}Z`, }; @@ -95,7 +134,7 @@ export function ArchiveGrowthChart({ const gapSets = PLOT_SIZES.map((size) => { const segments = gapSegments(stacked, yMax, size); const paths = layers.map((l, k) => ({ - key: l.site.siteId, + key: l.key, d: runsOf(segments[k]) .map((run) => `M${[...run, run.at(-1)! + 1].map((i) => l.top[i]).join("L")}`) .join(""), @@ -113,11 +152,18 @@ export function ArchiveGrowthChart({ const last = monthLabel(months[n - 1].month); const peakIdx = totals.indexOf(peak); const all = totals.reduce((a, b) => a + b, 0); + // The legend is hidden from assistive tech, so the image's label says what + // Other holds; the caption says what it is. + const otherNote = folded.length + ? `${folded.slice(0, -1).join(", ")} and ${folded.at(-1)}, each under ` + + `${FOLD_PERCENT}% of the total, are drawn together as ${OTHER_LABEL}.` + : ""; const label = `Stacked area chart of ${all.toLocaleString()} transcripts by the month each ` + `video was published, ${first} to ${last}, across ${sites.length} official ` + `instances. The busiest month is ${monthLabel(months[peakIdx].month)}, ` + - `with ${peak.toLocaleString()}.`; + `with ${peak.toLocaleString()}.` + + (otherNote ? ` ${otherNote}` : ""); // Per-year table for anyone who wants the numbers rather than the shape. const byYear = new Map<string, Record<string, number>>(); @@ -130,10 +176,10 @@ export function ArchiveGrowthChart({ return ( <figure className="flex flex-col gap-4"> <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: colors[i] }} /> - {s.siteTitle} + {layers.map((l) => ( + <li key={l.key} 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: l.color }} /> + {l.label} </li> ))} </ul> @@ -163,7 +209,7 @@ export function ArchiveGrowthChart({ /> ))} {layers.map((l) => ( - <path key={l.site.siteId} d={l.area} fill={l.color} /> + <path key={l.key} d={l.area} fill={l.color} /> ))} {/* THE SURFACE GAP (the marks spec): touching bands are parted by a 2 px gap in the colour behind the plot — the page ground, as @@ -194,6 +240,8 @@ export function ArchiveGrowthChart({ ))} {months.map((m, i) => { const w = W / n; + // Every site with data that month, by name — a folded one too: + // the title and the table are where Other's sites are found. const parts = sites .map((s) => [s.siteTitle, m.bySite[s.siteId] ?? 0] as const) .filter(([, v]) => v > 0) @@ -245,6 +293,8 @@ export function ArchiveGrowthChart({ <figcaption className="text-sm text-[var(--muted-foreground)]"> Transcripts by the month each video was published, all official instances. + {folded.length > 0 && + ` Instances under ${FOLD_PERCENT}% of the total are drawn together as ${OTHER_LABEL}.`} </figcaption> <details className="text-sm"> <summary className="cursor-pointer text-[var(--muted-foreground)] underline decoration-[var(--border-strong)] underline-offset-4 hover:text-[var(--foreground)]"> diff --git a/homepage/app/lib/growthGaps.test.ts b/homepage/app/lib/growthGaps.test.ts @@ -1,13 +1,20 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; import { buildFixtureSummary } from "../../e2e/fixture-summary"; import { GAP_PX, MIN_KEEP_PX, + OTHER_COLOR, + OTHER_KEY, PLOT_SIZES, bandAbove, + foldedSites, gapSegments, + growthLayers, growthStack, + layerColors, + ownLayers, type Band, type PlotSize, } from "./growthGaps"; @@ -83,15 +90,30 @@ function slivers(): { stack: Band[]; yMax: number } { return { stack, yMax }; } -test("the fixture summary: gaps are drawn, and no band is ever covered, at every height", () => { +test("the fixture summary, as the chart draws it (Other on top): gaps are drawn, and no band is ever covered, at every height", () => { const summary = buildFixtureSummary(); - const { stack, yMax } = growthStack(summary.monthly ?? [], summary.sites); + const months = summary.monthly ?? []; + const layers = growthLayers(months, summary.sites); + // The fixture's last two sites are under 5 %: they fold. + assert.deepEqual(foldedSites(months, summary.sites), [4, 5]); + assert.equal(layers.at(-1)!.key, OTHER_KEY); + const { stack, yMax } = growthStack(months, summary.sites, layers); let drawn = 0; + let underOther = 0; for (const size of PLOT_SIZES) { - drawn += gapSegments(stack, yMax, size).flat().length; + const segs = gapSegments(stack, yMax, size); + drawn += segs.flat().length; + // The gap under Other parts it from the top kept site; nothing sits on + // Other, so its own upper edge has none. + underOther += segs[stack.length - 2].filter((i) => bandAbove(stack, stack.length - 2, i) === stack.length - 1).length; + assert.deepEqual(segs[stack.length - 1], [], `${size.px}px: a gap along Other's top`); assert.deepEqual(covered(stack, yMax, size), [], `${size.px}px`); } assert.ok(drawn > 0, "no gap drawn at all"); + assert.ok(underOther > 0, "no gap between the top kept site and Other"); + // Unfolded, the same summary keeps its promise too. + const own = growthStack(months, summary.sites); + for (const size of PLOT_SIZES) assert.deepEqual(covered(own.stack, own.yMax, size), [], `unfolded ${size.px}px`); }); test("slivers beside large bands, with one-month spikes: no band is ever covered", () => { @@ -126,3 +148,142 @@ test("no gap along the stack's top, and none under the run length", () => { assert.deepEqual(gapSegments(stack, yMax, size), [[], []], `${size.px}px`); } }); + +// ── The fold ────────────────────────────────────────────────────────────────── +// +// A site under 5 % of the placed total (every site's transcripts over the whole +// plotted range) folds into ONE Other band on top, when two or more do. The +// range's sums are what count: `monthsOf` spreads each site's sum over three +// months, so no single month decides. + +type Named = { siteId: string; siteTitle: string; accentId?: string }; + +function sitesOf(ids: readonly string[], accents: readonly (string | undefined)[] = []): Named[] { + return ids.map((siteId, i) => ({ siteId, siteTitle: siteId.toUpperCase(), accentId: accents[i] })); +} + +function monthsOf(sites: readonly Named[], sums: readonly number[]) { + // Thirds, the remainder in the last month. + return [0, 1, 2].map((k) => ({ + bySite: Object.fromEntries( + sites.map((s, i) => [s.siteId, k < 2 ? Math.floor(sums[i] / 3) : sums[i] - 2 * Math.floor(sums[i] / 3)]), + ), + })); +} + +test("the fold with today's proportions: the three largest keep their bands, the other three are one Other band on top", () => { + // The family's shares in the 2026-09-28 summary, in its order and with its + // accents: 42.03, 38.40, 9.18, 4.33, 3.79, 2.26 %. + const sites = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); + const sums = [4203, 3840, 918, 433, 379, 226]; + const months = monthsOf(sites, sums); + assert.deepEqual(foldedSites(months, sites), [3, 4, 5]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites, l.other]), + [ + ["a", [0], false], + ["b", [1], false], + ["c", [2], false], + [OTHER_KEY, [3, 4, 5], true], + ], + ); + // Other is the sum of its sites, on top: the stack's top is every month's + // total, as before the fold. + const { stack, totals } = growthStack(months, sites, layers); + assert.deepEqual(stack[3].hi.map((v, i) => v - stack[3].lo[i]), months.map((m) => m.bySite.d + m.bySite.e + m.bySite.f)); + assert.deepEqual(stack[3].hi, totals); + // Colours: the kept sites their families' slots, Other the neutral grey. + assert.deepEqual(layerColors(layers, sites), ["var(--chart-4)", "var(--chart-5)", "var(--chart-1)", OTHER_COLOR]); + assert.equal(OTHER_COLOR, "var(--chart-other)"); +}); + +test("the threshold's edge: exactly 5 % keeps its band, just under folds", () => { + const sites = sitesOf(["a", "b", "c", "d"]); + // b and c are 999 of 20,000 (4.995 %), d exactly 1,000 (5 %). + assert.deepEqual(foldedSites(monthsOf(sites, [17_002, 999, 999, 1_000]), sites), [1, 2]); + // Two sites at exactly 5 %: neither folds. + const three = sitesOf(["a", "b", "c"]); + assert.deepEqual(foldedSites(monthsOf(three, [18_000, 1_000, 1_000]), three), []); + assert.deepEqual(growthLayers(monthsOf(three, [18_000, 1_000, 1_000]), three), ownLayers(three)); +}); + +test("a fold of one is no fold: a single site under 5 % keeps its band", () => { + const sites = sitesOf(["a", "b", "c"]); + const months = monthsOf(sites, [60, 36, 4]); + assert.deepEqual(foldedSites(months, sites), []); + assert.deepEqual(growthLayers(months, sites), ownLayers(sites)); + assert.ok(growthLayers(months, sites).every((l) => !l.other)); +}); + +test("no site under 5 %: every site keeps its band, as before the fold", () => { + const sites = sitesOf(["a", "b", "c"]); + const months = monthsOf(sites, [50, 30, 20]); + assert.deepEqual(foldedSites(months, sites), []); + const layers = growthLayers(months, sites); + assert.deepEqual(layers, ownLayers(sites)); + // The same stack as growthStack's default. + assert.deepEqual(growthStack(months, sites, layers), growthStack(months, sites)); +}); + +test("all but one under 5 %: one kept band and one Other band", () => { + const sites = sitesOf(["a", "b", "c", "d", "e", "f"]); + const months = monthsOf(sites, [80, 4, 4, 4, 4, 4]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites]), + [ + ["a", [0]], + [OTHER_KEY, [1, 2, 3, 4, 5]], + ], + ); + const { stack, totals } = growthStack(months, sites, layers); + assert.equal(stack.length, 2); + assert.deepEqual(stack[1].hi, totals); +}); + +test("a site with nothing in the plotted range is under 5 %, and folds with another", () => { + const sites = sitesOf(["a", "b", "c"]); + assert.deepEqual(foldedSites(monthsOf(sites, [96, 4, 0]), sites), [1, 2]); + // Nothing plotted at all: nothing folds. + assert.deepEqual(foldedSites(monthsOf(sites, [0, 0, 0]), sites), []); +}); + +test("every site under 5 % (over twenty sites): every site folds, one Other band", () => { + const ids = Array.from({ length: 25 }, (_, i) => `s${i}`); + const sites = sitesOf(ids); + const layers = growthLayers(monthsOf(sites, ids.map(() => 40)), sites); + assert.deepEqual( + layers.map((l) => [l.key, l.sites.length]), + [[OTHER_KEY, 25]], + ); +}); + +test("a kept site's colour is the one it wears unfolded, whoever folds", () => { + // No accents: each site's slot is its index's (siteChartColors), so a list + // of the kept sites alone would repaint d — the chart runs it over them all. + const sites = sitesOf(["a", "b", "c", "d"]); + const months = monthsOf(sites, [500, 20, 20, 460]); + const layers = growthLayers(months, sites); + assert.deepEqual( + layers.map((l) => l.key), + ["a", "d", OTHER_KEY], + ); + const unfolded = layerColors(ownLayers(sites), sites); + assert.deepEqual(unfolded, siteChartColors(sites)); + const colours = layerColors(layers, sites); + assert.deepEqual(colours, [unfolded[0], unfolded[3], OTHER_COLOR]); + assert.notEqual(colours[1], siteChartColors([sites[0], sites[3]])[1], "the kept list alone would repaint d"); + // With accents, the same: each kept site keeps its family's slot. + const named = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); + const all = siteChartColors(named); + const nm = monthsOf(named, [4203, 3840, 918, 433, 379, 226]); + const nl = growthLayers(nm, named); + assert.deepEqual( + layerColors(nl, named).slice(0, -1), + nl.slice(0, -1).map((l) => all[l.sites[0]]), + ); + // The fold never hands a kept site the grey, nor Other a site's slot. + assert.ok(!layerColors(nl, named).slice(0, -1).includes(OTHER_COLOR)); + assert.ok(!all.includes(OTHER_COLOR)); +}); diff --git a/homepage/app/lib/growthGaps.ts b/homepage/app/lib/growthGaps.ts @@ -1,16 +1,34 @@ -// THE GROWTH CHART'S SURFACE GAPS, worked out (ArchiveGrowthChart.tsx draws -// them). Pure: no React, no DOM, so a unit test can check every segment. +import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; + +// THE GROWTH CHART'S LAYERS AND SURFACE GAPS, worked out (ArchiveGrowthChart.tsx +// draws them). Pure: no React, no DOM, so a unit test can check every layer and +// every segment. +// +// THE FOLD. A site under FOLD_PERCENT % of the placed total — every site's +// transcripts over the whole plotted range, the sum the bands are placed from +// — is drawn in ONE "Other" band on top of the stack, in the chart's neutral +// grey (OTHER_COLOR). Exactly FOLD_PERCENT % keeps its band; the comparison is +// on integers (100 × a site's sum < FOLD_PERCENT × the total), so the edge is +// exact. A fold of one is no fold: with a single site under the line, every +// site keeps its band. The kept sites keep their colours: each is its +// siteChartColors slot over EVERY site, folded or not, so a site's colour never +// changes because another one folded. The legend shows the kept sites and +// Other; the hover title and the "Numbers by year" table still name every site. // -// The marks spec parts touching bands by a 2 px gap in the colour behind the -// plot. The chart draws it centred on each band's upper edge, so each of the -// two bands gives 1 px. A band too thin to give its pixel and keep one of its -// own colour must touch its neighbour instead, or the gap erases it. "Thin" is -// measured AT RIGHT ANGLES to the edge, where the stroke's 2 px are measured: -// on a steep edge a band's perpendicular thickness is its vertical height × -// cos θ, and on a one-month dip at a phone's width it is nearly nothing. And it -// is measured at the NARROWEST plot each of the chart's three heights is drawn -// at, where the edges are steepest. A gap is drawn along a segment only where -// both bands keep at least MIN_KEEP_PX there after every gap that touches them. +// THE GAPS. The marks spec parts touching bands by a 2 px gap in the colour +// behind the plot. The chart draws it centred on each band's upper edge, so +// each of the two bands gives 1 px. A band too thin to give its pixel and keep +// one of its own colour must touch its neighbour instead, or the gap erases +// it. "Thin" is measured AT RIGHT ANGLES to the edge, where the stroke's 2 px +// are measured: on a steep edge a band's perpendicular thickness is its +// vertical height × cos θ, and on a one-month dip at a phone's width it is +// nearly nothing. And it is measured at the NARROWEST plot each of the chart's +// three heights is drawn at, where the edges are steepest. A gap is drawn along +// a segment only where both bands keep at least MIN_KEEP_PX there after every +// gap that touches them. The gaps work on bands, not sites: Other is one band, +// the top one, so the gap under it parts it from the kept site beneath (the +// highest with a height that month), and its own upper edge, where nothing +// sits, has none. export const GAP_PX = 2; export const MIN_KEEP_PX = 1; @@ -34,6 +52,8 @@ export type PlotSize = (typeof PLOT_SIZES)[number]; // The stack, bottom-up: each band's lower and upper value per month. export type Band = { lo: readonly number[]; hi: readonly number[] }; +type Month = { bySite: Record<string, number | undefined> }; + // The layers stack in the summary's order (the first five as they come, any // more after them). const STACK_ORDER = [0, 1, 2, 3, 4]; @@ -43,6 +63,68 @@ function stackOrder(n: number): number[] { return [...head, ...tail]; } +// ── The fold ────────────────────────────────────────────────────────────────── + +export const FOLD_PERCENT = 5; +export const OTHER_LABEL = "Other"; +// Other's own chart token (tokens.css `--chart-other`), a near-neutral grey: +// 7.36:1 on the Light ground and 3.22:1 on the Dark one (7.99 / 3.06 on the +// chart surface). tokens.css has its separation from each chart slot. +export const OTHER_COLOR = "var(--chart-other)"; + +// One band of the stack: a site's own (`sites` is its index in the summary's +// list), or Other (the indexes of every folded site, in the list's order). +// `key` is the site's id, or OTHER_KEY, which no site id can be (an id is +// `[a-z0-9][a-z0-9-]*`). +export type GrowthLayer = { key: string; sites: readonly number[]; other: boolean }; +export const OTHER_KEY = "(other)"; + +// Each site's transcripts over the whole plotted range. +function siteSums(months: readonly Month[], sites: readonly { siteId: string }[]): number[] { + return sites.map((s) => months.reduce((a, m) => a + (m.bySite[s.siteId] ?? 0), 0)); +} + +// The sites folded into Other, as indexes into `sites` in its order: every +// site under FOLD_PERCENT % of the placed total, when there are two or more of +// them; none otherwise. +export function foldedSites(months: readonly Month[], sites: readonly { siteId: string }[]): number[] { + const sums = siteSums(months, sites); + const all = sums.reduce((a, b) => a + b, 0); + if (all <= 0) return []; + const small = sums.flatMap((v, i) => (100 * v < FOLD_PERCENT * all ? [i] : [])); + return small.length >= 2 ? small : []; +} + +// Every site its own band, in stack order: the chart before the fold. +export function ownLayers(sites: readonly { siteId: string }[]): GrowthLayer[] { + return stackOrder(sites.length).map((i) => ({ key: sites[i].siteId, sites: [i], other: false })); +} + +// The chart's bands, bottom-up: the kept sites in stack order, then Other on +// top when anything folds. +export function growthLayers( + months: readonly Month[], + sites: readonly { siteId: string }[], +): GrowthLayer[] { + const folded = foldedSites(months, sites); + if (folded.length === 0) return ownLayers(sites); + const out = new Set(folded); + return [ + ...ownLayers(sites).filter((l) => !out.has(l.sites[0])), + { key: OTHER_KEY, sites: folded, other: true }, + ]; +} + +// Each layer's colour: a kept site its siteChartColors slot over EVERY site +// (so folding never repaints it), Other the neutral grey. +export function layerColors( + layers: readonly GrowthLayer[], + sites: readonly { accentId?: string }[], +): string[] { + const chart = siteChartColors(sites); + return layers.map((l) => (l.other ? OTHER_COLOR : chart[l.sites[0]])); +} + // A clean tick step giving three or four gridlines under `max`. function niceStep(max: number): number { const raw = max / 3.5; @@ -53,12 +135,16 @@ function niceStep(max: number): number { return 10 * pow; } -// The chart's numbers: each month's total, the peak, the value scale (yMax -// and its gridlines), and the bands bottom-up (`order[k]` is band k's index in -// `sites`). +// ── The stack ───────────────────────────────────────────────────────────────── + +// The chart's numbers: each month's total (every site, folded or not), the +// peak, the value scale (yMax and its gridlines), and one band per layer, +// bottom-up (`stack[k]` is `layers[k]`'s; a layer's value in a month is the sum +// of its sites'). Without `layers`, every site is its own band. export function growthStack( - months: readonly { bySite: Record<string, number | undefined> }[], + months: readonly Month[], sites: readonly { siteId: string }[], + layers: readonly GrowthLayer[] = ownLayers(sites), ) { const n = months.length; const totals = months.map((m) => sites.reduce((a, s) => a + (m.bySite[s.siteId] ?? 0), 0)); @@ -67,16 +153,19 @@ export function growthStack( const yMax = Math.ceil(peak / step) * step; const ticks: number[] = []; if (peak > 0) for (let t = step; t <= yMax; t += step) ticks.push(t); - const order = stackOrder(sites.length); const base = new Array<number>(n).fill(0); - const stack: Band[] = order.map((si) => { + const stack: Band[] = layers.map((layer) => { const lo = base.slice(); - const hi = months.map((m, i) => (base[i] += m.bySite[sites[si].siteId] ?? 0)); + const hi = months.map( + (m, i) => (base[i] += layer.sites.reduce((a, si) => a + (m.bySite[sites[si].siteId] ?? 0), 0)), + ); return { lo, hi }; }); - return { totals, peak, yMax, ticks, order, stack }; + return { totals, peak, yMax, ticks, layers, stack }; } +// ── The gaps ────────────────────────────────────────────────────────────────── + const height = (b: Band, i: number) => b.hi[i] - b.lo[i]; // The band a gap along band k's upper edge would share at month i: the next diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts @@ -27,6 +27,9 @@ import type { VideoStat } from "../../common/lib/stats"; // • every site transcribes every day of the last 200, so every line is full // and every series has a non-zero start for Indexed; // • uploads spread over 2019–2026, for the growth chart's months; +// • the last two sites each under 5 % of the growth chart's total (4.35 and +// 3.26 %), so the chart folds them into its Other band (release 14 slice +// CF; fixture-five transcribes 4 a day, not 5, for it); // • a seventh site, UNLISTED (site.json `listed: false`, release 14 slice // HS): its own two channels transcribe every day like the rest, and it // also exposes the first site's first channel. The summary names it nowhere @@ -65,7 +68,7 @@ export const FIXTURE_SITES: readonly FixtureSite[] = [ { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, wordmarkLead: "Fixture", channels: 3, daily: 9 }, { siteId: "fixture-three", siteTitle: "Fixture Three", wordmarkLead: "Fix", channels: 3, daily: 7 }, { siteId: "fixture-four", siteTitle: "Fixture Four", channels: 2, daily: 8 }, - { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 5 }, + { siteId: "fixture-five", siteTitle: "Fixture Five", channels: 2, daily: 4 }, { siteId: "fixture-six", siteTitle: "Fixture Six", accent: "vermilion", channels: 2, daily: 3 }, ]; diff --git a/homepage/e2e/growth-chart.spec.ts b/homepage/e2e/growth-chart.spec.ts @@ -1,13 +1,17 @@ import { test, expect, type Page } from "@playwright/test"; import { buildFixtureSummary } from "./fixture-summary"; import { painted, rgbOf } from "../../common/testing/chartPixels"; +import { siteChartColors } from "../../common/lib/siteColor"; +import { OTHER_COLOR, growthLayers } from "../app/lib/growthGaps"; // The growth chart's bands are parted by the marks spec's SURFACE GAP: 2 px // along each band's upper edge, in the colour behind the plot — the page // ground, which the chart sits on — never a line in the text colour // (ArchiveGrowthChart.tsx, globals.css `.growth-gap`), and the reader's Canvas // in forced colours. The fixture summary (fixture-summary.ts) has six sites -// with monthly data, so the chart and its gaps render. +// with monthly data, so the chart and its gaps render; its last two are each +// under 5 % of the chart's total, so they are drawn as one Other band on top +// (lib/growthGaps.ts, release 14 slice CF). const BASE_KEY = "ytdlp-tb:base"; @@ -87,8 +91,9 @@ for (const [width, shown] of [ // THE DATA IS WHAT IS PAINTED. Read back from a screenshot of the plot: at the // busiest month the stack's topmost painted row is within 1 px of where the -// month's true total sits on the value scale, and every site with data shows -// pixels of its own colour — the gaps take no band away. +// month's true total sits on the value scale, and every band with data — each +// kept site's and Other's — shows pixels of its own colour: the gaps take no +// band away. for (const width of [390, 1280]) { test(`${width} px: the chart paints its peak at its true height and every band`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); @@ -96,6 +101,7 @@ for (const width of [390, 1280]) { const summary = buildFixtureSummary(); const months = summary.monthly ?? []; const sites = summary.sites; + const layers = growthLayers(months, sites); const totals = months.map((m) => sites.reduce((a, x) => a + (m.bySite[x.siteId] ?? 0), 0)); const peak = Math.max(...totals); const peakAt = totals.indexOf(peak); @@ -111,10 +117,14 @@ for (const width of [390, 1280]) { const expected = (1 - peak / yMax) * box.height; const x = (peakAt / (months.length - 1)) * box.width; const ground = rgbOf(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)); + // The legend's swatches, one per band, bottom-up (kept sites, then Other). const swatches = await page .locator("figure ul li span") .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor)); - const withData = sites.map((st) => months.some((m) => (m.bySite[st.siteId] ?? 0) > 0)); + expect(swatches).toHaveLength(layers.length); + const withData = layers.map((l) => + months.some((m) => l.sites.some((si) => (m.bySite[sites[si].siteId] ?? 0) > 0)), + ); const shot = await painted(page, plot, { columns: [x - 1, x, x + 1], ground, @@ -124,8 +134,116 @@ for (const width of [390, 1280]) { }); const top = Math.min(...shot.tops.filter((t): t is number => t !== null)); expect(Math.abs(top - expected), `top ${top} vs ${expected.toFixed(1)}`).toBeLessThanOrEqual(1); - sites.forEach((st, i) => { - if (withData[i]) expect(shot.counts[i].columns, `${st.siteTitle}'s colour`).toBeGreaterThan(0); + layers.forEach((l, k) => { + if (withData[k]) expect(shot.counts[k].columns, `${l.key}'s colour`).toBeGreaterThan(0); }); }); } + +const resolveFill = (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); + +// WCAG contrast of two "rgb(…)" colours. +function contrast(a: string, b: string): number { + const lum = (css: string) => { + const [r, g, bl] = rgbOf(css).map((v) => { + const c = v / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const [hi, lo] = [lum(a), lum(b)].sort((p, q) => q - p); + return (hi + 0.05) / (lo + 0.05); +} + +// THE FOLD (lib/growthGaps.ts). The fixture's last two sites are each under +// 5 % of the chart's total: ONE Other band, drawn last (on top), in the chart's +// neutral grey. The legend shows the kept sites and Other; the table and +// every month's title name every site. +test("two sites under 5 % are one Other band on top: the legend shows the kept sites and Other, the table and the titles name all six", async ({ + page, +}) => { + const summary = buildFixtureSummary(); + const months = summary.monthly ?? []; + const sites = summary.sites; + const layers = growthLayers(months, sites); + expect(layers.map((l) => l.key)).toEqual([ + "fixture-one", + "fixture-two", + "fixture-three", + "fixture-four", + "(other)", + ]); + expect(layers.at(-1)!.sites).toEqual([4, 5]); + const titles = sites.map((s) => s.siteTitle); + await page.goto("/"); + const figure = page.locator("figure").filter({ has: page.locator('[role="img"]') }); + + // The legend: the four kept sites, then Other. + await expect(figure.locator("ul[aria-hidden='true'] li")).toHaveText([...titles.slice(0, 4), "Other"]); + + // The bands, in paint order: Other's is the last, in the neutral grey. + const fills = await figure + .locator("svg > path:not(.growth-gap)") + .evaluateAll((els) => els.map((e) => e.getAttribute("fill"))); + expect(fills).toEqual([...siteChartColors(sites).slice(0, 4), OTHER_COLOR]); + + // The image's label names the folded sites; the caption says what Other is. + await expect(figure.locator('[role="img"]')).toHaveAttribute( + "aria-label", + /Fixture Five and Fixture Six, each under 5% of the total, are drawn together as Other\.$/, + ); + await expect(figure.locator("figcaption")).toContainText( + "Instances under 5% of the total are drawn together as Other.", + ); + + // Every month's title names every site with data that month, folded or not. + const hits = await figure + .locator("svg rect.growth-hit title") + .evaluateAll((els) => els.map((e) => e.textContent ?? "")); + expect(hits).toHaveLength(months.length); + let foldedNamed = 0; + months.forEach((m, i) => { + for (const s of sites) { + const v = m.bySite[s.siteId] ?? 0; + if (v > 0) expect(hits[i], `${m.month}`).toContain(`\n${s.siteTitle} ${v.toLocaleString("en-US")}`); + } + if (hits[i].includes("\nFixture Five ") || hits[i].includes("\nFixture Six ")) foldedNamed++; + }); + expect(foldedNamed).toBeGreaterThan(0); + expect(hits.join("\n")).not.toContain("\nOther"); + + // The table: a column per site, all six, then Total. + await figure.locator("summary", { hasText: "Numbers by year" }).click(); + await expect(figure.locator("table thead th")).toHaveText(["Year", ...titles, "Total"]); +}); + +test("Other is its own grey (--chart-other), at least 3:1 on both grounds, and no kept site's colour", async ({ + page, +}) => { + await page.goto("/"); + for (const base of ["light", "dark"] as const) { + await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-base", base); + const swatches = await page + .locator("figure ul[aria-hidden='true'] li > span") + .evaluateAll((els) => els.map((e) => getComputedStyle(e).backgroundColor)); + const other = swatches.at(-1)!; + expect(other, base).toBe(await resolveFill(page, OTHER_COLOR)); + // Its own token, set on each base (a Dark block without it would fall + // back to the Light value from :root). + expect(other, base).toBe(base === "light" ? "rgb(62, 84, 92)" : "rgb(98, 98, 92)"); + expect(other, base).not.toBe(await resolveFill(page, "var(--chart-axis)")); + const ground = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); + expect(contrast(other, ground), `${base}: Other on the ground`).toBeGreaterThanOrEqual(3); + expect(swatches.slice(0, -1), base).not.toContain(other); + } +}); diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts @@ -4,6 +4,7 @@ import { test, expect, type Page } from "@playwright/test"; import { resolveAccent } from "../../common/lib/accent"; import { ACCENTS } from "../../common/lib/brand"; import { ACCENT_CHART_SLOT, siteChartColors } from "../../common/lib/siteColor"; +import { OTHER_COLOR } from "../app/lib/growthGaps"; import { FIXTURE_PALE_HEX, FIXTURE_SITES, FIXTURE_SUMMARY_NAME } from "./fixture-summary"; // The Official Instances cards wear their site's accent (release 10): a named @@ -16,7 +17,9 @@ import { FIXTURE_PALE_HEX, FIXTURE_SITES, FIXTURE_SUMMARY_NAME } from "./fixture // The dev server reads the synthetic summary (fixture-summary.ts): site 0 // Brass, site 1 a pale custom hex, 2–4 none, 5 Vermilion — so site 3's own // slot (chart-4) is Brass's and it takes the lowest free one, and six sites -// wear all six validated slots. +// wear all six validated slots. Sites 4 and 5 are each under 5 % of the +// chart's total, so the chart draws them as one Other band (release 14 slice +// CF): the legend has the four kept sites, in their own slots, and Other. const BASE_KEY = "ytdlp-tb:base"; @@ -89,6 +92,8 @@ test("each instance card wears its site's accent on every base, in its chart lay expect(chart[0]).toBe(`var(--chart-${ACCENT_CHART_SLOT.brass! + 1})`); expect(chart[5]).toBe("var(--chart-6)"); expect([...chart].sort()).toEqual([1, 2, 3, 4, 5, 6].map((k) => `var(--chart-${k})`)); + // The chart's bands: the four kept sites, then Other (sites 4 and 5). + const kept = 4; await page.goto("/"); const on = { dark: "onDark", light: "onLight" } as const; @@ -99,13 +104,16 @@ test("each instance card wears its site's accent on every base, in its chart lay 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, six apart. + // legend (and so each layer) wears each kept site's chart colour — its + // slot among all six, unchanged by the fold — then Other's grey, five + // apart. await expect(page.getByRole("img", { name: /transcripts by the month/i })).toHaveCount(1); - expect(legend, "legend swatches").toHaveLength(n); - for (let i = 0; i < n; i++) { + expect(legend, "legend swatches").toHaveLength(kept + 1); + for (let i = 0; i < kept; i++) { expect(legend[i], `legend ${i}`).toBe(await resolve(page, chart[i])); } - expect(new Set(legend).size, "six layers, six colours").toBe(n); + expect(legend[kept], "Other").toBe(await resolve(page, OTHER_COLOR)); + expect(new Set(legend).size, "five layers, five colours").toBe(kept + 1); // 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, @@ -124,16 +132,42 @@ test("each instance card wears its site's accent on every base, in its chart lay } // Sites 2–4 have no accent: their chart colour, the same as their legend - // swatch. + // swatch where they keep a band (2 and 3; 4 is in Other). for (let i = 2; i <= 4; i++) { expect(stripes[i], `site ${i}`).toBe(await resolve(page, chart[i])); - expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]); + if (i < kept) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]); } - // Site 5: Vermilion, the sixth slot's family — the rust layer, not the - // golden angle's hue 328 beside the magenta slot. + // Site 5: Vermilion, the sixth slot's family — its chart colour is the + // rust (chart[5], asserted above), not the golden angle's hue 328 beside + // the magenta slot. It is in Other on the growth chart; the test below + // reads the rust off its line on /stats/. expect(stripes[5]).toBe(await resolve(page, ACCENTS.vermilion[on[base]])); - expect(legend[5]).toBe(await resolve(page, "var(--chart-6)")); - expect(hueGap(stripes[5], legend[5]), "vermilion vs its layer").toBeLessThan(25); + expect(hueGap(stripes[5], await resolve(page, chart[5])), "vermilion vs its chart colour").toBeLessThan(25); + } +}); + +// The growth chart folds site 5 into Other, so the rust is checked where it is +// still drawn: /stats/' site lines (homepageChartData, one line per site in the +// summary's order, each in its siteChartColors colour). +test("on /stats/ each site's line wears its chart colour: the Vermilion site's is the rust", async ({ + page, +}) => { + const sites = fixtureSites(); + const chart = siteChartColors(sites); + await page.goto("/stats/"); + for (const base of ["dark", "light"] as const) { + await useBase(page, base); + const lines = page.locator(".recharts-line-curve"); + await expect(lines).toHaveCount(sites.length); + const strokes = await lines.evaluateAll((els) => els.map((el) => getComputedStyle(el).stroke)); + const want = []; + for (const c of chart) want.push(await resolve(page, c)); + expect(strokes, base).toEqual(want); + expect(strokes[5], `${base}: the Vermilion site's line`).toBe(await resolve(page, "var(--chart-6)")); + expect( + hueGap(strokes[5], await resolve(page, ACCENTS.vermilion[base === "dark" ? "onDark" : "onLight"])), + "vermilion vs its line", + ).toBeLessThan(25); } }); diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts @@ -113,8 +113,10 @@ test("the growth chart renders from the summary, or not at all", async ({ return; } await expect(chart).toBeVisible(); + // The caption; with sites folded into Other (lib/growthGaps.ts) a second + // sentence says what Other is. await expect( - page.getByText(/all official instances\.$/i), + page.getByText(/all official instances\.( |$)/i), ).toBeVisible(); }); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -6800,8 +6800,12 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below release 11), else `seriesColor(i)` when free (the first SIX are `var(--chart-1..6)`), else the lowest free slot — never two sites in one colour, and any six wear the six validated slots. 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 + it (release 14 slice CF: the growth chart draws two or more sites each under 5 % of its total as + ONE Other band on top, in its own `--chart-other` (tokens.css, Light `#3e545c`, Dark `#62625c`; + not in `REQUIRED_TOKENS`); `homepage/app/lib/growthGaps.ts` `growthLayers` / + `layerColors`, which run `siteChartColors` over EVERY site, so a kept site keeps its slot); 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-14.md b/plans/release-14.md @@ -22,9 +22,11 @@ slice HP added to it on the operator's ruling of the same day. Rules: | Lows, chart gap, T1, H1 (H3 folded in), H2 | `r14/two-grounds-headers` | The final review's Lows; the charts' surface gap; two grounds and each site in its own accent; the export and hub headers carry the social row and the toggle as one group, with an Archilyzer link to the homepage's `#instances` in place of the sites dropdown and the hub link; Changelog to the footer; after its review, the narrow header keeps the name and shows only the marked links (every header) | `common/components/{ThemeProvider,ThemeScript,ThemeToggle,SocialScroll}.tsx` + `themeConfig.ts` (and the deleted `ThemeMenu`, `ThemeRadios`), `common/components/charts/{ChartView,CrossSiteChart,surfaceGap}`, `common/styles/tokens.css`, `common/lib/{brand,accent,siteColor,paths,project,socialSvg,siteSchema,settingsSchema}.ts` + tests, `scripts/next-build-trace.test.mjs`, `export/app/components/{Header,MobileMenu,Footer}.tsx` (and the deleted `SiblingSwitcher`), `export/app/{layout.tsx,globals.css,changelog/page.tsx,lib/brand.ts}`, `export/e2e{,-hub}/**` (the theme, header and branding specs), `export/playwright.config.ts`, `editor/app/{layout.tsx,globals.css,sites/components/SiteForm.tsx}`, `editor/e2e/theme.spec.ts`, `homepage/app/{page.tsx,layout.tsx,globals.css,lib/*,changelog/page.tsx,components/{Header,ArchiveGrowthChart,ArchiveCards}.tsx}`, `homepage/e2e/**`, `homepage/content/docs/operate.md`, `SETTINGS.md`, `SITE.md` | | HS | `r14/hidden-sites` | Hidden sites: `site.json` `listed` (absent = listed). An unlisted site builds and deploys as before, and is left off the homepage (cards, chart, `/stats`), the hub (members, federated search, `corpus.json`, `llms.txt`), every other site's footer and the published id lists (`channel-sites.json`, the pooled `stats/`); the channels only it exposes count in no public total. One checkbox in the site form | `common/lib/{siteSchema,site,homepageSummary}.ts` + tests, `common/controller/{poolSummary,buildStats}.ts` + tests, `common/bin/{compose-homepage,compose-hub}.ts` + `compose-hub.test.ts`, `editor/app/sites/{actions.ts,components/SiteForm.tsx}`, `editor/e2e/{helpers.ts,sites-crud.spec.ts}`, `homepage/e2e/{fixture-summary.ts,unlisted-site.spec.ts}`, `SITE.md`, `plans/FACTS.md` (Naming hazards) | | S1 | `r14/first-search` | A clear screen until the first Search, on every site's search page and the hub's: no results area until the visitor asks (a Search, a profile load, or a link that carries a query or a filter), with the bar's "Press Enter or click Search to apply" line meanwhile; two page-life flags, the hold of release 8 unchanged and the gate new | `common/components/{SearchSessionContext,SearchResults,SearchBar}.tsx`, `export/e2e/first-search.spec.ts` (new), `export/e2e/{browse-all,workspace-shell,charts,restore-no-refire,responsive,tag-chips}.spec.ts`, `export/e2e/helpers.ts` (`showAll`), `export/e2e-hub/federated-search.spec.ts`, `export/CHANGELOG.md`, `plans/export-header-first-search.md` | +| CF | `r14/chart-fold` | The homepage's growth chart folds two or more sites each under 5 % of its total into one Other band on top, in its own near-neutral grey (`--chart-other`); the kept sites keep their colours; the legend shows them and Other, the hover titles and the table every site | `homepage/app/lib/growthGaps.ts` + test, `homepage/app/components/ArchiveGrowthChart.tsx`, `common/styles/tokens.css` (`--chart-other`), `homepage/e2e/{fixture-summary.ts,growth-chart.spec.ts,instance-colours.spec.ts,marketing.spec.ts}`, `homepage/CHANGELOG.md`, `plans/FACTS.md` | **Order:** HP → `r14/two-grounds-headers` → HS (`r14/hidden-sites`) and S1 (`r14/first-search`), -siblings. The shared files are the three changelogs' `[Unreleased]` sections and this record. +siblings → CF (`r14/chart-fold`), off `main` after HS, with `main` merged in after S1. The shared +files are the three changelogs' `[Unreleased]` sections and this record. ## Record @@ -1410,11 +1412,251 @@ The fix changes only which flag each place sets, so the full suites were not re- load average was 33. On the second attempt the dev server did not start within 120 s. The third run passed 13/13. +### Slice CF, as shipped — the growth chart folds the small sites into Other (2026-09-29) + +Branch `r14/chart-fold` off `main` `69e058d6` (slice HS merged), worktree +`~/Projects/homepage-social-visible` (block #3: homepage e2e 3340, homepage static 3331), one Opus +implementer. Scratch files `cf-*` in the job's `tmp`. The rulings (2026-09-29, not re-opened): +1. A site under **5 %** of the placed total — the chart's total over the whole plotted range, the + sum the bands are placed from — folds into ONE "Other" band, drawn on top of the stack, in a + neutral grey from the chart tokens that keeps ≥ 3:1 on the Light and the Dark ground. +2. A fold of one is no fold: with a single site under the threshold, nothing folds. +3. The legend shows the kept sites plus "Other"; the hover title and the "Numbers by year" table + still name every site. +4. Colour follows the site, never its rank: a kept site's colour does not change because another + folded. +5. The instance cards and `/stats` are unchanged; the surface gap draws correctly with Other on + top. +6. (The review, ruled by the parent.) Other wears a dedicated `--chart-other` token in both chart + blocks of `tokens.css`: Light `#3e545c`, Dark `#62625c` (the near-neutral). + +| sha | what | +|---|---| +| `8351d24f` | `homepage:` the fold in `lib/growthGaps.ts` (`foldedSites`, `growthLayers`, `ownLayers`, `layerColors`; `growthStack` stacks layers); the chart draws the layers, its legend, label and caption; the unit tests; the e2e fixture's fifth site at 4 a day | +| `6e0b652d` | `homepage:` e2e: `growth-chart.spec.ts` (the fold, the grey), `instance-colours.spec.ts`, `marketing.spec.ts` adjusted | +| `e95a8058` | `plans:` this record, the slices table, Order and Rollout; FACTS; the homepage changelog | +| `17832633` | `common, homepage:` `--chart-other` in both chart blocks with its numbers; `OTHER_COLOR` points at it; the chart's header comment; `growth-chart.spec.ts` resolves `OTHER_COLOR` and pins each base's value (review M1) | +| `c42d2bbc` | `homepage:` `instance-colours.spec.ts` reads each site line's stroke on `/stats/`, the Vermilion site's the rust (review L2) | +| `afddd66e` | `plans:` the review's fixes in this record (the grey, the Review table, the screenshots, Found and left, Decisions); the CF row's files; FACTS; the homepage changelog | +| `9267ec08` | merge of `main` `721ed0eb` (slice S1); `plans/release-14.md` resolved by hand (below), the changelogs clean | +| _this_ | `plans:` the post-merge gates; the commit table | + +**What shipped.** +- **The rule** (`homepage/app/lib/growthGaps.ts`, pure, beside `growthStack`): + - `foldedSites(months, sites)`: each site's sum over the plotted months against the sum of all of + them. A site is under when `100 × its sum < FOLD_PERCENT × the total` (`FOLD_PERCENT = 5`), on + integers, so exactly 5 % keeps its band. The folded sites are returned only when two or more + are under; none when the total is 0. + - `growthLayers(months, sites)`: the kept sites in stack order (the summary's, `STACK_ORDER` + unchanged), then `{ key: "(other)", sites: [the folded indexes], other: true }` — a key no site + id can be (`[a-z0-9][a-z0-9-]*`). `ownLayers(sites)` is every site its own band. + - `growthStack(months, sites, layers = ownLayers(sites))`: one band per layer, a layer's value + in a month the sum of its sites'. It returns `layers` in place of `order` (the chart was + `order`'s only reader). Totals, peak, value scale and gridlines are over every site, so the + stack's top and the scale are the same folded or not. + - `layerColors(layers, sites)`: `siteChartColors` over EVERY site, and a kept site takes its own + entry, so a fold never repaints one (with no accents, the kept list alone would move a site + after a folded one to a lower slot; the unit test proves it); Other is `OTHER_COLOR`. +- **The grey is its own token, `--chart-other`** (`common/styles/tokens.css`, beside `--chart-axis` + in both chart blocks, each with its numbers in a comment as `--chart-6` has; review M1): Light + `#3e545c` (OKLCH L 0.43, C 0.03), Dark `#62625c` (L 0.49, C 0.01). Contrast: 7.36:1 on the Light + ground and 7.99:1 on its chart surface; 3.22:1 on the Dark ground and 3.06:1 on its chart surface + (the Dark slots are 3.37–6.46:1 on the ground: Other is the dimmest mark there). Against each + chart slot it can sit on (the dataviz skill's validator's measures, OKLab ΔE × 100, normal / the + worse of protan and deutan): + + | Slot | Light | Dark | + |---|---|---| + | blue (`--chart-1`) | 21.9 / 21.4 | 17.8 / 17.9 | + | green (`--chart-2`) | 17.4 / 15.2 | 19.2 / 15.7 | + | violet (`--chart-3`) | 16.2 / 13.4 | 23.4 / 21.5 | + | amber (`--chart-4`) | 22.1 / 18.2 | 20.3 / 18.1 | + | magenta (`--chart-5`) | 24.5 / 9.1 | 19.5 / 7.3 | + | rust (`--chart-6`) | 14.1 / 10.5 | 13.5 / 9.3 | + + - Every CVD pair clears the floor (6) on both bases, and the target (8) on Light. Dark's magenta + (7.3) is in the 6–8 floor band, legal with the legend, the table and Other's place on top. + Rust is the one pair under the normal-vision 15, on both bases. + - A grey is under the validator's chroma floor by definition (it is the de-emphasis role, not a + categorical slot). + - Other touches only the kept site beneath it (the highest with a height that month). In + today's data that is Bonnellyzer, blue, in every month Other has data. + - The first build used `--chart-axis` (Light `#55646e`, Dark `#8a8170`: magenta at CVD 4.5 / + 4.2, and on Dark green at 4.1, under the floor). The review's sweep found in-band greys that + clear CVD 8 against every slot; the parent ruled the token above. +- **The chart** (`ArchiveGrowthChart.tsx`): + - the legend is the layers: the kept sites' titles, then "Other"; + - the areas and the gaps are keyed by layer; + - the hover title is unchanged: every site with data that month, by name, folded or not; + - the table is unchanged: a column per site; + - the image's `aria-label` gains, when something folds, "Hasanalyzer, Rekietalyzer and + Jasolyzer, each under 5% of the total, are drawn together as Other." (the legend is hidden + from assistive tech); + - the caption gains, when something folds, "Instances under 5% of the total are drawn together as + Other."; + - the header comment carries the grey's numbers. +- **The gaps are unchanged code.** They work on bands, so Other is one band, the top one: + `bandAbove` of the top kept site is Other wherever Other has a height, and Other's own upper edge + has no gap (nothing sits on it). On today's summary the gap segments are the same folded and + unfolded, and none is drawn under Other: Bonnellyzer is under 3 px wherever Other sits on it, so + they touch, as the three small bands did before. + + | Plot height | Segments | Runs | Under Other | + |---|---|---|---| + | 200 px | 73 | 7 | 0 | + | 260 px | 112 | 6 | 0 | + | 300 px | 119 | 7 | 0 | + + On the fixture, gaps are drawn under Other, and no band is covered at any height (unit test). +- **Unchanged:** the instance cards, `/stats`, `siteChartColors`, the hub; in `tokens.css` only + `--chart-other` is added. +- **With today's data** (the primary's `homepage-summary.json` of 2026-09-28, 75,821 transcripts + on the chart): Jeralyzer 42.03 %, Anilyzer 38.40 %, Bonnellyzer 9.18 % keep their bands; + Hasanalyzer 4.33 %, Rekietalyzer 3.79 % and Jasolyzer 2.26 % are Other. + +**The e2e fixture** (`homepage/e2e/fixture-summary.ts`). Its six sites were 60.21, 12.90, 9.68, +8.60, 5.38 and 3.23 % of the chart: one under 5 %, no fold. The smallest change that gives two: +`fixture-five` transcribes 4 a day, not 5 (1,600 recordings, not 2,000). The shares are now 60.87, +13.04, 9.78, 8.70, **4.35** and **3.26 %**, and `fixture-five` and `fixture-six` fold. The +unlisted site stays out of the chart (it is out of the summary). Every changed expectation: +- The fixture's listed totals: 36,799 transcripts, not 37,199; with the unlisted site listed, + 39,199, not 39,599 (HS's record has the old pair). No spec reads either: `unlisted-site.spec.ts` + computes both sides. +- `growth-chart.spec.ts`, the pixel test: one colour per band (four kept sites and Other), not per + site. +- `instance-colours.spec.ts`: the legend has five swatches, not six (the four kept sites in their + slots among all six, then Other's grey); `fixture-five`'s card is no longer compared with a + legend swatch (it has none); `fixture-six`'s hue check reads its chart colour, the rust, not a + legend swatch. +- `marketing.spec.ts`: the caption's check is `/all official instances\.( |$)/i`, not `/…\.$/i`, + since the Other sentence can follow. + +**Tests.** +- `growthGaps.test.ts`: the fixture test now runs the chart's layers (the fold is `[4, 5]`; a gap is + drawn between the top kept site and Other; none along Other's top; no band covered at any + height, folded or not). New: + - today's proportions (42.03 / 38.40 / 9.18 / 4.33 / 3.79 / 2.26 %, the family's accents): the + three largest keep their bands, Other holds the other three, its top is every month's total, + the colours are amber, magenta, blue and the grey; + - the edge: 999 of 20,000 (4.995 %) folds, 1,000 (exactly 5 %) does not; two sites at exactly + 5 % fold nothing; + - a single site under 5 %: no fold; + - no site under: `growthLayers` is `ownLayers`, and the stack is `growthStack`'s default; + - all but one under: one kept band and Other; + - a site with nothing in the range folds with another; nothing plotted folds nothing; + - 25 equal sites: every site folds (see "Decisions"); + - colour stability: the kept sites' colours equal the unfolded run's, and differ from what the + kept list alone would give. +- `growth-chart.spec.ts`, new: + - two sites under 5 % are one Other band on top: the layers are the four kept sites and + `(other)`; the legend reads the four titles and "Other"; the areas' fills, in paint order, are + the four slots and `OTHER_COLOR` (`var(--chart-other)`) last; the label names "Fixture Five and Fixture Six"; + the caption says what Other is; every month's title names every site with data that month and + no title says "Other"; the table's header is Year, the six titles, Total; + - Other is `OTHER_COLOR` on both grounds, rendered `rgb(62, 84, 92)` on Light and `rgb(98, 98, 92)` + on Dark (so each base declares its own), not the axis colour, at least 3:1 on the ground, and no + kept site's colour (review M1). +- `instance-colours.spec.ts`, new (review L2): on `/stats/`, on both bases, the six site lines' + rendered strokes are the six sites' chart colours in the summary's order; the Vermilion site's is + the rust (`--chart-6`) and within 25° of Vermilion's hue. The card test's own site-5 check is the + card's hue against its chart colour; the tautology it replaced (`chart[5]` against + `var(--chart-6)` twice) is gone. + +**They bite** (each change made by hand, the unit tests run, the change reverted): +- `<=` for `<` in the rule: the edge test fails. +- A fold of one allowed: the single-site test fails. +- Colours from `siteChartColors` over the kept sites alone: the colour-stability test fails (the + today's-proportions test does not: its sites' slots come from their accents). +- Other at the bottom of the stack: four tests fail (the fixture's, today's, all-but-one, colour). + +#### Gates (at `6e0b652d`; logs `$T/cf-*.log`) + +- **tsc** clean in every package on the tree of `6e0b652d`, run before the first commit (79 s); + `8351d24f`'s tree has `main`'s versions of the three spec files, which import nothing it changed. +- **Unit:** common **2,229/2,229**; homepage **20/20** (12 before, 8 new). +- **Build**, capped at 5 GB with no swap, from a clean `.next`, with the primary's summary copied + into the worktree's `homepage/public` for the screenshots (the worktree's own put back after): + `pnpm --filter homepage exec next build` **ok**, 39 s, max RSS 779,504 KB. `main`'s chart, + built the same way for the "before" shots: 80 s, 754,160 KB. After the review (`17832633`, for + the retaken shots): 20 s, 809,752 KB. +- **Homepage e2e, full** (97 at `main` after HS; 2 new): + + | Run | Passed | Failed | Time | + |---|---|---|---| + | first (`$T/cf-e2e-homepage.log`, after 9.5 min in the queue) | 98 | 1 | 4.2 min | + | again (`$T/cf-e2e-homepage2.log`, after 5.5 min in the queue) | **99** | **0** | 3.9 min | + + The first run's failure was `marketing.spec.ts`' "Changelog is reachable from the footer on + every page": the 30 s test timeout, reached on the seventh of its seven pages (the dev server + compiling each on first visit, the machine's load average 11–17 with other suites running). It + passed in 5.3 s in the second run and in the three-spec run below; nothing in it reads the + chart. +- **Along the way:** `growth-chart`, `instance-colours` and `marketing` specs, 21 passed, 0 failed + (1.8 min). +- **Numbers tool:** none. +- Not run: the export, hub and editor suites and their builds (no file of theirs changed). + +**Screenshots** (`~/reports/release-14/shots/chart3/`, 2×, the static server on 3331, today's +summary): +- `after-{390,1280}-{light,dark}.png`: the chart with `--chart-other` (retaken after the review; + the operator judges the Dark one); `…-2019-2026.png`: the plot from 2019 on, where Other lies; +- `after-axis-…`: the same from the first build, Other in `--chart-axis`, for comparison; +- `before-…`: the same from `main`'s chart and the same summary (six bands); +- `after-1280-{light,dark}-table.png`: "Numbers by year" open, a column per site. + +**Found and left:** +- **The pairs short of the validator's targets** are Dark's magenta (CVD 7.3, the 6–8 floor band) + and rust on both bases (normal 14.1 / 13.5). They matter only when that site is the top kept one under Other; today it + is blue. +- **`/stats`' channel breakdown has an "Other (N)" of its own**, in `--muted-foreground` + (`common/lib/homepageChart.ts` `OTHER_COLOR`), not `--chart-other`. `/stats` is unchanged, as + ruled. +- **`--chart-other` is not in `REQUIRED_TOKENS`** (`common/components/themeConfig.ts`, the list + `themeTokens.test.ts` checks every base declares): that file is outside this slice. + `growth-chart.spec.ts` pins each base's rendered value instead (a Dark block without it would + paint the Light value from `:root`). +- **The fold is the homepage chart's only.** `/stats`, the hub and the sites' charts draw every + site, as ruled. + +#### Decisions the operator could overturn + +| What I assumed | The alternative | +|---|---| +| The caption gains one sentence saying what Other is, only when something folds | the caption unchanged | +| The image's label names the folded sites | name only how many | +| The legend reads "Other", with no count or names | "Other (3)" | +| The hover title lists every site with data in the summary's order, with no Other subtotal | an "Other N" line, its sites under it | +| When every site is under 5 % (21 or more sites), every site folds: one Other band | keep the largest, or fold nothing | +| A site with nothing in the plotted range is under 5 % and folds with another | leave it out of the chart | + +#### Review (verdict SHIP AFTER FIXES; `$T/cf-review.md`) + +| Finding | Fix | +|---|---| +| M1: the axis grey was under the CVD floor against magenta on both bases (4.5 / 4.2) and against green on Dark (4.1); the record's sweep sentence overstated the case against a better grey | `17832633`: `--chart-other` in both chart blocks (Light `#3e545c`, Dark `#62625c`, as ruled), `OTHER_COLOR` points at it, the chart's comment and the spec follow; the sweep sentence is withdrawn and the token's measured numbers stated ("What shipped"); the labels' shared colour is gone from Found and left; the four `after-*` shots retaken, the first build's kept as `after-axis-*` | +| L1: the CF row omitted `plans/FACTS.md` | `afddd66e`: the row lists it, and `common/styles/tokens.css` | +| L2: `instance-colours.spec.ts` checked the sixth site's colour against itself | `c42d2bbc`: a rendered check of every site line's stroke on `/stats/`, the sixth the rust | +| L3: "Found and left" left rust out of the weak pairs | moot with M1; the line names Dark's magenta and rust | +| L4: the merge of `main` (`721ed0eb`, S1 merged) conflicts in this record only | `9267ec08`: S1's row, then CF's; S1's section, then CF's, before "## Rollout"; the Order line and the Rollout's intro name S1 and CF; both sets of live checks. The changelogs merged clean, every bullet under `[Unreleased]` (checked by eye) | +| L5: the changelog's "at this release's numbers" will drift | left, as a release note | + +The caption and the image-label sentences are kept (the review: acceptable additions). + +**Gates after the review and the merge of `main`** (at `9267ec08`; logs `$T/cf-tsc2.log`, +`$T/cf-tsc3.log`, `$T/cf-gates2.log`, `$T/cf-e2e3.log`): +- **tsc** clean in every package before the fix commits (44 s) and on the merged tree before the + merge commit (164 s). +- **Unit:** common **2,229/2,229**; homepage **20/20**. +- **e2e**, detached and queued: `growth-chart`, `instance-colours` and `marketing`, **22 passed, 0 + failed** (1.1 min; the new `/stats/` line test among them). The full suite was not re-run, as + directed. +- **Build** (for the retaken shots, at `17832633`): 20 s, 809,752 KB, capped. + ## Rollout -Release 14 is slice HP (merged, `bfa1ff3c`), `r14/two-grounds-headers` and slice HS -(`r14/hidden-sites`), each after the parent's merge. Every command below is typed **from the -primary checkout's root**. There is no `archilyzer` on PATH, so it is `pnpm archilyzer …`. The +Release 14 is slice HP (merged, `bfa1ff3c`), `r14/two-grounds-headers`, slice HS +(`r14/hidden-sites`), slice S1 (`r14/first-search`) and slice CF (`r14/chart-fold`), each after the +parent's merge. Every command below is typed **from the primary checkout's root**. There is no `archilyzer` on PATH, so it is `pnpm archilyzer …`. The command forms are the ones verified in `plans/stats-cache-key.md`'s rollout. **Preconditions.** @@ -1464,6 +1706,10 @@ hub goes before the sites, because in basic mode they share `export/out`. The si and `localStorage.getItem("ytdlp-tb:base")` now reads `"light"`. - The homepage's growth chart has no slash in the page colour through any band. `/stats` in Area mode has coloured top lines. +- The growth chart's legend lists the sites with 5 % or more of the chart's total, then + **Other** (a grey band on top); the caption ends "Instances under 5% of the total are drawn + together as Other."; **Numbers by year** has a column for every site. `/stats` still has a line + per site. - Every site's settings in the editor show **List on the Archilyzer homepage and hub**, ticked. With none unticked, the homepage, the hub and every footer list the same sites as before, and `https://archilyzer.pages.dev/homepage-summary.json` reads `"version":6`.