diff --git a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx
index d4598cdb..e1f70cc4 100644
--- a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx
+++ b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx
@@ -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 (
![]()
([])
+ const [newIds, setNewIds] = useState
>(new Set())
const [connected, setConnected] = useState(false)
const eventSourceRef = useRef(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) => {
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 (