commit afed744706a6625e45e589bba4ad01560ce99e3d
parent bff87f0a506762ece7912df4aabfc5e2fb7b86e6
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:58:32 -0400
common: the published accent stays a hex — /site.json, hub-sites.json, the homepage summary (brand S0)
Third-party hubs read other sites' /site.json and draw `accent` as a colour,
so the three publishers route site.json's accent through accentHex: an id
is published as its on-dark value, a custom hex as stored, no accent as no
key (unchanged). Tests: siteDescriptor (new), compose-hub's pool file, and
buildHomepageSummary. wordmarkLead is not published.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 90 insertions(+), 3 deletions(-)
diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts
@@ -53,3 +53,31 @@ test("with no index, compose-hub writes the pool and no hub-summary.json, removi
rmSync(root, { recursive: true, force: true });
}
});
+
+test("hub-sites.json publishes every accent as a hex: an id becomes its on-dark value", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "compose-hub-"));
+ const log = console.log;
+ try {
+ const paths = fixturePaths(root);
+ const write = (id: string, site: Record<string, unknown>) => {
+ mkdirSync(path.join(paths.sitesDir, id), { recursive: true });
+ writeFileSync(path.join(paths.sitesDir, id, "site.json"), JSON.stringify(site));
+ };
+ write("named", { siteTitle: "Named", siteUrl: "https://named.example", accent: "violet" });
+ write("custom", { siteTitle: "Custom", siteUrl: "https://custom.example", accent: "#CC3366" });
+ write("plain", { siteTitle: "Plain", siteUrl: "https://plain.example" });
+ console.log = () => {};
+ await main({ paths });
+ console.log = log;
+ const pool = JSON.parse(
+ readFileSync(path.join(paths.exportPublicDir, "hub-sites.json"), "utf8"),
+ ) as Array<{ siteId: string; accent?: string }>;
+ const accentOf = (id: string) => pool.find((s) => s.siteId === id);
+ assert.equal(accentOf("named")?.accent, "#b49cf2");
+ assert.equal(accentOf("custom")?.accent, "#cc3366");
+ assert.ok(accentOf("plain") && !("accent" in accentOf("plain")!));
+ } finally {
+ console.log = log;
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -18,6 +18,7 @@ import path from "node:path";
import { existsSync } from "node:fs";
import { cp, rm, writeFile, access } from "node:fs/promises";
import { getPaths, type Paths } from "../lib/paths";
+import { accentHex } from "../lib/accent";
import { listSites, resolveHubUrl } from "../lib/site";
import { getHomepageConfig } from "../lib/homepage";
import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor";
@@ -91,7 +92,8 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> {
siteId: site.siteId,
siteTitle: site.siteTitle,
siteUrl: site.siteUrl,
- ...(site.accent ? { accent: site.accent } : {}),
+ // The hub draws it as a colour: an accent id is published as its hex.
+ ...(accentHex(site.accent) ? { accent: accentHex(site.accent) } : {}),
...(resolveHubUrl(site) ? { hubUrl: resolveHubUrl(site) } : {}),
pwa: site.pwa === true,
contract: SITE_DESCRIPTOR_VERSION,
diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts
@@ -184,3 +184,16 @@ test("gone counts only a deleted record we hold (it has a downloadedDate)", () =
assert.equal(s.sites.find((x) => x.siteId === "beta")!.gone, 0);
assert.equal(s.official!.gone, 1);
});
+
+test("a site's accent is published as a hex: an id becomes its on-dark value", () => {
+ const sites = [
+ { ...site("alpha", ["a1", "a2"], "https://alpha.example"), accent: "brass" },
+ { ...site("beta", ["b1"], "https://beta.example"), accent: "#CC3366" },
+ ] as Site[];
+ const s = buildHomepageSummary(STATS, CHANNEL_SITES, sites, NOW);
+ assert.equal(s.sites.find((x) => x.siteId === "alpha")!.accent, "#e3b15c");
+ assert.equal(s.sites.find((x) => x.siteId === "beta")!.accent, "#cc3366");
+ // No accent: no key, as before.
+ const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW);
+ assert.ok(plain.sites.every((x) => !("accent" in x)));
+});
diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts
@@ -1,6 +1,7 @@
import type { Platform } from "./platform";
import type { VideoStat } from "./stats";
import type { Site } from "./site";
+import { accentHex } from "./accent";
import { VIDEO_STATES, type VideoState } from "./availability";
// Pre-computed, lightweight cross-site summary for the hub (homepage) landing.
@@ -428,7 +429,8 @@ export function buildHomepageSummary(
recordings: siteAcc.get(s.siteId)?.recordings ?? 0,
hoursArchived: Math.round((siteAcc.get(s.siteId)?.seconds ?? 0) / 3600),
gone: siteAcc.get(s.siteId)?.gone ?? 0,
- ...(s.accent ? { accent: s.accent } : {}),
+ // A published colour: an accent id becomes its hex (lib/accent.ts).
+ ...(accentHex(s.accent) ? { accent: accentHex(s.accent) } : {}),
}))
// Keep a public site only if it has any activity in either metric.
.filter((s) => s.transcribed.total > 0 || s.downloaded.total > 0)
diff --git a/common/lib/siteDescriptor.test.ts b/common/lib/siteDescriptor.test.ts
@@ -0,0 +1,40 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildSiteDescriptor } from "./siteDescriptor";
+import { parseSite } from "./siteSchema";
+import type { Manifest } from "./manifest";
+import { ACCENTS } from "./brand";
+
+// The public /site.json is read by OTHER hubs, which draw `accent` as a colour.
+// A site.json may now store an accent id; the descriptor must still publish a
+// hex (lib/accent.ts accentHex).
+
+const MANIFEST = {
+ version: 3,
+ totalCount: 0,
+ pageSize: 100,
+ pageCount: 0,
+ generatedAt: "2026-09-25T00:00:00.000Z",
+ channels: [],
+} as Manifest;
+
+function descriptorFor(raw: Record<string, unknown>) {
+ return buildSiteDescriptor(parseSite("s", raw), MANIFEST, [], { pwa: false });
+}
+
+test("an accent id is published as its on-dark hex", () => {
+ assert.equal(descriptorFor({ accent: "brass" }).accent, ACCENTS.brass.onDark);
+ assert.equal(descriptorFor({ accent: "sakura" }).accent, "#ee8fb5");
+});
+
+test("a custom hex is published as stored; no accent publishes no key", () => {
+ assert.equal(descriptorFor({ accent: "#CC3366" }).accent, "#cc3366");
+ assert.equal("accent" in descriptorFor({}), false);
+ assert.equal("accent" in descriptorFor({ accent: "gold" }), false);
+});
+
+test("the wordmark lead is not part of the public descriptor", () => {
+ const d = descriptorFor({ headerTitle: "Jeralyzer", wordmarkLead: "Jer" });
+ assert.equal("wordmarkLead" in d, false);
+ assert.equal(d.headerTitle, "Jeralyzer");
+});
diff --git a/common/lib/siteDescriptor.ts b/common/lib/siteDescriptor.ts
@@ -1,3 +1,4 @@
+import { accentHex } from "./accent";
import type { ChannelGroup } from "./channelGroups";
import { CONTRACT } from "./corpus";
import type { Manifest } from "./manifest";
@@ -84,7 +85,8 @@ export function buildSiteDescriptor(
siteDescription: site.siteDescription,
headerTitle: site.headerTitle,
homeTagline: site.homeTagline,
- ...(site.accent ? { accent: site.accent } : {}),
+ // Always a hex on the wire: an accent id means nothing to another hub.
+ ...(accentHex(site.accent) ? { accent: accentHex(site.accent) } : {}),
...(site.siteUrl ? { siteUrl: site.siteUrl } : {}),
...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
pwa: opts.pwa,