feat(sprite-generation): Update React components and theme for sprite coverage dashboard and theming support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 22:38:20 -07:00
parent 97e9235a39
commit c82e894c9f
6 changed files with 676 additions and 251 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View file

@ -7,6 +7,7 @@ import { SpritePage } from './pages/SpritePage'
import SpriteTheaterPage from './pages/SpriteTheaterPage'
import TerrainGridPage from './pages/TerrainGridPage'
import VariantPage from './pages/VariantPage'
import { SpriteCoveragePage } from './pages/SpriteCoveragePage'
import { SpriteStream } from './SpriteStream'
import { colors } from './pages/theme'
@ -52,6 +53,7 @@ export function App(): ReactElement {
{[
{ to: '/theater', label: 'Theater' },
{ to: '/review', label: 'Review' },
{ to: '/coverage', label: 'Coverage' },
{ to: '/terrain-grid', label: 'Terrain Grid' },
].map(({ to, label }) => {
const active = location.pathname === to
@ -81,6 +83,7 @@ export function App(): ReactElement {
<Route path="/review" element={<ReviewQueuePage />} />
<Route path="/category/:name" element={<CategoryPage />} />
<Route path="/variant/:id" element={<VariantPage />} />
<Route path="/coverage" element={<SpriteCoveragePage />} />
<Route path="/terrain-grid" element={<TerrainGridPage />} />
<Route path="/sprite/*" element={<SpritePage />} />
</Routes>

View file

@ -1,28 +1,10 @@
import { useEffect, useRef, useState, useCallback, type CSSProperties, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { FlashNumber, FlashRow, useFlashRow } from '@lilith/ui-animated'
import { useNavigate, Link } from 'react-router-dom'
import { FlashNumber, FlashRow } from '@lilith/ui-animated'
import { fetchPipeline, runPipeline, fetchPipelineStatus, type PipelineState } from '../api'
import { colors } from './theme'
import { colors, SCORER_COLORS, SCORER_ORDER, SCORER_TOOLTIPS, type ScorerName } from './theme'
import { Tooltip } from '../components/Tooltip'
const SCORER_COLORS: Record<string, string> = {
qwen3: '#8b5cf6',
haiku: '#06b6d4',
sonnet: '#3b82f6',
opus: '#f59e0b',
}
const SCORER_ORDER = ['qwen3', 'haiku', 'sonnet', 'opus'] as const
const SCORER_TOOLTIPS: Record<string, string> = {
qwen3: 'QWEN3 — fast visual scorer',
haiku: 'Haiku — Claude aesthetic scorer',
sonnet: 'Sonnet — Claude mid-tier scorer',
opus: 'Opus — premium quality scorer',
}
type ScorerName = 'qwen3' | 'haiku' | 'sonnet' | 'opus'
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const s = Math.floor(diff / 1000)
@ -40,23 +22,6 @@ const card: CSSProperties = {
padding: '18px 20px',
}
const thS: CSSProperties = {
textAlign: 'left',
padding: '7px 10px',
borderBottom: `1px solid ${colors.accent}`,
color: colors.muted,
fontWeight: 600,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
}
const tdS: CSSProperties = {
padding: '8px 10px',
borderBottom: `1px solid ${colors.accent}30`,
fontSize: 12,
}
function SectionTitle({ children }: { children: ReactNode }): ReactNode {
return (
@ -414,7 +379,7 @@ function ActivityFeed({
<span style={{ color: colors.muted, fontSize: 13 }}>No recent scoring events.</span>
)}
{scores.map((ev, i) => {
const color = SCORER_COLORS[ev.scorer_name] ?? colors.muted
const color = SCORER_COLORS[ev.scorer_name as keyof typeof SCORER_COLORS] ?? colors.muted
const passColor = ev.gate_passed ? '#4ade80' : '#f87171'
const isHovered = hovered === i
return (
@ -478,206 +443,6 @@ function ActivityFeed({
)
}
// ── Sprite Coverage Table ────────────────────────────────────────────────────
function TierDot({ scored, passed, color }: { scored: number; passed: number; color: string }): ReactNode {
if (scored === 0) {
return (
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: colors.accent,
opacity: 0.4,
}}
/>
)
}
const allPassed = passed === scored
return (
<span
title={`${passed}/${scored} passed`}
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: allPassed ? color : '#f8717180',
boxShadow: allPassed ? `0 0 4px ${color}88` : 'none',
}}
/>
)
}
function CoverageRow({
row,
isHovered,
onRowClick,
onMouseEnter,
onMouseLeave,
}: {
row: PipelineState['sprite_coverage'][number]
isHovered: boolean
onRowClick: (spriteId: string) => void
onMouseEnter: () => void
onMouseLeave: () => void
}): ReactNode {
const hasDeficit = row.deficit > 0
const flash = useFlashRow({
watchKey: row.processed + row.all_passed,
groupKey: 'coverage',
color: hasDeficit ? '#f87171' : '#4ade80',
})
return (
<tr
key={row.sprite_id}
className={flash.className}
data-flashing={flash['data-flashing']}
style={{
...flash.style,
background: isHovered
? colors.accent + '50'
: hasDeficit
? '#f8717108'
: 'transparent',
cursor: 'pointer',
transition: 'background 0.1s',
}}
onClick={() => onRowClick(row.sprite_id)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<td style={tdS}>
<span style={{ color: colors.text, fontWeight: 600 }}>{row.entity_id}</span>
<br />
<span style={{ color: colors.muted, fontSize: 10, fontFamily: 'monospace' }}>
{row.sprite_id}
</span>
</td>
<td style={{ ...tdS, textAlign: 'center', color: colors.muted }}>{row.total_variants}</td>
<td style={{ ...tdS, textAlign: 'center', color: '#3b82f6' }}>
<FlashNumber value={row.processed} color="#3b82f6" />
</td>
{SCORER_ORDER.map((s) => {
const tier = row.tier_counts[s]
return (
<td key={s} style={{ ...tdS, textAlign: 'center' }}>
{tier ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 4 }}>
<TierDot scored={tier.scored} passed={tier.passed} color={SCORER_COLORS[s]} />
<span style={{ color: colors.muted, fontSize: 10 }}>
<FlashNumber value={tier.passed} color={SCORER_COLORS[s]} />
/
<FlashNumber value={tier.scored} color={SCORER_COLORS[s]} />
</span>
</div>
) : (
<span style={{ color: colors.accent, fontSize: 11 }}></span>
)}
</td>
)
})}
<td style={{ ...tdS, textAlign: 'center' }}>
<span
style={{
color: row.all_passed >= 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted,
fontWeight: 700,
}}
>
<FlashNumber
value={row.all_passed}
color={row.all_passed >= 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted}
/>
</span>
</td>
<td style={{ ...tdS, textAlign: 'center' }}>
{hasDeficit ? (
<span
style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 8,
fontSize: 10,
fontWeight: 700,
background: '#4a1a1a',
color: '#f87171',
border: '1px solid #f8717144',
}}
>
-<FlashNumber value={row.deficit} color="#f87171" />
</span>
) : (
<span style={{ color: '#4ade80', fontSize: 14 }}></span>
)}
</td>
</tr>
)
}
function SpriteCoverageTable({
coverage,
onRowClick,
}: {
coverage: PipelineState['sprite_coverage']
onRowClick: (spriteId: string) => void
}): ReactNode {
const deficitCount = coverage.filter(r => r.deficit > 0).length
const [hovered, setHovered] = useState<string | null>(null)
return (
<div style={{ ...card }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
<SectionTitle>Sprite Coverage</SectionTitle>
{deficitCount > 0 && (
<span
style={{
fontSize: 11,
fontWeight: 700,
color: '#f87171',
background: '#f8717118',
border: '1px solid #f8717144',
borderRadius: 8,
padding: '2px 8px',
}}
>
<FlashNumber value={deficitCount} color="#f87171" /> need work
</span>
)}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr>
<th style={thS}>Sprite</th>
<th style={{ ...thS, textAlign: 'center' }}>Var</th>
<th style={{ ...thS, textAlign: 'center' }}>Proc</th>
{SCORER_ORDER.map((s) => (
<th key={s} style={{ ...thS, textAlign: 'center', color: SCORER_COLORS[s] }}>
{s[0].toUpperCase()}
</th>
))}
<th style={{ ...thS, textAlign: 'center' }}></th>
<th style={{ ...thS, textAlign: 'center' }}>Δ</th>
</tr>
</thead>
<tbody>
{coverage.map((row) => (
<CoverageRow
key={row.sprite_id}
row={row}
isHovered={hovered === row.sprite_id}
onRowClick={onRowClick}
onMouseEnter={() => setHovered(row.sprite_id)}
onMouseLeave={() => setHovered(null)}
/>
))}
</tbody>
</table>
</div>
</div>
)
}
// ── Scorer Toggle Button ──────────────────────────────────────────────────────
@ -1099,11 +864,50 @@ export function DashboardPage(): ReactNode {
onRowClick={(id) => navigate('/sprite/' + id)}
/>
{/* Coverage — full width at bottom */}
<SpriteCoverageTable
coverage={pipeline.sprite_coverage}
onRowClick={(id) => navigate('/sprite/' + id)}
/>
{/* Coverage summary — links to full coverage page */}
{(() => {
const deficitCount = pipeline.sprite_coverage.filter(r => r.deficit > 0).length
return (
<Link
to="/coverage"
style={{ textDecoration: 'none' }}
>
<div
style={{
...card,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = colors.highlight }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = colors.accent }}
>
<div>
<SectionTitle>Sprite Coverage</SectionTitle>
<span style={{ color: colors.muted, fontSize: 13 }}>
{pipeline.sprite_coverage.length} sprites tracked
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{deficitCount > 0 && (
<span style={{
fontSize: 11, fontWeight: 700, color: '#f87171',
background: '#f8717118', border: '1px solid #f8717144',
borderRadius: 8, padding: '2px 8px',
}}>
{deficitCount} need work
</span>
)}
<span style={{ color: colors.highlight, fontSize: 13, fontWeight: 600 }}>
Coverage
</span>
</div>
</div>
</Link>
)
})()}
</>
)}
</div>

View file

@ -0,0 +1,601 @@
import { useCallback, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { FlashNumber, useFlashRow } from '@lilith/ui-animated'
import { fetchPipeline, fetchVariants, triggerGenerate, type PipelineState } from '../api'
import type { Variant } from '../types'
import { colors, SCORER_COLORS, SCORER_ORDER } from './theme'
import { Tooltip } from '../components/Tooltip'
type CoverageRow = PipelineState['sprite_coverage'][number]
type BtnState = 'idle' | 'loading' | 'queued'
// ── Shared styles ────────────────────────────────────────────────────────────
const card: CSSProperties = {
background: colors.surface,
borderRadius: 10,
border: `1px solid ${colors.accent}`,
padding: '18px 20px',
}
const thS: CSSProperties = {
textAlign: 'left',
padding: '7px 10px',
borderBottom: `1px solid ${colors.accent}`,
color: colors.muted,
fontWeight: 600,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: '0.5px',
whiteSpace: 'nowrap',
}
const tdS: CSSProperties = {
padding: '8px 10px',
borderBottom: `1px solid ${colors.accent}30`,
fontSize: 12,
}
// ── Tier dot ────────────────────────────────────────────────────────────────
function TierDot({ scored, passed, color }: { scored: number; passed: number; color: string }): ReactNode {
if (scored === 0) {
return (
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: colors.accent, opacity: 0.4,
}} />
)
}
const allPassed = passed === scored
return (
<span
title={`${passed}/${scored} passed`}
style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: allPassed ? color : '#f8717180',
boxShadow: allPassed ? `0 0 4px ${color}88` : 'none',
}}
/>
)
}
// ── +3 generate button ───────────────────────────────────────────────────────
function GenerateButton({ spriteId }: { spriteId: string }): ReactNode {
const [state, setState] = useState<BtnState>('idle')
const handleClick = async (e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (state !== 'idle') return
setState('loading')
try {
await triggerGenerate({ sprite_id: spriteId, variants: 3 })
setState('queued')
setTimeout(() => setState('idle'), 2000)
} catch {
setState('idle')
}
}
const label = state === 'loading' ? '…' : state === 'queued' ? '✓' : '+3'
const bg =
state === 'queued' ? '#16543a' :
state === 'loading' ? colors.accent :
'#0f346030'
const borderColor =
state === 'queued' ? '#22c55e66' :
state === 'loading' ? colors.muted + '44' :
'#3b82f644'
const textColor =
state === 'queued' ? '#4ade80' :
state === 'loading' ? colors.muted :
'#60a5fa'
return (
<Tooltip text="Queue 3 more generation jobs for this sprite" placement="bottom">
<button
onClick={(e) => { void handleClick(e) }}
disabled={state !== 'idle'}
style={{
border: `1px solid ${borderColor}`,
borderRadius: 5,
padding: '3px 7px',
fontSize: 10,
fontWeight: 700,
cursor: state === 'idle' ? 'pointer' : 'default',
background: bg,
color: textColor,
fontFamily: 'monospace',
minWidth: 28,
textAlign: 'center',
transition: 'all 0.15s',
letterSpacing: '0.3px',
}}
>
{label}
</button>
</Tooltip>
)
}
// ── Expanded detail panel ────────────────────────────────────────────────────
function DetailPanel({
row,
variants,
loading,
onNavigate,
}: {
row: CoverageRow
variants: Variant[]
loading: boolean
onNavigate: () => void
}): ReactNode {
const sorted = [...variants].sort((a, b) => {
if (a.rating === null && b.rating === null) return 0
if (a.rating === null) return 1
if (b.rating === null) return -1
return b.rating - a.rating
})
const top = sorted.slice(0, 8)
return (
<tr>
<td
colSpan={3 + SCORER_ORDER.length + 2}
style={{ padding: 0, borderBottom: `1px solid ${colors.accent}50` }}
>
<div
style={{
background: `${colors.bg}cc`,
borderTop: `1px solid ${colors.accent}40`,
padding: '14px 16px 14px 52px',
display: 'flex',
gap: 20,
alignItems: 'flex-start',
}}
>
{/* Thumbnail strip */}
<div style={{ flex: 1 }}>
{loading ? (
<span style={{ color: colors.muted, fontSize: 12 }}>Loading variants</span>
) : top.length === 0 ? (
<span style={{ color: colors.muted, fontSize: 12 }}>No variants yet</span>
) : (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{top.map((v) => {
const src = v.processed_path
? `/images/variants/${v.processed_path.split('/').pop()}`
: v.raw_path
? `/images/raw/${v.raw_path.split('/').pop()}`
: null
const ratingColor =
v.rating !== null && v.rating >= 4 ? '#4ade80' :
v.rating !== null && v.rating >= 3 ? '#fbbf24' :
'#f87171'
return (
<div
key={v.id}
style={{
position: 'relative',
width: 64,
flexShrink: 0,
}}
>
<div style={{
width: 64, height: 64,
background: colors.accent,
borderRadius: 6,
overflow: 'hidden',
border: v.is_approved
? '2px solid #22c55e'
: `1px solid ${colors.accent}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{src ? (
<img
src={src}
alt={`variant ${v.id}`}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : (
<span style={{ color: colors.muted, fontSize: 9 }}>no img</span>
)}
</div>
{v.rating !== null && (
<span style={{
position: 'absolute', bottom: -2, right: -2,
fontSize: 9, fontWeight: 700,
background: colors.surface,
color: ratingColor,
borderRadius: 4,
padding: '1px 3px',
border: `1px solid ${ratingColor}44`,
lineHeight: 1,
}}>
{v.rating.toFixed(1)}
</span>
)}
{v.is_approved && (
<span style={{
position: 'absolute', top: -4, left: -4,
fontSize: 10, color: '#22c55e',
}}></span>
)}
</div>
)
})}
</div>
)}
</div>
{/* Tier breakdown mini bars */}
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6, minWidth: 160 }}>
{SCORER_ORDER.map((s) => {
const tier = row.tier_counts[s]
if (!tier) return null
const pct = tier.scored > 0 ? (tier.passed / tier.scored) * 100 : 0
const color = SCORER_COLORS[s]
return (
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color, fontWeight: 700, fontSize: 10, width: 40, textAlign: 'right', textTransform: 'uppercase' }}>
{s[0].toUpperCase() + s.slice(1, 3)}
</span>
<div style={{
flex: 1, height: 6, background: colors.accent,
borderRadius: 3, overflow: 'hidden',
}}>
<div style={{
width: `${pct}%`, height: '100%',
background: color, borderRadius: 3,
transition: 'width 0.3s ease',
}} />
</div>
<span style={{ color: colors.muted, fontSize: 10, width: 36 }}>
{tier.passed}/{tier.scored}
</span>
</div>
)
})}
</div>
{/* Actions */}
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
<button
onClick={onNavigate}
style={{
border: `1px solid ${colors.highlight}66`,
borderRadius: 6,
padding: '5px 12px',
fontSize: 11,
fontWeight: 600,
cursor: 'pointer',
background: `${colors.highlight}18`,
color: colors.highlight,
}}
>
Open sprite
</button>
<span style={{ color: colors.muted, fontSize: 10 }}>
{variants.length} total variants
</span>
</div>
</div>
</td>
</tr>
)
}
// ── Coverage row ─────────────────────────────────────────────────────────────
function CoverageTableRow({
row,
expanded,
variants,
variantsLoading,
onToggle,
onNavigate,
}: {
row: CoverageRow
expanded: boolean
variants: Variant[]
variantsLoading: boolean
onToggle: () => void
onNavigate: () => void
}): ReactNode {
const hasDeficit = row.deficit > 0
const flash = useFlashRow({
watchKey: row.processed + row.all_passed,
groupKey: 'coverage',
color: hasDeficit ? '#f87171' : '#4ade80',
})
return (
<>
<tr
className={flash.className}
data-flashing={flash['data-flashing']}
style={{
...flash.style,
background: expanded
? colors.accent + '60'
: hasDeficit
? '#f8717108'
: 'transparent',
cursor: 'pointer',
transition: 'background 0.1s',
}}
onClick={onToggle}
>
{/* +3 button cell */}
<td style={{ ...tdS, width: 36, paddingRight: 4 }} onClick={(e) => e.stopPropagation()}>
<GenerateButton spriteId={row.sprite_id} />
</td>
{/* Sprite name */}
<td style={tdS}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
color: colors.muted, fontSize: 10,
transform: `rotate(${expanded ? 90 : 0}deg)`,
display: 'inline-block', transition: 'transform 0.15s',
userSelect: 'none',
}}></span>
<div>
<span style={{ color: colors.text, fontWeight: 600 }}>{row.entity_id}</span>
<br />
<span style={{ color: colors.muted, fontSize: 10, fontFamily: 'monospace' }}>
{row.sprite_id}
</span>
</div>
</div>
</td>
<td style={{ ...tdS, textAlign: 'center', color: colors.muted }}>{row.total_variants}</td>
<td style={{ ...tdS, textAlign: 'center', color: '#3b82f6' }}>
<FlashNumber value={row.processed} color="#3b82f6" />
</td>
{SCORER_ORDER.map((s) => {
const tier = row.tier_counts[s]
return (
<td key={s} style={{ ...tdS, textAlign: 'center' }}>
{tier ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 4 }}>
<TierDot scored={tier.scored} passed={tier.passed} color={SCORER_COLORS[s]} />
<span style={{ color: colors.muted, fontSize: 10 }}>
<FlashNumber value={tier.passed} color={SCORER_COLORS[s]} />
/
<FlashNumber value={tier.scored} color={SCORER_COLORS[s]} />
</span>
</div>
) : (
<span style={{ color: colors.accent, fontSize: 11 }}></span>
)}
</td>
)
})}
<td style={{ ...tdS, textAlign: 'center' }}>
<span style={{
color: row.all_passed >= 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted,
fontWeight: 700,
}}>
<FlashNumber
value={row.all_passed}
color={row.all_passed >= 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted}
/>
</span>
</td>
<td style={{ ...tdS, textAlign: 'center' }}>
{hasDeficit ? (
<span style={{
display: 'inline-block', padding: '1px 6px', borderRadius: 8,
fontSize: 10, fontWeight: 700,
background: '#4a1a1a', color: '#f87171', border: '1px solid #f8717144',
}}>
-<FlashNumber value={row.deficit} color="#f87171" />
</span>
) : (
<span style={{ color: '#4ade80', fontSize: 14 }}></span>
)}
</td>
</tr>
{expanded && (
<DetailPanel
row={row}
variants={variants}
loading={variantsLoading}
onNavigate={onNavigate}
/>
)}
</>
)
}
// ── Main page ────────────────────────────────────────────────────────────────
export function SpriteCoveragePage(): ReactNode {
const navigate = useNavigate()
const [coverage, setCoverage] = useState<CoverageRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const [variantCache, setVariantCache] = useState<Map<string, Variant[]>>(new Map())
const [variantsLoading, setVariantsLoading] = useState(false)
const [search, setSearch] = useState('')
const load = useCallback((): void => {
fetchPipeline()
.then((data) => {
setCoverage(data.sprite_coverage)
setLoading(false)
setError(null)
})
.catch((e: unknown) => {
setError(e instanceof Error ? e.message : String(e))
setLoading(false)
})
}, [])
useEffect((): (() => void) => {
load()
const interval = setInterval(load, 5000)
return () => clearInterval(interval)
}, [load])
const handleToggle = (spriteId: string): void => {
if (expanded === spriteId) {
setExpanded(null)
return
}
setExpanded(spriteId)
if (!variantCache.has(spriteId)) {
setVariantsLoading(true)
fetchVariants(spriteId)
.then((vs) => {
setVariantCache((prev) => new Map(prev).set(spriteId, vs))
setVariantsLoading(false)
})
.catch(() => {
setVariantCache((prev) => new Map(prev).set(spriteId, []))
setVariantsLoading(false)
})
}
}
const filtered = search.trim()
? coverage.filter((r) =>
r.sprite_id.includes(search.toLowerCase()) ||
r.entity_id.toLowerCase().includes(search.toLowerCase())
)
: coverage
const deficitCount = coverage.filter((r) => r.deficit > 0).length
return (
<div style={{ minHeight: '100vh', background: colors.bg, color: colors.text }}>
{/* Header bar */}
<div style={{
position: 'sticky', top: 0, zIndex: 10,
background: colors.surface,
borderBottom: `1px solid ${colors.accent}`,
padding: '10px 24px',
display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap',
}}>
<button
onClick={() => navigate('/')}
style={{
background: 'none', border: 'none', color: colors.muted,
cursor: 'pointer', fontSize: 13, padding: 0,
}}
>
Dashboard
</button>
<div style={{ width: 1, height: 18, background: colors.accent }} />
<h1 style={{ margin: 0, fontSize: 16, fontWeight: 800, color: colors.text, letterSpacing: '-0.3px' }}>
Sprite Coverage
</h1>
{deficitCount > 0 && (
<span style={{
fontSize: 11, fontWeight: 700, color: '#f87171',
background: '#f8717118', border: '1px solid #f8717144',
borderRadius: 8, padding: '2px 8px',
}}>
{deficitCount} need work
</span>
)}
<div style={{ flex: 1 }} />
<input
type="text"
placeholder="Search sprites…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
background: colors.bg,
border: `1px solid ${colors.accent}`,
borderRadius: 6,
color: colors.text,
fontSize: 12,
padding: '5px 10px',
outline: 'none',
width: 180,
}}
/>
<span style={{ color: colors.muted, fontSize: 11 }}>
{filtered.length} sprites
</span>
</div>
{/* Body */}
<div style={{ padding: '20px 28px', maxWidth: 1400, margin: '0 auto' }}>
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 16px', background: '#4a1a1a',
border: '1px solid #f8717144', borderRadius: 8, marginBottom: 20,
}}>
<span style={{ color: '#f87171', fontSize: 13, flex: 1 }}>{error}</span>
<button
onClick={load}
style={{
background: '#f8717120', border: '1px solid #f8717144', borderRadius: 6,
color: '#f87171', fontSize: 12, fontWeight: 600, padding: '4px 12px', cursor: 'pointer',
}}
>
Retry
</button>
</div>
)}
{loading ? (
<div style={{ color: colors.muted, fontSize: 13, padding: '40px 0' }}>Loading coverage</div>
) : (
<div style={{ ...card, padding: 0, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr>
<th style={{ ...thS, width: 36 }} />
<th style={thS}>Sprite</th>
<th style={{ ...thS, textAlign: 'center' }}>Var</th>
<th style={{ ...thS, textAlign: 'center' }}>Proc</th>
{SCORER_ORDER.map((s) => (
<th key={s} style={{ ...thS, textAlign: 'center', color: SCORER_COLORS[s] }}>
{s[0].toUpperCase()}
</th>
))}
<th style={{ ...thS, textAlign: 'center' }}></th>
<th style={{ ...thS, textAlign: 'center' }}>Δ</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<CoverageTableRow
key={row.sprite_id}
row={row}
expanded={expanded === row.sprite_id}
variants={variantCache.get(row.sprite_id) ?? []}
variantsLoading={variantsLoading && expanded === row.sprite_id && !variantCache.has(row.sprite_id)}
onToggle={() => handleToggle(row.sprite_id)}
onNavigate={() => navigate(`/sprite/${row.sprite_id}`)}
/>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -1,3 +1,20 @@
export const SCORER_ORDER = ['qwen3', 'haiku', 'sonnet', 'opus'] as const
export type ScorerName = typeof SCORER_ORDER[number]
export const SCORER_COLORS: Record<ScorerName, string> = {
qwen3: '#8b5cf6',
haiku: '#06b6d4',
sonnet: '#3b82f6',
opus: '#f59e0b',
}
export const SCORER_TOOLTIPS: Record<ScorerName, string> = {
qwen3: 'QWEN3 — fast visual scorer',
haiku: 'Haiku — Claude aesthetic scorer',
sonnet: 'Sonnet — Claude mid-tier scorer',
opus: 'Opus — premium quality scorer',
}
export const colors = {
bg: '#1a1a2e',
surface: '#16213e',

View file

@ -109,13 +109,6 @@ def create_app(
limit=limit, offset=offset,
)
@app.get("/api/sprites/{sprite_id:path}")
def get_sprite(sprite_id: str) -> dict:
sprite = registry.get_sprite(sprite_id)
if not sprite:
raise HTTPException(404, f"Sprite not found: {sprite_id}")
return sprite
@app.get("/api/sprites/{sprite_id:path}/variants")
def get_variants(
sprite_id: str,
@ -126,6 +119,13 @@ def create_app(
raise HTTPException(404, f"Sprite not found: {sprite_id}")
return registry.get_variants(sprite_id, dimension_id=dimension_id)
@app.get("/api/sprites/{sprite_id:path}")
def get_sprite(sprite_id: str) -> dict:
sprite = registry.get_sprite(sprite_id)
if not sprite:
raise HTTPException(404, f"Sprite not found: {sprite_id}")
return sprite
@app.post("/api/sprites/{sprite_id:path}/approve")
def approve_sprite(sprite_id: str, body: ApproveRequest) -> dict:
sprite = registry.get_sprite(sprite_id)