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:
parent
97e9235a39
commit
c82e894c9f
6 changed files with 676 additions and 251 deletions
BIN
.playwright-mcp/page-2026-03-30T05-33-28-861Z.png
Normal file
BIN
.playwright-mcp/page-2026-03-30T05-33-28-861Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
601
tools/sprite-generation/gui/src/pages/SpriteCoveragePage.tsx
Normal file
601
tools/sprite-generation/gui/src/pages/SpriteCoveragePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue