Archilyzer · Source

archilyzer

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

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:
Acommon/components/OptionsDialog.tsx | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/SocialLinks.tsx | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/ThemeRadios.tsx | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/Footer.tsx | 48+++++++++++++++++++++++++++++++-----------------
Mexport/app/components/MobileMenu.tsx | 91++++---------------------------------------------------------------------------
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> - ); -}