feat(audit): Batch 19 — Settings information architecture (UX7, UX8, UX23)

UX7: 11 cards grouped into five sections (Downloads, Appearance,
Network & accounts, Advanced, App & maintenance) with section headings
and a jump nav under the search box; search hides empty sections and
the nav; heading hierarchy corrected (h1 page, h2 section, h3 card).
UX8: post-processing card co-located under the Downloads card and
renamed 'Post-processing' with a defaults-vs-advanced hint.
UX23: closed as stale duplicate of the already-fixed L71 (editable,
validated, reconciled folder inputs) — verified in the preview probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 12:00:47 -04:00
parent 93f0091104
commit a5885739d2
12 changed files with 115 additions and 39 deletions
+91 -19
View File
@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
Input, Input,
Button,
Caption1, Caption1,
Subtitle2,
Skeleton, Skeleton,
SkeletonItem, SkeletonItem,
makeStyles, makeStyles,
@@ -29,26 +31,56 @@ const useStyles = makeStyles({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: SPACE.section gap: SPACE.section
},
// The section jump nav (UX7): a row of anchor buttons under the search box.
sectionNav: {
display: 'flex',
gap: SPACE.xtight,
flexWrap: 'wrap'
},
sectionHeading: {
color: tokens.colorNeutralForeground3,
paddingTop: SPACE.tight
},
section: {
display: 'flex',
flexDirection: 'column',
gap: SPACE.section,
// Anchor scrolling lands the heading just below the screen padding.
scrollMarginTop: SPACE.section
} }
}) })
// The cards, in display order. Rendered inside React-owned wrappers so the search type CardComponent = () => React.JSX.Element
// filter controls each card's visibility via state instead of mutating the card's
// own DOM `style` (M14) — which broke if a card ever set its own inline style. /**
const CARDS = [ * The cards, grouped into stable sections (UX7) with a jump nav — Settings used
DownloadsCard, * to be one unsegmented ~11-card scroll. Related cards are co-located: the
AppearanceCard, * Downloads section holds the format defaults AND the post-processing card so
PostProcessingCard, * the two halves of "format" sit together (UX8). Cards render inside
NetworkCard, * React-owned wrappers so the search filter controls each card's visibility via
CookiesCard, * state instead of mutating the card's own DOM `style` (M14).
CustomCommandsCard, */
FilenamesCard, const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
BackupCard, { id: 'downloads', title: 'Downloads', cards: [DownloadsCard, PostProcessingCard, FilenamesCard] },
DiagnosticsCard, { id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
SoftwareUpdateCard, { id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
AboutCard { id: 'advanced', title: 'Advanced', cards: [CustomCommandsCard] },
{
id: 'app',
title: 'App & maintenance',
cards: [BackupCard, DiagnosticsCard, SoftwareUpdateCard, AboutCard]
}
] ]
// Flat list in render order — the search filter indexes cards globally.
const CARDS: CardComponent[] = SECTIONS.flatMap((s) => s.cards)
// Each section's [start, end) range into CARDS.
const SECTION_RANGES = SECTIONS.map((s, si) => {
const start = SECTIONS.slice(0, si).reduce((n, x) => n + x.cards.length, 0)
return { start, end: start + s.cards.length }
})
export function SettingsView(): React.JSX.Element { export function SettingsView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
const screen = useScreenStyles() const screen = useScreenStyles()
@@ -62,6 +94,7 @@ export function SettingsView(): React.JSX.Element {
// visibility, rather than us writing `display:none` onto React's own card nodes (M14). // visibility, rather than us writing `display:none` onto React's own card nodes (M14).
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const cardRefs = useRef<(HTMLDivElement | null)[]>([]) const cardRefs = useRef<(HTMLDivElement | null)[]>([])
const sectionRefs = useRef<(HTMLDivElement | null)[]>([])
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false)) const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
useEffect(() => { useEffect(() => {
@@ -76,6 +109,11 @@ export function SettingsView(): React.JSX.Element {
}, [search]) }, [search])
const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean) const noResults = search.trim() !== '' && hidden.length > 0 && hidden.every(Boolean)
const searching = search.trim() !== ''
function jumpTo(si: number): void {
sectionRefs.current[si]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return ( return (
<div className={screen.page}> <div className={screen.page}>
@@ -100,6 +138,17 @@ export function SettingsView(): React.JSX.Element {
)} )}
</div> </div>
{/* Section jump nav (UX7). Hidden while searching — the filter is the finder then. */}
{loaded && !searching && (
<nav className={styles.sectionNav} aria-label="Settings sections">
{SECTIONS.map((s, si) => (
<Button key={s.id} size="small" appearance="subtle" onClick={() => jumpTo(si)}>
{s.title}
</Button>
))}
</nav>
)}
{!loaded ? ( {!loaded ? (
// Boot skeleton (UX26): three placeholder cards while persisted settings // Boot skeleton (UX26): three placeholder cards while persisted settings
// load, instead of a flash of fallback values. // load, instead of a flash of fallback values.
@@ -109,10 +158,29 @@ export function SettingsView(): React.JSX.Element {
))} ))}
</Skeleton> </Skeleton>
) : ( ) : (
CARDS.map((Card, i) => ( SECTIONS.map((section, si) => {
const range = SECTION_RANGES[si]!
// A section disappears with all of its cards during a search.
const sectionHidden = section.cards.every((_, ci) => hidden[range.start + ci])
return (
<div <div
// Static array, never reordered/filtered (cards only hide) — index key is stable. key={section.id}
key={i} ref={(el) => {
sectionRefs.current[si] = el
}}
className={styles.section}
style={sectionHidden ? { display: 'none' } : undefined}
>
<Subtitle2 as="h2" className={styles.sectionHeading}>
{section.title}
</Subtitle2>
{section.cards.map((Card, ci) => {
const i = range.start + ci
return (
<div
// Static arrays, never reordered/filtered (cards only hide) —
// index keys are stable.
key={ci}
ref={(el) => { ref={(el) => {
cardRefs.current[i] = el cardRefs.current[i] = el
}} }}
@@ -120,7 +188,11 @@ export function SettingsView(): React.JSX.Element {
> >
<Card /> <Card />
</div> </div>
)) )
})}
</div>
)
})
)} )}
</div> </div>
</div> </div>
@@ -47,7 +47,7 @@ export function AboutCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} /> <InfoRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">About</Subtitle2> <Subtitle2 as="h3">About</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
@@ -48,7 +48,7 @@ export function AppearanceCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} /> <PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Appearance</Subtitle2> <Subtitle2 as="h3">Appearance</Subtitle2>
</div> </div>
<Field label="Theme"> <Field label="Theme">
@@ -16,7 +16,7 @@ export function BackupCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} /> <DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Backup &amp; restore</Subtitle2> <Subtitle2 as="h3">Backup &amp; restore</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on another Save your settings and custom-command templates to a JSON file, or restore them on another
@@ -33,7 +33,7 @@ export function CookiesCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} /> <CookiesRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Cookies</Subtitle2> <Subtitle2 as="h3">Cookies</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Some sites only serve full quality, age-restricted, or members-only video to a logged-in Some sites only serve full quality, age-restricted, or members-only video to a logged-in
@@ -17,7 +17,7 @@ export function CustomCommandsCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} /> <CodeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Custom commands</Subtitle2> <Subtitle2 as="h3">Custom commands</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g. Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
@@ -29,7 +29,7 @@ export function DiagnosticsCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} /> <BugRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Diagnostics</Subtitle2> <Subtitle2 as="h3">Diagnostics</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the details Failed downloads are logged here even after you clear the queue, so you can copy the details
@@ -48,7 +48,7 @@ export function DownloadsCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} /> <ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Downloads</Subtitle2> <Subtitle2 as="h3">Downloads</Subtitle2>
</div> </div>
<Field <Field
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} /> <DocumentRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Filenames</Subtitle2> <Subtitle2 as="h3">Filenames</Subtitle2>
</div> </div>
<Field <Field
label="Filename template" label="Filename template"
@@ -30,7 +30,7 @@ export function NetworkCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} /> <GlobeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Network</Subtitle2> <Subtitle2 as="h3">Network</Subtitle2>
</div> </div>
<Field <Field
@@ -14,10 +14,14 @@ export function PostProcessingCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} /> <OptionsRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Format &amp; post-processing</Subtitle2> <Subtitle2 as="h3">Post-processing</Subtitle2>
</div> </div>
{/* UX8: this card is the advanced half — the default format itself (video/
audio + quality) lives in the Downloads card, co-located just above. */}
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work). Advanced conversion applied to every new download container, codec, subtitles,
SponsorBlock (yt-dlp and ffmpeg do the work). The default format itself is set in the
Downloads card above.
</Caption1> </Caption1>
<DownloadOptionsForm <DownloadOptionsForm
@@ -45,7 +45,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
<Card className={styles.card}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} /> <ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Software update</Subtitle2> <Subtitle2 as="h3">Software update</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
{appVersion {appVersion