a11y: semantic headings (UI33) + command-palette listbox semantics (UI27)

UI33 — render titles as real headings so Narrator heading-navigation works:
the shared ScreenHeader title is now <Subtitle2 as="h1"> (one h1 per screen in
one place — Downloads/History/Library/Settings/Terminal); DownloadsView "Queue
(N)" and all 11 settings cards are <Subtitle2 as="h2"> sections; Onboarding
"Welcome" is <Title2 as="h1">. A minimal h1–h6 margin reset in base.css zeroes
the UA heading margin (Fluent's typography classes out-specify the element
selector, so the visual ramp is unchanged) so layout matches the former spans.

UI27 — give the command palette proper combobox/listbox semantics: the input is
role="combobox" with aria-controls/aria-activedescendant/aria-autocomplete, the
list is role="listbox", and each row is role="option" with aria-selected, so the
active row is announced to screen readers via active-descendant. Focus stays on
the input (standard combobox pattern), so the rows became non-focusable divs;
onMouseEnter is kept to unify pointer + keyboard highlight on one sel state.

Also corrected UI30's checkbox in the audit (already implemented via the shared
SegmentedControl radiogroup; only the marker lagged).

typecheck + 253 tests + eslint + prettier + production build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 08:02:54 -04:00
parent 36699531cf
commit 4aec32e3c2
17 changed files with 69 additions and 27 deletions
+18
View File
@@ -9,6 +9,24 @@
outline-offset: 2px;
}
/*
* Reset the UA heading styles (UI33). Titles are rendered as real headings
* (h1/h2) for Narrator heading-navigation, but they carry Fluent's typography
* classes for their visual ramp; those classes out-specify this element
* selector, so only the browser's default heading margin needs zeroing to keep
* the layout identical to the former styled-span titles.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
html,
body {
margin: 0;
+21 -11
View File
@@ -98,6 +98,9 @@ export function CommandPalette({
const listRef = useRef<HTMLDivElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase()))
const listId = 'cmdpalette-list'
const optionId = (id: string): string => `cmdpalette-option-${id}`
const activeId = filtered[sel] ? optionId(filtered[sel].id) : undefined
useEffect(() => {
inputRef.current?.focus()
@@ -106,8 +109,8 @@ export function CommandPalette({
setSel(0)
}, [q])
useEffect(() => {
const btn = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
btn?.scrollIntoView({ block: 'nearest' })
const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [sel])
function onKeyDown(e: React.KeyboardEvent): void {
@@ -144,22 +147,29 @@ export function CommandPalette({
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command…"
role="combobox"
aria-expanded={filtered.length > 0}
aria-controls={listId}
aria-activedescendant={activeId}
aria-autocomplete="list"
aria-label="Command palette search"
/>
<div className={styles.list} ref={listRef}>
<div className={styles.list} ref={listRef} id={listId} role="listbox" aria-label="Commands">
{filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div>
) : (
filtered.map((a, i) => (
<button
// A non-focusable option: focus stays on the combobox input and the
// active row is announced via aria-activedescendant (UI27), the
// standard combobox+listbox pattern. onMouseEnter keeps the pointer
// and keyboard highlight unified on one `sel` state.
<div
key={a.id}
type="button"
id={optionId(a.id)}
role="option"
aria-selected={i === sel}
data-sel={i === sel ? 'true' : undefined}
className={mergeClasses(
styles.item,
i === sel && styles.itemActive,
focus.focusRing
)}
className={mergeClasses(styles.item, i === sel && styles.itemActive)}
onClick={() => {
a.run()
onClose()
@@ -168,7 +178,7 @@ export function CommandPalette({
>
<span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button>
</div>
))
)}
</div>
@@ -115,7 +115,7 @@ export function DownloadsView(): React.JSX.Element {
)}
<div className={styles.queueHeader}>
<Subtitle2>Queue ({queueCount})</Subtitle2>
<Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}>
{summary.failed > 0 && (
<Button
+1 -1
View File
@@ -142,7 +142,7 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2>Welcome to AeroFetch</Title2>
<Title2 as="h1">Welcome to AeroFetch</Title2>
</div>
<Body1>
@@ -94,7 +94,7 @@ export function AboutCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<InfoRegular className={styles.sectionIcon} />
<Subtitle2>About</Subtitle2>
<Subtitle2 as="h2">About</Subtitle2>
</div>
<Caption1 className={styles.hint}>
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
@@ -24,7 +24,7 @@ export function AppearanceCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
<Subtitle2 as="h2">Appearance</Subtitle2>
</div>
<Field label="Theme">
@@ -51,7 +51,7 @@ export function BackupCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<DocumentArrowDownRegular className={styles.sectionIcon} />
<Subtitle2>Backup &amp; restore</Subtitle2>
<Subtitle2 as="h2">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
@@ -68,7 +68,7 @@ export function CookiesCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<CookiesRegular className={styles.sectionIcon} />
<Subtitle2>Cookies</Subtitle2>
<Subtitle2 as="h2">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>Custom commands</Subtitle2>
<Subtitle2 as="h2">Custom commands</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
@@ -28,7 +28,7 @@ export function DiagnosticsCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<BugRegular className={styles.sectionIcon} />
<Subtitle2>Diagnostics</Subtitle2>
<Subtitle2 as="h2">Diagnostics</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Failed downloads are logged here even after you clear the queue, so you can copy the details
@@ -55,7 +55,7 @@ export function DownloadsCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowDownloadRegular className={styles.sectionIcon} />
<Subtitle2>Downloads</Subtitle2>
<Subtitle2 as="h2">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>Filenames</Subtitle2>
<Subtitle2 as="h2">Filenames</Subtitle2>
</div>
<Field
label="Filename template"
@@ -20,7 +20,7 @@ export function NetworkCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<GlobeRegular className={styles.sectionIcon} />
<Subtitle2>Network</Subtitle2>
<Subtitle2 as="h2">Network</Subtitle2>
</div>
<Field
@@ -14,7 +14,7 @@ export function PostProcessingCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
<Subtitle2>Format &amp; post-processing</Subtitle2>
<Subtitle2 as="h2">Format &amp; post-processing</Subtitle2>
</div>
<Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work).
@@ -78,7 +78,7 @@ export function SoftwareUpdateCard(): React.JSX.Element {
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2>
<Subtitle2 as="h2">Software update</Subtitle2>
</div>
<Caption1 className={styles.hint}>
{appVersion
+1 -1
View File
@@ -65,7 +65,7 @@ export function ScreenHeader({
return (
<div className={styles.header}>
<div className={styles.titleBlock}>
<Subtitle2>{title}</Subtitle2>
<Subtitle2 as="h1">{title}</Subtitle2>
{description && <Caption1 className={styles.description}>{description}</Caption1>}
</div>
{actions && <div className={styles.actions}>{actions}</div>}