Cross-reference face detections with YOLO 'person' bounding boxes —
only keep faces that overlap >= 50% with a detected human body. This
eliminates false positives on dogs, paintings, and cartoons without
needing an aggressive score threshold.
Lower face detection threshold back to 0.6 since the person-overlap
check is now the primary precision filter.
Tested: 6 verified faces from 4 photos, zero false positives.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminates false positives (dog faces, painting faces) by requiring
score >= 0.85. Tighten cluster eps to 0.25 for better separation.
Tested: 4 real faces from 2 photos, no false positives.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix Photo.created_at → Photo.added_at (column doesn't exist)
- Fix Postgres DISTINCT + ORDER BY conflict by using a subquery for
tag_id filtering instead of JOIN + DISTINCT on the outer query
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clicking a person card now opens a detail sub-view within the People
section showing their photo grid. Back arrow returns to the card grid.
Photos are clickable to open the preview. Rename is available in both
the card grid and the detail header.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- recluster_faces now writes photo_tags rows for each face cluster so
the tag count and tag_ids filter work (previously count was always 0)
- Old cluster tags and photo_tags are cleaned up before re-clustering
- Raise face detection threshold from 0.4 to 0.7 to reduce false
positives (was detecting dog faces as people)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rewrite faces.py to use cv2.FaceDetectorYN instead of raw ONNX
(handles multi-scale anchor decoding and NMS internally)
- Load original photo files at up to 4000px for face detection instead
of 240px thumbnails — faces were too small to detect at thumbnail res
- Falls back to thumbnail if original is unavailable
Tested: 33 faces extracted from 13 photos, clustered into 1 person.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the tag-grouping people section with a dedicated PeopleView:
- Grid of face cluster cards showing representative photo thumbnail,
person name, and photo count
- Click a card → navigates to all-photos filtered by that person's tag
- Inline rename via pencil icon on hover
- Empty state when no faces have been clustered yet
Wired into App.tsx as a section-level route alongside Map and Duplicates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rewrite all vision tasks to use sync psycopg2 sessions instead of
asyncpg — fixes 'another operation in progress' and event loop errors
when Celery forks workers sharing the async connection pool
- Letterbox-pad images to exactly 640x640 for YuNet face detector
(was crashing on non-square thumbnails)
- Deduplicate object detections per label per photo — keep highest
confidence only to avoid photo_tags PK violation on multiple
detections of the same class
- Add all queues (-Q default,high,low,vision) to worker command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add export_models.py for OpenCLIP ViT-B/32 and YOLOv8n ONNX export
- Fix ArgMax(13) ORT ARM64 incompatibility by passing eot_indices as a
separate ONNX input (computed outside the graph in embed.py)
- Use legacy TorchScript exporter (dynamo=False) for IR version 9 compat
- Upgrade onnxruntime to 1.18.1
- Rewrite bootstrap_models.py with clear separation of auto-downloadable
models (YuNet, SFace) vs manually-exported ones (OpenCLIP, YOLOv8n)
- Wire bootstrap into worker CMD (runs before Celery)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add search API client (POST /photos/search) and useSearchQuery hook
for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters
The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create face_embeddings table with pgvector Vector(128) + HNSW index
- Implement extract_faces task (YuNet detection + SFace recognition)
- Implement recluster_faces task (DBSCAN clustering → Tag(kind=face_cluster))
- Clusters are named "Person N" and get representative_photo_id
- cluster_id FK → tags.id, SET NULL on delete for merge/rename support
Migration 0005 creates the face_embeddings table.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement detect_objects Celery task:
- Runs YOLOv8n on 640px thumbnail via ONNX Runtime
- Creates Tag(kind=object) rows for each COCO class detected
- Writes photo_tags associations with confidence, bbox, and source
- Wipes previous detections per source model on re-run
No new tables/migrations — uses the unified Tag model from PR3.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create ocr_text table for storing per-region OCR results
- Add tsvector search_vector column to photos with GIN index and
auto-update trigger on filename/user_title/user_notes
- Implement ocr_photo Celery task using rapidocr-onnxruntime
- Add FTS leg to hybrid search: queries photos.search_vector and
ocr_text via UNION, fused with semantic results via RRF (k=60)
Migration 0004 backfills search_vector for existing rows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire the full embedding flow:
- Rewrite Embedding model to use pgvector Vector(512) with HNSW index
- Add embed_photo, vision_fanout, backfill_vision Celery tasks on
dedicated `vision` queue
- Hook vision_fanout into generate_thumbnails completion
- Add POST /api/v1/photos/search with hybrid RRF ranking (semantic-only
for now; FTS leg added in PR5)
- Stub ocr_photo, detect_objects, extract_faces tasks for later PRs
Migration 0003 drops/recreates the embeddings table (was never populated).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unify object detections, scene labels, and face clusters with user tags
via new columns on the existing Tag model:
- kind (user|object|scene|face_cluster), source, representative_photo_id
- photo_tags gains confidence, bbox (JSONB), source per-association
- Uniqueness moves from (name) to (name, kind) so ML labels coexist
with user tags without collision
Add Alembic migration 0002 with defensive IF NOT EXISTS guards.
Update tags router: kind filter on GET, merge endpoint for combining
auto-detected clusters/objects, include kind/source in list response.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch the default database from SQLite to Postgres + pgvector (via
pgvector/pgvector:pg16 Docker image) to support the upcoming vision
pipeline (embeddings, OCR, object detection, face clustering).
- Add `db` service to docker-compose.yml with healthcheck
- Wire `alembic upgrade head` into backend CMD before uvicorn
- Bootstrap empty 0001_baseline revision (schema still owned by create_all)
- Guard SQLite-only PRAGMAs and inline ALTERs behind _is_sqlite flag
- Run `CREATE EXTENSION IF NOT EXISTS vector` on Postgres init
- Add asyncpg, psycopg2-binary, pgvector to requirements
- Provide docker-compose.sqlite.yml escape hatch for legacy SQLite mode
Fresh DB + rescan assumed — no SQLite→Postgres data migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Discard yanks photos from the grid optimistically (cache strip + active
cursor advance) instead of waiting for the mutation round-trip; wired
through the X hotkey, RightSidebar bulk discard, LeftSidebar discard
drop, and DiscardActionBar restore/delete.
- usePhotosQuery resolves on the first 500-photo page and streams the
remaining pages into the cache in the background, so the first
thumbnails paint immediately on large libraries.
- Closing preview restores the photo it was opened on (snapshot ref in
PreviewView, written directly to the store) and Timeline scrolls that
row back into view. Escape is handled on the dialog with
stopPropagation so Timeline's window-level Esc handler doesn't wipe
the restored selection.
- Preview overlay bumped to z-[1000] so it covers Leaflet map tiles,
and the right sidebar no longer collapses during preview — both fix
visible layout shifts on close.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a `colored` field to /library/stats counting non-discarded
photos with a color_label set, mirroring how `rated` is exposed.
The Colors sidebar entry now shows the live count and refreshes
through the existing LIBRARY_STATS_QUERY_KEY invalidations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a Colors entry under Views that buckets photos in canonical
R-O-Y-G-B-P order (plus an Uncolored tail), mirroring the rated/tags
grouping pattern. Surfaces the color label on each thumbnail as a
small swatch chip preceding the rating badge in the BL corner so
labels are visible everywhere, not just inside the new view. Also
types color_label on the Photo interface — the backend already
returned it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LIBRARY_STATS_QUERY_KEY was never invalidated by the bulk/single
mutation paths in RightSidebar and PhotoInfoPanel, so the All Photos
/ Rated / Discarded / Tags badges only updated on reload. Add it to
both shared invalidation helpers and the inline updateMutation, and
correct the stale docstring on useLibraryStatsQuery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirrors the tags view: rated section now buckets photos 5★→1★ with
an "Unrated" tail group, instead of a flat ratingMin=1 stream.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch tree and heap rows from py-0.5 to a fixed h-[24px] + leading-none
so the hover-only kebab buttons can't stretch the row vertically as the
pointer crosses them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bump the ActiveHeapCard stack thumbnails to a layered drop shadow and
a darker ring so they lift cleanly off the softened desert backdrop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace browser scrollbars with slim 6px overlay pills that tint to
primary on hover (transparent track, Firefox + WebKit).
- Rework the thumbnail badge family: drop the mismatched white ring
halo for a 1px dark frame + inset top highlight, and swap rounded-
full pills for rounded-sm chips so the ornaments match the pixel-art
aesthetic used elsewhere in the app.
- Soften the ActiveHeapCard desert backdrop so the fanned thumbnails
read on top.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compact the left/right sidebars and the photo info panel: shrink panel
widths, drop header height, switch top-level tree groups and metadata
sections to small uppercase eyebrow labels, and tighten row padding,
icon sizes, and count badges throughout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the cool-grey neutral theme with a warm desert dusk palette
pulled from a new pixel-art TopBar: a tiled, right-to-left scrolling
desert under a sky gradient, a 6-frame walking mule sprite where the
logo used to sit, and an ASCII block-character title on a black plate.
The active heap card now uses a cactus/dune scene as its stack
backdrop, and the "Built with hubris" mark moves into the TopBar's
bottom-right corner (AppFooter component removed).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every overlay on a thumbnail now composes the shared THUMB_BADGE_* classes
(one shape, one height, one ring, three semantic colour variants: primary
for user state, neutral for metadata, pick for auto-suggested best). RAW
badges, BEST pill, Keep-this button and the dimensions chip — previously
three different styles — join the family, is_duplicate moves to neutral
since it's file metadata not a user decision, and the selection check
shrinks to match the rest.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Arrow-key navigation required a click to establish an active photo
first. Now the grid auto-selects photos[0] on initial mount when no
active photo is set, so the user can land on the app and immediately
walk the grid with arrows. Guarded on viewMode === 'grid' and
!activePhotoId so we never clobber an existing selection or fight
with PreviewView.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bottom-center hint bar used bg-surface/40 + text-text-muted, which
left busy photos leaking through and washed the text out — users
couldn't actually read the shortcut labels when a bright thumbnail sat
directly behind the pill. Switched to a near-opaque bg-black/80 with
white-alpha text + white/15 key caps. Backdrop blur stays for the
subtle glass feel, but the contrast is now unconditional on the
photo underneath.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New ActiveHeapCard pinned just below the Library header in the left
sidebar. Renders null when no heap is active. When one is, shows the
heap name + member count + a fan of the last 5 member thumbnails
(newest front-and-center, older members rotated ±6°/±18px outward).
Clicking the header navigates to the heap via the same
navigateToSection pattern HeapsPanel uses.
- Stack animates with framer-motion (previously pinned in
package.json but unused). New picks spring-slide into the front of
the stack by subscribing to the ['heap-photo-ids', heapId] cache
that the existing pick mutation already updates optimistically —
no new event wiring. Unpicks run the exit transition and the
remaining cards re-fan.
- Toasts are now subtle: glassy bg-surface/80 + backdrop-blur, thin
2px left accent bar in the type color instead of a full tinted
fill, smaller icons and text, tighter padding, truncation on
overflow so they stay a single compact row. The colored alert
block that was competing with the rest of the UI is gone.
- Card lives at the top of the sidebar specifically so the bottom-left
toast stack can never occlude it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Parallelize the six celery inspect.*() calls in /library/maintenance/
worker-status via asyncio.gather + to_thread, and drop per-call
timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
so reopening shows cached data instantly while a background refetch
updates. Worker polling moved to refetchInterval. Loading spinners
only show when there's no cached data yet, so background refetches
don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
bottom of the LeftSidebar so it sits alongside the other library
controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
same control already lives in Settings → Library → Re-scan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Right sidebar stays open by default and shows an empty state when
nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
expand button in the TopBar that only appears when collapsed, so
each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
with a ~35% peek margin, cueing the user that there's more content
in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
ResizeObserver when Timeline first rendered the loading state (ref
was null, empty-dep effect didn't re-run), so containerWidth stuck
at 0 and the grid fell back to 4 columns × 200px forever. Switched
to a callback ref that attaches the observer the moment the
sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The grid was leaving horizontal space unused for two reasons:
1. Width measurement was based on parentRef.clientWidth - PADDING*2,
which is fragile to padding/box-sizing/scrollbar mismatches and was
off by enough pixels in practice to drop a column. Replace with a
1px-tall normal-flow sentinel rendered inside the inner virtualizer
wrapper at the exact horizontal extent rows render at. ResizeObserver
on the sentinel gives the authoritative row width — no padding
subtraction, no scrollbar guesswork.
2. The flex layout with explicit per-cell px widths accumulated floor()
rounding error and let cells drift away from square. Switch to a CSS
grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
`gridAutoRows`) so every track is exactly cellSize wide AND tall. By
construction `cols × cellSize + (cols-1) × gap == measured width`,
so the row fills edge-to-edge with cells that are guaranteed square.
PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vite content-hashes the JS/CSS bundle filenames, but the only thing
that tells the browser to fetch a new hash is a fresh index.html.
Without explicit no-cache headers nginx falls back to heuristic
caching, so users keep loading the old index.html → old bundle hash
until they hard-reload. Just hit this rolling out the timeline
pagination fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):
usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.
Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
- off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
columns there are only N-1 inter-cell gaps, so the correct form
is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
remainder almost fits.
- the floor remainder was discarded instead of distributed back
into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
each cell to (available - (cols-1)*gap) / cols so the row fills
the container.
Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
prune_missing_photos() previously only deleted Photo rows whose files
were gone, leaving every folder row from the old library in the DB —
which made the sidebar tree wildly out of sync with the on-disk
structure (still showing /photos/2024/, /photos/2026/03/, etc. that
no longer exist).
Now also drops Folder rows whose path doesn't resolve under a mounted
source root, with the same defensive "skip if source root unmounted"
guard. The Settings orphan card surfaces both counts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.
Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).
Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
scan.py used scalar_one_or_none() to test whether any other photo
shared the same file_hash, but that helper raises MultipleResultsFound
the moment 2+ rows match — i.e. exactly the duplicate case it was
trying to flag. Every file beyond the second copy bombed out with
"Multiple rows were found when one or none was required" and was
left in the failed bucket. Replace with a COUNT(*) > 0 check.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds /api/v1/library/maintenance/worker-status (Celery inspect + queue
depths + recent failed photos) and a Workers section in the Settings
dialog so users can debug stuck queues and task failures without
tailing container logs. Auto-polls every 5s while open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- photos.py: stop crashing in FileResponse when a thumb hasn't been
generated; return a clean 404 with Retry-After so the frontend can
back off.
- thumbs.py: fix process_video_thumbnail (overwrite_output, robust
duration probe across stream/format, eager frame load + temp cleanup)
so videos stop ending up as the gray placeholder.
- library.py: new /maintenance/* endpoints — thumbnail-stats,
regenerate-thumbnails (with media_type / only_failed filters), and a
manual data-integrity cleanup trigger.
- Frontend Settings panel (gear in TopBar) surfacing those endpoints
plus a re-scan button and live thumbnail status counts.
- PhotoThumbnail: stretch the auto-retry schedule for slow RAW jobs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The badges on a thumbnail were a rainbow — yellow stars, blue check,
green heap pill, red discard, black duplicate — and read as five
unrelated palettes. Collapse to a single family.
PhotoThumbnail:
- Rating stars: bg-primary pill with white star icons (was yellow on
a translucent dark backing).
- Heap basket / name chip: bg-primary (was bg-pick green).
- Duplicate badge: bg-primary (was bg-black/70).
- Discarded badge: bg-black/75 (kept neutral-dark, deliberately NOT
blue, so "in this collection" and "trashed" never collapse into the
same visual).
- All badges share the white outer ring + thicker icon stroke from
the previous contrast pass.
HeapsPanel:
- Active heap row uses bg-primary/8 instead of bg-pick/10.
- Basket icon turns text-primary on the active row (was text-pick).
- "Active" pill uses bg-primary/25 + text-primary (was bg-pick/25 +
text-pick).
The whole indicator family now reads as one consistent thing in the
brand blue.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>