Every <svg> in index.html is a decorative graphic that visually reinforces an adjacent text label — the logo SVG sits next to "Twitch VOD Manager", the 7 nav-item SVGs each pair with their tab name, the refresh button SVG sits next to "Aktualisieren", and the 3 empty-state SVGs sit above a heading + paragraph. The graphics carry no information that isn't already in the surrounding text.
Without aria-hidden, screen readers either announce these as "image" / "graphic" (depending on implementation) or silently traverse them — neither adds value, and the former adds noise to nav navigation in particular.
Added aria-hidden="true" to all 12 SVG elements in static HTML. Screen readers now skip them and read just the labelled text, which is the desired UX for decorative icons.
(The two existing aria-hidden="true" attributes on the flag-icon spans in the language picker were already correct and are not affected.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three places in the renderer use a literal middle-dot character (· / ·) as a visual separator between two label components:
- Sidebar streamer-section counter: "12 · 3 live"
- Stats top-streamers row sub-label: "streamerName · 5 files"
- Stats size-bucket row sub-label: "count · size"
Screen readers either announce "middle dot" verbatim or skip it depending on the implementation — neither is useful information. Wrapped each dot in <span aria-hidden="true"> so the assistive-tech read-out becomes "12 3 live" / "streamerName 5 files" / "count size" — the meaningful parts only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Verfugbare Variablen / Available Variables" reference table inside the Template Guide modal (a 3-column data table mapping placeholders to descriptions and example output) was missing two standard data-table a11y hooks:
- The three <th> cells had no scope attribute. The implicit <th>-inside-<thead> column scope works in modern screen readers but explicit scope="col" is the recommended pattern and what a11y audit tooling looks for.
- The <table> had no programmatic relationship to its own heading (the <h3 id="templateGuideVarsTitle"> directly above). Added aria-labelledby="templateGuideVarsTitle" so screen readers announce the table's purpose ("Verfugbare Variablen, Tabelle") when the user enters it.
Pairs nicely with the storage-stats table fix in 4.6.107 — both data tables in the app now have explicit column scopes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Storage card's per-streamer stats table was built without scope attributes on its <th> cells and the last column (the "Open" action column) had an empty header — screen readers couldn't tell which column a data cell belonged to once you tabbed into the rows, and the actions column had no announced name at all.
Fixes:
- All 6 column headers get scope="col" — explicit signal to screen readers that each <th> is a column header (the implicit <th>-inside-<thead> semantics work, but explicit scope is best practice for data tables and the de-facto standard for accessibility tooling validation)
- The empty actions header gets aria-label set from a new storageColumnActionsAria locale key (DE: "Aktionen", EN: "Actions") so screen readers announce "Aktionen, Spaltenkopf" for that column instead of skipping over an unnamed header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The app had no @media (prefers-reduced-motion: reduce) rule, meaning users with the OS-level "Reduce motion" setting enabled still got the full animation set: the empty-state floating SVG (4s infinite), the btn-icon-spin on Refresh hover, the vod-bulk-bar slide-in, the storyboard fade-in, and the ~6 transition: all declarations scattered across button hover states.
For users with vestibular disorders this is real discomfort, not aesthetic preference. Windows 11 and macOS both expose the setting via Settings > Accessibility, and the media query is the standard way to honour it from CSS.
Added the conventional reduce-motion block at the bottom of styles.css:
- animation-duration: 0.01ms (effectively instant)
- animation-iteration-count: 1 (kills infinite loops)
- transition-duration: 0.01ms (state changes are immediate)
- scroll-behavior: auto (kills smooth-scroll)
All hover/state changes still happen — they just snap rather than animate. No feature is lost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each VOD card renders 2-3 action buttons in its footer (.vod-btn — Trim, Queue, etc.). They had :hover states but no :focus-visible, so keyboard users tabbing through the VOD grid would land on each button without any visual focus indicator.
Added:
- .vod-btn:focus-visible — purple ring (covers the secondary variant which has a translucent grey bg)
- .vod-btn.primary:focus-visible — inner-white + outer-purple double-ring so the indicator stays visible against the button's own purple background (same pattern used for .btn-pill.primary and .btn-primary in 4.6.81/82)
Continues the per-area focus-visible pass — VOD-grid keyboard nav (already covered: card itself, checkbox; now: action buttons inside the card) is the final big interactive surface to get coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more interactive buttons that had hover + active states but no keyboard focus indicator:
- .modal-close (the X-close button used by all 5 modals — update, clip-cutter, events viewer, chat viewer, template guide). Red-toned ring matching the hover colour family.
- .queue-retry-btn (per-item retry button on failed queue items). Purple ring matching the hover state's accent-purple feedback.
Continues the focus-visible pass started in 4.6.81/82/103. Closes the remaining shell-and-modal buttons that keyboard users could tab to without seeing where they were.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two language-picker buttons (Deutsch / English) had :hover and an
.active state but no :focus-visible — keyboard users tabbing into the
group couldn't see which button was focused unless it also happened to
be the active one. And even then, the active state uses a 1px soft
shadow which is easy to mistake for the hover border tweak.
Added two rules:
- .lang-option:focus-visible — purple-accent ring matching the rest of
the app's focus-visible convention
- .lang-option.active:focus-visible — combines the pressed-state border
with the thicker 2px focus halo so focus and pressed state are both
visible when they coincide
Continues the focus-visible pass started in 4.6.81 (btn-primary/secondary/
pill/close) and 4.6.82 (queue + top-bar + add-streamer buttons).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every renderer module that persists state was wrapping its localStorage.getItem/setItem/removeItem call in the same try/catch idiom — handling private-browsing quirks and other sandbox contexts where storage isn't writable. Three identical patterns repeated nine times across renderer-streamers (filter / sort / hide-downloaded state), renderer-updates (skipped-update version), and renderer.ts (active-tab persistence).
Introduced three helpers in renderer-shared.ts:
- safeLocalStorageGet(key, fallback = '') — wraps getItem with the try/catch + fallback
- safeLocalStorageSet(key, value) — wraps setItem
- safeLocalStorageRemove(key) — wraps removeItem (needed for clearSkippedUpdateVersion which actually deletes the entry rather than blanking it)
Refactored 9 callsites. Reduces the noise:before:
try { return localStorage.getItem(KEY) ?? ''; } catch { return ''; }
try { localStorage.setItem(KEY, value); } catch { /* localStorage may be unavailable */ }
after:
return safeLocalStorageGet(KEY);
safeLocalStorageSet(KEY, value);
Left the VOD scroll-positions persistence in renderer-streamers untouched — its surrounding try/catch wraps JSON.parse/stringify logic that doesn't fit the simple helper signature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The language picker is a pair of <button aria-pressed=...> toggles (Deutsch / English) sitting inside a div, with a separate <label id="languageLabel">Sprache</label> directly above. Two issues:
- The <label> has no for= attribute because the actual control is a custom button-pair, not a single input. So the label was just floating text — present visually but not programmatically connected to the buttons it describes.
- The two buttons were a flat list with no group container, so a screen reader navigating button-by-button announced "Deutsch, pressed" / "English, not pressed" without any context that these are language alternatives.
Added role="group" and aria-labelledby="languageLabel" to the language-picker div. Screen readers now announce "Sprache, group, Deutsch button pressed" — the floating label becomes the group's accessible name and the relationship is exposed via the accessibility tree.
Kept the aria-pressed toggle pattern (rather than rewiring to role="radiogroup"+role="radio") because the JS state plumbing already aligns with the toggle semantics and the group label is enough to clarify the picker's intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .clip-template-lint CSS rule was documented as a no-op alias kept in case any external reference still used it (deprecated when the shared .template-lint class with .ok/.warn modifiers took over). Grep confirms zero external references — both .ts files and index.html only mention the new .template-lint class. Deleted the rule + its 5-line comment.
Same edit moves the clipTemplateGuideBtn's margin-top:8px from inline to a .clip-template-wrap .btn-secondary descendant rule. One inline style attribute gone, and the spacing now lives next to the .clip-template-wrap definition where future readers will look for it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The stats card header (Archiv-Statistik) had an inline-styled inner div grouping the "last scanned" label with the Aktualisieren button — display:flex; gap:8px; align-items:center;. Extracted that pattern to .section-header-actions, which has a real semantic role: it's the right-side action cluster inside a section-header that needs to stay together as a single flex item so the parent's justify-content:space-between can pin it to the right.
Same edit drops the inline style="margin-top:0;" from <h3 id="archiveTitle"> — the global * { margin: 0 } reset already zeroes it, so the inline declaration was a literal no-op that just added noise to the markup. Worth removing as it might mislead future readers into thinking the override does something.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more inline-styled secondary paragraphs converted to the shared .card-intro class:
- mergeDesc (Merge tab's intro paragraph): was color/margin-bottom:15px inline
- versionInfo (Updates card's "Version: v4.1.13" line): was color/margin-bottom:10px inline
Both sit below an h3 inside a settings-card — the same structural role as the 7 other .card-intro paragraphs. Converting unifies font-size (was browser-default ~14-16px, now the consistent 13px secondary-text size) and margin rhythm (12px) with the rest of the app's card intros. Visual delta is sub-perceptible (a few px in margin, ~1-3px in font-size) but the markup now reads its intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both template-lint usages (clip-cutter modal at index.html:105 and Settings filename-template card at index.html:657) had different inline margin-top values — 4px vs 6px. The visual difference is essentially imperceptible but it's a pointless divergence: the same class, same context (a lint badge directly below a template input), spaced differently for no design reason.
Hoisted margin-top:6px into the .template-lint base class. Both usages now drop their inline override and pick up the same rhythm. Clip-cutter's lint shifts 2px down — visually identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more inline-styled blocks moved into CSS classes:
- The Clips Info card had max-width:600px; margin:20px auto inline on the settings-card itself, plus color/line-height/white-space:pre-line inline on the info paragraph. Now .settings-card.centered (modifier for narrow-centred cards) and .info-text (multi-line description text with preserved authored line breaks).
- The Stats Summary KPI grid carried a 3-property grid declaration inline (display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:12px). The layout is unique to this one container, but lifting it to .stats-summary-grid makes the markup self-documenting and matches the rest of the .stats-* class family.
Net: 3 inline style attributes gone, 3 new CSS rules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related size-modifier patterns extracted:
1. .form-stack.size-sm (min-width:120px) and .form-stack.size-md (min-width:160px) — the Auto-Cleanup 3-up row carried three near-identical inline min-width declarations (120 / 160 / 160). They control where the row breaks to a new line on narrow widths, so the breakpoint metadata is logically a layout-modifier-class concern, not inline-style markup.
2. .input-narrow (width:90px) — the two Auto-VOD inputs (Poll-Intervall, Max. Alter) had their width inline because the values are 2-3 digits and a full-width input would look odd. Encoded once as a class, applied twice.
Net: 5 inline style attributes gone, 3 new CSS rules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Twitch-API card's help-text paragraph contains one inline link (apiHelpLink → dev.twitch.tv/console/apps). It carried color/text-decoration/cursor inline — but text-decoration:underline and cursor:pointer are the browser defaults for <a href=...>, so only the accent colour was actually doing work. Hoisted that to a single .card-intro a descendant rule — any future inline link inside a card intro paragraph picks up the accent colour without ceremony.
Same edit converts the archiveSearchSummary div from inline (font-size:12px; color:var(--text-secondary)) to the existing .form-sublabel class — exact match.
Two more inline style attributes gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The merge tab's empty state at index.html:400 was written as <div class="empty-state" style="padding: 40px 20px"> with two more inline-styled descendants (SVG opacity:0.3 and p margin-top:10px). A .merge-empty-state class with exactly those three properties has been sitting in styles.css since the original empty-state pass — the HTML just never picked it up. Switched to <div class="empty-state merge-empty-state"> + bare children. 3 inline styles gone.
Same pattern in the clip-cutter video preview placeholder at index.html:332-333 (svg opacity:0.3, p margin-top:10px). Added two descendant rules to the existing .video-preview .placeholder block so the styling lives in CSS. 2 more inline styles gone.
Net 5 inline style attributes gone, no visual change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the label-for pass from 4.6.79 + 4.6.83. The Settings tab, Twitch-API card, Discord card, Clips template guide, and the main cutter time-input row all had label elements sitting right next to their inputs/selects with no programmatic for=/id pairing — screen readers couldn't announce the label on focus, and clicking the label text didn't focus the control.
Fixed labels (label-id → target-input-id):
- themeLabel → themeSelect
- clientIdLabel → clientId
- clientSecretLabel → clientSecret
- storageLabel → downloadPath
- modeLabel → downloadMode
- partMinutesLabel → partMinutes
- parallelDownloadsLabel → parallelDownloads
- streamlinkQualityLabel → streamlinkQuality
- performanceModeLabel → performanceMode
- metadataCacheMinutesLabel → metadataCacheMinutes
- discordWebhookUrlLabel → discordWebhookUrl
- templateGuideTemplateLabel → templateGuideInput
- cutterStartLabel → startTime
- cutterEndLabel → endTime
Together with the prior passes (clip-cutter modal labels in 4.6.79, filename-template labels in 4.6.83), every plain label-above-input pair in static HTML now uses for=. Remaining unattached labels (languageLabel, vodHideDownloadedLabel) are intentional — the first targets a custom button-pair language picker, the second wraps its own checkbox via .inline-toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three icon-only buttons in the always-visible UI had no programmatic accessible name:
- the "+" add-streamer button (no id, no title, no aria-label — entire button was unnamed)
- the bulk-remove X button next to the Streamer section title (had a localized title, but title is not a reliable accessible-name source — many screen readers don't expose it)
- the VOD-filter clear X button above the VOD grid (same — title-only)
For all three the visible text is just a glyph ("+", "x"), so the accessible name has to come from somewhere else. Added:
- new locale key static.streamerAddAriaLabel ("Streamer hinzufuegen" / "Add streamer") and an id on the "+" button so renderer-texts can localize it
- new setAriaLabel(id, value) helper in renderer-texts mirroring the existing setTitle/setPlaceholder pattern
- aria-label calls for all three buttons, in addition to the existing title (so the tooltip stays for sighted users)
The two existing X buttons reuse their existing title strings as aria-label — no new translation work, just exposing the already-present text via the right ARIA attribute.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Settings cards (Debug-Log, Auto-VOD-Download, Runtime Metrics) carried the identical inline style attribute on their action-row form-row:
style="margin-bottom: 10px; align-items: center;"
Extracted into a single .form-row.aligned modifier — three duplicated inline declarations collapsed to one shared rule that pairs naturally with the existing .form-row.section-header pattern.
Two more inline styles converted to the existing .form-sublabel class:
- autoVodStatusLine: style="font-size:12px; color: var(--text-secondary);" — exact match for .form-sublabel
- storageSummary: same as above plus margin-bottom:8px (kept inline since margin is a one-off positioning concern, not part of the sublabel concept)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Auto-Cleanup subsection inside the Storage card carries three inline-styled patterns that are good candidates to live in the cascade rather than the markup:
- <hr> with border:none; border-top:1px solid var(--border-soft); margin:16px 0
- <h4> with margin:0 0 8px 0; font-size:14px
- a checkbox <label> wrapping the "Auto-Cleanup aktivieren" toggle with the same display:flex; align-items:center; gap:8px pattern that the existing .toggle-row class already encodes
Hoisted the hr + h4 styling to default .settings-card descendant rules — any future subsection divider/heading inside a settings card now follows the same rhythm without ceremony — and the label reuses .toggle-row (margin-bottom:8px stays inline because the next sibling is a form-row, not another toggle-row, so the .toggle-row + .toggle-row sibling rule doesn't apply).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four autoUpdater event handlers (checking-for-update, update-available, update-not-available, update-downloaded) were logging via raw console.log while the sibling 'error' handler already used appendDebugLog. Two consequences:
1. In a packaged build the user has no visible record of the update lifecycle — console.log streams to stderr which is invisible without DevTools. appendDebugLog writes to the timestamped debug log file that the user can inspect via the Live Debug-Log card in Settings.
2. Inconsistent — the existing 'auto-updater-error' tag in line 6479 was the only update-related event reaching the debug log. New tags ('auto-updater-checking', 'auto-updater-update-available', 'auto-updater-update-not-available', 'auto-updater-update-downloaded') give the full lifecycle a coherent grep-friendly prefix in the log.
The version info that was being printed inline ("Update available: 4.7.0") now lives in the structured details payload instead of a free-form message — easier to parse mechanically and matches the rest of the codebase's debug-log conventions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar's streamer section-title carried two inline flex containers:
- outer .section-title with display:flex; align-items:center; gap:6px; justify-content:space-between (so the bulk-remove X button can pin right while the title group stays left)
- inner span with display:flex; align-items:baseline; gap:8px (so the title text and the streamer counter share the same text-baseline rather than centring against each other)
.section-title is only used in this one spot, so the flex layout becomes part of the class definition (no risk of bleeding into other usages). The inner-span pattern moves to a dedicated .section-title-label class. Two inline style attributes gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Statistics tab's "Archiv-Statistik" card had its own bespoke inline-styled flex header (display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px) plus an inline margin:0 on the h3 and an inline-styled intro paragraph with a different margin rhythm than the other 7 card intros (margin-top:8px; margin-bottom:0).
Two small additions reuse the existing classes:
- flex-wrap:wrap added to .form-row.section-header so the System-Check / Storage / now-Stats headers all wrap gracefully on narrow widths instead of overflowing. Strict improvement everywhere it's already used.
- .card-intro.flush modifier (margin-top:8px; margin-bottom:0) for intros that sit flush against the next block rather than spaced from it.
Three more inline style attributes gone, and the stats header now shares the same width-collapse behavior as the other section-header rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two recurring inline-style patterns in Settings:
1. The "card title + right-aligned refresh button" header — used by the System-Check card and the Storage card. Both carried the identical 3-property inline style on the form-row plus an inline margin:0 on the h3 inside. Now expressed as .form-row.section-header (with a descendant h3 margin reset) — two cards, four inline attrs gone.
2. The "checkbox + Auto-Refresh label" pattern next to the debug-log and runtime-metrics action buttons — both were inline-styled with display:flex; align-items:center; gap:6px; font-size:13px; color:var(--text-secondary). The existing .inline-toggle class (already used by the VOD filter row's Hide-downloaded toggle) is the exact same pattern at 12px instead of 13px — close enough that unifying onto the shared class is the right move. 1px down beats keeping a third near-duplicate definition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven settings/feature cards (Archive, API help, Storage, Cleanup, Discord, Auto-VOD, Backup) each carry an intro paragraph with the exact same inline style attribute:
style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;"
That's the same 4-property declaration duplicated 7 times. Extracted into a single .card-intro class — HTML reads as semantic intent ("this is a card-intro paragraph") instead of repeated style soup, and any future tweak to intro-paragraph styling now lives in one place.
(statsIntro is similar but uses margin-top:8px; margin-bottom:0; — different rhythm because it sits above the stats grid, left as-is.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The filename-templates 3-pair grid in Settings carried four inline style attributes:
- the wrapper div's display:grid; gap:8px; margin-top:8px
- three labels with the same font-size:13px; color:var(--text-secondary), with the 2nd and 3rd also having margin-top:4px
Extracted into a single .filename-template-grid class block (with descendant label styling + a :not(:first-child) margin-top rule). HTML drops from a noisy block of inline styles to a clean labelled grid.
Same edit added for= associations on the three labels (vodTemplateLabel → vodFilenameTemplate, partsTemplateLabel → partsFilenameTemplate, defaultClipTemplateLabel → defaultClipFilenameTemplate) — they were sitting right next to their inputs with no programmatic association. Continues the label-for a11y work from 4.6.79; clicking a label now focuses its input and screen readers announce the label when the input is focused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the keyboard-focus pass from 4.6.81. Five more interactive controls in the always-visible UI surface had no visible focus indicator:
- .btn (the shared base for queue-action buttons — Start/Merge/Wiederholen/Leeren)
- .btn-start (green): inner-white + green-outer ring so it stays visible against the success-green background, and a red-outer variant when .downloading is active (button switches to error red)
- .btn-icon (top-bar Refresh)
- .header-search button (the purple "+" add-streamer button next to the streamer input — uses inner-white + accent-outer like .btn-pill.primary so the ring stays visible against the purple bg)
- .header-search input (was bare, now matches the .btn-icon focus convention — purple border + soft halo)
Tab order on the main shell is now fully keyboard-traversable with visible state at every step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keyboard-only users had no visible focus indicator on five widely-used button classes:
- .btn-primary (main action button — used in clip download, modal confirms)
- .btn-secondary (cancel buttons, neutral actions; also missing hover transitions)
- .btn-pill (toolbar/bulk-bar action buttons; primary + success + danger variants)
- .btn-close (X-close button used in filter clears, inline removals)
- .queue-detail-btn (queue item detail chip buttons + archive companion buttons)
Tabbing through these buttons left no indication of which one would activate on Enter/Space — WCAG 2.4.7 (Focus Visible) violation.
Added :focus-visible rings using the established box-shadow convention (purple-accent for default, white-inner + accent-outer for purple/green pill variants so the ring stays visible against the button's own purple background, red-toned for .btn-close / .btn-pill.danger). Also added :hover + transition to .btn-secondary which previously had neither — clicking felt unresponsive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The filter row above the VOD grid carried three inline style attributes:
- the row's own flex layout (display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap)
- vodSortLabel had margin-left:8px (extra spacing past the row's gap to visually group "Sort:" with the select)
- vodFilterCount had min-width:80px (prevents layout shift as count text changes during typing)
All three are now CSS class definitions (.vod-filter-row, .vod-sort-label, .vod-filter-count). HTML reads cleaner and the styling lives alongside the related .vod-bulk-bar block in styles.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five <label> elements in the clip-cutter modal and two <span class="form-sublabel"> elements in the Auto-VOD settings card had no for=/id pairing with their associated form controls — screen readers couldn't announce the label when the input was focused, and clicking the label didn't focus the input.
Fix: added for= to 5 clip-modal labels (Start/StartTime/End/EndTime/Part) and converted the two Auto-VOD sublabels from span to label for= so screen readers correctly associate them with autoVodPollMinutes and autoVodMaxAgeHours.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.6.77 inlined the setPageTitle global-window resolution + fallback
twice — once in each branch of the "vods+streamer vs other tab"
conditional. Reading two near-identical 4-line if/else stanzas back
to back was harder than necessary.
Collapsed the branch to a single ternary that picks the title text
first, then a single setPageTitle resolve + apply block at the end.
Same behavior, one resolution of the optional global.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
document.title was stamped once during app boot with the static
"Twitch VOD Manager vX.Y.Z" string. After that, the H1 page-title
in the header updated as the user navigated tabs and selected
streamers, but the OS-level window title — the string shown in the
taskbar, Alt+Tab switcher, and OS notifications — never changed.
Multitasking suffered: a user with three Electron windows pinned
to taskbar all read identical "Twitch VOD Manager v4.6.x", with
no clue which window had what tab or streamer loaded.
Added a setPageTitle(text) helper in renderer.ts that:
- Updates the H1 #pageTitle textContent (the visible header)
- Updates document.title with `${text} - ${appName} v${version}`
for non-default text, or just `${appName} v${version}` for the
default app-name fallback
- Exposed on window so the renderer-streamers.ts and
renderer-settings.ts modules can reach it without crossing the
module-vs-bundle boundary
Three call sites updated to use the helper:
- showTab → uses for tab-derived titles
- selectStreamer → uses for "xrohat" style streamer titles
- the renderer-settings language-switch refresh path
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bulk-select checkbox on each VOD card was carrying a ~140 char
inline-style block (absolute positioning, dimensions, accent-color,
z-index, cursor) — duplicated across every rendered VOD — plus a
truly bizarre title-attribute fallback that hacked the
bulkSelectedCount locale string by stripping placeholder digits:
title=`${UI_TEXT.vods.bulkSelectedCount
.replace("{count}", "0")
.replace(/[0-9]/g, "")
.trim() || "Select"}`
That worked by accident — the placeholder happens to be "{count}
ausgewahlt" / "{count} selected" so stripping digits gave a usable
fragment — but it was fragile and not really an accessible label.
Three fixes:
- Extracted the inline styles to a .vod-select-checkbox CSS rule.
The custom checkbox styling from 4.6.26 means accent-color was
redundant anyway, so dropping it is a no-op visually.
- Added a proper locale key vods.selectAriaLabel ("Select VOD for
bulk action" / "VOD fuer Bulk-Aktion auswaehlen") for the
aria-label attribute.
- Dropped the title-attribute hack entirely. aria-label now provides
the AT-readable name; sighted users get the visual checkbox
which is self-explanatory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl+F was wired to focus the VOD filter input — but only when the
VODs tab was active. On the Archive tab (added in 4.6.15) Ctrl+F
did nothing useful: the browser default find bar was suppressed
(Electron renderer doesn't have one anyway) and the app handler
didn't have a branch for the archive context.
Now Ctrl+F also targets the archiveSearchQuery input when the
Archive tab is the active tab. Other tabs (Clips / Cutter / Merge /
Stats / Settings) let the shortcut fall through to no-op since
they don't have a primary search/filter input.
Same input-focus convention as the VODs tab: focus + select-all so
the user can immediately type to replace or append.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the Statistik (4.6.14) and Archiv (4.6.15) tabs were added to
the sidebar nav, the TAB_IDS const never got extended past its
original five entries:
const TAB_IDS = ["vods", "clips", "cutter", "merge", "settings"]
Two consequences:
1) Ctrl+1..5 keyboard shortcut was hard-capped at five tabs (the
guard `tabIndex < TAB_IDS.length` filtered Ctrl+6 and Ctrl+7 out).
Even though there were 7 visible tabs.
2) persistActiveTab(tab) called isKnownTab(tab) before localStorage
write. For 'stats' or 'archive' that returned false, so the tab
was silently NOT persisted. Open the app on the Archiv tab,
close it, reopen — it'd boot on VODs because the persisted value
was the previous non-stats/archive selection.
Extended TAB_IDS to all seven nav-items + bumped the keyboard
shortcut range from 1-5 to 1-7. Ctrl+5 now maps to Statistik,
Ctrl+6 to Archiv, Ctrl+7 to Einstellungen. Persistence works for
the full set.
Added a comment to TAB_IDS pointing out the failure modes when
this is out of sync with the HTML nav, so the next nav addition
doesn't repeat the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three console.log calls in main.ts were flooding stdout during normal
operation:
1) `console.log("Starting download:", cmd, args)` — redundant with
the appendDebugLog("download-part-start", ...) one line below.
Duplicate logging; pure noise.
2) `console.log("Streamlink:", line)` — fired for every line of
streamlink stdout, which is 10-100 lines/sec during an active
download. Hundreds of thousands of lines per multi-hour recording.
Progress + state parsing already happens on the same line; the
raw output was never consumed.
3) `console.log("Download progress: X%")` in the autoUpdater
handler — fires ~10x/sec during an in-flight update download.
The renderer banner is the user-visible feedback; this was
developer-only and never necessary in prod.
Removed all three. The remaining four console.log calls (login
flow, update-available, update-downloaded, no-updates-available)
are once-per-event and fine to keep.
Practical benefit: stdout becomes useful for actual diagnostics
again. Performance gain is marginal in absolute terms but the
buffered noise on a long-running session was real.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final piece of the renderer-stats.ts extraction. The recording-size
distribution histogram (6 buckets: <100MB ... >10GB) was rendering
each bucket-row as a 5-inline-style template — same shape as the
top-streamers list (margin row, flex meta header, two spans, bar
track, bar fill).
Extracted to a .stats-bucket-* family in styles.css:
- .stats-bucket-row + .stats-bucket-row:last-child margin trim
- .stats-bucket-meta + .stats-bucket-meta-sub for the flex label/
count header
- .stats-bucket-bar-track + .stats-bucket-bar-fill for the
horizontal bar (with width-transition so the bar fills
animate on data refresh)
That completes the Statistik tab pass — 26 inline styles -> 22
CSS class assignments + 4 truly-dynamic width/height percent
overrides for the bar fills. Tabular numerics, hover states, and
data refresh animations all flow from the central stylesheet now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the renderer-stats.ts inline-style extraction. The
"Aktivitaet (letzte 30 Tage)" bar chart built each day-column as
a 5-inline-style template:
<div style="flex: 1; display:flex; flex-direction:column;
align-items:center; gap:4px; min-width:0;">
<div style="width: 100%; height: 90px; display:flex;
align-items: flex-end;">
<div style="width:100%; height: 70%;
background: var(--accent, #9146ff);
border-radius: 2px 2px 0 0;" title="...">
<div style="font-size: 9px; color: var(--text-secondary);
white-space: nowrap;">
30 columns rendered per refresh meant ~7.5KB of duplicated inline
style attribute strings in the DOM after every refresh.
Extracted to .stats-day-col + .stats-day-bar-track + .stats-day-bar-
fill + .stats-day-label, plus .stats-activity-row + .stats-activity-
summary for the outer wrappers. Only the per-day height percent
stays inline (it's truly dynamic, per-day data).
Polish riders:
- Bar fill picks up height: 0.3s ease-out transition so the bars
animate up on data refresh instead of snapping
- Hover state shifts the bar from accent to accent-hover so the
hovered day reads as the focus
- Day-label spans get tabular-nums so the "05-12" type strings
align column-to-column
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second pass on the Statistik tab. The top-10 streamers-by-size
list rendered each row as a 6-inline-style template (margin,
two flex containers, two span colour overrides, two bar wrappers,
two bar fills with hard-coded gradient).
Extracted to a .stats-top-* family in styles.css:
- .stats-top-row — outer row spacing
- .stats-top-meta + .stats-top-meta-sub for the label/byte-size
flex header
- .stats-top-share for the muted (X.Y%) suffix
- .stats-top-bar-track + .stats-top-bar-fill for the gradient
progress bar (now with a width-transition for the streamer-by-
streamer animation when the data refreshes)
- .stats-top-bar-labels for the overlaid LIVE/VOD breakdown that
gets pointer-events: none so the bar isn't accidentally hover-
blocked
Also picked up the "no top streamers" empty-state message and
swapped its inline-style div for the existing .form-note utility
class introduced in 4.6.42.
Top streamers row hover state intentionally NOT added — these are
read-only summary rows, not interactive ones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The six-tile overview grid at the top of the Statistik tab built
each KPI card as a four-property inline-styled div:
<div style="background: var(--bg-elevated); border: 1px solid
var(--border-soft); border-radius: 6px; padding: 12px;">
<div style="font-size: 11px; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;">
<div style="font-size: 22px; font-weight: 600; margin-top: 4px;">
<div style="font-size: 12px; color: var(--text-secondary);
margin-top: 4px;">
Each card repeated the same ~250 chars of inline styling. Card hover
state, number alignment, future polish all required editing the
renderer.
Extracted to .stats-kpi-card + .stats-kpi-label + .stats-kpi-value
+ .stats-kpi-sub. Added two enhancements while at it:
- subtle hover state (purple-tint border + 1px lift) so the cards
feel interactive in line with the rest of the apps language
- font-variant-numeric: tabular-nums on values + subs so the
numbers align properly across the six-tile grid
Also stats-no-root for the "Download folder not found" fallback
that grid-column-spans across all 6 columns.
The remaining 22 inline styles in renderer-stats (top-streamers
bar list, activity calendar, size buckets) come in subsequent
iterations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderArchiveSearchResults was building each result row as an HTML
template literal carrying ~10 inline-style props per row (flex
layouts, padding, border-bottom, font-sizes, secondary text colour,
ellipsis truncation, gap...). For a 200-hit search that meant
~2KB of duplicated inline style noise in the DOM and made any
visual tweak require editing the renderer.
Extracted to a .archive-result-* family in styles.css:
- .archive-result-row + hover-tint (table-row scannability — was
missing before, every row read flat)
- .archive-result-body / -meta / -streamer / -date / -filename /
-size / -actions for the column layout
- .archive-type-badge with .live + .vod modifiers for the LIVE/VOD
pill (was two separate inline-styled spans with hard-coded
rgba colours)
- .archive-no-matches for the empty-state line
Dates + sizes in the row pick up font-variant-numeric: tabular-nums
so columns of numbers align even when filenames are different
widths. Last-child gets its bottom border dropped so the list
doesnt end on a dangling line — same treatment as the storage
stats table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showAppToast spawns / reuses a single floating toast at the bottom-
right of the window for transient status (e.g. "1 new VOD auto-queued",
"Cannot start recording", etc). The toast had no a11y semantics —
screen readers never announced it, so the entire transient-feedback
channel was silent for AT users.
Promoted the toast container to a live region:
- role="status" for info toasts + aria-live="polite" so the reader
waits for a natural break in current speech before announcing
- role="alert" for warn toasts + aria-live="assertive" so the reader
interrupts whatever it was saying (matches the visual amber-left-
border meaning — warn IS urgent)
- aria-atomic="true" so the reader announces the whole message at
once instead of attempting to diff against the previous toast
Critical detail: aria-live attributes have to be in place BEFORE the
text changes for AT to register the change as a live-region update.
The current implementation now sets role / aria-live first and only
then writes the new textContent.
WCAG 4.1.3 — Status Messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third progress bar in this a11y pass — the download-progress bar
shown in the update banner during an auto-update download. Same
pattern as 4.6.64 (queue) + 4.6.65 (cut/merge): bare div with
JS-driven width, no semantic role.
Promoted the .update-banner-progress-track to role="progressbar"
with aria-valuemin / max / now + a localized aria-label
(updateProgressAria: "Update download progress" / "Update-Download-
Fortschritt").
Three call sites in renderer-updates.ts that drive bar.style.width
now also stamp aria-valuenow on the gauge:
- onUpdateProgress event handler (per-tick percent)
- setDownloadPendingUi (initial 30% indeterminate placeholder)
- setDownloadReadyUi (100% on finish)
renderer-texts.applyText sets the localized aria-label at boot +
on language switch.
That's all three application-level progress bars now AT-friendly.
The same pattern would extend to any future progress UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following 4.6.64 for the queue progress bars, the cut + merge
progress containers in their respective tabs had the same gap:
a plain <div class="progress-bar"> wrapping a <div class="progress-
bar-fill"> with no semantic role. JS poked the bar's style.width
on every percent update; AT had no way to read out the running
value.
Promoted both .progress-bar wrappers to role="progressbar" with
aria-valuemin / max / now, plus aria-label sourced from new
locale strings (cutProgressAria / mergeProgressAria) so EN/DE
both work.
The progress event handlers in renderer.ts now also stamp
aria-valuenow on each tick, so AT live regions pick up the
percentage as the cut / merge advances. setAttribute is cheap
relative to the FFmpeg progress event rate (~1/s), no perf
concern.
renderer-texts.applyText sets the localized aria-label on both
gauges at boot + language switch — text contents already get
re-applied through the same path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The download progress bar inside each queue item was a plain
<div class="queue-progress-bar" style="width: 73%;"> with no
semantic indication that it represented progress. Screen readers
just announced the surrounding text ("Downloading...") with no
running value.
Added role="progressbar" + aria-valuemin=0 + aria-valuemax=100 +
aria-valuenow on the wrapping .queue-progress-wrap (since the bar
itself is just the visual fill — the wrap is the semantic gauge
region). aria-label is the status label so AT announces "VOD title
75 percent" instead of an unlabeled gauge.
updateQueueItemProgress also re-stamps aria-valuenow as the
percentage advances, so AT live regions can pick up the running
update without needing a full re-render.
For indeterminate progress (pre-rolling, before the first byte
event arrives) aria-valuenow stays at 0 — the screen reader still
gets a coherent reading even if visually the bar is in
indeterminate-pulse mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vodSortSelect + the 3 archive-search selects all sat OUTSIDE
the .form-group container, which meant the existing
.form-group select rule did not apply to them. Each carried the
same ~110 chars of inline style hard-coding bg / border / radius /
padding / color.
Extracted to a shared .select-compact class (6px radius, 7px
padding, var(--bg-card) base, var(--border-soft) border) and
swapped all four call sites. Visual consistency between the
filter-row select in the VODs tab and the multi-select control
strip in the Archive search — same heights, same border treatment.
Minor visual change for the 3 archive selects (4px -> 6px
border-radius, 6px -> 7px vertical padding) so the controls now
match the rest of the apps button + input family. Worth the 1px
delta for the consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to 4.6.61. The open-file IPC handler (used by the
"Open file" buttons in the queue + archive) was previously a
plain shell.openPath call with only an existsSync check:
if (typeof filePath !== "string" || !filePath) return false;
if (!fs.existsSync(filePath)) return false;
const result = await shell.openPath(filePath);
shell.openPath happily launches any path the OS knows how to
execute. An XSS landing through e.g. a smuggled queue item URL
that reached the renderer-side openFile global function could
pass `C:\\Windows\\System32\\calc.exe` and the IPC would launch
calc.
Added a deny-list of obvious shell-execution extensions (.exe,
.bat, .cmd, .com, .ps1, .vbs, .vbe, .js, .jse, .wsf, .wsh, .scr,
.msi, .msp, .lnk, .cpl, .reg, .hta, .jar, .application). Rejected
calls log to debug + return false to the renderer. Media + text +
image extensions remain unaffected — those open in their normal
default-app viewers, which is the intended use case.
show-in-folder + open-folder stay permissive on extension since
they only open File Explorer (no execution).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The open-external IPC was a pass-through:
ipcMain.handle("open-external", async (_, url) =>
await shell.openExternal(url));
shell.openExternal on Windows happily resolves any URL scheme the OS
knows how to launch — including file:// paths, ms-settings:, shell:,
javascript:, and assorted protocol handlers. The renderer is
contextIsolated + nodeIntegration: false so direct exploits are
blocked, but an XSS landing through (for example) a streamer name
that smuggled HTML into a renderer template would have a clean path
through this IPC to launch arbitrary local executables via the OS
shell.
Validation gate: reject anything that isn't an http:// or https://
URL. Trim before the test so a smuggled leading/trailing whitespace
attempt does not slip through. Rejected requests get a debug-log
entry (truncated to 200 chars so a megabyte payload doesnt nuke the
log) and return silently — the renderer caller already swallows
the promise without checking, so silent-drop matches existing
behaviour.
Defence-in-depth. No known active exploit; just removing an
unnecessary surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>