import fs from "node:fs"; import path from "node:path"; 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 // accent at its value on the base in force (`var(--swatch-)`), a custom hex // FITTED to each base as the site's own pages fit it (release 11), and a site // with no accent its chart colour. The growth chart draws each site in the // validated chart slot of its accent's HUE FAMILY (siteChartColors: Brass → // amber, --chart-4; Vermilion → rust, --chart-6), so a family card and its // legend swatch are one hue; a card with no accent equals its legend swatch. // 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. 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"; // The summary the dev server reads: its sites, in its order. function fixtureSites(): Array<{ siteId: string; accent?: string; accentId?: string }> { const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME); const s = JSON.parse(fs.readFileSync(file, "utf8")); expect(Array.isArray(s.monthly) && s.monthly.length > 0, "the fixture has monthly").toBe(true); return s.sites; } // The colour a CSS colour resolves to on this page, as the browser reports it. const resolve = (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); // OKLCH hue, in degrees, of an "rgb(r, g, b)". function hue(rgb: string): number { const [r, g, b] = (rgb.match(/\d+/g) ?? []).slice(0, 3).map((n) => { const c = Number(n) / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360; } const hueGap = (a: string, b: string) => Math.abs(((hue(a) - hue(b) + 540) % 360) - 180); async function colours(page: Page) { const section = page.locator("section").filter({ has: page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }), }); const stripes = await section .locator("li > span[aria-hidden='true']") .evaluateAll((els) => els.map((el) => getComputedStyle(el).backgroundColor)); const legend = await page .locator("figure ul[aria-hidden='true'] li > span") .evaluateAll((els) => els.map((el) => getComputedStyle(el).backgroundColor)); return { stripes, legend }; } async function useBase(page: Page, base: "dark" | "light") { await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); await page.reload(); await expect(page.locator("html")).toHaveAttribute("data-base", base); } test("each instance card wears its site's accent on every base, in its chart layer's hue", async ({ page, }) => { const sites = fixtureSites(); const n = sites.length; expect(sites.map((x) => x.siteId)).toEqual(FIXTURE_SITES.map((x) => x.siteId)); expect(sites[0].accentId).toBe("brass"); expect(sites[1]).toMatchObject({ accent: FIXTURE_PALE_HEX }); expect(sites[1].accentId).toBeUndefined(); expect(sites[5].accentId).toBe("vermilion"); // The chart colours the page must wear, as the helper assigns them: the six // validated slots, each once. const chart = siteChartColors(sites); 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; const pale = resolveAccent(FIXTURE_PALE_HEX); for (const base of ["dark", "light"] as const) { await useBase(page, base); const { stripes, legend } = await colours(page); expect(stripes).toHaveLength(n); // The summary's monthly series draws the chart, legend and all: the // 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(kept + 1); for (let i = 0; i < kept; i++) { expect(legend[i], `legend ${i}`).toBe(await resolve(page, chart[i])); } 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, // the same hue family (a few degrees apart), at a different value. expect(stripes[0]).toBe(await resolve(page, ACCENTS.brass[on[base]])); expect(await resolve(page, chart[0])).toBe(await resolve(page, "var(--chart-4)")); expect(hueGap(stripes[0], legend[0]), "brass vs its layer").toBeLessThan(25); // Site 1: the pale custom hex, FITTED to this base — on light // darkened to 4.5:1 (as published it is ~1.4:1 there), on dark as is. expect(stripes[1], "the pale hex, fitted").toBe(await resolve(page, pale[base])); if (base !== "dark") { expect(stripes[1], "not the pale hex as published").not.toBe( await resolve(page, FIXTURE_PALE_HEX), ); } // Sites 2–4 have no accent: their chart colour, the same as their legend // 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])); if (i < kept) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]); } // 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(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); } });