Archilyzer · Source

archilyzer

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

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:
Mcommon/components/QueryBuilder.tsx | 17+++++++++++------
Mcommon/components/QueryGroupView.tsx | 50++++++++++++++++++++++++++++++--------------------
Mcommon/components/QueryLeafView.tsx | 73+++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcommon/components/TranscriptSearch.tsx | 312+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcommon/components/badges.tsx | 2+-
Mcommon/components/charts/ChartShapeControls.tsx | 30++++++++++++++++--------------
Mcommon/lib/settings.ts | 31+++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/settings/actions.ts | 2++
Meditor/app/settings/components/SettingsForm.tsx | 7+++++++
Mexport/CHANGELOG.md | 4++++
Mexport/app/components/Footer.tsx | 25+++++++++++++++----------
Aexport/app/components/Header.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/NewSinceLastVisit.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/app/components/SiblingSwitcher.tsx | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/globals.css | 4++--
Mexport/app/layout.tsx | 55++++++++++---------------------------------------------
Mexport/app/page.tsx | 11++++++++---
Mexport/e2e/query-tree.spec.ts | 13++++++++-----
Mexport/e2e/theme.spec.ts | 16++++++++++------
Mexport/package.json | 1+
Mhomepage/app/components/Footer.tsx | 7+++++--
Mpnpm-lock.yaml | 3+++
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)