commit c01acc4903d187c7172a2df226e20a6d74d26e7b
parent 3601780504a245c737acb81cba4e426ecc678a8b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 22:03:08 -0400
homepage: e2e for the growth chart's Other band
`growth-chart.spec.ts`: the fixture's two sites under 5 % are one Other band,
painted last, in `--chart-axis`; the legend shows the four kept sites and
Other; every month's title names every site with data that month and the
table has a column per site; the image's label names the folded sites and
the caption says what Other is; Other is at least 3:1 on both grounds and no
kept site's colour. The pixel test checks each band's colour, Other's too.
`instance-colours.spec.ts`: the legend is the four kept sites in their slots
among all six, then Other's grey; the Vermilion site's hue check reads its
chart colour, since its layer is in Other. `marketing.spec.ts`: the caption's
first sentence may be followed by the Other sentence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 144 insertions(+), 18 deletions(-)
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,112 @@ 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 the chart's neutral grey, 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, "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,18 @@ 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, not the golden angle's hue 328 beside the magenta slot (it is in
+ // Other on the growth chart; /stats draws it in this colour).
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);
+ const rust = await resolve(page, chart[5]);
+ expect(rust).toBe(await resolve(page, "var(--chart-6)"));
+ expect(hueGap(stripes[5], rust), "vermilion vs its chart colour").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();
});