From 80e1eccfb001dad73f3303a4b32c885fba3f1adf Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 29 Mar 2026 06:36:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(sprite-generation):=20=E2=9C=A8=20Introduc?= =?UTF-8?q?e=20real-time=20sprite=20streaming=20UI=20components=20and=20pr?= =?UTF-8?q?eview=20logic=20in=20App.tsx=20and=20SpriteStream.tsx?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/sprite-generation/gui/src/App.tsx | 44 ++-- .../gui/src/SpriteStream.tsx | 200 +++--------------- 2 files changed, 60 insertions(+), 184 deletions(-) diff --git a/tools/sprite-generation/gui/src/App.tsx b/tools/sprite-generation/gui/src/App.tsx index e77ab133..fbcfa5a1 100644 --- a/tools/sprite-generation/gui/src/App.tsx +++ b/tools/sprite-generation/gui/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route, Link, useSearchParams } from 'react-router-dom' +import { Routes, Route, Link, useLocation } from 'react-router-dom' import type { ReactElement } from 'react' import { DashboardPage } from './pages/DashboardPage' import { ReviewQueuePage } from './pages/ReviewQueuePage' @@ -9,17 +9,9 @@ import VariantPage from './pages/VariantPage' import { SpriteStream } from './SpriteStream' import { colors } from './pages/theme' -function DashboardOrTheater(): ReactElement { - const [params] = useSearchParams() - if (params.get('spriteTheater') === 'true') { - return - } - return -} - export function App(): ReactElement { - const [params] = useSearchParams() - const isTheater = params.get('spriteTheater') === 'true' + const location = useLocation() + const isTheater = location.pathname === '/theater' return (
- - Theater - - - Review - + {[ + { to: '/theater', label: 'Theater' }, + { to: '/review', label: 'Review' }, + ].map(({ to, label }) => { + const active = location.pathname === to + return ( + + {label} + + ) + })}
)} - } /> + } /> } /> } /> } /> diff --git a/tools/sprite-generation/gui/src/SpriteStream.tsx b/tools/sprite-generation/gui/src/SpriteStream.tsx index dbbfa68f..156a6462 100644 --- a/tools/sprite-generation/gui/src/SpriteStream.tsx +++ b/tools/sprite-generation/gui/src/SpriteStream.tsx @@ -1,12 +1,12 @@ -import { useEffect, useRef, useState, useCallback, type ReactNode, type CSSProperties } from 'react' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useEffect, useRef, useState, useCallback, type ReactNode } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' import { fetchRecentVariants, variantStreamUrl, bestImageUrl, rawImageUrl } from './api' import type { RecentVariant } from './types' import { colors } from './pages/theme' const MAX_SPRITES = 60 -const STREAM_HEIGHT = 110 -const SPRITE_SIZE = 80 +const STREAM_HEIGHT = 132 +const SPRITE_SIZE = 100 const CARD_GAP = 16 const CARD_TOTAL = SPRITE_SIZE + CARD_GAP const SSE_RETRY_DELAYS = [5000, 10000, 20000, 30000] @@ -30,46 +30,23 @@ const categoryColors: Record = { ui: '#6b7280', } -interface RatingSentiment { - emoji: string - label: string - color: string -} - -function getRatingSentiment(rating: number): RatingSentiment { - if (rating >= 4.5) return { emoji: '\u2728', label: 'stellar', color: '#fbbf24' } - if (rating >= 3.5) return { emoji: '\u2705', label: 'good', color: '#10b981' } - if (rating >= 2.5) return { emoji: '\uD83D\uDE10', label: 'okay', color: '#f59e0b' } - if (rating >= 1.5) return { emoji: '\uD83D\uDC4E', label: 'weak', color: '#ef4444' } - return { emoji: '\u274C', label: 'poor', color: '#dc2626' } -} - -function parseTopScore(notes: string | null): string | null { - if (!notes) return null - const parsed: unknown = JSON.parse(notes) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null - const entries = Object.entries(parsed as Record) - .filter((e): e is [string, number] => typeof e[1] === 'number') - .sort((a, b) => b[1] - a[1]) - if (entries.length === 0) return null - const [key, val] = entries[0] - return `${key.replace(/_/g, ' ')} ${Math.round(val * 100)}%` +function ratingColor(rating: number): string { + if (rating >= 4.5) return '#fbbf24' + if (rating >= 3.5) return '#10b981' + if (rating >= 2.5) return '#f59e0b' + return '#ef4444' } function RatingBadge({ variant, - size, justScored, }: { variant: RecentVariant - size: number justScored: boolean }): ReactNode { - if (variant.rating === null) return null + if (variant.rating === null || variant.rating <= 0) return null - const sentiment = getRatingSentiment(variant.rating) - const topScore = parseTopScore(variant.notes) - const isLarge = size > 100 + const color = ratingColor(variant.rating) return (
- - {sentiment.emoji} - + /> + {variant.rating.toFixed(1)} - {isLarge && topScore && ( - - {topScore} - - )}
) } @@ -149,7 +112,7 @@ function SpriteCard({ alignItems: 'center', flexShrink: 0, cursor: 'pointer', - opacity: loaded ? 0.7 : 0, + opacity: loaded ? 0.75 : 0, transition: 'opacity 0.4s ease, transform 0.2s ease', animation: isNew ? `spriteGlow ${GLOW_DURATION}ms ease-out forwards` : undefined, padding: '4px 2px', @@ -159,7 +122,7 @@ function SpriteCard({ e.currentTarget.style.transform = 'scale(1.08)' }} onMouseLeave={(e): void => { - e.currentTarget.style.opacity = '0.7' + e.currentTarget.style.opacity = '0.75' e.currentTarget.style.transform = 'scale(1)' }} > @@ -170,7 +133,7 @@ function SpriteCard({ height: size, borderRadius: 6, overflow: 'hidden', - border: `1px solid ${variant.rating !== null ? getRatingSentiment(variant.rating).color + '66' : colors.accent + '66'}`, + border: `1px solid ${variant.rating !== null ? ratingColor(variant.rating) + '66' : colors.accent + '66'}`, background: colors.bg, transition: 'border-color 0.6s ease', }} @@ -202,7 +165,7 @@ function SpriteCard({ right: 3, padding: '1px 5px', borderRadius: 6, - fontSize: size > 100 ? 9 : 8, + fontSize: 8, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.3px', @@ -221,21 +184,21 @@ function SpriteCard({ left: 3, padding: '1px 5px', borderRadius: 6, - fontSize: size > 100 ? 9 : 8, + fontSize: 8, fontWeight: 700, background: '#22c55e44', color: '#22c55e', border: '1px solid #22c55e33', }} > - {'\u2713'} + ✓
)} - + 100 ? 10 : 9, + fontSize: 9, color: colors.muted, marginTop: 3, maxWidth: size + 10, @@ -251,60 +214,13 @@ function SpriteCard({ ) } -const controlBtnStyle: CSSProperties = { - background: 'none', - border: 'none', - color: colors.muted, - cursor: 'pointer', - fontSize: 16, - padding: '4px 8px', - lineHeight: 1, - borderRadius: 4, - transition: 'color 0.15s, background 0.15s', -} - -function ControlButton({ - onClick, - title, - children, - active, -}: { - onClick: () => void - title: string - children: ReactNode - active?: boolean -}): ReactNode { - return ( - - ) -} - - export function SpriteStream(): ReactNode { const navigate = useNavigate() - const [searchParams, setSearchParams] = useSearchParams() + const location = useLocation() + const theater = location.pathname === '/theater' const [sprites, setSprites] = useState([]) const [newIds, setNewIds] = useState>(new Set()) const [scoredIds, setScoredIds] = useState>(new Set()) - const [theater, setTheaterState] = useState(searchParams.get('spriteTheater') === 'true') const [containerWidth, setContainerWidth] = useState(0) const containerRef = useRef(null) const trackRef = useRef(null) @@ -314,18 +230,6 @@ export function SpriteStream(): ReactNode { const prevLengthRef = useRef(0) const shiftingRef = useRef(false) - const toggleTheater = useCallback((on: boolean): void => { - setTheaterState(on) - setSearchParams(prev => { - if (on) { - prev.set('spriteTheater', 'true') - } else { - prev.delete('spriteTheater') - } - return prev - }, { replace: true }) - }, [setSearchParams]) - const visibleCount = containerWidth > 0 ? Math.floor(containerWidth / CARD_TOTAL) : 0 // Track container width via ResizeObserver @@ -344,7 +248,6 @@ export function SpriteStream(): ReactNode { const mergeSprites = useCallback((incoming: RecentVariant[], markNew: boolean): void => { setSprites(prev => { const existingMap = new Map(prev.map(s => [s.variant_id, s])) - const freshIds: number[] = [] const justScoredIds: number[] = [] for (const v of incoming) { @@ -355,13 +258,10 @@ export function SpriteStream(): ReactNode { } existingMap.set(v.variant_id, v) } else { - freshIds.push(v.variant_id) existingMap.set(v.variant_id, v) } } - if (freshIds.length === 0 && justScoredIds.length === 0) return prev - if (justScoredIds.length > 0) { setScoredIds(p => { const next = new Set(p) @@ -474,9 +374,6 @@ export function SpriteStream(): ReactNode { }, [mergeSprites, connectSSE, stopPolling]) // Animate shift when new sprites arrive - // Sprites are newest-first. We display them reversed (oldest left, newest right). - // When new items prepend to `sprites`, the visible window gains items on the right - // and we animate the track shifting left by the number of new cards. useEffect((): void => { const track = trackRef.current if (!track || shiftingRef.current) return @@ -486,11 +383,9 @@ export function SpriteStream(): ReactNode { const shiftPx = newCount * CARD_TOTAL shiftingRef.current = true - // Start shifted right (new items off-screen right) track.style.transition = 'none' track.style.transform = `translateX(${shiftPx}px)` - // Force reflow, then animate to 0 void track.offsetHeight track.style.transition = `transform ${SHIFT_DURATION_MS}ms ease-out` track.style.transform = 'translateX(0)' @@ -510,29 +405,22 @@ export function SpriteStream(): ReactNode { const keyframeStyles = ` @keyframes spriteGlow { 0% { opacity: 1; filter: drop-shadow(0 0 12px ${colors.highlight}aa) drop-shadow(0 0 24px ${colors.highlight}44); } - 100% { opacity: 0.7; filter: drop-shadow(0 0 0px transparent); } + 100% { opacity: 0.75; filter: drop-shadow(0 0 0px transparent); } } @keyframes spritePulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 0.6; } } - @keyframes theaterFadeIn { - 0% { opacity: 0; transform: translateY(8px) scale(0.95); } - 100% { opacity: 1; transform: translateY(0) scale(1); } - } @keyframes scoreFlash { 0% { background: rgba(251, 191, 36, 0.4); box-shadow: 0 0 8px rgba(251, 191, 36, 0.6); } 100% { background: rgba(0, 0, 0, 0.75); box-shadow: none; } } ` - // When theater mode is active, SpriteTheaterPage handles the full view. - // SpriteStream hides itself to avoid rendering on top. if (theater) { return null } - // Sprites are newest-first. Reverse so oldest is left, newest is right. const displaySprites = visibleCount > 0 ? sprites.slice(0, visibleCount).reverse() : sprites.slice(0, 8).reverse() @@ -578,7 +466,7 @@ export function SpriteStream(): ReactNode { position: 'relative', }} > - {/* Sprite track — items flow left-to-right, newest on the right */} + {/* Sprite track */}
{displaySprites.map(variant => ( @@ -617,26 +505,6 @@ export function SpriteStream(): ReactNode { }} /> - {/* Theater toggle — bottom-right */} -
- toggleTheater(true)} - title="Theater mode" - > - ▦ - -
)