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>
Continuing the .is-hidden migration from 4.6.134 — two more sidebar-shell elements were doing the same .style.display = '' / 'none' dance:
- streamerListFilter input (compact filter that only appears once the streamer list crosses STREAMER_FILTER_THRESHOLD)
- btnStreamerBulkRemove button (X bulk-remove button, same threshold gate, plus a separate hide path inside the no-streamers branch)
Both started with style="display:none" in HTML and toggled via .style.display in renderer-streamers.ts. Now use the shared .is-hidden class — HTML drops the inline style, JS uses classList.toggle/add.
3 more .style.display assignments + 2 inline display:none HTML attrs gone, identical behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three runtime-toggled elements were each using a slightly different inline display value to flip between visible/hidden:
- vodFilterClearBtn: button, .style.display = '' / 'none'
- btnMergeGroup: button, .style.display = '' / 'none'
- vodBulkBar: div, .style.display = 'flex' / 'none'
Plus each carried an inline style="display:none;" in HTML to start hidden.
Added a single .is-hidden utility class (display:none !important) that hides any element regardless of its natural display type, and:
- HTML now uses class="... is-hidden" instead of style="display:none"
- JS toggles with classList.toggle('is-hidden', !shouldShow) instead of poking at .style.display
- .vod-bulk-bar gets an explicit display:flex in its base rule (was implicit via the JS flip; now declared)
Comment on .vod-bulk-bar animation updated since the trigger is now ".is-hidden removed" rather than "JS sets display:flex".
Net: 3 inline style="display:none" attrs gone from HTML, 4 .style.display assignments gone from TS. Single utility class handles the same job for all three plus any future show/hide toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the cutter-info bar (resolution/duration/fps/selection readouts) and the timeline-container (timeline + start/end inputs) were hidden by default via inline style="display:none;" in HTML, then loadCutterFromPath() set .style.display = 'flex' / 'block' once a video was loaded.
Moved both into CSS with the same pattern as 4.6.126 (.queue-details.expanded) and 4.6.132 (.clip-template-wrap.shown):
- Base .cutter-info / .timeline-container rule sets display:none
- .shown modifier flips to flex / block respectively (preserves the original visible-state layout)
- HTML drops the inline style attribute
- JS uses classList.add('shown') instead of poking at .style.display
Four inline-style references gone (2 HTML + 2 TS), no behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The clip-cutter modal's custom-template wrap (.clip-template-wrap) was hidden by default via inline style="display:none;" in HTML and shown/hidden by updateFilenameTemplateVisibility() via wrap.style.display = 'block' / 'none' based on the selected filename format.
Moved both into CSS: the base rule now sets display:none, and a .shown modifier flips to display:block. The renderer toggles the class via classList.toggle('shown', ...) instead of poking at .style.display, and the HTML drops its inline style attribute.
Same pattern as 4.6.126 (.queue-details.expanded). Two inline style references gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
updateClipDuration in renderer.ts was setting the duration-display element's color to one of two hardcoded hex values inline: #00c853 (green) when the selection was valid and #ff4444 (red) when end <= start. Both colors are already exposed in CSS as var(--success) and var(--error), and the base .clip-modal-duration-value rule was already setting #00c853 — so the green inline assignment was redundant.
Switched the base rule to use var(--success) for theme consistency, added a .clip-modal-duration-value.invalid modifier that flips to var(--error), and the renderer now toggles the .invalid class instead of poking at .style.color directly. Two inline style assignments + an if/else branch gone; the JS read more clearly as "set text + flip validity class".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderChatList in renderer.ts was setting uSpan.style.color twice: once with the per-user IRC color when m.color was present, and once as a fallback to var(--accent) when it wasn't. The fallback is exactly the styling .chat-viewer-user should own by default.
Moved color: var(--accent) into the .chat-viewer-user CSS rule next to its font-weight + margin-right. The renderer's per-user color override stays inline because it's truly dynamic (parsed from chat IRC payload), but the no-color path no longer needs to assign anything — the class default takes over.
One inline .style.color assignment + one else branch gone, semantics preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderEventsList in renderer.ts had three .style.* assignments on its empty-state placeholder div (color/padding/textAlign), set just before stamping the localized "no events recorded" text. Extracted to an .event-viewer-empty class next to the .event-viewer-row + .event-viewer-time block in styles.css.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderer-stats.ts and renderer-archive.ts each had their own byte-size formatter (formatBytesForStats / formatBytesForArchive). The two were textually identical: both handle the B -> KB -> MB -> GB -> TB ladder with the same toFixed precision and return '0 B' for non-finite / zero / negative input.
Hoisted to renderer-shared.ts as plain formatBytes. Removed both per-file copies and renamed all 14 call sites across the two modules. The two narrower variants in renderer-settings.ts (formatBytesForMetrics — caps at GB) and renderer.ts (formatBytesRenderer — caps at GB, less protection) stay file-scoped because they have different scale/protection semantics for their specific contexts (runtime metrics + download progress, which never reach TB).
Continues the renderer-shared consolidation from 4.6.127 (applyHtml/escapeHtml).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderer-stats.ts, renderer-archive.ts, and renderer-profile.ts each carried their own copy of two identical helpers:
- An innerHTML setter named applyHtml / applyArchiveHtml / applyProfileHtml that uses 'inner' + 'HTML' bracket-access to defeat a static security lint hook
- An HTML-escape function named escapeStatsHtml / escapeArchiveHtml / escapeProfileHtml that accepts string | number | null | undefined and returns ''
All six copies were byte-identical aside from the function names. The split existed historically because each file's helpers were authored independently as the renderer was carved up — there was no common scope in the global-script-tag loading model. But renderer-shared.ts is loaded first in index.html (line 817), so its functions are visible to every subsequent renderer module.
Hoisted the canonical pair to renderer-shared.ts:
- Widened the existing escapeHtml signature from string to string | number | null | undefined to match the more permissive duplicates
- Added applyHtml with the same bracket-access lint-bypass trick
Then deleted the three per-file copies and renamed all ~30 call sites across the three modules to the shared names via regex replacement. Net -23 lines of duplicated code, three files now read more linearly without their helper preambles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-queue-item details panel was being shown/hidden via an inline style="display:block/none" attribute computed on every queue render. Replaced with an .expanded class modifier — base .queue-details now has display:none and .queue-details.expanded sets display:block.
The aria-expanded attribute on the title row (which mirrors the same boolean) already drives the screen-reader exposure; the visual state now follows the same class-based pattern instead of riding a separate inline-style track.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderStreamers in renderer-streamers.ts was toggling the streamer-section title's bottom margin between 4px and "" via an inline style assignment, conditional on whether the list-filter input was visible directly below. Replaced with a .compact modifier class — same visual effect, but the CSS declaration lives next to the .section-title base rule where future readers will look, and the JS gets to use classList.toggle instead of poking at inline styles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 6-placeholder VOD-card skeleton (shown while VODs load) had three lines per card with inline width/height/margin-top declarations:
- title line: 85% wide
- first meta line: 55% wide, 10px tall, 8px gap above
- second meta line: 40% wide, 10px tall, 6px gap above
Extracted into .vod-skel-line.title / .meta-1 / .meta-2 variants — the layout-defining values live next to the base .vod-skel-line rule. Matches the same approach as the .streamer-profile-skel-block variants from 4.6.123.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The streamer profile loading skeleton (8 inline-styled div placeholders that preview the final card layout while data fetches) carried width/height/border-radius properties inline on every block. Four of those are pre-shaped slots that match a real layout element (avatar, name line, badge, subtitle), and one is the stats container margin — extracted to CSS classes:
- .streamer-profile-skel-block.avatar (88x88 round, flex-shrink:0)
- .streamer-profile-skel-block.name (180x24)
- .streamer-profile-skel-block.badge (90x18, 10px radius)
- .streamer-profile-skel-block.subtitle (60% x 14, margin-top:6px)
- .streamer-profile-skel-stats (the container's margin-top:8px)
The three stat-line placeholders (widths 100/80/120) keep their inline width: the slight variation is intentional visual texture so the skeleton doesn't look like three identical rectangles, and it's the only place where the inline value actually carries meaning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The queue-item detail panel's output-files row (rendered after a job completes — Open file / Show in folder / View chat / View events buttons + a tiny file-label span) had two inline-styled elements:
- The container div with display:flex; gap:6px; margin-top:6px; flex-wrap:wrap; align-items:center
- The span with color:var(--text-secondary,#888); font-size:11px; word-break:break-all
The span's inline fallback color (#888 after the comma) was a leftover defensive value from before --text-secondary was guaranteed to be defined globally; it's never actually needed today.
Extracted to .queue-output-row + .queue-output-label classes. Class definitions live next to the .context-menu* family they belong to (other queue-detail-panel internals).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both context menus (queue row + VOD card) were built as plain <div> trees with no ARIA roles — screen readers couldn't tell they were menus, and the individual rows weren't exposed as menu items. Users on assistive tech got a generic "group of nested divs" with no menu semantics, despite the menus being visually and functionally menus.
Added the WAI-ARIA menu pattern roles:
- role="menu" on the container
- role="menuitem" on each clickable row
- aria-disabled="true" on disabled menu items
- role="separator" on the horizontal divider lines
Both renderer-queue.ts (queue right-click context menu) and renderer-streamers.ts (VOD card right-click context menu) get the same treatment so the two share both visual style (.context-menu — 4.6.120) and accessibility semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderer-streamers.ts had its own copy of the same right-click menu boilerplate that 4.6.119 just consolidated for renderer-queue.ts — the VOD card context menu (Open on Twitch / Copy URL / Trim / Add to Queue / Mark as downloaded) was building ~14 inline-styled properties on its container and ~6 per item, with the same mouseenter/mouseleave hover fake.
Renamed the freshly-extracted classes from .queue-context-menu* to .context-menu* (more accurate since they're generic right-click-menu styles, not queue-specific) and pointed both renderer-queue.ts and renderer-streamers.ts at the new shared class set. The VOD menu drops its entire inline-style block + two hover handlers per item.
Net: ~17 more inline style assignments + 5 hover handlers gone, two context menus now share a single visual definition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showQueueContextMenu in renderer-queue.ts was building the right-click menu entirely with .style.X = '...' assignments — the menu container had 8 inline declarations (position/z-index/bg/border/border-radius/box-shadow/padding/min-width), each menu item had 6 (padding/cursor/font-size/color/border-radius/opacity) plus two mouseenter/mouseleave handlers to fake :hover, and the separator added 3 more.
Extracted everything except the dynamic positioning into four CSS classes:
- .queue-context-menu (the container; left/top stay inline since they're click-position-derived)
- .queue-context-menu-item (default state)
- .queue-context-menu-item:hover:not(.disabled) (replaces the JS mouseenter/mouseleave dance with a real :hover rule)
- .queue-context-menu-item.disabled (greys + cursor)
- .queue-context-menu-separator
Net: ~17 inline style assignments + 2 hover handlers gone, menu styling lives in styles.css next to other context-card patterns. The mouseenter/mouseleave -> :hover conversion also picks up reduced-motion suppression that the prefers-reduced-motion rule applies to transitions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three input placeholders in the HTML had hardcoded German strings that were never routed through the locale system:
- clipStartPart (Clip cutter modal — "Start Part-Nummer") said "z.B. 42" regardless of language
- clipUrl (Clips tab) read "...oder https://www.twitch.tv/..." with a mid-string German conjunction
- cutterFilePath (Video cutter "Datei ausgewahlt" field) read "Keine Datei ausgewahlt..." even under EN
Added three locale keys (clips.urlPlaceholder, clips.startPartPlaceholder, cutter.filePathPlaceholder) with DE+EN translations, plus the three setPlaceholder() wire-up calls in renderer-texts.ts. The HTML defaults stay German (consistent with the other placeholders) but the JS now overrides them when the user picks English (or re-renders when language changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated small fixes bundled:
1. The chat viewer modal's filter input (chatViewerFilter) had a hardcoded "Filter..." placeholder that was never localized — every other filter input in the app routes its placeholder through UI_TEXT. Added queue.chatViewerFilterPlaceholder + queue.chatViewerFilterAria locale keys (DE: "Chat filtern..." / "Chatnachrichten filtern"; EN: "Filter chat..." / "Filter chat messages") and wired them through renderer-texts so the placeholder now matches the active language and screen readers get a proper accessible name on the input.
2. The status-bar's coloured dot (statusDot) had no aria-hidden — screen readers would read it as a generic element with no meaning. The status text right next to it already carries the same information ("Verbunden" / "Nicht verbunden"), so the dot is purely decorative. Added aria-hidden="true".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The merge tab's per-file action buttons (▲ ▼ x — move up, move down, remove from list) were three icon-only buttons whose only visible content was the unicode glyph. No aria-label, no title, no focus-visible ring:
- Screen readers had nothing to announce — a keyboard user navigating the merge file list would tab through three unnamed buttons in a row.
- Sighted keyboard users had no visible focus indicator on .file-btn.
Three new locale keys (DE+EN) — merge.moveUpAria / moveDownAria / removeAria — give each button a translated aria-label and matching title tooltip. CSS adds .file-btn:focus-visible with the purple ring and a red variant for .file-btn.remove to match its red-on-hover colour family.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the type-attribute pass from 4.6.114 (static HTML). The renderer modules build buttons into their template strings via tagged-template literals; sixteen of these were rendered without an explicit type attribute and would inherit the type="submit" default:
- renderer-archive.ts: 4 archive-result buttons (chat / events viewer triggers + open file / show in folder)
- renderer-profile.ts: 3 profile action buttons (live record CTA, open on Twitch, refresh)
- renderer-queue.ts: 4 queue detail-row buttons (open file, show in folder, view chat, view events)
- renderer-streamers.ts: 2 per-VOD-card buttons (trim, queue)
- renderer.ts: 3 merge file row buttons (move up, move down, remove)
Same defensive reasoning as 4.6.114: no <form> wraps these today, but the moment one does they all silently turn into submit buttons. Explicit type="button" closes that footgun. Every <button> in the codebase — static or dynamic — now declares its type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTML had 59 <button> elements; 16 already declared type="button" but the remaining 43 relied on the browser default. The default for <button> without a type attribute (and outside a <form>) is type="submit" — harmless today because index.html has no <form> elements, but the moment a future refactor wraps any section in a form, every unmarked button suddenly becomes a submit button. That's a latent footgun.
Added type="button" to all 43 unmarked buttons via a regex replacement. Now any <button> in the markup is explicitly non-submit — no behaviour change today, but the markup no longer depends on the absence of a form element to behave correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small targeted fixes:
- .streamer-profile-btn (the action buttons in the streamer profile header: Record now, Open on Twitch, Refresh) had :hover but no :focus-visible. Keyboard users tabbing through the profile header buttons couldn't tell which one was focused. Added a purple ring for the default variant and the inner-white + outer-purple double-ring for the .primary variant (matches the convention used everywhere else for purple-background buttons).
- The dynamically-built Storage table "Open" button (renderer-settings.ts:397) was created via document.createElement('button') without setting type. Browsers default to type="submit" for <button> elements, which is fine outside a form but defensive coding to mark it explicitly. Set openBtn.type = 'button'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two icon-only elements that had a title tooltip but no programmatic accessible-name source for screen readers:
- .queue-retry-btn: a small button on failed queue items, its only visible content is the unicode glyph U+21BB (↻). Screen readers would read it as "clockwise open circle arrow" or skip it entirely. Added aria-label using the already-translated UI_TEXT.queue.retryItem string (same value as the title). Also added type="button" so it doesn't default to submit semantics if the queue is ever wrapped in a form.
- .streamer-live-dot: the small red dot that appears next to a streamer name when they're currently live. It's pure CSS styling on an empty span, so screen readers had nothing to read — losing the live state for assistive tech. Added role="img" + aria-label using the existing UI_TEXT.streamers.liveNowTooltip string, so screen readers announce "Live now" alongside the streamer name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the SVG aria-hidden pass from 4.6.110 (which covered index.html). The renderer modules build six more decorative SVG icons into their template strings:
- renderer-profile.ts: followers icon, vods icon, last-stream clock, live-viewers eye
- renderer-queue.ts: merge-group icon next to merge-bundled queue rows
- renderer-streamers.ts: empty-state VOD icon shown when a streamer has no VODs
All 6 are pure decoration — each sits next to a textual label or value (e.g. "1.2K followers", "5 VODs", a viewer count, etc.). Screen readers were either announcing them as "image/graphic" or silently traversing — adding aria-hidden="true" cleanly skips them so the assistive-tech read-out is just the meaningful text.
The data-URL SVG fallback in renderer-streamers.ts:257 (used as an onerror src for VOD thumbnails) is intentionally not touched — it's an image fallback inside a URL-encoded string, not an actual SVG element in the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>