commit bfa0009ebed5a3019123cf860324237777d63760
parent 155ce098f71868387afadc99b8ba450b3c80f505
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 02:37:30 -0400
redesign Phase 2: public Archive identity, kit-migrated search, family nav, FOUC fix
Restyle the public export site onto the family "Archive / Reading Room" identity
and migrate the shared search UI onto the shadcn kit, plus family navigation,
a "new since your last visit" banner, and the static-deploy FOUC fix.
Export identity (export/app):
- layout: defaults to the public "archive" family (paper/ink) with a
light/dark/system toggle; token-driven body (bg-background/text-foreground).
- new Header: serif wordmark + brass mark, calm nav, theme toggle, and the
"family" chrome — a kit DropdownMenu sibling-site switcher (SiblingSwitcher,
fed by resolveRelatedSites) and an optional hub backlink.
- page: serif transcript count; Footer: token restyle.
- export theme spec updated for the archive default.
Search UI → tokens + shadcn kit (common/components: TranscriptSearch,
QueryBuilder, QueryGroupView, QueryLeafView, charts/ChartShapeControls, badges):
- Raw zinc/blue Tailwind pairs replaced with the shared token contract
(bg-card/muted, border-border, text-muted-foreground, bg-primary, etc.);
mono signature on the query/power layer.
- Native controls swapped for the kit: Button (Search CTA, resets, profile
actions, card/hit rows), Checkbox (filter + leaf/group toggles), Input
(leaf query). Test-coupled controls intentionally kept native and only
restyled: the <select>s asserted via selectOption (profile/scope/group-op +
all chart selects), date inputs, advanced number inputs, the <details>
disclosures, the view-toggle, and the Examples dropdown.
- EVERY data-testid / role / aria-label / asserted text label preserved. The
Search button's dirty signal moved from a bg-blue-600 class to a stable
data-dirty attribute. Progressive disclosure (one-box simple → filters /
query builder / charts on demand) is retained.
"New since your last visit" (export NewSinceLastVisit): a dismissible banner
comparing the current transcript count against a per-site, versioned
localStorage baseline; first-time visitors get a one-line power-tools nudge.
Fully client-side (a static export can't know the visitor).
Family hub URL (settings): new optional global `homepageUrl`
(normalizeHomepageUrl → absolute http(s), "" = none); editor Settings form
field + save-action wiring; consumed by the export header/footer backlink.
FOUC fix (the giant-social-icons flash): normalizeSocialSvg strips width/height
so a viewBox-only <svg> has no intrinsic size and balloons before CSS loads on a
static host. New sizeSocialSvg() re-injects an intrinsic pixel size at RENDER
time (stored site.json already has the attrs stripped, so this runs on read);
applied in both the export and homepage footers.
Deps: export gains lucide-react (icons now used directly in export app code).
Verification: common typecheck; production builds of all three apps (editor;
homepage + export static export). Full export e2e under CI conditions (retries):
74 passed, 0 failed. The query-tree checkbox specs were updated to read the kit
Checkbox via ARIA (role=checkbox button) instead of input.checked. The 3
metadata-mode charts.spec tests flake on a 20s recharts timeout under full-suite
dev load (heavier page delays stats hydration; SearchChartPanel/useStats are
unchanged and pass on base, in isolation, and on retry) — documented; CI retries
absorb them, same as the existing actionable.spec flakiness.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
23 files changed, 602 insertions(+), 297 deletions(-)
diff --git a/common/components/QueryBuilder.tsx b/common/components/QueryBuilder.tsx
@@ -26,6 +26,7 @@ import {
import type { GroupState, LeafState } from "../lib/searchEval";
import QueryGroupView from "./QueryGroupView";
import QueryLeafView from "./QueryLeafView";
+import { Button } from "./ui/button";
type Props = {
root: GroupNode;
@@ -80,8 +81,10 @@ export default function QueryBuilder({
}}
/>
<div className="flex items-center gap-2 text-xs">
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={() =>
onAddChild(
root.id,
@@ -89,12 +92,14 @@ export default function QueryBuilder({
)
}
data-testid="compact-add-layer"
- className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
+ Add layer
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={() =>
onAddChild(
root.id,
@@ -104,10 +109,10 @@ export default function QueryBuilder({
}),
)
}
- className="underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
+ Add group
- </button>
+ </Button>
</div>
</div>
);
diff --git a/common/components/QueryGroupView.tsx b/common/components/QueryGroupView.tsx
@@ -16,6 +16,11 @@ import {
} from "../lib/searchQuery";
import type { GroupState, LeafState } from "../lib/searchEval";
import QueryLeafView from "./QueryLeafView";
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
+
+const negateCheckboxClass =
+ "border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive data-[state=checked]:text-white";
type Props = {
group: GroupNode;
@@ -45,7 +50,7 @@ export default function QueryGroupView({
const state = groupStates.get(group.id);
const onlyLeafCount = group.children.filter(isLeaf).length;
- const indent = depth === 0 ? "" : "pl-4 border-l-2 border-zinc-200 dark:border-zinc-800";
+ const indent = depth === 0 ? "" : "pl-4 border-l-2 border-border";
return (
<div
@@ -60,20 +65,19 @@ export default function QueryGroupView({
onChangeNode(group.id, { ...group, op: e.target.value as "AND" | "OR" })
}
data-testid={`group-op-${group.id}`}
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs font-medium"
+ className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-xs font-medium"
>
<option value="AND">ALL of (AND)</option>
<option value="OR">ANY of (OR)</option>
</select>
{!isRoot && (
<label className="flex items-center gap-1 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={group.negate}
- onChange={(e) =>
- onChangeNode(group.id, { ...group, negate: e.target.checked })
+ onCheckedChange={(checked) =>
+ onChangeNode(group.id, { ...group, negate: checked === true })
}
- className="accent-red-500"
+ className={negateCheckboxClass}
data-testid={`group-negate-${group.id}`}
/>
NOT
@@ -81,7 +85,7 @@ export default function QueryGroupView({
)}
{state && (
<span
- className="text-zinc-500 dark:text-zinc-400"
+ className="text-muted-foreground"
data-testid={`group-count-${group.id}`}
>
→ {state.slugCount} video{state.slugCount === 1 ? "" : "s"}
@@ -89,10 +93,12 @@ export default function QueryGroupView({
)}
{!isRoot && (
<div className="ml-auto flex items-center gap-1">
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={() => onUnwrapGroup(group.id)}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
title={
group.negate
? "Flatten this group into its parent (each child gets NOT applied)"
@@ -101,16 +107,18 @@ export default function QueryGroupView({
data-testid={`group-unwrap-${group.id}`}
>
unwrap
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="outline"
+ size="icon-xs"
onClick={() => onRemoveNode(group.id)}
aria-label="Remove group"
data-testid={`group-delete-${group.id}`}
- className="text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ className="text-xs"
>
×
- </button>
+ </Button>
</div>
)}
</div>
@@ -154,8 +162,10 @@ export default function QueryGroupView({
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
- <button
+ <Button
type="button"
+ variant="outline"
+ size="sm"
onClick={() =>
onAddChild(
group.id,
@@ -163,12 +173,13 @@ export default function QueryGroupView({
)
}
data-testid={`group-add-leaf-${group.id}`}
- className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Add layer
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="outline"
+ size="sm"
onClick={() =>
onAddChild(
group.id,
@@ -179,10 +190,9 @@ export default function QueryGroupView({
)
}
data-testid={`group-add-group-${group.id}`}
- className="rounded border border-zinc-300 dark:border-zinc-700 px-2 py-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
+ Add group
- </button>
+ </Button>
</div>
</div>
);
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -11,6 +11,13 @@
import type { LeafNode, LayerScope } from "../lib/searchQuery";
import type { LeafState } from "../lib/searchEval";
import { LayerSwatch, swatchSoftBgStyle } from "./LayerSwatch";
+import { Button } from "./ui/button";
+import { Input } from "./ui/input";
+import { Checkbox } from "./ui/checkbox";
+import { cn } from "../lib/utils";
+
+const negateCheckboxClass =
+ "border-destructive/60 data-[state=checked]:bg-destructive data-[state=checked]:border-destructive data-[state=checked]:text-white";
type Props = {
leaf: LeafNode;
@@ -70,29 +77,28 @@ export default function QueryLeafView({
: "Search transcripts...";
const queryInput = (
- <input
+ <Input
type="search"
value={leaf.query}
onChange={(e) => update({ query: e.target.value })}
placeholder={placeholder}
data-testid={`leaf-query-${leaf.id}`}
- className={`flex-1 min-w-0 rounded-lg border ${
- regexInvalid
- ? "border-red-500"
- : "border-zinc-300 dark:border-zinc-700"
- } bg-white dark:bg-zinc-900 px-3 py-2 text-base outline-none focus:ring-2 focus:ring-blue-500 ${
- leaf.useRegex ? "font-mono" : ""
- }`}
+ aria-invalid={regexInvalid || undefined}
+ className={cn(
+ "flex-1 min-w-0",
+ regexInvalid && "border-destructive",
+ leaf.useRegex && "font-mono",
+ )}
/>
);
const scopeSelect = (
- <label className="flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-400">
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<select
value={leaf.scope}
onChange={(e) => update({ scope: e.target.value as LayerScope })}
data-testid={`leaf-scope-${leaf.id}`}
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-1.5 py-1 text-xs"
+ className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-xs font-mono"
>
{(["transcripts", "chat", "metadata", "description", "tags"] as const).map((s) => (
<option key={s} value={s}>
@@ -105,11 +111,9 @@ export default function QueryLeafView({
const regexToggle = (
<label className="flex items-center gap-1 text-xs select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={leaf.useRegex}
- onChange={(e) => update({ useRegex: e.target.checked })}
- className="accent-blue-600"
+ onCheckedChange={(checked) => update({ useRegex: checked === true })}
data-testid={`leaf-regex-${leaf.id}`}
/>
Regex
@@ -118,7 +122,7 @@ export default function QueryLeafView({
const countBadge = state ? (
<span
- className="ml-auto text-xs text-zinc-500 dark:text-zinc-400 shrink-0"
+ className="ml-auto text-xs text-muted-foreground shrink-0"
data-testid={`leaf-count-${leaf.id}`}
>
→ {state.slugCount} video{state.slugCount === 1 ? "" : "s"}
@@ -144,7 +148,7 @@ export default function QueryLeafView({
<LayerSwatch leafId={leaf.id} />
{queryInput}
</div>
- <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-600 dark:text-zinc-400">
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{scopeSelect}
{regexToggle}
{countBadge}
@@ -156,7 +160,7 @@ export default function QueryLeafView({
return (
<div
- className="flex flex-col gap-2 rounded-lg border bg-white/60 dark:bg-zinc-900/40 px-3 py-2"
+ className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-2"
style={swatchSoftBgStyle(leaf.id)}
data-leaf-id={leaf.id}
>
@@ -169,22 +173,23 @@ export default function QueryLeafView({
{scopeSelect}
{regexToggle}
<label className="flex items-center gap-1 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={leaf.contributeHits}
- onChange={(e) => update({ contributeHits: e.target.checked })}
- className="accent-blue-600"
+ onCheckedChange={(checked) =>
+ update({ contributeHits: checked === true })
+ }
data-testid={`leaf-hits-${leaf.id}`}
/>
Show hits in results
</label>
{canNegate && (
<label className="flex items-center gap-1 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={leaf.negate}
- onChange={(e) => update({ negate: e.target.checked })}
- className="accent-red-500"
+ onCheckedChange={(checked) =>
+ update({ negate: checked === true })
+ }
+ className={negateCheckboxClass}
data-testid={`leaf-negate-${leaf.id}`}
/>
NOT
@@ -193,30 +198,34 @@ export default function QueryLeafView({
{cachedBadge}
<div className="ml-auto flex items-center gap-1">
{onWrapInGroup && (
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onWrapInGroup}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
title="Wrap this layer in a sub-group so you can add OR siblings"
data-testid={`leaf-wrap-${leaf.id}`}
>
wrap
- </button>
+ </Button>
)}
- <button
+ <Button
type="button"
+ variant="outline"
+ size="icon-xs"
onClick={onDelete}
disabled={!canDelete}
aria-label="Remove layer"
data-testid={`leaf-delete-${leaf.id}`}
- className="text-xs rounded border border-zinc-300 dark:border-zinc-700 w-6 h-6 inline-flex items-center justify-center hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed"
+ className="text-xs"
>
×
- </button>
+ </Button>
</div>
</div>
{regexInvalid && (
- <p className="text-xs text-red-600 font-mono">Invalid regex</p>
+ <p className="text-xs text-destructive font-mono">Invalid regex</p>
)}
</div>
);
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -48,6 +48,8 @@ import {
saveAdvanced,
} from "./exportAdvancedStorage";
import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
+import { Button } from "./ui/button";
+import { Checkbox } from "./ui/checkbox";
import QueryBuilder from "./QueryBuilder";
import {
defaultChartShape,
@@ -1362,29 +1364,29 @@ export default function TranscriptSearch() {
groupStates={groupStates}
/>
) : (
- <div className="h-12 rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/40" />
+ <div className="h-12 rounded-lg border border-border bg-muted" />
)}
<div className="flex items-center gap-2 flex-wrap">
- <button
+ <Button
type="submit"
data-testid="search-submit"
- className={`shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- queryDirty || filtersDirty
- ? "bg-blue-600 text-white hover:bg-blue-700"
- : "border border-zinc-300 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800"
- }`}
+ data-dirty={queryDirty || filtersDirty ? "true" : "false"}
+ variant={queryDirty || filtersDirty ? "default" : "outline"}
+ className="shrink-0"
>
Search
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={handleResetLayers}
disabled={layersResetDisabled}
data-testid="reset-layers"
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline disabled:cursor-not-allowed"
+ className="text-xs text-muted-foreground"
>
Reset layers
- </button>
+ </Button>
{(queryDirty || filtersDirty) && (
<span className="text-xs text-amber-600 dark:text-amber-400">
Press Enter or click Search to apply
@@ -1392,7 +1394,7 @@ export default function TranscriptSearch() {
)}
{hasSubs && (
<span
- className="text-xs text-zinc-400 ml-auto"
+ className="text-xs text-muted-foreground ml-auto"
title={`${liveChatTotalCount} videos have live chat`}
>
Live chat available on {liveChatTotalCount} video
@@ -1413,18 +1415,18 @@ export default function TranscriptSearch() {
e.preventDefault();
toggleFiltersCollapsed();
}}
- className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400"
+ className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground"
>
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Filters
</span>
- <span className="text-xs text-zinc-500">
+ <span className="text-xs text-muted-foreground">
{selectedChannelNames.length} of {channelOptions.length} channel
{channelOptions.length === 1 ? "" : "s"}
</span>
{filtersCollapsed && selectedChannelNames.length > 0 && (
<span
- className="text-xs text-zinc-500 truncate min-w-0 flex-1"
+ className="text-xs text-muted-foreground truncate min-w-0 flex-1"
title={selectedChannelsLabel}
data-testid="filters-summary-channels"
>
@@ -1432,11 +1434,11 @@ export default function TranscriptSearch() {
</span>
)}
</summary>
- <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-zinc-600 dark:text-zinc-400">
+ <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
{channelOptions.length > 1 && (
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Channels
</span>
<ChannelAllToggle
@@ -1444,28 +1446,34 @@ export default function TranscriptSearch() {
draftExcludedChannels={draftExcludedChannels}
setDraftExcludedChannels={setDraftExcludedChannels}
/>
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={handleResetChannels}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
Reset channels
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={handleResetAllFilters}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
Reset all filters
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={handleResetEverything}
data-testid="reset-everything"
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
Reset everything
- </button>
+ </Button>
</div>
<div className="flex flex-col gap-1.5">
{channelGroupings.map(({ group, channels }) => {
@@ -1485,44 +1493,48 @@ export default function TranscriptSearch() {
(e.target as HTMLDetailsElement).open,
)
}
- className="rounded border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40"
+ className="rounded border border-border bg-card/60"
>
<summary className="cursor-pointer select-none flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1.5 text-sm">
{group.name && (
- <span className="font-medium text-zinc-700 dark:text-zinc-200">
+ <span className="font-medium text-foreground">
{group.name}
</span>
)}
- <span className="text-xs text-zinc-500">
+ <span className="text-xs text-muted-foreground">
({selectedCount}/{channels.length} selected)
</span>
<span className="ml-auto flex items-center gap-2">
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelected(channels, true);
}}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
All
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSelected(channels, false);
}}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
None
- </button>
+ </Button>
</span>
</summary>
{group.description && (
- <div className="px-3 pt-0 pb-1 text-xs text-zinc-500">
+ <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground">
{group.description}
</div>
)}
@@ -1534,18 +1546,16 @@ export default function TranscriptSearch() {
key={name}
className="flex items-center gap-1.5 select-none"
>
- <input
- type="checkbox"
+ <Checkbox
checked={checked}
- onChange={(e) => {
+ onCheckedChange={(value) => {
setDraftExcludedChannels((prev) => {
const next = new Set(prev);
- if (e.target.checked) next.delete(name);
+ if (value === true) next.delete(name);
else next.add(name);
return next;
});
}}
- className="accent-blue-600"
/>
{name}
</label>
@@ -1559,85 +1569,71 @@ export default function TranscriptSearch() {
</div>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Type
</span>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNov}
- onChange={(e) => setDraftNov(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNov(value !== true)}
/>
Videos
</label>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNol}
- onChange={(e) => setDraftNol(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNol(value !== true)}
/>
Livestreams
</label>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Audience
</span>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNaa}
- onChange={(e) => setDraftNaa(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNaa(value !== true)}
/>
All ages
</label>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNar}
- onChange={(e) => setDraftNar(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNar(value !== true)}
/>
Age-restricted
</label>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Availability
</span>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNav}
- onChange={(e) => setDraftNav(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNav(value !== true)}
/>
Available
</label>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNu}
- onChange={(e) => setDraftNu(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNu(value !== true)}
/>
Unlisted
</label>
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={!draftNd}
- onChange={(e) => setDraftNd(!e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => setDraftNd(value !== true)}
/>
Deleted
</label>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Date
</span>
<label className="flex items-center gap-1.5 select-none">
@@ -1649,7 +1645,7 @@ export default function TranscriptSearch() {
onChange={(e) =>
setDraftDateFrom(inputToYmd(e.target.value) ?? "")
}
- className="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
/>
</label>
<label className="flex items-center gap-1.5 select-none">
@@ -1661,7 +1657,7 @@ export default function TranscriptSearch() {
onChange={(e) =>
setDraftDateTo(inputToYmd(e.target.value) ?? "")
}
- className="rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm"
/>
</label>
</div>
@@ -1669,11 +1665,11 @@ export default function TranscriptSearch() {
</details>
)}
- <details className="-mt-3 text-sm text-zinc-600 dark:text-zinc-400">
- <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300">
+ <details className="-mt-3 text-sm text-muted-foreground">
+ <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
Advanced options
</summary>
- <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
+ <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60">
<LimitField
label="Max hits per batch"
hint="Pause after this many matches; “Show more” adds another batch."
@@ -1702,18 +1698,22 @@ export default function TranscriptSearch() {
onChange={setFlushIntervalMs}
/>
<div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
- <button
+ <Button
type="button"
+ variant="outline"
+ size="sm"
onClick={() => {
setHitBatchUnlimited(true);
setHitLimit(Infinity);
}}
- className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs"
+ className="text-xs"
>
Search through all transcripts
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="outline"
+ size="sm"
onClick={() => {
setHitBatchValue(DEFAULT_MAX_HITS);
setHitBatchUnlimited(false);
@@ -1721,16 +1721,16 @@ export default function TranscriptSearch() {
setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS);
clearAdvanced();
}}
- className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs"
+ className="text-xs"
>
Reset to defaults
- </button>
+ </Button>
</div>
</div>
</details>
<section data-testid="results-section">
- <h2 className="text-sm font-medium text-zinc-500 mb-2 flex items-center gap-2 flex-wrap">
+ <h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2 flex-wrap">
<span data-testid="results-summary">
{hasActiveQuery ? (
<>
@@ -1746,27 +1746,27 @@ export default function TranscriptSearch() {
)}
</span>
{(indexLoading || (!hasActiveQuery && summariesLoading)) && (
- <span className="text-xs text-zinc-400 font-normal">
+ <span className="text-xs text-muted-foreground font-normal">
loading index{indexProgress ? ` (${indexProgress})` : ""}…
</span>
)}
{searching && (
- <span className="text-xs text-zinc-400 font-normal">
+ <span className="text-xs text-muted-foreground font-normal">
searched {processed}/{totalToProcess}
{pipelineActive ? "…" : ""}
</span>
)}
<span
data-testid="view-toggle"
- className="ml-auto inline-flex overflow-hidden rounded-md border border-zinc-300 dark:border-zinc-700"
+ className="ml-auto inline-flex overflow-hidden rounded-md border border-border bg-muted"
>
<button
type="button"
onClick={() => setView("results")}
className={`px-2.5 py-1 text-xs font-normal ${
view === "results"
- ? "bg-blue-600 text-white"
- : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
Results
@@ -1776,8 +1776,8 @@ export default function TranscriptSearch() {
onClick={showChartView}
className={`px-2.5 py-1 text-xs font-normal ${
view === "chart"
- ? "bg-blue-600 text-white"
- : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
Chart
@@ -1790,9 +1790,9 @@ export default function TranscriptSearch() {
<details
data-testid="chart-options"
open
- className="rounded-lg border border-zinc-200 dark:border-zinc-800 px-3 py-2"
+ className="rounded-lg border border-border px-3 py-2"
>
- <summary className="cursor-pointer text-sm font-medium text-zinc-600 dark:text-zinc-300">
+ <summary className="cursor-pointer text-sm font-medium text-foreground">
Chart options
</summary>
<div className="pt-3">
@@ -1810,7 +1810,7 @@ export default function TranscriptSearch() {
) : (
<>
{!hasActiveQuery && !summariesLoading && resultGroups.length > 0 && (
- <p className="text-sm text-zinc-500 mb-2" data-testid="browse-hint">
+ <p className="text-sm text-muted-foreground mb-2" data-testid="browse-hint">
Showing all videos — type a query above to narrow.
</p>
)}
@@ -1818,11 +1818,11 @@ export default function TranscriptSearch() {
? !indexLoading &&
resultGroups.length === 0 &&
!pipelineActive && (
- <p className="text-sm text-zinc-500">No matching videos.</p>
+ <p className="text-sm text-muted-foreground">No matching videos.</p>
)
: !summariesLoading &&
resultGroups.length === 0 && (
- <p className="text-sm text-zinc-500">
+ <p className="text-sm text-muted-foreground">
No videos match the current filters.
</p>
)}
@@ -1838,13 +1838,13 @@ export default function TranscriptSearch() {
)}
{capped && (
<div className="mt-3 flex justify-center">
- <button
+ <Button
type="button"
+ variant="outline"
onClick={() => setHitLimit((l) => l + hitBatchSize)}
- className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm"
>
Load more results
- </button>
+ </Button>
</div>
)}
</>
@@ -2006,26 +2006,27 @@ function ResultCard({
<div
data-result-slug={group.slug}
data-card-header=""
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
+ className="border border-border rounded-lg overflow-hidden bg-card"
>
- <button
+ <Button
type="button"
+ variant="ghost"
onClick={() => openWithMode(group.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
+ className="w-full h-auto justify-start text-left items-baseline gap-2 px-4 py-2 rounded-none border-b border-border bg-muted font-normal"
>
<span className="font-medium truncate flex-1 min-w-0">
{group.title}
</span>
{group.isLivestream && <LivestreamBadge />}
{group.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
+ <span className="text-xs text-muted-foreground shrink-0">
{group.channel && `${group.channel} · `}
{group.date}
{group.hits.length > 0 &&
` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
</span>
- </button>
- <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
+ </Button>
+ <ul className="flex flex-col divide-y divide-border">
{Array.from(buckets.entries()).map(([leafId, hits]) => {
if (hits.length === 0) return null;
const leafInfo = leavesById.get(leafId);
@@ -2033,40 +2034,41 @@ function ResultCard({
return (
<li key={leafId}>
<div
- className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800"
+ className="flex items-center gap-2 px-4 py-1.5 bg-muted/40 border-b border-border"
data-leaf-section={leafId}
>
<LayerSwatch leafId={leafId} size="xs" />
- <span className="text-[10px] uppercase tracking-wide text-zinc-500">
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{leafInfo.scope === "metadata"
? "Title / channel"
: leafInfo.scope === "chat"
? "Live chat"
: "Transcripts"}
</span>
- <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
+ <span className="font-mono text-xs text-muted-foreground truncate">
{leafInfo.query}
</span>
- <span className="ml-auto text-[10px] text-zinc-500">
+ <span className="ml-auto text-[10px] text-muted-foreground">
{hits.length} hit{hits.length === 1 ? "" : "s"}
</span>
</div>
- <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
+ <ul className="flex flex-col divide-y divide-border/60">
{hits.map((h, i) => {
const isActive =
activeVideo === group.slug && activeTime === h.start;
return (
<li key={i}>
- <button
+ <Button
type="button"
+ variant="ghost"
onClick={() => openWithMode(group.slug, h)}
- className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
+ className={`w-full h-auto justify-start text-left flex gap-3 px-4 py-2 rounded-none font-normal whitespace-normal ${
isActive
? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
: ""
}`}
>
- <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
+ <span className="text-xs font-mono text-muted-foreground shrink-0 w-16">
{h.scope === "metadata"
? "—"
: formatSeconds(h.start)}
@@ -2077,7 +2079,7 @@ function ResultCard({
<span className="flex-1 min-w-0 text-sm">
{highlight(h.text, leafInfo.query, leafInfo.useRegex)}
</span>
- </button>
+ </Button>
</li>
);
})}
@@ -2093,7 +2095,7 @@ function ResultCard({
function TrackBadge({ track }: { track: string }) {
const label = track === "live_chat" ? "live chat" : track;
return (
- <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 self-center">
+ <span className="shrink-0 text-[10px] uppercase tracking-wide font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground self-center">
{label}
</span>
);
@@ -2130,14 +2132,14 @@ function ProfilesRow({
className="flex flex-wrap items-center gap-x-3 gap-y-1"
data-testid="profiles-row"
>
- <span className="text-xs uppercase tracking-wide text-zinc-500">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
Profile
</span>
<select
value={activeProfileName ?? ""}
onChange={(e) => onSelect(e.target.value)}
data-testid="profile-select"
- className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-0.5 text-sm"
+ className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm"
>
<option value="">(unsaved)</option>
{profileNames.map((n) => (
@@ -2156,60 +2158,72 @@ function ProfilesRow({
•
</span>
)}
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onSave}
disabled={!activeProfileName || !profileDirty}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline"
+ className="text-xs text-muted-foreground"
>
Save
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onSaveAs}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
Save as…
- </button>
+ </Button>
{showRevert && (
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onRevert}
data-testid="profile-revert"
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
Revert
- </button>
+ </Button>
)}
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onRename}
disabled={!activeProfileName}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-40 disabled:no-underline"
+ className="text-xs text-muted-foreground"
>
Rename
- </button>
- <button
+ </Button>
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onDelete}
disabled={!activeProfileName}
- className="text-xs underline text-red-600 hover:text-red-700 disabled:opacity-40 disabled:no-underline"
+ className="text-xs text-destructive"
>
Delete
- </button>
+ </Button>
<div className="ml-auto flex items-center gap-x-3">
- <button
+ <Button
type="button"
+ variant="link"
+ size="sm"
onClick={onShareCurrentSearch}
title={
shareCopied
? "Copied!"
: "Copy link encoding the current search + filters"
}
- className="text-xs underline text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
+ className="text-xs text-muted-foreground"
>
{shareCopied ? "Link copied!" : "Share current search"}
- </button>
+ </Button>
</div>
</div>
);
@@ -2227,15 +2241,13 @@ function ChannelAllToggle({
const allChecked = draftExcludedChannels.size === 0;
return (
<label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={allChecked}
- onChange={() => {
+ onCheckedChange={() => {
setDraftExcludedChannels(
allChecked ? new Set(channelOptions) : new Set(),
);
}}
- className="accent-blue-600"
/>
All
</label>
@@ -2269,7 +2281,7 @@ function NumberField({
}) {
return (
<label className="flex flex-col gap-1">
- <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ <span className="text-xs font-medium text-foreground">
{label}
</span>
<input
@@ -2288,9 +2300,9 @@ function NumberField({
const n = Math.max(min, Math.min(max, Math.floor(Number(raw))));
if (Number.isFinite(n)) onChange(n);
}}
- className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
/>
- {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</label>
);
}
@@ -2315,15 +2327,13 @@ function LimitField({
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
- <span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
+ <span className="text-xs font-medium text-foreground">
{label}
</span>
<label className="flex items-center gap-1 text-xs select-none">
- <input
- type="checkbox"
+ <Checkbox
checked={unlimited}
- onChange={(e) => onUnlimitedChange(e.target.checked)}
- className="accent-blue-600"
+ onCheckedChange={(value) => onUnlimitedChange(value === true)}
/>
No limit
</label>
@@ -2345,9 +2355,9 @@ function LimitField({
const n = Math.max(1, Math.floor(Number(raw)));
if (Number.isFinite(n)) onValueChange(n);
}}
- className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm disabled:opacity-50"
+ className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50"
/>
- {hint && <span className="text-xs text-zinc-500">{hint}</span>}
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
</div>
);
}
diff --git a/common/components/badges.tsx b/common/components/badges.tsx
@@ -5,7 +5,7 @@ export function LivestreamBadge() {
return (
<span
title="Livestream"
- className={`${badgeBase} bg-zinc-200 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-200`}
+ className={`${badgeBase} bg-secondary text-secondary-foreground`}
>
Live
</span>
diff --git a/common/components/charts/ChartShapeControls.tsx b/common/components/charts/ChartShapeControls.tsx
@@ -14,10 +14,11 @@ import {
type YMetric,
} from "../../lib/chartConfig";
import type { ChartShape } from "../../lib/chartShare";
+import { Checkbox } from "../ui/checkbox";
const sel =
- "rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm";
-const lbl = "flex flex-col gap-1 text-xs font-medium text-zinc-500";
+ "rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm";
+const lbl = "flex flex-col gap-1 text-xs font-medium text-muted-foreground";
const METRIC_FIELDS: { value: MetricField; label: string }[] = [
{ value: "duration", label: "Duration" },
@@ -64,7 +65,7 @@ export function ChartShapeControls({
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-col gap-1">
<span className={lbl}>Data source</span>
- <div className="inline-flex w-fit overflow-hidden rounded-md border border-zinc-300 dark:border-zinc-700">
+ <div className="inline-flex w-fit overflow-hidden rounded-md border border-border bg-muted">
{(["search", "metadata"] as const).map((m) => (
<button
key={m}
@@ -72,8 +73,8 @@ export function ChartShapeControls({
onClick={() => patch({ dataSource: m })}
className={`px-3 py-1 text-sm ${
shape.dataSource === m
- ? "bg-blue-600 text-white"
- : "bg-white dark:bg-zinc-900 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
{m === "metadata" ? "Metadata" : "Search matches"}
@@ -87,18 +88,18 @@ export function ChartShapeControls({
type="button"
data-testid="chart-examples-button"
onClick={() => setShowExamples((v) => !v)}
- className="rounded-md border border-zinc-300 dark:border-zinc-700 px-3 py-1.5 text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ className="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
>
Examples ▾
</button>
{showExamples && (
<div
data-testid="chart-examples-menu"
- className="absolute right-0 z-10 mt-1 w-64 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-1 shadow-lg"
+ className="absolute right-0 z-10 mt-1 w-64 rounded-md border border-border bg-popover text-popover-foreground p-1 shadow-md"
>
{PRESET_GROUPS.map((group) => (
<div key={group.id} className="py-1">
- <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
+ <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.title}
</div>
{group.templates.map((t) => (
@@ -109,7 +110,7 @@ export function ChartShapeControls({
onChange(shapeFromConfig(t.config, shape));
setShowExamples(false);
}}
- className="block w-full rounded px-2 py-1 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ className="block w-full rounded px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground"
>
{t.title}
</button>
@@ -154,16 +155,17 @@ export function ChartShapeControls({
</label>
<label className="flex items-center gap-2 self-end text-sm">
- <input
- type="checkbox"
+ <Checkbox
checked={shape.cumulative}
- onChange={(e) => patch({ cumulative: e.target.checked })}
+ onCheckedChange={(checked) =>
+ patch({ cumulative: checked === true })
+ }
/>
Cumulative
</label>
</div>
- <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3">
+ <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
<legend className="px-1 text-xs font-semibold">X axis</legend>
<label className={lbl}>
Dimension
@@ -275,7 +277,7 @@ export function ChartShapeControls({
</fieldset>
{shape.dataSource === "metadata" && (
- <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-zinc-200 dark:border-zinc-800 p-3">
+ <fieldset className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
<legend className="px-1 text-xs font-semibold">Y metric</legend>
<label className={lbl}>
Aggregate
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -114,6 +114,10 @@ export type SiteSettings = {
// common/lib/site.ts. The one presentation field that lives globally so a
// shared footer doesn't have to be repeated per site.
socialLinks: SocialLink[];
+ // Absolute public URL of the family hub/homepage (e.g. "https://archilyzer.com").
+ // Every export site links back to it ("the family" backlink) when set. Empty =
+ // no hub link rendered. Normalized to a trailing-slash-free http(s) URL.
+ homepageUrl: string;
// Backup configuration for the saved-video store (Phase 4 of the
// video-persistence feature). When enabled with a destination, the store is
// mirrored there (additively, no deletes) with a per-backup manifest, and the
@@ -440,6 +444,7 @@ function defaults(): SiteSettings {
syncScheduler: defaultSyncScheduler(),
autoQueue: defaultAutoQueue(),
socialLinks: [],
+ homepageUrl: "",
savedVideoBackup: defaultSavedVideoBackup(),
buildPipeline: defaultBuildPipeline(),
};
@@ -447,6 +452,16 @@ function defaults(): SiteSettings {
const SOCIAL_URL_RE = /^(https?:\/\/|mailto:|\/)/i;
+// Normalize the family hub URL into a trailing-slash-free absolute http(s) URL.
+// Returns "" for anything that isn't a usable absolute URL (the "no hub" state).
+// Kept local to settings.ts to avoid a settings<->homepage import cycle; mirrors
+// parseHomepageUrl() in homepage.ts.
+export function normalizeHomepageUrl(input: unknown): string {
+ if (typeof input !== "string") return "";
+ const trimmed = input.trim().replace(/\/+$/, "");
+ return /^https?:\/\/\S+/i.test(trimmed) ? trimmed : "";
+}
+
export function parseSocialLinks(input: unknown): SocialLink[] {
if (!Array.isArray(input)) return [];
const out: SocialLink[] = [];
@@ -497,6 +512,20 @@ export function normalizeSocialSvg(raw: string): string | null {
return opening + rest;
}
+// normalizeSocialSvg() deliberately STRIPS width/height so the icon scales to its
+// wrapper. The cost is that a viewBox-only <svg> has no intrinsic size, so before
+// the stylesheet loads on a static host it paints at the replaced-element default
+// (huge) — the "flash of giant social icons" FOUC. sizeSocialSvg() re-injects an
+// intrinsic pixel size at RENDER time (existing site.json files already have the
+// attributes stripped, so this must run on read, not just on write). The size is
+// an *attribute*, not inline style, so a wrapper's `w-*`/`h-*` utilities still win
+// once CSS loads — it only governs the pre-CSS first paint.
+export function sizeSocialSvg(svg: string, px = 20): string {
+ if (typeof svg !== "string") return svg;
+ if (/^<svg[^>]*\swidth\s*=/i.test(svg)) return svg; // already sized
+ return svg.replace(/^<svg\b/i, `<svg width="${px}" height="${px}"`);
+}
+
export function clampSleepBetweenDownloadsSeconds(value: unknown): number {
const n =
typeof value === "number" && Number.isFinite(value)
@@ -604,6 +633,7 @@ export function getSettings(): SiteSettings {
merged.syncScheduler = sanitizeSyncScheduler(merged.syncScheduler);
merged.autoQueue = sanitizeAutoQueue(merged.autoQueue);
merged.socialLinks = parseSocialLinks(merged.socialLinks);
+ merged.homepageUrl = normalizeHomepageUrl(merged.homepageUrl);
merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup);
merged.buildPipeline = sanitizeBuildPipeline(merged.buildPipeline);
// Workers. When the file predates the worker model (no `workers` key),
@@ -776,6 +806,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
syncScheduler: sanitizeSyncScheduler(next.syncScheduler),
autoQueue: sanitizeAutoQueue(next.autoQueue),
socialLinks,
+ homepageUrl: normalizeHomepageUrl(next.homepageUrl),
savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup),
buildPipeline: sanitizeBuildPipeline(next.buildPipeline),
};
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **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.
- **A complete-but-corrupt audio download is no longer re-downloaded forever — it's kept and flagged instead.** When an audio-checked download finished (yt-dlp exit 0, all bytes) but its final integrity probe came back `malformed`, the orchestrator rolled the whole file back and re-downloaded it — repeatedly. For a fast download that completes inside one checkpoint interval no `.good` baseline ever exists, so each "rollback" discarded the entire file and re-fetched it from scratch (a 2.46 GB Odysee video looped until the rollback cap, leaving no audio behind), and a cancel landing mid-loop was deferred behind the next full re-download. Now the final-probe path is bounded: a malformed final probe triggers **exactly one** re-download; if it's still malformed, the orchestrator **keeps the downloaded container on disk** (for inspection) and records a new terminal **`corrupt-full-source`** status — re-downloading a complete file can't change a deterministic verdict. This is distinct from `failed-corrupt-source` (a download that never finished, driven by the checkpoint rollback cap). Surfaced as a new **`corruptFullSource`** channel-snapshot bucket → a Download-stage summary line ("corrupt full source (file kept)"), an **amber download-dot** + `corrupt_full_source` row status in the per-channel video list (folded into the *No audio* filter), and a **"Corrupt full source — download completed but audio is malformed (file kept)"** badge on the video page. It's terminal and **not** retried (auto-runner treats it as skipped; the kept file is an artifact, so it isn't re-queued) and **not** counted as a failed transcription or a transcode-pending video. Cancellation is also fixed: a pending abort now wins over an in-flight rollback decision, and the loop re-checks the abort signal after the final probe so Cancel stops it promptly. See `common/ytdlp/audioCheckedDownload.ts`, `common/ytdlp/downloadOneManaged.ts`, `common/lib/downloadOutcome.ts`, `common/controller/{autoRunner.ts,channelSnapshot.ts}`, `common/ytdlp/runYtdlp.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/components/VideoPanel.tsx}`, and `editor/e2e/audio-check-scenarios.spec.ts`.
- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts
@@ -34,6 +34,7 @@ export async function saveSettingsAction(
formData: FormData,
): Promise<SaveResult> {
const adminTitle = String(formData.get("adminTitle") ?? "").trim();
+ const homepageUrl = String(formData.get("homepageUrl") ?? "").trim();
const maxBytesRaw = String(formData.get("maxTranscriptPageBytes") ?? "").trim();
const cookiesFromBrowser = String(
formData.get("cookiesFromBrowser") ?? "",
@@ -213,6 +214,7 @@ export async function saveSettingsAction(
// re-sanitizes it regardless.
autoQueue: getSettings().autoQueue,
socialLinks,
+ homepageUrl,
// Preserve the saved-video backup config on an unrelated settings save (the
// Saved Videos page edits it). writeSettings re-sanitizes it regardless.
savedVideoBackup: getSettings().savedVideoBackup,
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -38,6 +38,13 @@ export function SettingsForm({ initial, apps }: Props) {
hint="Title for this editor's own shell. Public sites get their titles from each site's config."
/>
<Field
+ label="Family hub URL"
+ name="homepageUrl"
+ defaultValue={initial.homepageUrl}
+ type="url"
+ hint="Absolute URL of the family hub/homepage (e.g. https://archilyzer.com). Every export site links back to it. Leave blank for no hub link."
+ />
+ <Field
label="Max transcript page bytes"
name="maxTranscriptPageBytes"
defaultValue={String(initial.maxTranscriptPageBytes)}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
+- **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).
+- **Fixed: flash of giant social icons on first load.** Footer social icons now carry an intrinsic size, so they render correctly before the stylesheet loads on a static host instead of briefly ballooning.
- **Transcript coverage is now a chart metric.** Each video's stats record carries a `coverage` fraction (transcript's last cue end ÷ video duration), selectable as a Y-axis metric ("Transcript coverage") in the chart options — e.g. average coverage by channel surfaces channels with truncated/partial transcripts. Coverage is `null` for videos with no transcript or no usable duration. (Requires the one-time `build:index` + `build:stats` rebuild on the bumped stats schema; the field is back-filled for every transcribed video from its `transcript.cues.json`.)
## [0.4.1] - 2026-06-22
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -1,4 +1,7 @@
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ getSettings,
+ sizeSocialSvg,
+} from "yt-dlp-transcript-common/lib/settings";
import {
listSites,
resolveRelatedSites,
@@ -14,13 +17,15 @@ export default function Footer() {
// related-sites block is omitted entirely.
const related = resolveRelatedSites(site, listSites());
return (
- <footer className="mt-auto border-t border-zinc-200 dark:border-zinc-800 bg-white/40 dark:bg-zinc-900/40">
- <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between gap-4 text-sm text-zinc-500">
+ <footer className="mt-auto border-t border-border bg-muted/30">
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-3">
- <span>Downloads:</span>
+ <span className="font-mono text-xs uppercase tracking-[0.14em]">
+ Downloads
+ </span>
<a
href="/code.tar.gz"
- className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ className="underline underline-offset-2 hover:text-foreground transition-colors"
>
code.tar.gz
</a>
@@ -35,8 +40,8 @@ export default function Footer() {
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
- className="inline-block w-5 h-5 text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: link.svg }}
+ className="inline-block w-5 h-5 text-muted-foreground hover:text-brand transition-colors [&_svg]:w-full [&_svg]:h-full"
+ dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }}
/>
</li>
))}
@@ -46,7 +51,7 @@ export default function Footer() {
{related.length > 0 && (
<nav
aria-label="Related sites"
- className="max-w-6xl mx-auto px-4 pb-4 -mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-xs text-zinc-400"
+ className="max-w-6xl mx-auto px-4 sm:px-6 pb-4 -mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-xs text-muted-foreground/80"
>
{related.map((group, gi) => (
<div
@@ -54,7 +59,7 @@ export default function Footer() {
className="flex flex-wrap items-baseline gap-x-2 gap-y-1"
>
{group.label && (
- <span className="uppercase tracking-wide text-zinc-400/80">
+ <span className="font-mono uppercase tracking-[0.14em] text-muted-foreground/70">
{group.label}
</span>
)}
@@ -65,7 +70,7 @@ export default function Footer() {
<a
href={s.url}
rel="noopener noreferrer"
- className="hover:text-zinc-900 dark:hover:text-zinc-100"
+ className="hover:text-brand transition-colors"
>
{s.title}
</a>
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -0,0 +1,64 @@
+import Link from "next/link";
+import { ArrowUpLeft } from "lucide-react";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { listSites, resolveRelatedSites } from "yt-dlp-transcript-common/lib/site";
+import { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
+import { currentSite } from "../lib/site";
+import SiblingSwitcher from "./SiblingSwitcher";
+
+// The export site's masthead, in the family "Archive / Reading Room" identity:
+// a brass mark + serif wordmark, calm sans nav, and the cross-site "family"
+// chrome (hub backlink + sibling switcher). All cross-site data is resolved at
+// build time from the pool, so single-site installs simply render neither.
+export default function Header() {
+ const site = currentSite();
+ const settings = getSettings();
+ const hubUrl = settings.homepageUrl;
+ const related = resolveRelatedSites(site, listSites());
+
+ return (
+ <header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur-md">
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 flex h-14 items-center gap-x-5 gap-y-1 flex-wrap">
+ <Link href="/" className="group flex items-center gap-2.5 shrink-0">
+ <span className="h-2.5 w-2.5 shrink-0 rotate-45 rounded-[3px] bg-brand shadow-[0_0_14px_var(--brand)] transition-colors group-hover:bg-brand-strong" />
+ <span className="font-display text-[1.35rem] font-semibold leading-none tracking-tight text-foreground">
+ {site.headerTitle}
+ </span>
+ </Link>
+
+ <nav className="flex items-center gap-4 text-sm font-medium">
+ <Link href="/" className="text-foreground hover:text-brand transition-colors">
+ Search
+ </Link>
+ <Link
+ href="/duplicates"
+ className="text-foreground hover:text-brand transition-colors"
+ >
+ Duplicates
+ </Link>
+ </nav>
+
+ <div className="ml-auto flex items-center gap-2 sm:gap-3 shrink-0">
+ <SiblingSwitcher groups={related} />
+ {hubUrl && (
+ <a
+ href={hubUrl}
+ rel="noopener noreferrer"
+ className="hidden sm:inline-flex items-center gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground hover:text-brand transition-colors"
+ >
+ <ArrowUpLeft className="size-3.5" aria-hidden="true" />
+ Hub
+ </a>
+ )}
+ <Link
+ href="/changelog"
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Changelog
+ </Link>
+ <ThemeToggle />
+ </div>
+ </div>
+ </header>
+ );
+}
diff --git a/export/app/components/NewSinceLastVisit.tsx b/export/app/components/NewSinceLastVisit.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { X } from "lucide-react";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+
+// "New since your last visit" — a calm, dismissible banner that compares the
+// current transcript count against a per-site baseline stored in localStorage on
+// the previous visit. Two messages, in priority order:
+// 1. transcripts grew → "N new transcripts since your last visit"
+// 2. first visit ever (and the power-tools hint hasn't been seen) → a nudge
+// toward the power features people tend to miss.
+// All client-side: a static export can't know who visited or when, so the
+// baseline lives in the browser. Versioned key so the shape can evolve safely.
+
+const VERSION = 1;
+const visitKey = (siteId: string) => `ytdlp-tb:lastVisit:${siteId}`;
+const POWER_HINT_KEY = "ytdlp-tb:powerHintSeen";
+
+type Stored = { v: number; count: number; ts: number };
+
+function readStored(siteId: string): Stored | null {
+ try {
+ const raw = localStorage.getItem(visitKey(siteId));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Stored;
+ if (parsed && parsed.v === VERSION && typeof parsed.count === "number") {
+ return parsed;
+ }
+ } catch {
+ /* ignore */
+ }
+ return null;
+}
+
+export default function NewSinceLastVisit({
+ siteId,
+ count,
+}: {
+ siteId: string;
+ count: number;
+}) {
+ const [message, setMessage] = useState<string | null>(null);
+ const [hint, setHint] = useState<string | null>(null);
+
+ useEffect(() => {
+ const prev = readStored(siteId);
+ const delta = prev ? count - prev.count : 0;
+
+ if (delta > 0) {
+ setMessage(
+ `${delta.toLocaleString()} new transcript${delta === 1 ? "" : "s"} since your last visit`,
+ );
+ setHint("Use Filters and the query builder to narrow them down.");
+ } else if (!prev) {
+ let seen = false;
+ try {
+ seen = localStorage.getItem(POWER_HINT_KEY) === "1";
+ } catch {
+ /* ignore */
+ }
+ if (!seen) {
+ setMessage("Welcome to the archive");
+ setHint(
+ "Beyond simple search: layer queries, scope by transcript or live chat, filter by channel or date, and chart what you find.",
+ );
+ }
+ }
+
+ // Mark this visit as the new baseline regardless of what we showed.
+ try {
+ const next: Stored = { v: VERSION, count, ts: Date.now() };
+ localStorage.setItem(visitKey(siteId), JSON.stringify(next));
+ } catch {
+ /* ignore */
+ }
+ }, [siteId, count]);
+
+ if (!message) return null;
+
+ return (
+ <div
+ data-testid="new-since-last-visit"
+ className="flex items-start gap-3 rounded-lg border border-border bg-brand-soft/60 px-4 py-3"
+ >
+ <span className="mt-0.5 h-2 w-2 shrink-0 rotate-45 rounded-[2px] bg-brand" aria-hidden="true" />
+ <div className="min-w-0 flex-1">
+ <p className="font-display text-sm font-semibold text-foreground">{message}</p>
+ {hint && <p className="mt-0.5 text-sm text-muted-foreground">{hint}</p>}
+ </div>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="-mr-2 -mt-1 size-7 shrink-0 text-muted-foreground"
+ aria-label="Dismiss"
+ onClick={() => {
+ try {
+ localStorage.setItem(POWER_HINT_KEY, "1");
+ } catch {
+ /* ignore */
+ }
+ setMessage(null);
+ }}
+ >
+ <X className="size-4" aria-hidden="true" />
+ </Button>
+ </div>
+ );
+}
diff --git a/export/app/components/SiblingSwitcher.tsx b/export/app/components/SiblingSwitcher.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { ChevronDown } from "lucide-react";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "yt-dlp-transcript-common/components/ui/dropdown-menu";
+
+// A sibling-site switcher for the export header: the "family" navigation across
+// the other public sites in the pool. Fed by the same resolveRelatedSites() data
+// the footer uses (groups of { siteId, title, url }), resolved server-side and
+// passed in as plain props so this stays a thin client shell over the kit.
+export type SwitcherSite = { siteId: string; title: string; url: string };
+export type SwitcherGroup = { label?: string; sites: SwitcherSite[] };
+
+export default function SiblingSwitcher({
+ groups,
+}: {
+ groups: SwitcherGroup[];
+}) {
+ const total = groups.reduce((n, g) => n + g.sites.length, 0);
+ if (total === 0) return null;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="gap-1 font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground"
+ >
+ Sites
+ <ChevronDown className="size-3.5" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="min-w-48">
+ {groups.map((group, gi) => (
+ <div key={group.label ?? `group-${gi}`}>
+ {gi > 0 && <DropdownMenuSeparator />}
+ {group.label && (
+ <DropdownMenuLabel className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
+ {group.label}
+ </DropdownMenuLabel>
+ )}
+ {group.sites.map((s) => (
+ <DropdownMenuItem key={s.siteId} asChild>
+ <a href={s.url} rel="noopener noreferrer">
+ {s.title}
+ </a>
+ </DropdownMenuItem>
+ ))}
+ </div>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+}
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -3,8 +3,8 @@
@source "../../common/components";
/* Design tokens, the `dark` variant, and theme palettes live in
- common/styles/tokens.css. Export stays on the neutral "base" family in
- Phase 0 (visual parity); the Archive restyle lands in a later phase. */
+ common/styles/tokens.css. Export runs the public "archive" family (paper/ink);
+ the layout sets data-theme="archive" via ThemeScript. */
body {
background: var(--background);
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
-import Link from "next/link";
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 { ThemeToggle } from "yt-dlp-transcript-common/components/ThemeToggle";
import { QueryProvider } from "yt-dlp-transcript-common/components/QueryProvider";
import { currentSite } from "./lib/site";
+import Header from "./components/Header";
import Footer from "./components/Footer";
import "./globals.css";
@@ -25,56 +24,22 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const settings = currentSite();
return (
<html
lang="en"
suppressHydrationWarning
className={`${fontVars} h-full antialiased`}
>
- <body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 font-sans">
- <ThemeScript />
- <ThemeProvider>
+ <body className="min-h-full flex flex-col bg-background text-foreground font-sans">
+ <ThemeScript defaultTheme="archive" defaultMode="system" />
+ <ThemeProvider defaultTheme="archive" defaultMode="system">
<QueryProvider>
- <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 backdrop-blur sticky top-0 z-20">
- <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-3">
- <div className="flex items-center gap-5 min-w-0">
- <Link href="/" className="font-semibold tracking-tight shrink-0">
- {settings.headerTitle}
- </Link>
- <nav className="flex items-center gap-4 text-sm font-medium">
- <Link
- href="/"
- className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
- >
- Search
- </Link>
- <Link
- href="/duplicates"
- className="text-zinc-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400"
- >
- Duplicates
- </Link>
- </nav>
- </div>
- <div className="flex items-center gap-3 shrink-0">
- <nav className="text-sm">
- <Link
- href="/changelog"
- className="text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
- >
- Changelog
- </Link>
- </nav>
- <ThemeToggle />
- </div>
- </div>
- </header>
- <main className="flex-1 w-full max-w-6xl mx-auto px-4 py-6">
- {children}
- </main>
- <Footer />
- </QueryProvider>
+ <Header />
+ <main className="flex-1 w-full max-w-6xl mx-auto px-4 sm:px-6 py-6">
+ {children}
+ </main>
+ <Footer />
+ </QueryProvider>
</ThemeProvider>
</body>
</html>
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -3,6 +3,7 @@ import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvid
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
import { currentSite } from "./lib/site";
+import NewSinceLastVisit from "./components/NewSinceLastVisit";
export default async function Home() {
const settings = currentSite();
@@ -11,12 +12,16 @@ export default async function Home() {
return (
<PlayerProvider>
<div className="flex flex-col gap-6">
- <div>
- <h1 className="text-2xl font-semibold">{count} transcripts</h1>
+ <div className="flex flex-col gap-1">
+ <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
+ <span className="tabular-nums">{count.toLocaleString()}</span>{" "}
+ <span className="font-normal text-muted-foreground">transcripts</span>
+ </h1>
{settings.homeTagline && (
- <p className="text-sm text-zinc-500 mt-1">{settings.homeTagline}</p>
+ <p className="text-sm text-muted-foreground">{settings.homeTagline}</p>
)}
</div>
+ <NewSinceLastVisit siteId={settings.siteId} count={count} />
<TranscriptSearch />
</div>
<TranscriptModal />
diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts
@@ -305,8 +305,10 @@ test.describe("composite search — query tree", () => {
),
).toHaveCount(1);
- // Flip the alpha leaf's "Show hits" checkbox on and re-commit.
- const showHits = page.locator('input[data-testid^="leaf-hits-"]').first();
+ // Flip the alpha leaf's "Show hits" checkbox on and re-commit. (The kit
+ // Checkbox renders a role="checkbox" button, not an <input> — match by
+ // testid prefix; Playwright's check() drives the ARIA checkbox.)
+ const showHits = page.locator('[data-testid^="leaf-hits-"]').first();
await showHits.check();
await page.getByTestId("search-submit").click();
@@ -429,11 +431,12 @@ test.describe("composite search — query tree", () => {
// The metadata leaf should now be a direct child of root with its
// `negate` checkbox checked (De Morgan applied locally).
await expect(page.getByTestId(/^group-unwrap-/)).toHaveCount(0);
- const negateBoxes = page.locator('input[data-testid^="leaf-negate-"]');
+ const negateBoxes = page.locator('[data-testid^="leaf-negate-"]');
// The transcripts leaf and the metadata leaf are now both root-level
- // children. The metadata leaf is the negated one.
+ // children. The metadata leaf is the negated one. (Kit Checkbox = a
+ // role="checkbox" button, so read aria-checked rather than input.checked.)
const checkedStates = await negateBoxes.evaluateAll((els) =>
- (els as HTMLInputElement[]).map((e) => e.checked),
+ els.map((e) => e.getAttribute("aria-checked") === "true"),
);
expect(checkedStates.filter(Boolean).length).toBe(1);
});
diff --git a/export/e2e/theme.spec.ts b/export/e2e/theme.spec.ts
@@ -1,7 +1,7 @@
import { test, expect, type Page } from "@playwright/test";
-// Phase 0 foundation: the shared theme system on the export site. Export stays
-// on the neutral "base" family (no data-theme attribute) in Phase 0; the mode
+// Phase 2: the export site adopts the public "archive" family (paper/ink) with a
+// system-default mode. The data-theme attribute is always "archive"; the mode
// toggle must apply, persist across reloads, and be set before hydration.
async function state(page: Page) {
@@ -20,13 +20,13 @@ async function cycleTo(page: Page, target: "dark" | "light") {
}
}
-test("base family; mode toggle applies + persists with no FOUC", async ({
+test("archive family; mode toggle applies + persists with no FOUC", async ({
page,
}) => {
await page.goto("/");
- // Base family => no data-theme attribute on <html>.
- expect((await state(page)).theme).toBeNull();
+ // Public archive family => data-theme="archive" on <html>.
+ expect((await state(page)).theme).toBe("archive");
await expect(page.getByRole("button", { name: /switch to/i })).toBeVisible();
// Explicit dark, then reload-persists before hydration.
@@ -34,15 +34,17 @@ test("base family; mode toggle applies + persists with no FOUC", async ({
let s = await state(page);
expect(s.mode).toBe("dark");
expect(s.dark).toBe(true);
- expect(s.theme).toBeNull();
+ expect(s.theme).toBe("archive");
await page.reload({ waitUntil: "commit" });
let onCommit = await page.evaluate(() => ({
dark: document.documentElement.classList.contains("dark"),
+ theme: document.documentElement.getAttribute("data-theme"),
mode: localStorage.getItem("ytdlp-tb:mode"),
}));
expect(onCommit.mode).toBe("dark");
expect(onCommit.dark).toBe(true);
+ expect(onCommit.theme).toBe("archive");
// Explicit light, then reload-persists.
await cycleTo(page, "light");
@@ -53,8 +55,10 @@ test("base family; mode toggle applies + persists with no FOUC", async ({
await page.reload({ waitUntil: "commit" });
onCommit = await page.evaluate(() => ({
dark: document.documentElement.classList.contains("dark"),
+ theme: document.documentElement.getAttribute("data-theme"),
mode: localStorage.getItem("ytdlp-tb:mode"),
}));
expect(onCommit.mode).toBe("light");
expect(onCommit.dark).toBe(false);
+ expect(onCommit.theme).toBe("archive");
});
diff --git a/export/package.json b/export/package.json
@@ -21,6 +21,7 @@
},
"dependencies": {
"@tanstack/react-query": "^5.99.1",
+ "lucide-react": "^1.16.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
diff --git a/homepage/app/components/Footer.tsx b/homepage/app/components/Footer.tsx
@@ -1,4 +1,7 @@
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import {
+ getSettings,
+ sizeSocialSvg,
+} from "yt-dlp-transcript-common/lib/settings";
import { resolveHomepageSocialLinks } from "yt-dlp-transcript-common/lib/homepage";
import { currentHomepage } from "../lib/homepage";
@@ -25,7 +28,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
className="inline-block w-5 h-5 text-[var(--faint)] hover:text-[var(--brand)] transition-colors [&_svg]:w-full [&_svg]:h-full"
- dangerouslySetInnerHTML={{ __html: link.svg }}
+ dangerouslySetInnerHTML={{ __html: sizeSocialSvg(link.svg) }}
/>
</li>
))}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -157,6 +157,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.99.1
version: 5.100.5(react@19.2.4)
+ lucide-react:
+ specifier: ^1.16.0
+ version: 1.16.0(react@19.2.4)
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)