Archilyzer · Source

archilyzer

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

commit dfc66c1f7c66b81319b34e5d5ddda427d357da91
parent 9b4da2bd95fc93bf717b50b7c5470cf0c15db878
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 21:00:53 -0400

editor: the site form picks a named accent (or a custom hex) and a wordmark lead (brand S0)

"Brand accent" is now a native radio group: the seven named swatches
(each with its on-light / on-dark dot, Signal marked default) plus
"Custom", whose hex lives in a "Custom hex" field — typing there selects
Custom. The default is stored as absent; a bare hex posted in `accent`
(a pre-radio form) is taken as custom. New "Wordmark lead" field after the
header title; the action refuses a lead that is not a proper prefix rather
than letting the schema drop it silently. writeSite stays the one writer.
No new label or hint contains "header title", "site title", "site id" or
"public url". sites-crud gains the round-trip test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/app/sites/actions.ts | 42+++++++++++++++++++++++++++++++++++++-----
Meditor/app/sites/components/SiteForm.tsx | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/e2e/sites-crud.spec.ts | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 196 insertions(+), 12 deletions(-)

diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -2,7 +2,14 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { parseAccent } from "yt-dlp-transcript-common/lib/accent"; +import { + parseAccent, + parseAccentSetting, +} from "yt-dlp-transcript-common/lib/accent"; +import { + DEFAULT_ACCENT, + wordmarkLeadFor, +} from "yt-dlp-transcript-common/lib/brand"; import { writeSite, deleteSite, @@ -42,13 +49,37 @@ export async function saveSiteAction( if (!headerTitle) return { ok: false, error: "Header title is required" }; const siteDescription = String(formData.get("siteDescription") ?? "").trim(); const homeTagline = String(formData.get("homeTagline") ?? "").trim(); - const accentRaw = String(formData.get("accent") ?? "").trim(); - if (accentRaw && !parseAccent(accentRaw)) { + // The wordmark's heavy lead must be a proper prefix of the header title; + // lib/siteSchema.ts would silently drop anything else, so say so here. + const wordmarkLead = String(formData.get("wordmarkLead") ?? "").trim(); + if (wordmarkLead && !wordmarkLeadFor(headerTitle, wordmarkLead)) { return { ok: false, - error: "Brand accent must be a hex color like #95661a (or left blank).", + error: `Wordmark lead must be how the header title starts, same case, and shorter than it (e.g. "Jer" for "Jeralyzer") — or left blank.`, }; } + // Brand accent: a named id from the radio group, or "custom" + its hex + // field. The default (Signal) is stored as absent — the two read the same. + // A bare hex in `accent` (a form from before the radio group) is taken as + // a custom colour. + const accentChoice = String(formData.get("accent") ?? "").trim(); + const accentCustomRaw = String(formData.get("accentCustom") ?? "").trim(); + let accent: string | undefined; + if (accentChoice === "custom") { + accent = parseAccent(accentCustomRaw); + if (!accent) { + return { + ok: false, + error: "Custom accent must be a hex color like #cc3366.", + }; + } + } else if (accentChoice) { + accent = parseAccentSetting(accentChoice); + if (!accent) { + return { ok: false, error: `Unknown brand accent "${accentChoice}".` }; + } + if (accent === DEFAULT_ACCENT) accent = undefined; + } const cloudflareProject = String( formData.get("cloudflareProject") ?? "", ).trim(); @@ -171,12 +202,13 @@ export async function saveSiteAction( siteTitle, siteDescription, headerTitle, + ...(wordmarkLead ? { wordmarkLead } : {}), homeTagline, ...(socialLinks !== undefined ? { socialLinks } : {}), groups, defaultGroupId, channels, - ...(parseAccent(accentRaw) ? { accent: parseAccent(accentRaw) } : {}), + ...(accent ? { accent } : {}), ...(cloudflareProject ? { cloudflareProject } : {}), ...(siteUrl ? { siteUrl } : {}), ...(hubUrl ? { hubUrl } : {}), diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -1,9 +1,15 @@ "use client"; -import { useState } from "react"; +import { useState, type CSSProperties } from "react"; import { useActionState } from "react"; import { saveSiteAction, type SaveResult } from "../actions"; import type { Site } from "yt-dlp-transcript-common/lib/site"; +import { + ACCENTS, + ACCENT_IDS, + DEFAULT_ACCENT, + isAccentId, +} from "yt-dlp-transcript-common/lib/brand"; import type { ChannelGroup } from "yt-dlp-transcript-common/lib/channelGroups"; import { SocialLinksField, @@ -62,6 +68,18 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { })), ); const [defaultGroupId, setDefaultGroupId] = useState(initial.defaultGroupId); + // Brand accent: one of the named accents (absent reads as the default, + // Signal) or "custom" with its own hex. Controlled, so the choice survives + // the form reset React does after an action. + const initialCustom = + initial.accent && !isAccentId(initial.accent) ? initial.accent : ""; + const [accentChoice, setAccentChoice] = useState<string>(() => + initialCustom ? "custom" : (initial.accent ?? DEFAULT_ACCENT), + ); + const [customHex, setCustomHex] = useState(initialCustom); + const customSwatch = /^#?[0-9a-f]{6}$/i.test(customHex.trim()) + ? `#${customHex.trim().replace(/^#/, "")}` + : undefined; // undefined socialLinks = inherit the global default (the default for new // sites); an array (even empty) = this site supplies its own list. const [inheritSocial, setInheritSocial] = useState( @@ -217,6 +235,12 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { required /> <Field + label="Wordmark lead" + name="wordmarkLead" + defaultValue={initial.wordmarkLead ?? ""} + hint={'The heavy start of the wordmark — the subject\'s name, e.g. "Jer" for Jeralyzer; the rest is set light. It must be how the header text begins (same case) and shorter than it. Leave blank to set the whole name heavy.'} + /> + <Field label="Site description" name="siteDescription" defaultValue={initial.siteDescription} @@ -226,12 +250,71 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { name="homeTagline" defaultValue={initial.homeTagline} /> - <Field - label="Brand accent" - name="accent" - defaultValue={initial.accent ?? ""} - hint="Optional brand color as a hex (#rrggbb) — overrides the family brass on this site. Leave blank to inherit the family accent." - /> + <fieldset className="flex flex-col gap-2 text-sm"> + <legend className="font-medium">Brand accent</legend> + <p className="text-xs text-muted-foreground"> + This site&apos;s default accent (its icon and first paint); a reader + can pick another. A custom colour is darkened or lightened per theme + until it reads at 4.5:1. + </p> + <div className="flex flex-wrap gap-x-4 gap-y-2"> + {ACCENT_IDS.map((id) => ( + <label key={id} className="flex items-center gap-1.5"> + <input + type="radio" + name="accent" + value={id} + checked={accentChoice === id} + onChange={() => setAccentChoice(id)} + className="accent-brand" + /> + <span + aria-hidden="true" + className="size-3.5 rounded-full border border-border bg-[var(--swatch-on-light)] dark:bg-[var(--swatch-on-dark)]" + style={ + { + "--swatch-on-light": ACCENTS[id].onLight, + "--swatch-on-dark": ACCENTS[id].onDark, + } as CSSProperties + } + /> + {ACCENTS[id].name} + {id === DEFAULT_ACCENT ? " (default)" : ""} + </label> + ))} + <label className="flex items-center gap-1.5"> + <input + type="radio" + name="accent" + value="custom" + checked={accentChoice === "custom"} + onChange={() => setAccentChoice("custom")} + className="accent-brand" + /> + <span + aria-hidden="true" + className="size-3.5 rounded-full border border-border" + style={{ background: customSwatch ?? "transparent" }} + /> + Custom + </label> + </div> + <label className="flex items-center gap-2"> + <span className="font-medium">Custom hex</span> + <input + type="text" + name="accentCustom" + value={customHex} + onChange={(e) => { + setCustomHex(e.target.value); + setAccentChoice("custom"); + }} + placeholder="#cc3366" + spellCheck={false} + className="w-28 rounded border border-border bg-card px-2 py-1 font-mono text-sm" + /> + </label> + </fieldset> <Field label="Cloudflare Pages project" name="cloudflareProject" diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -400,3 +400,72 @@ test("first-run migrate button appears only when no sites exist", async ({ page.getByRole("button", { name: /migrate existing settings/i }), ).toHaveCount(0); }); + +test("brand accent radio group + wordmark lead round-trip to site.json", async ({ + page, +}) => { + await resetData("empty"); + await writeSite("brandy", { siteTitle: "Brandyalyzer" }); + + type BrandSiteFile = { accent?: string; wordmarkLead?: string }; + const file = "test-transcripts/sites/brandy/site.json"; + const accents = page.getByRole("group", { name: "Brand accent" }); + const radio = (name: string | RegExp) => + accents.getByRole("radio", { name, exact: typeof name === "string" }); + const hex = page.getByRole("textbox", { name: "Custom hex" }); + const lead = page.getByLabel(/wordmark lead/i); + const save = async () => { + await page.getByRole("button", { name: /save site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + }; + + // No accent on disk reads as the default, Signal; seven swatches + Custom. + await page.goto("/sites/brandy"); + await expect(accents.getByRole("radio")).toHaveCount(8); + await expect(radio(/^Signal/)).toBeChecked(); + await expect(lead).toHaveValue(""); + + await radio("Brass").check(); + await lead.fill("Brandy"); + await save(); + await expect(async () => { + const site = await readJson<BrandSiteFile>(file); + expect(site.accent).toBe("brass"); + expect(site.wordmarkLead).toBe("Brandy"); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites/brandy"); + await expect(radio("Brass")).toBeChecked(); + await expect(lead).toHaveValue("Brandy"); + + // Typing a hex selects Custom; it is stored normalized. + await hex.fill("#CC3366"); + await expect(radio("Custom")).toBeChecked(); + await save(); + await expect(async () => { + const site = await readJson<BrandSiteFile>(file); + expect(site.accent).toBe("#cc3366"); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites/brandy"); + await expect(radio("Custom")).toBeChecked(); + await expect(hex).toHaveValue("#cc3366"); + + // A lead that is not how the header title starts is refused, not dropped. + await lead.fill("brandy"); + await page.getByRole("button", { name: /save site/i }).click(); + await expect(page.getByRole("alert")).toContainText(/wordmark lead/i); + expect((await readJson<BrandSiteFile>(file)).wordmarkLead).toBe("Brandy"); + + // Back to the default: stored as absent; a blank lead removes the key. + await lead.fill(""); + await radio(/^Signal/).check(); + await save(); + await expect(async () => { + const site = await readJson<BrandSiteFile>(file); + expect("accent" in site).toBe(false); + expect("wordmarkLead" in site).toBe(false); + }).toPass({ timeout: 10_000 }); +});