feat: themed scrollbars and chiseled thumbnail chips

- 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>
This commit is contained in:
2026-04-09 20:09:49 +02:00
parent 8f25c58b74
commit 7d1149aabd
3 changed files with 50 additions and 21 deletions

View File

@@ -88,6 +88,7 @@ export function ActiveHeapCard() {
backgroundPosition: 'center bottom', backgroundPosition: 'center bottom',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
imageRendering: 'pixelated', imageRendering: 'pixelated',
opacity: 0.7,
}} }}
> >
{visible.length === 0 ? ( {visible.length === 0 ? (

View File

@@ -11,20 +11,29 @@ export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
// ── Thumbnail badge family ─────────────────────────────────────────────── // ── Thumbnail badge family ───────────────────────────────────────────────
// Every ornament that overlays a thumbnail — here and in other views that // Every ornament that overlays a thumbnail — here and in other views that
// wrap PhotoThumbnail (e.g. DuplicatesView) — must compose these classes so // wrap PhotoThumbnail (e.g. DuplicatesView) — must compose these classes so
// the set reads as one coherent system. Shape, height, ring, typography are // the set reads as one coherent system. Shape, height, typography are
// fixed; only the colour variant varies by semantics: // fixed; variants set the fill + chiseled-pixel frame:
// PRIMARY → user-affirmed state (selection, rating, active-heap) // PRIMARY → user-affirmed state (selection, rating, active-heap)
// NEUTRAL → informational metadata (duplicate flag, discard, file type, dims) // NEUTRAL → informational metadata (duplicate flag, discard, file type, dims)
// PICK → auto-suggested "best" in duplicate groups // PICK → auto-suggested "best" in duplicate groups
//
// Frame is a single box-shadow stack rather than a Tailwind `ring` so the
// 1px outline, the subtle inset top highlight, and the drop shadow all
// live in one paint, and variants can tint the inset highlight to suit
// their fill. Corners are `rounded-sm` (2px) to echo the pixel-art theme
// used in the TopBar and ActiveHeapCard.
export const THUMB_BADGE_BASE = export const THUMB_BADGE_BASE =
'inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[10px] font-medium leading-none text-white shadow-md ring-1 ring-white/90' 'inline-flex h-5 items-center gap-1 rounded-sm px-1.5 text-[10px] font-semibold leading-none text-white'
/** Square icon-only variant — compose alongside THUMB_BADGE_BASE. */ /** Square icon-only variant — compose alongside THUMB_BADGE_BASE. */
export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0' export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0'
/** Standard icon size for any lucide glyph inside a badge. */ /** Standard icon size for any lucide glyph inside a badge. */
export const THUMB_BADGE_ICON = 'h-3 w-3' export const THUMB_BADGE_ICON = 'h-3 w-3'
export const THUMB_BADGE_PRIMARY = 'bg-primary' export const THUMB_BADGE_PRIMARY =
export const THUMB_BADGE_NEUTRAL = 'bg-black/70 backdrop-blur-sm' 'bg-primary shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
export const THUMB_BADGE_PICK = 'bg-pick' export const THUMB_BADGE_NEUTRAL =
'bg-black/75 backdrop-blur-sm shadow-[0_0_0_1px_rgba(255,255,255,0.12),inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.55)]'
export const THUMB_BADGE_PICK =
'bg-pick shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]'
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so // Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
// the first hit on a freshly-scanned library returns 404 "not ready" until // the first hit on a freshly-scanned library returns 404 "not ready" until
@@ -135,7 +144,7 @@ export function PhotoThumbnail({
useEffect(() => { useEffect(() => {
return () => clearRetryTimer() return () => clearRetryTimer()
}, []) }, [])
// Build the drag payload at fire time so multi-selection drags carry the // Build the drag payload at fire time so multi-selection drags carry the
// current selection. If the dragged photo isn't part of the selection, // current selection. If the dragged photo isn't part of the selection,
// drag just that one photo (matches Finder semantics). // drag just that one photo (matches Finder semantics).
@@ -160,7 +169,7 @@ export function PhotoThumbnail({
// pops on light AND dark photos. // pops on light AND dark photos.
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg', 'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
isSelected && isSelected &&
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg', 'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
!imageLoaded && 'bg-surface animate-pulse' !imageLoaded && 'bg-surface animate-pulse'
)} )}
style={ style={
@@ -222,7 +231,7 @@ export function PhotoThumbnail({
</div> </div>
</div> </div>
)} )}
{/* ── Ornaments ──────────────────────────────────────────────────── {/* ── Ornaments ────────────────────────────────────────────────────
* All overlays compose the THUMB_BADGE_* classes so they share one * All overlays compose the THUMB_BADGE_* classes so they share one
* shape/size/ring family. Colour signals semantics: * shape/size/ring family. Colour signals semantics:
@@ -297,10 +306,10 @@ export function PhotoThumbnail({
{/* TR — file-type metadata (RAW / VIDEO) */} {/* TR — file-type metadata (RAW / VIDEO) */}
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) || {(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && ( photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
<div className={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}> <div className={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'} {photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div> </div>
)} )}
</div> </div>
) )
} }

View File

@@ -24,22 +24,41 @@
} }
} }
/* Custom scrollbar styles */ /* Compact themed scrollbars. Track is fully transparent so the bar
::-webkit-scrollbar { floats over the panel surface, and the thumb is a slim translucent
width: 8px; pill that tints toward the primary on hover. Firefox uses the
height: 8px; `scrollbar-*` properties below; Chromium / WebKit uses the
`::-webkit-scrollbar*` rules. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(240, 230, 210, 0.18) transparent;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar {
@apply bg-surface; width: 6px;
height: 6px;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-surface-offset rounded; background-color: rgba(240, 230, 210, 0.18);
border-radius: 9999px;
/* Inset border keeps the thumb visually slimmer than the track gutter
so it reads as a pill rather than a bar. */
border: 1px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-text-faint; background-color: rgba(232, 150, 90, 0.55); /* primary, soft */
}
::-webkit-scrollbar-thumb:active {
background-color: rgba(232, 150, 90, 0.85);
} }
/* Mule walk cycle — 3x2 sprite sheet (6 frames). Each keyframe holds /* Mule walk cycle — 3x2 sprite sheet (6 frames). Each keyframe holds