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