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
+16 -2
View File
@@ -952,9 +952,16 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
### Hover, focus, disabled, keyboard & accessibility ### Hover, focus, disabled, keyboard & accessibility
- [ ] **UI27 — Command palette uses JS hover, not CSS.** Items highlight via `onMouseEnter` setting - [x] **UI27 — Command palette uses JS hover, not CSS.** Items highlight via `onMouseEnter` setting
`itemActive` (no `:hover`), unlike every other list, and expose no `aria-selected`/active-descendant, `itemActive` (no `:hover`), unlike every other list, and expose no `aria-selected`/active-descendant,
so the highlight is invisible to screen readers. **Standard:** CSS `:hover` + listbox/option semantics. so the highlight is invisible to screen readers. **Standard:** CSS `:hover` + listbox/option semantics.
*Fixed in [CommandPalette.tsx](src/renderer/src/components/CommandPalette.tsx): the input is now a
`role="combobox"` with `aria-controls`/`aria-activedescendant`/`aria-autocomplete="list"`, the list is a
`role="listbox"`, and each row is a `role="option"` with `aria-selected` — so the active row is announced
to Narrator via active-descendant (the standard combobox pattern; focus stays on the input, so the rows
became non-focusable `div`s rather than `button`s). `onMouseEnter` is kept deliberately: it unifies the
pointer and keyboard highlight onto one `sel` state, which — now that `aria-activedescendant` tracks it —
is more correct than a pure CSS `:hover` (which wouldn't move the announced selection).*
- [x] **UI28 — Command palette input has no focus ring.** The search `<input>` sets `outline: 'none'` - [x] **UI28 — Command palette input has no focus ring.** The search `<input>` sets `outline: 'none'`
with no replacement. **Standard:** a visible focus ring (Fluent stroke token). *(a11y)* with no replacement. **Standard:** a visible focus ring (Fluent stroke token). *(a11y)*
- [x] **UI29 — Fragmented focus treatment.** Fluent ring (Fluent controls) vs custom border (Select) - [x] **UI29 — Fragmented focus treatment.** Fluent ring (Fluent controls) vs custom border (Select)
@@ -968,9 +975,16 @@ are an undefined spacing/radius/type scale and a drift between Fluent components
so that control can't show a disabled state while Fluent controls can. **Standard:** add `disabled` + styling. so that control can't show a disabled state while Fluent controls can. **Standard:** add `disabled` + styling.
- [ ] **UI32 — Native-control dark mode is uneven.** The DownloadBar `datetime-local` sets - [ ] **UI32 — Native-control dark mode is uneven.** The DownloadBar `datetime-local` sets
`colorScheme: 'light dark'` explicitly; the `Select` relies on the root `colorScheme`. **Standard:** set `colorScheme` consistently on both. `colorScheme: 'light dark'` explicitly; the `Select` relies on the root `colorScheme`. **Standard:** set `colorScheme` consistently on both.
- [ ] **UI33 — No semantic headings.** Titles render as `Subtitle2`/`Title2` (styled spans), so there's - [x] **UI33 — No semantic headings.** Titles render as `Subtitle2`/`Title2` (styled spans), so there's
no h1h6 hierarchy for screen-reader heading navigation (landmarks `<nav>`/`<main>` exist; headings no h1h6 hierarchy for screen-reader heading navigation (landmarks `<nav>`/`<main>` exist; headings
don't). **Standard:** render titles as real headings (`as="h1"`/`"h2"` or `role="heading" aria-level`). *(a11y)* don't). **Standard:** render titles as real headings (`as="h1"`/`"h2"` or `role="heading" aria-level`). *(a11y)*
*Fixed by rendering the Fluent typography titles as real headings (the `as` prop keeps the visual ramp):
the shared [ScreenHeader](src/renderer/src/components/ui/Screen.tsx) title is now `<Subtitle2 as="h1">`,
giving every screen (Downloads, History, Library, Settings, Terminal) one `h1` in one place; the
DownloadsView "Queue (N)" and all 11 settings cards are `<Subtitle2 as="h2">` sections beneath it; and the
Onboarding "Welcome" title is `<Title2 as="h1">`. A minimal `h1h6` margin reset in
[base.css](src/renderer/src/assets/base.css) zeroes the UA heading margin (Fluent's typography classes
out-specify the element selector, so the visual ramp is unchanged) so the layout matches the former spans.*
### Top cohesion-breakers (the "not one product" shortlist) ### Top cohesion-breakers (the "not one product" shortlist)
+18
View File
@@ -9,6 +9,24 @@
outline-offset: 2px; 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, html,
body { body {
margin: 0; margin: 0;
+21 -11
View File
@@ -98,6 +98,9 @@ export function CommandPalette({
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const filtered = actions.filter((a) => a.label.toLowerCase().includes(q.trim().toLowerCase())) 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(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
@@ -106,8 +109,8 @@ export function CommandPalette({
setSel(0) setSel(0)
}, [q]) }, [q])
useEffect(() => { useEffect(() => {
const btn = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`) const row = listRef.current?.querySelector<HTMLElement>(`[data-sel="true"]`)
btn?.scrollIntoView({ block: 'nearest' }) row?.scrollIntoView({ block: 'nearest' })
}, [sel]) }, [sel])
function onKeyDown(e: React.KeyboardEvent): void { function onKeyDown(e: React.KeyboardEvent): void {
@@ -144,22 +147,29 @@ export function CommandPalette({
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Type a command…" 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" 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 ? ( {filtered.length === 0 ? (
<div className={styles.empty}>No matching commands</div> <div className={styles.empty}>No matching commands</div>
) : ( ) : (
filtered.map((a, i) => ( 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} key={a.id}
type="button" id={optionId(a.id)}
role="option"
aria-selected={i === sel}
data-sel={i === sel ? 'true' : undefined} data-sel={i === sel ? 'true' : undefined}
className={mergeClasses( className={mergeClasses(styles.item, i === sel && styles.itemActive)}
styles.item,
i === sel && styles.itemActive,
focus.focusRing
)}
onClick={() => { onClick={() => {
a.run() a.run()
onClose() onClose()
@@ -168,7 +178,7 @@ export function CommandPalette({
> >
<span>{a.label}</span> <span>{a.label}</span>
{a.hint && <span className={styles.itemHint}>{a.hint}</span>} {a.hint && <span className={styles.itemHint}>{a.hint}</span>}
</button> </div>
)) ))
)} )}
</div> </div>
@@ -115,7 +115,7 @@ export function DownloadsView(): React.JSX.Element {
)} )}
<div className={styles.queueHeader}> <div className={styles.queueHeader}>
<Subtitle2>Queue ({queueCount})</Subtitle2> <Subtitle2 as="h2">Queue ({queueCount})</Subtitle2>
<div className={styles.headerActions}> <div className={styles.headerActions}>
{summary.failed > 0 && ( {summary.failed > 0 && (
<Button <Button
+1 -1
View File
@@ -142,7 +142,7 @@ export function Onboarding(): React.JSX.Element {
<div className={styles.mark}> <div className={styles.mark}>
<ArrowDownloadFilled /> <ArrowDownloadFilled />
</div> </div>
<Title2>Welcome to AeroFetch</Title2> <Title2 as="h1">Welcome to AeroFetch</Title2>
</div> </div>
<Body1> <Body1>
@@ -94,7 +94,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>About</Subtitle2> <Subtitle2 as="h2">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
@@ -24,7 +24,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>Appearance</Subtitle2> <Subtitle2 as="h2">Appearance</Subtitle2>
</div> </div>
<Field label="Theme"> <Field label="Theme">
@@ -51,7 +51,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>Backup &amp; restore</Subtitle2> <Subtitle2 as="h2">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
@@ -68,7 +68,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>Cookies</Subtitle2> <Subtitle2 as="h2">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>Custom commands</Subtitle2> <Subtitle2 as="h2">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.
@@ -28,7 +28,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>Diagnostics</Subtitle2> <Subtitle2 as="h2">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
@@ -55,7 +55,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>Downloads</Subtitle2> <Subtitle2 as="h2">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>Filenames</Subtitle2> <Subtitle2 as="h2">Filenames</Subtitle2>
</div> </div>
<Field <Field
label="Filename template" label="Filename template"
@@ -20,7 +20,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>Network</Subtitle2> <Subtitle2 as="h2">Network</Subtitle2>
</div> </div>
<Field <Field
@@ -14,7 +14,7 @@ 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>Format &amp; post-processing</Subtitle2> <Subtitle2 as="h2">Format &amp; post-processing</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Defaults applied to every new download (yt-dlp and ffmpeg do the work). 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}> <Card className={styles.card}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<ArrowSyncRegular className={styles.sectionIcon} /> <ArrowSyncRegular className={styles.sectionIcon} />
<Subtitle2>Software update</Subtitle2> <Subtitle2 as="h2">Software update</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
{appVersion {appVersion
+1 -1
View File
@@ -65,7 +65,7 @@ export function ScreenHeader({
return ( return (
<div className={styles.header}> <div className={styles.header}>
<div className={styles.titleBlock}> <div className={styles.titleBlock}>
<Subtitle2>{title}</Subtitle2> <Subtitle2 as="h1">{title}</Subtitle2>
{description && <Caption1 className={styles.description}>{description}</Caption1>} {description && <Caption1 className={styles.description}>{description}</Caption1>}
</div> </div>
{actions && <div className={styles.actions}>{actions}</div>} {actions && <div className={styles.actions}>{actions}</div>}