commit 4b243682d58c765517f3beca4cf3cddc36259367
parent 566af0096a78e231d6867e6c5d5b397b16e09daf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:53:47 -0400
Merge r11/homepage-lows (follow-ups O2b) — the hub's visitor-added archives get their colour fitted per base (a remote accent that is not a six-digit hex falls back); a Figma-style clip path no longer makes a one-colour social icon count as two; the homepage's no-data pages have an e2e again; the review's comment nits
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
16 files changed, 320 insertions(+), 22 deletions(-)
diff --git a/common/lib/normalizeSocialSvg.test.ts b/common/lib/normalizeSocialSvg.test.ts
@@ -145,8 +145,41 @@ test("an empty self-closing <mask/> does not swallow what follows it", () => {
assert.ok(out.includes(`<mask id="b"><rect fill="white" width="8" height="8"/></mask>`));
});
+// Figma's export of a one-colour icon: the shape in a group clipped to the
+// frame, and the clip a white rect in <defs>. A clip never paints, so its white
+// is no second colour (release 11, O2b; O1's review, "a <clipPath>'s fill").
+const FIGMA = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_1_2)">
+<path d="M12 2L2 22h20L12 2z" fill="black"/>
+</g>
+<defs>
+<clipPath id="clip0_1_2">
+<rect width="24" height="24" fill="white"/>
+</clipPath>
+</defs>
+</svg>`;
+
+test("Figma: a clip's white is no colour — the black shape is themed, the clip kept", () => {
+ const out = normalized(FIGMA);
+ assert.deepEqual(fills(out), ["none", "currentColor", "white"]);
+ assert.equal(
+ out,
+ FIGMA.replace(' width="24" height="24" viewBox', ' viewBox')
+ .replace("<svg ", '<svg aria-hidden="true" ')
+ .replace('fill="black"', 'fill="currentColor"'),
+ );
+ // A self-closing clipPath passes over just itself; a clip with two colours
+ // inside still counts as none of the icon's.
+ const selfClosed =
+ `<svg viewBox="0 0 8 8"><clipPath id="c"/><path fill="#fff" d="M0 0"/>` +
+ `<clipPath id="d"><rect fill="white"/><circle fill="#f00"/></clipPath></svg>`;
+ const out2 = normalized(selfClosed);
+ assert.ok(out2.includes(`<clipPath id="c"/><path fill="currentColor" d="M0 0"/>`));
+ assert.ok(out2.includes(`<clipPath id="d"><rect fill="white"/><circle fill="#f00"/></clipPath>`));
+});
+
test("idempotent: a stored icon normalizes to itself", () => {
- for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE, YOUTUBE, BADGE]) {
+ for (const raw of [X_COM, KIWI_FARMS, FIXTURE, BARE, YOUTUBE, BADGE, FIGMA]) {
const once = normalized(raw);
assert.equal(normalized(once), once);
}
diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts
@@ -1341,11 +1341,15 @@ function mapTagFills(tag: string, paint: (value: string) => string): string {
// The children, tag by tag. Passed over whole, never read or changed:
// - a <mask> (its white and black say how much shows through, not what
-// colour) — self-closing first, so an empty `<mask …/>` cannot swallow
-// everything up to the next `</mask>`;
+// colour) and a <clipPath> (a clip never paints: only its shape counts —
+// Figma exports almost every icon as a path clipped by a
+// `<clipPath><rect fill="white"/></clipPath>`, release 11, O2b) —
+// self-closing first, so an empty `<mask …/>` or `<clipPath …/>` cannot
+// swallow everything up to the next closing tag;
// - an animation tag, whose `fill="freeze"` / `fill="remove"` is timing.
-const CHILD_TAG = /<mask\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<[a-zA-Z][^>]*>/gi;
-const PASSED_OVER = /^<(?:mask|animate\w*|set)\b/i;
+const CHILD_TAG =
+ /<(?:mask|clipPath)\b[^>]*\/>|<mask\b[\s\S]*?<\/mask\s*>|<clipPath\b[\s\S]*?<\/clipPath\s*>|<[a-zA-Z][^>]*>/gi;
+const PASSED_OVER = /^<(?:mask|clipPath|animate\w*|set)\b/i;
function mapChildFills(body: string, paint: (value: string) => string): string {
return body.replace(CHILD_TAG, (m) => (PASSED_OVER.test(m) ? m : mapTagFills(m, paint)));
@@ -1357,7 +1361,7 @@ function mapChildFills(body: string, paint: (value: string) => string): string {
// + aria-hidden on the root <svg>. Requires a viewBox so the icon scales.
//
// A SINGLE-COLOUR icon follows the theme: when its fills — the root's and its
-// children's together, outside a <mask> — hold at most one solid colour, each
+// children's together, outside a <mask> or <clipPath> — hold at most one solid colour, each
// becomes `currentColor`, the footer link's colour. Drawn for one background,
// such an icon vanishes on another (X's official logo is a `<path
// fill="white">`: 1.11:1 on the light footer). An icon of TWO or more colours
diff --git a/common/lib/siteColor.test.ts b/common/lib/siteColor.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { ACCENT_CHART_SLOT, perBaseColor, siteChartColors, siteColor } from "./siteColor";
+import { ACCENT_CHART_SLOT, fittedHex, 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";
@@ -67,6 +67,27 @@ test("perBaseColor: one value, each base's colour; the light one with no token s
assert.doesNotMatch(css, /["';]|url\(/);
});
+test("perBaseColor: anything but a #rrggbb per base throws, never paints rgb(NaN …)", () => {
+ const ok = { light: "#010203", sepia: "#a0b0c0", dark: "#ffeedd" };
+ for (const bad of ["#fff", "red", "", "var(--brand)", "#12345g", "#1234567"]) {
+ assert.throws(() => perBaseColor({ ...ok, sepia: bad }), RangeError, bad);
+ }
+ assert.throws(() => perBaseColor({ light: "#010203" } as never), /dark|sepia/);
+});
+
+test("fittedHex: a published hex fitted per base; anything else is undefined", () => {
+ assert.equal(fittedHex("#F4C2D7"), perBaseColor(resolveAccent("#f4c2d7")));
+ // A named accent's published on-dark hex, with no id beside it (a visitor's
+ // added archive): fitted like any custom hex, readable on every ground.
+ const brass = fittedHex(ACCENTS.brass.onDark)!;
+ for (const base of BASE_GROUND_IDS) {
+ assert.ok(contrastRatio(paint(brass, base), BASE_GROUNDS[base]) >= 4.5, base);
+ }
+ for (const v of [undefined, "", "red", "url(https://x.example/p.png)", "var(--brand)", 7]) {
+ assert.equal(fittedHex(v), undefined, String(v));
+ }
+});
+
test("siteColor: no accent is the site's chart colour", () => {
assert.equal(siteColor({}, seriesColor(0)), "var(--chart-1)");
assert.equal(siteColor({}, "var(--chart-4)"), "var(--chart-4)");
diff --git a/common/lib/siteColor.ts b/common/lib/siteColor.ts
@@ -94,8 +94,18 @@ export function siteChartColors(sites: readonly { accentId?: string }[]): string
// 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.
+//
+// The value is CSS only: never parse it or compare it as a hex. Each value
+// must be a `#rrggbb` (resolveAccent's output); anything else THROWS rather
+// than paint `rgb(NaN …)`, which a browser drops without a word. A fourth
+// base needs its own flag in tokens.css (themeTokens.test.ts holds exactly one
+// 1 per base, over the same BASE_GROUND_IDS this walks).
+const RRGGBB = /^#[0-9a-f]{6}$/i;
export function perBaseColor(values: Readonly<Record<BaseGround, string>>): string {
const rgb = BASE_GROUND_IDS.map((b) => {
+ if (!RRGGBB.test(values[b] ?? "")) {
+ throw new RangeError(`perBaseColor: ${b} is ${JSON.stringify(values[b])}, not a #rrggbb`);
+ }
const n = parseInt(values[b].slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
});
@@ -106,9 +116,16 @@ export function perBaseColor(values: Readonly<Record<BaseGround, string>>): stri
return `rgb(${channel(0)} ${channel(1)} ${channel(2)})`;
}
+// A published hex (a site's own, or a named accent's on-dark value when its
+// id is not known) fitted to each base, as one CSS value; undefined for
+// anything that is not a `#rrggbb`, which never reaches a style.
+export function fittedHex(accent: unknown): string | undefined {
+ const hex = parseAccent(accent);
+ return hex ? perBaseColor(resolveAccent(hex)) : undefined;
+}
+
// 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})`;
- const hex = parseAccent(site.accent);
- return hex ? perBaseColor(resolveAccent(hex)) : chart;
+ return fittedHex(site.accent) ?? chart;
}
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -244,7 +244,7 @@ html[data-base="light"] {
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
+ pair with them is CVD ΔE 12.5 deutan 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;
@@ -331,7 +331,7 @@ 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. chart-6, the
- rust, is light's: worst pair CVD 9.1 protan, normal 16.6. */
+ rust, is light's: worst pair CVD 9.1 deutan, normal 16.6. */
--chart-1: #3574d6;
--chart-2: #2a7d4f;
--chart-3: #5e3aa8;
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -6,9 +6,10 @@
- **The hub's Ask AI waits for the hub's own list of archives.** The archives added in this browser are known at once and the hub's own a moment later, and the chat counted as ready in between: a question sent then was answered from the added archives alone. It now waits for both.
- **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.
+- **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, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma (its shape clipped by a white rectangle) follows the footer too. 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.
+- **On the hub, an archive you added, and an official one whose colour the hub cannot name, also read on every ground.** An archive you added shows the colour its own site publishes, and that colour was painted as published on its card, its chip and its search results' edge, so a pale one was faint on Light and Sepia; it is now adjusted for each ground the same way. A value that is not a colour is ignored, and the card falls back to the hub's own. An official archive whose named accent the hub cannot confirm (its `hub-summary.json` is missing, or names another colour) was painted in the accent's Dark value on every ground; it is now adjusted for each ground too. 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/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -24,7 +24,8 @@
// useHubSites' (officialInstances), shared with the scope chips, the results'
// stripes and /ask, so an archive is one colour on every surface. With no
// summary the order is hub-sites.json's and the colour the card's place. An
-// added archive with no accent wears the family's signal colour.
+// added archive wears its own published hex fitted to the base in force
+// (useHubSites, release 11 O2b), or, with none, the family's signal colour.
import { X } from "lucide-react";
import { Badge } from "yt-dlp-transcript-common/components/ui/badge";
@@ -56,8 +57,8 @@ function ArchiveCard({
onRemove,
}: {
site: RegisteredSite;
- // The stripe's colour: an official card's from useHubSites, an added one's
- // own accent or the family's signal colour.
+ // The stripe's colour, from useHubSites: an official card's, or an added
+ // one's own hex fitted per base, or the family's signal colour.
accent: string;
// Official: the build-time figures (null when the summary lacks them).
// Added: undefined — the card shows the descriptor's channel count only.
diff --git a/export/app/components/hub/useHubSites.ts b/export/app/components/hub/useHubSites.ts
@@ -12,7 +12,11 @@
// and reordered a moment later. Both files are small, same-origin and
// requested together, so the wait is the gap between two requests already in
// flight. The archives a visitor added follow, in the order they were added,
-// each in its own accent or none.
+// each in its own accent or none: the hex its /site.json published (a named
+// accent's on-dark value, or the site's own), FITTED to each base like any
+// custom hex (lib/siteColor.ts fittedHex, release 11 O2b) — so a pale one reads
+// on light and sepia on its card, chip and result stripe. A value that is not
+// a hex is dropped, never passed into a style.
//
// `listed` says the list is the hub's WHOLE list: `/hub-sites.json` has been
// answered (siteRegistry's `builtinsLoaded`) and the summary has settled. Until
@@ -27,6 +31,7 @@ import {
} from "yt-dlp-transcript-common/components/siteRegistry";
import type { FederatedSite } from "yt-dlp-transcript-common/components/SearchDataContext";
import { officialInstances } from "yt-dlp-transcript-common/lib/hubSummary";
+import { fittedHex } from "yt-dlp-transcript-common/lib/siteColor";
import { useHubScope } from "./useHubScope";
import { useHubSummary } from "./useHubSummary";
@@ -41,7 +46,9 @@ export function useHubSites() {
summary,
)
: [];
- const added = sites.filter((s) => s.kind === "external");
+ const added = sites
+ .filter((s) => s.kind === "external")
+ .map((s) => ({ ...s, accent: fittedHex(s.accent) }));
// Every archive, in the one order, each official one wearing its colour.
const all: RegisteredSite[] = [
...official.map((o) => ({ ...o.site, accent: o.accent })),
diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
+import { resolveAccent } from "../../common/lib/accent";
import { ACCENTS } from "../../common/lib/brand";
// The hub's "Official Instances": one card per built-in member
@@ -187,4 +188,72 @@ test.describe("hub official instances", () => {
.toBe(rgb(base === "light" ? ACCENTS.brass.onLight : ACCENTS.brass.onSepia));
}
});
+
+ // Release 11 (O2b): an archive the VISITOR added wears the hex its
+ // /site.json published, fitted to the base in force — on its card and on its
+ // scope chip — like any custom hex. A pale one used to be painted as is:
+ // ~1.4:1 on the light and sepia grounds.
+ test("an added archive's own hex is fitted to each base, on its card and its chip", async ({
+ page,
+ }) => {
+ const ADDED = "http://localhost:4597";
+ const PALE = "#f4c2d7";
+ await page.route("**/hub-sites.json", (r) => fulfillJson(r, []));
+ await page.route("**/hub-summary.json", (r) => r.fulfill({ status: 404, body: "" }));
+ await page.route(`${ADDED}/**`, (r) =>
+ r.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" }, body: "" }),
+ );
+ // As the registry stores an archive added through the form.
+ await page.addInitScript(
+ ([origin, accent]) => {
+ if (localStorage.getItem("ytdlp-tb:hub-sites")) return;
+ localStorage.setItem(
+ "ytdlp-tb:hub-sites",
+ JSON.stringify([
+ {
+ origin,
+ siteId: "added",
+ siteTitle: "Added Archive",
+ accent,
+ pwa: false,
+ contract: 1,
+ addedAt: 1,
+ },
+ ]),
+ );
+ },
+ [ADDED, PALE],
+ );
+ const stripe = page
+ .getByTestId("shelf-spine")
+ .filter({ hasText: "Added Archive" })
+ .locator(":scope > span[aria-hidden='true']");
+ const dot = page
+ .getByTestId(`hub-scope-chip-${ADDED}`)
+ .locator("button[aria-pressed] > span.rounded-full");
+ const colours = async () => ({
+ stripe: await stripe.evaluate((el) => getComputedStyle(el).backgroundColor),
+ dot: await dot.evaluate((el) => getComputedStyle(el).backgroundColor),
+ });
+ const rgb = (hex: string) =>
+ `rgb(${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(", ")})`;
+ const fitted = resolveAccent(PALE);
+ // Fitted, not as published, where the ground needs it.
+ expect(fitted.light).not.toBe(PALE);
+ expect(fitted.sepia).not.toBe(PALE);
+
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { level: 2, name: "Archives You Added", exact: true }),
+ ).toBeVisible();
+ for (const base of ["dark", "light", "sepia"] as const) {
+ if (base !== "dark") {
+ await page.evaluate((b) => localStorage.setItem("ytdlp-tb:base", b), base);
+ await page.reload();
+ }
+ await expect(page.locator("html")).toHaveAttribute("data-base", base);
+ const want = rgb(fitted[base]);
+ await expect.poll(colours, { message: base }).toEqual({ stripe: want, dot: want });
+ }
+ });
});
diff --git a/homepage/CHANGELOG.md b/homepage/CHANGELOG.md
@@ -2,6 +2,7 @@
## [Unreleased]
+- **A single-colour social icon shows on every ground.** The footer's social icons are the operator's (`homepage.json`'s, else `settings.socialLinks`), normalized when they are saved (`normalizeSocialSvg`, release 11 slice O1). An icon drawn in one colour now takes the footer's colour throughout; before, 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 of two or more colours, such as YouTube's red mark with its white triangle, keeps its colours as pasted. "No fill", gradients, masks, clip paths and animation timing are never changed, and a clip path's own colour does not count, so a one-colour icon exported from Figma follows the footer too. It applies when the settings are next saved, then needs a rebuild and deploy of the homepage.
- **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.
diff --git a/homepage/app/components/ArchiveGrowthChart.tsx b/homepage/app/components/ArchiveGrowthChart.tsx
@@ -42,7 +42,8 @@ import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor";
// • adjacent — the stack — in today's order (Jeralyzer, Anilyzer,
// Bonnellyzer, Hasanalyzer, Rekietalyzer): PASS on every base, worst CVD
// Δ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
+// dark; today's mapping — release 10's slice MC, re-run in release 11);
+// 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
diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts
@@ -36,7 +36,8 @@ export function loadSummary(): HomepageSummary | null {
// The file loadSummary reads: `public/homepage-summary.json`, or the file
// HOMEPAGE_SUMMARY_FILE names — but ONLY outside a production build. The e2e's
-// `next dev` points it at a copy with fixture accents (playwright.config.ts);
+// `next dev` points it at a synthetic summary (e2e/fixture-summary.ts, written
+// by playwright.config.ts);
// `next build` runs with NODE_ENV=production and ignores it, so a variable left
// in a build shell can never ship a fixture as the homepage's numbers.
export function summaryFile(
diff --git a/homepage/e2e/no-data.spec.ts b/homepage/e2e/no-data.spec.ts
@@ -0,0 +1,43 @@
+import fs from "node:fs";
+import path from "node:path";
+import { test, expect } from "@playwright/test";
+import { FIXTURE_SUMMARY_NAME } from "./fixture-summary";
+
+// A source-only build has no summary, and both pages must say so rather than
+// draw zeroes. The e2e server always has the synthetic summary
+// (fixture-summary.ts), so this file moves it aside, reads `/` and `/stats/`,
+// and puts it back — `next dev` reads the file on every request (app/lib/
+// summary.ts), and the suite runs one test at a time (`workers: 1`). The copy
+// goes to a gitignored name beside it (`.e2e-summary.json*`), and it is
+// restored however the test ends; playwright.config.ts also rewrites the file
+// whenever a worker starts.
+
+test.describe.configure({ mode: "serial" });
+
+test("with no summary, / and /stats/ render no numbers and say why", async ({ page }) => {
+ const file = path.resolve(test.info().project.testDir, FIXTURE_SUMMARY_NAME);
+ const aside = `${file}.aside`;
+ fs.renameSync(file, aside);
+ try {
+ await page.goto("/");
+ // The claim without a count, the ways in, and none of the numbers.
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText(
+ "Every word a channel said, searchable to the second.",
+ );
+ await expect(page.getByRole("link", { name: /download the source/i })).toBeVisible();
+ await expect(page.getByRole("img", { name: /transcripts by the month/i })).toHaveCount(0);
+ await expect(
+ page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }),
+ ).toHaveCount(0);
+ await expect(page.locator("time[datetime]")).toHaveCount(0);
+
+ await page.goto("/stats/");
+ await expect(page.getByText("This build shipped without dashboard data")).toBeVisible();
+ await expect(page.locator(".recharts-surface")).toHaveCount(0);
+ } finally {
+ fs.renameSync(aside, file);
+ }
+ // Back: the next request draws the fixture's numbers again.
+ await page.goto("/stats/");
+ await expect(page.locator(".recharts-surface").first()).toBeVisible();
+});
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -6752,7 +6752,11 @@ S4, as shipped"; `release-10.md` "Slice L2 / L1, as shipped". Every anchor below
`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. 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
+ one `rgb(calc(…))` over tokens.css's one-hot `--base-light|sepia|dark`. That value is CSS ONLY —
+ never parse or compare it as a hex; `perBaseColor` throws on anything but a `#rrggbb` per base, and a
+ fourth base must add its own flag to tokens.css (`themeTokens.test` holds one 1 per base over
+ `BASE_GROUND_IDS`). A visitor-ADDED hub archive's published hex goes through the same fit
+ (`fittedHex`, in `useHubSites`; release 11 O2b), and a non-hex accent is dropped. 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
diff --git a/plans/brand-and-themes.md b/plans/brand-and-themes.md
@@ -113,7 +113,9 @@ ground and with white ink. A unit test enforces it.
### Bases
Each base declares all 48 colour tokens listed in `homepage/e2e/theme.spec.ts:63-112`, plus
-`color-scheme`.
+`color-scheme`. (2026-09-28: 50 now — `REQUIRED_TOKENS` in `common/components/themeConfig.ts`, which
+the spec reads; `--chart-6` came in release 11, slice O2. Each base also declares the non-colour
+flags `--base-light|sepia|dark`.)
| Base | Source | Values |
|---|---|---|
diff --git a/plans/release-11.md b/plans/release-11.md
@@ -1030,7 +1030,7 @@ surface | page ground> --pairs all|adjacent`. Log: `o2-validate.log`.
- 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.
+ - on the homepage, gone shows only as the FamilyStats figure (`RecentAdditions` would badge it, but is rendered nowhere).
**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
@@ -1282,6 +1282,99 @@ adapted; S4's fixture copied to `o5-fixture`):
record** in `brand-and-themes.md` (l. 1451–1453, 1541) states them as they were. Neither file is
this slice's to edit, so they are left for the integration pass.
+### Follow-ups O2b (2026-09-28)
+
+After O2's review (`o2-review.md`, SHIP) and the merge (`main` `139a648e`: O4, O3, O6-A, O1, O2), one
+small round on the same branch and worktree. `main` merged first (a fast-forward).
+
+**What changed**
+- **The hub's visitor-added archives are fitted per base** (O2's found-and-left, now safe: O1 is
+ merged).
+ - `useHubSites` maps every external archive's accent through `fittedHex`, which is
+ `perBaseColor(resolveAccent(hex))` (`common/lib/siteColor.ts`; `siteColor` uses it too).
+ - So the card stripe, the scope chip's dot, the search result edge and the channel group all get
+ the fitted value from the one place.
+ - A value that is not a `#rrggbb` is dropped, so it never reaches a style. Before, a remote
+ `/site.json` could put any string there. The card then falls back to `var(--brand)`, as with no
+ accent.
+- **R2's low 2, recorded here:** O2 also changed the hub's DEGRADED path.
+ - This is an official archive with a named accent whose `hub-summary.json` is missing, or whose own
+ hex disagrees with it.
+ - It used to paint the on-dark hex on every base, and now paints that hex fitted per base (for
+ example Brass `#e3b15c` → `#8a6c38` on light, against the swatch `#95661a`).
+ - The export changelog now says so.
+- **`normalizeSocialSvg` passes over a `<clipPath>` exactly like a `<mask>`** (O1 review, re-read
+ low).
+ - It handles the block and the self-closing form, the self-closing one first.
+ - So Figma's usual one-colour export counts as ONE colour and is themed: a black path in a group
+ clipped by `<clipPath><rect fill="white"/></clipPath>`.
+ - x.com and Kiwi Farms are byte-for-byte as before (their tests pin the whole output), and the
+ rule is still idempotent (the Figma icon is in the idempotence loop).
+- **R2's nits:**
+ - `tokens.css`: the chart-6 CVD pairs are "deutan", as the validator prints.
+ - `homepage/app/lib/summary.ts`: the comment names the synthetic summary.
+ - `ArchiveGrowthChart.tsx`: the today-order numbers are "today's mapping — release 10's slice MC,
+ re-run in release 11".
+ - This record: gone shows only as `FamilyStats`' figure (`RecentAdditions` is rendered nowhere).
+ - `brand-and-themes.md`: 50 colour tokens (a dated note).
+ - `perBaseColor` throws a `RangeError` on anything but a `#rrggbb` per base, rather than paint
+ `rgb(NaN …)`.
+ - FACTS: `perBaseColor`'s value is CSS only (never parse or compare it as a hex); a fourth base must
+ add its flag; the added archives use the same fit.
+- **The no-data pages are specced again** (R2's low 4). `homepage/e2e/no-data.spec.ts` runs serially.
+ - It renames `e2e/.e2e-summary.json` to `.e2e-summary.json.aside`, which is gitignored by the same
+ pattern, and restores it in `finally`.
+ - It asserts `/` shows "Every word a channel said, searchable to the second." with no growth
+ chart, no Official Instances and no build date.
+ - It asserts `/stats/` shows "This build shipped without dashboard data" and no chart.
+ - It then checks the next `/stats/` draws the fixture again.
+- **Changelogs:**
+ - export `[Unreleased]` gains one bullet: the added archives and the degraded path, fitted per
+ base.
+ - O1's single-colour bullet now says clip paths are left alone and do not count (the Figma case).
+ - `homepage/CHANGELOG.md` gains the single-colour social icon (the homepage footer's links are the
+ same normalized `socialLinks`), on the parent's ruling on O1's question.
+
+| sha | what |
+|---|---|
+| `c17ead28` | `common:` `normalizeSocialSvg` passes over `<clipPath>` (block + self-closing); the Figma-shape test |
+| `ee0a252e` | `hub:` the added archives' accent through `fittedHex` (`useHubSites`); `perBaseColor`'s input check; `ArchiveShelf` comments; unit tests; the hub e2e |
+| `ac8c4d2b` | `homepage(e2e):` `no-data.spec.ts` |
+| `36e4fabd` | `comments:` the three code-comment nits (`tokens.css`, `summary.ts`, `ArchiveGrowthChart.tsx`) |
+| `36fc74f8` | `changelogs:` export (the fitted hub archives; clip paths in O1's bullet), homepage (the single-colour icon) |
+| _this_ | `plans:` this section; the record's gone line; FACTS; `brand-and-themes.md`'s token count |
+
+**Gates** (logs `o2b-*.log`):
+- **tsc** clean before the code commits (89 s, `o2b-tsc1.log`).
+- **common 2,110/2,110:** `main` `139a648e`'s count + 3 (the Figma test, `fittedHex`, the input
+ check).
+- **e2e** (queued behind nothing; nothing failed, so no re-runs):
+ - `e2e:hub` full **33 passed** (1.2 min; 32 at O1 + the added-archive test);
+ - export `site-branding` **7 passed** (12 s);
+ - homepage full **31 passed, 0 skipped** (44 s; 30 + `no-data.spec`), from this worktree with no
+ `homepage/public` data;
+ - after the homepage run the fixture file is back in place and no `.aside` is left.
+- **The primary's `export/public`** is byte-identical before and after (392 entries, 380 files).
+
+**They bite:**
+- **Figma:** against the previous `settingsSchema.ts`, the Figma test fails with the fills
+ `['none', 'black', 'white']`: two colours, so nothing is themed. The other 13 pass
+ (`o2b-bite-svg.log`).
+- **The added archive:** with `useHubSites`' `.map(fittedHex)` removed, the hub test fails on light,
+ expecting `rgb(132, 105, 116)` and receiving `rgb(244, 194, 215)` for both the stripe and the dot
+ (`o2b-e2e-bite-hub.log`).
+- **The input check:** without it, `perBaseColor`'s throw test fails (1 of 15;
+ `o2b-bite-unit-check.log`). Against `main`'s `siteColor.ts` the test file does not load at all
+ (no `fittedHex`).
+- **No data:** with the loader's old bug (a zero-filled summary when there is none),
+ `no-data.spec` fails: `/stats/` never says "This build shipped without dashboard data"
+ (`o2b-e2e-bite-nodata.log`).
+
+**Found and left**
+- **A dev-console warning, not O2b's:** the homepage run logs `[browser] Received NaN for the width
+ attribute` during `stats.spec`'s Customize test (ranked / small multiples). It is in O2's runs too,
+ so it predates O2b.
+
## Rollout
Nothing is rolled out tonight. The morning runbook lists what is owed: the :3001 editor restart,