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>