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 {
Input,
Button,
Caption1,
Subtitle2,
Skeleton,
SkeletonItem,
makeStyles,
@@ -29,26 +31,56 @@ const useStyles = makeStyles({
display: 'flex',
flexDirection: 'column',
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
// 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 = [
DownloadsCard,
AppearanceCard,
PostProcessingCard,
NetworkCard,
CookiesCard,
CustomCommandsCard,
FilenamesCard,
BackupCard,
DiagnosticsCard,
SoftwareUpdateCard,
AboutCard
type CardComponent = () => React.JSX.Element
/**
* The cards, grouped into stable sections (UX7) with a jump nav — Settings used
* to be one unsegmented ~11-card scroll. Related cards are co-located: the
* Downloads section holds the format defaults AND the post-processing card so
* the two halves of "format" sit together (UX8). Cards render inside
* React-owned wrappers so the search filter controls each card's visibility via
* state instead of mutating the card's own DOM `style` (M14).
*/
const SECTIONS: { id: string; title: string; cards: CardComponent[] }[] = [
{ id: 'downloads', title: 'Downloads', cards: [DownloadsCard, PostProcessingCard, FilenamesCard] },
{ id: 'appearance', title: 'Appearance', cards: [AppearanceCard] },
{ id: 'network', title: 'Network & accounts', cards: [NetworkCard, CookiesCard] },
{ 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 {
const styles = useStyles()
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).
const [search, setSearch] = useState('')
const cardRefs = useRef<(HTMLDivElement | null)[]>([])
const sectionRefs = useRef<(HTMLDivElement | null)[]>([])
const [hidden, setHidden] = useState<boolean[]>(() => CARDS.map(() => false))
useEffect(() => {
@@ -76,6 +109,11 @@ export function SettingsView(): React.JSX.Element {
}, [search])
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 (
<div className={screen.page}>
@@ -100,6 +138,17 @@ export function SettingsView(): React.JSX.Element {
)}
</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 ? (
// Boot skeleton (UX26): three placeholder cards while persisted settings
// load, instead of a flash of fallback values.
@@ -109,10 +158,29 @@ export function SettingsView(): React.JSX.Element {
))}
</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
// Static array, never reordered/filtered (cards only hide) — index key is stable.
key={i}
key={section.id}
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) => {
cardRefs.current[i] = el
}}
@@ -120,7 +188,11 @@ export function SettingsView(): React.JSX.Element {
>
<Card />
</div>
))
)
})}
</div>
)
})
)}
</div>
</div>
@@ -47,7 +47,7 @@ export function AboutCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">About</Subtitle2>
<Subtitle2 as="h3">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
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}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Appearance</Subtitle2>
<Subtitle2 as="h3">Appearance</Subtitle2>
</div>
<Field label="Theme">
@@ -16,7 +16,7 @@ export function BackupCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Backup &amp; restore</Subtitle2>
<Subtitle2 as="h3">Backup &amp; restore</Subtitle2>
</div>
<Caption1 className={styles.hint}>
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}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Cookies</Subtitle2>
<Subtitle2 as="h3">Cookies</Subtitle2>
</div>
<Caption1 className={styles.hint}>
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}>
<div className={styles.sectionHeader}>
<CodeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Custom commands</Subtitle2>
<Subtitle2 as="h3">Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
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}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Diagnostics</Subtitle2>
<Subtitle2 as="h3">Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
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}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Downloads</Subtitle2>
<Subtitle2 as="h3">Downloads</Subtitle2>
</div>
<Field
@@ -14,7 +14,7 @@ export function FilenamesCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Filenames</Subtitle2>
<Subtitle2 as="h3">Filenames</Subtitle2>
</div>
<Field
label="Filename template"
@@ -30,7 +30,7 @@ export function NetworkCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Network</Subtitle2>
<Subtitle2 as="h3">Network</Subtitle2>
</div>
<Field
@@ -14,10 +14,14 @@ export function PostProcessingCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Format &amp; post-processing</Subtitle2>
<Subtitle2 as="h3">Post-processing</Subtitle2>
</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}>
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>
<DownloadOptionsForm
@@ -45,7 +45,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2 as="h2">Software update</Subtitle2>
<Subtitle2 as="h3">Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion