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. 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 - [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. 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 `ProgressBar`s have no accessible name; several `Select`s carry both a `<Field label>` and an
`aria-label` (double announcement). `aria-label` (double announcement).
- [x] **M21 — `--audio-quality` always emitted.** `buildArgs` passes `--audio-quality` even for - [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. (~40px) Input/Buttons in DownloadBar.
- [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually - [x] **L7 — `canceled` and `paused` share the 'warning' badge color** (QueueItem) — visually
ambiguous. 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. 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, - [ ] **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. 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 ### 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`." not a hard filter," "Endcards / credits" ("End cards"), "Proof-of-Origin token," "`--extractor-args`."
Reads like engineer notes. **Fix:** plain-language hints. Reads like engineer notes. **Fix:** plain-language hints.
- [ ] **SR10 — Inconsistent product self-description / casing** surfaced to users: "yt-dlp frontend" caption - [ ] **SR10 — Inconsistent product self-description / casing** surfaced to users: "yt-dlp frontend" caption
@@ -1,4 +1,4 @@
import { import {
Field, Field,
Input, Input,
Switch, Switch,
@@ -115,7 +115,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
onChange={(v) => setOpt('audioFormat', v as AudioFormat)} onChange={(v) => setOpt('audioFormat', v as AudioFormat)}
/> />
</Field> </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 <Select
aria-label="Video container" aria-label="Video container"
value={value.videoContainer} value={value.videoContainer}
@@ -125,7 +125,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
</Field> </Field>
<Field <Field
label="Preferred codec" 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 <Select
aria-label="Preferred video codec" aria-label="Preferred video codec"
@@ -138,7 +138,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field <Field
label="Format sorting (advanced)" 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 <Input
value={value.formatSort} value={value.formatSort}
@@ -201,7 +201,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'} validationState={value.sponsorBlockCategories.length === 0 ? 'warning' : 'none'}
validationMessage={ validationMessage={
value.sponsorBlockCategories.length === 0 value.sponsorBlockCategories.length === 0
? 'No categories selected no segments will be skipped.' ? 'No categories selected -- no segments will be skipped.'
: undefined : undefined
} }
> >
@@ -290,7 +290,7 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
<Field <Field
label="Sidecar files" 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}> <div className={styles.subGroup}>
<Checkbox <Checkbox
@@ -1,4 +1,4 @@
import { import {
Subtitle2, Subtitle2,
Body1, Body1,
Caption1, Caption1,
@@ -82,7 +82,7 @@ export function DownloadsView(): React.JSX.Element {
(i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled' (i) => i.status === 'completed' || i.status === 'error' || i.status === 'canceled'
) )
// The header count is the live queue (work not yet finished), not the whole // 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( const queueCount = items.filter(
(i) => (i) =>
i.status === 'downloading' || i.status === 'downloading' ||
@@ -99,7 +99,12 @@ export function DownloadsView(): React.JSX.Element {
{summary.active && ( {summary.active && (
<div className={styles.summary}> <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}> <Caption1 className={styles.summaryText}>
{summary.downloading} downloading {summary.downloading} downloading
{summary.queued ? `, ${summary.queued} queued` : ''} {summary.queued ? `, ${summary.queued} queued` : ''}
+10 -4
View File
@@ -1,4 +1,4 @@
import { memo } from 'react' import { memo } from 'react'
import { import {
Text, Text,
Caption1, Caption1,
@@ -120,7 +120,7 @@ export const QueueItem = memo(function QueueItem({
const active = item.status === 'downloading' || item.status === 'queued' 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 // 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. // row itself holds focus, so Delete on one of its action buttons is unaffected.
function onKeyDown(e: React.KeyboardEvent): void { function onKeyDown(e: React.KeyboardEvent): void {
@@ -168,7 +168,7 @@ export const QueueItem = memo(function QueueItem({
<Text className={styles.title}>{item.title}</Text> <Text className={styles.title}>{item.title}</Text>
<StatusChip status={item.status} /> <StatusChip status={item.status} />
{item.incognito && ( {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 }} /> <EyeOffRegular fontSize={14} style={{ color: tokens.colorNeutralForeground3 }} />
</Hint> </Hint>
)} )}
@@ -185,6 +185,7 @@ export const QueueItem = memo(function QueueItem({
className={styles.progressBar} className={styles.progressBar}
value={item.finishing || item.sizeUnknown ? undefined : item.progress} value={item.finishing || item.sizeUnknown ? undefined : item.progress}
thickness="large" thickness="large"
aria-label={`Download progress for ${item.title ?? item.url}`}
/> />
<Caption1 className={styles.stats}> <Caption1 className={styles.stats}>
{item.finishing ? ( {item.finishing ? (
@@ -209,7 +210,12 @@ export const QueueItem = memo(function QueueItem({
{item.status === 'paused' && ( {item.status === 'paused' && (
<div className={styles.progressRow}> <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> <Caption1 className={styles.stats}>Paused {pct(item.progress)}</Caption1>
</div> </div>
)} )}
+2 -2
View File
@@ -1000,7 +1000,7 @@ export function SettingsView(): React.JSX.Element {
) : ( ) : (
<div className={styles.errorList}> <div className={styles.errorList}>
{errorEntries.slice(0, 20).map((e) => ( {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}> <div className={styles.errorRowHeader}>
<Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text> <Text className={styles.errorRowTitle}>{e.title ?? e.url}</Text>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
@@ -1086,7 +1086,7 @@ export function SettingsView(): React.JSX.Element {
manually. manually.
</Caption1> </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 { import {
Button, Button,
Textarea, Textarea,
@@ -16,9 +16,12 @@ import { ScreenHeader, useScreenStyles } from './ui/Screen'
type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys' type LineKind = 'stdout' | 'stderr' | 'cmd' | 'sys'
interface Line { interface Line {
id: number
text: string text: string
kind: LineKind 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 // 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 // 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 * 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 * 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 { export function TerminalView(): React.JSX.Element {
const styles = useStyles() const styles = useStyles()
@@ -81,20 +84,14 @@ export function TerminalView(): React.JSX.Element {
window.api.onTerminalOutput((ev) => { window.api.onTerminalOutput((ev) => {
if (ev.id !== runId.current) return if (ev.id !== runId.current) return
if (ev.type === 'output') { if (ev.type === 'output') {
setLines((ls) => setLines((ls) => [...ls, mkLine(ev.line, ev.stream)].slice(-MAX_LOG_LINES))
[...ls, { text: ev.line, kind: ev.stream } as Line].slice(-MAX_LOG_LINES)
)
} else if (ev.type === 'error') { } else if (ev.type === 'error') {
setLines((ls) => setLines((ls) => [...ls, mkLine(ev.error, 'stderr')].slice(-MAX_LOG_LINES))
[...ls, { text: ev.error, kind: 'stderr' as const }].slice(-MAX_LOG_LINES)
)
setRunning(false) setRunning(false)
runId.current = null runId.current = null
} else { } else {
setLines((ls) => setLines((ls) =>
[...ls, { text: ` exited (code ${ev.code ?? '?'})`, kind: 'sys' as const }].slice( [...ls, mkLine(`-- exited (code ${ev.code ?? '?'})`, 'sys')].slice(-MAX_LOG_LINES)
-MAX_LOG_LINES
)
) )
setRunning(false) setRunning(false)
runId.current = null runId.current = null
@@ -113,19 +110,19 @@ export function TerminalView(): React.JSX.Element {
if (!a || running) return if (!a || running) return
const id = newId('t') const id = newId('t')
runId.current = id runId.current = id
setLines((ls) => [...ls, { text: `> yt-dlp ${a}`, kind: 'cmd' }]) setLines((ls) => [...ls, mkLine(`> yt-dlp ${a}`, 'cmd')])
setRunning(true) setRunning(true)
window.api window.api
.runTerminal(id, a) .runTerminal(id, a)
.then((res) => { .then((res) => {
if (!res.ok) { 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) setRunning(false)
runId.current = null runId.current = null
} }
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
setLines((ls) => [...ls, { text: String(e), kind: 'stderr' }]) setLines((ls) => [...ls, mkLine(String(e), 'stderr')])
setRunning(false) setRunning(false)
runId.current = null runId.current = null
}) })
@@ -158,7 +155,7 @@ export function TerminalView(): React.JSX.Element {
{!customCommandEnabled && ( {!customCommandEnabled && (
<Body1 className={styles.gate}> <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. Custom commands to use it.
</Body1> </Body1>
)} )}
@@ -210,8 +207,8 @@ export function TerminalView(): React.JSX.Element {
{lines.length === 0 ? ( {lines.length === 0 ? (
<span className={styles.empty}>Output will appear here.</span> <span className={styles.empty}>Output will appear here.</span>
) : ( ) : (
lines.map((l, i) => ( lines.map((l) => (
<div key={i} className={lineClass(l.kind)}> <div key={l.id} className={lineClass(l.kind)}>
{l.text} {l.text}
</div> </div>
)) ))