Fix L8/SR9/M20: list keys, jargon hints, ProgressBar a11y

L8: TerminalView lines now carry a stable auto-increment id (keyed by that
    instead of array index) so front-trimming at MAX_LOG_LINES doesn't cause
    React to reuse wrong DOM nodes. SettingsView error list keyed by id alone
    (not id+occurredAt, which was redundant since IDs are already unique).

SR9: DownloadOptionsForm -- rewrote 'Container for merged video' to a plain
     description; rewrote format-sorting hint to remove raw '-S string' /
     'yt-dlp syntax' jargon.

M20: All ProgressBars now carry aria-label -- QueueItem (downloading +
     paused rows, keyed on title/URL), DownloadsView overall summary bar,
     SettingsView app-update download bar.

typecheck + 242 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 14:09:36 -04:00
parent 4e3b141d6d
commit 67c0cd8bf4
6 changed files with 43 additions and 35 deletions
+3 -3
View File
@@ -279,7 +279,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
label contradicts the actual output format; `--audio-quality` is also meaningless for lossless.
- [x] **M19 — Sidebar-collapsed pref uses `localStorage`** while every other preference uses
electron-store — won't back up/restore and is lost in the portable build across machines.
- [ ] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
- [x] **M20 — ProgressBar / native `<select>` accessibility.** QueueItem/DownloadsView
`ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
`aria-label` (double announcement).
- [x] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for
@@ -390,7 +390,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
(~40px) Input/Buttons in DownloadBar.
- [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually
ambiguous.
- [ ] **L8 — Index/compound list keys.** TerminalView keys log lines by array index; Settings
- [x] **L8 — Index/compound list keys.** TerminalView keys log lines by array index; Settings
Diagnostics keys by `id + occurredAt` while every other list keys by `id` alone.
- [ ] **L9 — Thumbnail box sizes not shared.** Four hand-tuned 16:9 boxes (120×68, 108×64, 72×44,
60×34) with no shared aspect/size constant.
@@ -1393,7 +1393,7 @@ the end so the picture is complete. **Verified-clean first:** card/label/button
### Wording / jargon polish
- [ ] **SR9 — Dev jargon in user-facing option hints** (extends M37): "mux them into the video," "Tiebreaker,
- [x] **SR9 — Dev jargon in user-facing option hints** (extends M37): "mux them into the video," "Tiebreaker,
not a hard filter," "Endcards / credits" ("End cards"), "Proof-of-Origin token," "`--extractor-args`."
Reads like engineer notes. **Fix:** plain-language hints.
- [ ] **SR10 — Inconsistent product self-description / casing** surfaced to users: "yt-dlp frontend" caption
@@ -1,4 +1,4 @@
import {
import {
Field,
Input,
Switch,
@@ -115,7 +115,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/>
</Field>
<Field label="Video container" hint="Container for merged video.">
<Field label="Video container" hint="The file format used when video and audio are combined into one file.">
<Select
aria-label="Video container"
value={value.videoContainer}
@@ -125,7 +125,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field>
<Field
label="Preferred codec"
hint="A preference when several formats match — not a strict filter."
hint="A preference when several formats match -- not a strict filter."
>
<Select
aria-label="Preferred video codec"
@@ -138,7 +138,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field
label="Format sorting (advanced)"
hint="Raw yt-dlp -S string to rank formats by priority, e.g. res:1080,vcodec:av01,size. Overrides the preferred-codec tiebreaker. Leave empty unless you know yt-dlp's -S syntax."
hint="Custom expression to rank available formats, e.g. res:1080,vcodec:av01,size. Leave blank to use the Preferred codec setting above. See yt-dlp docs for the full syntax."
>
<Input
value={value.formatSort}
@@ -201,7 +201,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
validationMessage={
value.sponsorBlockCategories.length === 0
? 'No categories selected — no segments will be skipped.'
? 'No categories selected -- no segments will be skipped.'
: undefined
}
>
@@ -290,7 +290,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field
label="Sidecar files"
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
hint="Write separate metadata/poster/description files next to each download -- handy for Jellyfin, Plex or Kodi libraries."
>
<div className={styles.subGroup}>
<Checkbox
@@ -1,4 +1,4 @@
import {
import {
Subtitle2,
Body1,
Caption1,
@@ -82,7 +82,7 @@ export function DownloadsView(): React.JSX.Element {
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
)
// The header count is the live queue (work not yet finished), not the whole
// list — completed/canceled/error rows linger until "Clear finished" (L11).
// list -- completed/canceled/error rows linger until "Clear finished" (L11).
const queueCount = items.filter(
(i) =>
i.status === 'downloading' ||
@@ -99,7 +99,12 @@ export function DownloadsView(): React.JSX.Element {
{summary.active && (
<div className={styles.summary}>
<ProgressBar className={styles.summaryBar} value={summary.progress} thickness="large" />
<ProgressBar
className={styles.summaryBar}
value={summary.progress}
thickness="large"
aria-label="Overall download progress"
/>
<Caption1 className={styles.summaryText}>
{summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''}
+10 -4
View File
@@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo } from 'react'
import {
Text,
Caption1,
@@ -120,7 +120,7 @@ export const QueueItem = memo(function QueueItem({
const active = item.status === 'downloading' || item.status === 'queued'
// W7: Delete on a focused row removes it — cancelling first if it's still running,
// W7: Delete on a focused row removes it -- cancelling first if it's still running,
// since an in-flight download can't just be dropped from the list. Only when the
// row itself holds focus, so Delete on one of its action buttons is unaffected.
function onKeyDown(e: React.KeyboardEvent): void {
@@ -168,7 +168,7 @@ export const QueueItem = memo(function QueueItem({
<Text className={styles.title}>{item.title}</Text>
<StatusChip status={item.status} />
{item.incognito && (
<Hint label="Private — not saved to history" placement="top" align="start">
<Hint label="Private -- not saved to history" placement="top" align="start">
<EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
</Hint>
)}
@@ -185,6 +185,7 @@ export const QueueItem = memo(function QueueItem({
className={styles.progressBar}
value={item.finishing || item.sizeUnknown ? undefined : item.progress}
thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/>
<Caption1 className={styles.stats}>
{item.finishing ? (
@@ -209,7 +210,12 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'paused' && (
<div className={styles.progressRow}>
<ProgressBar className={styles.progressBar} value={item.progress} thickness="large" />
<ProgressBar
className={styles.progressBar}
value={item.progress}
thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/>
<Caption1 className={styles.stats}>Paused • {pct(item.progress)}</Caption1>
</div>
)}
+2 -2
View File
@@ -1000,7 +1000,7 @@ export function SettingsView(): React.JSX.Element {
) : (
<div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => (
<div key={e.id + String(e.occurredAt)} className={styles.errorRow}>
<div key={e.id} className={styles.errorRow}>
<div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}>
@@ -1086,7 +1086,7 @@ export function SettingsView(): React.JSX.Element {
manually.
</Caption1>
)}
{appDownloading && <ProgressBar value={appFraction} />}
{appDownloading && <ProgressBar value={appFraction} aria-label="App update download progress" />}
</>
)}
+14 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Button,
Textarea,
@@ -16,9 +16,12 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line {
id: number
text: string
kind: LineKind
}
let lineSeq = 0
const mkLine = (text: string, kind: LineKind): Line => ({ id: ++lineSeq, text, kind })
// Verbose yt-dlp runs (--verbose, -F on a large channel) can emit thousands of
// lines in seconds. Cap the visible log to prevent unbounded React state growth
@@ -62,7 +65,7 @@ const useStyles = makeStyles({
/**
* Built-in yt-dlp terminal (Phase N): type raw yt-dlp args, run the bundled
* binary, and watch its output stream. Gated on the customCommandEnabled consent
* flag (enforced again in main — see src/main/terminal.ts).
* flag (enforced again in main -- see src/main/terminal.ts).
*/
export function TerminalView(): React.JSX.Element {
const styles = useStyles()
@@ -81,20 +84,14 @@ export function TerminalView(): React.JSX.Element {
window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return
if (ev.type === 'output') {
setLines((ls) =>
[...ls, { text: ev.line, kind: ev.stream } as Line].slice(-MAX_LOG_LINES)
)
setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
} else if (ev.type === 'error') {
setLines((ls) =>
[...ls, { text: ev.error, kind: 'stderr' as const }].slice(-MAX_LOG_LINES)
)
setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
setRunning(false)
runId.current = null
} else {
setLines((ls) =>
[...ls, { text: `— exited (code ${ev.code ?? '?'})`, kind: 'sys' as const }].slice(
-MAX_LOG_LINES
)
[...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
)
setRunning(false)
runId.current = null
@@ -113,19 +110,19 @@ export function TerminalView(): React.JSX.Element {
if (!a || running) return
const id = newId('t')
runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }])
setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true)
window.api
.runTerminal(id, a)
.then((res) => {
if (!res.ok) {
setLines((ls) => [...ls, { text: res.error ?? 'Failed to start.', kind: 'stderr' }])
setLines((ls) => [...ls, mkLine(res.error ?? 'Failed to start.', 'stderr')])
setRunning(false)
runId.current = null
}
})
.catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }])
setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false)
runId.current = null
})
@@ -158,7 +155,7 @@ export function TerminalView(): React.JSX.Element {
{!customCommandEnabled && (
<Body1 className={styles.gate}>
The terminal is part of custom commands. Turn on “Run custom commands” in Settings →
The terminal is part of custom commands. Turn on "Run custom commands" in Settings →
Custom commands to use it.
</Body1>
)}
@@ -210,8 +207,8 @@ export function TerminalView(): React.JSX.Element {
{lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span>
) : (
lines.map((l, i) => (
<div key={i} className={lineClass(l.kind)}>
lines.map((l) => (
<div key={l.id} className={lineClass(l.kind)}>
{l.text}
</div>
))