simocracy-v2 · petdex-codex-pet-tab
The Mint-a-Sim modal’s Codex Pet tab used to be “drop a folder from ~/.codex/pets/” only. This run adds a sub-tab that lets users browse and pick from ~1,177 community pets on petdex.crafter.run — search by vibe, click, save. End-to-end smoke-tested with a fresh certified.one account; every record cleaned up afterwards. Polished after first review with infinite-scroll pagination, bar-cropped thumbs, and hover-triggered idle-loop animation.
It works. Search proxy returns petdex’s 30-pet page in <1.5 s, the click-to-pick flow validates the 1536×1872 spritesheet locally before commit, and saving lands a fully-shaped org.simocracy.sim on the user’s ePDS with spriteKind="codexPet", the slim petManifest, and the spritesheet blob. The senate & profile pages render the new otter sprite. After cleanup, every collection on the test PDS goes back to 0 records.
Three pieces under simocracy-v2/:
app/api/petdex/search/route.tsSame-origin GET proxy. Forwards an allowlist of query keys (q, sort, cursor, limit, kinds, vibes, colors, batches) to petdex.crafter.run/api/pets/search. 60 s edge cache. Petdex doesn’t set Access-Control-Allow-Origin, so this is the only way to read its catalog from the browser without a CORS fight.
app/api/petdex/proxy/route.tsStreams binary assets (thumbnails, spritesheet.webp, pet.json) through our origin. Allowlist of two hosts (petdex.crafter.run + the public R2 bucket); rejects anything else with 400. 8 MB ceiling stops a future hi-res sheet from turning this into an SSRF egress proxy.
components/sim/codex-pet-petdex-browser.tsxThe picker UI. Debounced query, 30-pet grid, click-to-fetch → validate dimensions → emit a CodexPetDropResult identical to what the existing dropzone produces, so the rest of the create-sim flow (name input + CodexPetSave) is reused unchanged.
appearance-editor-dialog.tsxInside the existing Codex Pet tab, a small two-pill segmented control switches between Browse Petdex (default) and Bring your own. The dropzone path is byte-for-byte unchanged.
fit:contain letterbox bars are pushed off-cell by an 8% horizontal scale (192:208 sprite → 80×80 thumb has ~3 px transparent bars on each side).
otter debounces 300 ms then refilters server-side. 5 pets back, including Boba (the otter sipping bubble tea — petdex’s curated example).
/api/petdex/proxy (1.9 MB, ~700 ms), validatePetSheet checks 1536×1872, pet.json shapes into the petManifest. Preview canvas runs Boba’s idle loop, name pre-fills.
petSheet blob the user just uploaded to their PDS.
/sims/[did]/[rkey] route. “Minted by @1ek1qg.certified.one” · YOURS pill · chat / interview / S-process slots wired up the same as any pixel-avatar sim.
listRecords across sim / history / agents / style all return 0; indexer agrees.
max-height: 360px container; an IntersectionObserver with root: gridRef + rootMargin: 120 px watches a sentinel below the cards. Confirmed via dev logs: cursor=0 → 30 → 60 → 90 as the user scrolls. 120 of 1,177 pets shown here, with the pet count updating every page.
mouseenter, the spritesheet (~1.9 MB) is lazy-loaded through /api/petdex/proxy and renderPetAnimated drives the idle row on a <canvas> overlay. Static <img> fades to opacity: 0 when the canvas is ready; on mouseleave the rAF loop stops and the static thumb fades back. Verified by sampling canvas.toDataURL() over time — the data length oscillates frame-to-frame.The full smoke-test path landed a real org.simocracy.sim on a fresh certified.one PDS:
{
"$type": "org.simocracy.sim",
"name": "Petdex Smoke Boba",
"spriteKind": "codexPet",
"petManifest": {
"id": "boba",
"displayName": "Boba",
"description": "A tiny otter sipping bubble tea while keeping you company in Codex."
},
"image": { "$type": "blob", "mimeType": "image/png", "size": 26103 },
"petSheet": { "$type": "blob", "mimeType": "image/webp", "size": 1931458 }
}
The simocracy indexer also reflects it within seconds (at://did:plc:chek6tibulc4ujr4mishea5i/org.simocracy.sim/3mlcbbqpzx22u).
Initial review caught the grid stopping at 30 pets. Fixed in the polish pass with cursor-driven pagination. Stress test:
cursor=0 → first 30 pets
cursor=30 → fired by IntersectionObserver as the sentinel approaches view
cursor=60 → … rinse repeat through 1,177 pets…
nextCursor=null → sentinel renders “— end of petdex —” and the IO disconnects
Stale-page guard: a requestSeqRef counter increments on every reset (search query change). In-flight pages whose seq doesn’t match the current one are dropped on arrival, so a slow first-page response can’t clobber a fresh search.
First review noted the cards looked flat. Polish pass adds an idle-loop animation that runs only on hover so the picker doesn’t pre-load 30 × 1.9 MB spritesheets just for browsing:
onMouseEnter → loadPetSheet(proxyUrl) // module-level cache, dedup'd
→ drawPetFrame(canvas, sheet, “idle”, 0) // first paint asap
→ renderPetAnimated(canvas, url, “idle”) // rAF loop on durations
onMouseLeave → cleanup() stops rAF + setCanvasReady(false)
→ static <img> fades back to opacity:1 (120 ms)
Verified by sampling canvas.toDataURL().length at 400 ms intervals during hover — lengths oscillate (e.g. 7950 → 7610 → 7950) confirming distinct frames are landing. Subsequent hovers on the same pet are instant because loadPetSheet caches per URL.
Petdex’s thumb pipeline is sharp.resize(80, 80, { fit: “contain”, kernel: “nearest” }) over a 192×208 source cell, which leaves ~3 px of transparent letterbox on each side (sprite is taller than wide). The card now wraps the <img> in overflow: hidden and applies transform: scaleX(80 / 73.8) ≈ scaleX(1.084), pushing the bars off-cell. The hover canvas paints the same 192×208 source cell into the same square shape, so the visual stretch matches frame-for-frame and the swap is a no-op shape-wise.
Direct curl tests against the running dev server:
# in-allowlist hosts succeed
/api/petdex/proxy?url=https%3A%2F%2Fpetdex.crafter.run%2Fapi%2Fpets%2Fboba%2Fthumb → 200 image/webp 1978 B
/api/petdex/proxy?url=https%3A%2F%2Fpub-944…r2.dev%2Fcurated%2Fboba%2Fspritesheet.webp → 200 image/webp 1931458 B
# off-allowlist host rejected
/api/petdex/proxy?url=https%3A%2F%2Fexample.com → 400
The 8 MB cap (via Content-Length) plus redirect: "error" means a future malicious petdex deploy can’t turn this into an open egress proxy or a fetch-redirect SSRF.
uploadBlob regression as last timeFirst attempt failed with POST /api/upload-blob 500 — expected non-null body source coming from undici on Node v25.9.0 when the agent uploadBlob writes the 1.9 MB sheet to certified.one. Identical regression to simocracy-v2/codex-pet-create. Workaround: downgrade dev server to Node 22 LTS —
PATH="/opt/homebrew/Cellar/node@22/22.22.2_2/bin:$PATH" npm run dev
The retry on Node 22 succeeded immediately. This is an environment issue, not a regression introduced by the petdex tab — the existing dropzone path also breaks on Node 25. Worth a follow-up upstream against atproto-sdk / undici, but out of scope for this change.
Confirmed both petdex.crafter.run and the public R2 bucket return responses with no Access-Control-Allow-Origin header. Without our same-origin proxy, every browser fetch would fail. If petdex ever adds CORS we can drop the proxy and call them directly — noted as a future simplification, not a current bug.
The first attempt at clicking Boba by ref while the popular grid was scrolled below the fold dismissed the dialog instead of selecting the pet. Re-running with a search filter (so Boba was in the visible viewport) worked first try. Likely an agent-browser focus-restore quirk under Radix Dialog rather than our code — humans clicking the actual button never trigger this. Worth keeping in mind for follow-up automation runs.
Per the smoke-test skill (§0: “leave production exactly as you found it”), every record written during the run was deleted before declaring done.
1ek1qg.certified.one · did:plc:chek6tibulc4ujr4mishea5i · PDS https://certified.one
Sim recordat://…/org.simocracy.sim/3mlcbbqpzx22u → deleted via My Sims trash icon.
History sidecarat://…/org.simocracy.history/3mlcbkvh32s2u (auto-written type="delete" sidecar) → deleted via DELETE /api/records using the still-live OAuth session.
Triple-checkPDS listRecords on sim / history / agents / style all return {records:[]}; indexer reports 0 sim matches for the test DID. See verify.webm.
app/api/petdex/{search,proxy}/route.ts · components/sim/codex-pet-petdex-browser.tsx · components/sim/appearance-editor-dialog.tsx
Run dev serverPATH="/opt/homebrew/Cellar/node@22/22.22.2_2/bin:$PATH" npm run dev (Node 25 has the undici regression noted above)
Sign inSIGN IN → EMAIL → disposable mail.tm address. The smoke-test skill’s §1 walks through the full mail.tm + certified.one OTP flow.
Smoke testMINT A NEW SIM → Codex Pet tab → Browse Petdex → search otter → click Boba → confirm name → SAVE TO ATPROTO → verify on PDS via listRecords → delete sim → verify empty.
Verify webmverify.webm (vhs) — runs verify.sh against the test DID.
Verify scriptverify.sh + verify.tape — reproducible PDS + indexer checks.