Ctrl+F was wired to focus the VOD filter input — but only when the
VODs tab was active. On the Archive tab (added in 4.6.15) Ctrl+F
did nothing useful: the browser default find bar was suppressed
(Electron renderer doesn't have one anyway) and the app handler
didn't have a branch for the archive context.
Now Ctrl+F also targets the archiveSearchQuery input when the
Archive tab is the active tab. Other tabs (Clips / Cutter / Merge /
Stats / Settings) let the shortcut fall through to no-op since
they don't have a primary search/filter input.
Same input-focus convention as the VODs tab: focus + select-all so
the user can immediately type to replace or append.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the Statistik (4.6.14) and Archiv (4.6.15) tabs were added to
the sidebar nav, the TAB_IDS const never got extended past its
original five entries:
const TAB_IDS = ["vods", "clips", "cutter", "merge", "settings"]
Two consequences:
1) Ctrl+1..5 keyboard shortcut was hard-capped at five tabs (the
guard `tabIndex < TAB_IDS.length` filtered Ctrl+6 and Ctrl+7 out).
Even though there were 7 visible tabs.
2) persistActiveTab(tab) called isKnownTab(tab) before localStorage
write. For 'stats' or 'archive' that returned false, so the tab
was silently NOT persisted. Open the app on the Archiv tab,
close it, reopen — it'd boot on VODs because the persisted value
was the previous non-stats/archive selection.
Extended TAB_IDS to all seven nav-items + bumped the keyboard
shortcut range from 1-5 to 1-7. Ctrl+5 now maps to Statistik,
Ctrl+6 to Archiv, Ctrl+7 to Einstellungen. Persistence works for
the full set.
Added a comment to TAB_IDS pointing out the failure modes when
this is out of sync with the HTML nav, so the next nav addition
doesn't repeat the bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three console.log calls in main.ts were flooding stdout during normal
operation:
1) `console.log("Starting download:", cmd, args)` — redundant with
the appendDebugLog("download-part-start", ...) one line below.
Duplicate logging; pure noise.
2) `console.log("Streamlink:", line)` — fired for every line of
streamlink stdout, which is 10-100 lines/sec during an active
download. Hundreds of thousands of lines per multi-hour recording.
Progress + state parsing already happens on the same line; the
raw output was never consumed.
3) `console.log("Download progress: X%")` in the autoUpdater
handler — fires ~10x/sec during an in-flight update download.
The renderer banner is the user-visible feedback; this was
developer-only and never necessary in prod.
Removed all three. The remaining four console.log calls (login
flow, update-available, update-downloaded, no-updates-available)
are once-per-event and fine to keep.
Practical benefit: stdout becomes useful for actual diagnostics
again. Performance gain is marginal in absolute terms but the
buffered noise on a long-running session was real.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final piece of the renderer-stats.ts extraction. The recording-size
distribution histogram (6 buckets: <100MB ... >10GB) was rendering
each bucket-row as a 5-inline-style template — same shape as the
top-streamers list (margin row, flex meta header, two spans, bar
track, bar fill).
Extracted to a .stats-bucket-* family in styles.css:
- .stats-bucket-row + .stats-bucket-row:last-child margin trim
- .stats-bucket-meta + .stats-bucket-meta-sub for the flex label/
count header
- .stats-bucket-bar-track + .stats-bucket-bar-fill for the
horizontal bar (with width-transition so the bar fills
animate on data refresh)
That completes the Statistik tab pass — 26 inline styles -> 22
CSS class assignments + 4 truly-dynamic width/height percent
overrides for the bar fills. Tabular numerics, hover states, and
data refresh animations all flow from the central stylesheet now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continuing the renderer-stats.ts inline-style extraction. The
"Aktivitaet (letzte 30 Tage)" bar chart built each day-column as
a 5-inline-style template:
<div style="flex: 1; display:flex; flex-direction:column;
align-items:center; gap:4px; min-width:0;">
<div style="width: 100%; height: 90px; display:flex;
align-items: flex-end;">
<div style="width:100%; height: 70%;
background: var(--accent, #9146ff);
border-radius: 2px 2px 0 0;" title="...">
<div style="font-size: 9px; color: var(--text-secondary);
white-space: nowrap;">
30 columns rendered per refresh meant ~7.5KB of duplicated inline
style attribute strings in the DOM after every refresh.
Extracted to .stats-day-col + .stats-day-bar-track + .stats-day-bar-
fill + .stats-day-label, plus .stats-activity-row + .stats-activity-
summary for the outer wrappers. Only the per-day height percent
stays inline (it's truly dynamic, per-day data).
Polish riders:
- Bar fill picks up height: 0.3s ease-out transition so the bars
animate up on data refresh instead of snapping
- Hover state shifts the bar from accent to accent-hover so the
hovered day reads as the focus
- Day-label spans get tabular-nums so the "05-12" type strings
align column-to-column
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second pass on the Statistik tab. The top-10 streamers-by-size
list rendered each row as a 6-inline-style template (margin,
two flex containers, two span colour overrides, two bar wrappers,
two bar fills with hard-coded gradient).
Extracted to a .stats-top-* family in styles.css:
- .stats-top-row — outer row spacing
- .stats-top-meta + .stats-top-meta-sub for the label/byte-size
flex header
- .stats-top-share for the muted (X.Y%) suffix
- .stats-top-bar-track + .stats-top-bar-fill for the gradient
progress bar (now with a width-transition for the streamer-by-
streamer animation when the data refreshes)
- .stats-top-bar-labels for the overlaid LIVE/VOD breakdown that
gets pointer-events: none so the bar isn't accidentally hover-
blocked
Also picked up the "no top streamers" empty-state message and
swapped its inline-style div for the existing .form-note utility
class introduced in 4.6.42.
Top streamers row hover state intentionally NOT added — these are
read-only summary rows, not interactive ones.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The six-tile overview grid at the top of the Statistik tab built
each KPI card as a four-property inline-styled div:
<div style="background: var(--bg-elevated); border: 1px solid
var(--border-soft); border-radius: 6px; padding: 12px;">
<div style="font-size: 11px; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 0.5px;">
<div style="font-size: 22px; font-weight: 600; margin-top: 4px;">
<div style="font-size: 12px; color: var(--text-secondary);
margin-top: 4px;">
Each card repeated the same ~250 chars of inline styling. Card hover
state, number alignment, future polish all required editing the
renderer.
Extracted to .stats-kpi-card + .stats-kpi-label + .stats-kpi-value
+ .stats-kpi-sub. Added two enhancements while at it:
- subtle hover state (purple-tint border + 1px lift) so the cards
feel interactive in line with the rest of the apps language
- font-variant-numeric: tabular-nums on values + subs so the
numbers align properly across the six-tile grid
Also stats-no-root for the "Download folder not found" fallback
that grid-column-spans across all 6 columns.
The remaining 22 inline styles in renderer-stats (top-streamers
bar list, activity calendar, size buckets) come in subsequent
iterations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
renderArchiveSearchResults was building each result row as an HTML
template literal carrying ~10 inline-style props per row (flex
layouts, padding, border-bottom, font-sizes, secondary text colour,
ellipsis truncation, gap...). For a 200-hit search that meant
~2KB of duplicated inline style noise in the DOM and made any
visual tweak require editing the renderer.
Extracted to a .archive-result-* family in styles.css:
- .archive-result-row + hover-tint (table-row scannability — was
missing before, every row read flat)
- .archive-result-body / -meta / -streamer / -date / -filename /
-size / -actions for the column layout
- .archive-type-badge with .live + .vod modifiers for the LIVE/VOD
pill (was two separate inline-styled spans with hard-coded
rgba colours)
- .archive-no-matches for the empty-state line
Dates + sizes in the row pick up font-variant-numeric: tabular-nums
so columns of numbers align even when filenames are different
widths. Last-child gets its bottom border dropped so the list
doesnt end on a dangling line — same treatment as the storage
stats table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showAppToast spawns / reuses a single floating toast at the bottom-
right of the window for transient status (e.g. "1 new VOD auto-queued",
"Cannot start recording", etc). The toast had no a11y semantics —
screen readers never announced it, so the entire transient-feedback
channel was silent for AT users.
Promoted the toast container to a live region:
- role="status" for info toasts + aria-live="polite" so the reader
waits for a natural break in current speech before announcing
- role="alert" for warn toasts + aria-live="assertive" so the reader
interrupts whatever it was saying (matches the visual amber-left-
border meaning — warn IS urgent)
- aria-atomic="true" so the reader announces the whole message at
once instead of attempting to diff against the previous toast
Critical detail: aria-live attributes have to be in place BEFORE the
text changes for AT to register the change as a live-region update.
The current implementation now sets role / aria-live first and only
then writes the new textContent.
WCAG 4.1.3 — Status Messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The download progress bar inside each queue item was a plain
<div class="queue-progress-bar" style="width: 73%;"> with no
semantic indication that it represented progress. Screen readers
just announced the surrounding text ("Downloading...") with no
running value.
Added role="progressbar" + aria-valuemin=0 + aria-valuemax=100 +
aria-valuenow on the wrapping .queue-progress-wrap (since the bar
itself is just the visual fill — the wrap is the semantic gauge
region). aria-label is the status label so AT announces "VOD title
75 percent" instead of an unlabeled gauge.
updateQueueItemProgress also re-stamps aria-valuenow as the
percentage advances, so AT live regions can pick up the running
update without needing a full re-render.
For indeterminate progress (pre-rolling, before the first byte
event arrives) aria-valuenow stays at 0 — the screen reader still
gets a coherent reading even if visually the bar is in
indeterminate-pulse mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vodSortSelect + the 3 archive-search selects all sat OUTSIDE
the .form-group container, which meant the existing
.form-group select rule did not apply to them. Each carried the
same ~110 chars of inline style hard-coding bg / border / radius /
padding / color.
Extracted to a shared .select-compact class (6px radius, 7px
padding, var(--bg-card) base, var(--border-soft) border) and
swapped all four call sites. Visual consistency between the
filter-row select in the VODs tab and the multi-select control
strip in the Archive search — same heights, same border treatment.
Minor visual change for the 3 archive selects (4px -> 6px
border-radius, 6px -> 7px vertical padding) so the controls now
match the rest of the apps button + input family. Worth the 1px
delta for the consistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to 4.6.61. The open-file IPC handler (used by the
"Open file" buttons in the queue + archive) was previously a
plain shell.openPath call with only an existsSync check:
if (typeof filePath !== "string" || !filePath) return false;
if (!fs.existsSync(filePath)) return false;
const result = await shell.openPath(filePath);
shell.openPath happily launches any path the OS knows how to
execute. An XSS landing through e.g. a smuggled queue item URL
that reached the renderer-side openFile global function could
pass `C:\\Windows\\System32\\calc.exe` and the IPC would launch
calc.
Added a deny-list of obvious shell-execution extensions (.exe,
.bat, .cmd, .com, .ps1, .vbs, .vbe, .js, .jse, .wsf, .wsh, .scr,
.msi, .msp, .lnk, .cpl, .reg, .hta, .jar, .application). Rejected
calls log to debug + return false to the renderer. Media + text +
image extensions remain unaffected — those open in their normal
default-app viewers, which is the intended use case.
show-in-folder + open-folder stay permissive on extension since
they only open File Explorer (no execution).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The open-external IPC was a pass-through:
ipcMain.handle("open-external", async (_, url) =>
await shell.openExternal(url));
shell.openExternal on Windows happily resolves any URL scheme the OS
knows how to launch — including file:// paths, ms-settings:, shell:,
javascript:, and assorted protocol handlers. The renderer is
contextIsolated + nodeIntegration: false so direct exploits are
blocked, but an XSS landing through (for example) a streamer name
that smuggled HTML into a renderer template would have a clean path
through this IPC to launch arbitrary local executables via the OS
shell.
Validation gate: reject anything that isn't an http:// or https://
URL. Trim before the test so a smuggled leading/trailing whitespace
attempt does not slip through. Rejected requests get a debug-log
entry (truncated to 200 chars so a megabyte payload doesnt nuke the
log) and return silently — the renderer caller already swallows
the promise without checking, so silent-drop matches existing
behaviour.
Defence-in-depth. No known active exploit; just removing an
unnecessary surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vodStoryboardClientCache was a plain Map<vodId, VodStoryboard | null>
with no eviction. Every VOD ever hovered cached its first sprite
data URL — about 50-200 KB each. Browsing a long-running streamer's
2000-VOD archive could leave the renderer holding 100-400 MB of
hover-only sprite data permanently, with no signal to the user
that it was happening.
Wrapped writes in a rememberStoryboard helper that caps the cache
at 100 entries with FIFO eviction (Map iterator is insertion-ordered
so .keys().next().value is always the oldest). Cache hit / miss
semantics unchanged for the live set — only the dropped-off-the-
back entries get re-fetched if the user scrolls back to a VOD they
hovered hundreds of cards ago, and that re-fetch is fast because
the main-process side has its own metadata cache that survives the
renderer-side eviction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
confirmClipDialog (the handler behind the clip-cutter modals "Add
to queue" button) opens an alert with a hardcoded English message
when the parsed start / end / duration values come back as NaN —
which can happen if the user types non-numeric characters or
otherwise breaks the time-input pattern. German-locale users got
an English alert on a German UI.
Added clips.invalidTime to both locales ("Invalid time values" /
"Ungueltige Zeitangaben") and swapped the inline string for the
locale lookup. All the other alerts in that handler already go
through UI_TEXT.clips.* — this was the one outlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When mergeFiles is empty, the renderer dropped an inline-styled
innerHTML template into #mergeFileList:
<div class="empty-state" style="padding: 40px 20px;">
<svg style="opacity:0.3" ...><path ...></svg>
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
</div>
Three issues:
- innerHTML interpolating a locale string (lint hook flags pattern
even though locale strings are app-controlled)
- Inline styles for padding / opacity / margin
- The same SVG icon as the static HTML, duplicated
Built via createElement + createElementNS for the SVG namespace, so
the renderer never touches innerHTML for this branch. Styling moved
to a .merge-empty-state class that scopes the padding override
(needed because the merge file-list sits in a settings-card with
its own padding) without leaking into the global .empty-state used
by the VOD grid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtle leak in runLiveStatusBatchPoll: the eviction pass (which
removes liveStatusByLogin entries for streamers no longer in
config.streamers) ran INSIDE the fetch branch — but the fetch
branch is skipped early when logins.length === 0.
Concretely: if a user had 3 streamers all marked live, then
removed all 3, the poll would early-return at length-check,
leaving stale liveStatusByLogin entries forever (until app
restart) — main-process memory + an inaccurate
get-live-status-snapshot IPC response.
Renderer wasn't visibly affected because renderStreamers only
looks up entries for streamers in the rendered list, but the
underlying state was wrong.
Restructured so the eviction pass always runs first based on the
current watch list, then the fetch + diff only runs when the list
is non-empty. Empty-list case still emits "removed -> offline"
changes to the renderer so its parallel map stays in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more click-only divs in the queue item template were leaving
keyboard users stuck:
- .queue-selector — the "X" number-badge to the left of pending
queue items that toggles bulk-select. Previously a div with onclick.
Now role="checkbox" + tabindex + aria-checked tracking the selection
state + Enter/Space keydown handler.
- .queue-item .title — the truncated VOD title that, when clicked,
toggles the expanded detail panel underneath the row. Previously
a div with onclick. Now role="button" + tabindex +
aria-expanded reflecting the panel state + aria-controls pointing
at the details panel ID + Enter/Space keydown handler.
Both pick up 2px purple focus-visible rings to match the rest of
the a11y family.
aria-expanded on a button is the conventional pattern for
"disclosure widget" controls (collapsible/expandable content),
so screen readers will now announce the title as "VOD title,
button, collapsed" or "expanded" as the user navigates and toggles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two click-only divs in the new profile header (4.6.17+) had no
keyboard equivalent:
- .streamer-profile-avatar-wrap (clicking the avatar opens the
channel on twitch.tv) — the only way to trigger that action
besides the "Open on Twitch" button in the action column, so
keyboard users were missing a primary affordance
- .streamer-profile-live-card (clicking anywhere on the live
preview card starts a live recording) — the embedded Record-now
button inside the card already covered keyboard activation, so
this one is more about completeness than necessity
Both got:
- role="button" + tabindex="0"
- aria-label = the existing tooltip locale string (so AT reads
the same text shown to sighted users on hover)
- An inline onkeydown that re-fires the same onclick handler on
Enter / Space. The live-card additionally checks
event.target === event.currentTarget so a focused inner button
pressing Enter doesn't double-fire the wrapper handler
CSS adds focus-visible rings:
- Purple ring on the avatar wrap (matching the existing avatar's
purple border)
- Red ring + glow on the live card (matching the existing card
border colour)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following the chip + row + nav a11y passes, the VOD cards in the
main grid were the last big mouse-only surface in the sidebar +
main panel flow. Each card already delegated click via a single
vodGrid handler — but the card div itself was unfocusable, so a
keyboard user could only reach the +Queue / Trim VOD buttons
inside, never the card thumbnail click that opens the VOD page on
Twitch.
Added on each .vod-card:
- role="button" + tabindex="0"
- aria-label set to the VOD title so AT announces it correctly
("32h37m9s VOD: Cyborg Watchparty button") instead of reading
the whole card content row by row
Added to the existing delegated vodGrid handler:
- A keydown branch that opens the VOD on Twitch when Enter / Space
fires on a focused .vod-card and the event target is the card
itself (not a child action button or checkbox — those have
their own native button / checkbox semantics that handle Enter
/ Space correctly already)
CSS adds a 3px purple focus-visible ring + accent-coloured border
on the focused card, mirroring the hover state's purple glow.
Tab order through the VOD grid now goes: VOD card -> checkbox -> Trim
button -> +Queue button -> next VOD card. Predictable enough for
keyboard navigation through a 38-VOD streamer profile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous a11y pass made all four chip-buttons inside a streamer
row (AUTO / VOD / REC / remove) keyboard-accessible, but the row
itself — the parent .streamer-item div whose click selects the
streamer — was still mouse-only. A keyboard user could focus the
chips but never the row, so they could never select a streamer
without a mouse.
Made the row a focusable role="button":
- role + tabindex on the .streamer-item div
- aria-label set to the streamer's name (so AT announces "xrohat
button" rather than reading every chip child)
- aria-current="true" on the currently selected row (mirroring
the visual .active state) so AT understands which row is the
current selection
- A keydown handler on the row that fires selectStreamer on
Enter / Space, but ONLY when the row itself (not a chip child)
is the event target. The chips already preventDefault +
stopPropagation on their own keydowns so they never reach this
handler — and even if they did, the e.target check guards.
Focus-visible adds an inset 2px purple ring (inset to match the
row's left-border-marker styling for the active state). Tab order
through the sidebar is now: nav-items → streamer row → AUTO →
VOD → REC → remove-X → next streamer row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All 7 sidebar nav-items (Twitch VODs / Clips / Cutter / Merge /
Statistik / Archiv / Einstellungen) were plain `<div class="nav-item">`
elements with only an onclick. Same a11y story as the previous two
iterations: no role, no tabindex, no semantic active-state marker,
no keyboard activation.
Added on each nav item:
- role="button" and tabindex="0" so they enter the tab order and
read as activatable buttons to assistive tech
- aria-current="page" applied to the active item, removed from the
others — both managed in showTab() since that's the single
switch point for active-state transitions
- A delegated keydown handler on the .nav container (one listener,
not seven) that fires showTab on Enter / Space for whatever
nav-item descendant is currently focused. Bound once with a
data-keynav-bound guard so init() re-running doesn't double-bind
CSS adds a 2px purple focus-visible ring matching the rest of the
keyboard-focus family added in 4.6.50 and 4.6.51.
WCAG 2.1 success criterion 2.1.1 (Keyboard) — every interactive
element activated by keyboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three streamer-row action chips (AUTO toggle, VOD toggle, REC
one-shot) were spans wired only with click listeners. Same a11y
gap as the .remove X chips in 4.6.50: no role, no tabindex, no
keyboard activation, no semantic state for toggles. Screen readers
read them as raw text "AUTO", "VOD", "REC" with no clue they were
interactive controls.
Factored a wireChipButton helper inside renderStreamers and ran all
three chips through it. The helper stamps:
- role="button" + tabindex="0"
- aria-label (locale-driven, picked up the existing
autoRecordTitle / autoVodTitle / recordLiveTitle locale keys
that were previously only used for the visual title-tooltip)
- aria-pressed="true"/"false" for the AUTO and VOD toggles so
AT announces the on/off state
- A keydown handler that synthesises the same click handler on
Enter / Space and stops propagation so the row's click handler
(streamer-select) does not also fire
CSS adds three focus-visible rings (green for AUTO, blue for VOD,
red for REC) matching each chips active-state colour palette.
Keyboard navigators tabbing through a streamer item now see the
ring on the focused chip clearly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>