commit bff87f0a506762ece7912df4aabfc5e2fb7b86e6
parent 70a4e9131fbdca8d1134be6f956395caeb17ec75
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:55:57 -0400
common: site.json takes an accent id and a wordmarkLead (brand S0)
`accent` parses through parseAccentSetting: a named accent id (signal,
brass, vermilion, violet, sakura, blue, green) or a custom #rrggbb; absent
is Signal. New optional `wordmarkLead` after `headerTitle` — the heavy
subject part of the wordmark — kept on read and written by siteToDisk only
when it is a proper prefix of headerTitle (the object step resolves it
beside defaultGroupId). SITE.md regenerated; --check clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 89 insertions(+), 9 deletions(-)
diff --git a/SITE.md b/SITE.md
@@ -14,6 +14,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| [`siteTitle`](#sitetitle) | `"Transcript Browser"` |
| [`siteDescription`](#sitedescription) | `"Browse and search video transcripts"` |
| [`headerTitle`](#headertitle) | `"Transcript Browser"` |
+| [`wordmarkLead`](#wordmarklead) | absent |
| [`homeTagline`](#hometagline) | `""` |
| [`socialLinks`](#sociallinks) | absent |
| [`groups`](#groups) | list — see below |
@@ -52,6 +53,12 @@ The title shown in the site header.
Default: `"Transcript Browser"`
+## `wordmarkLead`
+
+The heavy first part of the header wordmark: the subject's name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.
+
+Default: absent
+
## `homeTagline`
Tagline under the home page title. Empty = none.
@@ -139,7 +146,7 @@ Default: absent
## `accent`
-Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site's public build. Absent = inherit the family brass. Any other spelling is dropped.
+Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site's default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.
Default: absent
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -158,6 +158,56 @@ test("siteToDisk persists only the non-defaults", () => {
assert.deepEqual(full.socialLinks, []);
});
+test("accent: a named id or a custom hex is kept; anything else is dropped", () => {
+ assert.equal(parseSite("s", { accent: "brass" }).accent, "brass");
+ assert.equal(parseSite("s", { accent: " Sakura " }).accent, "sakura");
+ assert.equal(parseSite("s", { accent: "#CC3366" }).accent, "#cc3366");
+ for (const v of ["gold", "custom", "#abc", 7, null]) {
+ assert.equal(parseSite("s", { accent: v }).accent, undefined, String(v));
+ }
+ const disk = siteToDisk(parseSite("s", { accent: "Violet" })) as Record<string, unknown>;
+ assert.equal(disk.accent, "violet");
+ assert.equal("accent" in siteToDisk(parseSite("s", { accent: "gold" })), false);
+});
+
+test("wordmarkLead: kept only as a proper prefix of headerTitle, and written after it", () => {
+ const site = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: " Jer " });
+ assert.equal(site.wordmarkLead, "Jer");
+ // The key sits right after headerTitle, in the read AND on disk.
+ const keys = Object.keys(site);
+ assert.equal(keys[keys.indexOf("headerTitle") + 1], "wordmarkLead");
+ const disk = siteToDisk(site) as Record<string, unknown>;
+ const diskKeys = Object.keys(disk);
+ assert.equal(disk.wordmarkLead, "Jer");
+ assert.equal(diskKeys[diskKeys.indexOf("headerTitle") + 1], "wordmarkLead");
+ // Not a prefix, wrong case, the whole title, longer, blank, not a string:
+ // dropped on read, and never written.
+ for (const lead of ["Hasan", "jer", "Jeralyzer", "Jeralyzers", "", " ", 3]) {
+ const s2 = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: lead });
+ assert.equal(s2.wordmarkLead, undefined, JSON.stringify(lead));
+ assert.ok("wordmarkLead" in s2, "every key is emitted");
+ assert.equal("wordmarkLead" in siteToDisk(s2), false, JSON.stringify(lead));
+ }
+ // siteToDisk re-checks it: a caller's non-prefix lead is not persisted.
+ const edited = { ...site, headerTitle: "Hasanalyzer" };
+ assert.equal("wordmarkLead" in siteToDisk(edited), false);
+});
+
+test("writeSite → getSite round-trips a named accent and a wordmark lead", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ const site = parseSite("s", {
+ siteTitle: "Rekietalyzer",
+ headerTitle: "Rekietalyzer",
+ wordmarkLead: "Rekieta",
+ accent: "vermilion",
+ });
+ await writeSite(site, paths);
+ assert.deepEqual(getSite("s", paths), site);
+ const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8"));
+ assert.equal(disk.accent, "vermilion");
+ assert.equal(disk.wordmarkLead, "Rekieta");
+});
+
function fixtures(): Array<[string, unknown]> {
const out: Array<[string, unknown]> = [
["empty", {}],
@@ -192,6 +242,14 @@ function fixtures(): Array<[string, unknown]> {
},
],
];
+ out.push([
+ "named accent + wordmark lead",
+ { headerTitle: "Jeralyzer", wordmarkLead: "Jer", accent: "Brass" },
+ ]);
+ out.push([
+ "non-prefix lead + unknown accent",
+ { headerTitle: "Jeralyzer", wordmarkLead: "Hasan", accent: "gold" },
+ ]);
const e2e = path.join(HERE, "..", "..", "editor", "e2e", "fixtures", "sites", "testsite", "site.json");
out.push(["e2e testsite", JSON.parse(fs.readFileSync(e2e, "utf8"))]);
return out;
diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts
@@ -4,14 +4,15 @@
// one-core phase 3 slice 4b, on slice 4a's pattern (lib/settingsSchema.ts):
// every field is `settingsField(coerce)` over the parser that already existed —
// parseChannelGroups, resolveDefaultGroupId, parseSiteChannels,
-// parseSocialLinks, parseAccent, parseSiteUrl, parseRelatedSites — so no
+// parseSocialLinks, parseAccentSetting, parseSiteUrl, parseRelatedSites — so no
// boundary moved; zod supplies the key plumbing and strips unknown keys. No
// `.default()`, no `.passthrough()`.
//
// WHAT A READ PROMISES (parseSite): it never throws, and it emits EVERY key —
// an absent optional key is present with the value `undefined` — exactly as the
-// hand-written parseSite did. Two fields depend on a sibling (the default group
-// must be one of the groups; a membership's group must exist), so the schema is
+// hand-written parseSite did. Three fields depend on a sibling (the default
+// group must be one of the groups; a membership's group must exist; the
+// wordmark lead must be a proper prefix of the header title), so the schema is
// the per-key object followed by ONE object-level step that resolves them.
//
// WHAT A WRITE PROMISES (writeSite in lib/site.ts): the throwing validations,
@@ -35,7 +36,8 @@ import {
resolveDefaultGroupId,
type ChannelGroup,
} from "./channelGroups";
-import { parseAccent } from "./accent";
+import { parseAccentSetting } from "./accent";
+import { wordmarkLeadFor } from "./brand";
import { parseSocialLinks, type SocialLink } from "./settingsSchema";
import { settingsField } from "./settingsFieldSchemas";
import type { FieldDocs } from "./fieldDocs";
@@ -73,6 +75,7 @@ export type Site = {
siteTitle: string;
siteDescription: string;
headerTitle: string;
+ wordmarkLead?: string;
homeTagline: string;
socialLinks?: SocialLink[];
groups: ChannelGroup[];
@@ -96,6 +99,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
siteTitle: "The site's title (browser tab, manifest, headings).",
siteDescription: "One-line description (meta description, manifest).",
headerTitle: "The title shown in the site header.",
+ wordmarkLead:
+ 'The heavy first part of the header wordmark: the subject\'s name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.',
homeTagline: "Tagline under the home page title. Empty = none.",
socialLinks:
"Per-site social links. ABSENT means inherit the global default (`settings.json` `socialLinks`); an array — even an empty one — overrides it. Each link's SVG must be safe to inline or the save is refused.",
@@ -108,7 +113,7 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
cloudflareProject:
"Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.",
accent:
- 'Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site\'s public build. Absent = inherit the family brass. Any other spelling is dropped.',
+ 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s default accent — a reader can pick another. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. Any other spelling is dropped.',
siteUrl:
"Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.",
relatedSites:
@@ -223,6 +228,10 @@ export const siteFieldsSchema = z.object({
d.siteDescription,
),
headerTitle: settingsField(stringOr(SITE_DEFAULT_TITLE)).describe(d.headerTitle),
+ // Checked against `headerTitle` in the object step below.
+ wordmarkLead: settingsField((v): string | undefined =>
+ typeof v === "string" && v.trim() ? v.trim() : undefined,
+ ).describe(d.wordmarkLead),
homeTagline: settingsField(stringOr("")).describe(d.homeTagline),
// Key present (array) = override; absent = inherit the global default.
socialLinks: settingsField((v): SocialLink[] | undefined =>
@@ -235,7 +244,7 @@ export const siteFieldsSchema = z.object({
cloudflareProject: settingsField((v): string | undefined =>
typeof v === "string" && v.trim() ? v.trim() : undefined,
).describe(d.cloudflareProject),
- accent: settingsField(parseAccent).describe(d.accent),
+ accent: settingsField(parseAccentSetting).describe(d.accent),
siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl),
relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites),
pwa: settingsField((v): boolean => v === true).describe(d.pwa),
@@ -249,7 +258,8 @@ export const siteFieldsSchema = z.object({
hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl),
});
-// The whole schema: the per-key object, then the two sibling-dependent fields.
+// The whole schema: the per-key object, then the three sibling-dependent
+// fields.
//
// THE OBJECT STEP ALSO RE-EMITS EVERY KEY, in order. zod 4 omits a key that was
// absent from the input when its transform returns `undefined`, so the per-key
@@ -261,6 +271,9 @@ export const siteSchema = siteFieldsSchema.transform((s): Site => {
const resolved: Partial<Record<keyof Site, unknown>> = {
...s,
defaultGroupId: resolveDefaultGroupId(s.defaultGroupId, groups),
+ // A lead that is not a proper prefix of the header title is dropped (the
+ // wordmark then sets the whole title heavy; lib/brand.ts splitWordmark).
+ wordmarkLead: wordmarkLeadFor(s.headerTitle, s.wordmarkLead),
// Drop a membership's groupId that names no configured group (it folds to
// the default at render time via resolveChannelGroupId); keep a valid one
// so the editor round-trips it.
@@ -299,7 +312,8 @@ export function siteToDisk(site: Site): Site {
(c) => !c.groupId || groups.some((g) => g.id === c.groupId),
);
const relatedSites = parseRelatedSites(site.relatedSites);
- const accent = parseAccent(site.accent);
+ const accent = parseAccentSetting(site.accent);
+ const wordmarkLead = wordmarkLeadFor(site.headerTitle, site.wordmarkLead);
const siteUrl = parseSiteUrl(site.siteUrl);
const hubUrl = parseSiteUrl(site.hubUrl);
const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes);
@@ -308,6 +322,7 @@ export function siteToDisk(site: Site): Site {
siteTitle: site.siteTitle,
siteDescription: site.siteDescription,
headerTitle: site.headerTitle,
+ ...(wordmarkLead ? { wordmarkLead } : {}),
homeTagline: site.homeTagline,
// undefined socialLinks = inherit the global default; only an explicit
// override (an array, even empty) is persisted.