commit d97213426c799e8855d9e6eee22e9be227ea5622
parent cd54c6f2179e53e9e4d22bfc0b902de439828eb9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 13:59:48 -0400
redesign Phase 4: StreamActionLog onto the kit + optional per-site brand accent
Two pieces: migrate the long-running action-log chrome onto the shared kit
(preserving its exact a11y contract), and add an optional per-site brand accent
that overrides the family brass end to end.
StreamActionLog (common/components/StreamActionLog.tsx):
- The primary action button and the Cancel button become shadcn `Button`s
(default + outline/destructive); the job-id span, error/notice banner,
queue-status banner, and the streamed `<pre>` log move from raw zinc/blue/red
onto the design tokens (bg-muted, border-border, text-destructive, …).
- EVERY a11y string preserved verbatim: `role="log"` aria-label
`"{name} output"`, error `role="alert"` / info `role="status"` aria-label
`"{name} error|notice"`, queue `role="status"` aria-label `"{name} queue
status"`, `aria-label="Cancel {name}"`, and the `job {id}` span. All ~13
editor call sites inherit the new look with no per-site changes.
Per-site brand accent (optional, defaults to family brass):
- `common/lib/accent.ts`: pure helpers `parseAccent` (validate "#rrggbb") and
`siteAccentVars` (derive `--brand` / `--brand-strong` / `--brand-soft`).
- `Site.accent?: string` on the type, parsed in getSite and persisted in
writeSite (mirrors the cloudflareProject optional handling).
- Editor: a validated "Brand accent" field on the Sites form (blank = inherit).
- Export: the layout bakes the accent onto `<html style>` at PRERENDER, so the
override is present on first paint (before CSS/JS) and applies in both light
and dark; an unset accent renders no override. The ThemeScript per-visit
`--brand` override still wins over the per-site default.
Gate: common + editor typecheck; editor + export production builds; export
site-branding.spec (incl. a new accent assertion — fixture accent #cc3366 →
`--brand` override on <html>) 4/4; editor build/cancel/import-video specs 9/9
(confirming StreamActionLog's button, Cancel a11y label, and log output survive
the migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
10 files changed, 97 insertions(+), 11 deletions(-)
diff --git a/common/components/StreamActionLog.tsx b/common/components/StreamActionLog.tsx
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { StreamActionResult } from "../jobs/streamCommand";
+import { Button } from "./ui/button";
type Props = {
trigger: () => Promise<StreamActionResult>;
@@ -158,27 +159,31 @@ export function StreamActionLog({
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 flex-wrap">
- <button
+ <Button
type="button"
+ size="sm"
onClick={handleClick}
disabled={running || disabled}
- className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{running ? runningLabel : buttonLabel}
- </button>
+ </Button>
{running && jobId && cancelAction && (
- <button
+ <Button
type="button"
+ variant="outline"
+ size="sm"
onClick={handleCancel}
disabled={cancelling}
aria-label={`Cancel ${accessibleName}`}
- className="px-3 py-2 rounded-md border border-red-300 dark:border-red-800 text-sm font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{cancelling ? "Cancelling…" : "Cancel"}
- </button>
+ </Button>
)}
{jobId && (
- <span className="text-xs text-zinc-500 font-mono">job {jobId}</span>
+ <span className="text-xs text-muted-foreground font-mono">
+ job {jobId}
+ </span>
)}
{extraControls}
</div>
@@ -188,8 +193,8 @@ export function StreamActionLog({
aria-label={`${accessibleName} ${errorIsInfo ? "notice" : "error"}`}
className={
errorIsInfo
- ? "rounded border border-zinc-300 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300"
- : "rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"
+ ? "rounded-md border border-border bg-muted px-3 py-2 text-sm text-muted-foreground"
+ : "rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
}
>
{error}
@@ -199,7 +204,7 @@ export function StreamActionLog({
<div
role="status"
aria-label={`${accessibleName} queue status`}
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900 px-3 py-2 text-sm text-zinc-700 dark:text-zinc-300"
+ className="rounded-md border border-border bg-muted px-3 py-2 text-sm text-muted-foreground"
>
Queued in <code className="font-mono">{poll.queueKey}</code>
{typeof poll.queuePosition === "number" && poll.queuePosition > 0
@@ -213,7 +218,7 @@ export function StreamActionLog({
onScroll={handleScroll}
role="log"
aria-label={`${accessibleName} output`}
- className="text-xs font-mono bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded p-3 h-96 overflow-auto whitespace-pre-wrap"
+ className="text-xs font-mono bg-muted border border-border rounded-md p-3 h-96 overflow-auto whitespace-pre-wrap"
>
{log || "Waiting for output…"}
</pre>
diff --git a/common/lib/accent.ts b/common/lib/accent.ts
@@ -0,0 +1,38 @@
+// Per-site accent: an optional brand color a site can set to override the family
+// brass. Stored on Site.accent as a "#rrggbb" string; applied at static-build
+// time as an inline `<html style>` on the export site (see export/app/layout),
+// which wins over the [data-theme] token rules for BOTH light and dark, so one
+// color replaces the brass everywhere. Unset → family brass (the tokens.css
+// default). The user/per-visit override in ThemeScript still wins over this.
+
+const HEX_RE = /^#?([0-9a-f]{6})$/i;
+
+// Normalize a raw accent into a lowercase "#rrggbb", or undefined if it isn't a
+// 6-digit hex color. Mirrors parseSiteUrl/cloudflareProject optional handling.
+export function parseAccent(input: unknown): string | undefined {
+ if (typeof input !== "string") return undefined;
+ const m = HEX_RE.exec(input.trim());
+ return m ? `#${m[1].toLowerCase()}` : undefined;
+}
+
+// Derive the `--brand*` CSS variables from a base accent hex. Returns null for
+// an invalid color (caller then renders no override → family brass).
+// --brand the color itself (used as the main accent)
+// --brand-strong same color (hover emphasis; a single override can't go both
+// lighter-on-dark AND darker-on-light, so keep it stable)
+// --brand-soft a translucent wash for soft backgrounds (mode-agnostic)
+export function siteAccentVars(
+ accent: string | undefined,
+): Record<string, string> | null {
+ const hex = parseAccent(accent);
+ if (!hex) return null;
+ const n = parseInt(hex.slice(1), 16);
+ const r = (n >> 16) & 255;
+ const g = (n >> 8) & 255;
+ const b = n & 255;
+ return {
+ "--brand": hex,
+ "--brand-strong": hex,
+ "--brand-soft": `rgba(${r}, ${g}, ${b}, 0.16)`,
+ };
+}
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -8,6 +8,7 @@ import {
type ChannelGroup,
} from "./channelGroups";
import { getPaths, type Paths } from "./paths";
+import { parseAccent } from "./accent";
import {
getSettings,
normalizeSocialSvg,
@@ -40,6 +41,10 @@ export type Site = {
siteDescription: string;
headerTitle: string;
homeTagline: string;
+ // Optional per-site brand accent ("#rrggbb"). Overrides the family brass on
+ // this site's public build (see siteAccentVars / export layout). Undefined =
+ // inherit the family brass.
+ accent?: string;
// Per-site social links. `undefined` (no `socialLinks` key in site.json)
// means inherit the global default from SiteSettings.socialLinks; an array
// (even empty) overrides it. Resolve with resolveSocialLinks() at render.
@@ -226,6 +231,7 @@ export function parseSite(siteId: string, raw: unknown): Site {
typeof r.cloudflareProject === "string" && r.cloudflareProject.trim()
? r.cloudflareProject.trim()
: undefined,
+ accent: parseAccent(r.accent),
siteUrl: parseSiteUrl(r.siteUrl),
relatedSites: parseRelatedSites(r.relatedSites),
};
@@ -374,6 +380,7 @@ export async function writeSite(
...(site.cloudflareProject && site.cloudflareProject.trim()
? { cloudflareProject: site.cloudflareProject.trim() }
: {}),
+ ...(parseAccent(site.accent) ? { accent: parseAccent(site.accent) } : {}),
...(parseSiteUrl(site.siteUrl) ? { siteUrl: parseSiteUrl(site.siteUrl) } : {}),
...(parseRelatedSites(site.relatedSites).length > 0
? { relatedSites: parseRelatedSites(site.relatedSites) }
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Per-site brand accent.** Each site's config (under Sites) gains an optional **Brand accent** field — a hex color that overrides the family brass on that site's public build. Validated on save; blank inherits the family accent. The long-running action logs (download, transcribe, build, deploy, …) are also restyled onto the shared kit (Button + design tokens), keeping their exact accessible labels. See `common/lib/accent.ts`, `common/lib/site.ts`, `common/components/StreamActionLog.tsx`, `editor/app/sites/{components/SiteForm.tsx,actions.ts}`, and `export/app/layout.tsx`.
- **Command-first cockpit: a ⌘K command palette that runs actions, plus a live dashboard.** Press **⌘K / Ctrl-K** anywhere to open a fast command palette (rebuilt on cmdk) that jumps to any page **and runs pool/site actions inline** — Sync all channels, Refresh all reports, Detect duplicate shorts, Retry all failed jobs, Drain the queue, Pause/Resume all workers — each firing the server action and reporting the result as a **toast** (Sonner). On a channel page it also offers the channel's status filters. The sidebar and palette now read from one shared nav source (`editor/app/lib/nav.ts`) so they never drift (the old palette listed a stale 5-route subset), and every nav item gains an icon. The **Dashboard** gains a **Live jobs** panel that polls running jobs in real time (reusing the active-jobs monitor), and the whole editor shell is restyled compact onto the shared design tokens/kit with the Source type family. See `editor/app/{layout.tsx,page.tsx,lib/nav.ts,components/{CommandPalette.tsx,ChangelogNavLink.tsx}}` and `common/components/ui/sonner.tsx`.
- **New "Family hub URL" setting.** Settings gains an optional hub URL (e.g. `https://archilyzer.com`); every export site renders a link back to it in the header/footer, tying the family of public sites together. Leave it blank for no hub link. Normalized to an absolute http(s) URL on save (`settings.homepageUrl`; see `common/lib/settings.ts`, `editor/app/settings/{components/SettingsForm.tsx,actions.ts}`). Part of the family-wide UI redesign that also restyles the public export sites.
- **Source-truncated downloads are now caught at download time, and the yt-dlp download format is selectable (Odysee defaults to `original`).** A download that completed (yt-dlp exit 0) but whose audio is far shorter than the video — e.g. an Odysee/LBRY video whose every HLS rung is CDN-truncated to a few minutes while the full audio lives only in the multi-GB `original` format — used to pass the audio-check (the short file is structurally *clean*) and get transcribed, so only the **post-transcription** coverage detector caught it. Two changes fix this. **(1) A download-time duration guard:** after each managed download the produced `audio.<fmt>` is probed with **ffprobe** (new `common/ytdlp/ffprobeDuration.ts`, `paths.ffprobeBin` / `FFPROBE_BIN`) and compared to the metadata duration using the same thresholds as the transcript-coverage detector (`isShortAudio` in `common/lib/transcriptCoverage.ts`: non-livestream, ≥10min, <50% covered). A large shortfall is recorded as a new terminal **`failed-short-audio`** status with the measured `{audioDurationSec, expectedDurationSec, coverage}` — caught **before** transcription (the inline no-subs-fallback whisper is pre-checked too). The short stub is **kept on disk** (so it isn't silently re-downloaded into a loop) and is **not** classified for platform backoff (a truncated stream isn't a transient error). Surfaced as a new **`shortAudio`** snapshot bucket (mirroring `corrupt-full-source`: excluded from `downloadedNoTranscript` so it's never auto-transcribed, and from the wrong-format cleanup so the kept file isn't deleted) → a **`short_audio`** channel-list filter + **Select short-audio** quick-select + bulk **Re-download truncated** action, a **"Download was truncated at the source"** banner on the video page with a one-click **Re-download as Original**, a **"Channels with truncated downloads (short audio)"** `/actionable` section, and a **"Truncated download — audio far shorter than the video (file kept)"** download badge. The auto-download runner lands a `failed-short-audio` unit as a failed job. **(2) Selectable, platform-aware download format:** the previously-hardcoded `-f bestaudio/worst` is now a preset (`auto` | `original` | `bestaudio` | `bestvideo_audio`; `common/ytdlp/downloadFormat.ts`) resolved with the same override chain as `audioFormat` — per-download (the redownload **Format** picker) > per-channel (`ChannelConfig.downloadFormat`, a select in the channel form) > global (`SiteSettings.downloadFormat`, a select in **Settings**). **`auto` is platform-aware:** Odysee/LBRY resolves to `original/bestaudio/worst` (so the truncation is avoided at the source); everything else stays `bestaudio/worst`. The source platform is taken from the prefetched metadata extractor. The short-audio bucket's re-download reuses the per-video fixer (delete the stub → re-fetch with the per-source default → re-transcribe) via the existing `redownload-incomplete-bucket` job (now also driving `ReplayBucket: "shortAudio"`). See `common/ytdlp/{downloadFormat.ts,ffprobeDuration.ts,downloadOneManaged.ts,runYtdlp.ts}`, `common/lib/{transcriptCoverage.ts,downloadOutcome.ts,settings.ts,channelConfig.ts,paths.ts,transcripts-server.ts}`, `common/controller/{channelSnapshot.ts,autoRunner.ts}`, `common/jobs/jobSpec.ts`, `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,lib/{fixIncompleteTranscript.ts,videoRows.ts,videoRowsServer.ts,stageStatus.ts},components/VideoListPane.tsx,videos/[id]/{videoActions.ts,components/VideoPanel.tsx}}`, `editor/app/{settings/{actions.ts,components/SettingsForm.tsx},channels/components/{ChannelForm.tsx,parseChannelForm.ts},actionable/{lib/loadActionable.ts,page.tsx,components/InlineActionButton.tsx},jobs/jobReplayRegistry.ts}`, and `editor/e2e/download-format-guard.spec.ts`.
diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { parseAccent } from "yt-dlp-transcript-common/lib/accent";
import {
writeSite,
deleteSite,
@@ -41,6 +42,13 @@ 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)) {
+ return {
+ ok: false,
+ error: "Brand accent must be a hex color like #95661a (or left blank).",
+ };
+ }
const cloudflareProject = String(
formData.get("cloudflareProject") ?? "",
).trim();
@@ -138,6 +146,7 @@ export async function saveSiteAction(
groups,
defaultGroupId,
channels,
+ ...(parseAccent(accentRaw) ? { accent: parseAccent(accentRaw) } : {}),
...(cloudflareProject ? { cloudflareProject } : {}),
...(siteUrl ? { siteUrl } : {}),
...(relatedSites.length > 0 ? { relatedSites } : {}),
diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx
@@ -222,6 +222,12 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) {
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."
+ />
+ <Field
label="Cloudflare Pages project"
name="cloudflareProject"
defaultValue={initial.cloudflareProject ?? ""}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Optional per-site brand accent.** A site can set its own accent color (a hex, in the editor's site config) to replace the family brass everywhere on its public build — the wordmark mark, links, badges, and highlights. It's baked onto the page at build time so it's correct on the very first paint, and applies in both light and dark. Sites that don't set one keep the family brass.
- **New look: the "Archive / Reading Room" identity.** The public site adopts the family design system — a serif display face (Source Serif 4) for headings/counts, clean sans for body, and mono for the query/power layer, over a warm paper (light) / ink (dark) palette with a brass accent. A light/dark/system theme toggle in the header replaces OS-only dark mode and persists your choice. Header, footer, and the whole search UI (query builder, filters, scopes, charts) are rebuilt on a shared component kit for a calmer, more consistent feel — the simple one-box search is unchanged; the power tools just read better.
- **"New since your last visit."** A dismissible banner on the home page tells you how many transcripts were added since you were last here, and first-time visitors get a one-line nudge toward the power tools (layered queries, scopes, filters, charts). Entirely client-side — nothing about your visit leaves your browser.
- **Family navigation.** The header gains a "Sites" switcher to hop between sibling sites in the pool, plus an optional link back to the family hub (set the hub URL once in the editor's Settings).
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -3,6 +3,7 @@ import { fontVars } from "yt-dlp-transcript-common/styles/fonts";
import { ThemeScript } from "yt-dlp-transcript-common/components/ThemeScript";
import { ThemeProvider } from "yt-dlp-transcript-common/components/ThemeProvider";
import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
+import { siteAccentVars } from "yt-dlp-transcript-common/lib/accent";
import { currentSite } from "./lib/site";
import Header from "./components/Header";
import Footer from "./components/Footer";
@@ -24,11 +25,15 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ // Per-site accent: bake the brand-color override onto <html> at prerender so
+ // it's present on first paint (before any CSS/JS). Unset → family brass.
+ const accentVars = siteAccentVars(currentSite().accent);
return (
<html
lang="en"
suppressHydrationWarning
className={`${fontVars} h-full antialiased`}
+ style={accentVars ?? undefined}
>
<body className="min-h-full flex flex-col bg-background text-foreground font-sans">
<ThemeScript defaultTheme="archive" defaultMode="system" />
diff --git a/export/e2e/fixtures/sites/testsite/site.json b/export/e2e/fixtures/sites/testsite/site.json
@@ -4,6 +4,7 @@
"siteDescription": "Fixture site description",
"headerTitle": "Fixture Header",
"homeTagline": "Fixture tagline here",
+ "accent": "#cc3366",
"socialLinks": [
{
"label": "GitHub",
diff --git a/export/e2e/site-branding.spec.ts b/export/e2e/site-branding.spec.ts
@@ -28,3 +28,16 @@ test("footer renders the site's social links", async ({ page }) => {
page.getByRole("link", { name: "GitHub" }),
).toHaveAttribute("href", "https://github.com/example");
});
+
+test("per-site accent is baked onto <html> as a brand override", async ({
+ page,
+}) => {
+ await installRoutes(page);
+ await page.goto("/");
+ // site.json sets accent #cc3366; the layout emits it as an inline --brand
+ // override on <html> at prerender (overriding the family brass).
+ await expect(page.locator("html")).toHaveAttribute(
+ "style",
+ /--brand:\s*#cc3366/,
+ );
+});