diff --git a/.gitignore b/.gitignore
index 20e10408..577f9101 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@ Thumbs.db
# Auto-added by auto-commit-service
node_modules/
+.venv/
# Auto-added by auto-commit-service
*.log
diff --git a/.project/objectives/DASHBOARD_CATEGORIES.md b/.project/objectives/DASHBOARD_CATEGORIES.md
index 2b246b69..45d07df8 100644
--- a/.project/objectives/DASHBOARD_CATEGORIES.md
+++ b/.project/objectives/DASHBOARD_CATEGORIES.md
@@ -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) | π’ |
diff --git a/.project/objectives/DASHBOARD_COMPLETED.md b/.project/objectives/DASHBOARD_COMPLETED.md
index e4238eb4..6865c248 100644
--- a/.project/objectives/DASHBOARD_COMPLETED.md
+++ b/.project/objectives/DASHBOARD_COMPLETED.md
@@ -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 |
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 597f43ef..7821fcae 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -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** |
@@ -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 |
diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json
index c1fc5153..9a590b6b 100644
--- a/.project/objectives/objectives.json
+++ b/.project/objectives/objectives.json
@@ -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",
diff --git a/.project/objectives/p2-22-sprite-generation-pipeline.md b/.project/objectives/p2-22-sprite-generation-pipeline.md
index acea24cb..9ca2cf61 100644
--- a/.project/objectives/p2-22-sprite-generation-pipeline.md
+++ b/.project/objectives/p2-22-sprite-generation-pipeline.md
@@ -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.)
diff --git a/tooling/claude/dot-claude/launch.json b/tooling/claude/dot-claude/launch.json
new file mode 100644
index 00000000..50ffbc8a
--- /dev/null
+++ b/tooling/claude/dot-claude/launch.json
@@ -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
+ }
+ ]
+}
diff --git a/tools/sprite-generation/gui/pnpm-lock.yaml b/tools/sprite-generation/gui/pnpm-lock.yaml
index be8a52f6..69459447 100644
--- a/tools/sprite-generation/gui/pnpm-lock.yaml
+++ b/tools/sprite-generation/gui/pnpm-lock.yaml
@@ -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):
diff --git a/tools/sprite-generation/gui/src/pages/SheetCard.tsx b/tools/sprite-generation/gui/src/pages/SheetCard.tsx
deleted file mode 100644
index 82c3d5e8..00000000
--- a/tools/sprite-generation/gui/src/pages/SheetCard.tsx
+++ /dev/null
@@ -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 (
-
- )
-}
-
-// ---------------------------------------------------------------------------
-// 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 (
- 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',
- }}
- >
-
- {imageUrl ? (
- 
- ) : (
-
- No image
-
- )}
-
- {statusLabel}
-
- {hovered && actionable && (
- e.stopPropagation()}
- >
-
-
-
- )}
-
-
-
- {base}
-
-
- {race && {race}}
- {gender && {gender}}
- {timeAgo(sheet.created_at)}
-
-
-
- )
-})
diff --git a/tools/sprite-generation/gui/src/pages/SheetDetailModal.tsx b/tools/sprite-generation/gui/src/pages/SheetDetailModal.tsx
deleted file mode 100644
index 938429e4..00000000
--- a/tools/sprite-generation/gui/src/pages/SheetDetailModal.tsx
+++ /dev/null
@@ -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([])
-
- 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(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 (
-
- 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 */}
-
-
-
- {base}
-
-
- {race && {race}}
- {gender && {gender}}
- seed {sheet.seed}
- {timeAgo(sheet.created_at)}
-
-
-
-
-
- {/* Main content: sheet image + frame grid */}
-
-
-
- Raw Sheet (1024Γ1024)
-
- {imageUrl ? (
- 
- ) : (
-
- No image
-
- )}
-
-
-
-
- Extracted Frames {framesLoaded ? '(128Γ128 each)' : 'β loadingβ¦'}
-
- {framesLoaded ? (
-
- {ANIMATION_STATES.map((label, rowIdx) => (
-
-
- {label}
-
- {frames[rowIdx]?.map((src, colIdx) => (
- 
- ))}
-
- ))}
-
- ) : (
-
- {imageUrl ? 'Extracting framesβ¦' : 'No sheet image available'}
-
- )}
-
-
-
- {error && (
-
- {error}
-
- )}
- {state === 'approved' && (
-
- β Installed {framesInstalled} frames to game assets
-
- )}
- {state === 'rejected' && (
-
- Sheet rejected
-
- )}
-
- {state !== 'approved' && state !== 'rejected' && (
-
-
-
-
- )}
-
-
- )
-}
diff --git a/tools/sprite-generation/gui/src/pages/SheetsPage.tsx b/tools/sprite-generation/gui/src/pages/SheetsPage.tsx
deleted file mode 100644
index 5ff5dec9..00000000
--- a/tools/sprite-generation/gui/src/pages/SheetsPage.tsx
+++ /dev/null
@@ -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([])
- const [statusFilter, setStatusFilter] = useState(
- (searchParams.get('status') as StatusFilter | null) ?? 'pending'
- )
- const [entitySearch, setEntitySearch] = useState(searchParams.get('entity') ?? '')
- const [selected, setSelected] = useState(null)
- const [loading, setLoading] = useState(false)
- const [generating, setGenerating] = useState(false)
- const entityTimeout = useRef | 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 (
-
-
-
- Sprite Sheets
-
- 4Γ4 animation grid: IDLE / WALK / ATTACK / DEATH β approve to slice & install 16 frames
-
-
-
-
- {pendingCount > 0 && {pendingCount} ready to review}
- {approvedCount > 0 && {approvedCount} installed}
-
-
-
-
-
-
-
- {(['all', 'pending', 'approved', 'failed'] as StatusFilter[]).map(s => (
-
- ))}
- 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,
- }}
- />
-
- {loading ? 'Loadingβ¦' : `${sheets.length} sheets`}
-
-
-
- {sheets.length === 0 && !loading ? (
-
- {statusFilter === 'pending'
- ? 'No sheets ready for review. Run "generate-sheets" to create some.'
- : 'No sheets found.'}
-
- ) : (
-
- {submittedSheets.map(sheet => )}
- {completedSheets.map(sheet => (
-
- ))}
-
- )}
-
- {selected && (
-
- )}
-
- )
-}
|