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