Archilyzer · Source

archilyzer

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

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:
Mhomepage/e2e/growth-chart.spec.ts | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mhomepage/e2e/instance-colours.spec.ts | 32+++++++++++++++++++++-----------
Mhomepage/e2e/marketing.spec.ts | 4+++-
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(); });