Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2dae56843a37e7160ffcb05d1b5861c91985bb21
parent ed5c706d6d4c68cf21f74082681201accbd151b8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 18:44:04 -0400

homepage: each Official Instances card names its site with the site's wordmark, the lead tinted in its accent

- The summary's sites carry an optional `wordmarkLead` (site.json's, resolved
  by lib/brand.ts wordmarkLeadFor; still version 5); the homepage's loader
  keeps it only when it is a proper prefix of the title.
- ArchiveCards sets the title with the shared Wordmark at the card title's
  size, the lead in the site's own accent (siteAccentColor, siteColor's accent
  half; above 4:1 on the card on every base). No lead, or an older summary:
  the plain title. The link's accessible name stays the plain title.
- Wordmark gains `leadStyle`.
- Tests: the fold, the loader (a lead that is not a proper prefix is
  dropped), and instance-wordmark.spec (a mid-word lead keeps the name whole;
  the tint for a named accent and a custom hex; ≥ 3:1 on three grounds).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/components/Wordmark.tsx | 7+++++--
Mcommon/lib/homepageSummary.test.ts | 23+++++++++++++++++++++++
Mcommon/lib/homepageSummary.ts | 16++++++++++++++--
Mcommon/lib/siteColor.ts | 13+++++++++++--
Mhomepage/app/components/ArchiveCards.tsx | 31+++++++++++++++++++++++++++++--
Mhomepage/app/lib/summary.test.ts | 38+++++++++++++++++++++++++++++++++++++-
Mhomepage/app/lib/summary.ts | 18++++++++++++++++--
Mhomepage/e2e/fixture-summary.ts | 35++++++++++++++++++++++++-----------
Ahomepage/e2e/instance-wordmark.spec.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 273 insertions(+), 22 deletions(-)

diff --git a/common/components/Wordmark.tsx b/common/components/Wordmark.tsx @@ -13,15 +13,18 @@ import { splitWordmark } from "../lib/brand"; // // Size, leading and truncation come from `className`. The face is Archivo, // the display face on every base (common/styles/fonts.ts `--font-display`, -// with the `wdth` axis the stretch needs). +// with the `wdth` axis the stretch needs). `leadStyle` is laid over the lead's +// own style (the homepage's instance cards tint it in the site's accent). export function Wordmark({ title, lead, className, + leadStyle, }: { title: string; lead?: string; className?: string; + leadStyle?: React.CSSProperties; }) { const parts = splitWordmark(title, lead); return ( @@ -35,7 +38,7 @@ export function Wordmark({ > <span data-wordmark-lead="" - style={{ fontWeight: 720, color: "var(--foreground)" }} + style={{ fontWeight: 720, color: "var(--foreground)", ...leadStyle }} > {parts.lead} </span> diff --git a/common/lib/homepageSummary.test.ts b/common/lib/homepageSummary.test.ts @@ -214,3 +214,26 @@ test("a named accent also travels as its id; a custom hex and no accent carry no const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); assert.ok(plain.sites.every((x) => !("accentId" in x))); }); + +test("a site's wordmark lead travels when it is a proper prefix of the title; otherwise no key", () => { + const sites = [ + { ...site("alpha", ["a1", "a2"], "https://alpha.example"), siteTitle: "Jeralyzer", wordmarkLead: " Jer " }, + { ...site("beta", ["b1"], "https://beta.example"), siteTitle: "Anilyzer", wordmarkLead: "Anilyzer" }, + ] as Site[]; + const s = buildHomepageSummary(STATS, CHANNEL_SITES, sites, NOW); + assert.equal(s.sites.find((x) => x.siteId === "alpha")!.wordmarkLead, "Jer"); + // The whole title is no split (lib/brand.ts wordmarkLeadFor): no key. + assert.ok(!("wordmarkLead" in s.sites.find((x) => x.siteId === "beta")!)); + // Resolved against the title the card shows, case-sensitive. + const other = buildHomepageSummary( + STATS, + CHANNEL_SITES, + [{ ...sites[0], wordmarkLead: "jer" }] as Site[], + NOW, + ); + assert.ok(!("wordmarkLead" in other.sites[0])); + // No lead configured: no key, as before. + const plain = buildHomepageSummary(STATS, CHANNEL_SITES, SITES, NOW); + assert.ok(plain.sites.every((x) => !("wordmarkLead" in x))); + assert.equal(s.version, HOMEPAGE_SUMMARY_VERSION); +}); diff --git a/common/lib/homepageSummary.ts b/common/lib/homepageSummary.ts @@ -2,7 +2,7 @@ import type { Platform } from "./platform"; import type { VideoStat } from "./stats"; import type { Site } from "./site"; import { accentHex, accentIdOf } from "./accent"; -import type { AccentId } from "./brand"; +import { wordmarkLeadFor, type AccentId } from "./brand"; import { VIDEO_STATES, type VideoState } from "./availability"; // Pre-computed, lightweight cross-site summary for the hub (homepage) landing. @@ -30,7 +30,9 @@ import { VIDEO_STATES, type VideoState } from "./availability"; // the same reason: a v4 summary on disk must still render (the numbers hide). // // Still v5: the per-site `accentId` (release 10) is additive and optional in -// the same way — a summary without it paints its sites' hex, as before. +// the same way — a summary without it paints its sites' hex, as before. So is +// the per-site `wordmarkLead` (release 14): a summary without it shows each +// card's title plain, as before. Nothing reads this number to accept a file. export const HOMEPAGE_SUMMARY_VERSION = 5; // Day buckets are capped to this many trailing days so the embedded summary stays @@ -90,6 +92,13 @@ export type HomepageSummarySite = { // base (`var(--swatch-<id>)`, lib/siteColor.ts). Optional, additive // (release 10): absent for a custom hex, no accent, or an older summary. accentId?: AccentId; + // The site's wordmark lead ("Jer" of "Jeralyzer"): site.json `wordmarkLead` + // resolved against `siteTitle` by lib/brand.ts wordmarkLeadFor, the resolver + // the sites' own header and site.json use — a proper prefix of the title, or + // absent. The homepage's card sets the title as the site's wordmark with it. + // Optional, additive (release 14): absent for a site with no lead, or an + // older summary. + wordmarkLead?: string; }; export type HomepageChannelMeta = { slug: string; name: string }; @@ -443,6 +452,9 @@ export function buildHomepageSummary( // travels as its id too. ...(accentHex(s.accent) ? { accent: accentHex(s.accent) } : {}), ...(accentIdOf(s.accent) ? { accentId: accentIdOf(s.accent) } : {}), + ...(wordmarkLeadFor(s.siteTitle, s.wordmarkLead) + ? { wordmarkLead: wordmarkLeadFor(s.siteTitle, s.wordmarkLead) } + : {}), })) // 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/siteColor.ts b/common/lib/siteColor.ts @@ -124,8 +124,17 @@ export function fittedHex(accent: unknown): string | undefined { return hex ? perBaseColor(resolveAccent(hex)) : undefined; } +// A site's OWN accent as a CSS colour on the base in force — a named accent's +// swatch, or a custom hex fitted to each base — or undefined for a site with +// none. Both are text colours: ≥ 4.5:1 on each ground, and above 4:1 on the +// homepage's card surface, whose instance cards tint the wordmark's lead with +// it. +export function siteAccentColor(site: SiteColorSource): string | undefined { + if (isAccentId(site.accentId)) return `var(--swatch-${site.accentId})`; + return fittedHex(site.accent); +} + // 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 fittedHex(site.accent) ?? chart; + return siteAccentColor(site) ?? chart; } diff --git a/homepage/app/components/ArchiveCards.tsx b/homepage/app/components/ArchiveCards.tsx @@ -1,5 +1,10 @@ import type { HomepageSummarySite } from "yt-dlp-transcript-common/lib/homepageSummary"; -import { siteChartColors, siteColor } from "yt-dlp-transcript-common/lib/siteColor"; +import { + siteAccentColor, + siteChartColors, + siteColor, +} from "yt-dlp-transcript-common/lib/siteColor"; +import { Wordmark } from "yt-dlp-transcript-common/components/Wordmark"; // The official instances: one card per public archive, its title linking out, // its own numbers underneath. No description line under the title — the @@ -38,6 +43,28 @@ function Figure({ value, unit }: { value: number | undefined; unit: string }) { ); } +// THE NAME IS THE SITE'S WORDMARK when the summary carries its lead: the lead +// heavy, the rest light (common/components/Wordmark.tsx, as the site's own +// header sets it), at the card title's size. The lead is TINTED in the site's +// own accent (siteAccentColor: a named accent's swatch, a custom hex fitted to +// the base in force — above 4:1 on the card on every base); a site with no +// accent keeps the foreground. The two spans are adjacent inline text, so the +// link's accessible name stays the plain title. With no lead (none configured, +// or a summary from before release 14) the title is plain, as it was. In forced +// colours the tint is the system's text colour, like any text. +function SiteName({ site }: { site: HomepageSummarySite }) { + if (!site.wordmarkLead) return <>{site.siteTitle}</>; + const accent = siteAccentColor(site); + return ( + <Wordmark + title={site.siteTitle} + lead={site.wordmarkLead} + className="tracking-[-0.01em]" + leadStyle={accent ? { color: accent } : undefined} + /> + ); +} + export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) { if (sites.length === 0) return null; const chart = siteChartColors(sites); @@ -60,7 +87,7 @@ export function ArchiveCards({ sites }: { sites: HomepageSummarySite[] }) { rel="noopener noreferrer" className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-[var(--border-strong)]" > - {site.siteTitle} + <SiteName site={site} /> <span aria-hidden="true" className="ml-1.5 text-sm text-[var(--faint)]">↗</span> </a> </h3> diff --git a/homepage/app/lib/summary.test.ts b/homepage/app/lib/summary.test.ts @@ -1,6 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { summaryFile } from "./summary"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadSummary, summaryFile } from "./summary"; // Run with: // pnpm --filter homepage test @@ -22,3 +25,36 @@ test("outside production (the e2e's next dev) it names the file to read", () => assert.equal(summaryFile({ NODE_ENV: "development" }, PUBLIC), PUBLIC); assert.equal(summaryFile({ NODE_ENV: "development", E2E_HOMEPAGE_SUMMARY_FILE: "" }, PUBLIC), PUBLIC); }); + +// A summary written before release 14 carries no `wordmarkLead` on its sites; +// it must still load, and its cards show the plain title (ArchiveCards). A lead +// that is not a proper prefix of the title (hand-edited, or not a string) is +// dropped on read, so the card never splits a title it does not start. +test("the loader keeps a wordmarkLead only when it is a proper prefix of the title", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hp-summary-")); + const file = path.join(dir, "homepage-summary.json"); + const site = { siteId: "a", siteTitle: "Jeralyzer", siteDescription: "", siteUrl: "https://a.example" }; + // Next types NODE_ENV as read-only; the test writes through a plain view. + const env = process.env as Record<string, string | undefined>; + const prev = { NODE_ENV: env.NODE_ENV, E2E: env.E2E_HOMEPAGE_SUMMARY_FILE }; + const write = (sites: unknown[]) => + fs.writeFileSync(file, JSON.stringify({ version: 5, totals: { transcripts: 1 }, sites })); + try { + delete env.NODE_ENV; + env.E2E_HOMEPAGE_SUMMARY_FILE = file; + write([site]); + assert.ok(!("wordmarkLead" in loadSummary()!.sites[0]), "an older summary: no lead"); + write([{ ...site, wordmarkLead: "Jer" }]); + assert.equal(loadSummary()?.sites[0].wordmarkLead, "Jer"); + for (const bad of ["Jeralyzer", "jer", "Xyz", "", 7, null]) { + write([{ ...site, wordmarkLead: bad }]); + assert.ok(!("wordmarkLead" in loadSummary()!.sites[0]), JSON.stringify(bad)); + } + } finally { + if (prev.NODE_ENV === undefined) delete env.NODE_ENV; + else env.NODE_ENV = prev.NODE_ENV; + if (prev.E2E === undefined) delete env.E2E_HOMEPAGE_SUMMARY_FILE; + else env.E2E_HOMEPAGE_SUMMARY_FILE = prev.E2E; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/homepage/app/lib/summary.ts b/homepage/app/lib/summary.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import type { HomepageSummary } from "yt-dlp-transcript-common/lib/homepageSummary"; +import type { + HomepageSummary, + HomepageSummarySite, +} from "yt-dlp-transcript-common/lib/homepageSummary"; +import { wordmarkLeadFor } from "yt-dlp-transcript-common/lib/brand"; // The build-time cross-site summary, or null when this build shipped without // corpus data (`build:nodata`, or a source-only checkout that has never run @@ -28,7 +32,7 @@ export function loadSummary(): HomepageSummary | null { // A file that exists but carries no sites/totals is as uninformative as no // file at all; treat it the same rather than rendering an empty dashboard. if (!parsed || typeof parsed.totals?.transcripts !== "number") return null; - return parsed; + return { ...parsed, sites: (parsed.sites ?? []).map(withCheckedLead) }; } catch { return null; } @@ -47,3 +51,13 @@ export function summaryFile( const override = env.NODE_ENV !== "production" ? env.E2E_HOMEPAGE_SUMMARY_FILE : undefined; return override || publicFile; } + +// A site's `wordmarkLead` (release 14) is used only when it is what compose +// would have written: a proper prefix of its title (lib/brand.ts +// wordmarkLeadFor). A summary from before release 14 has none, and one edited +// by hand may hold anything; either way the card then shows the plain title. +export function withCheckedLead(site: HomepageSummarySite): HomepageSummarySite { + const { wordmarkLead, ...rest } = site; + const lead = wordmarkLeadFor(site.siteTitle, wordmarkLead); + return lead ? { ...rest, wordmarkLead: lead } : rest; +} diff --git a/homepage/e2e/fixture-summary.ts b/homepage/e2e/fixture-summary.ts @@ -38,16 +38,28 @@ export const FIXTURE_NOW = new Date("2026-09-15T12:00:00Z"); 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 +// accent id or a custom hex), as compose reads it; `wordmarkLead` the +// site.json one (a named accent's site, the custom hex's and a site with no +// accent have one — the last ends MID-WORD, "Fix" + "ture Three", as +// "Jer" + "alyzer" does; the rest show their title plain); `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 }, +export type FixtureSite = { + siteId: string; + siteTitle: string; + accent?: string; + wordmarkLead?: string; + channels: number; + daily: number; +}; + +export const FIXTURE_SITES: readonly FixtureSite[] = [ + { siteId: "fixture-one", siteTitle: "Fixture One", accent: "brass", wordmarkLead: "Fixture", channels: 4, daily: 15 }, + { siteId: "fixture-two", siteTitle: "Fixture Two", accent: FIXTURE_PALE_HEX, wordmarkLead: "Fixture", channels: 3, daily: 9 }, + { siteId: "fixture-three", siteTitle: "Fixture Three", wordmarkLead: "Fix", channels: 3, daily: 7 }, + { siteId: "fixture-four", siteTitle: "Fixture Four", channels: 2, daily: 8 }, + { siteId: "fixture-five", siteTitle: "Fixture Five", 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 }; @@ -64,7 +76,7 @@ function uploadDate(n: number): string { return ymd(d.getTime()); } -export function buildFixtureSummary() { +export function buildFixtureSummary(fixtureSites: readonly FixtureSite[] = FIXTURE_SITES) { const stats: VideoStat[] = []; const channelSites: Record<string, string[]> = {}; const sites: Site[] = []; @@ -100,7 +112,7 @@ export function buildFixtureSummary() { }); }; const today = Date.UTC(2026, 8, 15); - FIXTURE_SITES.forEach((s, i) => { + fixtureSites.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({ @@ -110,6 +122,7 @@ export function buildFixtureSummary() { siteUrl: `https://${s.siteId}.example`, channels: slugs.map((slug) => ({ slug })), ...(s.accent ? { accent: s.accent } : {}), + ...(s.wordmarkLead ? { wordmarkLead: s.wordmarkLead } : {}), } as unknown as Site); slugs.forEach((slug, c) => { const name = `${s.siteTitle} Channel ${c + 1}`; @@ -123,7 +136,7 @@ export function buildFixtureSummary() { }); // The megaspike: one week of bulk transcription on the first site's first // channel. - const spikeSite = FIXTURE_SITES[SPIKE.site]; + const spikeSite = fixtureSites[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); } diff --git a/homepage/e2e/instance-wordmark.spec.ts b/homepage/e2e/instance-wordmark.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, type Page } from "@playwright/test"; +import { resolveAccent } from "../../common/lib/accent"; +import { FIXTURE_PALE_HEX, FIXTURE_SITES } from "./fixture-summary"; + +// Each Official Instances card names its site with the site's WORDMARK when +// the summary carries its lead: the lead heavy and TINTED in the site's own +// accent (a named accent's swatch on the base in force, a custom hex fitted to +// it), the rest light. A site with a lead and no accent keeps the foreground; +// a site with no lead shows its title plain, as before. The link's accessible +// name is the plain title throughout. +// +// The fixture (fixture-summary.ts): Fixture One (Brass, lead "Fixture"), +// Fixture Two (a pale custom hex, lead "Fixture"), Fixture Three (no accent, +// lead "Fix" — mid-word, so a split name would read "Fix ture Three"), Four and +// Five (neither), Six (Vermilion, no lead). + +const BASE_KEY = "ytdlp-tb:base"; + +const section = (page: Page) => + page.locator("section").filter({ + has: page.getByRole("heading", { level: 2, name: "Official Instances", exact: true }), + }); + +const card = (page: Page, title: string) => + section(page) + .locator("li") + .filter({ has: page.getByRole("link", { name: title, exact: true }) }); + +// The colour a CSS colour resolves to on this page. +const resolve = (page: Page, css: string) => + page.evaluate((c) => { + const el = document.createElement("span"); + el.style.color = c; + document.body.append(el); + const out = getComputedStyle(el).color; + el.remove(); + return out; + }, css); + +function luminance(rgb: string): number { + const [r, g, b] = (rgb.match(/[\d.]+/g) ?? []).slice(0, 3).map((n) => { + const c = Number(n) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +const contrast = (a: string, b: string) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +async function wordmarkOf(page: Page, title: string) { + return card(page, title).evaluate((li) => { + const lead = li.querySelector("[data-wordmark-lead]"); + const suffix = li.querySelector("[data-wordmark-suffix]"); + const cs = (el: Element | null) => (el ? getComputedStyle(el) : null); + return { + lead: lead?.textContent ?? null, + suffix: suffix?.textContent ?? null, + leadWeight: Number(cs(lead)?.fontWeight ?? 0), + suffixWeight: Number(cs(suffix)?.fontWeight ?? 0), + leadColor: cs(lead)?.color ?? null, + foreground: getComputedStyle(document.documentElement).getPropertyValue("--foreground").trim(), + cardBg: getComputedStyle(li).backgroundColor, + }; + }); +} + +test("each card's name: the wordmark where there is a lead, tinted in the site's accent at ≥ 3:1 on every base, and the plain title as its name", async ({ + page, +}) => { + await page.goto("/"); + for (const s of FIXTURE_SITES) { + await expect(section(page).getByRole("link", { name: s.siteTitle, exact: true })).toHaveCount(1); + } + const pale = resolveAccent(FIXTURE_PALE_HEX); + for (const base of ["light", "sepia", "dark"] as const) { + await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BASE_KEY, base]); + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-base", base); + + const want: Record<string, { lead: string; colour: string }> = { + "Fixture One": { lead: "Fixture", colour: await resolve(page, "var(--swatch-brass)") }, + "Fixture Two": { lead: "Fixture", colour: await resolve(page, pale[base]) }, + "Fixture Three": { lead: "Fix", colour: await resolve(page, "var(--foreground)") }, + }; + for (const [title, { lead, colour }] of Object.entries(want)) { + const w = await wordmarkOf(page, title); + expect(w.lead, `${title} lead`).toBe(lead); + expect(w.suffix, `${title} suffix`).toBe(title.slice(lead.length)); + expect(w.leadWeight, `${title}: the lead is heavier`).toBeGreaterThan(w.suffixWeight); + expect(w.leadColor, `${title} on ${base}`).toBe(colour); + expect( + contrast(w.leadColor!, w.cardBg), + `${title}'s lead against its card on ${base}`, + ).toBeGreaterThanOrEqual(3); + } + // The named accent and the custom hex are tinted, not the foreground. + const fg = await resolve(page, "var(--foreground)"); + expect(want["Fixture One"].colour).not.toBe(fg); + expect(want["Fixture Two"].colour).not.toBe(fg); + // A lead that ends mid-word leaves the name whole: one accessible name, + // and the text as a reader sees it, with no space inserted. + const three = card(page, "Fixture Three").getByRole("link"); + await expect(three).toHaveAccessibleName("Fixture Three"); + expect((await three.innerText()).replace("↗", "").trim()).toBe("Fixture Three"); + + // No lead: the plain title, no wordmark — accent or not. + for (const title of ["Fixture Four", "Fixture Five", "Fixture Six"]) { + await expect(card(page, title).locator("[data-wordmark]"), title).toHaveCount(0); + await expect(card(page, title).getByRole("link")).toHaveText(new RegExp(`^${title}`)); + } + } +});