feat(sprite-generation): Add interactive sprite sequence controls and improved layout for SpriteTheaterPage

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 11:38:32 -07:00
parent c27abb5ef9
commit cfa7142e26

View file

@ -11,7 +11,7 @@ import {
type ReactNode,
type RefObject,
} from 'react'
import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api'
import { fetchRecentVariants, variantStreamUrl, rawImageUrl, approveVariant, rejectVariant } from '../api'
import type { RecentVariant } from '../types'
import { colors } from './theme'
@ -58,9 +58,6 @@ function confidence(v: RecentVariant): number {
return vals.reduce((a, b) => a + b, 0) / vals.length
}
function isLabeled(v: RecentVariant): boolean {
return v.rating !== null || v.is_approved
}
/* ── responsive grid hook ──────────────────────────────────────── */
@ -146,10 +143,10 @@ interface CardProps {
v: RecentVariant
isNew: boolean
index: number
onApprove: (variantId: number) => void
}
const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew, index }, ref) {
const labeled = isLabeled(v)
const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew, index, onApprove }, ref) {
const conf = useMemo(() => confidence(v), [v.notes])
const scores = useMemo(() => parseScores(v.notes), [v.notes])
const passing = conf >= 0.7
@ -160,15 +157,13 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
? { animation: `fadeScaleIn ${CARD_ENTER_MS}ms ease forwards` }
: { transition: `transform ${CARD_SHIFT_MS}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_MS}ms ease` }
const cardBg = labeled ? colors.surface : '#0d0d18'
const cardBorder = labeled
? (passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b')
: '1px solid #151528'
const cardShadow = labeled ? '0 4px 12px rgba(0,0,0,0.4)' : 'none'
const imgFilter = labeled ? 'none' : 'saturate(0.5) brightness(0.85)'
const imgOpacity = labeled ? 1 : 0.65
const textColor = labeled ? colors.muted : '#4a5060'
const entityColor = labeled ? colors.text : '#6a7080'
const cardBg = colors.surface
const cardBorder = passing ? '1px solid #10b981' : v.is_approved ? '1px solid #8b5cf6' : '1px solid transparent'
const cardShadow = '0 4px 12px rgba(0,0,0,0.4)'
const textColor = colors.muted
const entityColor = colors.text
const confColor = conf > 0 ? (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444') : ''
return (
<div
@ -184,7 +179,12 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
...animStyle,
}}
>
<div style={{ position: 'relative' }}>
{/* Clean image — clickable, no overlays */}
<a
href={`/sprite/${v.sprite_id}?variant=${v.variant_id}`}
style={{ display: 'block', cursor: 'pointer' }}
title={`#${v.variant_id}${v.sprite_id} (click to open)`}
>
<img
src={rawImageUrl(filename)}
alt={v.entity_id}
@ -193,47 +193,41 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
display: 'block',
aspectRatio: '1/1',
objectFit: 'cover',
filter: imgFilter,
opacity: imgOpacity,
transition: 'filter 0.4s ease, opacity 0.4s ease',
}}
loading="lazy"
/>
{conf > 0 && (
<span style={{
position: 'absolute', top: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: labeled
? (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444')
: (passing ? '#10b98166' : conf >= 0.5 ? '#f59e0b66' : '#ef444466'),
color: labeled ? '#000' : (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444'),
}}>
{(conf * 100).toFixed(0)}%
</span>
)}
<span style={{
position: 'absolute', top: 8, left: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
background: labeled ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)',
color: labeled ? colors.muted : '#4a5060',
}}>
{v.category}
</span>
{v.is_approved && (
<span style={{
position: 'absolute', bottom: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: '#8b5cf6', color: '#000',
}}>
APPROVED
</span>
)}
</div>
</a>
{/* Info below image — ID, category, scores */}
<div style={{ padding: '8px 10px', fontSize: 11, color: textColor }}>
<div style={{ fontWeight: 600, color: entityColor, marginBottom: 2 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3 }}>
<a
href={`/sprite/${v.sprite_id}?variant=${v.variant_id}`}
style={{ fontWeight: 700, color: entityColor, fontSize: 12, textDecoration: 'none' }}
title={`Click to open variant #${v.variant_id}`}
>
#{v.variant_id}
</a>
<span style={{
padding: '1px 6px', borderRadius: 10, fontSize: 10, fontWeight: 600,
background: 'rgba(255,255,255,0.08)',
color: colors.muted,
}}>
{v.category}
</span>
{conf > 0 && (
<span style={{
padding: '1px 6px', borderRadius: 10, fontSize: 11, fontWeight: 700,
background: confColor,
color: '#000',
}}>
{(conf * 100).toFixed(0)}%
</span>
)}
</div>
<div style={{ color: entityColor, marginBottom: 2 }}>
{v.entity_id}
</div>
<div>seed: {v.seed}</div>
<div style={{ fontSize: 10, color: '#4a5060' }}>seed: {v.seed}</div>
{scores && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{Object.entries(scores).map(([k, val]) => (
@ -241,11 +235,51 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
key={k}
label={k.replace('_', ' ').replace('production ', 'prod ')}
value={val}
muted={!labeled}
muted={false}
/>
))}
</div>
)}
{!!v.is_approved ? (
<div style={{
marginTop: 4, padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 700,
background: '#8b5cf6', color: '#000', display: 'inline-block',
}}>
APPROVED
</div>
) : scores && (
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
<button
onClick={(e) => {
e.stopPropagation()
approveVariant(v.sprite_id, v.variant_id).then(
() => { onApprove(v.variant_id) },
(err) => { console.error('Approve failed:', err) },
)
}}
style={{
flex: 1, padding: '4px 0', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: '#10b98133', color: '#10b981', border: '1px solid #10b98155',
cursor: 'pointer',
}}
>
Approve
</button>
<button
onClick={(e) => {
e.stopPropagation()
onApprove(-v.variant_id)
}}
style={{
flex: 1, padding: '4px 0', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: '#ef444433', color: '#ef4444', border: '1px solid #ef444455',
cursor: 'pointer',
}}
>
Skip
</button>
</div>
)}
</div>
</div>
)
@ -272,7 +306,7 @@ function LeavingCard({
animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`,
}}
>
<Card v={v} isNew={false} index={0} />
<Card v={v} isNew={false} index={0} onApprove={() => {}} />
</div>
)
}
@ -300,11 +334,33 @@ function useCardRefCallbacks(cardRefs: React.MutableRefObject<Map<number, HTMLDi
/* ── page component ────────────────────────────────────────────── */
type FilterMode = 'all' | 'review'
export default function SpriteTheaterPage(): ReactNode {
const [variants, setVariants] = useState<RecentVariant[]>([])
const [newIds, setNewIds] = useState<Set<number>>(new Set())
const [connected, setConnected] = useState(false)
const [leaving, setLeaving] = useState<Array<{ v: RecentVariant; rect: DOMRect }>>([])
const searchParams = new URLSearchParams(window.location.search)
const [filter, setFilter] = useState<FilterMode>(
searchParams.get('onlyInReview') === 'true' ? 'review' : 'all'
)
const handleApproveOrSkip = useCallback((variantId: number) => {
if (variantId > 0) {
// Approve — mark as approved in local state (backend already called by card)
setVariants(prev => prev.map(v =>
v.variant_id === variantId ? { ...v, is_approved: true } : v
))
} else {
// Skip (negative ID) — persist rejection to DB + hide locally
const realId = -variantId
rejectVariant(realId).catch(err => console.error('Reject failed:', err))
setVariants(prev => prev.map(v =>
v.variant_id === realId ? { ...v, rating: -1 } : v
))
}
}, [])
const gridRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
@ -377,9 +433,28 @@ export default function SpriteTheaterPage(): ReactNode {
prevRectsRef.current = rects
}, [])
// Initial fetch
// Initial fetch + periodic refresh (picks up new ratings from Sonnet)
useEffect(() => {
fetchRecentVariants(200).then(setVariants).catch(() => { /* initial load failed */ })
const load = (): void => {
fetchRecentVariants(5000).then(fresh => {
setVariants(prev => {
if (prev.length === 0) return fresh
// Merge: update existing variants with new data (ratings), add new ones
const freshMap = new Map(fresh.map(v => [v.variant_id, v]))
const merged = prev.map(v => freshMap.get(v.variant_id) ?? v)
// Add any variants in fresh that aren't in prev
const prevIds = new Set(prev.map(v => v.variant_id))
const brandNew = fresh.filter(v => !prevIds.has(v.variant_id))
if (brandNew.length > 0) {
setNewIds(new Set(brandNew.map(v => v.variant_id)))
}
return [...brandNew, ...merged]
})
}).catch(() => { /* load failed */ })
}
load()
const interval = setInterval(load, 5_000) // refresh every 5s (localhost)
return () => clearInterval(interval)
}, [])
// SSE connection
@ -406,16 +481,27 @@ export default function SpriteTheaterPage(): ReactNode {
return () => es.close()
}, [captureRects])
// Memoize sorted + visible to prevent effect re-fire
// Memoize sorted + filtered to prevent effect re-fire
const sorted = useMemo(
() => {
const s = [...variants].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
let s = [...variants].sort((a, b) => {
const dt = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
return dt !== 0 ? dt : b.variant_id - a.variant_id
})
if (filter === 'review') {
s = s.filter(v => {
if (v.is_approved || v.rating === -1) return false
const scores = parseScores(v.notes)
if (!scores) return false
const vals = Object.values(scores)
const conf = vals.reduce((a, b) => a + b, 0) / vals.length
return conf >= 0.7
})
}
totalCountRef.current = s.length
return s
},
[variants],
[variants, filter],
)
const visible = useMemo(
@ -536,9 +622,33 @@ export default function SpriteTheaterPage(): ReactNode {
{connected ? 'Live' : 'Disconnected'} &mdash; {variants.length} sprites
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{(['all', 'review'] as const).map(mode => (
<button
key={mode}
onClick={() => {
setFilter(mode)
const url = new URL(window.location.href)
if (mode === 'review') {
url.searchParams.set('onlyInReview', 'true')
} else {
url.searchParams.delete('onlyInReview')
}
window.history.replaceState({}, '', url.toString())
}}
style={{
padding: '3px 10px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: filter === mode ? '#ffffff18' : 'transparent',
color: filter === mode ? colors.text : colors.muted,
border: filter === mode ? '1px solid #ffffff22' : '1px solid transparent',
cursor: 'pointer',
}}
>
{mode === 'all' ? 'All' : 'In Review (≥70%)'}
</button>
))}
<span style={{ fontSize: 11, color: colors.muted }}>
{cols}&times;{rows} grid &middot; {visible.length}/{sorted.length}
{visible.length}/{sorted.length}
</span>
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Dashboard
@ -585,6 +695,7 @@ export default function SpriteTheaterPage(): ReactNode {
v={v}
isNew={newIds.has(v.variant_id)}
index={i}
onApprove={handleApproveOrSkip}
/>
))}
</div>