commit 314b4ed2b5a260c58bd884a37b36467f8dffecf8
parent 3cc3f46f5218c55c6024797de9fce7d0490eace1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 03:40:33 -0400
hub: an archive the visitor added wears its published hex fitted to each base — card, scope chip, result stripe
useHubSites maps every external archive's accent through fittedHex
(perBaseColor(resolveAccent(hex)), common/lib/siteColor.ts), so the card,
the chip's dot, the search stripe and the channel group all get the fitted
value; a value that is not a #rrggbb is dropped (never into a style) and the
card falls back to the family's colour. siteColor uses the same helper.
perBaseColor now throws a RangeError on anything but a #rrggbb per base
instead of painting rgb(NaN …), and says the value is CSS only (review nit).
Unit: fittedHex, the input check. e2e-hub official-instances: a stored
pale #f4c2d7 archive's stripe and chip dot per base.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 123 insertions(+), 8 deletions(-)
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/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 });
+ }
+ });
});