feat(sprite-generation): Introduce interactive sprite theater UI with drag-and-drop, zoom, and animation preview features

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 01:44:20 -07:00
parent 5bd982c62b
commit c4bedc98b7

View file

@ -52,6 +52,9 @@ const gridStyle: CSSProperties = {
padding: '16px',
}
const CARD_ENTER_DURATION = 400
const CARD_SHIFT_DURATION = 300
function ScoreBadge({ label, value }: { label: string; value: number }) {
const bg = value >= 0.7 ? 'rgba(16,185,129,0.2)' : value >= 0.5 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)'
const fg = value >= 0.7 ? '#10b981' : value >= 0.5 ? '#f59e0b' : '#ef4444'
@ -62,12 +65,18 @@ function ScoreBadge({ label, value }: { label: string; value: number }) {
)
}
function Card({ v }: { v: RecentVariant }) {
function Card({ v, isNew, index }: { v: RecentVariant; isNew: boolean; index: number }) {
const conf = confidence(v)
const scores = parseScores(v.notes)
const passing = conf >= 0.7
const filename = extractFilename(v.raw_path)
const enterDelay = isNew ? 0 : index * 30
const animStyle: CSSProperties = {
transition: `transform ${CARD_SHIFT_DURATION}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_DURATION}ms ease`,
...(isNew ? { animation: `fadeScaleIn ${CARD_ENTER_DURATION}ms ease forwards` } : {}),
}
return (
<div style={{
background: colors.surface,
@ -75,6 +84,7 @@ function Card({ v }: { v: RecentVariant }) {
overflow: 'hidden',
border: passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b',
position: 'relative',
...animStyle,
}}>
<div style={{ position: 'relative' }}>
<img
@ -121,11 +131,19 @@ function Card({ v }: { v: RecentVariant }) {
export default function SpriteTheaterPage() {
const [variants, setVariants] = useState<RecentVariant[]>([])
const [newIds, setNewIds] = useState<Set<number>>(new Set())
const [connected, setConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
// Clear "new" flags after animation completes
useEffect(() => {
fetchRecentVariants(100).then(setVariants).catch(() => { /* initial load failed — will retry via SSE */ })
if (newIds.size === 0) return
const timer = setTimeout(() => setNewIds(new Set()), CARD_ENTER_DURATION + 200)
return () => clearTimeout(timer)
}, [newIds])
useEffect(() => {
fetchRecentVariants(200).then(setVariants).catch(() => { /* initial load failed */ })
}, [])
useEffect(() => {
@ -137,25 +155,24 @@ export default function SpriteTheaterPage() {
es.onmessage = (event: MessageEvent<string>) => {
try {
const newVariants: RecentVariant[] = JSON.parse(event.data) as RecentVariant[]
const incoming: RecentVariant[] = JSON.parse(event.data) as RecentVariant[]
setVariants(prev => {
const ids = new Set(prev.map(v => v.variant_id))
const fresh = newVariants.filter(v => !ids.has(v.variant_id))
const existingIds = new Set(prev.map(v => v.variant_id))
const fresh = incoming.filter(v => !existingIds.has(v.variant_id))
if (fresh.length === 0) return prev
setNewIds(new Set(fresh.map(v => v.variant_id)))
return [...fresh, ...prev]
})
} catch { /* keepalive or malformed — ignore */ }
} catch { /* keepalive or malformed */ }
}
return () => es.close()
}, [])
const sorted = [...variants].sort((a, b) => {
const ca = confidence(a)
const cb = confidence(b)
if (ca !== cb) return cb - ca
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// Sort by recency: newest first (top-left), oldest last (bottom-right)
const sorted = [...variants].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
return (
<div style={page}>