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
@@ -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>
))