The download progress bar inside each queue item was a plain
<div class="queue-progress-bar" style="width: 73%;"> with no
semantic indication that it represented progress. Screen readers
just announced the surrounding text ("Downloading...") with no
running value.
Added role="progressbar" + aria-valuemin=0 + aria-valuemax=100 +
aria-valuenow on the wrapping .queue-progress-wrap (since the bar
itself is just the visual fill — the wrap is the semantic gauge
region). aria-label is the status label so AT announces "VOD title
75 percent" instead of an unlabeled gauge.
updateQueueItemProgress also re-stamps aria-valuenow as the
percentage advances, so AT live regions can pick up the running
update without needing a full re-render.
For indeterminate progress (pre-rolling, before the first byte
event arrives) aria-valuenow stays at 0 — the screen reader still
gets a coherent reading even if visually the bar is in
indeterminate-pulse mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vodSortSelect + the 3 archive-search selects all sat OUTSIDE
the .form-group container, which meant the existing
.form-group select rule did not apply to them. Each carried the
same ~110 chars of inline style hard-coding bg / border / radius /
padding / color.
Extracted to a shared .select-compact class (6px radius, 7px
padding, var(--bg-card) base, var(--border-soft) border) and
swapped all four call sites. Visual consistency between the
filter-row select in the VODs tab and the multi-select control
strip in the Archive search — same heights, same border treatment.
Minor visual change for the 3 archive selects (4px -> 6px
border-radius, 6px -> 7px vertical padding) so the controls now
match the rest of the apps button + input family. Worth the 1px
delta for the consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to 4.6.61. The open-file IPC handler (used by the
"Open file" buttons in the queue + archive) was previously a
plain shell.openPath call with only an existsSync check:
if (typeof filePath !== "string" || !filePath) return false;
if (!fs.existsSync(filePath)) return false;
const result = await shell.openPath(filePath);
shell.openPath happily launches any path the OS knows how to
execute. An XSS landing through e.g. a smuggled queue item URL
that reached the renderer-side openFile global function could
pass `C:\\Windows\\System32\\calc.exe` and the IPC would launch
calc.
Added a deny-list of obvious shell-execution extensions (.exe,
.bat, .cmd, .com, .ps1, .vbs, .vbe, .js, .jse, .wsf, .wsh, .scr,
.msi, .msp, .lnk, .cpl, .reg, .hta, .jar, .application). Rejected
calls log to debug + return false to the renderer. Media + text +
image extensions remain unaffected — those open in their normal
default-app viewers, which is the intended use case.
show-in-folder + open-folder stay permissive on extension since
they only open File Explorer (no execution).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The open-external IPC was a pass-through:
ipcMain.handle("open-external", async (_, url) =>
await shell.openExternal(url));
shell.openExternal on Windows happily resolves any URL scheme the OS
knows how to launch — including file:// paths, ms-settings:, shell:,
javascript:, and assorted protocol handlers. The renderer is
contextIsolated + nodeIntegration: false so direct exploits are
blocked, but an XSS landing through (for example) a streamer name
that smuggled HTML into a renderer template would have a clean path
through this IPC to launch arbitrary local executables via the OS
shell.
Validation gate: reject anything that isn't an http:// or https://
URL. Trim before the test so a smuggled leading/trailing whitespace
attempt does not slip through. Rejected requests get a debug-log
entry (truncated to 200 chars so a megabyte payload doesnt nuke the
log) and return silently — the renderer caller already swallows
the promise without checking, so silent-drop matches existing
behaviour.
Defence-in-depth. No known active exploit; just removing an
unnecessary surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vodStoryboardClientCache was a plain Map<vodId, VodStoryboard | null>
with no eviction. Every VOD ever hovered cached its first sprite
data URL — about 50-200 KB each. Browsing a long-running streamer's
2000-VOD archive could leave the renderer holding 100-400 MB of
hover-only sprite data permanently, with no signal to the user
that it was happening.
Wrapped writes in a rememberStoryboard helper that caps the cache
at 100 entries with FIFO eviction (Map iterator is insertion-ordered
so .keys().next().value is always the oldest). Cache hit / miss
semantics unchanged for the live set — only the dropped-off-the-
back entries get re-fetched if the user scrolls back to a VOD they
hovered hundreds of cards ago, and that re-fetch is fast because
the main-process side has its own metadata cache that survives the
renderer-side eviction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
confirmClipDialog (the handler behind the clip-cutter modals "Add
to queue" button) opens an alert with a hardcoded English message
when the parsed start / end / duration values come back as NaN —
which can happen if the user types non-numeric characters or
otherwise breaks the time-input pattern. German-locale users got
an English alert on a German UI.
Added clips.invalidTime to both locales ("Invalid time values" /
"Ungueltige Zeitangaben") and swapped the inline string for the
locale lookup. All the other alerts in that handler already go
through UI_TEXT.clips.* — this was the one outlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When mergeFiles is empty, the renderer dropped an inline-styled
innerHTML template into #mergeFileList:
<div class="empty-state" style="padding: 40px 20px;">
<svg style="opacity:0.3" ...><path ...></svg>
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
</div>
Three issues:
- innerHTML interpolating a locale string (lint hook flags pattern
even though locale strings are app-controlled)
- Inline styles for padding / opacity / margin
- The same SVG icon as the static HTML, duplicated
Built via createElement + createElementNS for the SVG namespace, so
the renderer never touches innerHTML for this branch. Styling moved
to a .merge-empty-state class that scopes the padding override
(needed because the merge file-list sits in a settings-card with
its own padding) without leaking into the global .empty-state used
by the VOD grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtle leak in runLiveStatusBatchPoll: the eviction pass (which
removes liveStatusByLogin entries for streamers no longer in
config.streamers) ran INSIDE the fetch branch — but the fetch
branch is skipped early when logins.length === 0.
Concretely: if a user had 3 streamers all marked live, then
removed all 3, the poll would early-return at length-check,
leaving stale liveStatusByLogin entries forever (until app
restart) — main-process memory + an inaccurate
get-live-status-snapshot IPC response.
Renderer wasn't visibly affected because renderStreamers only
looks up entries for streamers in the rendered list, but the
underlying state was wrong.
Restructured so the eviction pass always runs first based on the
current watch list, then the fetch + diff only runs when the list
is non-empty. Empty-list case still emits "removed -> offline"
changes to the renderer so its parallel map stays in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more click-only divs in the queue item template were leaving
keyboard users stuck:
- .queue-selector — the "X" number-badge to the left of pending
queue items that toggles bulk-select. Previously a div with onclick.
Now role="checkbox" + tabindex + aria-checked tracking the selection
state + Enter/Space keydown handler.
- .queue-item .title — the truncated VOD title that, when clicked,
toggles the expanded detail panel underneath the row. Previously
a div with onclick. Now role="button" + tabindex +
aria-expanded reflecting the panel state + aria-controls pointing
at the details panel ID + Enter/Space keydown handler.
Both pick up 2px purple focus-visible rings to match the rest of
the a11y family.
aria-expanded on a button is the conventional pattern for
"disclosure widget" controls (collapsible/expandable content),
so screen readers will now announce the title as "VOD title,
button, collapsed" or "expanded" as the user navigates and toggles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two click-only divs in the new profile header (4.6.17+) had no
keyboard equivalent:
- .streamer-profile-avatar-wrap (clicking the avatar opens the
channel on twitch.tv) — the only way to trigger that action
besides the "Open on Twitch" button in the action column, so
keyboard users were missing a primary affordance
- .streamer-profile-live-card (clicking anywhere on the live
preview card starts a live recording) — the embedded Record-now
button inside the card already covered keyboard activation, so
this one is more about completeness than necessity
Both got:
- role="button" + tabindex="0"
- aria-label = the existing tooltip locale string (so AT reads
the same text shown to sighted users on hover)
- An inline onkeydown that re-fires the same onclick handler on
Enter / Space. The live-card additionally checks
event.target === event.currentTarget so a focused inner button
pressing Enter doesn't double-fire the wrapper handler
CSS adds focus-visible rings:
- Purple ring on the avatar wrap (matching the existing avatar's
purple border)
- Red ring + glow on the live card (matching the existing card
border colour)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following the chip + row + nav a11y passes, the VOD cards in the
main grid were the last big mouse-only surface in the sidebar +
main panel flow. Each card already delegated click via a single
vodGrid handler — but the card div itself was unfocusable, so a
keyboard user could only reach the +Queue / Trim VOD buttons
inside, never the card thumbnail click that opens the VOD page on
Twitch.
Added on each .vod-card:
- role="button" + tabindex="0"
- aria-label set to the VOD title so AT announces it correctly
("32h37m9s VOD: Cyborg Watchparty button") instead of reading
the whole card content row by row
Added to the existing delegated vodGrid handler:
- A keydown branch that opens the VOD on Twitch when Enter / Space
fires on a focused .vod-card and the event target is the card
itself (not a child action button or checkbox — those have
their own native button / checkbox semantics that handle Enter
/ Space correctly already)
CSS adds a 3px purple focus-visible ring + accent-coloured border
on the focused card, mirroring the hover state's purple glow.
Tab order through the VOD grid now goes: VOD card -> checkbox -> Trim
button -> +Queue button -> next VOD card. Predictable enough for
keyboard navigation through a 38-VOD streamer profile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous a11y pass made all four chip-buttons inside a streamer
row (AUTO / VOD / REC / remove) keyboard-accessible, but the row
itself — the parent .streamer-item div whose click selects the
streamer — was still mouse-only. A keyboard user could focus the
chips but never the row, so they could never select a streamer
without a mouse.
Made the row a focusable role="button":
- role + tabindex on the .streamer-item div
- aria-label set to the streamer's name (so AT announces "xrohat
button" rather than reading every chip child)
- aria-current="true" on the currently selected row (mirroring
the visual .active state) so AT understands which row is the
current selection
- A keydown handler on the row that fires selectStreamer on
Enter / Space, but ONLY when the row itself (not a chip child)
is the event target. The chips already preventDefault +
stopPropagation on their own keydowns so they never reach this
handler — and even if they did, the e.target check guards.
Focus-visible adds an inset 2px purple ring (inset to match the
row's left-border-marker styling for the active state). Tab order
through the sidebar is now: nav-items → streamer row → AUTO →
VOD → REC → remove-X → next streamer row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 7 sidebar nav-items (Twitch VODs / Clips / Cutter / Merge /
Statistik / Archiv / Einstellungen) were plain `<div class="nav-item">`
elements with only an onclick. Same a11y story as the previous two
iterations: no role, no tabindex, no semantic active-state marker,
no keyboard activation.
Added on each nav item:
- role="button" and tabindex="0" so they enter the tab order and
read as activatable buttons to assistive tech
- aria-current="page" applied to the active item, removed from the
others — both managed in showTab() since that's the single
switch point for active-state transitions
- A delegated keydown handler on the .nav container (one listener,
not seven) that fires showTab on Enter / Space for whatever
nav-item descendant is currently focused. Bound once with a
data-keynav-bound guard so init() re-running doesn't double-bind
CSS adds a 2px purple focus-visible ring matching the rest of the
keyboard-focus family added in 4.6.50 and 4.6.51.
WCAG 2.1 success criterion 2.1.1 (Keyboard) — every interactive
element activated by keyboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three streamer-row action chips (AUTO toggle, VOD toggle, REC
one-shot) were spans wired only with click listeners. Same a11y
gap as the .remove X chips in 4.6.50: no role, no tabindex, no
keyboard activation, no semantic state for toggles. Screen readers
read them as raw text "AUTO", "VOD", "REC" with no clue they were
interactive controls.
Factored a wireChipButton helper inside renderStreamers and ran all
three chips through it. The helper stamps:
- role="button" + tabindex="0"
- aria-label (locale-driven, picked up the existing
autoRecordTitle / autoVodTitle / recordLiveTitle locale keys
that were previously only used for the visual title-tooltip)
- aria-pressed="true"/"false" for the AUTO and VOD toggles so
AT announces the on/off state
- A keydown handler that synthesises the same click handler on
Enter / Space and stops propagation so the row's click handler
(streamer-select) does not also fire
CSS adds three focus-visible rings (green for AUTO, blue for VOD,
red for REC) matching each chips active-state colour palette.
Keyboard navigators tabbing through a streamer item now see the
ring on the focused chip clearly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The two `<span class="remove">x</span>` glyphs (one per queue
item, one per streamer-list item) had no semantic role, no
aria-label, no tabindex — entirely mouse-only and screen-readers
just announced them as the bare "x" character.
Made both fully keyboard-accessible:
- role="button" + tabindex="0" so they enter the tab order and
read as buttons to AT
- aria-label="Remove" / "Entfernen" via new
streamers.removeAria locale key (DE + EN)
- Keydown handler on Enter + Space synthesizes the same removal
callback (mirroring native button behaviour for synthetic
buttons — Enter on real buttons fires click, Space does too)
- Focus-visible state: 2px red glow ring + force opacity:1 on
the streamer-list .remove (which is normally opacity:0 until
the streamer-item is hovered) so keyboard navigators can see
the focused X
Both call sites preserved e.stopPropagation in the keydown handler
so Enter on a focused X doesn't bubble up to the row's click
handler (which would trigger streamer-select).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three queue-item detail labels were hardcoded German in the
renderer's HTML template: "Streamer:" / "Dauer:" / "Datum:". The
queue-details panel that expands when a user clicks a queue items
title would render these labels in German regardless of the
selected locale — same i18n gap as the empty states fixed in
4.6.37.
Added queue.detailStreamer / detailDuration / detailDate to both
locale tables ("Streamer:" / "Duration:" / "Date:" in EN,
"Streamer:" / "Dauer:" / "Datum:" in DE) and wrapped the labels
in a .queue-detail-label span so the colour distinction between
label and value (secondary vs primary text colour) is consistent.
The error-state retry button was a span with five inline-style
props and a unicode ↻ as its glyph — visually drab and not really
a button semantically (screen reader read it as text). Promoted
to a real <button class="queue-retry-btn"> with a proper hover
state (purple-tinted background + accent border + white text +
active-press scale-down). Subtler than .btn-pill but obviously
clickable.
Cursor:pointer on .queue-item .title moved from inline style on
the HTML template to the CSS rule — same effect, one less
attribute per queue item.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The update banner that shows during an auto-update download was
hosting three inline-styled divs in a row for its progress
indicator:
<div style="display: none; flex: 1; margin: 0 15px;">
<div style="background: rgba(0,0,0,0.3); border-radius: 4px;
height: 8px; overflow: hidden;">
<div id="updateProgressBar"
style="background: white; height: 100%; width: 0%;
transition: width 0.3s;">
Renderer-updates.ts kept reaching in to mutate
updateProgressBar.style.width as the download advanced — works,
but the bar's static look-and-feel (rounded, dark track, 8px tall,
white fill, 0.3s transition) was buried in HTML attributes
instead of CSS.
Extracted to:
- .update-banner-progress-wrap (the flex:1 container with side
margin)
- .update-banner-progress-track (the rounded dark track)
- .update-banner-progress-bar (the white fill, 0% default, transition)
The JS continues to drive width via style.width, which now layers
on top of the class's transition for the smooth animation. Zero
visual change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two separate places (Settings filename templates + clip-cutter
modal custom template) had their own lint state. Each set the
colour by JS as `lintNode.style.color = "#8bc34a"` (green for OK)
or `"#ff8a80"` (red for unknown placeholder). Same intent, different
implementations, different shades than the rest of the app
(--success #00c853 + --error #ff4444).
Extracted to a shared .template-lint class with .ok / .warn modifiers
driven by the canonical CSS vars. The renderers now swap classNames
instead of inline colours.
Also picked up the stale `color: #888` on filenameTemplateHint and
replaced with the existing .form-note utility class (which uses
var(--text-secondary)).
The old .clip-template-lint rule stays as a no-op alias for safety,
but its hard-coded #8bc34a is removed — colour now comes from
.template-lint.ok / .warn. Three hard-coded hex literals retired,
two state branches consolidated, semantics now track the global
palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Hide downloaded" checkbox in the VOD filter row was carrying
the inline style:
display:flex; align-items:center; gap:6px;
color: var(--text-secondary); font-size:12px;
cursor:pointer; user-select:none
This is a distinct visual variant of the existing .toggle-row class
(filter-row context, smaller gap, smaller font, secondary text
color) so a separate .inline-toggle class is the cleaner fit
rather than overloading .toggle-row with another modifier.
One site cleaned up now, the class is reusable for future
tool-row inline toggles without copy-pasting the seven inline
properties.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported via screenshot against 4.6.44. The Twitch-style "32h37m9s"
duration pill introduced in 4.6.20 was anchored at bottom: 8px right:
8px inside .vod-card. But .vod-card is the WHOLE card — thumbnail
plus info + action buttons row below — so the badge sat at the
bottom-right of that whole stack, landing on top of the rightmost
action button ("+ Queue") and obscuring it. The user couldnt click
through the badge.
Fix wraps the .vod-thumbnail in a .vod-thumb-wrap div with
position:relative so the badge's absolute positioning anchors to
the thumbnail's bounding box. The badge now sits at the bottom-
right of the actual image, exactly where the Twitch convention
puts it.
.vod-thumb-wrap also gets line-height: 0 to kill the inline
baseline whitespace that would otherwise show as a thin gap
between the image and the .vod-info section below.
The storyboard hover preview overlay (renderer-vod-hover.ts) is
unaffected — it still appendChild's into .vod-card and uses
aspect-ratio: 16/9 to size itself, which matches the thumbnail
zone above the .vod-thumb-wrap container exactly. Verified end-to-
end pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Settings tab has 17 checkbox toggles, each wrapping the input +
its label in a `<label style="display:flex; align-items:center;
gap:8px; margin-top: 8px;">`. The first toggle in a group skipped
the margin-top; one indented sub-toggle added margin-left: 22px.
All 18 sites were carrying the same ~50 chars of inline style.
Extracted to .toggle-row + the adjacent-sibling combinator
.toggle-row + .toggle-row { margin-top: 8px }, which automatically
adds the gap between consecutive toggles without needing the
per-instance override. The one indented case becomes
.toggle-row.indented (single rule, single margin-left).
Replacements done via Edit with replace_all (13 + 2 + 1 = 16
exact-match replacements + the one manual indented variant). Zero
visual change.
This will pay off on the next Settings card that adds a toggle —
class="toggle-row" is six characters of meaning vs the previous
~50 character inline-style copy-paste.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar queue empty state was built via inline-style HTML
template: `<div style="color: var(--text-secondary); font-size:
12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}
</div>`. Worked but had two issues:
1. Flat plain-text styling that did not match the
.streamer-list-empty card-style empty hint sitting directly
above it in the same sidebar — visually inconsistent.
2. innerHTML interpolation of a locale string. The string is
safe (locale-controlled, not user input), but the lint hook
pattern-matches innerHTML use anyway, leaving the file flagged
on every audit pass.
Rebuilt via createElement / textContent so no innerHTML touches
the locale string, and extracted the styling into a .queue-empty
CSS class that mirrors the .streamer-list-empty card look
(dashed border + tinted bg + 6px radius + centred text). The two
sidebar empty states now read as a family instead of two
unrelated takes on "empty".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit turned up two ~6-occurrence inline-style patterns in Settings:
- `style="font-size:12px; color:var(--text-secondary);"` on small
sub-labels above stacked inputs (autoCleanupDaysLabel,
TargetLabel, ActionLabel, autoVodPollMinutesLabel,
autoVodMaxAgeHoursLabel, statsLastScannedLabel)
- `style="display:flex; flex-direction:column; gap:4px; flex:1;
min-width:NNNpx;"` on labels that wrap a sublabel + control as a
vertical pair in a flex row (3 of these in the auto-cleanup
grid)
Both lifted to .form-sublabel and .form-stack utility classes.
Plus a .form-note for block-level secondary-coloured text (the
cleanupReport "X files would be freed" panel). The min-width
values stay inline since they vary per call site (120 / 160 / 200).
Zero visual change — the class values match what was inline.
Future edits to "what does a small label look like" go through
one selector instead of grep + sed across six sites.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderStorageStats was building the storage-stats table in
Einstellungen by setting ~10 style props per element with
.style.padding / .style.color / .style.borderBottom / etc. Verbose
in TypeScript and harder to retheme than CSS-class-based markup.
Extracted to .storage-stats-table family in styles.css:
- Header cells become uppercase 10px tracking-wide labels (matches
the look of the .vod-bulk-count "X selected" label and the
archive search type-pill chips)
- Body rows pick up a hover-background tint for scannability
- Numbers use font-variant-numeric: tabular-nums so file counts
and byte sizes don't jitter as values change between scans
- Last row drops the bottom border so the table doesn't end on
a dangling line
Open-folder button was using .btn-secondary with inline font-size
+ padding overrides — swapped to the .btn-pill class, which is
already the small/compact action button used in vod-bulk-bar and
the archive search results. Visual consistency across the app.
The "Other folders" subheading (.storage-stats-section) gets the
same uppercase-tracking look as the table headers — small
consistency win that ties the section together visually.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The live-status batch poller (60s cadence, every streamer in the
watch list) was sending two things on every tick:
- `changes` — the diff vs. the previous tick, used by the renderer
- `snapshot` — the full Map<login, boolean> serialized as a record
Renderer destructures only `changes` (renderer-streamers.ts line 20).
The snapshot field was wire-noise. For a typical 30-50 streamer
watch list, that snapshot is ~1.5KB of JSON every minute, never
read on the other side. Dropped from the broadcast payload.
Initial-state sync still works: the renderer's
initLiveStatusSubscription calls window.api.getLiveStatusSnapshot()
once at boot to pre-fill its map. The broadcast is only for diffs.
Also added a short-circuit on the main side: if changes.length === 0
(every streamer's live status matched the cached value this tick),
don't broadcast at all. The renderer would just iterate an empty
array and trigger a no-op render; saves the wakeup entirely.
Type signature updates ride through preload.ts +
renderer-globals.d.ts so the API contract stays accurate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>