commit 0197fb7cfcf4bffa741240bb325eef5a409c6a78
parent b7601fe123a4b6bcd243dfcf83c5270be8740c3d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 18:42:46 -0400
common: SocialLinks, OptionsDialog and ThemeRadios; export: the slide-out menu renders ThemeRadios, the footer's icons pass the render-time check
- SocialLinks: one row for any header or footer (`links`, `placement`); 36 px
keys around 20 px glyphs (44 px under a coarse pointer), the muted
foreground, a 2 px focus ring and the browser's outline in forced colours;
the header placement shows at most four; ids scoped per copy; an icon that
fails the render-time check is not inlined — the link shows its label.
- ThemeRadios: Base and Accent as native radio groups, extracted from the
export's MobileMenu unchanged; MobileMenu renders it.
- OptionsDialog: a gear named "Options", dressed as a social key, opening a
modal titled "Options" with ThemeRadios. It styles its own panel border and
Close key and opens on the checked Base radio; the shared ui/dialog.tsx is
used as it is.
- The export footer inlines an icon only through safeSocialSvg.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 329 insertions(+), 104 deletions(-)
diff --git a/common/components/OptionsDialog.tsx b/common/components/OptionsDialog.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useRef } from "react";
+import { Settings, XIcon } from "lucide-react";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "./ui/dialog";
+import { ThemeRadios } from "./ThemeRadios";
+import { cn } from "../lib/utils";
+
+// ONE OPTIONS BUTTON in place of the theme dropdown (ThemeMenu) and the base
+// toggle (ThemeToggle): a gear, named "Options", that opens a modal titled
+// "Options" holding the theme's two radio groups, Base and Accent (ThemeRadios).
+//
+// A pick applies at once and the dialog stays open. Escape, the close button
+// and a click outside close it; while it is open focus is trapped in it and the
+// page does not scroll; on close focus returns to the gear (Radix Dialog, the
+// shared ui/dialog.tsx, used as it is). The dialog opens with focus on the
+// CHECKED Base radio, so an arrow key moves from the base in force. The content
+// scrolls inside the dialog when a short screen cannot hold it.
+//
+// Styled here, not in the shared wrapper (the editor's command palette renders
+// it too): the panel names `border-border` (no app sets a base border colour,
+// and Tailwind v4's bare `border` is currentColor), and its own Close is a key
+// like the gear, in place of the wrapper's.
+//
+// The trigger is dressed exactly as a social link's key (SocialLinks.tsx): a
+// 36 px box, 44 px under a coarse pointer, no border, the muted foreground and
+// the faint hover square, the ring colour's 2 px focus ring, and in forced
+// colours the browser's own outline — so placed directly after the social row
+// it reads as that row's last key. The name and the radio labels are e2e
+// contracts. Must render inside a <ThemeProvider/>; the no-flash script and
+// the provider are untouched by where this renders.
+const KEY =
+ "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11";
+
+const NAME_PREFIX = "options-theme";
+
+export function OptionsDialog({ className }: { className?: string }) {
+ const content = useRef<HTMLDivElement>(null);
+ return (
+ <Dialog>
+ <DialogTrigger aria-label="Options" title="Options" className={cn(KEY, className)}>
+ <Settings className="size-5" aria-hidden="true" />
+ </DialogTrigger>
+ <DialogContent
+ ref={content}
+ showCloseButton={false}
+ // A dialog of two radio groups needs no prose; this is Radix's opt-out
+ // of its "missing Description" warning.
+ aria-describedby={undefined}
+ onOpenAutoFocus={(event) => {
+ const checked = content.current?.querySelector<HTMLInputElement>(
+ `input[name="${NAME_PREFIX}-base"]:checked`,
+ );
+ if (checked) {
+ event.preventDefault();
+ checked.focus();
+ }
+ }}
+ className="max-h-[calc(100dvh-2rem)] gap-5 overflow-y-auto border-border p-5 text-foreground sm:max-w-sm"
+ >
+ <DialogTitle className="font-display text-lg">Options</DialogTitle>
+ <DialogClose aria-label="Close" className={cn(KEY, "absolute top-3 right-3")}>
+ <XIcon className="size-4" aria-hidden="true" />
+ </DialogClose>
+ <div className="-mx-2 flex flex-col gap-5">
+ <ThemeRadios namePrefix={NAME_PREFIX} />
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/common/components/SocialLinks.tsx b/common/components/SocialLinks.tsx
@@ -0,0 +1,97 @@
+import { useId } from "react";
+import type { SocialLink } from "../lib/settingsSchema";
+import {
+ headerSocialLinks,
+ safeSocialSvg,
+ scopeSvgIds,
+ sizeSocialSvg,
+} from "../lib/socialLinks";
+import { cn } from "../lib/utils";
+
+export type SocialLinksPlacement = "header" | "footer";
+
+// THE SOCIAL ROW, for any header or footer. The links are the operator's
+// (settings.json `socialLinks`, or a site's or the homepage's own list), each an
+// icon with its label as its accessible name — never text beside it.
+//
+// - `placement: "header"` shows at most four (headerSocialLinks: the
+// `featured` ones when any is marked, else all; of those the last four);
+// `"footer"` shows every link.
+// - Each link is a 36 px key around a 20 px glyph, 44 px under a coarse
+// pointer. The glyph is the link's colour (`--muted-foreground`, the
+// foreground on hover) when the icon is single-colour; an icon of two or
+// more colours keeps its own (normalizeSocialSvg).
+// - Focus: the ring colour's 2 px ring. In forced colours a box-shadow is not
+// drawn, so the browser's own focus outline is left in place there.
+// - The ids inside each inlined icon are scoped to this row and this link
+// (scopeSvgIds): a page inlines the same icon more than once, and a gradient
+// defined in a copy that is `display: none` would not paint in the others.
+// - An icon is inlined only if it passes the save-time check again
+// (safeSocialSvg). One that does not is not injected: the link shows its
+// label as text instead, so a bad file costs the icon, never the page.
+//
+// Server-safe and client-safe: no state, no effects; `useId` works in both.
+// Renders nothing when there is nothing to show, so a caller can drop the
+// column or the group around it on the same condition.
+// Complete literal class strings (Tailwind v4 scans them as written).
+const KEY =
+ "inline-flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11 [&_svg]:size-5 [&_svg]:shrink-0";
+const TEXT_KEY =
+ "inline-flex h-9 shrink-0 items-center rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:h-11";
+
+export function SocialLinks({
+ links,
+ placement,
+ className,
+}: {
+ links: readonly SocialLink[];
+ placement: SocialLinksPlacement;
+ className?: string;
+}) {
+ const scope = `sl${useId().replace(/[^A-Za-z0-9_-]/g, "")}`;
+ const shown = placement === "header" ? headerSocialLinks(links) : [...links];
+ if (shown.length === 0) return null;
+ return (
+ <ul
+ data-social-links={placement}
+ className={cn(
+ "flex items-center list-none",
+ // The footer's column is left-aligned under its eyebrow: pull the first
+ // key out by its inset so the first glyph lines up with the text.
+ placement === "footer" && "-mx-2 pointer-coarse:-mx-3",
+ className,
+ )}
+ >
+ {shown.map((link, i) => {
+ const svg = safeSocialSvg(link.svg);
+ return (
+ <li key={`${link.url}-${i}`} className="flex">
+ {svg ? (
+ <a
+ href={link.url}
+ title={link.label}
+ aria-label={link.label}
+ target="_blank"
+ rel="noopener noreferrer"
+ className={KEY}
+ dangerouslySetInnerHTML={{
+ __html: sizeSocialSvg(scopeSvgIds(svg, `${scope}-${i}`)),
+ }}
+ />
+ ) : (
+ <a
+ href={link.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ data-social-icon-refused=""
+ className={TEXT_KEY}
+ >
+ {link.label}
+ </a>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ );
+}
diff --git a/common/components/ThemeRadios.tsx b/common/components/ThemeRadios.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useTheme } from "./ThemeProvider";
+import {
+ THEME_BASES,
+ accentOptions,
+ isThemeAccent,
+ isThemeBase,
+} from "./themeConfig";
+
+// The theme's two choices as NATIVE radio groups: "Base" (System / Light /
+// Sepia / Dark) and "Accent" (the seven named accents, the site's own tagged
+// "default"; a custom-hex site adds "Site colour" first) — the same lists
+// ThemeMenu's dropdown offers. Used where a dropdown inside another layer would
+// be a focus-trap fight: the Options dialog (OptionsDialog.tsx) and the
+// export's slide-out menu. A pick applies at once (attributes on <html>;
+// tokens.css does the rest) and nothing closes.
+//
+// Renders the two groups as siblings, each `groupClassName`, so a caller lays
+// them out. The radiogroups are named "Base" and "Accent", and each radio by its
+// label — an e2e contract. `namePrefix` keeps two instances on one page from
+// sharing a radio group. Must render inside a <ThemeProvider/>.
+export function ThemeRadios({
+ namePrefix,
+ groupClassName,
+}: {
+ namePrefix: string;
+ groupClassName?: string;
+}) {
+ const { base, accent, siteAccent, setBase, setAccent } = useTheme();
+ return (
+ <>
+ <div className={groupClassName}>
+ <GroupHeading>Base</GroupHeading>
+ <RadioList
+ name={`${namePrefix}-base`}
+ label="Base"
+ value={base}
+ options={THEME_BASES}
+ onPick={(v) => {
+ if (isThemeBase(v)) setBase(v);
+ }}
+ />
+ </div>
+ <div className={groupClassName}>
+ <GroupHeading>Accent</GroupHeading>
+ <RadioList
+ name={`${namePrefix}-accent`}
+ label="Accent"
+ value={accent}
+ options={accentOptions(siteAccent)}
+ onPick={(v) => {
+ if (isThemeAccent(v)) setAccent(v);
+ }}
+ />
+ </div>
+ </>
+ );
+}
+
+function GroupHeading({ 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: ReadonlyArray<{
+ id: string;
+ label: string;
+ // An accent's dot (a CSS colour) and whether it is the site's own.
+ swatch?: string;
+ isSiteDefault?: boolean;
+ }>;
+ 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.swatch && (
+ <span
+ aria-hidden="true"
+ className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
+ style={{ background: o.swatch }}
+ />
+ )}
+ {o.label}
+ {o.isSiteDefault && (
+ <span className="ml-auto font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
+ default
+ </span>
+ )}
+ </label>
+ ))}
+ </div>
+ );
+}
diff --git a/export/app/components/Footer.tsx b/export/app/components/Footer.tsx
@@ -1,7 +1,5 @@
-import {
- getSettings,
- sizeSocialSvg,
-} from "yt-dlp-transcript-common/lib/settings";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { safeSocialSvg, sizeSocialSvg } from "yt-dlp-transcript-common/lib/socialLinks";
import {
listSites,
resolveRelatedSites,
@@ -96,19 +94,35 @@ export default function Footer() {
</span>
{socialLinks.length > 0 && (
<ul className="flex items-center gap-3 list-none">
- {socialLinks.map((link, i) => (
- <li key={`${link.url}-${i}`}>
- <a
- href={link.url}
- title={link.label}
- aria-label={link.label}
- target="_blank"
- rel="noopener noreferrer"
- 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>
- ))}
+ {socialLinks.map((link, i) => {
+ // Inlined only if the stored icon passes the save-time check
+ // again (lib/socialLinks.ts safeSocialSvg); otherwise the label.
+ const svg = safeSocialSvg(link.svg);
+ return (
+ <li key={`${link.url}-${i}`}>
+ {svg ? (
+ <a
+ href={link.url}
+ title={link.label}
+ aria-label={link.label}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-block w-5 h-5 text-muted-foreground hover:text-brand transition-colors [&_svg]:w-full [&_svg]:h-full"
+ dangerouslySetInnerHTML={{ __html: sizeSocialSvg(svg) }}
+ />
+ ) : (
+ <a
+ href={link.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-muted-foreground hover:text-brand transition-colors"
+ >
+ {link.label}
+ </a>
+ )}
+ </li>
+ );
+ })}
</ul>
)}
</div>
diff --git a/export/app/components/MobileMenu.tsx b/export/app/components/MobileMenu.tsx
@@ -11,13 +11,7 @@ import {
SheetTitle,
SheetTrigger,
} from "yt-dlp-transcript-common/components/ui/sheet";
-import { useTheme } from "yt-dlp-transcript-common/components/ThemeProvider";
-import {
- THEME_BASES,
- accentOptions,
- isThemeAccent,
- isThemeBase,
-} from "yt-dlp-transcript-common/components/themeConfig";
+import { ThemeRadios } from "yt-dlp-transcript-common/components/ThemeRadios";
import type { SwitcherGroup } from "./SiblingSwitcher";
// The phone half of the masthead. Below `md` the header keeps only the brand,
@@ -29,7 +23,8 @@ import type { SwitcherGroup } from "./SiblingSwitcher";
//
// Header is a server component, so the link groups arrive as plain serialisable
// props. The base and accent lists are read from the client ThemeProvider and
-// rendered as NATIVE radio groups ("Base", "Accent"), not by nesting
+// rendered as NATIVE radio groups ("Base", "Accent"; ThemeRadios, shared with
+// the Options dialog), 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.
@@ -43,7 +38,6 @@ export default function MobileMenu({
hubUrl?: string;
}) {
const [open, setOpen] = useState(false);
- const { base, accent, siteAccent, setBase, setAccent } = useTheme();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -120,31 +114,7 @@ export default function MobileMenu({
</div>
)}
- <div className="mt-4 px-2">
- <MenuHeading>Base</MenuHeading>
- <RadioList
- name="mobile-theme-base"
- label="Base"
- value={base}
- options={THEME_BASES}
- onPick={(v) => {
- if (isThemeBase(v)) setBase(v);
- }}
- />
- </div>
-
- <div className="mt-4 px-2">
- <MenuHeading>Accent</MenuHeading>
- <RadioList
- name="mobile-theme-accent"
- label="Accent"
- value={accent}
- options={accentOptions(siteAccent)}
- onPick={(v) => {
- if (isThemeAccent(v)) setAccent(v);
- }}
- />
- </div>
+ <ThemeRadios namePrefix="mobile-theme" groupClassName="mt-4 px-2" />
</SheetContent>
</Sheet>
);
@@ -157,56 +127,3 @@ function MenuHeading({ children }: { children: React.ReactNode }) {
</p>
);
}
-
-function RadioList({
- name,
- label,
- value,
- options,
- onPick,
-}: {
- name: string;
- label: string;
- value: string;
- options: ReadonlyArray<{
- id: string;
- label: string;
- // An accent's dot (a CSS colour) and whether it is the site's own.
- swatch?: string;
- isSiteDefault?: boolean;
- }>;
- 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.swatch && (
- <span
- aria-hidden="true"
- className="size-3 shrink-0 rounded-full ring-1 ring-inset ring-foreground/15"
- style={{ background: o.swatch }}
- />
- )}
- {o.label}
- {o.isSiteDefault && (
- <span className="ml-auto font-mono text-[0.625rem] uppercase tracking-[0.12em] text-muted-foreground">
- default
- </span>
- )}
- </label>
- ))}
- </div>
- );
-}