Refactors the new describe block to use the existing setupFresh()
helper pattern (mirrors setup() but with empty profile/folder lists
so we can verify the test response is what populates the dropdowns),
the lidarrSection() scoping helper (the cover-providers and SMTP
panels also have "Save changes" buttons that match unscoped queries),
and the correct mockQuery({ data: ... }) shape used elsewhere in the
file. Drops the duplicate afterEach (the file already has a global
one at line 131).
No behavior change in the tests themselves — same three cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new cases:
- Save on a fresh form calls test first, fills auto-defaults from the
response, then put-config with non-zero defaults
- Save aborts when test fails and surfaces the error code
- An operator-picked default value survives the test → save sequence
(auto-default $effect only fills when current value is 0/'')
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Click sequence on a fresh Lidarr config: enter URL + API key → click
Save. The page now calls testLidarrConnection first, stashes the
returned profiles/folders into local state, lets the existing
auto-default $effects pick first-of-each, then sends the put-config
request with non-zero defaults. If the test fails, the save aborts
and the error surfaces in saveError.
The explicit Test connection button keeps working and additionally
populates the dropdowns from the same response so an operator can
inspect/adjust before saving.
Each dropdown reads from its test-state array when present, falling
back to the existing query data (which only works after the first
successful save). list_errors entries surface as inline notes under
their respective dropdowns.
Closes the chicken-and-egg where saving required defaults that could
only be fetched after saving.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the server-side extension of POST /api/admin/lidarr/test. The
integrations page consumes these in the next commit to pre-fill the
quality/metadata/root-folder dropdowns on first-time Lidarr setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The +layout.svelte auth guard only treated /login as public, so any
unauthenticated visit to /register bounced to /login?returnTo=%2Fregister.
The "Register" link on the login page therefore looped back to itself,
making the first-user-becomes-admin bootstrap path (handleRegister +
CreateUserFirstAdminRace) unreachable in production.
Extracted isPublicRoute() into web/src/lib/auth/publicRoutes.ts so the
public-route set has one source of truth and is unit-testable. Test file
explicitly comment-tags /register as a #376 regression guard.
Closes the last gap in user-mgmt umbrella #376.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final task of PR2 DRY pass. Extracts the verbatim
`offset + items.length >= total` math repeated across 8
createInfiniteQuery sites into a single Page<T>-shape helper.
The task spec described a bare-array helper signature
(lastPage: T[], pageSize-based stop), but no call site in this
repo uses that shape — every paged endpoint returns a Page<T>
envelope. The helper is named pageGetNextPageParam and matches
the actual canonical shape so the 8 copies could collapse.
Migrated:
- likes.ts: 3 sites (TrackRef, AlbumRef, ArtistRef)
- albums.ts: 1 site (AlbumRef)
- queries.ts: 4 sites (artists + 3 search facets)
history.ts left alone — uses has_more/total, different shape.
Cleans up the 8 svelte-check warnings I deferred in prior CI runs.
Two distinct categories — handling them differently:
REAL REACTIVITY BUGS — fixed:
- AlbumMenu.svelte / ArtistMenu.svelte: cachedTracks initialized
from the `tracks` prop only at component creation. When the menu
is reused across rows (operator opens menu on one album, then
another), the cache from the first open serves stale tracks.
Fix: initialize cachedTracks to null; ensureTracks() reads the
current `tracks` prop on each call before falling through to
the network fetch.
INTENTIONAL PATTERNS LINT FLAGGED — suppressed with explanatory
comments:
- AlbumMenu / ArtistMenu / PlaylistCard menu containers: the
onclick={(e) => e.stopPropagation()} is a guard against the
window-level "any click closes the menu" listener — not
user-facing interaction. A paired keyboard handler is
intentionally absent (Escape-to-close lives on svelte:window
onkeydown). Suppressed a11y_click_events_have_key_events with
explanatory comments.
- CompactTrackCard overlay div: catches clicks on its inner
LikeButton + queue button so they don't bubble to the wrapping
play-card button. Inner buttons carry their own keyboard
handling. Suppressed both a11y_click_events_have_key_events and
a11y_no_static_element_interactions.
- theme.svelte.ts module-scope `_resolved = $state(resolve(_theme))`:
module-init reads _theme to seed _resolved; subsequent updates
go through setTheme() and the matchMedia listener. $derived
doesn't apply at module scope. Suppressed state_referenced_locally
with an explanatory comment.
Promised in the prior fix-forward: "I'll fix bugs when I see them
or open a follow-up task with a specific name, not park them in an
umbrella." Following through.
Three Go lint hits + two web test failures, all from the U3 push.
- internal/mailer/mailer.go: SentEmail.HtmlBody → HTMLBody (Go's
initialism convention; revive flagged); FakeSender.Send unused
ctx parameter renamed to _.
- internal/audit/audit_test.go: removed dead validUUID helper. It
was added speculatively in U1-T1 and never called by any test.
pgtype import stays — other tests use it.
- web/src/routes/settings/settings.test.ts: existing ListenBrainz
tests queried `getByRole('button', { name: /save/i })` which
worked when the page had only one Save button. The new Profile
card adds a "Save profile" button that also matches the regex,
triggering "found multiple elements". Anchored to /^save$/i for
exact-match. The newer Save profile tests still use
/save profile/i which is unique.
This is the same shape of test-fixture-lag I owe an answer for: I
landed new content that broke an existing test, and the existing
test had a too-loose selector. The right fix is to tighten the old
selector now (this commit) and to flag this pattern — selectors
that regex-match by partial words — as a candidate for the DRY
pass / test-utils consolidation.
Five fixes in one commit, all from the U1+U2+U3 push:
1. internal/api/library_test.go's Mount call wasn't updated when
U3-T4 added the mailer.Sender param. 17 args, sig wants 18.
Adds a trailing nil for the mailer. Same shape of test-fixture
lag the project has hit before — flagged as a recurring pattern
for the DRY-pass slice.
2. web/src/routes/settings/settings.test.ts mocked $lib/api/me with
`getAPIToken: vi.fn()` (no return value). The new /settings API
Token card's $effect calls `getAPIToken().then(...)` which threw
"Cannot read properties of undefined (reading 'then')" on every
ListenBrainz test. Default the mock to mockResolvedValue so any
test that doesn't override gets a valid resolution.
3. web/src/routes/admin/users/users.test.ts queried row buttons by
/^Delete$/ but T3 added per-user aria-labels ("Delete alice"),
so the row buttons' accessible name is no longer "Delete". The
modal's confirm button has no aria-label so its name IS "Delete"
exactly. Tests now click by per-user aria-label first, then by
/^Delete$/ for the modal confirm.
4. Same file: /Set password/i regex matched both the dialog's
submit button AND the row's "Reset password for ..." buttons
(because regex `Set` matches "ReSet" case-insensitively). Switched
to /^Set password$/ exact-match.
5. web/src/routes/reset-password/[token]/reset-password.test.ts had
/New password/i which matched both the "New password" and
"Confirm new password" labels. Switched to exact-match string
selectors.
6. web/src/routes/forgot-password/+page.svelte's onSubmit handler
had no catch — a rejected forgotPassword() bubbled as an
unhandled rejection in vitest. Added a silent catch since the
page intentionally shows the same success message regardless of
outcome (no-enumeration posture).
Three CI fixes from the U1+U2+U3 push:
- internal/api/admin_users.go imported the standalone
github.com/jackc/pgconn — that module isn't in go.sum and
go vet caught it. Switch to github.com/jackc/pgx/v5/pgconn
(the path used elsewhere in the package, e.g. auth_register.go
and me_profile.go).
- /reset-password/[token] dereferenced page.params.token without
the undefined narrowing svelte-kit's typegen requires. Coalesce
to '' on read and reject empty token at submit time with a
clear error.
- /admin/integrations SMTP card had a label without an associated
control as the TLS row's leading column header. Switched to a
span — the actual checkbox lives in the wrapping label below it.
Completes the U3 frontend that T5a's crashed dispatch left half-done.
- /reset-password/[token] — public; new password + confirm. Calls
POST /api/auth/reset-password with the URL's token. invalid_token,
password_too_short, and mismatched-passwords surface as inline
errors. Success redirects to /login?reset=ok.
- /admin/integrations gains an SMTP card (alongside Lidarr) with
enabled toggle + all fields + Save and Send-test-email buttons.
Password input is a separate state from the loaded form, so empty
submission preserves the stored server-side value (matches the
backend's empty-password-preserves contract). Test button error
codes (no_email_on_file / not_configured / send_failed) surface
as actionable toasts.
Tests cover both new pages plus extensions to settings + integrations
test files for the cards landed in T5a.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lands four of the U3 frontend surfaces. Splits T5 because the
dispatch covering everything in one shot crashed the runtime
mid-execution; this is the salvageable half.
- /settings gains three cards alongside Appearance: Profile
(display name + email), Password (current + new + confirm with
client-side mismatch check), API Token (display + copy +
regenerate-with-double-click-confirm). Server error codes map
to clear toasts.
- /forgot-password — public; takes an email and always shows the
success message regardless of whether the email is on file
(mirrors the server's no-enumeration posture).
- Login page gets a "Forgot password?" link below the existing
register link.
- API client functions for the four /me endpoints (changePassword,
updateProfile, getAPIToken, regenerateAPIToken), the SMTP admin
trio (getSMTPConfig, updateSMTPConfig, testSMTPConfig), and the
forgot/reset auth pair (forgotPassword, resetPassword).
Tests for these surfaces + /reset-password page + admin SMTP card
land in T5b (next follow-up).
Extends the U1 /admin/users page with the four U2 admin endpoints.
- "New user" button opens a modal: username, optional display
name, password + confirm, optional admin checkbox. Validates
password match client-side; surface server-side errors
(username_taken, username_invalid, password_too_short).
- Per-row "Delete" opens a confirm modal explaining the cascade
(plays, likes, sessions). Last-admin guard surfaces as a clear
toast if the server refuses.
- Per-row "Reset password" opens a small modal: new password +
confirm. Toast confirms success.
- Per-row "Enable / Disable auto-approve" toggles the
per-user flag (the #355 sub-feature surface). Inline button
state reflects the current value.
AdminUser type extended with auto_approve_requests; the badge
appears next to the admin badge when enabled.
Tests cover create-user submit, delete confirm flow, last-admin
toast on delete, reset-password submit, auto-approve toggle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New admin route /admin/users with two sections:
- Accounts — table of all users (username, optional display name,
admin badge, created_at). Per-row "Make admin" / "Remove admin"
button calls PUT /api/admin/users/{id}/admin. Last-admin guard
surfaces as a toast ("Can't remove the last admin — promote
someone else first").
- Invites — list of active invites with Copy + Revoke per row.
"Generate invite" button creates a 24h token. Operator shares
the token with the invitee, who enters it on /register.
AdminTabs gains a "Users" tab (5 tabs total). New typed client
functions in admin.ts (listUsers, updateUserAdmin, listInvites,
createInvite, deleteInvite) plus query factory functions
createAdminUsersQuery / createAdminInvitesQuery and query keys
qk.adminUsers / qk.adminInvites.
Tests cover: list rendering, admin badge, promote/demote actions,
last-admin guard toast, invite generation, revoke flow, empty states.
AdminTabs.test.ts updated to assert 5 tabs and Users active state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Public /register form: username, optional display name, password +
confirm, optional invite token. Submits to POST /api/auth/register
via the new register() helper in auth/store.svelte.ts. On success
the server sets the session cookie and the frontend redirects to /;
on error the page shows a code-specific message (invite_required,
invite_invalid, username_taken, etc.).
Login page gets a "Don't have an account? Register" link below the
form for symmetry.
Tests cover form rendering, password-mismatch client-side rejection,
the happy-path submit + redirect, and the invite-invalid error
mapping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Click on the For-You tile's play button now triggers a synchronous
refresh, then enqueues + auto-plays the freshly-built playlist.
Two-click-target tile pattern: tile BODY click still navigates to
the playlist detail page (existing behavior); the play button is
the new generate-and-play action.
Behavior is gated on playlist.system_variant === 'for_you' — every
other playlist (user, Discover, Songs-like-X) keeps the existing
"fetch detail, enqueue, play" flow. The atomic-replace BuildSystem-
Playlists creates a new playlist_id, so the play handler uses the
refresh response's id for the follow-up getPlaylist call rather
than the stale tile prop's id.
Empty-library response (playlist_id: null) is a no-op — clicking
play in a degenerate-empty library doesn't error, it just doesn't
start playback. Caches are invalidated post-refresh so the home
view re-fetches and the tile re-renders with the new id, avoiding
the corner case of a click-then-tile-body 404.
Tests cover the refresh-and-play happy path, the empty-library
no-op, and that non-For-You playlists keep the existing flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Operator can refresh their Discover playlist without waiting for
the next daily cron tick. Two surfaces, both gated on
system_variant === 'discover':
- Detail page header: "Refresh" button next to the cover art / meta.
- Home Playlists row tile kebab: "Refresh Discover" menu item.
Both call POST /api/playlists/system/discover/refresh, show a
confirmation toast, and invalidate the playlist + playlists queries
so the new content lands without a manual page reload.
Extends Playlist.system_variant union with 'discover'. Adds
refreshDiscover() typed client. Tests cover client behaviour
(success + empty-library) plus conditional rendering on both
surfaces.
POST /api/admin/cover-sources/research bumps cover_art_sources_meta.
current_version unconditionally; the next enrichment pass re-eligibles
every 'none' row. Existing positively-sourced rows are not affected —
the version-mismatch eligibility rule only kicks in for 'none'.
Admin Cover Art panel gains a "Re-search missing art" button that
calls the endpoint and shows a confirmation toast. Operator's escape
hatch when:
- They've added a Last.fm key and want to retry failed rows
immediately rather than waiting for the next scheduled scan.
- An upstream provider's catalog has updated (e.g. Deezer added a
back-catalog import).
- They want to verify a fix end-to-end before the next scheduled
scan fires.
SettingsService.BumpVersion (the unconditional version of T5's
BumpVersionIfProvidersChanged) is the single-call DB writer; both
endpoints route through it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The admin tab strip (Overview / Integrations / Requests / Quarantine)
scrolled out of view as the operator paged through long sections like
Library Scan, Cover Art, etc. Pin it to the top of the <main>
scroll container with sticky positioning, an opaque background to
hide content scrolling behind it, and z-10 to layer above section
content.
Adds a "Scan schedule" section to the admin overview page between
the existing Library Scan and Cover Art sections. Mode picker
(Off / Every N hours / Daily / Weekly) with conditional interval /
time / day inputs. Save button disabled until local state diverges
from the server state. "Next scan: in N hours (timestamp)"
inline indicator when mode != off.
formatRelative is a static helper (recomputes on render); operator
sees the configuration confirmation at a glance — not a countdown
timer.
admin.test.ts mock extended with createScanScheduleQuery returning
mode='off' default. New test asserts the section renders. Existing
assertions still pass.
Adds ScanSchedule type, getScanSchedule / updateScanSchedule
against the new admin endpoints, createScanScheduleQuery (60s
staleTime; refetches on focus + after PATCH invalidation — no
polling).
New qk.scanSchedule() key follows the existing tuple pattern.
ScanScheduleUpdate uses optional fields so mode=off updates can
omit per-mode fields entirely (server normalizes anyway).
Vitest covers the GET path, PATCH with body, and the mode=off
minimal-body case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each of the 4 stage cards in the admin Library Scan section gains a
StageBadge in its header. The badge derives state via stageState()
from the existing ScanStatus payload — no new query, no schema
change. Active stage shows spinner + "Processing" in moss green;
done shows check + "Done" in moss green; pending and skipped show
muted text.
Card body now shows either the (possibly partial) numbers OR a
"Waiting for previous stage…" placeholder when the badge is
pending. The previous bottom-of-card "Pending…" / "Skipped."
fallback text disappears (the badge replaces it).
StageBadge.test.ts covers the four state renderings.
admin.test.ts adds one new case asserting the Processing badge
shows when scan.library is populated and in_flight: true. Existing
admin.test.ts assertions still pass (the default mock data has no
stage tallies + in_flight: false → all stages skipped, no Loader/
Check icons rendered).
Pure-function module that derives per-stage StageState (one of
pending / in_progress / done / skipped) from the existing ScanStatus
payload. No new query, no new endpoint — reuses what
createScanStatusQuery already polls.
Truth table covered by tests: scan undefined → skipped; in-flight
with no tallies → all pending; in-flight with library tally only →
library in_progress, rest pending; in-flight with library +
mbid_backfill → library done, mbid in_progress; finished with all
tallies → all done; finished with missing library tally → library
skipped, others done; in-flight with last stage tally → only last
stage in_progress (all earlier done).
Three issues from the previous push:
1. internal/api/admin_covers_test.go used t.Context() (Go 1.24+) but
go.mod declares go 1.23.0. Replace with context.Background() in
the testHandlersWithCovers helper; add "context" import.
2. internal/server/server_test.go's New() call missed the
*coverart.SettingsService argument added in T11. The new param
sits between the cover-art-backfill cap (int) and the *library.Scanner
pointer in the function signature; the test was using 12 args
instead of 13. Insert nil at the right position in all six call sites.
3. integrations.test.ts had 11 failing tests after T13 added the
cover-art-providers section. Both panels render "Save changes",
"Test connection", and "API key" controls, so the existing
Lidarr tests' role/text queries matched multiple elements. Add
helper functions (lidarrSection, coverProvidersSection,
providerCard) and scope every relevant query with within() so
each panel's tests interact only with their own controls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three failures from the prior push:
1. internal/api/library.go and 3 other callers of q.GetArtistByID
broke because T2 modified the query to use an explicit column
list, making sqlc generate GetArtistByIDRow instead of returning
dbq.Artist. dbq.Artist already has every column added by
migration 0018 (artist_thumb_path / artist_fanart_path /
artist_art_source / artist_art_sources_version), so the explicit
column list was unnecessary work. Revert the SELECT to SELECT *,
regenerate sqlc, GetArtistByIDRow disappears, all callers compile
again.
2. internal/tracks/service_test.go missed the dataDir parameter
added to NewService in T9. Add "" as the 4th arg to every test
call site (tests don't exercise the artist-art cleanup path; the
empty dataDir is fine).
3. integrations.test.ts:356's mock.calls.filter callback had a
too-narrow type annotation that svelte-check rejected. Relax to
any[] to match what mock.calls actually is.
Adds a "Cover art providers" section to the existing /admin/integrations
page with one card per registered provider (currently MBCAA + TheAudioDB).
Each card has:
- Provider name + capability badges (album cover · artist thumb · artist fanart)
- Status dot (green if enabled; muted if disabled)
- Enabled toggle + API key text field (only when requires_api_key)
- Save button (explicit save matches existing Lidarr card pattern)
- Test connection button (when testable) with inline ok/fail indicator
- Footer help line for TheAudioDB linking to the free key application
Save invalidates qk.coverProviders() always and qk.coverage() when
the PATCH response indicates version_bumped: true (so the coverage
gauge re-queries to show settled→with_art transitions on the next
scan).
API key field uses type=password with a placeholder that clarifies
the test-key fallback behaviour — never echoes back the stored key
(api_key_set bool from the GET response controls the "✓ Set"
indicator).
Local edit state is keyed by provider_id and survives query refetches
so in-flight edits aren't clobbered.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds CoverProvider type + capability union, getCoverProviders /
updateCoverProvider / testCoverProvider against the three new admin
endpoints, plus createCoverProvidersQuery (60s staleTime; refetches
on focus + after mutations rather than polling — settings rarely
change in operator time).
New api.patch helper added to client.ts (the existing helpers were
get/post/put/del; PATCH was missing).
New qk.coverProviders() key follows the existing tuple pattern.
api_key_set: bool replaces echoing the credential. UpdateCoverProviderPatch
uses optional fields so the operator can change just enabled, just
key, or both atomically.
Vitest covers GET path, PATCH path with body, version_bumped
response, POST /test path with empty body, ok=false error response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an artist-art-enrich stage after cover-enrich in RunScan. The
stage drains EnrichArtistBatch with a configurable cap and the
operator's data_dir, persists processed/succeeded/failed tallies to
the scan_runs.artist_art_enrich jsonb column added in migration
0018.
The admin /admin Library Scan card grid expands from 3 to 4 columns
with the new "Artist art" card mirroring the cover-enrichment one
(Processed / Succeeded / Failed). The TS ScanStatus type gains an
optional artist_art_enrich field. The Go scanStatusResp gains the
matching ArtistArtEnrich json.RawMessage field.
cmd/minstrel/main.go (and admin scan-trigger handler) wire the new
RunScanConfig fields: ArtistEnrichCap reuses cfg.Library.CoverArtBackfillCap
for now (can be split if separate budgets are useful), DataDir is
cfg.Storage.DataDir.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a library-wide cover-art coverage gauge inline to the right of
the "Refetch missing covers" button. Three buckets: with_art ·
pending · settled. Native HTML title= tooltip on "pending" surfaces
the pending_no_mbid sub-count when > 0, telling the operator how
many "pending" rows are blocked on missing MBID and won't be moved
by another scan.
Run-scan and Refetch-missing handlers extend their TanStack
invalidation to also clear qk.coverage(), so the gauge ticks
immediately after the operator clicks instead of waiting up to 3s
for the next refetch interval.
flex-wrap on the row so the gauge drops below the button on narrow
viewports instead of overflowing.
The existing admin page vitest mock for $lib/api/admin gains a
createCoverageQuery stub returning data: undefined so the new
import doesn't break the existing page tests (which don't touch
the gauge).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds CoverageRollup type, getCoverageRollup() against
/api/admin/library/coverage, and createCoverageQuery() with the same
3s/30s pacing used by scan-status. New qk.coverage() key follows the
existing zero-arg tuple pattern. Vitest covers the GET path and the
empty-library zero-state plus the with_art + pending + settled = total
invariant.
Forward-fix from running the prior two commits in production. Backfill
on a real library exposed two further issues:
(1) gofmt -s flagged the column-aligned stubMeta methods in
mbids_test.go. Switched to the gofmt-canonical single-space form.
(2) Many albums failed to heal with SQLSTATE 23505 (unique constraint
albums_mbid_unique). Cause: the operator's library has duplicate
album rows in the DB — same MusicBrainz release, split into
multiple rows by the pre-MBID scanner because of subtle title or
artist disagreements between files. When backfill now correctly
extracts the MBID, two rows want to claim the same one and the
partial unique index rejects the second.
The conflict is correct DB behavior — we shouldn't have two rows
with the same MBID — but it's not a write failure to alarm the
operator about. Detect 23505 specifically:
- downgrade the log line from Warn to Info
- track these in a new BackfillMBIDsResult.Duplicates counter
(separate from Skipped, which retains its "no MBID in tag"
meaning)
- leave the duplicate row's mbid NULL; merging duplicates is a
separate (future) operator workflow
Same handling threaded through the scanner heal path so a regular
rescan doesn't generate the noise either.
JSON tally + admin UI gain a "Duplicates" line so the operator
can see how many duplicate rows their library carries.
Follow-up scope (separate task): a duplicate-album merge UX that
reparents the duplicate's tracks to the canonical row and deletes
the orphaned album row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds getScanStatus/triggerScan helpers, createScanStatusQuery factory
(3s poll), qk.scanStatus(), and a Library Scan section on the admin
overview page with per-stage tallies and inline Run scan button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>