diff --git a/.playwright-mcp/page-2026-03-30T05-33-28-861Z.png b/.playwright-mcp/page-2026-03-30T05-33-28-861Z.png new file mode 100644 index 00000000..7e09f858 Binary files /dev/null and b/.playwright-mcp/page-2026-03-30T05-33-28-861Z.png differ diff --git a/tools/sprite-generation/gui/src/App.tsx b/tools/sprite-generation/gui/src/App.tsx index 579dfddd..ea206d3c 100644 --- a/tools/sprite-generation/gui/src/App.tsx +++ b/tools/sprite-generation/gui/src/App.tsx @@ -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 { } /> } /> } /> + } /> } /> } /> diff --git a/tools/sprite-generation/gui/src/pages/DashboardPage.tsx b/tools/sprite-generation/gui/src/pages/DashboardPage.tsx index bdcc0a9f..98b21c67 100644 --- a/tools/sprite-generation/gui/src/pages/DashboardPage.tsx +++ b/tools/sprite-generation/gui/src/pages/DashboardPage.tsx @@ -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 = { - qwen3: '#8b5cf6', - haiku: '#06b6d4', - sonnet: '#3b82f6', - opus: '#f59e0b', -} - -const SCORER_ORDER = ['qwen3', 'haiku', 'sonnet', 'opus'] as const - -const SCORER_TOOLTIPS: Record = { - 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({ No recent scoring events. )} {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 ( - - ) - } - const allPassed = passed === scored - return ( - - ) -} - -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 ( - onRowClick(row.sprite_id)} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - > - - {row.entity_id} -
- - {row.sprite_id} - - - {row.total_variants} - - - - {SCORER_ORDER.map((s) => { - const tier = row.tier_counts[s] - return ( - - {tier ? ( -
- - - - / - - -
- ) : ( - - )} - - ) - })} - - = 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted, - fontWeight: 700, - }} - > - = 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted} - /> - - - - {hasDeficit ? ( - - - - - ) : ( - - )} - - - ) -} - -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(null) - return ( -
-
- Sprite Coverage - {deficitCount > 0 && ( - - need work - - )} -
-
- - - - - - - {SCORER_ORDER.map((s) => ( - - ))} - - - - - - {coverage.map((row) => ( - setHovered(row.sprite_id)} - onMouseLeave={() => setHovered(null)} - /> - ))} - -
SpriteVarProc - {s[0].toUpperCase()} - Δ
-
-
- ) -} // ── Scorer Toggle Button ────────────────────────────────────────────────────── @@ -1099,11 +864,50 @@ export function DashboardPage(): ReactNode { onRowClick={(id) => navigate('/sprite/' + id)} /> - {/* Coverage — full width at bottom */} - navigate('/sprite/' + id)} - /> + {/* Coverage summary — links to full coverage page */} + {(() => { + const deficitCount = pipeline.sprite_coverage.filter(r => r.deficit > 0).length + return ( + +
{ (e.currentTarget as HTMLDivElement).style.borderColor = colors.highlight }} + onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.borderColor = colors.accent }} + > +
+ Sprite Coverage + + {pipeline.sprite_coverage.length} sprites tracked + +
+
+ {deficitCount > 0 && ( + + {deficitCount} need work + + )} + + Coverage → + +
+
+ + ) + })()} )} diff --git a/tools/sprite-generation/gui/src/pages/SpriteCoveragePage.tsx b/tools/sprite-generation/gui/src/pages/SpriteCoveragePage.tsx new file mode 100644 index 00000000..f8c81536 --- /dev/null +++ b/tools/sprite-generation/gui/src/pages/SpriteCoveragePage.tsx @@ -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 ( + + ) + } + const allPassed = passed === scored + return ( + + ) +} + +// ── +3 generate button ─────────────────────────────────────────────────────── + +function GenerateButton({ spriteId }: { spriteId: string }): ReactNode { + const [state, setState] = useState('idle') + + const handleClick = async (e: React.MouseEvent): Promise => { + 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 ( + + + + ) +} + +// ── 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 ( + + +
+ {/* Thumbnail strip */} +
+ {loading ? ( + Loading variants… + ) : top.length === 0 ? ( + No variants yet + ) : ( +
+ {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 ( +
+
+ {src ? ( + {`variant + ) : ( + no img + )} +
+ {v.rating !== null && ( + + {v.rating.toFixed(1)} + + )} + {v.is_approved && ( + + )} +
+ ) + })} +
+ )} +
+ + {/* Tier breakdown mini bars */} +
+ {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 ( +
+ + {s[0].toUpperCase() + s.slice(1, 3)} + +
+
+
+ + {tier.passed}/{tier.scored} + +
+ ) + })} +
+ + {/* Actions */} +
+ + + {variants.length} total variants + +
+
+ + + ) +} + +// ── 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 ( + <> + + {/* +3 button cell */} + e.stopPropagation()}> + + + + {/* Sprite name */} + +
+ +
+ {row.entity_id} +
+ + {row.sprite_id} + +
+
+ + + {row.total_variants} + + + + + {SCORER_ORDER.map((s) => { + const tier = row.tier_counts[s] + return ( + + {tier ? ( +
+ + + + / + + +
+ ) : ( + + )} + + ) + })} + + + = 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted, + fontWeight: 700, + }}> + = 3 ? '#4ade80' : row.all_passed > 0 ? '#fbbf24' : colors.muted} + /> + + + + + {hasDeficit ? ( + + - + + ) : ( + + )} + + + + {expanded && ( + + )} + + ) +} + +// ── Main page ──────────────────────────────────────────────────────────────── + +export function SpriteCoveragePage(): ReactNode { + const navigate = useNavigate() + const [coverage, setCoverage] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState(null) + const [variantCache, setVariantCache] = useState>(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 ( +
+ {/* Header bar */} +
+ + +
+ +

+ Sprite Coverage +

+ + {deficitCount > 0 && ( + + {deficitCount} need work + + )} + +
+ + 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, + }} + /> + + + {filtered.length} sprites + +
+ + {/* Body */} +
+ {error && ( +
+ {error} + +
+ )} + + {loading ? ( +
Loading coverage…
+ ) : ( +
+
+ + + + + + + {SCORER_ORDER.map((s) => ( + + ))} + + + + + + {filtered.map((row) => ( + handleToggle(row.sprite_id)} + onNavigate={() => navigate(`/sprite/${row.sprite_id}`)} + /> + ))} + +
+ SpriteVarProc + {s[0].toUpperCase()} + Δ
+
+
+ )} +
+
+ ) +} diff --git a/tools/sprite-generation/gui/src/pages/theme.ts b/tools/sprite-generation/gui/src/pages/theme.ts index 08a5342a..f77f13c9 100644 --- a/tools/sprite-generation/gui/src/pages/theme.ts +++ b/tools/sprite-generation/gui/src/pages/theme.ts @@ -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 = { + qwen3: '#8b5cf6', + haiku: '#06b6d4', + sonnet: '#3b82f6', + opus: '#f59e0b', +} + +export const SCORER_TOOLTIPS: Record = { + 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', diff --git a/tools/sprite-generation/server.py b/tools/sprite-generation/server.py index bb70aac2..86344e26 100644 --- a/tools/sprite-generation/server.py +++ b/tools/sprite-generation/server.py @@ -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)