ui(sprite-generation): 💄 Enhance sprite generation GUI with new options and improved preview/interaction in SpritePage and TheaterCard components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 23:23:59 -07:00
parent d9177021fe
commit 1793eabc63
2 changed files with 109 additions and 31 deletions

View file

@ -9,11 +9,34 @@ import {
regenerateSprite,
updatePrompt,
} from '../api'
import type { Sprite, Variant } from '../types'
import type { RecentVariant, Sprite, Variant } from '../types'
import { colors, statusColors } from './theme'
import { DimensionSection, ImageModal, VariantCard, variantSrc } from './SpriteComponents'
import { DimensionSection, ImageModal, variantSrc } from './SpriteComponents'
import { Card } from './TheaterCard'
import { Tooltip } from '../components/Tooltip'
function variantToRecent(v: Variant, sprite: Sprite): RecentVariant {
return {
variant_id: v.id,
sprite_id: v.sprite_id,
category: sprite.category,
entity_id: sprite.entity_id,
raw_path: v.raw_path ?? '',
processed_path: v.processed_path,
seed: v.seed,
created_at: v.created_at,
rating: v.rating,
notes: v.notes,
is_approved: v.is_approved,
scored_by: null,
review_tier: null,
quality_json: null,
gates_json: null,
reject_reason: v.reject_reason,
quality_scorer: null,
}
}
export function SpritePage(): ReactNode {
const params = useParams()
const navigate = useNavigate()
@ -342,29 +365,28 @@ export function SpritePage(): ReactNode {
<div style={{ marginBottom: 32 }}>
<h2 style={{ color: colors.text, fontSize: 18, marginBottom: 4 }}>Variants</h2>
<p style={{ color: colors.muted, fontSize: 12, margin: '0 0 14px 0' }}>
Keys 1-{Math.min(8, variants.length)} to select, Enter to approve selected, Esc to deselect
Keys 1-{Math.min(8, variants.length)} to select · Enter to approve selected · Esc to deselect
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
{variants.map((v, i) => (
<VariantCard
<div
key={v.id}
variant={v}
index={i}
isSelected={selectedIndex === i}
onSelect={(): void => {
setSelectedIndex(i)
const src = variantSrc(v)
if (src) {
setModalSrc(src)
}
}}
onApprove={(): void => {
void handleApprove(v.id)
}}
onReject={(): void => {
void handleRejectVariant(v.id)
}}
/>
style={{ width: 220, flexShrink: 0, outline: selectedIndex === i ? `2px solid ${colors.highlight}` : 'none', borderRadius: 8 }}
>
<Card
v={variantToRecent(v, sprite)}
isNew={false}
index={i}
alwaysShowActions
onSelect={(): void => {
setSelectedIndex(i)
const src = variantSrc(v)
if (src) setModalSrc(src)
}}
onApprove={(): void => { load() }}
onReject={(): void => { void handleRejectVariant(v.id) }}
/>
</div>
))}
</div>
</div>

View file

@ -232,9 +232,11 @@ export interface CardProps {
onApprove: (variantId: number) => void
onReject: (variantId: number) => void
onSelect?: (v: RecentVariant) => void
/** Show Approve/Reject buttons even when no scoring notes exist */
alwaysShowActions?: boolean
}
export const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew, index, onApprove, onReject, onSelect }, ref) {
export const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew, index, onApprove, onReject, onSelect, alwaysShowActions }, ref) {
const conf = useMemo(() => confidence(v), [v.notes])
const scores = useMemo(() => parseScores(v.notes), [v.notes])
const gateOk = useMemo(() => gatePassed(v), [v.notes])
@ -360,15 +362,48 @@ export const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v
{/* ── Art frame ───────────────────────────────────────────── */}
<div
onClick={() => onSelect?.(v)}
style={{ display: 'block', cursor: 'pointer' }}
style={{ display: 'block', cursor: 'pointer', position: 'relative' }}
title={`#${v.variant_id}${v.sprite_id} (click for details)`}
>
<img
src={imgUrl ?? ''}
alt={v.entity_id}
style={{ width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover' }}
loading="lazy"
/>
{imgUrl ? (
<img
src={imgUrl}
alt={v.entity_id}
style={{
width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover',
opacity: v.rating === -1 ? 0.35 : 1,
filter: v.rating === -1 ? 'saturate(0)' : 'none',
}}
loading="lazy"
/>
) : (
<div style={{
width: '100%', aspectRatio: '1/1',
background: '#0d0f1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 4,
}}>
<span style={{ fontSize: 22, opacity: 0.15 }}></span>
<span style={{ fontSize: 9, color: '#2a3044', letterSpacing: '0.5px' }}>no image</span>
</div>
)}
{v.rating === -1 && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none',
}}>
<span style={{
fontSize: 13, fontWeight: 800, color: '#ef4444',
background: 'rgba(0,0,0,0.7)',
border: '1px solid #ef444466',
borderRadius: 4, padding: '3px 10px',
letterSpacing: '1px', textTransform: 'uppercase',
}}>
Rejected
</span>
</div>
)}
</div>
{/* ── Text box ────────────────────────────────────────────── */}
@ -458,7 +493,28 @@ export const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v
}}>
APPROVED
</div>
) : scores && (
) : v.rating === -1 ? (
<div style={{ display: 'flex', gap: 6, padding: '0 10px 8px' }}>
<Tooltip text="Approve this variant despite prior rejection">
<button
onClick={(e) => {
e.stopPropagation()
approveVariant(v.sprite_id, v.variant_id).then(
() => { onApprove(v.variant_id) },
() => {},
)
}}
style={{
flex: 1, padding: '5px 0', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: '#10b98133', color: '#10b981', border: '1px solid #10b98155',
cursor: 'pointer',
}}
>
Approve anyway
</button>
</Tooltip>
</div>
) : (scores || alwaysShowActions) && imgUrl ? (
<div style={{ display: 'flex', gap: 6, padding: '0 10px 8px' }}>
<Tooltip text="Approve this variant as the final game sprite">
<button
@ -494,7 +550,7 @@ export const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v
</button>
</Tooltip>
</div>
)}
) : null}
</div>
)
}))