Commit Graph

131 Commits

Author SHA1 Message Date
xRangerDE
c393457492 a11y: update-banner progress bar role=progressbar + aria-valuenow
Third progress bar in this a11y pass — the download-progress bar
shown in the update banner during an auto-update download. Same
pattern as 4.6.64 (queue) + 4.6.65 (cut/merge): bare div with
JS-driven width, no semantic role.

Promoted the .update-banner-progress-track to role="progressbar"
with aria-valuemin / max / now + a localized aria-label
(updateProgressAria: "Update download progress" / "Update-Download-
Fortschritt").

Three call sites in renderer-updates.ts that drive bar.style.width
now also stamp aria-valuenow on the gauge:
- onUpdateProgress event handler (per-tick percent)
- setDownloadPendingUi (initial 30% indeterminate placeholder)
- setDownloadReadyUi (100% on finish)

renderer-texts.applyText sets the localized aria-label at boot +
on language switch.

That's all three application-level progress bars now AT-friendly.
The same pattern would extend to any future progress UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:36:42 +02:00
xRangerDE
fa440951d2 a11y: cut + merge progress bars role=progressbar + aria-valuenow
Following 4.6.64 for the queue progress bars, the cut + merge
progress containers in their respective tabs had the same gap:
a plain <div class="progress-bar"> wrapping a <div class="progress-
bar-fill"> with no semantic role. JS poked the bar's style.width
on every percent update; AT had no way to read out the running
value.

Promoted both .progress-bar wrappers to role="progressbar" with
aria-valuemin / max / now, plus aria-label sourced from new
locale strings (cutProgressAria / mergeProgressAria) so EN/DE
both work.

The progress event handlers in renderer.ts now also stamp
aria-valuenow on each tick, so AT live regions pick up the
percentage as the cut / merge advances. setAttribute is cheap
relative to the FFmpeg progress event rate (~1/s), no perf
concern.

renderer-texts.applyText sets the localized aria-label on both
gauges at boot + language switch — text contents already get
re-applied through the same path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:31:41 +02:00
xRangerDE
58e52399c5 a11y: queue progress bar — role=progressbar + aria-valuenow
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>
2026-05-11 04:27:13 +02:00
xRangerDE
274d3874f5 cleanup: extract .select-compact class — 4 selects outside .form-group
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>
2026-05-11 04:22:16 +02:00
xRangerDE
32e0b1ab7d security: open-file IPC blocks executable extensions
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>
2026-05-11 04:16:46 +02:00
xRangerDE
c6f423b5ac security: scheme-validate URLs handed to shell.openExternal
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>
2026-05-11 04:12:51 +02:00
xRangerDE
976ca40963 perf: bound the renderer-side VOD storyboard cache (FIFO 100)
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>
2026-05-11 04:08:42 +02:00
xRangerDE
2b4b8ae636 i18n: localize "Invalid time values" alert in clip-cutter
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>
2026-05-11 04:00:27 +02:00
xRangerDE
5d5ffa675b cleanup: merge-tab empty state — DOM-built instead of innerHTML template
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>
2026-05-11 03:56:13 +02:00
xRangerDE
77e4c84c45 fix: live-status poller — eviction now runs even when watch list is empty
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>
2026-05-11 03:51:42 +02:00
xRangerDE
3e37d780c3 a11y: queue-item title + selector keyboard-accessible
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>
2026-05-11 03:46:40 +02:00
xRangerDE
96113dc267 a11y: streamer-profile header — avatar wrap + live card keyboard-activatable
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>
2026-05-11 03:41:21 +02:00
xRangerDE
76be8d3949 a11y: VOD cards keyboard-activatable — opens VOD on Twitch via Enter/Space
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>
2026-05-11 03:37:14 +02:00
xRangerDE
26b03da765 a11y: streamer-item row itself keyboard-accessible + aria-current
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>
2026-05-11 03:32:03 +02:00
xRangerDE
5fda4e2103 a11y: sidebar nav-items keyboard-accessible + aria-current
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>
2026-05-11 03:27:53 +02:00
xRangerDE
1d4b6718b9 a11y: AUTO / VOD / REC streamer chips keyboard-accessible
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>
2026-05-11 03:23:29 +02:00
xRangerDE
35769959f4 a11y: remove-X spans become keyboard-accessible with aria-label
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>
2026-05-11 03:18:18 +02:00
xRangerDE
fedf3a9945 i18n+polish: localize queue detail labels + style the retry button
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>
2026-05-11 03:13:42 +02:00
xRangerDE
ce469b856c cleanup: update-banner progress bar — extract inline styles
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>
2026-05-11 03:07:59 +02:00
xRangerDE
c4201fc6d7 cleanup: unify template-lint visual + drop 3 hardcoded color literals
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>
2026-05-11 03:04:30 +02:00
xRangerDE
1123b9ac46 cleanup: extract .inline-toggle class — compact filter-row toggle pattern
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>
2026-05-11 02:58:52 +02:00
xRangerDE
38a50b7a32 fix: VOD duration badge overlapped the + Queue button
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>
2026-05-11 02:55:54 +02:00
xRangerDE
ac42ec3686 cleanup: extract 17 toggle-row inline styles into one .toggle-row class
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>
2026-05-11 02:53:02 +02:00
xRangerDE
63f1cafe1a cleanup+polish: queue empty state — class-based + visual sibling to sidebar
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>
2026-05-11 02:47:14 +02:00
xRangerDE
5d61094226 cleanup: extract recurring inline form patterns into utility classes
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>
2026-05-11 02:43:23 +02:00
xRangerDE
f1b4e6c39a cleanup: storage stats table — extract inline element.style.* into CSS
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>
2026-05-11 02:38:21 +02:00
xRangerDE
dd08f33dc6 perf: trim live-status batch IPC payload + skip empty broadcasts
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>
2026-05-11 02:33:09 +02:00
xRangerDE
e09efd4a33 cleanup: status bar — drop stale v4.1.13 hardcode + extract inline styles
The bottom status-bar version span had "v4.1.13" hardcoded as initial
content from a release several versions ago. init() overwrites it via
window.api.getVersion() on app boot, so the user only ever sees this
for the millisecond between paint and IPC return — but during that
window we were lying about the version, and if init() ever crashed
the user would be stuck looking at v4.1.13 in the corner forever.

Cleared the initial content to empty so the span just doesn't render
text until the real version arrives.

Side effect: extracted the inline `style="color:var(--text-secondary);
font-size:12px; margin-left:auto; padding-right:12px"` from
statusBarQueueSummary to a .status-bar-queue-summary class, plus a
new .status-bar-version class for the version pill. Both consistent
with the rest of the status-bar styling. Bonus: queue summary picks
up font-variant-numeric: tabular-nums so the "X B | Y avg | Z done"
numbers don't jitter horizontally as values update during a
download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:28:28 +02:00
xRangerDE
6fdfa08ecb feat: sidebar empty-state when no streamers added yet
First-launch (or after-clearing-everything) opens the app with an
empty sidebar streamer list — just the "Streamer" section heading
and a blank area below. New users had no in-app indication of where
to add their first streamer. The "Add streamer..." input lives in
the TOP bar, which is non-obvious from the sidebar context.

renderStreamers now short-circuits on empty streamers[] and stamps
a small dashed-border hint card into the list with locale-driven
copy pointing the user at the top-right input ("No streamers yet.
Add one via the input at the top right." / "Noch keine Streamer.
Fuege oben rechts einen hinzu.").

The empty state styling (.streamer-list-empty) is intentionally
subtler than the full-page .empty-state used for the VOD grid —
dashed border + tinted background + small padding so it fits the
narrow sidebar rail without dominating it.

Also clears the streamer-section-counter on this branch and hides
the bulk-remove X button, since both would otherwise have stale
state from a previous non-empty render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:50 +02:00
xRangerDE
5f514b1700 i18n: localize 3 empty-state strings (VOD grid + Merge tab)
Three empty-state texts hardcoded German in the HTML and never wired
through the locale system:

- VOD grid empty state: "Keine VODs" + "Wahle einen Streamer aus
  der Liste oder fuge einen neuen hinzu." Shown when no streamer is
  selected. English users were reading German strings here despite
  the rest of the app rendering in English.

- Merge tab empty state: "Keine Videos ausgewahlt." Shown in the
  Videos zusammenfugen tab before any files are added.

Existing locale tables already had `vods.noneTitle` /
`vods.noneText` / `merge.empty` in both EN and DE — they just
weren't being applied. Added IDs to the three elements
(vodGridEmptyTitle / vodGridEmptyText / mergeEmptyText) and
wired three setText calls in renderer-texts.applyText.

Zero new locale keys; pure plumbing fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:20:29 +02:00
xRangerDE
9afff4b8b0 feat: events viewer rows — class-based + data-type colour pills
renderEventsList was painting every timeline row with ~7 inline
style props per element (padding, border-bottom, font-size on the
row; margin-right, color on each span; padding-top on detail) AND
keeping a JS-side colour map per event type. For a chatty recording
with 100+ events the inline-style noise added up, and adding a new
event type meant editing the renderer to extend the map.

Extracted:
- .event-viewer-row picks up the padding + bottom border + font-size
- .event-viewer-time gets the secondary colour + monospace stack
- .event-viewer-tag becomes an actual pill (uppercase, letter-
  spacing, rounded background tint, bordered) — visually consistent
  with the chat viewer's [type] chip tag
- .event-viewer-detail handles the row-detail line spacing

Per-type colour is now driven by CSS [data-type="..."] attribute
selectors (recording_start = green, recording_end = purple,
recording_resume = blue, title_change = amber, game_change = red).
Each variant overrides background + border + text colour to give
each tag a contained "pill" look. The renderer just stamps
ev.type onto data-type and the CSS handles the rest.

Adding a new event type now means one new selector here, not a JS
map edit. Lint, focus, future polish all stay near the styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:14:33 +02:00
xRangerDE
5da4cc9e64 cleanup: unify filter inputs + monospace template inputs into shared classes
Five filter-style text inputs (vodFilterInput, streamerListFilter,
archiveSearchQuery, chatViewerFilter) plus three monospace template
inputs (vod / parts / clip filename templates) were each carrying
their own ~80 chars of inline style declaring near-identical
background / border / radius / padding combinations.

Consolidated into three new utility classes:
- .filter-input — base flex-1 minWidth-180 filter look, used by
  vodFilterInput
- .filter-input.compact — small variant for the sidebar streamer
  filter (smaller padding, smaller font, no flex, percent-width
  with margin)
- .filter-input.flex-1-1-240 — larger variant for the archive
  search box (240px basis, 200px min, smaller radius/padding to
  fit the multi-control form-row it sits in)
- .input-monospace — applies the same monospace stack (Consolas /
  Segoe UI Mono / monospace) used by .chat-viewer-time and
  .viewer-modal-list-chat to text inputs that hold code-shaped
  values

Side effect: vodHideDownloadedToggle had a hardcoded
`accent-color: var(--accent); cursor:pointer;` inline style, which
was redundant after the global custom-checkbox styling landed in
4.6.26 (the checkbox is now ::after-driven, accent-color does
nothing). Removed.

Zero visual change. The inputs render identically because the
class CSS values match what was inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:09:45 +02:00
xRangerDE
ee8f9425fc cleanup: extract events/chat viewer inline styles + drop dead var
The events-viewer and chat-viewer modals were each carrying ~5 inline
styled elements (modal sizing, status text, list container, filter
row + filter input) duplicated between the two modals. Edits to one
viewer left the other drifting visually.

Extracted to a shared .viewer-modal* family in styles.css:
- .viewer-modal sets the column flex layout
- .viewer-modal-events / .viewer-modal-chat set their own sizing
- .viewer-modal-title / .viewer-modal-status / .viewer-modal-list +
  inline + chat list variants for the data area
- .viewer-modal-filter-row + .viewer-modal-filter-input for the
  chat viewer's filter

Zero visual change; just stops the two viewers from drifting and
unblocks future polish (skeleton states inside the list, sticky
filter row, etc.) without an inline-edit-by-inline-edit grind.

Side: removed lastArchiveStatsScannedAt module variable in
renderer-stats.ts. It was assigned in refreshArchiveStats but never
read anywhere — leftover from an early plan to compare against a
previous timestamp before refreshing. The renderer-rendered "Last
scan" line reads stats.scannedAt directly. Dead, removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:05:37 +02:00
xRangerDE
b37244cccf a11y+i18n: localize modal close aria-labels + strip dead modal title fallbacks
Two related artifacts from the 4.6.31 a11y pass.

aria-label="Close" was hardcoded English on all five modal-close X
buttons — anyone running the German locale would still hear "Close
button" from their screen reader. Added a shared
.modal-close-localizable class on each X, plus a streamers.modalCloseAria
locale string ("Close dialog" / "Dialog schliessen"), plus a small
setAriaLabelAll helper in renderer-texts that resolves the class via
querySelectorAll and applies the localized label in one shot. Now all
five modals announce in the active language.

While editing the modal headers, also removed the dead "Stream events"
and "Chat replay" English fallback text from eventsViewerTitle and
chatViewerTitle. Both h2s get their textContent overwritten the
instant openEventsViewer / openChatViewer is called (with the
streamers name or a UI_TEXT fallback), so the inline English text was
never user-visible past first-paint and only mattered to a screen
reader if a user managed to focus an unopened modal. Empty <h2/> is
cheaper and removes the i18n drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:00:28 +02:00
xRangerDE
0df8bf357d feat: clip-cutter modal themed + global radio button styling
Two interrelated changes shipped together.

Clip-cutter modal cleanup. The "VOD zuschneiden" modal was the last
big surface still painted in the apps PRE-purple colour palette:
hardcoded #2b2b2b modal bg, #E5A00D orange title, #1a1a1a slider
tracks (already overridden by the global rule but inline-styles still
sat there), #333 input bgs, #444 borders, plain "white" text, #888
labels, #aaa radio labels. All of it inline. The result: opening the
clip dialog was visually jumping back two themes.

Extracted everything to class-based styles using var() colours:
- .clip-modal* family of classes for layout
- Title now uses var(--text), no orange
- Inputs use var(--bg-elevated) + var(--border-soft) and pick up
  the global focus ring automatically
- The duration display ("Dauer: 00:01:00") now sits in a small
  green-tinted card to make it visually distinct from the input
  rows around it
- Radio labels go through a unified .clip-radio-row with a hover
  background tint, and the :has(input:checked) selector swaps the
  label text colour + weight when a radio is selected

Global radio button styling. The clip modal had four radio buttons
that were the only non-OS-themed control left in the app. Custom
.appearance:none + ::after-driven dot styling matching the new
checkbox visual: 16px circle, 1.5px border, hover purple tint,
checked fills the inner circle with the accent colour + a soft
purple shadow, focus-visible has the same 3px purple ring as every
other form control. Cascades globally so any future radio gets the
treatment for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:56:07 +02:00
xRangerDE
afef213b45 a11y: dialog roles + aria-labelledby + aria-label on modal closes
All five modal-overlay containers (update, clip-cutter, events-viewer,
chat-viewer, template-guide) were rendering as plain divs from an
accessibility perspective. Screen readers would announce nothing
distinguishing when one of them opened, and the close-X buttons would
read as "x button" with no semantic meaning.

Added on each .modal-overlay:
- role="dialog" — tells assistive tech this is a modal region
- aria-modal="true" — instructs the reader to ignore content outside
  the dialog while it is open (matches the keyboard escape + click-
  outside-to-dismiss behavior the renderer already implements)
- aria-labelledby="<existingTitleId>" — every modal already had a
  uniquely-IDd h2; pointed each dialog at its own title so the reader
  announces e.g. "Stream events dialog" on open

Added on each .modal-close button:
- aria-label="Close" — gives the X button a real semantic label
  independent of the visual character

Zero visual change, zero behavior change. Just makes the app actually
usable for someone running NVDA/JAWS/Orca/VoiceOver. WCAG 4.1.2 +
2.1.1 + 1.3.1 alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:50:26 +02:00
xRangerDE
f93b07c87a cleanup: remove dead fetchOnlyFollowerCount + clarify profile inferred type
Two related artifacts left over from the avatar/banner GQL refactor
in 4.6.20:

- fetchOnlyFollowerCount was an early standalone helper from the
  iteration where Helix supplied core profile fields and a separate
  public-GQL roundtrip pulled just the follower count. The 4.6.19
  rewrite folded all of that into a single public-GQL query, so the
  helper has no callers. Removed.

- streamFromPublic was typed via
  `Awaited<ReturnType<typeof fetchPublicStreamerProfile>> extends ...`
  conditional inference because the inline stream shape was anonymous.
  That worked but read like a riddle. Hoisted the inline shapes to
  two named interfaces (PublicStreamInfo + PublicStreamerProfileResult)
  so the function signature is explicit and the local var is just
  `PublicStreamInfo | null`. Same type, an order of magnitude more
  obvious to anyone reading.

Both changes are zero-runtime-behavior; tests confirm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:13 +02:00
xRangerDE
9115819bb0 feat: VOD bulk-action bar — slide-in animation + style extracted from inline
The bulk-action bar (the purple-tinted row that appears between the
VOD filter and the grid when 1+ VOD checkbox is ticked) was 100%
inline-styled in HTML, which meant:
- No animation when it appears — it just popped into existence
- No reusable styling for similar action surfaces later
- Layout debugging meant editing HTML, not CSS

Extracted to a proper .vod-bulk-bar class, plus .vod-bulk-count for
the "N selected" label and a .vod-bulk-spacer for the flex push.
The CSS rule also picks up a 4px soft purple shadow + a slightly
richer gradient background that matches the rest of the purple
surfaces in the app.

Animation: vod-bulk-bar-slide @keyframes fires every time the JS
flips display:none -> display:flex, because @keyframes restart on
each display change. 220ms, cubic-bezier(0.16, 1, 0.3, 1) for a
quick spring landing, 10px translateY-from + opacity 0->1. The
appear feels intentional now instead of jarring.

Disappear (display:flex -> none) still snaps because CSS cannot
transition through display:none — adding that would require a
class-toggle refactor and an explicit timer to defer the actual
removal. Not worth the complexity for the polish-grade improvement
this is going for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:41:08 +02:00
xRangerDE
b9f2b68596 feat+cleanup: active streamer highlight polish + dead scrollbar rule removed
Active streamer state in the sidebar was a flat purple-tinted
background with a 3px left border. Felt slightly weak as the primary
"this is what you're looking at" affordance, especially with the
similarly-tinted hover state immediately next to it.

Bumped to:
- Gradient background fading purple-strong to purple-faint across
  the row, so the active item has a directional emphasis matching
  the rest of the Twitch-purple language.
- 1px inset purple ring on top so the active state reads as a
  clearly-bordered card, not just a tinted background.
- A small purple right-edge marker (3px wide, 60% tall, centered)
  drawn via ::after — mirrors the existing left border and makes
  the selected row feel "framed".
- Streamer name in the active row goes 600 weight so the identity
  pops over the meta toggles next to it.

Cleanup side: the old generic ::-webkit-scrollbar rule block from
the early days of the app was still in the file at line 1497, even
though the newer purple-themed *::-webkit-scrollbar block further
down has been overriding it for several releases (later wins on
identical specificity). Replaced the old block with a one-line
comment explaining where the live rule lives, so the next person
greping for "scrollbar" doesn't get a misleading hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:36:32 +02:00
xRangerDE
227c4bdf82 feat: range slider repaint + number input spinner cleanup
Two leftover form-control oddities from the audit.

Range sliders (used in the clip-cutter modal and any future
slider-group settings) had a stale orange thumb colour (#E5A00D) from
when the app was a different shade of Twitch. Reskinned to the
current purple family: track gets a subtle purple-to-dark gradient
that visually echoes the queue progress bar, thumb is a 16px purple
circle with a 2px white border and a soft shadow, hover scales the
thumb 1.15x and turns the shadow into a purple halo for the "I can
grab this" affordance. Focus-visible adds the same 3px purple ring
the rest of the form controls use, so keyboard tabbing through a
modal lands on a clearly-focused slider. Mirrored ::-moz-range-thumb
+ ::-webkit-slider-thumb so Firefox and Chromium-Electron look
identical.

Number inputs got the OS spinner stack hidden globally
(::-webkit-inner-spin-button / outer + -moz-appearance: textfield).
The default Webkit spinners are a tiny gray arrow pair that always
reads as "unfinished" and clutter the look. Users still get keyboard
arrow keys + wheel scroll. If a custom spinner pattern is needed
later it can come back as a chrome around the input — for now the
inputs read clean as text fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:31:29 +02:00
xRangerDE
12fd2b7217 feat: custom-styled checkboxes + select dropdowns
Audit turned up 20 raw `<input type="checkbox">` in Settings still
rendering with OS-default gray-square look — and most `<select>`
elements showing the OS-default dropdown arrow. With the rest of the
UI now Twitch-themed (purple inputs, modal pops, animated everything),
those felt jarringly out of place.

Checkbox: 16px rounded square, dark base with 1.5px border, hovers to
a purple-tinted border, fills purple + draws a CSS-only white check
on the diagonal when checked, scales down briefly on click, focus
shows the same 3px purple ring as the text inputs. No JS, just
:checked + ::after.

Select: appearance:none everywhere to kill the OS chevron, then an
inline-SVG chevron in background-image at right:8px (gray default,
purple on hover). padding-right boosted to 28px so option text never
overlaps the arrow. The dropdown menu itself still uses the OS list,
but the closed control matches the rest of the input family now.

Cascades globally via input[type="checkbox"] / select selectors — no
markup edits needed. The few selects/checkboxes that previously had
inline accent-color overrides keep working because accent-color
applies to native widgets, which we have now replaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:27:04 +02:00
xRangerDE
f7a54a2007 feat: sidebar streamer counter + VOD duration badge + queue shimmer + chat polish
Round-4 polish.

- Streamer section counter. Tiny line next to the "Streamer" sidebar
  heading: "12" when nobody is live, "12 · 3 live" with the live
  count highlighted red when broadcasters from the watch list are
  on air. Re-rendered on every renderStreamers call so it stays in
  sync with add/remove and the 60s live-status poll.

- VOD duration badge. Twitch-style bottom-right pill on every VOD
  thumbnail showing the recordings duration ("32h37m9s"). 11px,
  white-on-near-black, 2px backdrop-blur, hover deepens the
  background, fades out when the storyboard preview activates so
  the preview frame reads cleanly. Pairs with the existing
  downloaded checkmark badge (top-left) and live-recording badge
  to give each thumbnail a complete at-a-glance status row.

- Queue progress bar shimmer. The fill bar now uses a purple-to-
  light-purple gradient and rides a moving white-translucent
  highlight strip that sweeps L->R every 1.8s. Same translateX-100%
  to 100% trick used everywhere else, but only visible because
  the underlying bar has colour. Makes "currently downloading"
  obvious without needing a separate spinner.

- Chat viewer polish. Replaced the inline per-message styling with
  proper .chat-viewer-* classes: hoverable row background, system
  events (subs/raids/deletions) get a left-purple-border + tinted
  background to set them apart from normal chat lines, the type
  tag (e.g. [sub], [raid]) renders as a real chip with a border,
  timestamps are mono-fonted and faded. Per-user IRC colour from
  Twitch metadata is still respected as an inline override on the
  username.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:23:18 +02:00
xRangerDE
17b715ab24 fix+feat: input focus ring + queue item polish + toast + button class collision
Polish round 3, plus a class-collision fix.

Fix first: the new X-close button class introduced in 4.6.21 was
called .btn-icon, which collided with an EXISTING .btn-icon class
already used by the top-bar Refresh button (and elsewhere). The
second declaration partially overrode the first, leaving Refresh
with the wrong hover state (red instead of purple-tinted). Renamed
the close-button class to .btn-close and updated the two call sites
(btnStreamerBulkRemove + vodFilterClearBtn). Refresh now hovers
correctly with a purple tint + a 180deg SVG icon spin on hover.

Polish bundle:

- Input focus ring globally: every text/search/number/password
  input + textarea + select picks up a 3px rgba purple ring on
  focus, with a smooth 180ms transition on border-color, box-shadow,
  and background. Focus state finally reads as intentional instead
  of the OS default blue glow.

- Queue items: 3px left border that color-codes by status (purple
  while downloading, green when complete, red on error), faint
  purple-tinted background when downloading, soft glow on the
  status dot. The queue list now reads as a status timeline at a
  glance.

- Top-bar Refresh button picks up a 1px border, purple-tint hover
  background, and the SVG arrow spins on hover for the "refresh"
  feel.

- Header search box (Add streamer): consistent border-radius (6px
  vs 4px) and the + button gets a hover shadow + active-press
  micro-bounce.

- App toast: gradient background, accent-color left border (purple
  for info, amber for warn, red for error), animated slide-in from
  the right instead of vertical, backdrop blur for content
  legibility over busy backgrounds, and an extra .error variant
  class. Feels modern instead of like a notice strip from 2010.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:18:11 +02:00
xRangerDE
cc23f1e272 feat: skeleton cards + tab fade + modal pop animation + scrollbar polish
Visual-polish round 2.

- VOD skeleton loader: replaces the "Loading..." placeholder with
  six shimmering skeleton cards that share the real cards
  dimensions. The grid no longer collapses+expands as VODs arrive,
  and the shimmer telegraphs that work is happening rather than
  the app sitting silent. CSS @keyframes skel-shimmer drives a
  smooth 1.5s gradient pan.

- Tab switch animation: 180ms ease-out fade-in + 4px lift on
  every .tab-content.active. Switching between VODs / Statistik /
  Archiv / Einstellungen no longer feels like an instant
  paint-swap.

- Modal overhaul: backdrop-filter blur(8px) on the overlay so the
  app behind softly blurs out, animated pop on the modal itself
  (scale 0.96 -> 1 + translateY 8px -> 0 with a clean spring
  curve), proper bordered + glow-cornered card, and the close X
  swapped from a flat 24px text button to a real 30x30 rounded
  pill with hover-red highlight.

- Scrollbar: thin 10px purple-tinted webkit scrollbar across the
  entire app, matching the accent color. Hover deepens to full
  purple. Track is near-transparent. Looks intentional instead of
  the default OS gray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:14:15 +02:00
xRangerDE
11883889de feat: sidebar live indicators + polished hover + empty-state animation
The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.

How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
  every streamer in the user's watch list into a single GQL query
  using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
  chunked at 50 logins per request. One roundtrip for the whole
  list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
  `live-status-batch-update` event with only the entries that
  flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
  keeps a parallel Map, and re-renders the streamer list on
  change. Stamps a .streamer-live-dot before the name. Bold name
  for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
  via save-config) so a freshly added streamer gets their dot in
  seconds without waiting for the next 60s tick.

Polish bundled in the same release:

- VOD card hover gets a more substantial lift: 12px shadow + faint
  purple border-glow on hover. Subtle but enough to feel
  "tactile". Border-color transitions alongside the shadow.

- Empty states get a floating animation and a bigger SVG icon
  with accent-colored tint. "No VODs / select a streamer" now
  feels intentional instead of an oversight.

- Streamer-name span dedicated class (.streamer-name +
  .streamer-name.is-live) so a live streamer's name itself bolds,
  not just gets a dot beside it.

Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:11:26 +02:00
xRangerDE
30776c02b9 fix: sticky header opaque + banner visible + missing button styles
Three things from screenshot feedback against 4.6.20:

1) VODs visible through/above the sticky profile header. Root cause
   was a stack: the 0.10/0.04 alpha gradient over var(--bg-card)
   pushed the resulting background just barely under "opaque" in
   some renderers AND .content has padding-top: 25px which let
   VODs scroll through the area above the sticky element when
   top: 0 was used. Fix: drop the gradient (banner-bg + ::before
   pseudo handle the visual interest now), use straight
   var(--bg-card), set top: -25px to negate .contents padding so
   the header pins flush with the visible top edge, bump z-index
   to 100, add isolation:isolate to force a new stacking context
   so VODs cannot escape upward through the header.

2) Banner not visible. Was being suppressed by a 0.78-0.92 alpha
   dimming gradient applied via background-image alongside the
   banner URL — readable for text but visually killed the banner.
   Moved the gradient into a ::before pseudo at z-index 1 with
   gentler 0.55-0.78 alpha, dropped banner-bg blur from 18px to
   10px, took opacity from 0.55 back up to 1.0. Banner now
   actually shows behind the content the way twitch.tv does.

3) Stray un-styled buttons. Scan turned up a handful of action
   buttons rolling their own inline styles (.vodBulkAddBtn /
   MarkBtn / UnmarkBtn / ClearBtn, .vodFilterClearBtn,
   .btnStreamerBulkRemove, .clipDialogConfirmBtn) plus a missing
   .queue-detail-btn rule that was leaving every "View chat",
   "View events", "Open file", "Show in folder" button defaulting
   to the browsers gray fallback. Added three reusable classes
   (.btn-pill default/primary/success/danger, .btn-icon, plus the
   missing .queue-detail-btn) and swapped the inline styles for
   the classes. Visual consistency across queue bulk-bar, archive
   search results, and queue item detail rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:04:22 +02:00
xRangerDE
3c73efbad7 feat: banner background + live preview card + VOD hover storyboard + sticky header
Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.

1) Banner background — getStreamerProfile now also pulls
   bannerImageURL via public GQL, fetches the bytes server-side as a
   data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
   renderer puts it behind the header content with blur(18px) +
   saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
   colour identity at a glance, like twitch.tv's channel page.

2) Live preview card — when isLive, the public-GQL stream block also
   carries previewImageURL(640x360), viewersCount, title, game{name}.
   A second card slides in below the main profile row showing the
   current frame at 240×135, eye-icon viewer count, big bold title,
   game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
   OR on the button triggers triggerLiveRecording — same path as
   the sidebar REC dot, so the recording reaches the queue with
   identical settings.

3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
   pointing at a JSON manifest of sprite-sheet images, each a grid
   of preview thumbnails spanning the recording. New IPC
   get-vod-storyboard fetches the manifest, picks the high-quality
   first sprite, fetches its bytes as a data URL, and returns the
   grid metadata. Renderer (new renderer-vod-hover.ts) hooks
   delegated mouseover on #vodGrid: 220ms debounce, then on
   activation overlays a div positioned over the thumbnail with
   background-image=sprite + a setInterval cycling
   background-position through 4 evenly-spaced cells at 600ms each.
   Per-VOD result cached client-side so repeated hovers don't
   re-fetch. Negative results (private VODs, expired) are also
   cached so we don't re-query a known-empty manifest.

4) Sticky header — position:sticky;top:0;z-index:20 plus a
   backdrop-filter:blur(6px) so the VOD grid scrolling underneath
   reads through the banner subtly. Header stays anchored to the top
   of .content as the user scrolls hundreds of VODs.

GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.

Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:55:17 +02:00
xRangerDE
ec48592503 fix: public-mode profile shows letter X — GQL query referenced authed-only field
Root cause of the X-fallback in the new profile header when the app
runs without Twitch credentials ("public mode"): the GQL query in
fetchPublicStreamerProfile asked for `broadcasterType`, which exists
on the AUTHENTICATED Twitch GQL schema but NOT on the public one. The
public endpoint returned `errors[]` with "Cannot query field
broadcasterType on type User", which fetchPublicTwitchGql correctly
treats as a complete failure and returns null. That cascaded:
- avatarUrl stayed empty
- displayName fell back to the lowercase login
- description stayed empty
- partner/affiliate badge never rendered
- the renderer hit the letter-tile fallback path

Reproduced live against gql.twitch.tv with a curl-equivalent: the
exact query worked when broadcasterType was swapped for the public-
schema field roles{isPartner isAffiliate}. xrohat correctly comes
back as Partner, with the full 150x150 avatar URL, real displayName
"xRohat", and 1.25M follower count.

The 4.6.18 data-URL fetch fix is still right (Electron renderer img
loading against the Twitch CDN was its own minor headache) — it just
never got exercised because we never had a URL to fetch in the first
place. With this fix the data-URL path now activates on every
public-mode profile load, and avatars actually render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:43:53 +02:00
xRangerDE
b1fd73cbe6 fix: profile avatar shows fallback X instead of real picture
Reproduction: open the new profile header (4.6.17) for any streamer
with a real Twitch avatar. The fallback gradient letter tile renders
instead of the actual profile picture. Image works fine pasted into
the browser; only the renderer img tag fails.

Cause is Electron renderer image loading against the Twitch CDN
(static-cdn.jtvnw.net) — undocumented but reproducible: the same
HTTPS URL that loads fine in DevTools fails silently from the live
page, firing the img.onerror handler which (by design) swaps to the
letter-tile fallback.

Fix: fetch the avatar bytes in the main process via axios (Node http
client, no renderer / CSP / referrer-policy / CORS shenanigans),
convert to base64 data URL, and put THAT in the profile.avatarUrl
field. The renderer just renders the data URL via img src — same
code path, but the URL is now data:image/png;base64,... so no
external fetch is involved.

Bytes cached by source URL in a small FIFO map (256 entries) so the
same avatar across cache misses only downloads once. Profile cache
itself is unchanged, just stores the data URL now instead of the
remote URL. On a clean restart the user sees the fix on first
streamer click; mid-session a click on Refresh (top-right of the
header) forces a re-fetch through the new path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:41:03 +02:00
xRangerDE
9239eebf34 feat: streamer profile header — modern channel-page card above VOD grid
When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.

The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh

The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.

Backend (main.ts):
- New getStreamerProfile(login) that combines:
  - Helix /users for display_name, profile_image_url, description,
    broadcaster_type (when authenticated)
  - Public GQL fallback for the same fields when not authenticated
  - Public GQL UserFollowers query for the follower count — Helix
    /channels/followers needs a moderator scope we do not have
  - getVODs (already cached) for vodCount + lastStreamAt — zero
    extra network hits when the VOD list is already warm
  - getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
  via the user-configurable metadata_cache_minutes setting), so the
  whole header costs one Helix call + one GQL call once per cache
  window, not on every streamer click.

Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
  renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
  global openTwitchChannel that goes through the existing
  open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
  or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
  bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
  the card never lingers after its streamer is gone.

CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:38:38 +02:00