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:
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`.