Three more user-triggered status messages that updated silently for screen-reader users:
- clipStatus — clip download progress/result text below the URL input on the Clips tab
- eventsViewerStatus — loading/error message inside the events viewer modal
- chatViewerStatus — loading/error message + filter result count inside the chat viewer modal
All three get role="status" + aria-live="polite". Same reasoning as 4.6.153/154 — the user explicitly clicks a button or opens a modal, then the result fills in asynchronously. Without aria-live the announcement was lost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following the cleanupReport fix in 4.6.153, three more user-triggered refresh result strings were updating silently:
- statsLastScannedLabel — "Letzter Scan: <timestamp>" / "Scanne..." in the Archive-Statistik header after clicking Aktualisieren
- archiveSearchSummary — "{matchCount} matches (scanned {scanned}…)" / "Scanne..." after clicking Suchen
- storageSummary — "Total: {files} files, {size} — Free disk: {free}" / "Scanne..." after clicking Aktualisieren on the Storage card
All three are textContent updates triggered by an explicit user action that finishes asynchronously. Without aria-live, screen reader users hear nothing after pressing the action button — the result text fills in off-screen.
Added role="status" + aria-live="polite" to all three. "polite" because the result isn't urgent — the user requested it and waiting for a natural break in speech is fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Auto-Cleanup card has a "Vorschau" (dry-run) and "Jetzt ausfuehren" button pair followed by a cleanupReport div that displays the result text — "Wuerde X Dateien aelter als Y Tagen verschieben", "X Dateien archiviert", or "Fehler". Without an aria-live region, screen reader users who click the button never hear what happened: the focus is still on the button, the result text appears off to the side silently.
Added role="status" + aria-live="polite" so the report's textContent change gets announced when it's set. "polite" because the report isn't an alert/error — the user requested the action, so they can wait for the screen reader's natural break in current speech to announce the result.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The newStreamer text input (where the user types a Twitch username to add to the streamer list) had only a placeholder ("Streamer hinzufuegen..." / "Add streamer...") as its accessible-name source. Same problem as the 3 filter/search inputs fixed in 4.6.151: placeholder text is not a reliable screen-reader name and disappears once typing starts.
Wired up setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel) — reusing the locale key already in use for the adjacent "+" button (4.6.91), which means screen reader users hear "Streamer hinzufuegen" / "Add streamer" for both the input and the button that submits it. Zero new translation work, immediate a11y win.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three filter / search inputs in the always-visible UI surface (VOD filter, sidebar streamer-list filter, Archive search) had only placeholder text as their accessible-name source. Placeholder text is unreliable as a screen-reader name — some implementations announce it, some skip it, and it disappears as soon as the user types so a re-focus during typing leaves the input unannounced.
Added three locale keys (DE+EN):
- vods.filterAria — "VOD-Titel filtern" / "Filter VOD titles"
- static.archiveSearchAria — "Archiv durchsuchen" / "Search archive"
- static.streamerListFilterAria — "Streamer-Liste filtern" / "Filter streamer list"
Wired through renderer-texts via the existing setAriaLabel helper (added in 4.6.91), so each input now has a proper aria-label that survives the placeholder vanishing and reads cleanly in screen-reader navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.6.141 added .filter-input:hover:not(:focus):not(:disabled) { border-color: rgba(145, 70, 255, 0.45); } as a targeted hover affordance. Then 4.6.142 added the same effect via the global input[type="text"]:hover:not(:focus):not(:disabled) rule covering every text input.
Since .filter-input is always applied to <input type="text"> elements (VOD filter, sidebar streamer filter, archive search input), the global rule already matches them all with identical effect. The class-scoped rule was duplicated work.
Replaced with an explanatory comment so future readers don't add it back. The .select-compact:hover stays because it adds a background tint on top of the global border-color change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking one of the three filename-template preset buttons (Default / Archive / Clipper) in Settings was a half-applied state: the three template inputs (vodFilenameTemplate / partsFilenameTemplate / defaultClipFilenameTemplate) got their .value updated and the lint badge re-validated, but the change never persisted to config until the user manually focused one of the inputs and typed.
Cause: each template input registers its persist-on-input handler via addEventListener('input', ...), and programmatic .value = ... does NOT dispatch the input event by HTML spec. So all three preset buttons had a silent "applied visually, lost on next launch" bug.
Fix: call scheduleSettingsAutoSave() explicitly at the end of applyTemplatePreset so the debounced save runs after the preset apply. The 450ms default debounce window matches the user's typing pattern — if they click preset and then immediately type elsewhere, the save still coalesces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two <img> elements rendered by renderer code had hardcoded English alt text that never localized:
- renderer.ts cutter preview frame: alt="Preview"
- renderer-profile.ts live-thumb: alt="Live preview"
Added two new locale keys (DE+EN):
- cutter.previewAlt — "Vorschau" / "Preview"
- profile.liveThumbAlt — "Live-Vorschau" / "Live preview"
renderer.ts updates: the three preview.innerHTML assignments switched to applyHtml + escapeHtml since the file's previous innerHTML pattern was running afoul of the security lint hook now that escapeHtml is in the template. Same shape as the other consolidated renderers (stats, archive, profile).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The showAppToast() function signature in renderer.ts is `(message: string, type: 'info' | 'warn' = 'info'): void` — there is no 'error' kind. Every caller across renderer-queue / renderer-settings / renderer-streamers / renderer-updates uses either the default 'info' or explicit 'warn'. No code path adds the .error class to the toast.
The .app-toast.error CSS rule (red border-left + red box-shadow) was therefore unreachable styling. Removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related locale-table fixes:
1. statsIntro was already defined in both DE+EN but never applied — the HTML's German static text stayed visible when the user picked English. Wired it through renderer-texts: the locale strings now include the <code>{streamer}/live/</code> / <code>{streamer}/</code> markup that the HTML carried inline, and the setText pass uses applyHtml to render them so the inline <code> styling survives. The locale strings are static developer-authored content (no untrusted input) so the inline <code> tags are safe.
2. statsScannedAtNever was defined in both DE+EN but had zero callsites — leftover from an earlier stats-card iteration. Removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The static.cutterDropHint entry (DE: "Video-Datei hierher ziehen zum Laden.", EN: "Drop a video file here to load it.") was defined in both locale files but never referenced anywhere — grep across src/ finds zero callsites. Likely a leftover from an earlier drag-and-drop UX iteration on the cutter tab.
Removed both entries. tsc + smoke + full E2E still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.btn-primary and .btn-pill already had :disabled rules from earlier polish passes. .btn-secondary was missing one even though several renderer flows disable .btn-secondary buttons while async work is in flight:
- btnRefreshStorage during storage scan
- btnStatsRefresh during archive stats scan
- btnExportConfig / btnImportConfig during the JSON dump
Without :disabled styling the button looked clickable but rejected clicks during the work. Added the same opacity:0.45 + cursor:not-allowed treatment used by .btn-pill:disabled and the new .btn:disabled from 4.6.143 — completes the disabled-state parity across the .btn-primary / .btn-secondary / .btn-pill / .btn family.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .btn base class (Queue actions: Start / Wiederholen / Leeren / Merge & Split) had no :disabled rule, so when the renderer set btnRetryFailed.disabled = true (no failed downloads in queue), the button visually looked identical to its enabled state — clickable-styled but rejecting clicks. The mouse cursor stayed `pointer` even though the button did nothing.
Added .btn:disabled with the standard 0.45 opacity + cursor:not-allowed (matching .btn-pill:disabled from 4.6.21-era styling). The .btn:disabled:hover { background: inherit; } companion stops the per-variant hover rules (.btn-retry:hover, .btn-start:hover, etc.) from overriding the disabled background — the disabled state stays inert under the cursor.
Affects btnRetryFailed (most visible — toggles disabled when no items are in the error state) and is also robustness against any future code that disables btnStart / btnClear / btnMergeGroup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following the .select-compact (4.6.140) and .filter-input (4.6.141) hover passes, the global text-input rule block now gets a matching mouseover affordance for every input type the app uses:
- input[type="text" / "search" / "number" / "password" / "email"]
- textarea
- select
A single :hover:not(:focus):not(:disabled) rule sets a soft purple-accent border (the same rgba(145,70,255,0.45) used by the per-class hovers). The :not(:focus) guard keeps the existing focus ring (the next rule above) from being downgraded when the user hovers a focused input; :not(:disabled) leaves read-only / disabled inputs inert.
Every text-shaped form control in the app — clip URL, Twitch client-id/secret, discord webhook, filename templates, all Settings number fields — now has a consistent hover affordance. The .select-compact and .filter-input rules from the previous two iterations are now redundant for the hover (the global rule covers them) but kept for the explicit declaration since they may diverge later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the hover-state pass from 4.6.140 (.select-compact). The .filter-input class (used by the VOD filter, sidebar streamer-list filter, and archive search input) had no :hover state — text inputs in the app gave no mouseover affordance, only a focus ring after click.
Added a :hover:not(:focus):not(:disabled) rule that softens the border to the same purple-accent half-tone used by .select-compact's hover. The :not(:focus) guard prevents the hover from competing with the global input:focus ring (which sets a darker accent + halo); the :not(:disabled) keeps it consistent with the .select-compact pattern.
The existing transition rule on all text inputs (line 661) covers the animation — no additional change needed there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small targeted polishes:
1. .select-compact had no :hover state — selects in the VOD sort, Archive filters, and other compact dropdowns gave no mouse-feedback visual when pointed at. Added a soft purple-accent tint on hover (background + border-color transition) matching the rest of the app's interactive-control hover palette. The global select:focus rule already handles keyboard focus; this only adds the mouse-hover affordance.
2. The filenameTemplatesTitle label carried style="margin: 0;" inline. The global * { margin: 0 } reset at the top of styles.css already zeros every element's margin by default, so the inline declaration was a literal no-op. Dropped it — same noise-removal as the archiveTitle fix in 4.6.99.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Archive-search tool-row carried two inline-style attrs:
- .form-row with gap:8px; margin-bottom:8px; flex-wrap:wrap; align-items:center (different rhythm from the existing .aligned / .section-header form-row modifiers — tool-row that should wrap on narrow widths)
- archiveSearchStreamer .select-compact with min-width:160px (matches the existing .form-stack.size-md width)
Extracted into two modifier classes:
- .form-row.search-bar — the search/filter tool-row pattern (search input + filter selects + go button collapse gracefully)
- .select-compact.size-md — wider select variant, mirrors the .form-stack.size-md pattern from 4.6.95 so width modifiers across element types share the same naming
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the renderer-updates display-toggle migration started in 4.6.137. Five more elements (updateChangelogCard, updateModalSkipBtn, updateModalMeta, updateProgress in two more code paths) were still using inline .style.display / inline display checks. Each has been switched to:
- classList.add/remove/toggle('is-hidden') for writes
- classList.contains('is-hidden') for the two state-reads (refreshUpdateChangelogToggleText, toggleUpdateChangelog)
Plus the two remaining inline style="display:none;" HTML attributes (updateModalMeta + updateChangelogCard) moved to class="... is-hidden".
After this: renderer-updates.ts has zero .style.display references — the entire update-banner + update-modal visibility surface is class-driven.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .update-banner CSS has had a .show modifier (display:flex) defined since the original auto-updater code, but the JS was bypassing it and setting .style.display='flex'/'none' directly. Three places had this inconsistency:
- showUpdateBanner() — set inline display:flex
- hideUpdateBanner() — set inline display:none
- The check at line 461 — read inline display === 'flex' to gate a "no update found" toast
Switched all three to the canonical .show class — adding/removing it and checking with classList.contains. The CSS rule does the actual display flip.
The updateProgress wrap (download progress bar inside the banner) was using the same .style.display = 'block' / 'none' inline-toggle pattern in three places (setUpdateBannerAvailableUi, setDownloadPendingUi, setDownloadReadyUi). Migrated all three to the shared .is-hidden class from 4.6.134, plus removed the inline style="display: none;" from the HTML.
Net: 5 inline display assignments + 1 state read + 1 inline HTML display:none gone, update-banner state machine is now fully class-driven.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The profile header on the VOD tab cycled between three display states via inline .style.display assignments — 'none' when no streamer is selected, 'flex' while loading (skeleton), 'block' once profile data is back. Three separate inline writes plus a starting style="display:none" in HTML.
Two changes:
1. Baked display:flex into the .streamer-profile-skeleton CSS rule itself (was previously implicit via the JS flip). Now the skeleton class fully owns its layout — adding it switches the element to flex, removing it falls back to .streamer-profile-header's base display:block.
2. Replaced the inline 'none' assignment with the shared .is-hidden class (from 4.6.134). hideStreamerProfileHeader adds it; renderStreamerProfileSkeleton + renderStreamerProfileCard both remove it as part of their existing classList work. HTML drops the inline display:none too.
Three .style.display assignments + one inline display:none gone, profile header's visual state is now entirely class-driven.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:26:00 +02:00
11 changed files with 126 additions and 53 deletions
<pid="archiveIntro"class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
statsIntro:'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter {streamer}/live/, VOD-Downloads direkt unter {streamer}/. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsIntro:'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
statsIntro:'Aggregated across the download folder. Live recordings live under {streamer}/live/, VOD downloads under {streamer}/. Scan time scales with file count.',
statsIntro:'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
statsRefresh:'Refresh',
statsScanning:'Scanning...',
statsScannedAt:'Last scan',
statsScannedAtNever:'Not yet scanned',
statsSummaryTitle:'Overview',
statsTopStreamersTitle:'Top streamers (by size)',
statsActivityTitle:'Activity (last 30 days)',
@ -141,6 +140,7 @@ const UI_TEXT_EN = {
archiveNoMatches:'No matches.',
archiveNoRoot:'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder:'Search...',
archiveSearchAria:'Search archive',
archiveOpen:'Open',
archiveShowInFolder:'Folder',
archiveViewChat:'Chat',
@ -178,11 +178,11 @@ const UI_TEXT_EN = {
downloadPathNotWritable:'Download folder is not writable. Pick another folder or grant write permission.',
streamerSectionTitle:'Streamer',
streamerListFilterPlaceholder:'Filter...',
streamerListFilterAria:'Filter streamer list',
streamerAddAriaLabel:'Add streamer',
streamerBulkRemoveTitle:'Remove all (or filtered)',
streamerBulkRemoveAll:'Remove all {count} streamers from the list?',
streamerBulkRemoveFiltered:'Remove the {count} matching streamer(s) from the list?',
cutterDropHint:'Drop a video file here to load it.',
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.