commit 192794b8f77c842ae4af9e4ca9236e4b2885bf4a
parent 228c495d3a05aac062ce0c7db849a941dfe9add7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 20:07:56 -0400
header: one row on a phone, with everything behind one menu
The masthead wrapped to three rows inside a fixed h-14, so rows two and
three rendered outside the sticky background and the page scrolled
through them. It is now one non-wrapping row: brand, theme toggle, and
below md a menu sheet holding the nav, the sites list, the hub backlink
that phones could never reach, and the theme picker. Ask AI joins the
primary nav. The footer stacks instead of overflowing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
3 files changed, 242 insertions(+), 47 deletions(-)
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -25,9 +25,9 @@ export default function Footer() {
const related =
instanceMode() === "site" ? resolveRelatedSites(site, listSites()) : [];
return (
- <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">
+ <footer className="mt-auto border-t border-border bg-muted/30 pb-safe">
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground">
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{/* The "Downloads" eyebrow labels the two links that may follow it, so
it is conditional on at least one existing. It used to be
unconditional because a `code.tar.gz` link always sat behind it —
@@ -71,7 +71,7 @@ export default function Footer() {
Use with AI
</a>
</div>
- <div className="flex items-center gap-4">
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{/* "Built with Archilyzer" — deliberately NOT gated on instanceMode()
(being identical on every deployment is the point), NOT dependent
on a configured hubUrl (that is the operator's family link, a
@@ -79,7 +79,7 @@ export default function Footer() {
navigates in the same tab, and only the social icons open new ones.
"Built with" sits OUTSIDE the anchor so the accessible name is
exactly "Archilyzer". */}
- <span className="text-xs whitespace-nowrap">
+ <span className="text-xs">
Built with{" "}
<a
href={PROJECT_URL}
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -13,6 +13,7 @@ import { instanceMode } from "../lib/mode";
import { hasArchives } from "../lib/archives";
import { hasDuplicates } from "../lib/duplicates";
import SiblingSwitcher from "./SiblingSwitcher";
+import MobileMenu from "./MobileMenu";
// The export site's masthead: a brand-colored mark + wordmark, calm sans nav,
// and the cross-site "family" chrome (hub backlink + sibling switcher). The
@@ -38,64 +39,70 @@ export default function Header() {
const showDownloads = hasArchives();
const showDuplicates = hasDuplicates();
+ // One list, two renderings: the inline nav from md, and the sheet below it —
+ // which also takes Changelog (a right-cluster link on a wide header) so the
+ // phone menu is the complete set. Ask AI is new: the chat was only reachable
+ // from the workspace control or from Use with AI, which is not where anyone
+ // looks for it.
+ const navLinks = [
+ { href: "/", label: "Search" },
+ { href: "/ask/", label: "Ask AI" },
+ ...(showDuplicates ? [{ href: "/duplicates", label: "Duplicates" }] : []),
+ ...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []),
+ { href: "/use-with-ai", label: "Use with AI" },
+ ];
+ const menuLinks = [...navLinks, { href: "/changelog", label: "Changelog" }];
+
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">
+ {/* min-h-14, not h-14, and never wrapping: a fixed-height flex-wrap row
+ put the overflow rows OUTSIDE the sticky header's background at phone
+ widths, and the page scrolled through them. */}
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 flex min-h-14 items-center gap-x-5 gap-y-1 flex-nowrap">
+ <Link href="/" className="group flex min-w-0 items-center gap-2.5">
<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">
+ <span className="truncate 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>
- {showDuplicates && (
+ <nav className="hidden md:flex items-center gap-4 text-sm font-medium">
+ {navLinks.map((l) => (
<Link
- href="/duplicates"
+ key={l.href}
+ href={l.href}
className="text-foreground hover:text-brand transition-colors"
>
- Duplicates
+ {l.label}
</Link>
- )}
- {showDownloads && (
- <Link
- href="/downloads"
- className="text-foreground hover:text-brand transition-colors"
- >
- Downloads
- </Link>
- )}
- <Link
- href="/use-with-ai"
- className="text-foreground hover:text-brand transition-colors"
- >
- Use with AI
- </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"
+ <div className="hidden md:flex items-center gap-2 sm:gap-3">
+ <SiblingSwitcher groups={related} />
+ {hubUrl && (
+ <a
+ href={hubUrl}
+ rel="noopener noreferrer"
+ className="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"
>
- <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>
- <ThemeMenu />
+ Changelog
+ </Link>
+ <ThemeMenu />
+ </div>
+ {/* The one control that stays at every width: theme.spec resolves it
+ by /switch to/i, and one tap to flip light/dark is worth a slot. */}
<ThemeToggle />
+ <MobileMenu links={menuLinks} sites={related} hubUrl={hubUrl} />
</div>
</div>
</header>
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { ArrowUpLeft, MenuIcon } from "lucide-react";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetTitle,
+ SheetTrigger,
+} from "yt-dlp-transcript-common/components/ui/sheet";
+import { useTheme } from "yt-dlp-transcript-common/components/ThemeProvider";
+import {
+ THEME_FAMILIES,
+ THEME_MODES,
+ type ThemeFamily,
+ type ThemeMode,
+} from "yt-dlp-transcript-common/components/themeConfig";
+import type { SwitcherGroup } from "./SiblingSwitcher";
+
+// The phone half of the masthead. Below `md` the header keeps only the brand,
+// the mode toggle and this trigger; everything else the header offers — the nav,
+// the sibling sites, the hub backlink, the theme family — moves in here, which
+// is also how the Hub backlink and the sites list become reachable on a phone at
+// all (they were `hidden sm:*` and a dropdown too small to hit).
+//
+// Header is a server component, so the link groups arrive as plain serialisable
+// props. The theme lists are read from the client ThemeProvider and rendered as
+// NATIVE radio groups, not by nesting ThemeMenu's DropdownMenu inside this
+// dialog — one popover layer inside another is a focus-trap fight, and the
+// dropdown's `menuitemradio`s are a spec hook that belongs to the md+ header.
+export default function MobileMenu({
+ links,
+ sites,
+ hubUrl,
+}: {
+ links: { href: string; label: string }[];
+ sites: SwitcherGroup[];
+ hubUrl?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const { theme, mode, setTheme, setMode } = useTheme();
+
+ return (
+ <Sheet open={open} onOpenChange={setOpen}>
+ <SheetTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label="Open menu"
+ className="md:hidden"
+ >
+ <MenuIcon aria-hidden="true" />
+ </Button>
+ </SheetTrigger>
+ <SheetContent
+ side="right"
+ // Radix warns when a Dialog has neither a Description nor an explicit
+ // opt-out. A nav sheet titled "Menu" needs no prose.
+ aria-describedby={undefined}
+ className="w-80 max-w-[85vw] gap-0 overflow-y-auto pb-[calc(1.5rem+env(safe-area-inset-bottom,0px))]"
+ >
+ <SheetTitle className="px-4 pt-4 text-base">Menu</SheetTitle>
+
+ {/* Every link closes the sheet on the way out. Header lives in the root
+ layout and never remounts on a client navigation, so without this the
+ sheet would stay open over the page the user just asked for. */}
+ <nav className="mt-2 flex flex-col px-2">
+ {links.map((l) => (
+ <SheetClose asChild key={l.href}>
+ <Link
+ href={l.href}
+ className="rounded-md px-2 py-3 text-base font-medium text-foreground transition-colors hover:bg-accent"
+ >
+ {l.label}
+ </Link>
+ </SheetClose>
+ ))}
+ {hubUrl && (
+ <a
+ href={hubUrl}
+ rel="noopener noreferrer"
+ className="flex items-center gap-1.5 rounded-md px-2 py-3 text-base font-medium text-muted-foreground transition-colors hover:bg-accent"
+ >
+ <ArrowUpLeft className="size-4" aria-hidden="true" />
+ Hub
+ </a>
+ )}
+ </nav>
+
+ {sites.length > 0 && (
+ <div className="mt-4 px-2">
+ <MenuHeading>Sites</MenuHeading>
+ {sites.map((group, gi) => (
+ <div key={group.label ?? `group-${gi}`} className="mt-1">
+ {group.label && (
+ <p className="px-2 py-1 font-mono text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground/70">
+ {group.label}
+ </p>
+ )}
+ <ul className="list-none">
+ {group.sites.map((s) => (
+ <li key={s.siteId}>
+ <a
+ href={s.url}
+ rel="noopener noreferrer"
+ className="block rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent"
+ >
+ {s.title}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4 px-2">
+ <MenuHeading>Theme</MenuHeading>
+ <RadioList
+ name="mobile-theme-family"
+ label="Theme"
+ value={theme}
+ options={THEME_FAMILIES}
+ onPick={(v) => setTheme(v as ThemeFamily)}
+ />
+ </div>
+
+ <div className="mt-4 px-2">
+ <MenuHeading>Mode</MenuHeading>
+ <RadioList
+ name="mobile-theme-mode"
+ label="Mode"
+ value={mode}
+ options={THEME_MODES}
+ onPick={(v) => setMode(v as ThemeMode)}
+ />
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
+
+function MenuHeading({ children }: { children: React.ReactNode }) {
+ return (
+ <p className="px-2 font-mono text-xs uppercase tracking-[0.14em] text-muted-foreground">
+ {children}
+ </p>
+ );
+}
+
+function RadioList({
+ name,
+ label,
+ value,
+ options,
+ onPick,
+}: {
+ name: string;
+ label: string;
+ value: string;
+ options: { id: string; label: string }[];
+ onPick: (id: string) => void;
+}) {
+ return (
+ <div role="radiogroup" aria-label={label} className="mt-1 flex flex-col">
+ {options.map((o) => (
+ <label
+ key={o.id}
+ className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2.5 text-sm text-foreground transition-colors hover:bg-accent"
+ >
+ <input
+ type="radio"
+ name={name}
+ value={o.id}
+ checked={value === o.id}
+ onChange={() => onPick(o.id)}
+ className="size-4 accent-[var(--brand)]"
+ />
+ {o.label}
+ </label>
+ ))}
+ </div>
+ );
+}