commit a0026d3f077aafd69dd7fe1c97611a8252bde013
parent b6b008a5ce7c29017bbc7743c7fb3ddffb3359bf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:32:30 -0400
Merge r11/homepage-lows — release 11 slice O2: a custom hex fitted per base on the homepage and hub cards; a sixth validated chart colour (--chart-6, Vermilion's rust); the mark's tile keeps an edge under forced colours; the homepage e2e builds its own summary, so a fresh clone runs every spec
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
26 files changed, 765 insertions(+), 216 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -126,8 +126,8 @@ yarn-error.log*
/homepage/test-results/
/homepage/playwright-report/
/homepage/out/
-# the e2e's copy of the summary with fixture accents (homepage/e2e/fixture-accents.ts)
-/homepage/e2e/.e2e-summary.json
+# the e2e's synthetic summary, and a killed run's temp copy (homepage/e2e/fixture-summary.ts)
+/homepage/e2e/.e2e-summary.json*
# site config
/settings.json
diff --git a/common/components/BrandMark.test.ts b/common/components/BrandMark.test.ts
@@ -28,6 +28,24 @@ test("the ring's corner is the ground's: MARK_GROUND_RX of MARK_VIEWBOX", () =>
assert.ok(BRAND_MARK_RING_CLASS.split(" ").includes("overflow-visible"));
});
+test("forced colours: a 1px CanvasText outline, the one ring the mode keeps", () => {
+ // Forced-colours mode puts the tile on the reader's Canvas on every base, so
+ // the tile gets an outline in the reader's text colour — on every base (no
+ // `dark:`), 1px (the bare `outline` utility), with no offset, so it sits
+ // where the ring does and follows the same rounded corner. Every class of it is forced-colours only:
+ // the other modes render exactly as they did.
+ const classes = BRAND_MARK_RING_CLASS.split(" ");
+ const forced = classes.filter((c) => c.startsWith("forced-colors:"));
+ assert.deepEqual(forced, ["forced-colors:outline", "forced-colors:outline-[color:CanvasText]"]);
+ assert.deepEqual(
+ classes.filter((c) => c.includes("outline")),
+ forced,
+ "no outline outside forced colours",
+ );
+ assert.ok(classes.includes("rounded-[21.875%]"));
+ assert.ok(!classes.some((c) => /^forced-colors:.*(offset|dark)/.test(c)));
+});
+
test("each palette rings in its own dim, set as --mark-ring on the svg", () => {
const parent = render(createElement(BrandMark, { palette: ICON_PALETTES.archilyzer }));
assert.match(parent, /style="--mark-ring:#586977"/);
diff --git a/common/components/BrandMark.tsx b/common/components/BrandMark.tsx
@@ -20,9 +20,22 @@ export type BrandMarkPalette = Readonly<Record<MarkTone, string>>;
// - `overflow-visible`: an <svg> clips its content by default, and with a
// border-radius that clip would be a second anti-aliased edge laid over
// the ground's own rounded corner.
+// FORCED COLOURS (Windows High Contrast, release 11): the reader's palette
+// replaces the page on every base, and its Canvas may be as dark as the tile
+// (a black high-contrast theme), while the ring is only drawn on dark — and an
+// engine that forces `box-shadow: none` there drops even that. So under
+// `@media (forced-colors: active)`, on every base, the tile gets a 1px OUTLINE
+// in the system `CanvasText`, outside it and following the same corner (an
+// outline follows border-radius). An <svg> keeps its own colours there
+// (Chromium's UA sheet gives it `forced-color-adjust: preserve-parent-color`,
+// which also keeps the dark ring; the outline is painted over the same pixel
+// ring), so the outline is the one part drawn in the reader's colours.
+// Outside forced colours the two classes match nothing: every other mode
+// renders as before.
// Complete literal class strings — Tailwind v4 only sees whole literals.
export const BRAND_MARK_RING_CLASS =
- "rounded-[21.875%] overflow-visible dark:shadow-[0_0_0_1px_var(--mark-ring)]";
+ "rounded-[21.875%] overflow-visible dark:shadow-[0_0_0_1px_var(--mark-ring)] " +
+ "forced-colors:outline forced-colors:outline-[color:CanvasText]";
// The Found-line mark, inline — drawn from the same MARK shape list as
// markSvg (the icon files), so the two cannot drift. Decorative: the text
diff --git a/common/components/themeConfig.ts b/common/components/themeConfig.ts
@@ -147,6 +147,7 @@ export const REQUIRED_TOKENS = [
"--chart-3",
"--chart-4",
"--chart-5",
+ "--chart-6",
"--chart-surface",
"--chart-grid",
"--chart-axis",
diff --git a/common/components/themeTokens.test.ts b/common/components/themeTokens.test.ts
@@ -92,8 +92,19 @@ test("every base declares every required token, color-scheme and every swatch",
}
});
-test("REQUIRED_TOKENS is the 49 colour tokens, each once", () => {
- assert.equal(REQUIRED_TOKENS.length, 49);
+test("--base-light|sepia|dark are 1 on their own base and 0 on the others", () => {
+ // lib/siteColor.ts perBaseColor multiplies each base's channel by these, so
+ // exactly one may be 1 — two would add two colours' channels together.
+ for (const base of BASE_GROUND_IDS) {
+ const r = rule(BASE_SELECTOR[base]);
+ for (const b of BASE_GROUND_IDS) {
+ assert.equal(r.decls.get(`--base-${b}`), b === base ? "1" : "0", `${base} --base-${b}`);
+ }
+ }
+});
+
+test("REQUIRED_TOKENS is the 50 colour tokens, each once", () => {
+ assert.equal(REQUIRED_TOKENS.length, 50);
assert.equal(new Set(REQUIRED_TOKENS).size, REQUIRED_TOKENS.length);
});
@@ -188,7 +199,12 @@ test("no retired theme family is left in the sheet", () => {
test("charts stay off the accent and off the gone colour", () => {
for (const base of BASE_GROUND_IDS) {
const r = rule(BASE_SELECTOR[base]);
- for (let n = 1; n <= 5; n++) assert.match(r.decls.get(`--chart-${n}`) ?? "", /^#[0-9a-f]{6}$/);
+ for (let n = 1; n <= 6; n++) assert.match(r.decls.get(`--chart-${n}`) ?? "", /^#[0-9a-f]{6}$/);
+ // Six different colours on every base (the sixth, release 11, is a
+ // validated slot of its own, not a repeat or the golden angle).
+ const six = [1, 2, 3, 4, 5, 6].map((n) => r.decls.get(`--chart-${n}`));
+ assert.equal(new Set(six).size, 6, `${base}: ${six.join(" ")}`);
+ assert.notEqual(r.decls.get("--chart-6"), r.decls.get("--state-gone"));
// FACTS.md ~6442: chart-3 once WAS the gone hex; the palette itself was
// re-validated with the dataviz validator (plans/brand-and-themes.md, S2).
assert.notEqual(r.decls.get("--chart-3"), r.decls.get("--state-gone"));
diff --git a/common/lib/homepageChart.ts b/common/lib/homepageChart.ts
@@ -14,11 +14,14 @@ export type Range = "2w" | "6w" | "90d" | "12mo" | "all";
export type Metric = MetricKey; // "transcribed" | "downloaded"
export type Display = "share" | "counts";
-// Stable series color. The first five use the globals.css palette
-// (--chart-1..5); beyond that we spread hues by the golden angle so many
-// channels / >5 sites stay visually distinct (and stable across renders).
+// Stable series color. The first six are the validated palette in
+// common/styles/tokens.css (--chart-1..6, fixed per base); beyond that we
+// spread hues by the golden angle so many channels / >6 sites stay distinct
+// (and stable across renders). The sixth was the golden angle's hue 328 until
+// release 11 — within a degree of --chart-5's magenta.
+export const CHART_SLOTS = 6;
export function seriesColor(index: number): string {
- if (index < 5) return `var(--chart-${index + 1})`;
+ if (index < CHART_SLOTS) return `var(--chart-${index + 1})`;
const hue = Math.round((index * 137.508) % 360);
return `hsl(${hue} 64% 55%)`;
}
diff --git a/common/lib/hubSummary.test.ts b/common/lib/hubSummary.test.ts
@@ -10,6 +10,11 @@ import {
} from "./hubSummary";
import { seriesColor } from "./homepageChart";
import type { HomepageSummary } from "./homepageSummary";
+import { resolveAccent } from "./accent";
+import { perBaseColor } from "./siteColor";
+
+// A custom hex as a card wears it: fitted to each base (lib/siteColor.ts).
+const fitted = (hex: string) => perBaseColor(resolveAccent(hex));
// Run with:
// pnpm --filter yt-dlp-transcript-common test
@@ -204,8 +209,8 @@ test("officialInstances: an accent the site sets wins; its place still follows t
out.map((o) => [o.site.siteId, o.accent]),
[
["jeralyzer", seriesColor(0)],
- ["anilyzer", "#e6a1c0"], // hub-sites.json's own
- ["hasanalyzer", "#b49cf2"], // the summary's, when hub-sites.json has none
+ ["anilyzer", fitted("#e6a1c0")], // hub-sites.json's own, fitted per base
+ ["hasanalyzer", fitted("#b49cf2")], // the summary's, when hub-sites.json has none
],
);
});
@@ -285,7 +290,7 @@ test("officialInstances: a named accent is painted per base; a site's own differ
out.map((o) => [o.site.siteId, o.accent]),
[
["jeralyzer", "var(--swatch-brass)"],
- ["anilyzer", "#cc3366"], // the site's own, which disagrees: no id
+ ["anilyzer", fitted("#cc3366")], // the site's own, which disagrees: no id
["hasanalyzer", seriesColor(2)],
],
);
diff --git a/common/lib/hubSummary.ts b/common/lib/hubSummary.ts
@@ -194,7 +194,8 @@ export type OfficialInstance<T> = {
// own accent, else the summary's, else its chart colour (siteChartColors over
// the summary's sites: its accent family's slot, or seriesColor() at its index
// when that is free). A NAMED accent is painted per base (`var(--swatch-<id>)`)
-// from the summary's `accentId`; hub-sites.json carries only the on-dark hex,
+// from the summary's `accentId`, and a custom hex is fitted to each base
+// (perBaseColor, release 11); hub-sites.json carries only the on-dark hex,
// so the id is used only when the two files name the same colour (one compose
// writes both, so they agree unless a site's own is newer). A member the
// summary does not name takes the chart colours after the summary's, so it
diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts
@@ -1,8 +1,27 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { ACCENT_CHART_SLOT, siteChartColors, siteColor } from "./siteColor";
-import { seriesColor } from "./homepageChart";
-import { ACCENTS, ACCENT_IDS } from "./brand";
+import { ACCENT_CHART_SLOT, perBaseColor, siteChartColors, siteColor } from "./siteColor";
+import { CHART_SLOTS, seriesColor } from "./homepageChart";
+import { resolveAccent } from "./accent";
+import { ACCENTS, ACCENT_IDS, BASE_GROUNDS, BASE_GROUND_IDS, contrastRatio, type BaseGround } from "./brand";
+
+// What the browser paints for a perBaseColor value on `base`: tokens.css sets
+// `--base-<base>` to 1 and the other two to 0 (`null`: no token sheet at all,
+// so every var() takes its fallback). Returns "#rrggbb".
+function paint(css: string, base: BaseGround | null): string {
+ const flags = css.replace(/var\(--base-(light|sepia|dark), ([01])\)/g, (_, b, fallback) =>
+ base === null ? fallback : b === base ? "1" : "0",
+ );
+ const channels = [...flags.matchAll(/calc\(([^()]*)\)/g)].map((m) =>
+ m[1].split("+").reduce((sum, term) => {
+ const [a, b] = term.split("*").map(Number);
+ return sum + a * b;
+ }, 0),
+ );
+ assert.match(flags, /^rgb\(calc\([^()]*\) calc\([^()]*\) calc\([^()]*\)\)$/, css);
+ assert.equal(channels.length, 3, css);
+ return `#${channels.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
+}
// ── siteColor: the mark colour ───────────────────────────────────────────────
@@ -17,9 +36,35 @@ test("siteColor: a named accent is its per-base swatch, whatever hex rides along
assert.equal(siteColor({ accentId: "brass" }, seriesColor(0)), "var(--swatch-brass)");
});
-test("siteColor: a custom hex is that hex, normalized", () => {
- assert.equal(siteColor({ accent: "#CC3366" }, seriesColor(0)), "#cc3366");
- assert.equal(siteColor({ accent: "#cc3366", accentId: undefined }, seriesColor(4)), "#cc3366");
+test("siteColor: a custom hex is fitted to each base, as the site's own pages fit it", () => {
+ // A pale hex: 1.43:1 on the light ground as published, so the old card
+ // painted it nearly invisible on light and sepia.
+ const pale = "#f4c2d7";
+ const fitted = resolveAccent(pale);
+ const css = siteColor({ accent: "#F4C2D7" }, seriesColor(0));
+ assert.equal(css, perBaseColor(fitted));
+ for (const base of BASE_GROUND_IDS) {
+ assert.equal(paint(css, base), fitted[base], base);
+ assert.ok(contrastRatio(paint(css, base), BASE_GROUNDS[base]) >= 4.5, base);
+ }
+ // Fitted, not as published, where the ground needs it; kept where it reads.
+ assert.deepEqual(
+ { light: paint(css, "light"), sepia: paint(css, "sepia"), dark: paint(css, "dark") },
+ { light: "#846974", sepia: "#7c636e", dark: pale },
+ );
+ // A hex that already reads on a ground is kept there exactly (#cc3366 on
+ // light), and the chart colour is never used for one.
+ assert.equal(paint(siteColor({ accent: "#cc3366" }, seriesColor(4)), "light"), "#cc3366");
+ assert.equal(paint(siteColor({ accent: "#cc3366" }, seriesColor(4)), "dark"), resolveAccent("#cc3366").dark);
+});
+
+test("perBaseColor: one value, each base's colour; the light one with no token sheet", () => {
+ const v = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" };
+ const css = perBaseColor(v);
+ for (const base of BASE_GROUND_IDS) assert.equal(paint(css, base), v[base]);
+ assert.equal(paint(css, null), v.light);
+ // Plain text a style attribute takes as is: no quotes, no url(), no `;`.
+ assert.doesNotMatch(css, /["';]|url\(/);
});
test("siteColor: no accent is the site's chart colour", () => {
@@ -29,8 +74,11 @@ test("siteColor: no accent is the site's chart colour", () => {
});
test("siteColor: anything unrecognised falls through, never into the style", () => {
- // Not an accent id: the hex (or the chart colour) stands.
- assert.equal(siteColor({ accentId: "gold", accent: "#112233" }, seriesColor(0)), "#112233");
+ // Not an accent id: the hex, fitted (or the chart colour), stands.
+ assert.equal(
+ siteColor({ accentId: "gold", accent: "#112233" }, seriesColor(0)),
+ perBaseColor(resolveAccent("#112233")),
+ );
assert.equal(siteColor({ accentId: "red);x:y" }, seriesColor(1)), seriesColor(1));
// Not a #rrggbb.
assert.equal(siteColor({ accent: "red" }, seriesColor(1)), seriesColor(1));
@@ -42,9 +90,16 @@ test("siteColor: anything unrecognised falls through, never into the style", ()
const named = (accentId: string) => ({ accentId });
const none = {};
-test("siteChartColors: each family takes its hue's slot of --chart-1..5", () => {
- // --chart-1..5 is blue, green, violet, amber, magenta on every base.
- assert.deepEqual(ACCENT_CHART_SLOT, { blue: 0, green: 1, violet: 2, brass: 3, sakura: 4 });
+test("siteChartColors: each family takes its hue's slot of --chart-1..6", () => {
+ // --chart-1..6 is blue, green, violet, amber, magenta, rust on every base.
+ assert.deepEqual(ACCENT_CHART_SLOT, {
+ blue: 0,
+ green: 1,
+ violet: 2,
+ brass: 3,
+ sakura: 4,
+ vermilion: 5,
+ });
// Today's homepage order with the operator's mapping (2026-09-27).
assert.deepEqual(
siteChartColors(["brass", "sakura", "violet", "blue", "green"].map(named)),
@@ -56,6 +111,23 @@ test("siteChartColors: each family takes its hue's slot of --chart-1..5", () =>
siteChartColors(["brass", "sakura", "violet", "green", "blue"].map(named)),
["var(--chart-4)", "var(--chart-5)", "var(--chart-3)", "var(--chart-2)", "var(--chart-1)"],
);
+ // Jasolyzer (Vermilion) published beside them, wherever it ranks: the rust
+ // slot, not the golden angle's hue 328 beside Anilyzer's magenta.
+ const five = ["brass", "sakura", "blue", "violet", "green"];
+ for (let at = 0; at <= five.length; at++) {
+ const ids = [...five.slice(0, at), "vermilion", ...five.slice(at)];
+ const out = siteChartColors(ids.map(named));
+ assert.equal(out[at], "var(--chart-6)", `vermilion at ${at}`);
+ }
+});
+
+test("seriesColor: six validated slots, then the golden angle", () => {
+ assert.equal(CHART_SLOTS, 6);
+ assert.deepEqual(
+ Array.from({ length: 6 }, (_, i) => seriesColor(i)),
+ [1, 2, 3, 4, 5, 6].map((n) => `var(--chart-${n})`),
+ );
+ assert.match(seriesColor(6), /^hsl\(/);
});
test("siteChartColors: no accents is seriesColor(0..n-1), as before", () => {
@@ -64,10 +136,10 @@ test("siteChartColors: no accents is seriesColor(0..n-1), as before", () => {
assert.deepEqual(siteChartColors([]), []);
});
-test("siteChartColors: no family (vermilion, signal, a custom hex, an unknown id) falls back to its index", () => {
+test("siteChartColors: no family (signal, a custom hex, an unknown id) falls back to its index", () => {
assert.deepEqual(
- siteChartColors([named("vermilion"), named("signal"), { accentId: undefined }, named("gold")]),
- [seriesColor(0), seriesColor(1), seriesColor(2), seriesColor(3)],
+ siteChartColors([named("signal"), { accentId: undefined }, named("gold")]),
+ [seriesColor(0), seriesColor(1), seriesColor(2)],
);
});
@@ -92,11 +164,34 @@ test("siteChartColors: a fallback whose index a family holds takes the lowest fr
siteChartColors([none, named("brass"), named("blue"), none, named("sakura")]),
["var(--chart-2)", "var(--chart-4)", "var(--chart-1)", "var(--chart-3)", "var(--chart-5)"],
);
- // Past the palette, the golden-angle hues, never one already worn.
- const six = siteChartColors([
+ // A sixth site with no free family takes the sixth slot; a seventh, past
+ // the palette, the golden-angle hues, never one already worn.
+ const seven = siteChartColors([
named("blue"), named("green"), named("violet"), named("brass"), named("sakura"), named("blue"),
+ named("vermilion"),
]);
- assert.equal(six[5], seriesColor(5));
+ assert.equal(seven[6], "var(--chart-6)");
+ assert.equal(seven[5], seriesColor(6));
+});
+
+test("siteChartColors: any six sites wear the six validated slots, each once", () => {
+ // Whatever the accents — families, duplicates, Signal, a custom hex, none —
+ // up to six published sites never fall past --chart-6 to the golden angle
+ // (whose sixth hue was within a degree of --chart-5's magenta).
+ const ids = [...ACCENT_IDS, undefined];
+ const validated = [1, 2, 3, 4, 5, 6].map((n) => `var(--chart-${n})`);
+ let seed = 11;
+ const rand = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
+ for (let run = 0; run < 500; run++) {
+ const n = 1 + Math.floor(rand() * 6);
+ const sites = Array.from({ length: n }, () => ({
+ accentId: ids[Math.floor(rand() * ids.length)],
+ }));
+ const out = siteChartColors(sites);
+ assert.equal(new Set(out).size, n, JSON.stringify(sites));
+ for (const c of out) assert.ok(validated.includes(c), `${c} for ${JSON.stringify(sites)}`);
+ if (n === 6) assert.deepEqual([...out].sort(), validated);
+ }
});
test("siteChartColors: never two sites in one colour", () => {
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -4,36 +4,38 @@
// TWO COLOURS PER SITE, ONE HUE.
//
// siteChartColors(sites) — the CHART colour: a slot of the validated chart
-// palette (`--chart-1..5`, tokens.css: blue, green, violet, amber,
-// magenta), picked by the hue family of the site's named accent:
+// palette (`--chart-1..6`, tokens.css: blue, green, violet, amber,
+// magenta, rust), picked by the hue family of the site's named accent:
// blue → blue (1), green → green (2), violet → violet (3),
-// brass → amber (4), sakura → magenta (5).
-// Vermilion, Signal, a custom hex and no accent have no family and take
-// seriesColor(i). No two sites share a colour (below).
+// brass → amber (4), sakura → magenta (5), vermilion → rust (6).
+// Signal, a custom hex and no accent have no family and take
+// seriesColor(i). No two sites share a colour (below), and any six sites
+// wear the six validated slots.
//
// siteColor(site, chart) — the MARK colour (a card's stripe, the hub's chip
// and result edge): a named accent is `var(--swatch-<id>)`, its value on
-// the base in force; a custom hex is that hex, as published; no accent is
-// its chart colour. So a card and its chart layer share a hue family, and a
-// site with no accent matches its layer exactly.
+// the base in force; a custom hex is FITTED to each base the way the site's
+// own pages fit it (lib/accent.ts resolveAccent: 4.5:1 on the ground and
+// with its ink), as one CSS value (perBaseColor); no accent is its chart
+// colour. So a card and its chart layer share a hue family, and a site with
+// no accent matches its layer exactly.
//
// WHY NOT THE ACCENTS THEMSELVES ON THE CHART. The accents are text colours
// (≥ 4.5:1 on each ground), not a categorical palette. Validated as one
// (release 10, the dataviz skill's validator, all pairs, every base): no five
// that include Brass and Blue pass — Brass↔Vermilion ΔE 1.0 deutan,
// Blue↔Violet ΔE 8.5 normal / 1.3 protan on dark — and on dark every accent is
-// above the band. The chart slots of the same hues, in today's homepage order
-// (jeralyzer brass, anilyzer sakura, bonnellyzer violet, hasanalyzer blue,
-// rekietalyzer green) and with the two closest sites swapped, pass every
-// ADJACENT pair on every base; over ALL pairs they are the palette's own
+// above the band. The chart slots of the same hues pass every ADJACENT pair on
+// every base in the homepage's order; over ALL pairs they are the palette's own
// borderline (CVD in the 6–8 floor band, normal ≥ 15.3), legal with the
-// chart's legend, its surface-gap edges and its table. The numbers are in
-// ArchiveGrowthChart.tsx.
+// chart's legend, its surface-gap edges and its table. The sixth slot (release
+// 11) adds no pair below the target: its worst is CVD 9.1 / normal 16.3. The
+// numbers are in ArchiveGrowthChart.tsx and tokens.css.
//
// Pure and client-safe: the hub resolves it in the browser.
-import { parseAccent } from "./accent";
-import { isAccentId, type AccentId } from "./brand";
+import { parseAccent, resolveAccent } from "./accent";
+import { BASE_GROUND_IDS, isAccentId, type AccentId, type BaseGround } from "./brand";
import { seriesColor } from "./homepageChart";
export type SiteColorSource = {
@@ -42,12 +44,14 @@ export type SiteColorSource = {
};
// The chart slot (seriesColor index, 0-based) of each accent's hue family.
+// Signal has none: a teal slot would sit on the family's own UI colour.
export const ACCENT_CHART_SLOT: Readonly<Partial<Record<AccentId, number>>> = {
blue: 0,
green: 1,
violet: 2,
brass: 3,
sakura: 4,
+ vermilion: 5,
};
// One chart colour per site, in `sites` order, all different.
@@ -56,7 +60,8 @@ export const ACCENT_CHART_SLOT: Readonly<Partial<Record<AccentId, number>>> = {
// 2. Every other site (no family, or the later of two) takes its own index's
// colour, seriesColor(i), when no one holds it — so a list with no accents
// is exactly seriesColor(0..n-1), as before — and otherwise the lowest
-// free slot (past the fifth, seriesColor's golden-angle hues).
+// free slot (past the sixth, seriesColor's golden-angle hues). So up to
+// six sites only ever wear the validated --chart-1..6.
export function siteChartColors(sites: readonly { accentId?: string }[]): string[] {
const slot: number[] = new Array(sites.length).fill(-1);
const taken = new Set<number>();
@@ -81,8 +86,29 @@ export function siteChartColors(sites: readonly { accentId?: string }[]): string
return slot.map((k) => seriesColor(k));
}
+// ONE CSS colour that is `values.light` on the light base, `values.sepia` on
+// sepia and `values.dark` on dark — for a colour a component knows and the
+// token sheet cannot (a custom hex is per site). tokens.css sets
+// `--base-light|sepia|dark` to 1 on their own base and 0 on the others, so each
+// channel is a calc() over the three and the browser resolves it to a plain
+// rgb() for whichever base is in force, switching with it. It goes anywhere a
+// colour goes (a background, a border, color-mix()); the fallbacks paint the
+// light value on a page with no token sheet, as `:root` does.
+export function perBaseColor(values: Readonly<Record<BaseGround, string>>): string {
+ const rgb = BASE_GROUND_IDS.map((b) => {
+ const n = parseInt(values[b].slice(1), 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+ });
+ const channel = (c: number) =>
+ `calc(${BASE_GROUND_IDS.map(
+ (b, i) => `${rgb[i][c]} * var(--base-${b}, ${b === "light" ? 1 : 0})`,
+ ).join(" + ")})`;
+ return `rgb(${channel(0)} ${channel(1)} ${channel(2)})`;
+}
+
// A site's mark colour; `chart` is its siteChartColors entry.
export function siteColor(site: SiteColorSource, chart: string): string {
if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`;
- return parseAccent(site.accent) ?? chart;
+ const hex = parseAccent(site.accent);
+ return hex ? perBaseColor(resolveAccent(hex)) : chart;
}
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -32,6 +32,11 @@
• `--swatch-<id>` is each named accent's value ON THIS BASE (lib/brand.ts
ACCENTS — themeTokens.test.ts keeps the two equal), so a picker can
show the colour a choice will actually paint.
+ • `--base-light`, `--base-sepia`, `--base-dark` are 1 on their own base
+ and 0 on the others. They let a colour that differs per base but is
+ known only to a component be ONE CSS value: each channel a calc() over
+ the three (lib/siteColor.ts perBaseColor — a custom-hex site's card on
+ the homepage and the hub, fitted to each ground).
========================================================================== */
@import "tw-animate-css";
@@ -93,6 +98,7 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
+ --color-chart-6: var(--chart-6);
/* Type system */
--font-display: var(--font-display);
@@ -207,6 +213,11 @@ html[data-base="light"] {
customAccentVars, inline on <html>); Signal if it is missing. */
--swatch-custom: var(--accent-custom-light, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 1;
+ --base-sepia: 0;
+ --base-dark: 0;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, black);
--brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
@@ -229,12 +240,19 @@ html[data-base="light"] {
with a secondary encoding, a legend or direct labels. chart-3 is a
violet, well apart from --state-gone (ΔE 24.3 normal, 21.7 protan): it
once WAS the gone hex, so the third archive in a chart read as "gone".
- chart-1 is a blue, not Signal (ΔE 17.3 from it). */
+ chart-1 is a blue, not Signal (ΔE 17.3 from it).
+ chart-6 (release 11) is a rust, Vermilion's family: the sixth site's
+ colour. The only hue family that clears every pair with chart-1..5 on
+ all three bases is the red-browns (OKLCH h ≈ 15–60); this one's worst
+ pair with them is CVD ΔE 12.5 protan and normal 16.6, 8.1:1 on the chart
+ surface and 7.4:1 on the ground. It sits near --state-gone (ΔE 8.4
+ normal), which is only ever labelled text, never a chart mark. */
--chart-1: #3a7de0;
--chart-2: #2f8a57;
--chart-3: #5e3aa8;
--chart-4: #a8741a;
--chart-5: #c24a8a;
+ --chart-6: #823c10;
--chart-surface: #ffffff;
--chart-grid: rgba(22, 28, 33, 0.09);
--chart-axis: #55646e;
@@ -297,6 +315,11 @@ html[data-base="sepia"] {
--swatch-green: #3d772b;
--swatch-custom: var(--accent-custom-sepia, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 0;
+ --base-sepia: 1;
+ --base-dark: 0;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, black);
--brand-soft: color-mix(in srgb, var(--brand) 12%, transparent);
@@ -307,12 +330,14 @@ html[data-base="sepia"] {
/* Same hue order as light, stepped for the paper surface. Adjacent pairs
pass every check; all pairs pass with green ↔ amber in the CVD floor
- band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. */
+ band (6.1). chart-3 vs --state-gone: ΔE 23.7 normal. chart-6, the
+ rust, is light's: worst pair CVD 9.1 protan, normal 16.6. */
--chart-1: #3574d6;
--chart-2: #2a7d4f;
--chart-3: #5e3aa8;
--chart-4: #a8741a;
--chart-5: #bb4585;
+ --chart-6: #823c10;
--chart-surface: #faf4e6;
--chart-grid: rgba(51, 40, 26, 0.09);
--chart-axis: #6b5c43;
@@ -373,6 +398,11 @@ html[data-base="dark"] {
--swatch-green: #7cc46a;
--swatch-custom: var(--accent-custom-dark, var(--swatch-signal));
+ /* Which base this is, as numbers (the header's note; lib/siteColor.ts). */
+ --base-light: 0;
+ --base-sepia: 0;
+ --base-dark: 1;
+
--brand: var(--swatch-signal);
--brand-strong: color-mix(in oklab, var(--brand) 78%, white);
--brand-soft: color-mix(in srgb, var(--brand) 16%, transparent);
@@ -383,12 +413,15 @@ html[data-base="dark"] {
/* Same hue order, stepped into the validator's dark lightness band.
Adjacent pairs pass every check; all pairs pass with green ↔ magenta in
- the CVD floor band (6.9). chart-3 vs --state-gone: ΔE 22.9 normal. */
+ the CVD floor band (6.9). chart-3 vs --state-gone: ΔE 22.9 normal.
+ chart-6, the rust stepped into the band: worst pair CVD 13.9, normal
+ 16.3, 3.2:1 on the surface. */
--chart-1: #3561c8;
--chart-2: #3fa577;
--chart-3: #9a7ee6;
--chart-4: #b98a2a;
--chart-5: #c24c8a;
+ --chart-6: #a54a08;
--chart-surface: #16110a;
--chart-grid: rgba(233, 220, 197, 0.08);
--chart-axis: #8a8170;
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -7,6 +7,7 @@
- **A bucket's retry keeps its log when it empties the bucket.** On a channel's Download stage, "Download with cookies", the partial-download resume and the missing-transcript retry could lose their run log part-way: the video they fetched left the bucket, the page refreshed, and the card disappeared with the log in it. The card now stays, with its log and its button disabled, until the page is reloaded. The Transcribe stage's "Fetch audio" button does the same. (A Diagnostics card still disappears, log and all, when its retry empties it.)
- **A release cut whose commit fails still refreshes the pages.** When the changelog's new heading was written but the commit after it failed, the Cut release form and `pnpm ops cut-release` answered as if nothing had happened and no page showed the new heading until a reload. Both now refresh the changelog pages, and `pnpm ops cut-release` says the file was written. Every refused cut's answer says whether anything was written (`untouched`), and a cut of both changelogs that stopped half-way names the one already cut as well as the failure.
- **`archilyzer` checks the machine, runs one operation offline, starts the MCP server, and is one command from the repo root.** `pnpm archilyzer <command>` is the short form (`pnpm archilyzer --help` lists them all). `pnpm archilyzer doctor` is a read-only report: Node, the checkout, the corpus and whether each channel's media is reachable, `settings.json`, every tool the paths name plus each enabled worker's engine and model, umtool's report-pipeline tools, and this checkout's ports; it exits 1 only for something the machine is set up to do and cannot. `pnpm archilyzer run <operation> <channel> [ids…]` runs diarization, either attribution pass or digest over one channel as the editor's job does (a job record and log under `.jobs/`, the same summary line, the same refusal for an unmounted drive); sync, the metadata scan, downloads and transcription are refused with the reason, because they run on the editor's paced download queue and worker pool. `pnpm archilyzer mcp` starts the MCP server, so it can be registered as `-- pnpm -C "$PWD" archilyzer mcp`. Every other script in `common/bin/` is a subcommand too (`duplicates`, `posts fetch`, `digest plan`, `verify transcripts`, …), and export's `detect:duplicates` script is now `archilyzer duplicates`. Every environment variable is listed, by audience, in the new `ENVIRONMENT.md`, and `DEPLOY_CLOUDFLARE.md` and `DEPLOY_DOCKER.md` are now one `PUBLISH.md`. Settings and `/sites` no longer call the Docker build mode a follow-up, and say what it is: a label. **Build all sites** builds in containers whenever a container engine answers, whichever mode is set.
+- **In high-contrast mode the sidebar's Archilyzer mark keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the mark's slate tile, whose thin ring is only drawn on Dark. In that mode the tile now gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode.
## [0.9.4] - 2026-09-28
- **On the Dark ground the sidebar's Archilyzer mark has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light and Sepia are unchanged, and so is the favicon.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -7,6 +7,8 @@
- **The hub's Ask AI says how many archives it searches.** One line under the heading counts the archives in its search out of all the hub's, the rest being those switched off with the scope chips, and links to the front page, where the chips are.
- **A hub archive whose live chat did not load says so on its chip.** Its videos are searched as before, and its chip now notes the missing live chat, with a Retry that asks for the live chat alone. The archive stays ready, its videos in the results, while the Retry runs. Ask AI, which answers from everything it searches, waits instead while it asks for a missing live chat again.
- **A single-colour social icon shows on every ground.** The footer's social icons take the footer's colour, but that reached only an icon's outer shape: a part that carried its own colour kept it, so X's official logo, which is white, was invisible on the Light ground. An icon drawn in one colour now follows the footer throughout. An icon of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted, since they are its shape. "No fill", gradients, masks and animation timing are never changed. It applies when the settings are next saved, then needs a rebuild and deploy of every export site and the hub.
+- **In high-contrast mode the mark's tile keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground, and it can be as dark as the header mark's tile, whose thin ring is only drawn on Dark. In that mode the tile now gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners; the footer's small Archilyzer mark and the hub's mark get the same. Nothing changes outside that mode. Needs a rebuild and deploy of every export site and the hub.
+- **On the hub, an archive with a colour of its own reads on every ground.** An archive that sets its own colour, rather than one of the named accents, was painted exactly as set on the hub's official cards, chips and result stripes, so a pale colour was faint on Light and Sepia. It is now adjusted for each ground the way the archive's own pages adjust it. No official archive uses one today. Needs a rebuild and deploy of the hub.
## [0.9.4] - 2026-09-28
- **On the Dark ground the mark's tile has a thin outline.** The header mark's ink tile is the colour of the Dark page, so only its lines showed. On Dark the tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines. The small Archilyzer mark by the footer credit and the hub's mark get the same ring in their own slate's unlit colour. Light and Sepia are unchanged, and so are the icons. Needs a rebuild and deploy of every export site and the hub.
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+- **In high-contrast mode the header mark's tile keeps its edge.** In Windows' high-contrast mode (forced colours) the reader's own background replaces the page on every ground and can be as dark as the slate tile, whose ring is only drawn on Dark. In that mode the tile gets a 1-pixel outline in the reader's text colour, on every ground, following its rounded corners. Nothing changes outside that mode.
+- **A sixth official instance has a chart colour of its own.** The growth chart, its legend and `/stats` had five validated colours, so a sixth site fell to a pink within a degree of the fifth's magenta. There is now a sixth, a rust (`--chart-6`: `#823c10` on Light and Sepia, `#a54a08` on Dark), which is Vermilion's hue family, so Jasolyzer's card and its layer will share a hue once it is published. It clears every pair with the other five on all three grounds for colour-blind readers (the dataviz validator, all pairs; worst CVD ΔE 9.1, normal 16.3). Any six sites now wear the six validated colours; `/stats`' sixth channel gets the rust too.
+- **A custom-hex accent reads on every ground.** An Official Instances card whose site sets its own hex painted it exactly as set, so a pale one was all but invisible on Light and Sepia. It is now fitted to each ground the way the site's own pages fit it (4.5:1, `resolveAccent`). No live site uses one.
+- **The e2e no longer needs the operator's data.** It reads a synthetic summary built by the real summary builder (`e2e/fixture-summary.ts`, six sites, deterministic) instead of `public/homepage-summary.json`, so a fresh clone runs every spec instead of skipping eight. `e2e/fixture-accents.ts` is gone.
- **On the Dark ground the header mark's tile has a thin outline.** Its slate tile now has a 1-pixel ring just outside it, following its rounded corners, in the colour of the mark's unlit lines, so the tile's edge shows against the dark page. Light and Sepia are unchanged, and so are the icons.
- **The lines inside the Archilyzer mark are easier to see.** The header mark's and the icons' three unlit lines now read at 3:1 against the slate tile instead of 2:1.
- **The homepage opens on the Dark ground in Signal, even with JavaScript off, and a reader can pick another ground or accent.**
diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx
@@ -8,18 +8,19 @@ import { siteChartColors, siteColor } from "yt-dlp-transcript-common/lib/siteCol
//
// THE STRIPE IS THE SITE'S OWN ACCENT (release 10, the operator's ask):
// siteColor() — a named accent as `var(--swatch-<id>)`, its value on the base
-// in force, from the summary's `accentId`; a custom hex as published; a site
-// with no accent its chart colour. The hub's official cards wear the same
-// (lib/hubSummary.ts officialInstances).
+// in force, from the summary's `accentId`; a custom hex fitted to each base as
+// the site's own pages fit it (release 11: a pale hex published as is was
+// ~1.4:1 on light and sepia); a site with no accent its chart colour. The
+// hub's official cards wear the same (lib/hubSummary.ts officialInstances).
//
// A CARD STILL READS AS ITS LAYER'S LEGEND, BY HUE. The growth chart draws
// each site in the validated chart slot of its accent's hue family
// (siteChartColors: Brass → amber, Blue → blue, Violet → violet, Green →
-// green, Sakura → magenta), not in the accent itself — the accents fail as a
-// chart palette (Brass↔Vermilion ΔE 1.0 deutan, Blue↔Violet 8.5 normal). So a
-// family accent's stripe and its layer are one hue at two values, and a site
-// with no accent matches its layer exactly. Vermilion, Signal and a custom hex
-// have no family, so their layer is a palette colour of its own;
+// green, Sakura → magenta, Vermilion → rust), not in the accent itself — the
+// accents fail as a chart palette (Brass↔Vermilion ΔE 1.0 deutan, Blue↔Violet
+// 8.5 normal). So a family accent's stripe and its layer are one hue at two
+// values, and a site with no accent matches its layer exactly. Signal and a
+// custom hex have no family, so their layer is a palette colour of its own;
// ArchiveGrowthChart's legend names every layer either way.
//
// Numbers are v5 fields; a pre-v5 summary lacks them and the card shows only
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -25,26 +25,30 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// the stack reads as strata, which is what it is.
//
// COLOUR follows the instance, never its rank on this chart: siteChartColors
-// (common/lib/siteColor.ts) gives each site a slot of the per-base --chart-1..5
-// palette (tokens.css: blue, green, violet, amber, magenta) by the hue family
-// of its accent — blue → blue, green → green, violet → violet, brass → amber,
-// sakura → magenta — so a layer shares its hue with the instance card below
-// (ArchiveCards, the accent's own value). A site with no family (Vermilion,
-// Signal, a custom hex, no accent) takes seriesColor(i), and no two layers ever
-// share a colour. The legend above the plot wears the same colours. The layers
-// stack in the summary's order (STACK_ORDER, unchanged).
+// (common/lib/siteColor.ts) gives each site a slot of the per-base --chart-1..6
+// palette (tokens.css: blue, green, violet, amber, magenta, rust) by the hue
+// family of its accent — blue → blue, green → green, violet → violet, brass →
+// amber, sakura → magenta, vermilion → rust — so a layer shares its hue with
+// the instance card below (ArchiveCards, the accent's own value). A site with
+// no family (Signal, a custom hex, no accent) takes seriesColor(i), and no two
+// layers ever share a colour; up to six sites wear only the validated slots.
+// The legend above the plot wears the same colours. The layers stack in the
+// summary's order (STACK_ORDER, unchanged).
//
-// VALIDATED (release 10, the dataviz skill's validator, each base's values on
-// its --chart-surface), with the operator's accents (Jeralyzer Brass, Anilyzer
-// Sakura, Bonnellyzer Violet, Hasanalyzer Blue, Rekietalyzer Green):
+// 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,
+// Rekietalyzer Green; Jasolyzer Vermilion once it is published):
// • adjacent — the stack — in today's order (Jeralyzer, Anilyzer,
// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on every base, worst CVD
-// ΔE 12.3 / 11.1 / 11.8 and normal 17.7 / 15.3 / 16.6 (light / sepia /
-// dark); with Hasanalyzer and Rekietalyzer swapped (3,048 vs 2,855
-// transcripts): PASS, CVD 12.3 / 11.3 / 13.7, normal 19.6 / 19.7 / 16.6;
-// • all pairs: the palette's own borderline in any order — CVD 6.2 / 6.1 /
-// 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with the
-// legend, the surface-gap edges and the table), normal ≥ 15.3.
+// ΔE 13.1 / 11.1 / 11.0 and normal 17.7 / 15.3 / 16.7 (light / sepia /
+// dark; release 10); with Jasolyzer at any place in it: PASS, worst CVD
+// 12.5 / 9.1 / 11.0, normal 16.6 / 15.3 / 16.3 (release 11);
+// • all pairs, all six: the palette's own borderline in any order — CVD 6.2
+// / 6.1 / 6.9 (green↔amber, green↔magenta: the 6–8 floor band, legal with
+// the legend, the surface-gap edges and the table), normal ≥ 15.3. The
+// sixth slot adds no pair under the target: its worst is CVD 9.1 (sepia,
+// against green), normal 16.3 (dark, against amber).
// The accents' own values fail as a chart palette (Brass↔Vermilion ΔE 1.0
// deutan, Blue↔Violet 8.5 normal), which is why the chart wears their hue
// families rather than the accents.
diff --git a/homepage/e2e/brand.spec.ts b/homepage/e2e/brand.spec.ts
@@ -53,7 +53,8 @@ async function onBase(page: Page, base: "light" | "sepia" | "dark") {
}
// The mark's visible box-shadow layers (Tailwind's composite lists four
-// transparent ones before the ring), its corner, and its laid-out size.
+// transparent ones before the ring), its outline, its corner, and its laid-out
+// size.
async function markRing(mark: Locator) {
return mark.evaluate((svg) => {
const cs = getComputedStyle(svg);
@@ -61,6 +62,7 @@ async function markRing(mark: Locator) {
const layers = cs.boxShadow === "none" ? [] : cs.boxShadow.split(/,(?![^(]*\))/);
return {
ring: layers.map((l) => l.trim()).filter((l) => !l.startsWith("rgba(0, 0, 0, 0)")),
+ outline: cs.outlineStyle,
radius: cs.borderTopLeftRadius,
size: [r.width, r.height],
};
@@ -78,12 +80,62 @@ test("on dark the header mark has a 1px ring in the slate's dim; on light and se
await onBase(page, "dark");
expect(await markRing(mark)).toEqual({
ring: ["rgb(88, 105, 119) 0px 0px 0px 1px"], // ICON_PALETTES.archilyzer.dim, #586977
+ outline: "none",
radius: "21.875%",
size: [28, 28],
});
for (const base of ["light", "sepia"] as const) {
await onBase(page, base);
- expect(await markRing(mark), base).toEqual({ ring: [], radius: "21.875%", size: [28, 28] });
+ expect(await markRing(mark), base).toEqual({
+ ring: [],
+ outline: "none",
+ radius: "21.875%",
+ size: [28, 28],
+ });
}
});
+
+// Forced colours (Windows High Contrast; release 11): the reader's Canvas
+// replaces the page on every base and may be as dark as the tile, and the ring
+// is only drawn on dark (an engine that forces box-shadow off drops even that).
+// So there the tile gets a 1px outline in the system CanvasText, on every base,
+// outside it and following the same corner; the mark's box is unchanged.
+// Playwright's emulation applies the forced palette itself (a white Canvas).
+// (Outside forced colours the test above holds: no outline.)
+test.describe("under forced colours", () => {
+ test("the header mark's tile is outlined in CanvasText on every base", async ({ page }) => {
+ await page.emulateMedia({ forcedColors: "active" });
+ await page.goto("/");
+ const mark = page
+ .getByRole("link", { name: "Archilyzer home", exact: true })
+ .locator("svg[data-brand-mark]");
+ for (const base of ["dark", "light", "sepia"] as const) {
+ await onBase(page, base);
+ expect(await page.evaluate(() => matchMedia("(forced-colors: active)").matches)).toBe(true);
+ const got = await mark.evaluate((svg) => {
+ const cs = getComputedStyle(svg);
+ const probe = document.createElement("span");
+ probe.style.color = "CanvasText";
+ document.body.append(probe);
+ const canvasText = getComputedStyle(probe).color;
+ probe.remove();
+ const r = svg.getBoundingClientRect();
+ return {
+ outline: `${cs.outlineStyle} ${cs.outlineWidth}`,
+ isCanvasText: cs.outlineColor === canvasText,
+ offset: cs.outlineOffset,
+ radius: cs.borderTopLeftRadius,
+ size: [r.width, r.height],
+ };
+ });
+ expect(got, base).toEqual({
+ outline: "solid 1px",
+ isCanvasText: true,
+ offset: "0px",
+ radius: "21.875%",
+ size: [28, 28],
+ });
+ }
+ });
+});
diff --git a/homepage/e2e/fixture-accents.ts b/homepage/e2e/fixture-accents.ts
@@ -1,43 +0,0 @@
-import fs from "node:fs";
-import { ACCENTS } from "../../common/lib/brand";
-
-// The instance cards' colours (release 10) need sites with known accents, and
-// the summary on disk is the operator's: its sites may set none at all. So the
-// e2e dev server reads a COPY of it (HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts)
-// with these accents laid over its first sites — a named accent, a custom hex,
-// then no accent — and every other number untouched. playwright.config.ts
-// writes it before the server starts; the specs import the same constants.
-
-// In the summary's order: site 0, site 1; every later site has none.
-export const FIXTURE_ACCENTS = [
- { accent: ACCENTS.brass.onDark, accentId: "brass" as const },
- { accent: "#cc3366" },
-] as const;
-
-export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json";
-
-// Write the fixture copy of `source` to `dest`. False (and any stale copy
-// removed) when there is no readable summary with sites: a source-only
-// checkout, whose data specs skip. The write is a rename, because Playwright
-// evaluates its config again in every worker it starts, while the dev server
-// may be reading the file.
-export function writeFixtureSummary(source: string, dest: string): boolean {
- let summary: { sites?: Array<Record<string, unknown>> } | null = null;
- try {
- summary = JSON.parse(fs.readFileSync(source, "utf8"));
- } catch {
- summary = null;
- }
- if (!Array.isArray(summary?.sites) || summary.sites.length === 0) {
- fs.rmSync(dest, { force: true });
- return false;
- }
- summary.sites = summary.sites.map((site, i) => {
- const { accent: _a, accentId: _id, ...rest } = site;
- return { ...rest, ...(FIXTURE_ACCENTS[i] ?? {}) };
- });
- const tmp = `${dest}.${process.pid}.tmp`;
- fs.writeFileSync(tmp, JSON.stringify(summary));
- fs.renameSync(tmp, dest);
- return true;
-}
diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts
@@ -0,0 +1,140 @@
+import fs from "node:fs";
+import { buildHomepageSummary } from "../../common/lib/homepageSummary";
+import type { Site } from "../../common/lib/site";
+import type { VideoStat } from "../../common/lib/stats";
+
+// THE HOMEPAGE E2E READS A SYNTHETIC SUMMARY, never the operator's.
+//
+// The pages' numbers come from homepage-summary.json, which compose writes into
+// public/ from a real corpus — gitignored, so a fresh clone or worktree has
+// none, and the data specs used to skip there (release 10: 8 of 29). So the
+// e2e dev server reads THIS instead (HOMEPAGE_SUMMARY_FILE, app/lib/summary.ts,
+// honoured only outside a production build): the real builder
+// (buildHomepageSummary) over made-up recordings, deterministic, written by
+// playwright.config.ts to e2e/.e2e-summary.json (gitignored, not linked) before
+// the server starts. Nothing is read from public/ or from another checkout, and
+// every spec that reads numbers reads this file.
+//
+// What the data is shaped to exercise:
+// • six public sites (the chart's six validated slots), in the order the
+// builder sorts them (transcripts, most first);
+// • their accents: a named accent (Brass), a PALE custom hex (fitted per
+// base on the cards, 1.43:1 on the light ground as published), three with
+// none, and Vermilion (the sixth slot's family);
+// • the first site dwarfs the rest, with one week of 12,000 transcripts in
+// May (the "megaspike", outside the six-week window), so /stats' All-time
+// symlog axis reaches 10K while the Recent weeks stay linear and small;
+// • 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.
+
+export const FIXTURE_SUMMARY_NAME = ".e2e-summary.json";
+
+// "Now", for the builder: the build month (September 2026) is partial and is
+// not charted; the week and day buckets end here.
+export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z");
+
+// A pale custom hex: published as is it would be a ghost on light and sepia.
+export const FIXTURE_PALE_HEX = "#f4c2d7";
+
+// The six sites, in summary order. `accent` is the site.json setting (an
+// accent id or a custom hex), as compose reads it; `daily` how many
+// recordings each channel transcribes a day.
+export const FIXTURE_SITES = [
+ { siteId: "fixture-one", siteTitle: "Fixture One", accent: "brass", channels: 4, daily: 15 },
+ { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, channels: 3, daily: 9 },
+ { siteId: "fixture-three", siteTitle: "Fixture Three", accent: undefined, channels: 3, daily: 7 },
+ { siteId: "fixture-four", siteTitle: "Fixture Four", accent: undefined, channels: 2, daily: 8 },
+ { siteId: "fixture-five", siteTitle: "Fixture Five", accent: undefined, channels: 2, daily: 5 },
+ { siteId: "fixture-six", siteTitle: "Fixture Six", accent: "vermilion", channels: 2, daily: 3 },
+] as const;
+
+const DAY = 86_400_000;
+const SPIKE = { site: 0, start: Date.UTC(2026, 4, 11), days: 7, count: 12_000 };
+const HISTORY_DAYS = 200;
+
+const ymd = (t: number) => new Date(t).toISOString().slice(0, 10).replace(/-/g, "");
+
+// A recording's upload date: spread over the 92 months before the build month,
+// deterministically (a multiplicative walk over the index, so every site has
+// uploads in most months).
+function uploadDate(n: number): string {
+ const back = 1 + ((n * 37) % 92); // months before September 2026
+ const d = new Date(Date.UTC(2026, 8 - back, 1 + ((n * 11) % 28)));
+ return ymd(d.getTime());
+}
+
+export function buildFixtureSummary() {
+ const stats: VideoStat[] = [];
+ const channelSites: Record<string, string[]> = {};
+ const sites: Site[] = [];
+ let n = 0;
+ const record = (siteIdx: number, slug: string, name: string, day: number) => {
+ n += 1;
+ stats.push({
+ slug: `${slug}/v${n}`,
+ id: `v${n}`,
+ channelSlug: slug,
+ channel: name,
+ title: `Fixture recording ${n}`,
+ platform: "youtube",
+ uploadDate: uploadDate(n),
+ downloadedDate: ymd(day),
+ transcribedDate: ymd(day),
+ timestamp: null,
+ duration: 1200 + ((n * 97) % 5400),
+ viewCount: null,
+ likeCount: null,
+ commentCount: null,
+ channelFollowerCount: null,
+ categories: [],
+ tags: [],
+ language: "en",
+ isLivestream: false,
+ mediaType: "video",
+ // One in forty re-checked and found gone at the source.
+ status: n % 40 === siteIdx ? "deleted" : "available",
+ hasTranscript: true,
+ cueCount: null,
+ coverage: null,
+ });
+ };
+ const today = Date.UTC(2026, 8, 15);
+ FIXTURE_SITES.forEach((s, i) => {
+ const slugs = Array.from({ length: s.channels }, (_, c) => `${s.siteId}-ch${c + 1}`);
+ for (const slug of slugs) channelSites[slug] = [s.siteId];
+ sites.push({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ siteDescription: `${s.siteTitle}, an e2e fixture archive.`,
+ siteUrl: `https://${s.siteId}.example`,
+ channels: slugs.map((slug) => ({ slug })),
+ ...(s.accent ? { accent: s.accent } : {}),
+ } as unknown as Site);
+ slugs.forEach((slug, c) => {
+ const name = `${s.siteTitle} Channel ${c + 1}`;
+ for (let d = 0; d < HISTORY_DAYS; d++) {
+ const day = today - d * DAY;
+ // A little day-to-day rhythm, never zero.
+ const count = Math.max(1, s.daily - c * 2 + ((d + c) % 3));
+ for (let k = 0; k < count; k++) record(i, slug, name, day);
+ }
+ });
+ });
+ // The megaspike: one week of bulk transcription on the first site's first
+ // channel.
+ const spikeSite = FIXTURE_SITES[SPIKE.site];
+ for (let k = 0; k < SPIKE.count; k++) {
+ record(SPIKE.site, `${spikeSite.siteId}-ch1`, `${spikeSite.siteTitle} Channel 1`, SPIKE.start + (k % SPIKE.days) * DAY);
+ }
+ return buildHomepageSummary(stats, channelSites, sites, FIXTURE_NOW);
+}
+
+// Write the fixture summary to `dest`. The write is a rename, because
+// Playwright evaluates its config again in every worker it starts, while the
+// dev server may be reading the file.
+export function writeFixtureSummary(dest: string): void {
+ const tmp = `${dest}.${process.pid}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(buildFixtureSummary()));
+ fs.renameSync(tmp, dest);
+}
diff --git a/homepage/e2e/instance-colours.spec.ts b/homepage/e2e/instance-colours.spec.ts
@@ -1,34 +1,31 @@
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 { FIXTURE_ACCENTS, FIXTURE_SUMMARY_NAME } from "./fixture-accents";
+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-<id>)`), a custom hex
-// as published, 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), so a Brass card and its legend
-// swatch are one hue; a card with no accent equals its legend swatch. The dev
-// server reads the summary on disk with fixture accents laid over its first
-// sites (fixture-accents.ts): site 0 Brass, site 1 the custom #cc3366, the rest
-// none — so with five sites, site 3's own slot (chart-4) is Brass's and it
-// takes the lowest free one.
+// 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.
const BASE_KEY = "ytdlp-tb:base";
-// The fixture copy the dev server reads: its sites, and whether it has the
-// monthly series the growth chart (and its legend) is drawn from.
-function fixture(): { sites: Array<{ accentId?: string }>; chart: boolean } | null {
- try {
- const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME);
- const s = JSON.parse(fs.readFileSync(file, "utf8"));
- if (!Array.isArray(s?.sites) || s.sites.length === 0) return null;
- return { sites: s.sites, chart: Array.isArray(s.monthly) && s.monthly.length > 0 };
- } catch {
- return null;
- }
+// 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.
@@ -79,51 +76,64 @@ async function useBase(page: Page, base: "dark" | "light" | "sepia") {
test("each instance card wears its site's accent on every base, in its chart layer's hue", async ({
page,
}) => {
- const f = fixture();
- test.skip(!f, "no summary on disk — the page has no instance cards");
- const { sites, chart: hasChart } = f!;
+ const sites = fixtureSites();
const n = sites.length;
- expect(FIXTURE_ACCENTS[0].accentId).toBe("brass");
- // The chart colours the page must wear, as the helper assigns them.
+ 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(new Set(chart).size).toBe(n);
+ expect(chart[5]).toBe("var(--chart-6)");
+ expect([...chart].sort()).toEqual([1, 2, 3, 4, 5, 6].map((k) => `var(--chart-${k})`));
await page.goto("/");
- const onBase = {
- dark: ACCENTS.brass.onDark,
- light: ACCENTS.brass.onLight,
- sepia: ACCENTS.brass.onSepia,
- } as const;
+ const on = { dark: "onDark", light: "onLight", sepia: "onSepia" } as const;
+ const pale = resolveAccent(FIXTURE_PALE_HEX);
for (const base of ["dark", "light", "sepia"] 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 site's chart colour. A summary
- // with none draws no chart at all.
- await expect(
- page.getByRole("img", { name: /transcripts by the month/i }),
- ).toHaveCount(hasChart ? 1 : 0);
- expect(legend, "legend swatches").toHaveLength(hasChart ? n : 0);
- for (let i = 0; i < legend.length; i++) {
+ // legend (and so each layer) wears each site's chart colour, six 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[i], `legend ${i}`).toBe(await resolve(page, chart[i]));
}
+ expect(new Set(legend).size, "six layers, six colours").toBe(n);
// 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, onBase[base]));
+ 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], await resolve(page, chart[0])), "brass vs its layer").toBeLessThan(25);
- // Site 1 (when there is one): the custom hex as published.
- if (n > 1) expect(stripes[1]).toBe(await resolve(page, FIXTURE_ACCENTS[1].accent));
- // Every other site has no accent: its chart colour, the same as its
- // legend swatch.
- for (let i = 2; i < n; i++) {
+ expect(hueGap(stripes[0], legend[0]), "brass vs its layer").toBeLessThan(25);
+
+ // Site 1: the pale custom hex, FITTED to this base — on light and sepia
+ // 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.
+ for (let i = 2; i <= 4; i++) {
expect(stripes[i], `site ${i}`).toBe(await resolve(page, chart[i]));
- if (hasChart) expect(stripes[i], `site ${i} vs legend`).toBe(legend[i]);
+ 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.
+ 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);
}
});
diff --git a/homepage/e2e/marketing.spec.ts b/homepage/e2e/marketing.spec.ts
@@ -1,12 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { test, expect } from "@playwright/test";
+import { FIXTURE_SUMMARY_NAME } from "./fixture-summary";
// The home page's job is to say what Archilyzer is and offer the download.
// Nothing asserted here names a count, a site or a headline number. The numeric
// parts are gated on corpus data, so the specs that touch them read the summary
-// on disk and branch on it — a source-only build is the build this page has to
-// work on.
+// the server reads — the e2e's synthetic one (fixture-summary.ts) — and branch
+// on it, as a source-only build (no summary) is legal too.
test.beforeEach(async ({ page }) => {
await page.goto("/");
@@ -51,9 +52,9 @@ test("every nav destination resolves", async ({ page }) => {
});
// The numeric parts of the page are gated on the build-time summary, so each
-// assertion reads the summary on disk and expects exactly what it implies: the
-// part present with data, absent without. A source-only build (no summary) is
-// a legal state and must render none of them rather than zeroes.
+// assertion reads the summary the server reads and expects exactly what it
+// implies: the part present with data, absent without. A source-only build (no
+// summary) is a legal state and must render none of them rather than zeroes.
function readSummary(): {
monthly?: unknown[];
sites?: unknown[];
@@ -61,12 +62,7 @@ function readSummary(): {
generatedAt?: string;
} | null {
try {
- const file = path.resolve(
- test.info().project.testDir,
- "..",
- "public",
- "homepage-summary.json",
- );
+ const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME);
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
diff --git a/homepage/e2e/stats.spec.ts b/homepage/e2e/stats.spec.ts
@@ -1,16 +1,15 @@
import { test, expect, type Page } from "@playwright/test";
+import { FIXTURE_SITES } from "./fixture-summary";
// The /stats/ dashboard's cross-site chart: the three site-based presets, plus
// the power-user Customize toolbox (chart types, scale, exclude-outlier,
// indexed) and its guard rails. Asserts against the live recharts SVG.
//
-// This file HARD-REQUIRES corpus data — it asserts an exact site-line count, so
-// there is nothing meaningful to check on a `build:nodata` tree. The whole file
-// skips when /stats/ served its no-data panel instead of the dashboard, which
-// keeps a source-only checkout green without weakening the assertions for a
-// configured one. (The site-line count follows the corpus: it is read from the
-// rendered legend rather than pinned at 3, because the operator's public-site
-// list grows.)
+// The e2e server reads the synthetic summary (fixture-summary.ts), so the
+// dashboard is always there — in a fresh clone too, where these used to skip
+// on "no corpus data" — and its numbers are known: six sites, the first with
+// a 12,000-transcript week in May (the All-time axis reaches 10K) and every
+// site's recent weeks in the hundreds.
const pressedPreset = (page: Page) =>
page.locator('[aria-label="Chart preset"] button[aria-pressed="true"]');
@@ -30,24 +29,15 @@ const fullLines = (page: Page) =>
).length,
);
-// How many public sites this build's summary actually has. The suite pins its
-// counts to THIS rather than a literal, because the operator's public-site list
-// grows (it went 3 → 4 when Bonnellyzer shipped) and a literal would turn a new
-// archive into a red suite.
-let siteCount = 0;
+// The fixture's public sites, one line each.
+const siteCount = FIXTURE_SITES.length;
test.beforeEach(async ({ page }) => {
await page.goto("/stats/");
- // A build with no corpus data serves the honest no-data panel here instead of
- // the dashboard. Nothing below is meaningful without numbers.
- const noData = await page
- .getByText("This build shipped without dashboard data")
- .isVisible()
- .catch(() => false);
- test.skip(noData, "no corpus data in this build — /stats/ has no dashboard");
+ // Never the no-data panel: the e2e always serves the fixture summary.
+ await expect(page.getByText("This build shipped without dashboard data")).toHaveCount(0);
await page.waitForSelector(".recharts-surface");
- siteCount = await page.locator(".recharts-line-curve").count();
- expect(siteCount, "summary should carry at least two public sites").toBeGreaterThanOrEqual(2);
+ await expect(page.locator(".recharts-line-curve")).toHaveCount(siteCount);
});
test("Recent is the default: every site line is full, on a linear axis", async ({
diff --git a/homepage/playwright.config.ts b/homepage/playwright.config.ts
@@ -3,11 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
import {
FIXTURE_SUMMARY_NAME,
writeFixtureSummary,
-} from "./e2e/fixture-accents";
+} from "./e2e/fixture-summary";
// Homepage e2e. Runs against `next dev` (default mode) so it reflects uncommitted
-// source — /stats/ reads its build-time summary from public/ on disk, which is
-// present in dev. Kill any stale dev server on the port between runs.
+// source. Kill any stale dev server on the port between runs.
//
// The port comes from HOMEPAGE_E2E_PORT, the block scripts/worktree.mjs already
// allocates per worktree (3040 on main). It used to read `PORT`, which is the
@@ -16,15 +15,13 @@ import {
const PORT = Number(process.env.HOMEPAGE_E2E_PORT ?? 3040);
const baseURL = `http://localhost:${PORT}`;
-// The dev server reads a copy of the summary on disk with fixture accents on
-// its first sites (e2e/fixture-accents.ts), so the instance cards' colours are
-// exact whatever the operator's sites set. Gitignored; rewritten every run. No
-// summary on disk, no copy: the server reads public/ as before and finds none.
+// The dev server reads a SYNTHETIC summary (e2e/fixture-summary.ts), never
+// public/homepage-summary.json: a fresh clone has none, and the operator's
+// numbers move. Built by the real summary builder, deterministic, written
+// here to e2e/.e2e-summary.json (gitignored) before the server starts; the
+// specs that read numbers read the same file.
const FIXTURE_SUMMARY = path.resolve(process.cwd(), "e2e", FIXTURE_SUMMARY_NAME);
-const hasFixtureSummary = writeFixtureSummary(
- path.resolve(process.cwd(), "public", "homepage-summary.json"),
- FIXTURE_SUMMARY,
-);
+writeFixtureSummary(FIXTURE_SUMMARY);
export default defineConfig({
testDir: "./e2e",
@@ -39,7 +36,7 @@ export default defineConfig({
url: baseURL,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
- env: hasFixtureSummary ? { HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY } : {},
+ env: { HOMEPAGE_SUMMARY_FILE: FIXTURE_SUMMARY },
},
use: {
baseURL,
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -6751,9 +6751,12 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
it goes through `siteColor` (`common/lib/siteColor.ts`): a NAMED accent is
`var(--swatch-<id>)` (per base) from the summary's `accentId`, used only when `hub-sites.json`'s
hex (on-dark only) is absent or equal to the summary's; the homepage's `ArchiveCards` use the
- same helper. With no accent the fallback is the site's CHART colour, `siteChartColors(sites)`:
- the `--chart-N` of its accent's hue family (blue 1, green 2, violet 3, brass 4, sakura 5), else
- `seriesColor(i)` when free, else the lowest free slot — never two sites in one colour. The growth
+ same helper. A CUSTOM hex is fitted per base (release 11, slice O2): `perBaseColor(resolveAccent(hex))`,
+ one `rgb(calc(…))` over tokens.css's one-hot `--base-light|sepia|dark`. With no accent the
+ fallback is the site's CHART colour, `siteChartColors(sites)`: the `--chart-N` of its accent's hue
+ family (blue 1, green 2, violet 3, brass 4, sakura 5, vermilion 6 — `--chart-6` is a rust since
+ 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
diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -69,6 +69,11 @@ tile, following its corner (`border-radius` 21.875% = rx 112 of 512), in the pal
(`#6b5d47`, `#586977`: 3.09:1 and 3.48:1 on the ink page), as a box-shadow under `.dark` only, so no
layout moves and light and sepia are unchanged. The icon files carry no ring.
+**Under forced colours the tile has a 1px `CanvasText` outline, on every base** (2026-09-28,
+release 11, slice O2; `release-11.md`): the reader's Canvas replaces the page there and may be as
+dark as the tile. Same place and corner as the ring (outline offset 0); outside forced colours the
+classes match nothing, and the marks render pixel-identical on light, sepia and dark.
+
Icon variants:
- `any`: `rx=112`.
- `maskable`: full-bleed ground, the mark scaled 0.8 about (256,256).
@@ -119,7 +124,8 @@ Each base declares all 48 colour tokens listed in `homepage/e2e/theme.spec.ts:63
- **Sepia** derives its status, panel and chart values from the archive paper block, darkened
for a light ground.
- **Dark** adds the `--destructive-soft` and `--state-gone(-soft)` it lacks today.
-- **Charts do not follow the accent:** each base has its own fixed `--chart-1..5`. Re-validate
+- **Charts do not follow the accent:** each base has its own fixed `--chart-1..5` (`--chart-6`, a
+ rust, since release 11: light and sepia `#823c10`, dark `#a54a08`; `release-11.md`, slice O2). Re-validate
chart-3 against `--state-gone` with the dataviz skill's validator (FACTS.md ~6442 records a
real past collision), and keep chart-1 ≠ Signal.
diff --git a/plans/release-11.md b/plans/release-11.md
@@ -936,6 +936,181 @@ homepage: the homepage's footer uses the same `settings.socialLinks`
(`homepage/app/components/Footer.tsx:20`; no `homepage.json` or `site.json` overrides them).
Whether `homepage/CHANGELOG.md` gets a bullet is the parent's call (O2 owns the homepage).
+### Slice O2, as shipped — homepage and brand lows (2026-09-28)
+
+Release 10 left four lows on the homepage and the mark: AC's "a custom hex is painted unfitted on
+every base" and "a sixth published site would nearly repeat Anilyzer's magenta", and MR's "the ring
+is a box-shadow, which forced-colours mode removes" and "a fresh worktree's homepage e2e skips 8
+specs". Branch `r11/homepage-lows` off `main` `2162db92`, `main` `c1d4790a` merged in (a fast-forward)
+before the first commit, worktree `/home/user/Projects/r11-homepage-lows` (block #3), one Opus
+implementer. Scratch files `o2-*` in the job's `tmp/overnight`.
+
+**What shipped**
+
+- **A custom hex is fitted per base, on the homepage cards and the hub's official cards.**
+ - `siteColor` paints one through `resolveAccent`, the fit the site's own pages use: 4.5:1 on the
+ ground and with its ink.
+ - The three values travel as ONE CSS value, `perBaseColor` (`common/lib/siteColor.ts`): each
+ channel is a `calc()` over new one-hot tokens `--base-light|sepia|dark`. They are 1 on their own
+ base and 0 on the others, declared in each base block of `tokens.css`. The browser resolves the
+ value to a plain `rgb()` and switches it with the base.
+ - A single string was needed because `siteColor`'s result goes, as is, into every hub surface:
+ card, chip, result stripe. So the fix is in the shared function, and no file under
+ `export/app/components/hub/**` changed.
+ - The fallbacks paint the light value on a page with no token sheet.
+ - The e2e's pale `#f4c2d7` (1.43:1 on the light ground as published) paints `#846974` on light,
+ `#7c636e` on sepia and `#f4c2d7` on dark.
+- **A sixth validated chart slot, `--chart-6`, a rust.** Light and sepia `#823c10`, dark `#a54a08`.
+ - `seriesColor(5)` is `var(--chart-6)` (`CHART_SLOTS = 6`), no longer the golden angle's
+ `hsl(328 64% 55%)`.
+ - `ACCENT_CHART_SLOT.vermilion = 5`. The rust is Vermilion's hue family: 8–10° of OKLCH hue from
+ its swatches, and 27–33° from the nearest other slot (amber).
+ - So Jasolyzer, once published, wears the rust at any rank, and any six sites wear the six
+ validated slots. Signal, a custom hex and no accent still have no family; they take the lowest
+ free slot, and the sixth when it is free.
+ - `REQUIRED_TOKENS` is 50 and `--color-chart-6` is in `@theme`. The homepage `theme.spec` checks
+ that every base declares it.
+- **Forced colours: a 1px `CanvasText` outline on the mark's tile, on every base.** It is two classes
+ on `BRAND_MARK_RING_CLASS`:
+ - `forced-colors:outline` and `forced-colors:outline-[color:CanvasText]`.
+ - They compile to `@media (forced-colors:active){…outline-width:1px…outline-color:canvastext}`.
+ - The outline has offset 0, so it sits where the dark ring does and follows the same corner.
+ - Outside forced colours the two classes match nothing.
+ - Every `BrandMark` has it: the export header and footer credit, the hub, the homepage and the
+ editor sidebar.
+- **The homepage e2e reads a synthetic summary.**
+ - `homepage/e2e/fixture-summary.ts` replaces `fixture-accents.ts`. It runs the real
+ `buildHomepageSummary` over made-up recordings and is deterministic (`FIXTURE_NOW` 2026-09-15).
+ - Its six public sites are, in order: Brass; the pale custom hex; three with no accent; Vermilion.
+ - The first site has a 12,000-transcript week in May, so the All-time axis reaches 10K. Every site
+ transcribes every day of the last 200.
+ - `playwright.config.ts` writes it to `e2e/.e2e-summary.json` before the server starts. That path is
+ gitignored, as is a killed run's temp copy, and is not linked. It always passes
+ `HOMEPAGE_SUMMARY_FILE`.
+ - Nothing is read from `public/` or from the primary.
+ - `stats.spec` no longer skips, and its site count is the fixture's six. `marketing.spec` reads the
+ fixture, the file the server reads. `instance-colours.spec` checks the fitted pale hex per base,
+ Vermilion's card in its rust layer's hue, and the six legend swatches as the six validated slots.
+
+**The sixth slot, validated.** Slice AC's method (`ac-validate.sh` / `ac-layers.mts`): the dataviz
+skill's validator on each base's own values, read from `tokens.css`. It runs over all pairs, since
+the homepage order moves with transcript counts. Contrast is checked against `--chart-surface` AND
+the page ground. The command, from `common/`, is `pnpm exec tsx $T/o2-validate.mts`. For every run it
+calls `node $T/dataviz/scripts/validate_palette.js "<chart-1..6>" --mode light|dark --surface <chart
+surface | page ground> --pairs all|adjacent`. Log: `o2-validate.log`.
+
+| base | `--chart-1..6` | surface / ground | band | chroma | CVD, all pairs | normal, all pairs | contrast | exit |
+|---|---|---|---|---|---|---|---|---|
+| light | `#3a7de0 #2f8a57 #5e3aa8 #a8741a #c24a8a` **`#823c10`** | `#ffffff` / `#f3f6f7` | PASS | PASS | WARN 6.2 (amber↔green) | PASS 15.4 | PASS / PASS | 0 / 0 |
+| sepia | `#3574d6 #2a7d4f #5e3aa8 #a8741a #bb4585` **`#823c10`** | `#faf4e6` / `#f4ecd8` | PASS | PASS | WARN 6.1 (amber↔green) | PASS 15.3 | PASS / PASS | 0 / 0 |
+| dark | `#3561c8 #3fa577 #9a7ee6 #b98a2a #c24c8a` **`#a54a08`** | `#16110a` / `#0c0a08` | PASS | PASS | WARN 6.9 (magenta↔green) | PASS 15.4 | PASS / PASS | 0 / 0 |
+
+- **The WARN pairs are the five's own floor band, which release 10 recorded; none involves the
+ sixth.** The sixth's own pairs, each run alone against `--chart-1..5`, are:
+ - worst CVD 12.5 / 9.1 / 13.9 (light / sepia / dark; against green, green, magenta);
+ - worst normal 16.6 / 16.6 / 16.3 (against amber);
+ - all above the target of 8 and the floor of 15.
+- **Contrast:** 8.1 / 7.4 / 3.2 to 1 on the chart surface and 7.4 / 6.9 / 3.4 to 1 on the ground.
+- **The stack (adjacent), as `siteChartColors` resolves it** for the operator's mapping, with
+ Jasolyzer (Vermilion) at each of the six places: PASS on every base. Worst CVD 12.5 / 9.1 / 11.0,
+ normal 16.6 / 15.3 / 16.3. Before this slice the same run could not even resolve:
+ `jasolyzer→hsl(328 64% 55%)` (`o2-validate-pre.log`).
+- **Why a rust.** A search over every OKLCH hue took each base's best L and C inside the band at
+ ≥ 3:1 on both the surface and the ground (`o2-chart6-search.mjs`, `-hue`, `-margin`; logs of the
+ same names). Only the red-browns, h ≈ 15–60, clear every pair with `--chart-1..5` on all three
+ bases. With 0.01 of L margin and ≥ 3.2:1, the best is h 48. The other hues fail:
+ - teal fails CVD against blue and green (4.0–6.4), and would sit on Signal;
+ - olive fails the normal floor on light and sepia (11–14.9);
+ - an electric blue or violet fails on dark, or reads as a second chart-1 or chart-3.
+- **A question for the operator: the rust sits near `--state-gone`.** The validator FAILs that pair,
+ so it is information, not a gate:
+ - normal ΔE 8.4 / 7.2 / 13.1, CVD 5.4 / 3.6 / 11.7;
+ - release 10 (S2's review) accepted amber and magenta at 11.8–14.5, because gone is only ever
+ labelled text, never a chart mark; the rust is closer;
+ - no sixth hue clears both `--chart-1..5` and gone ≥ 15 on every base; the gone-constrained
+ search's best, an electric violet, fails the palette's own normal floor (14.4 on light and
+ sepia; `o2-chart6-hue-gone.log`);
+ - on the homepage, gone is the FamilyStats figure and the rail's badge.
+
+**Forced colours, as rendered** (`o2-shots.mjs`, `o2-shots.log`, `o2-forced.png`).
+- **Normal modes are unchanged.** Each mark was shot as built and again with the two new classes
+ removed, which is `main`'s class string, then compared by pixel (`magick compare -metric AE`).
+ All 15 pairs differ by **0 px**: the homepage header, the site header and footer, and the hub header
+ and footer, on light, sepia and dark.
+- **The same pairs under forced colours differ,** on a light (white Canvas) and a dark (black Canvas)
+ forced palette: all 18.
+- **The worst case before, row 2 of `o2-forced.png`.** A site's ink tile on light or sepia under a
+ black Canvas had no edge at all. Now it has a white outline.
+- **Found: Chromium keeps the svg's box-shadow in forced colours.** Its UA sheet gives `svg`
+ `forced-color-adjust: preserve-parent-color`, so MR's premise ("which forced-colours mode removes")
+ does not hold there. The dark ring stays, and the outline is painted over the same pixel ring. What
+ the mode does do is put the reader's Canvas under the tile on every base, which is why the outline
+ is on every base.
+- **Playwright applies the forced palette** (`page.emulateMedia({ forcedColors: "active" })`). This
+ version's `test.use` has no `forcedColors`.
+
+| sha | what |
+|---|---|
+| `fbd6318a` | `common:` `--chart-6` + the base flags (`tokens.css`), `seriesColor` six, `ACCENT_CHART_SLOT.vermilion`, `perBaseColor`, `siteColor` fits a custom hex; `REQUIRED_TOKENS` 50; unit tests (siteColor, hubSummary, themeTokens); the homepage chart and card headers' notes |
+| `e253e244` | `common:` the forced-colours outline on `BRAND_MARK_RING_CLASS`; `BrandMark.test.ts` +1; the homepage `brand.spec` forced-colours test + outline none in the normal-mode test |
+| `c37d620e` | `homepage(e2e):` `fixture-summary.ts` (replaces `fixture-accents.ts`), `playwright.config.ts`, `stats`/`marketing`/`instance-colours` specs, `.gitignore` |
+| `7685e701` | `changelogs:` export and editor `[Unreleased]` bullets (the existing sections), the homepage's four |
+| _this_ | `plans:` this record; `FACTS.md`'s official-instance colour entry; dated notes in `brand-and-themes.md` (mark, charts) |
+
+**Gates** (logs `o2-*.log`):
+- **tsc** clean before each code commit (33–71 s; `o2-tsc{1..4}.log`). The one failure along the way
+ was `forcedColors` in `test.use`; the spec now uses `emulateMedia`.
+- **Units:**
+ - common **2,056/2,056** (2,051 on `main` + 5);
+ - editor unit **85/85**, `test:scripts` **175 + 1 skipped**, mcp **269/269**, homepage unit **2/2**.
+- **Builds:**
+ - Timings: editor ok (38 s), export site ok (23 s), export hub ok (25 s; `out/ask` present),
+ homepage ok (12 s; no `public/` data, so the no-data page and the header mark).
+ - Each stylesheet carries the `forced-colors:active` outline rule, `--chart-6` (×2 `#823c10`,
+ ×1 `#a54a08`) and the three flags per base.
+ - The primary's `export/public` is byte-identical before and after the builds and the e2e: 392
+ entries, 380 files, tree md5 `5c90d8e63753`, content md5 `4a0c46b39eeb`.
+- **e2e** (queued behind O1 for about a minute; nothing failed, so no re-runs):
+ - homepage FULL, from this worktree with NO `homepage/public` data (only its tracked `_headers`):
+ **30 passed, 0 skipped** (42 s). Release 10's fresh-worktree run was 21 passed / 8 skipped, and
+ 29 passed with the primary's data copied in; 30 is those 29 plus the forced-colours test.
+ - export `brand` + `site-branding` + `theme` + `theme-accent`: **20 passed** (50 s).
+ - `e2e:hub`: **26 passed** (1.0 min).
+ - editor `branding.spec`: **4 passed** (25 s).
+
+**They bite** (`o2-bite.sh`, `o2-bite*.log`). Each item was reverted to the old code on its own,
+with the new tests kept:
+1. **Custom hex as published.** Unit: 4 fail (the two `officialInstances` custom-hex cases, the fitted
+ test, and the unrecognised-id fall-through). e2e `instance-colours`: expected `rgb(132, 105, 116)`,
+ received `rgb(244, 194, 215)`.
+2. **Five slots, no vermilion family, no `--chart-6`.** Unit: 6 fail (the required tokens, the
+ charts test, the family slots, `seriesColor` six, the seventh-site case, and any-six-wear-six).
+ e2e: expected `var(--chart-6)`, received `hsl(328 64% 55%)`.
+3. **No outline.** Unit: 1 fails. e2e forced-colours test: `isCanvasText: false`.
+4. **`main`'s e2e setup** (its specs, config and `fixture-accents.ts`) in this worktree: **21 passed,
+ 8 skipped**. The skips are `stats.spec` ×7 and `instance-colours.spec`.
+
+**Found and left**
+- **The hub's ADDED archives still paint a custom hex as set.** This is `ArchiveShelf`'s
+ `site.accent || FALLBACK_ACCENT`, and the chips and stripes that read the registry's raw `accent`.
+ It is `export/app/components/hub/**`, O1's, so it was not touched here. The fix is one call where
+ it parses: `perBaseColor(resolveAccent(hex))`. For O1 or the parent.
+- **The rust near `--state-gone`:** the operator question above.
+- **Two things do not reach the sixth slot:**
+ - `ChartView.tsx` (export and editor charts) cycles `var(--chart-${(i % 5) + 1})`, so it never
+ wears `--chart-6`; its sixth series repeats chart-1, as before. Not a homepage surface.
+ - Past six, `seriesColor`'s golden angle still gives hue 105 (7th) and 243 (8th, near chart-1's
+ blue). Only `/stats`' by-channel views reach them (up to 8 channels plus Other).
+- **The no-data paths have no spec now.** The e2e server always has the fixture, so `/` and `/stats`
+ without a summary go untested; `marketing.spec` keeps its absent branches. Before, those paths ran
+ only in a fresh worktree, where the data specs skipped instead. A second web server without the
+ fixture would cover them.
+- **O6's `E2E_` rename will meet `HOMEPAGE_SUMMARY_FILE`,** which `app/lib/summary.ts` reads and
+ `playwright.config.ts` sets.
+- **The live sites carry none of this until rebuilt:** every export site and the hub (the outline,
+ and the hub's fitted hex), the homepage (all four), and the editor on its next restart (the
+ sidebar outline).
+
## Rollout
Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart,