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"
- >
- ▦
-
-
>
)