feat(@projects/@magic-civilization): mark sprite pipeline as complete

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-10 04:09:08 -07:00
parent e20a576d90
commit 3ebe54f387
11 changed files with 247 additions and 717 deletions

1
.gitignore vendored
View file

@ -35,6 +35,7 @@ Thumbs.db
# Auto-added by auto-commit-service
node_modules/
.venv/
# Auto-added by auto-commit-service
*.log

View file

@ -402,7 +402,7 @@
| [p2-19](p2-19-guide-progress-report-page.md) | ✅ done | P2 | Guide progress report page — dynamic dashboard + missing assets | — | 🟢 |
| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | ✅ done | P2 | Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink | [tourguide](../team-leads/tourguide.md) | 🟢 |
| [p2-21](p2-21-guide-simcache-static-bake.md) | ✅ done | P2 | Bake pre-computed sim-cache frames into the static build | [tourguide](../team-leads/tourguide.md) | 🟢 |
| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | P1 | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
| [p2-22](p2-22-sprite-generation-pipeline.md) | ✅ done | P1 | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | 🟡 partial | P1 | Unit sprites — Dwarf-racial roster (m/f variants) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | 🟡 partial | P1 | Unit sprites — wild creatures & fauna (generic, no race/sex) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |
| [p2-25](p2-25-building-sprites-base-coverage.md) | 🟡 partial | P1 | Building sprites — base game coverage (non-wonder) | [asset-sprite](../team-leads/asset-sprite.md) | 🟢 |

View file

@ -130,6 +130,7 @@
| [p1-60](p1-60-fog-of-war-testing-ai-fairness.md) | Fog-of-war end-to-end test coverage + AI fairness fix | — | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-18 |
| [p1-61](p1-61-ecology-content-gap-fill.md) | Ecology content gap fill: sparse biomes + lineage tier holes (P1 actions from ecology-audit-gaps.md) | — | [terraformer](../team-leads/terraformer.md) | 2026-06-06 |
| [p2-06](p2-06-export-pipeline.md) | Export pipeline for Windows / macOS / Linux | — | [shipwright](../team-leads/shipwright.md) | 2026-04-25 |
| [p2-22](p2-22-sprite-generation-pipeline.md) | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-10 |
| [p2-28](p2-28-sprite-provenance-ledger.md) | Sprite provenance ledger — LICENSES.md per-file attribution | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-25 |
| [p2-33](p2-33-sound-system-extension.md) | Sound system extension — categorical fallback, variant pools, per-entity routing | — | [asset-audio](../team-leads/asset-audio.md) | 2026-04-27 |
| [p2-80](p2-80-mc-worldsim-integration.md) | mc-worldsim orchestration crate — drive the existing worldsim engines in the playable turn | — | — | 2026-06-09 |

View file

@ -15,10 +15,10 @@
| Priority | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | ✅ | Total |
|---|---|---|---|---|---|---|---|
| **P0** | 0 | 0 | 0 | 0 | 0 | 44 | 44 |
| **P1** | 0 | 14 | 1 | 0 | 1 | 78 | 94 |
| **P1** | 0 | 13 | 1 | 0 | 1 | 79 | 94 |
| **P2** | 0 | 15 | 6 | 5 | 1 | 104 | 131 |
| **P3 (oos)** | 0 | 2 | 0 | 0 | 29 | 22 | 53 |
| **total** | **0** | **31** | **7** | **5** | **31** | **248** | **322** |
| **total** | **0** | **30** | **7** | **5** | **31** | **249** | **322** |
</td><td valign='top' style='padding-left:2em'>
@ -28,7 +28,7 @@
|---|---|
| [simulator-infra](../team-leads/simulator-infra.md) | 9 |
| [warcouncil](../team-leads/warcouncil.md) | 7 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [asset-sprite](../team-leads/asset-sprite.md) | 5 |
| [shipwright](../team-leads/shipwright.md) | 4 |
| [unassigned](../team-leads/unassigned.md) | 3 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
@ -48,7 +48,6 @@
| [p1-29i-refound-suppression](p1-29i-refound-suppression.md) | 🟡 partial | Refound-suppression / capture-stickiness lever — convert captures into eliminations | — | [warcouncil](../team-leads/warcouncil.md) | 2026-06-04 | 🟢 unblocked |
| [p1-29k](p1-29k.md) | 🟡 partial | Drive learned:* controllers on the autoplay (auto_play.gd) gate surface | ai, rl, controller, infra, bridge | [simulator-infra](../team-leads/simulator-infra.md) | 2026-06-08 | 🟢 unblocked |
| [p2-16](p2-16-audio-assets.md) | 🟡 partial | Audio assets — in-theme OSS launch pack + source ledger | — | [asset-audio](../team-leads/asset-audio.md) | 2026-06-08 | 🟢 unblocked |
| [p2-22](p2-22-sprite-generation-pipeline.md) | 🟡 partial | Sprite generation pipeline — runnable end-to-end | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-03 | 🟢 unblocked |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | 🟡 partial | Unit sprites — Dwarf-racial roster (m/f variants) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | 🟡 partial | Unit sprites — wild creatures & fauna (generic, no race/sex) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |
| [p2-25](p2-25-building-sprites-base-coverage.md) | 🟡 partial | Building sprites — base game coverage (non-wonder) | — | [asset-sprite](../team-leads/asset-sprite.md) | 2026-06-04 | 🟢 unblocked |

View file

@ -1,9 +1,9 @@
{
"generated_at": "2026-06-10T10:58:12Z",
"generated_at": "2026-06-10T11:07:40Z",
"totals": {
"done": 248,
"done": 249,
"in_progress": 0,
"partial": 31,
"partial": 30,
"stub": 7,
"missing": 5,
"oos": 31,
@ -1419,10 +1419,10 @@
"id": "p2-22",
"title": "Sprite generation pipeline — runnable end-to-end",
"priority": "p1",
"status": "partial",
"status": "done",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-06-03",
"updated_at": "2026-06-10",
"blocked_by": [],
"summary": "Gate-one objective for every other `asset-sprite` child (`p2-23` … `p2-27`). Before any sprite can legitimately land in `public/games/age-of-dwarves/assets/sprites/`, the `tools/sprite-generation/` pipeline has to run cleanly end-to-end: scan game data → generate variants via the configured model → auto-rank via Sonnet vision → surface in the Theater GUI for human approval → chroma-key + resize + install with LICENSES.md row written.\n\nSlate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-bar failure; the prompt library and ranker had drifted). This objective closes out the \"pipeline works\" half of the split; actual sprite shipping lives in the downstream children."
},
@ -3893,7 +3893,7 @@
},
{
"owner": "asset-sprite",
"remaining": 6
"remaining": 5
},
{
"owner": "shipwright",

View file

@ -2,10 +2,10 @@
id: p2-22
title: Sprite generation pipeline — runnable end-to-end
priority: p1
status: partial
status: done
scope: game1
owner: asset-sprite
updated_at: 2026-06-03
updated_at: 2026-06-10
evidence:
- tools/sprite-generation/cli.py
- tools/sprite-generation/sprite-config.json
@ -28,7 +28,7 @@ Slate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-ba
- ✓ `engine/prompts/` YAML library present (combat_types, composition, genders, keywords, negatives, quality_tiers, races, scoring_pipeline). `prompts.py` reads them at compose time — no hardcoded strings in the python.
- ✓ `sprite-config.json` model is `juggernaut-xi-v11`. RESOLVED (2026-06-03, operator decision — "add xi-v11 to charter"): XI-v11 added to the approved set in lockstep across `dot-claude/instructions/dataloader-sprites.md:24`, `safety-rules-local.md:11`, and `team-leads/asset-sprite.md:47`. License basis = same RunDiffusion family + OpenRAIL++-M commercial-use as the already-approved `juggernaut-xl-v9`; the charter's ship-time license re-verification rule still applies. The `model-commercial:juggernaut-xi-v11` ledger row written by `installer._append_ledger_row` is now charter-sanctioned. (Prior ESCALATION superseded: XI-v11 had not been on any approved list.)
- ✓ Ranker threshold documentation in `docs/PIPELINE.md`. 2026-06-03 — documented real values read from `engine/ranker.py` + `engine/prompts/scoring_pipeline.yaml`: 4-stage tiered pipeline (qwen3-VL 0.40 → haiku 0.50 → sonnet 0.58 → opus 0.65, opus single-pass), `target_approved=3`, base `CONFIDENCE_THRESHOLD=0.70`, `QUALITY_DIM_FLOOR=45`, `CATEGORY_THRESHOLDS` resources/improvements/ui=0.55, tiebreaker ranges, 15 unit gates + 5 confidence quality dims + 2 display-only dims, per-stage tiebreaker. No invented numbers.
- ◐ Theater GUI (`server.py` + `gui/`) boot smoke at `http://localhost:5850`. 2026-06-03 — **server side passes, front-end blocked.** `create_app()` builds 35 routes; `uvicorn server:app --port 5850` served `GET /api/stats` 200, `/api/theater` 200, `/api/progress` 200; stopped cleanly by worker PID (never by port). BUT root (`GET /`, the SPA index) returns 404 because `gui/dist` is not built, and **the build cannot complete in this environment**: `pnpm build` fails (`TS2307: Cannot find module 'react'`, ~20 JSX/TS errors) because `pnpm install` does not materialize `gui/node_modules` — the `@lilith/ui-animated` workspace dep hits the known pnpm `workspace:*` resolution bug (MEMORY `project_pnpmfile_workspace_fix`; needs `.pnpmfile.cjs` + Verdaccio). So the `/?spriteTheater=true` Theater UI cannot render until the GUI build is fixed. The Python server boot smoke is genuinely green; the React Theater render is unproven. ESCALATION: GUI build/install needs a separate fix (out of this session's fence focus).
- ✓ Theater GUI (`server.py` + `gui/`) boot smoke at `http://localhost:5850`**DONE (2026-06-10), front-end unblocked and visually verified.** Root cause of the 2026-06-03 block was NOT the `workspace:*` bug: `gui/` is a standalone package (registry semver `@lilith/ui-animated@^1.1.10`) that is *not* a member of the repo's `pnpm-workspace.yaml`, so a bare `pnpm install` walked up, bound to the workspace root, and installed nothing into `gui/node_modules`. Fix: `pnpm install --ignore-workspace` in `gui/` (resolves react + `@lilith/ui-animated` from Verdaccio/forge per `~/.npmrc`). Second blocker: 3 orphaned dead files (`SheetsPage.tsx`, `SheetDetailModal.tsx`, `SheetCard.tsx`) written against sheet API exports that exist in neither `src/api.ts` nor `server.py` (zero routes, zero imports anywhere) — deleted per zero-tech-debt. `pnpm build` then green (tsc + vite, 76 modules, `dist/` emitted). Server env: `tools/sprite-generation/.venv` (uv, from `requirements.txt`; `.venv/` added to `.gitignore`). Boot smoke: `GET /` 200 serving the built SPA, `/api/stats` 200, `/api/theater` 200. **Visual proof reviewed in-conversation** (Claude Preview, `.claude/launch.json` `sprite-theater` entry): full Pipeline dashboard renders live DB data — variant funnel 2,026 completed → 2,001 processed → 423 qwen3 → 193 haiku → 1 approved, per-tier scoring health, top-failing-gates table — zero console errors/warnings. **Bonus: the bullet-1 caveat is now disproven at runtime** — the dashboard shows the Claude escalation tiers HAVE since been exercised (haiku 333 scored / sonnet 12 / opus 17), so the tier-1+ scoring path is proven live, not just importable.
- ✓ `docs/PIPELINE.md` post-reset refresh. 2026-06-03 — full rewrite to current code: rembg (U2Net) background removal replacing the pre-reset chroma-key narrative, 9-layer SDXL YAML prompt library inventory, infra-dependency table, category resolution table (units 256², terrain/biome 384×332, buildings/spells 128², resources/improvements/ui 64²), `MAX_REGEN_ATTEMPTS=15`, adaptive guidance, 70/30 seed split, and the XI-v11 approval caveat.
## Missing-sprite fault closure — 2026-06-08
@ -102,4 +102,4 @@ write male/female/generic), to make the playable DEMO read like a game. This is
manual placeholder swap, **not** the generation pipeline this objective tracks, and
does not advance it.
**Ledger:** `public/games/age-of-dwarves/assets/sprites/DEMO_SPRITES_LICENSES.md` (per-id rows + source sha256). **DEMO-ONLY:** all demo art is **Battle for Wesnoth, dual GPL-2.0+ OR CC-BY-SA 4.0 (older sprites likely GPL-only) — copyleft either way, NOT commercial-ship-compatible**. Does NOT advance this objective toward `done`; the commercial-safe game-icons stand-ins remain regenerable via `tools/standin-sprites/build_standins.py`. Status stays **partial**; bespoke/CC0/commercial art still required before ship.
**Ledger:** `public/games/age-of-dwarves/assets/sprites/DEMO_SPRITES_LICENSES.md` (per-id rows + source sha256). **DEMO-ONLY:** all demo art is **Battle for Wesnoth, dual GPL-2.0+ OR CC-BY-SA 4.0 (older sprites likely GPL-only) — copyleft either way, NOT commercial-ship-compatible**. Does NOT advance this objective toward `done`; the commercial-safe game-icons stand-ins remain regenerable via `tools/standin-sprites/build_standins.py`. (2026-06-10: objective closed on its own acceptance — the pipeline runs end-to-end incl. the Theater GUI. Bespoke/CC0/commercial ship-art is the scope of `p2-23``p2-27`, not this objective.)

View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "sprite-theater",
"runtimeExecutable": "tools/sprite-generation/.venv/bin/python",
"runtimeArgs": ["-m", "uvicorn", "server:app", "--port", "5850", "--app-dir", "tools/sprite-generation"],
"port": 5850
}
]
}

View file

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@lilith/ui-animated':
specifier: ^1.1.10
version: 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
react:
specifier: ^18.3.1
version: 18.3.1
@ -17,6 +20,9 @@ importers:
react-router-dom:
specifier: ^6.26.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
styled-components:
specifier: ^6.3.11
version: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.3
@ -119,6 +125,15 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@emotion/is-prop-valid@1.4.0':
resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==}
'@emotion/memoize@0.9.0':
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
'@emotion/unitless@0.10.0':
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@ -273,6 +288,51 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lilith/chart-math@1.0.1':
resolution: {integrity: sha512-bxw27DfDmxPP4euqCrOkNiLKrsVIs1uzNxlXIyOBzTkCVo+CG2FZvu35HccanzeapTPFOmoazzpfhuEnREScYA==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fchart-math/-/1.0.1/chart-math-1.0.1.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
styled-components: ^6.0.0
'@lilith/format@1.0.0':
resolution: {integrity: sha512-IQSZARV8wmyTFjzRYnrNDg0wxYLA7wykxLuvgO4smRN8pyjKPubnNwEzxH70CG/TCz/20yvMUO6vOmx3e1geug==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fformat/-/1.0.0/format-1.0.0.tgz}
'@lilith/ui-animated@1.1.11':
resolution: {integrity: sha512-4JZf5Gi+cyFxjFFmLFLf9qsDibLiLMFkPwKX1ML3MnPrNcAtxoH+BTfMGkhx1x3/C/O+zmjq7ydP93hcqpYNgA==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-animated/-/1.1.11/ui-animated-1.1.11.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
styled-components: ^6.0.0
'@lilith/ui-design-tokens@1.2.1':
resolution: {integrity: sha512-AWMV1SDBBarbMqFMNZmD1PmxckWmXFoZ7kq+ntnS8V9CLNIJteLIzBhszpR4xfBuLrZVVwNoG6yr0j/dQuewvw==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-design-tokens/-/1.2.1/ui-design-tokens-1.2.1.tgz}
'@lilith/ui-styled-components@6.3.9':
resolution: {integrity: sha512-Qh5U2el6aoAs+XuybFviLBm5AlUQmzkNr911nTr2uvLAGIOCnnEy2iedKYMz3OCHnocnIme6cOeakhAByCfMjg==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-styled-components/-/6.3.9/ui-styled-components-6.3.9.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@lilith/ui-theme@1.5.2':
resolution: {integrity: sha512-gbU56dGlifJpPi1fXOBx5vcofIueHoE47y5igc+MsEpLteeIVaRPvdy0ZP90/Rzxfnf4sGeiFJuAR7sVzM/HJw==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-theme/-/1.5.2/ui-theme-1.5.2.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
styled-components: ^6.0.0
'@lilith/ui-utils@2.0.0':
resolution: {integrity: sha512-ArpGGsyEAdxh8Z6pAiC1Km711qCk+g0g/I8nIladMshTYaAoZJ+Y+gyItPaa7nrTLv8s/1hGVP6PWDb/4mGMBg==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-utils/-/2.0.0/ui-utils-2.0.0.tgz}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
styled-components: ^6.0.0
'@lilith/ui-zname@1.2.5':
resolution: {integrity: sha512-y1HrD7un1z5RIdf0k6KIoBmTW1FFloZJOJGeaA03Z3/49n+LkHPtzLIcaM8TArZ8Tkdt1PYe+u74YQOoKHIZtQ==, tarball: http://forge.black.lan/api/packages/lilith/npm/%40lilith%2Fui-zname/-/1.2.5/ui-zname-1.2.5.tgz}
peerDependencies:
react: '>=16.8.0'
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
@ -314,79 +374,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@ -444,6 +491,9 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
'@types/stylis@4.2.7':
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -460,12 +510,22 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
camelize@1.0.1:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -533,6 +593,13 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@ -575,10 +642,45 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
styled-components@6.3.8:
resolution: {integrity: sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==}
engines: {node: '>= 16'}
peerDependencies:
react: '>= 16.8.0'
react-dom: '>= 16.8.0'
peerDependenciesMeta:
react-dom:
optional: true
styled-components@6.4.2:
resolution: {integrity: sha512-xZBhBJsMtGqb+aKcwKgaT+BtuFums9VynX2JRvXJGTx5UfZzN12rk5r4nVdhXYvRw+hE7yiYxVrOqJZaK2+Txg==}
engines: {node: '>= 16'}
peerDependencies:
css-to-react-native: '>= 3.2.0'
react: '>= 16.8.0'
react-dom: '>= 16.8.0'
react-native: '>= 0.68.0'
peerDependenciesMeta:
css-to-react-native:
optional: true
react-dom:
optional: true
react-native:
optional: true
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -738,6 +840,14 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@emotion/is-prop-valid@1.4.0':
dependencies:
'@emotion/memoize': 0.9.0
'@emotion/memoize@0.9.0': {}
'@emotion/unitless@0.10.0': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
@ -826,6 +936,52 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lilith/chart-math@1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/format@1.0.0': {}
'@lilith/ui-animated@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
'@lilith/ui-styled-components': 6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/ui-theme': 1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@lilith/ui-utils': 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@lilith/ui-zname': 1.2.5(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/ui-design-tokens@1.2.1': {}
'@lilith/ui-styled-components@6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.3.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/ui-theme@1.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
'@lilith/ui-design-tokens': 1.2.1
'@lilith/ui-styled-components': 6.3.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/ui-utils@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))':
dependencies:
'@lilith/chart-math': 1.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@lilith/format': 1.0.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@lilith/ui-zname@1.2.5(react@18.3.1)':
dependencies:
react: 18.3.1
'@remix-run/router@1.23.2': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
@ -939,6 +1095,8 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/stylis@4.2.7': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
dependencies:
'@babel/core': 7.29.0
@ -961,10 +1119,20 @@ snapshots:
node-releases: 2.0.36
update-browserslist-db: 1.2.3(browserslist@4.28.1)
camelize@1.0.1: {}
caniuse-lite@1.0.30001780: {}
convert-source-map@2.0.0: {}
css-color-keywords@1.0.0: {}
css-to-react-native@3.2.0:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
csstype@3.2.3: {}
debug@4.4.3:
@ -1028,6 +1196,14 @@ snapshots:
picocolors@1.1.1: {}
postcss-value-parser@4.2.0: {}
postcss@8.4.49:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@ -1095,8 +1271,39 @@ snapshots:
semver@6.3.1: {}
shallowequal@1.1.0: {}
source-map-js@1.2.1: {}
styled-components@6.3.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@emotion/is-prop-valid': 1.4.0
'@emotion/unitless': 0.10.0
'@types/stylis': 4.2.7
css-to-react-native: 3.2.0
csstype: 3.2.3
postcss: 8.4.49
react: 18.3.1
shallowequal: 1.1.0
stylis: 4.3.6
tslib: 2.8.1
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@emotion/is-prop-valid': 1.4.0
csstype: 3.2.3
react: 18.3.1
stylis: 4.3.6
optionalDependencies:
css-to-react-native: 3.2.0
react-dom: 18.3.1(react@18.3.1)
stylis@4.3.6: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):

View file

@ -1,167 +0,0 @@
import { useState, memo } from 'react'
import type { ReactElement } from 'react'
import type { SpriteSheet } from '../types'
import { sheetImageUrl } from '../api'
import { colors } from './theme'
import { timeAgo, parseEntityParts } from './sheetUtils'
// ---------------------------------------------------------------------------
// SheetSkeletonCard — shown while job_status === 'submitted' (generating)
// ---------------------------------------------------------------------------
export function SheetSkeletonCard(): ReactElement {
return (
<div
style={{
width: 300,
background: colors.surface,
border: `1px solid ${colors.accent}`,
borderRadius: 10,
overflow: 'hidden',
}}
>
<style>{`
@keyframes shimmer {
0% { background-position: -600px 0; }
100% { background-position: 600px 0; }
}
.skeleton-shimmer {
background: linear-gradient(
90deg,
#1a1f2e 0%, #252b3b 40%, #2a3148 50%, #252b3b 60%, #1a1f2e 100%
);
background-size: 600px 100%;
animation: shimmer 1.6s infinite linear;
}
`}</style>
<div className="skeleton-shimmer" style={{ width: 300, height: 300 }}>
<div style={{
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 8, color: '#3b4460',
}}>
<div style={{ fontSize: 28 }}></div>
<div style={{ fontSize: 11, letterSpacing: '0.5px', fontWeight: 600 }}>GENERATING</div>
</div>
</div>
<div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="skeleton-shimmer" style={{ height: 14, borderRadius: 4, width: '65%' }} />
<div style={{ display: 'flex', gap: 6 }}>
<div className="skeleton-shimmer" style={{ height: 10, borderRadius: 3, width: 48 }} />
<div className="skeleton-shimmer" style={{ height: 10, borderRadius: 3, width: 24 }} />
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// SheetCard
// ---------------------------------------------------------------------------
interface SheetCardProps {
sheet: SpriteSheet
onClick: (sheet: SpriteSheet) => void
onApprove: (sheet: SpriteSheet) => void
onReject: (sheet: SpriteSheet) => void
}
export const SheetCard = memo(function SheetCard({ sheet, onClick, onApprove, onReject }: SheetCardProps): ReactElement {
const [hovered, setHovered] = useState(false)
const { base, race, gender } = parseEntityParts(sheet.entity_id)
const imageUrl = sheet.raw_path ? sheetImageUrl(sheet.raw_path) : null
const statusColor = sheet.is_approved
? '#a855f7'
: sheet.job_status === 'failed' || sheet.job_status === 'rejected'
? '#ef4444'
: sheet.job_status === 'completed'
? '#22c55e'
: '#6b7280'
const statusLabel = sheet.is_approved
? 'INSTALLED'
: sheet.job_status === 'failed'
? 'FAILED'
: sheet.job_status === 'rejected'
? 'REJECTED'
: sheet.job_status === 'completed'
? 'READY'
: 'PENDING'
const actionable = sheet.job_status === 'completed' && !sheet.is_approved
return (
<div
onClick={() => onClick(sheet)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
width: 300,
background: colors.surface,
border: `1px solid ${hovered ? colors.highlight : colors.accent}`,
borderRadius: 10,
overflow: 'hidden',
cursor: 'pointer',
transition: 'border-color 0.15s, transform 0.15s',
transform: hovered ? 'translateY(-2px)' : 'none',
position: 'relative',
}}
>
<div style={{ position: 'relative', width: 300, height: 300, background: '#0a0a1a' }}>
{imageUrl ? (
<img
src={imageUrl}
alt={sheet.entity_id}
loading="lazy"
style={{ width: 300, height: 300, objectFit: 'contain', display: 'block' }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted, fontSize: 12 }}>
No image
</div>
)}
<div style={{
position: 'absolute', top: 8, right: 8,
background: `${statusColor}22`,
color: statusColor,
border: `1px solid ${statusColor}44`,
borderRadius: 4,
fontSize: 10, fontWeight: 700, padding: '2px 6px', letterSpacing: '0.5px',
}}>
{statusLabel}
</div>
{hovered && actionable && (
<div
style={{ position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}
onClick={e => e.stopPropagation()}
>
<button
onClick={() => onApprove(sheet)}
style={{ background: '#22c55e', color: '#000', border: 'none', borderRadius: 6, padding: '6px 16px', fontSize: 12, fontWeight: 700, cursor: 'pointer' }}
>
Approve
</button>
<button
onClick={() => onReject(sheet)}
style={{ background: 'transparent', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 6, padding: '6px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}
>
Reject
</button>
</div>
)}
</div>
<div style={{ padding: '10px 12px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: colors.text, textTransform: 'capitalize', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{base}
</div>
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
{race && <span style={{ fontSize: 10, background: colors.accent, color: colors.muted, borderRadius: 3, padding: '1px 5px' }}>{race}</span>}
{gender && <span style={{ fontSize: 10, background: colors.accent, color: colors.muted, borderRadius: 3, padding: '1px 5px' }}>{gender}</span>}
<span style={{ fontSize: 10, color: colors.muted, marginLeft: 'auto' }}>{timeAgo(sheet.created_at)}</span>
</div>
</div>
</div>
)
})

View file

@ -1,253 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import type { ReactElement } from 'react'
import type { SpriteSheet } from '../types'
import { approveSheet, rejectSheet, sheetImageUrl } from '../api'
import { colors } from './theme'
import { ANIMATION_STATES, SHEET_ROWS, SHEET_COLS, STATE_COLORS, timeAgo, parseEntityParts } from './sheetUtils'
function useSheetFrames(imageUrl: string | null): string[][] {
const [frames, setFrames] = useState<string[][]>([])
useEffect(() => {
if (!imageUrl) { setFrames([]); return }
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const fw = Math.floor(img.naturalWidth / SHEET_COLS)
const fh = Math.floor(img.naturalHeight / SHEET_ROWS)
const result: string[][] = []
for (let r = 0; r < SHEET_ROWS; r++) {
const row: string[] = []
for (let c = 0; c < SHEET_COLS; c++) {
const canvas = document.createElement('canvas')
canvas.width = fw
canvas.height = fh
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, c * fw, r * fh, fw, fh, 0, 0, fw, fh)
row.push(canvas.toDataURL('image/png'))
}
result.push(row)
}
setFrames(result)
}
img.src = imageUrl
}, [imageUrl])
return frames
}
export interface SheetDetailModalProps {
sheet: SpriteSheet
onClose: () => void
onApproved: (sheetId: number) => void
onRejected: (sheetId: number) => void
}
export function SheetDetailModal({ sheet, onClose, onApproved, onRejected }: SheetDetailModalProps): ReactElement {
const imageUrl = sheet.raw_path ? sheetImageUrl(sheet.raw_path) : null
const frames = useSheetFrames(imageUrl)
const [state, setState] = useState<'idle' | 'approving' | 'rejecting' | 'approved' | 'rejected'>('idle')
const [framesInstalled, setFramesInstalled] = useState(0)
const [error, setError] = useState<string | null>(null)
const { base, race, gender } = parseEntityParts(sheet.entity_id)
const handleApprove = useCallback(async () => {
setState('approving')
setError(null)
try {
const result = await approveSheet(sheet.id)
setFramesInstalled(result.frames_installed)
setState('approved')
onApproved(sheet.id)
} catch (e) {
setError(e instanceof Error ? e.message : 'Approval failed')
setState('idle')
}
}, [sheet.id, onApproved])
const handleReject = useCallback(async () => {
setState('rejecting')
setError(null)
try {
await rejectSheet(sheet.id)
setState('rejected')
onRejected(sheet.id)
} catch (e) {
setError(e instanceof Error ? e.message : 'Rejection failed')
setState('idle')
}
}, [sheet.id, onRejected])
const isDisabled = state !== 'idle' || sheet.is_approved || sheet.job_status === 'rejected'
const framesLoaded = frames.length === SHEET_ROWS
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(0,0,0,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: colors.surface,
border: `1px solid ${colors.accent}`,
borderRadius: 12,
padding: 24,
maxWidth: 1060,
width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: colors.text, textTransform: 'capitalize' }}>
{base}
</div>
<div style={{ fontSize: 13, color: colors.muted, marginTop: 2 }}>
{race && <span style={{ marginRight: 8 }}>{race}</span>}
{gender && <span style={{ marginRight: 8 }}>{gender}</span>}
<span>seed {sheet.seed}</span>
<span style={{ marginLeft: 8 }}>{timeAgo(sheet.created_at)}</span>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none', border: `1px solid ${colors.accent}`,
color: colors.muted, borderRadius: 6, padding: '4px 12px',
cursor: 'pointer', fontSize: 13,
}}
>
Close
</button>
</div>
{/* Main content: sheet image + frame grid */}
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div style={{ flexShrink: 0 }}>
<div style={{ fontSize: 11, color: colors.muted, marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Raw Sheet (1024×1024)
</div>
{imageUrl ? (
<img
src={imageUrl}
alt="sprite sheet"
style={{
width: 460, height: 460,
objectFit: 'contain',
border: `1px solid ${colors.accent}`,
borderRadius: 8,
background: '#0a0a1a',
}}
/>
) : (
<div style={{ width: 460, height: 460, background: colors.bg, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted }}>
No image
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<div style={{ fontSize: 11, color: colors.muted, marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Extracted Frames {framesLoaded ? '(128×128 each)' : '— loading…'}
</div>
{framesLoaded ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ANIMATION_STATES.map((label, rowIdx) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 56, textAlign: 'right', fontSize: 11, fontWeight: 700,
color: STATE_COLORS[label], letterSpacing: '0.4px', flexShrink: 0,
}}>
{label}
</div>
{frames[rowIdx]?.map((src, colIdx) => (
<img
key={colIdx}
src={src}
alt={`${label} frame ${colIdx}`}
style={{
width: 100, height: 100,
objectFit: 'contain',
background: '#0d1b2a',
border: `1px solid ${colors.accent}`,
borderRadius: 6,
imageRendering: 'pixelated',
}}
/>
))}
</div>
))}
</div>
) : (
<div style={{ height: 460, display: 'flex', alignItems: 'center', justifyContent: 'center', color: colors.muted, fontSize: 13 }}>
{imageUrl ? 'Extracting frames…' : 'No sheet image available'}
</div>
)}
</div>
</div>
{error && (
<div style={{ color: '#ef4444', fontSize: 13, background: 'rgba(239,68,68,0.1)', padding: '8px 12px', borderRadius: 6 }}>
{error}
</div>
)}
{state === 'approved' && (
<div style={{ color: '#22c55e', fontSize: 13, background: 'rgba(34,197,94,0.1)', padding: '8px 12px', borderRadius: 6 }}>
Installed {framesInstalled} frames to game assets
</div>
)}
{state === 'rejected' && (
<div style={{ color: '#ef4444', fontSize: 13, background: 'rgba(239,68,68,0.1)', padding: '8px 12px', borderRadius: 6 }}>
Sheet rejected
</div>
)}
{state !== 'approved' && state !== 'rejected' && (
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={handleApprove}
disabled={isDisabled}
style={{
background: isDisabled ? colors.accent : '#22c55e',
color: isDisabled ? colors.muted : '#000',
border: 'none', borderRadius: 8,
padding: '10px 24px', fontSize: 14, fontWeight: 700,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.5 : 1,
transition: 'opacity 0.15s',
}}
>
{state === 'approving' ? 'Processing…' : '✓ Approve & Install'}
</button>
<button
onClick={handleReject}
disabled={isDisabled}
style={{
background: 'transparent',
color: isDisabled ? colors.muted : '#ef4444',
border: `1px solid ${isDisabled ? colors.accent : '#ef4444'}`,
borderRadius: 8,
padding: '10px 24px', fontSize: 14, fontWeight: 600,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.5 : 1,
}}
>
{state === 'rejecting' ? 'Rejecting…' : '✕ Reject'}
</button>
</div>
)}
</div>
</div>
)
}

View file

@ -1,269 +0,0 @@
/**
* Sprite Sheets review page.
*
* Each sheet is a 1024×1024 image containing a 4×4 animation grid:
* Row 0 IDLE (4 frames)
* Row 1 WALK (4 frames)
* Row 2 ATTACK (4 frames)
* Row 3 DEATH (4 frames)
*
* Approve to slice & install 16 frames into game assets.
* Sheet completions are pushed via SSE no polling.
*
* URL params:
* ?id=15 open sheet detail modal directly
* ?status=all pre-select status filter
* ?entity=foo pre-fill entity search
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ReactElement } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { SpriteSheet } from '../types'
import { fetchSheets, fetchSheet, rejectSheet, sheetStreamUrl, triggerGenerateSheets } from '../api'
import { colors } from './theme'
import { SheetCard, SheetSkeletonCard } from './SheetCard'
import { SheetDetailModal } from './SheetDetailModal'
type StatusFilter = 'all' | 'pending' | 'approved' | 'failed'
export default function SheetsPage(): ReactElement {
const [searchParams, setSearchParams] = useSearchParams()
const [sheets, setSheets] = useState<SpriteSheet[]>([])
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
(searchParams.get('status') as StatusFilter | null) ?? 'pending'
)
const [entitySearch, setEntitySearch] = useState(searchParams.get('entity') ?? '')
const [selected, setSelected] = useState<SpriteSheet | null>(null)
const [loading, setLoading] = useState(false)
const [generating, setGenerating] = useState(false)
const entityTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async (status: StatusFilter, entity: string) => {
setLoading(true)
try {
const data = await fetchSheets({
status: status === 'all' ? undefined : status,
entity: entity || undefined,
limit: 200,
})
setSheets(data)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(statusFilter, entitySearch) }, [load, statusFilter])
// Open sheet from ?id= param once sheets are loaded or on direct fetch
useEffect(() => {
const idParam = searchParams.get('id')
if (!idParam) return
const sheetId = parseInt(idParam, 10)
if (isNaN(sheetId)) return
const existing = sheets.find(s => s.id === sheetId)
if (existing) {
setSelected(existing)
} else {
// Sheet may not be in current filter — fetch it directly
fetchSheet(sheetId).then(sheet => setSelected(sheet)).catch(() => { /* not found */ })
}
}, [searchParams, sheets])
// Sync selected sheet → ?id= param
const openSheet = useCallback((sheet: SpriteSheet) => {
setSelected(sheet)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.set('id', String(sheet.id))
return next
}, { replace: true })
}, [setSearchParams])
const closeSheet = useCallback(() => {
setSelected(null)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('id')
return next
}, { replace: true })
}, [setSearchParams])
const setFilter = useCallback((status: StatusFilter) => {
setStatusFilter(status)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (status === 'pending') next.delete('status')
else next.set('status', status)
return next
}, { replace: true })
}, [setSearchParams])
const handleEntityChange = (value: string) => {
setEntitySearch(value)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (value) next.set('entity', value)
else next.delete('entity')
return next
}, { replace: true })
if (entityTimeout.current) clearTimeout(entityTimeout.current)
entityTimeout.current = setTimeout(() => load(statusFilter, value), 400)
}
// SSE subscription — promote skeleton cards to real cards as sheets complete
useEffect(() => {
const es = new EventSource(sheetStreamUrl())
es.onmessage = (event: MessageEvent) => {
const arrived: SpriteSheet[] = JSON.parse(event.data as string)
setSheets(prev => {
const byId = new Map(prev.map(s => [s.id, s]))
for (const s of arrived) byId.set(s.id, s)
return Array.from(byId.values())
})
}
return () => es.close()
}, [])
const handleApproved = useCallback((sheetId: number) => {
setSheets(prev => prev.map(s => s.id === sheetId ? { ...s, is_approved: true } : s))
setSelected(prev => prev?.id === sheetId ? { ...prev, is_approved: true } : prev)
}, [])
const handleRejected = useCallback((sheetId: number) => {
setSheets(prev => prev.map(s => s.id === sheetId ? { ...s, job_status: 'rejected' } : s))
closeSheet()
}, [closeSheet])
const handleQuickApprove = useCallback((sheet: SpriteSheet) => {
openSheet(sheet)
}, [openSheet])
const handleQuickReject = useCallback(async (sheet: SpriteSheet) => {
try {
await rejectSheet(sheet.id)
handleRejected(sheet.id)
} catch { /* ignore */ }
}, [handleRejected])
const handleGenerateSheets = useCallback(async () => {
setGenerating(true)
try {
const result = await triggerGenerateSheets({ sheets: 4 })
alert(`Submitted ${result.submitted} sheet requests for ${result.sprites} sprites`)
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to submit')
} finally {
setGenerating(false)
}
}, [])
const submittedSheets = sheets.filter(s => s.job_status === 'submitted')
const completedSheets = sheets.filter(s => s.job_status !== 'submitted')
const pendingCount = sheets.filter(s => s.job_status === 'completed' && !s.is_approved).length
const approvedCount = sheets.filter(s => s.is_approved).length
return (
<div style={{ padding: 24, minHeight: '100vh' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<div>
<h1 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: colors.text }}>Sprite Sheets</h1>
<div style={{ fontSize: 13, color: colors.muted, marginTop: 4 }}>
4×4 animation grid: IDLE / WALK / ATTACK / DEATH approve to slice &amp; install 16 frames
</div>
</div>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ fontSize: 13, color: colors.muted }}>
{pendingCount > 0 && <span style={{ color: '#22c55e', marginRight: 12 }}>{pendingCount} ready to review</span>}
{approvedCount > 0 && <span style={{ color: '#a855f7' }}>{approvedCount} installed</span>}
</div>
<button
onClick={handleGenerateSheets}
disabled={generating}
style={{
background: generating ? colors.accent : colors.highlight,
color: '#fff', border: 'none', borderRadius: 8,
padding: '8px 18px', fontSize: 13, fontWeight: 600,
cursor: generating ? 'not-allowed' : 'pointer',
}}
>
{generating ? 'Submitting…' : '+ Generate Sheets'}
</button>
<button
onClick={() => load(statusFilter, entitySearch)}
style={{
background: 'transparent', color: colors.muted,
border: `1px solid ${colors.accent}`, borderRadius: 8,
padding: '8px 14px', fontSize: 13, cursor: 'pointer',
}}
>
Refresh
</button>
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
{(['all', 'pending', 'approved', 'failed'] as StatusFilter[]).map(s => (
<button
key={s}
onClick={() => setFilter(s)}
style={{
background: statusFilter === s ? colors.highlight : colors.surface,
color: statusFilter === s ? '#fff' : colors.muted,
border: `1px solid ${statusFilter === s ? colors.highlight : colors.accent}`,
borderRadius: 6, padding: '5px 14px', fontSize: 13,
cursor: 'pointer', textTransform: 'capitalize',
transition: 'background 0.15s',
}}
>
{s === 'pending' ? 'Pending Review' : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
<input
type="text"
value={entitySearch}
onChange={e => handleEntityChange(e.target.value)}
placeholder="Filter by entity…"
style={{
background: colors.surface, color: colors.text,
border: `1px solid ${colors.accent}`, borderRadius: 6,
padding: '5px 12px', fontSize: 13, outline: 'none', width: 200,
}}
/>
<span style={{ fontSize: 13, color: colors.muted }}>
{loading ? 'Loading…' : `${sheets.length} sheets`}
</span>
</div>
{sheets.length === 0 && !loading ? (
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
{statusFilter === 'pending'
? 'No sheets ready for review. Run "generate-sheets" to create some.'
: 'No sheets found.'}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
{submittedSheets.map(sheet => <SheetSkeletonCard key={sheet.id} />)}
{completedSheets.map(sheet => (
<SheetCard
key={sheet.id}
sheet={sheet}
onClick={openSheet}
onApprove={handleQuickApprove}
onReject={handleQuickReject}
/>
))}
</div>
)}
{selected && (
<SheetDetailModal
sheet={selected}
onClose={closeSheet}
onApproved={handleApproved}
onRejected={handleRejected}
/>
)}
</div>
)
}