feat(audit): Batch 12 — a11y & discoverability

- L118: decouple URL-field focus. The DownloadBar registers a focuser (its own
  input ref) in the nav store; App calls useNav.getState().focusUrlField() on
  launch / "New download" instead of document.getElementById('aerofetch-url').
  The hardcoded id is removed — App no longer reaches into DownloadBar's markup.
- L155: the accent swatches are now a real role="radiogroup" with aria-checked
  role="radio" swatches, roving tabindex, ←/→/↑/↓/Home/End arrow-key navigation,
  and the shared focus ring — matching the app's SegmentedControl radio pattern.
  Redundant "(selected)" aria-label text dropped (aria-checked conveys it).
- UX21: keyboard-shortcut discoverability — an Onboarding "Press Ctrl+K anytime"
  tip, and the command palette's Settings action surfaces its "Ctrl ," shortcut.
- UX22 / L67: onboarding is revisitable — a "Show welcome tips again" button in
  Settings → About re-opens the welcome screen.

Verified & documented (no code needed): UX17 (Fetch/Paste already have aria-label
+ focus-reachable Hint tooltips — a11y met; a permanent visible label is a compact-
layout choice), UI24 (context menus "none by design" — row actions are all visible
inline buttons + keyboard, and Fluent's Menu is portal-based, which the app avoids
for the dev-GPU flicker reason).

Verified: typecheck (node+web) + 279 tests + eslint + production build green;
touched files prettier-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:15:10 -04:00
parent 661a4e7572
commit d131ee67d5
7 changed files with 148 additions and 35 deletions
+9 -8
View File
@@ -114,13 +114,11 @@ function App(): React.JSX.Element {
}, [])
// UX16: focus the URL field on launch so the user can paste + type immediately
// (Downloads is the default tab). Double-rAF waits for paint, matching the command
// palette's "New download" focus.
// (Downloads is the default tab). Double-rAF waits for paint + the DownloadBar to
// register its focuser (L118), matching the command palette's "New download" focus.
useEffect(() => {
if (useNav.getState().tab !== 'downloads') return
requestAnimationFrame(() =>
requestAnimationFrame(() => document.getElementById('aerofetch-url')?.focus())
)
requestAnimationFrame(() => requestAnimationFrame(() => useNav.getState().focusUrlField()))
}, [])
// Promote scheduled ('saved' + a due scheduledFor) items when their time arrives.
@@ -165,7 +163,9 @@ function App(): React.JSX.Element {
{
id: 'go-settings',
label: 'Go to Settings',
hint: 'Navigate',
// Surface the real shortcut here so the palette doubles as shortcut
// discovery (UX21).
hint: 'Ctrl ,',
run: () => setTab('settings')
},
{
@@ -174,9 +174,10 @@ function App(): React.JSX.Element {
hint: 'Focus URL',
run: () => {
setTab('downloads')
// Double-rAF waits for React's re-render + the browser's paint before focusing.
// Double-rAF waits for React's re-render + paint (so the DownloadBar has
// mounted and registered its focuser) before requesting focus (L118).
requestAnimationFrame(() =>
requestAnimationFrame(() => document.getElementById('aerofetch-url')?.focus())
requestAnimationFrame(() => useNav.getState().focusUrlField())
)
}
},
+11 -1
View File
@@ -24,7 +24,9 @@ import {
SettingsRegular,
WindowConsoleRegular
} from '@fluentui/react-icons'
import { useEffect, useRef } from 'react'
import { type MediaKind } from '../store/downloads'
import { useNav } from '../store/nav'
import { QUALITY_OPTIONS } from '../qualityOptions'
import { Select } from './Select'
import { Hint } from './Hint'
@@ -42,6 +44,14 @@ export function DownloadBar(): React.JSX.Element {
const styles = useDownloadBarStyles()
const text = useTextStyles()
const bar = useDownloadBar()
// Register how to focus the URL field so App can request focus (launch / "New
// download") without a hardcoded getElementById into this markup (L118).
const urlInputRef = useRef<HTMLInputElement>(null)
const registerUrlFocuser = useNav((s) => s.registerUrlFocuser)
useEffect(() => {
registerUrlFocuser(() => urlInputRef.current?.focus())
return () => registerUrlFocuser(null)
}, [registerUrlFocuser])
const {
url,
kind,
@@ -82,7 +92,7 @@ export function DownloadBar(): React.JSX.Element {
<div className={styles.urlRow}>
<Input
className={styles.url}
input={{ id: 'aerofetch-url', 'aria-label': 'Video or playlist URL' }}
input={{ ref: urlInputRef, 'aria-label': 'Video or playlist URL' }}
value={url}
onChange={(_, d) => bar.onUrlChange(d.value)}
onKeyDown={(e) => {
+6 -1
View File
@@ -18,7 +18,8 @@ import {
RocketRegular,
FolderRegular,
MusicNote2Regular,
VideoClipRegular
VideoClipRegular,
KeyboardRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
import { SPACE, ICON } from './ui/tokens'
@@ -128,6 +129,10 @@ const TIPS: { icon: React.JSX.Element; text: string }[] = [
{
icon: <OptionsRegular />,
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
},
{
icon: <KeyboardRegular />,
text: 'Press Ctrl+K anytime to jump to any screen or start a new download.'
}
]
@@ -100,6 +100,12 @@ export function AboutCard(): React.JSX.Element {
AeroFetch is a generic frontend for yt-dlp. It bundles ffmpeg and manages its own copy of
yt-dlp, keeping it up to date automatically.
</Caption1>
<div className={styles.folderRow}>
{/* Re-show the first-run welcome/tips screen on demand (UX22). */}
<Button size="small" onClick={() => update({ hasCompletedOnboarding: false })}>
Show welcome tips again
</Button>
</div>
<Field
label="Keep yt-dlp updated automatically"
@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Field, Button, Card, Subtitle2, Caption1, mergeClasses } from '@fluentui/react-components'
import { PaintBucketRegular, AccessibilityRegular } from '@fluentui/react-icons'
import { type ThemeMode, type AccentColor } from '@shared/ipc'
@@ -5,6 +6,7 @@ import { useSettings } from '../../store/settings'
import { useSystemTheme } from '../../store/systemTheme'
import { Select } from '../Select'
import { ACCENT_OPTIONS } from '../../theme'
import { useFocusStyles } from '../ui/focusRing'
import { useSettingsStyles } from './settingsStyles'
const THEME_MODE_OPTIONS = [
@@ -15,11 +17,31 @@ const THEME_MODE_OPTIONS = [
export function AppearanceCard(): React.JSX.Element {
const styles = useSettingsStyles()
const focus = useFocusStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const update = useSettings((s) => s.update)
// Roving-tabindex arrow-key navigation for the accent radiogroup (L155), so the
// single-select swatches behave like the Theme radio pattern rather than a row
// of plain buttons.
const swatchRefs = useRef<(HTMLButtonElement | null)[]>([])
function onSwatchKey(e: React.KeyboardEvent, index: number): void {
const n = ACCENT_OPTIONS.length
let next = -1
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % n
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (index - 1 + n) % n
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = n - 1
else return
const opt = ACCENT_OPTIONS[next]
if (!opt) return
e.preventDefault()
update({ accentColor: opt.value as AccentColor })
swatchRefs.current[next]?.focus()
}
return (
<Card className={styles.card}>
<div className={styles.sectionHeader}>
@@ -36,21 +58,34 @@ export function AppearanceCard(): React.JSX.Element {
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-label={`${opt.label}${accentColor === opt.value ? ' (selected)' : ''}`}
title={opt.label}
/>
))}
<div className={styles.swatchRow} role="radiogroup" aria-label="Accent color">
{ACCENT_OPTIONS.map((opt, i) => {
const selected = accentColor === opt.value
return (
<button
key={opt.value}
ref={(el) => {
swatchRefs.current[i] = el
}}
type="button"
role="radio"
aria-checked={selected}
// Roving tabindex: only the selected swatch is a tab stop; arrows
// move within the group (L155).
tabIndex={selected ? 0 : -1}
className={mergeClasses(
styles.swatch,
selected && styles.swatchActive,
focus.focusRing
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
onKeyDown={(e) => onSwatchKey(e, i)}
aria-label={opt.label}
title={opt.label}
/>
)
})}
</div>
</Field>
+13 -1
View File
@@ -17,6 +17,15 @@ interface NavState {
openLibraryWith: (url: string) => void
/** Read and clear the staged Library URL (returns null if none is pending). */
consumeLibraryUrl: () => string | null
/**
* The DownloadBar registers a focuser for its URL field here (L118), so App can
* ask for focus (on launch / "New download") without reaching into the DOM by a
* hardcoded element id. The DownloadBar owns *how* it focuses (its own ref);
* callers just call `focusUrlField()`.
*/
urlFocuser: (() => void) | null
registerUrlFocuser: (fn: (() => void) | null) => void
focusUrlField: () => void
}
export const useNav = create<NavState>((set, get) => ({
@@ -28,5 +37,8 @@ export const useNav = create<NavState>((set, get) => ({
const url = get().pendingLibraryUrl
if (url !== null) set({ pendingLibraryUrl: null })
return url
}
},
urlFocuser: null,
registerUrlFocuser: (fn) => set({ urlFocuser: fn }),
focusUrlField: () => get().urlFocuser?.()
}))