3 Commits

Author SHA1 Message Date
6fa00f4b37 style: unify thumbnail indicators on the primary blue palette
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>
2026-04-08 22:30:35 +02:00
e49f64659e feat: surface active heap on photos + sidebar row, drop topbar pill
- PhotoThumbnail: the bottom-right basket badge now expands into a
  name chip when the active heap name is supplied. Truncated to a
  120px max-width so it doesn't eat the thumbnail.
- Timeline: read activeHeap.name from useActiveHeapMembersQuery and
  pipe it down to PhotoThumbnail.
- TopBar: drop the leftover heap pill next to "Mulimago" — the active
  state now lives where the user navigates to it (the heaps row).
- HeapsPanel: the active heap row gets a soft bg-pick/10 wash when
  not also filtered, the basket icon turns text-pick, and a small
  "ACTIVE" pill renders next to the name. Together they make it
  obvious which heap Pick / T target without needing the topbar pill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:27:22 +02:00
9317730885 style: improve thumbnail indicator + selection contrast
- Bump primary from #3b6ed8 to #3b82f6 (Tailwind blue-500). Higher
  saturation reads better against both the dark surface and varied
  photo content. Contrast against bg-bg goes from ~5.7:1 to ~6.6:1.
- Selection ring: add ring-offset-2 ring-offset-bg so the bright blue
  has a dark gap separating it from the photo edge — pops on light
  and dark photos alike. Hover ring gets the same treatment.
- Selection check badge: white ring + shadow + thicker stroke so the
  badge is legible against any photo (was disappearing on bright
  scenes).
- Rating stars: wrap in a translucent dark pill with backdrop-blur so
  yellow stars don't vanish on yellow / sandy photos.
- Heap / duplicate / discarded badges: matching white ring + thicker
  icon stroke so they all read consistently and don't blend in.
- Timeline date headers: text-text instead of text-text-muted so the
  group labels actually pop above the grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:22:07 +02:00
6 changed files with 72 additions and 54 deletions

View File

@@ -266,6 +266,10 @@ export function HeapsPanel() {
className={clsx(
'group relative flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
// Active-heap row gets a soft primary wash so the
// user always knows where Pick / T will land, even
// when viewing a different section.
isActive && !isFiltered && 'bg-primary/8 text-text',
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '32px' }}
@@ -308,7 +312,7 @@ export function HeapsPanel() {
<ShoppingBasket
className={clsx(
'h-4 w-4 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
)}
/>
@@ -330,12 +334,23 @@ export function HeapsPanel() {
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span
className={clsx('flex-1 truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
</span>
<>
<span
className={clsx('truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
</span>
{isActive && (
<span
className="ml-1 flex-shrink-0 rounded-full bg-primary/25 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-primary"
title="Active heap — Pick (P) and the basket badge on photos point here"
>
Active
</span>
)}
<span className="flex-1" />
</>
)}
{/* Right cluster. Count is the rightmost element in the

View File

@@ -1,33 +1,17 @@
import { ShoppingBasket } from 'lucide-react'
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
import muliLogo from '../../assets/muli-logo.png'
/**
* Slim top bar — just the logo and the active-heap pill. The search input
* lives in the FilterBar now (next to the rest of the filter controls).
* Slim top bar — just the logo. The active heap badge moved into the
* Heaps panel in the left sidebar (where it actually relates to the
* heap rows the user navigates to).
*/
export function TopBar() {
const { data: heapsList = [] } = useHeapsQuery()
const activeHeap = heapsList.find((h) => h.is_active)
return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div>
{activeHeap && (
<span
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary"
title="Active heap — press P to add selected photos here"
>
<ShoppingBasket className="h-3 w-3" />
{activeHeap.name}
</span>
)}
<div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
</div>
<div className="flex items-center gap-2" />
</header>
)

View File

@@ -18,6 +18,9 @@ interface PhotoThumbnailProps {
isSelected: boolean
/** True when the photo belongs to the currently active heap. */
isInActiveHeap?: boolean
/** Name of the active heap. When set + isInActiveHeap, the basket
* badge expands into a name chip so the user knows which heap. */
activeHeapName?: string | null
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
}
@@ -27,6 +30,7 @@ export function PhotoThumbnail({
size,
isSelected,
isInActiveHeap = false,
activeHeapName = null,
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
@@ -118,8 +122,11 @@ export function PhotoThumbnail({
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
'hover:ring-2 hover:ring-primary/50',
isSelected && 'ring-2 ring-primary shadow-lg',
// Two-tone hover ring: bright primary inner + dark offset so it
// pops on light AND dark photos.
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
isSelected &&
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
!imageLoaded && 'bg-surface animate-pulse'
)}
style={{
@@ -181,49 +188,59 @@ export function PhotoThumbnail({
</div>
)}
{/* Selection Indicator */}
{/* Selection Indicator — white ring around the primary fill so the
* badge stays visible against any photo (light or dark). */}
{isSelected && (
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
<Check className="h-4 w-4" />
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90">
<Check className="h-4 w-4" strokeWidth={3} />
</div>
)}
{/* Rating Stars */}
{/* Rating Stars — same primary blue as every other indicator,
* in a matching pill so the rating reads as part of the same
* badge family instead of a yellow accent from a different
* palette. */}
{photo.rating > 0 && (
<div className="absolute bottom-1 left-1 flex gap-0.5">
<div className="absolute bottom-1 left-1 flex h-5 items-center gap-0.5 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90">
{Array.from({ length: photo.rating }).map((_, i) => (
<Star
key={i}
className="h-3 w-3 fill-star text-star"
/>
<Star key={i} className="h-3 w-3 fill-white text-white" />
))}
</div>
)}
{/* Flag Indicators */}
{/* Flag Indicators — all share the same blue-primary chip style
* + white ring so the family reads as one thing. The discard
* indicator is the only exception: it stays neutral-dark
* because mixing it with the positive blue chips would conflate
* "in this collection" with "trashed". */}
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-pick text-white shadow-md"
title="In active heap"
className="flex h-5 max-w-[120px] items-center gap-1 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90"
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
>
<ShoppingBasket className="h-3 w-3" />
<ShoppingBasket className="h-3 w-3 flex-shrink-0" strokeWidth={2.5} />
{activeHeapName && (
<span className="truncate text-[10px] font-medium leading-none">
{activeHeapName}
</span>
)}
</div>
)}
{photo.is_duplicate && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90"
title="Duplicate (matches another photo's hash)"
>
<Copy className="h-3 w-3" />
<Copy className="h-3 w-3" strokeWidth={2.5} />
</div>
)}
{photo.is_discarded && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-reject text-white shadow-md"
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/75 text-white shadow-md ring-2 ring-white/90"
title="Discarded"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3 w-3" strokeWidth={2.5} />
</div>
)}
</div>

View File

@@ -196,7 +196,8 @@ export function Timeline() {
// Membership in the active heap (for the basket affordance). Subscribed
// once at this level so we don't have hundreds of thumbnails each
// subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
const activeHeapName = activeHeap?.name ?? null
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers
@@ -482,7 +483,7 @@ export function Timeline() {
}}
className="flex items-end pb-1"
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
{item.label}
</h3>
</div>
@@ -510,6 +511,7 @@ export function Timeline() {
size={THUMBNAIL_SIZE}
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
activeHeapName={activeHeapName}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)

View File

@@ -12,7 +12,7 @@
--color-text: #e8e6e0;
--color-text-muted: #878580;
--color-text-faint: #4a4845;
--color-primary: #3b6ed8;
--color-primary: #3b82f6;
--color-pick: #4f9e5c;
--color-reject: #c25a5a;
--color-star: #d4a340;

View File

@@ -17,7 +17,7 @@ export default {
text: '#e8e6e0',
'text-muted': '#878580',
'text-faint': '#4a4845',
primary: '#3b6ed8', // deep royal blue
primary: '#3b82f6', // saturated blue (Tailwind blue-500) — high contrast on dark bg AND on photo content
pick: '#4f9e5c', // green for picked
reject: '#c25a5a', // red for rejected
star: '#d4a340', // amber for stars