perf+style: grid subscription hygiene, a11y, shadcn-style consistency

Perf / a11y (high-impact review items)
- Timeline arrow-key handler binds once per (viewMode, currentSection)
  and reads fresh state via navStateRef instead of an 8-element dep
  array of new-each-render values.
- usePhotosQuery collapses 14 individual Zustand selectors into one
  useShallow selector returning the params object.
- PhotoThumbnail no longer subscribes to the search query directly;
  Timeline subscribes once and passes it down as a prop.
- PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed,
  Enter/Space key handlers and a focus-visible ring. Timeline marked
  role="grid"; RightSidebar marked role="region".

Style consistency
- Swap clsx for cn (tailwind-merge aware) across 17 files so
  conflicting utility classes collapse correctly.
- New Badge primitive (ui/badge.tsx) with default/neutral/overlay/
  outline variants; adopted in ColorsView, RatedView, TagsView for
  the repeated count overlay pill.
- Fix palette drift: text-amber-400 -> text-star, text-green-*
  -> text-pick, text-red-* -> text-reject (5 files).
- Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated
  compact-button pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:30:16 +02:00
parent e65e798021
commit a073ee7fb9
21 changed files with 268 additions and 160 deletions

View File

@@ -26,7 +26,7 @@ import {
Upload as UploadIcon,
Download as DownloadIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
@@ -487,11 +487,11 @@ export function LeftSidebar() {
return (
<div key={item.id}>
<div
className={clsx(
className={cn(
'group flex cursor-pointer items-center gap-1',
isSectionHeader
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
: clsx(
: cn(
// Fixed h-[24px] (not min-h) locks the row height so the
// hover-only kebab button can't grow the row vertically.
'h-[24px] rounded px-2 text-[12px] leading-none',
@@ -577,7 +577,7 @@ export function LeftSidebar() {
* without hunting through the kebab menu. */}
{item.icon && !isSectionHeader && (
<span
className={clsx(
className={cn(
'flex-shrink-0',
isSelected
? 'text-primary'
@@ -619,7 +619,7 @@ export function LeftSidebar() {
/>
) : (
<span
className={clsx(
className={cn(
'flex-1 truncate',
item.isHidden && !isSelected && 'italic text-text-muted/80'
)}
@@ -677,7 +677,7 @@ export function LeftSidebar() {
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
>
<div
className={clsx(
className={cn(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
@@ -871,7 +871,7 @@ export function LeftSidebar() {
return (
<div
key={sf.id}
className={clsx(
className={cn(
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
@@ -881,7 +881,7 @@ export function LeftSidebar() {
}
>
<Users
className={clsx(
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isSelected ? 'text-primary' : 'text-text-muted'
)}
@@ -922,7 +922,7 @@ export function LeftSidebar() {
)}
<button
onClick={logout}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
title="Sign out"
>
<LogOut className="h-3 w-3" />
@@ -933,7 +933,7 @@ export function LeftSidebar() {
{isAdmin && (
<button
onClick={() => navigateToSection('settings', {})}
className={clsx(
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
)}