← back to Paul Conrad Cartoons
TK-12241: Shadow Man gallery + Arena Ideas curation in Inkwell
d32917485617ee6f6e6312bf58108b794b2a13fc · 2026-09-25 11:06:20 -0700 · Steve
- Rendered 20 original Shadow Man briefs locally ($0 SDXL) + 2 simplified-prompt retries;
visual review (signature, no fake lettering, gag adherence) kept 13 of 20 briefs.
- gen_shadowman.py: 6% bottom crop kills SDXL's painted fake signatures; raw frames saved;
--force re-render; per-brief negative_extra. retry_briefs.py writes r1/r2 retry batches.
- build-shadowman.mjs publishes reviewed finals to public/shadowman/*.jpg + data/shadowman.json.
- Inkwell: Shadow Man section + Arena Ideas section (62 ideas from 5 local models, extracted by
scripts/extract-arena-ideas.cjs), theme/era/model filters, sort, density, created date+time chips,
checkbox select, big Approve/Delete buttons, sticky bulk bar, in-page delete confirm,
show-deleted/restore. Loopback-only POST APIs through sendClean(); delete moves images to
generator/out/_trash; approved arena ideas queue to generator/idea-queue.json and
compose-prompts.mjs --from-queue turns them into the next batch. Actions log to
data/shadowman-actions.jsonl.
- verify-inkwell.cjs: +36 checks (62/62 pass against a scratch copy).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA data/arena-ideas.jsonA data/shadowman.jsonM generator/compose-prompts.mjsM generator/gen_shadowman.pyA generator/out/batch-r1.jsonA generator/out/batch-r2.jsonA generator/out/batch-refinish.jsonA generator/out/batch.jsonA generator/out/manifest.jsonA generator/retry_briefs.pyA public/arena.jsA public/curate.jsM public/index.htmlA public/shadowman.jsA public/shadowman/shadowman-20260925T172534-002-r1.jpgA public/shadowman/shadowman-20260925T172534-005.jpgA public/shadowman/shadowman-20260925T172534-006-r2.jpgA public/shadowman/shadowman-20260925T172534-007-r2.jpgA public/shadowman/shadowman-20260925T172534-008.jpgA public/shadowman/shadowman-20260925T172534-009.jpgA public/shadowman/shadowman-20260925T172534-011.jpgA public/shadowman/shadowman-20260925T172534-012-r1.jpgA public/shadowman/shadowman-20260925T172534-013.jpgA public/shadowman/shadowman-20260925T172534-016-r1.jpgA public/shadowman/shadowman-20260925T172534-017-r1.jpgA public/shadowman/shadowman-20260925T172534-019-r1.jpgA public/shadowman/shadowman-20260925T172534-020.jpgM public/style.cssA scripts/build-shadowman.mjsA scripts/extract-arena-ideas.cjsA scripts/shadowman-review.jsonM scripts/verify-inkwell.cjsM server.js
Diff
commit d32917485617ee6f6e6312bf58108b794b2a13fc
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 25 11:06:20 2026 -0700
TK-12241: Shadow Man gallery + Arena Ideas curation in Inkwell
- Rendered 20 original Shadow Man briefs locally ($0 SDXL) + 2 simplified-prompt retries;
visual review (signature, no fake lettering, gag adherence) kept 13 of 20 briefs.
- gen_shadowman.py: 6% bottom crop kills SDXL's painted fake signatures; raw frames saved;
--force re-render; per-brief negative_extra. retry_briefs.py writes r1/r2 retry batches.
- build-shadowman.mjs publishes reviewed finals to public/shadowman/*.jpg + data/shadowman.json.
- Inkwell: Shadow Man section + Arena Ideas section (62 ideas from 5 local models, extracted by
scripts/extract-arena-ideas.cjs), theme/era/model filters, sort, density, created date+time chips,
checkbox select, big Approve/Delete buttons, sticky bulk bar, in-page delete confirm,
show-deleted/restore. Loopback-only POST APIs through sendClean(); delete moves images to
generator/out/_trash; approved arena ideas queue to generator/idea-queue.json and
compose-prompts.mjs --from-queue turns them into the next batch. Actions log to
data/shadowman-actions.jsonl.
- verify-inkwell.cjs: +36 checks (62/62 pass against a scratch copy).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
.gitignore | 6 +
data/arena-ideas.json | 882 +++++++++++++++++++++
data/shadowman.json | 394 +++++++++
generator/compose-prompts.mjs | 28 +-
generator/gen_shadowman.py | 46 +-
generator/out/batch-r1.json | 160 ++++
generator/out/batch-r2.json | 106 +++
generator/out/batch-refinish.json | 120 +++
generator/out/batch.json | 215 +++++
generator/out/manifest.json | 795 +++++++++++++++++++
generator/retry_briefs.py | 54 ++
public/arena.js | 104 +++
public/curate.js | 113 +++
public/index.html | 100 +++
public/shadowman.js | 126 +++
.../shadowman/shadowman-20260925T172534-002-r1.jpg | Bin 0 -> 430000 bytes
public/shadowman/shadowman-20260925T172534-005.jpg | Bin 0 -> 507561 bytes
.../shadowman/shadowman-20260925T172534-006-r2.jpg | Bin 0 -> 457800 bytes
.../shadowman/shadowman-20260925T172534-007-r2.jpg | Bin 0 -> 401410 bytes
public/shadowman/shadowman-20260925T172534-008.jpg | Bin 0 -> 522456 bytes
public/shadowman/shadowman-20260925T172534-009.jpg | Bin 0 -> 517723 bytes
public/shadowman/shadowman-20260925T172534-011.jpg | Bin 0 -> 496872 bytes
.../shadowman/shadowman-20260925T172534-012-r1.jpg | Bin 0 -> 466730 bytes
public/shadowman/shadowman-20260925T172534-013.jpg | Bin 0 -> 585854 bytes
.../shadowman/shadowman-20260925T172534-016-r1.jpg | Bin 0 -> 590321 bytes
.../shadowman/shadowman-20260925T172534-017-r1.jpg | Bin 0 -> 442831 bytes
.../shadowman/shadowman-20260925T172534-019-r1.jpg | Bin 0 -> 568155 bytes
public/shadowman/shadowman-20260925T172534-020.jpg | Bin 0 -> 585289 bytes
public/style.css | 60 ++
scripts/build-shadowman.mjs | 63 ++
scripts/extract-arena-ideas.cjs | 97 +++
scripts/shadowman-review.json | 190 +++++
scripts/verify-inkwell.cjs | 154 +++-
server.js | 115 +++
34 files changed, 3912 insertions(+), 16 deletions(-)
diff --git a/.gitignore b/.gitignore
index 69e7414..ee6acab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,9 @@ dist/
build/
.next/
shots/
+# Shadow Man renders (TK-12241): full-size PNGs, raw SDXL frames and trashed finals stay local;
+# the kept finals are committed as public/shadowman/*.jpg, the manifest/batches are committed.
+generator/out/*.png
+generator/out/raw/
+generator/out/_trash/
+node_modules
diff --git a/data/arena-ideas.json b/data/arena-ideas.json
new file mode 100644
index 0000000..753fd73
--- /dev/null
+++ b/data/arena-ideas.json
@@ -0,0 +1,882 @@
+{
+ "generated_at": "2026-09-25T17:58:41.109Z",
+ "source": "model-arena battle 7b062a7d42e5",
+ "per_model": {
+ "qwen2-5-coder-32b-instruct-gguf-q4-k-m": {
+ "file": "mac2__qwen2-5-coder-32b-instruct-gguf-q4-k-m.html",
+ "via": "dom",
+ "extracted": 12,
+ "kept": 12,
+ "dropped": 0
+ },
+ "qwen3-30b-a3b-instruct-2507-4bit": {
+ "file": "mlx__qwen3-30b-a3b-instruct-2507-4bit.html",
+ "via": "script-array",
+ "extracted": 12,
+ "kept": 12,
+ "dropped": 0
+ },
+ "qwen3-next-80b-a3b-instruct-4bit": {
+ "file": "mlx__qwen3-next-80b-a3b-instruct-4bit.html",
+ "via": "script-array",
+ "extracted": 19,
+ "kept": 15,
+ "dropped": 4
+ },
+ "qwen25-7b": {
+ "file": "qwen25-7b.html",
+ "via": "dom",
+ "extracted": 12,
+ "kept": 11,
+ "dropped": 1
+ },
+ "qwen3-14b": {
+ "file": "qwen3-14b.html",
+ "via": "script-array",
+ "extracted": 12,
+ "kept": 12,
+ "dropped": 0
+ }
+ },
+ "counts": {
+ "extracted": 67,
+ "kept": 62,
+ "dropped": 5
+ },
+ "dropped": [
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "title": "Mushroom Cloud DJ Set",
+ "reason": "names a brand / franchise"
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "title": "Microaggression Metronome",
+ "reason": "racial caricature risk"
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "title": "Justice Scales with Uber",
+ "reason": "names a brand / franchise"
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "title": "Instagram Therapy App",
+ "reason": "names a brand / franchise"
+ },
+ {
+ "model": "qwen25-7b",
+ "title": "The Middle Earth War",
+ "reason": "names a brand / franchise"
+ }
+ ],
+ "items": [
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Civil rights & race",
+ "title": "Rainbow Racetrack",
+ "caption": "Run the gauntlet of unity!",
+ "scene": "A racially diverse group running on a track made of rainbow stripes.",
+ "why": "Unity symbolized by colorful stripes and diversity in action.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-16162f7885",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Local LA & California politics",
+ "title": "Sunshine Siren",
+ "caption": "Call me, mayor!",
+ "scene": "A glamorous woman in a siren uniform standing under a sunny sky.",
+ "why": "California politics personified as a radiant and charismatic sirens.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-8f9598d64e",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Presidential power & scandal",
+ "title": "Presidential Pancake Flip",
+ "caption": "Flip the script on foreign policy!",
+ "scene": "A president flipping a huge pancake over another country's flag.",
+ "why": "The absurdity of using food to solve geopolitical issues.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-3c01713cc5",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Middle East & foreign policy",
+ "title": "Sandcastle Summit",
+ "caption": "Building peace, one grain at a time!",
+ "scene": "Leaders building sandcastles on the beach with flags.",
+ "why": "The simplicity of sandcastles symbolizing complex diplomatic relations.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-8d6dd5d044",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Taxes, economy & inequality",
+ "title": "Wealth Waterfall",
+ "caption": "Money flows downward!",
+ "scene": "A rich man in a top hat standing on a waterfall with money cascading down.",
+ "why": "The absurdity of wealth distribution through natural elements.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-c26c8a0e48",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Environment & energy",
+ "title": "Solar Surgeon",
+ "caption": "Cutting-edge healing!",
+ "scene": "A solar panel used as a scalpel in a doctor's hands.",
+ "why": "Medical equipment powered by renewable energy sources.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-dee9d81d61",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "War & militarism",
+ "title": "Tank Twerk",
+ "caption": "Show some moves on the battlefield!",
+ "scene": "A tank doing a wild twerk with military flags blowing around.",
+ "why": "Military machines performing silly dances.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-050c67e411",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Nuclear arms & the Cold War",
+ "title": "Duck and Duck Tape",
+ "caption": "Protect yourself!",
+ "scene": "People duct-taping themselves into duck poses under fallout shelters.",
+ "why": "The irony of using everyday objects for atomic defense.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-7627cb6d86",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Elections & party politics",
+ "title": "Election Elephant Tango",
+ "caption": "Step aside, bipartisanship!",
+ "scene": "Two elephants doing a tango in front of a voting booth.",
+ "why": "Political parties dancing instead of debating.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-26468e2366",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Press, speech & dissent",
+ "title": "Microphone Monkey",
+ "caption": "Free press or banana?",
+ "scene": "A monkey holding a microphone with bananas in the background.",
+ "why": "The absurdity of choosing between freedom and temptation.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-ed02c0fbd6",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Space & technology",
+ "title": "Rocket Rabbit",
+ "caption": "Buncho' zoom!",
+ "scene": "A rabbit sitting in a rocket ship blasting off.",
+ "why": "Space travel personified as a fast-moving, fluffy animal.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-707286d414",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen2-5-coder-32b-instruct-gguf-q4-k-m",
+ "theme": "Courts, law & justice",
+ "title": "Judge Juggernaut",
+ "caption": "Pound of flesh!",
+ "scene": "A judge smashing down scales while wielding a huge hammer.",
+ "why": "The judicial system using extreme measures to dispense justice.",
+ "drawability": 2,
+ "created_at": "2026-09-25T17:36:01.834Z",
+ "id": "arena-3500cfe4e5",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Presidential power & scandal",
+ "title": "Tiny Dictator",
+ "caption": "I didn’t ban dissent—just issued a pamphlet.",
+ "scene": "A tiny man in a suit stands atop a giant toilet plunger, pointing at a crowd of protestors with tiny foam fingers.",
+ "why": "The absolute power of a press release.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-bfd63d610e",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "War & militarism",
+ "title": "Tank on a Bike",
+ "caption": "This isn’t a draft—I borrowed it from a grade school parade.",
+ "scene": "A child’s red tricycle carrying a full-size tank, wheels spinning on air, helmeted soldier frowning at a knitted afghan draped over the gun barrel.",
+ "why": "The war effort gets a dubious upgrade.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-e4c2011e8c",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Civil rights & race",
+ "title": "Scales of Justice",
+ "caption": "Bias? No, that’s just weight distribution.",
+ "scene": "A golden scale balancing a tiny figure made of melting clocks on one side, and a black steel bar on the other, floating in a bath of soup.",
+ "why": "Justice still ‘floats’ in the same broth.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-f88c965f4f",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Nuclear arms & Cold War",
+ "title": "Nuclear Tickle",
+ "caption": "This warm-up exercise could make anyone happy.",
+ "scene": "A nuke shaped like a giant feather lightly tickling a turtleneck-wearing bear mid-yawn.",
+ "why": "The Cold War didn’t end—just became a joke.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-c4b8c91bd6",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Middle East & foreign policy",
+ "title": "Peace Treaty Doughnut",
+ "caption": "We’re not arguing—we’re just filling out paperwork for this pastry.",
+ "scene": "Two men in suits pass a giant glazed doughnut across a torn map, one wearing a tie made of barbed wire.",
+ "why": "Diplomacy: wrapped in sugar and grease.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-01165dda80",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Courts, law & justice",
+ "title": "Verdict Yogurt",
+ "caption": "I’ve seen this batch. It’s clearly guilty.",
+ "scene": "A judge dips a spoon into a large, wobbling tub of yogurt labeled ‘Justice,’ watching it slowly spill over a suspended wire.",
+ "why": "The verdict drips faster than the court’s patience.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-07f2b1a277",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Elections & party politics",
+ "title": "Debate Microphones",
+ "caption": "He said he’d crack a joke—if I handed him one first.",
+ "scene": "Two politicians on a stage, one holding a giant lemon, the other a block of ice, both stuck to a long spring stretched across the floor.",
+ "why": "The campaign season built on emotional instability.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-3166a2aa93",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Taxes, economy & inequality",
+ "title": "Wealth Obelisk",
+ "caption": "That’s not a pyramid—it’s just how we keep our savings in order.",
+ "scene": "A gold pyramid stacking on a palm of a man made entirely of old currency, the base slowly sinking into a puddle of rust.",
+ "why": "The top runs on credit, but the base's been underwater for years.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-9c8b9361d7",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Local LA & California politics",
+ "title": "Museum of Self-Defense",
+ "caption": "This sofa was confiscated during a media event.",
+ "scene": "A hotel room with a golden leopard-print sofa trapped in a glass case labeled 'After the Protest.'",
+ "why": "Parking spots still beat other people to the spotlight.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-19219bf6b7",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Press, speech & dissent",
+ "title": "Word Balloon Seizure",
+ "caption": "I’ll have you know this frown contains subversive air.",
+ "scene": "A man on a park bench, head full of clouds shaped like exploding Popsicles, one balloon floating away with a tiny fireworks trail.",
+ "why": "When free speech gets over-caffeinated.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-9edc35c731",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Environment & energy",
+ "title": "Solar Mayor",
+ "caption": "My forehead absorbs 98% of all sunlight. It’s… efficient.",
+ "scene": "A city leader sitting on a giant chair shaped like a sun, face glowing like a toaster, face half-melted into grins.",
+ "why": "He’s not good at expressing himself—he’s got zero facial muscles left.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-86e86d5284",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-30b-a3b-instruct-2507-4bit",
+ "theme": "Health & medicine",
+ "title": "Syringe Sirens",
+ "caption": "This isn’t medicine—it’s a lullaby from my childhood.",
+ "scene": "A giant syringe with a music box inside it plugged into a sleeping baby, melting into a puddle of glowing jazz notes.",
+ "why": "The cure sounded too sweet to trust.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:35:46.705Z",
+ "id": "arena-4aa4eeec77",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Middle East & foreign policy",
+ "title": "Oil Reserve Yoga Retreat",
+ "caption": "The dunes have been rebranded: 'Unplug, Recharge, Sip Camel Milk.",
+ "scene": "A luxury desert resort with yoga mats on dunes shaped like oil rigs, guests in yoga pants meditating as giant pumps gently squirt mineral water beside them.",
+ "why": "Capitalism has turned geopolitical crisis into an Instagrammable wellness fad.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-4ffcbf8c91",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Local LA & California politics",
+ "title": "Cone of Silence Over Venice Boardwalk",
+ "caption": "For clarity, the city has silenced all skateboarders. The beach is now just wind and sorrow.",
+ "scene": "A huge translucent bubble encircles Venice Beach’s boardwalk, silencing dozens of skateboarders mid-air—each with speech bubbles showing 'WHOOOO!' frozen into ghostly shapes.",
+ "why": "Trying to reduce noise pollution? You're just silencing the only thing keeping LA alive.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-99843bce77",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Environment & energy",
+ "title": "Plastic Bag Walking Through Desert",
+ "caption": "This bag is the last ecosystem. It carries the last tree seed in its fibers.",
+ "scene": "A single plastic grocery bag caught on a cactus in a barren desert, with a tiny seedling growing through its weave while birds perch on it like it’s a monument.",
+ "why": "The plastic bag outlived the forest. Progress is a landfill with roots.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-abe2528559",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Taxes, economy & inequality",
+ "title": "Tax-Deductible Luxury Dreams",
+ "caption": "Your yachts and gold-plated waffle irons are now 'essential economic stimulants.",
+ "scene": "A billionaire in a tuxedo stands on a yacht wearing a tax receipt as a scarf, while a tax auditor in a pinstripe suit writes 'DEDUCTIBLE' on a giant waffle iron with a gold pen.",
+ "why": "If your breakfast utensil is expensive enough, the IRS declares it national infrastructure.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-07bf49050a",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Presidential power & scandal",
+ "title": "Executive Privilege Taco Truck",
+ "caption": "I justauthorized the taco truck to be immune from inspection. Hunger is now a state secret.",
+ "scene": "A president in a tailored suit stands behind a taco truck painted with the presidential seal, while inspectors in lab coats peer through the tortilla veil to see floating enchiladas and a pile of signed executive orders.",
+ "why": "Executive privilege applied to street food? The nation's hunger and laws are equally unexamined.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-a739e890b3",
+ "status": "pending",
+ "status_at": null,
+ "note": "garbled CJK token cleaned"
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Elections & party politics",
+ "title": "Vote-or-Die Voter Drive",
+ "caption": "We’re bribing you to vote with... life insurance. Pay $50, get a payout if you live to see results.",
+ "scene": "A campaign rally where people line up to buy lottery tickets labeled 'Vote Today—Win Death After' while a candidate high-fives a coffin with a campaign banner.",
+ "why": "When democracy requires your own obituary as collateral, you know the system’s on life support.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-78120c9e0c",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Press, speech & dissent",
+ "title": "Free Speech Thermometer",
+ "caption": "Warning: Your tweet caused 0.3°C of warmth in the democratic atmosphere.",
+ "scene": "A lab technician in a lab coat points a thermometer at a laptop screen showing a political tweet—the mercury rises infinitesimally while a sign reads 'DISSENT TEMPERATURE: MINIMAL'",
+ "why": "Free speech is now measured like global warming—your opinions barely warm the system.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-4ad11c6241",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "War & militarism",
+ "title": "Peace Parade Banners",
+ "caption": "We’ve automated our peacekeeping—now our drones hang decorative banners instead of bombs.",
+ "scene": "A field of military drones each holding a giant, fluttering banner that reads 'Wash Your Hands', 'Donate to Literacy', and 'Plant a Tree'—while soldiers stand in awe, hands on hips.",
+ "why": "Militarized pacifism so extreme, even weapons now do yoga instead of bombing.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-e1e2a80f0d",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Labor & unions",
+ "title": "Taco Truck Union Squeeze",
+ "caption": "The union just won a 3% wage increase... for the chili peppers.",
+ "scene": "A union leader in a hard hat holds a picket sign saying 'NO MORE HOT SAUCE POVERTY!' while a pile of jalapeños wear tiny hats and salute in a plaza.",
+ "why": "Even the spices are unionized. Spiciness has rights now.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-82cef14f9e",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Guns & violence",
+ "title": "Self-Defense Umbrella",
+ "caption": "Now you can legally carry a weapon that screams 'I'm not a threat!",
+ "scene": "A woman opens a giant umbrella with a tentacle-like design that sprays confetti and plays 'I Will Survive' while the tag reads 'NON-VIOLENT PROTECTION DEVICE'",
+ "why": "Your defense tool doesn't shoot bullets—it performs a Broadway number.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-09ce1d8c38",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Immigration & borders",
+ "title": "Wall Flower Garden",
+ "caption": "Our border wall now grows bioluminescent cacti. They bloom when you cross.",
+ "scene": "A towering concrete wall covered in glowing cactus blooms with petals shaped like passports, while one flower sprouts from the other side in full color.",
+ "why": "The wall doesn’t stop people—it decorates their courage with light.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-22c13e2c3f",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Space & technology",
+ "title": "Elon's Lunar Toll Road",
+ "caption": "Landing on the moon now requires a PayPal subscription. Bronze tier: no footprints.",
+ "scene": "A man in a spacesuit puts a coin into a vending machine labeled 'Lunar Access—$49.99/mo' while behind him, a lunar lander has a toll booth with a red flag.",
+ "why": "The final frontier is just another subscription service with gas station rest stops.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-4587a44ee6",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Education",
+ "title": "Grading the Gradebook",
+ "caption": "We now grade teachers by how much their students believe they're cute.",
+ "scene": "A teacher at a desk, face blushing, surrounded by sticky notes from students saying 'You're cute' and 'You're cute!!' sticking to the ceiling like constellations.",
+ "why": "Educational performance is now based on emotional attractiveness—reading comprehension is optional.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-4caadf0f51",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Religion & politics",
+ "title": "Prayer Plug-In",
+ "caption": "Your morning devotion now requires a 15-minute USB timeout.",
+ "scene": "A person sits cross-legged on a chair, plugged into a wall socket with wires leading to a glowing Bible labeled 'Spiritual Power Pack - Charge Required: 15 min'",
+ "why": "Faith now comes with a power adapter. Divine energy is measured in watts.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-16540ee016",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-next-80b-a3b-instruct-4bit",
+ "theme": "Corruption & money in politics",
+ "title": "Lobbyist Dishwasher",
+ "caption": "Your lobbyist’s bribe? Washed and sanitized—'mandatory compliance rinse",
+ "scene": "A giant industrial dishwasher labeled 'LEGISLATIVE CLEANSING' spins with cash, gilded pens, and a small statue of Lady Justice—exiting spotless and gleaming.",
+ "why": "Corruption isn't hidden anymore—it’s cleaned with a rinse cycle and labeled 'compliant'.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:40:25.129Z",
+ "id": "arena-3f02cc6ed7",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Presidential Power & Scandal",
+ "title": "Dream Team Debate",
+ "caption": "A debate where candidates compete for a dream job.",
+ "scene": "Candidates in silly suits, facing a bed of pillows.",
+ "why": "Silly clothing and mattress metaphor.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-0487280d2a",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Civil Rights & Race",
+ "title": "Color Blindness Test",
+ "caption": "A test to see who's really blind.",
+ "scene": "People with bandages on their eyes, voting in a booth.",
+ "why": "Blindfolds over eyes and voting suggests irony.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-454a38746b",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Nuclear Arms & the Cold War",
+ "title": "Atomic Picnic",
+ "caption": "A nuclear family gathers for a cookout.",
+ "scene": "Families around an atomic bomb as a grill.",
+ "why": "Nuclear bomb serving as food and metaphor for danger in leisure.",
+ "drawability": 2,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-79c416e9ee",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Courts, Law & Justice",
+ "title": "Jury of the Future",
+ "caption": "A jury made up entirely of robots.",
+ "scene": "Robots in a courtroom with gavels and chairs.",
+ "why": "Irony of modern tech deciding human cases.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-50f67704a0",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Elections & Party Politics",
+ "title": "The Great Debate",
+ "caption": "Politicians debate over who is better at drinking coffee.",
+ "scene": "Politicians in a café setting, with cups of coffee.",
+ "why": "Humorous comparison to serious issues through triviality.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-06e78f772f",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Taxes, Economy & Inequality",
+ "title": "Taxing the Rich",
+ "caption": "Billionaires donating their million-dollar bills.",
+ "scene": "Billionaires handing out one-dollar bills to others.",
+ "why": "Irony of wealth disparity using money quantity.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-5cad7901a3",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Local LA & California Politics",
+ "title": "LA’s Water Crisis",
+ "caption": "Pizzas float on water.",
+ "scene": "Pizza slices floating in a lake.",
+ "why": "Irony of pizza needing water in a drought.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-7662bcadb4",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Press, Speech & Dissent",
+ "title": "The Press Is Papercut",
+ "caption": "Journalists bite their tongues to share the truth.",
+ "scene": "Journalists biting their tongue.",
+ "why": "Irony of journalists speaking with difficulty due to censorship.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-23334dbc53",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Environment & Energy",
+ "title": "The Great Recycling Experiment",
+ "caption": "Recycling bins turn into basketballs.",
+ "scene": "Recycling bins used as balls in a game.",
+ "why": "Irony of recycling materials taking on unexpected forms.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-e1b4ef30c6",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Health & Medicine",
+ "title": "The Flu Vaccination Ceremony",
+ "caption": "People receiving flu shots from Santa.",
+ "scene": "Santa Claus giving out flu shots.",
+ "why": "Irony of holiday jollity in a medical setting.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-37af83038f",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen25-7b",
+ "theme": "Labor & Unions",
+ "title": "Unionized Construction",
+ "caption": "A construction site with talking tools.",
+ "scene": "Tools holding a meeting.",
+ "why": "Irony of inanimate objects having discussions.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:34:14.807Z",
+ "id": "arena-6f6fefd9be",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Presidential power & scandal",
+ "title": "The Oath of Silence",
+ "caption": "He promised to be transparent, then signed a moat.",
+ "scene": "A figure in a suit stands on a raised platform, surrounded by a moat of floating paper towels.",
+ "why": "The absurdity of a presidential promise being nullified by a moat.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-ee45194341",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "War & militarism",
+ "title": "Drone Diplomacy",
+ "caption": "Peace talks are being held with a swarm of flying calculators.",
+ "scene": "A group of generals and diplomats sitting around a table, with tiny drones hovering above them like birds.",
+ "why": "The absurdity of using autonomous machines for diplomacy.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-7e4bfdc421",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Civil rights & race",
+ "title": "The Rainbow Filter",
+ "caption": "He sees everything in Technicolor — except the truth.",
+ "scene": "A person wearing sunglasses that make everything around them look like a cartoon, but their face remains monochrome.",
+ "why": "The irony of a leader's selective perception.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-f0d13cbadb",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Nuclear arms & the Cold War",
+ "title": "Atomic Cupcakes",
+ "caption": "A treaty is being baked — in a nuclear oven.",
+ "scene": "Two generals sitting at a kitchen table, baking cupcakes with radioactive sprinkles.",
+ "why": "The absurdity of nuclear weapons being made into desserts.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-4bdeff9664",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Middle East & foreign policy",
+ "title": "The Olive Branch",
+ "caption": "He planted the olive tree — then patented the fruit.",
+ "scene": "A man in a suit watering an olive tree, holding a patent certificate with the tree on it.",
+ "why": "The irony of a peace symbol being monopolized.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-2b4600059d",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Courts, law & justice",
+ "title": "The Verdict of the Cat",
+ "caption": "The cat was the only witness — and it refused to testify.",
+ "scene": "A judge presiding over a trial with a cat sitting on the bench, smugly licking its paws.",
+ "why": "The futility of an unreliable witness in a serious court.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-13eadea06e",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Elections & party politics",
+ "title": "The Vote Vortex",
+ "caption": "Voters are being sucked into a black hole of misinformation.",
+ "scene": "A vortex of newspaper headlines and screaming voters being pulled into a black hole.",
+ "why": "The chaos of election misinformation.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-f5bb46d62d",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Taxes, economy & inequality",
+ "title": "The Tax Tornado",
+ "caption": "The rich are spinning in circles — but not from the tax code.",
+ "scene": "A wealthy man spinning in a tornado made of cash, surrounded by people outside the storm.",
+ "why": "The absurdity of the wealthy being caught in their own wealth.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-7ce61ec660",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Space & technology",
+ "title": "The Mars Memo",
+ "caption": "He signed the Moon Treaty — while still on Earth.",
+ "scene": "A man holding a pen, signing a document that shows a moon on one side and Earth on the other.",
+ "why": "The hypocrisy of making interplanetary treaties from Earth.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-0afd727d0d",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Environment & energy",
+ "title": "The Green Bubble",
+ "caption": "He promised to be green — then inflated a bubble made of oil.",
+ "scene": "A figure in a green suit holding a bubble filled with black oil, smiling.",
+ "why": "The irony of making environmental promises with the wrong resource.",
+ "drawability": 4,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-2230769742",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Health & medicine",
+ "title": "The Pill of Perpetual Optimism",
+ "caption": "Take one pill a day — and forget your problems.",
+ "scene": "A doctor handing a pill shaped like a smiley face to a patient who's crying.",
+ "why": "The absurdity of a solution that's just a placebo.",
+ "drawability": 5,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-fa87e9e5b6",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "model": "qwen3-14b",
+ "theme": "Education",
+ "title": "The Exam of Emptiness",
+ "caption": "He passed the test — but the test was empty.",
+ "scene": "A student sitting in an empty classroom, smiling at a completely blank exam sheet.",
+ "why": "The irony of passing a test with no content.",
+ "drawability": 3,
+ "created_at": "2026-09-25T17:33:23.252Z",
+ "id": "arena-ddb5902602",
+ "status": "pending",
+ "status_at": null
+ }
+ ]
+}
diff --git a/data/shadowman.json b/data/shadowman.json
new file mode 100644
index 0000000..f044e34
--- /dev/null
+++ b/data/shadowman.json
@@ -0,0 +1,394 @@
+{
+ "generated_at": "2026-09-25T17:58:40.213Z",
+ "note": "Original single-panel editorial cartoons written from archive themes and eras (never a redraw of a specific published cartoon), rendered locally with SDXL at $0 and signed \"Shadow Man\".",
+ "counts": {
+ "briefs": 20,
+ "rendered": 43,
+ "kept": 13,
+ "dropped": 30
+ },
+ "dropped": [
+ {
+ "id": "shadowman-20260925T172534-001",
+ "reason": "gag missing: generic shop counter, no pill/pillow/price"
+ },
+ {
+ "id": "shadowman-20260925T172534-002",
+ "reason": "gag missing: man at desk, no hiding/lobbyist"
+ },
+ {
+ "id": "shadowman-20260925T172534-003",
+ "reason": "gag missing: surreal ruin, no eraser/newspaper"
+ },
+ {
+ "id": "shadowman-20260925T172534-004",
+ "reason": "gag missing: courtroom, no price-tag gavel; fake painted signature bottom-right"
+ },
+ {
+ "id": "shadowman-20260925T172534-006",
+ "reason": "gag missing: oath with bible but no hand in donor pocket"
+ },
+ {
+ "id": "shadowman-20260925T172534-007",
+ "reason": "gag missing: podium occupied, no candle/folded flag"
+ },
+ {
+ "id": "shadowman-20260925T172534-010",
+ "reason": "gag missing: decrepit schoolhouse only, no stadium/quicksand"
+ },
+ {
+ "id": "shadowman-20260925T172534-012",
+ "reason": "gag missing: elephant vs elephant-man, no donkey/voter"
+ },
+ {
+ "id": "shadowman-20260925T172534-014",
+ "reason": "gag missing: masked woman in crowd, does not read as Justice peeking"
+ },
+ {
+ "id": "shadowman-20260925T172534-015",
+ "reason": "garbage lettering on the slot machine"
+ },
+ {
+ "id": "shadowman-20260925T172534-016",
+ "reason": "gag missing: house-boat at sea, no leak/bailing"
+ },
+ {
+ "id": "shadowman-20260925T172534-017",
+ "reason": "gag missing: kissing a woman, no baby/pickpocket"
+ },
+ {
+ "id": "shadowman-20260925T172534-018",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ },
+ {
+ "id": "shadowman-20260925T172534-019",
+ "reason": "gag missing: rope knot over a maze, no politician"
+ },
+ {
+ "id": "shadowman-20260925T172534-001-r1",
+ "reason": "garbage lettering on the cushion object; no price/single pill"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r1",
+ "reason": "garbage fake headline lettering across the newspaper"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r1",
+ "reason": "garbage lettering on the price sign (\"PRICE PIAIG\")"
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r1",
+ "reason": "gag missing: raised hand only, no donor pocket"
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r1",
+ "reason": "gag missing: speaker at podium, no candle/flag"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r1",
+ "reason": "gag missing: beach house on sand, no stadium/quicksand"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r1",
+ "reason": "gag missing: Lady Justice without blindfold, not peeking"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r1",
+ "reason": "garbage lettering on the slot machine reels"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r1",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ },
+ {
+ "id": "shadowman-20260925T172534-001-r2",
+ "reason": "gag unclear: pills read as gemstones in a jewelry case"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r2",
+ "reason": "gag missing: crowd portrait, no eraser/photo"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r2",
+ "reason": "gag missing: robed speaker, no auction/bidders"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r2",
+ "reason": "gag missing: stadium only, no schoolhouse"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r2",
+ "reason": "gag missing: Justice with scales, no blindfold peek"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r2",
+ "reason": "gag unclear: man with a box of coins, no slot-machine ballot box/lever"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r2",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ }
+ ],
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-005",
+ "title": "Ribbon-Cutting at the Pothole",
+ "caption": "\"Another infrastructure milestone.\"",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "scene": "a mayor cutting a ribbon on a pothole, 1960s props, skinny ties and television antennas",
+ "created_at": "2026-09-25T17:45:59+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 134093824,
+ "gen_seconds": 25.9,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-005.jpg",
+ "review_note": "PASS (weak): officials + press camera ceremonially gathered at a pothole; ribbon not drawn",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-008",
+ "title": "Paper Mountain",
+ "caption": "\"Nothing to see here.\"",
+ "theme": "Presidential power & scandal",
+ "era": "1960s",
+ "scene": "a president shredding documents that pile up into a mountain spelling out the very thing he denies, 1960s props, skinny ties and television antennas",
+ "created_at": "2026-09-25T17:46:24+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 856879872,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-008.jpg",
+ "review_note": "PASS: official at his desk beside a mountain of shredded/stacked paper",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-009",
+ "title": "Traffic Relief",
+ "caption": "\"Traffic relief is on the way.\"",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "scene": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below, 1960s props, skinny ties and television antennas",
+ "created_at": "2026-09-25T17:46:50+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1236883200,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-009.jpg",
+ "review_note": "PASS (weak): total gridlock under an overpass carries the caption irony; floating city hall not drawn",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-011",
+ "title": "The Money Tank",
+ "caption": "\"Peace is expensive. War is more profitable.\"",
+ "theme": "War & militarism",
+ "era": "1960s",
+ "scene": "a giant war machine made of dollar bills, rolling over a tiny olive branch, 1960s props, skinny ties and television antennas",
+ "created_at": "2026-09-25T17:47:15+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 561833088,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-011.jpg",
+ "review_note": "PASS: a tank built of dollar bills rolling over a field of cash",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-013",
+ "title": "The Marionette Chamber",
+ "caption": "\"Democracy in action.\"",
+ "theme": "Corruption & money in politics",
+ "era": "2020s",
+ "scene": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs, contemporary props, smartphones and livestream cameras",
+ "created_at": "2026-09-25T17:47:41+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1081563136,
+ "gen_seconds": 25.2,
+ "cost_usd": 0,
+ "source": "theme-bank+today-headline-tags",
+ "src": "/shadowman/shadowman-20260925T172534-013.jpg",
+ "review_note": "PASS: a legislative chamber seated with marionettes on strings",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-020",
+ "title": "Passing the Plate",
+ "caption": "\"Tithing, bipartisan.\"",
+ "theme": "Religion & politics",
+ "era": "1970s",
+ "scene": "a collection plate being passed around a legislative chamber, 1970s props, wide lapels and gas lines",
+ "created_at": "2026-09-25T17:48:06+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 221318144,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-020.jpg",
+ "review_note": "PASS (weak): portly legislator passing a collection jar along the chamber benches",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-002-r1",
+ "title": "Good Boy",
+ "caption": "\"Good boy.\"",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "scene": "a frightened politician hiding under his office desk while a smiling lobbyist in a suit pats his head like a dog",
+ "created_at": "2026-09-25T17:48:31+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 163015837,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-002-r1.jpg",
+ "review_note": "PASS: grinning lobbyist patting a defeated lawmaker slumped at his desk",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-012-r1",
+ "title": "Party Arm-Wrestle",
+ "caption": "\"May the best party win.\"",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "scene": "an elephant and a donkey arm-wrestling on a table, a tiny ordinary man squashed flat under the table",
+ "created_at": "2026-09-25T17:48:57+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1688326547,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank+today-headline-tags",
+ "src": "/shadowman/shadowman-20260925T172534-012-r1.jpg",
+ "review_note": "PASS: elephant and donkey locked in an arm-wrestle across a table (voter underneath not drawn)",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-016-r1",
+ "title": "Ship of State",
+ "caption": "\"There is no leak. Next question.\"",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "scene": "the White House drawn as a sinking ship in a stormy sea, men in suits bailing water out with teacups",
+ "created_at": "2026-09-25T17:49:22+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1415033303,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank+today-headline-tags",
+ "src": "/shadowman/shadowman-20260925T172534-016-r1.jpg",
+ "review_note": "PASS (weak): officials crowding a storm-tossed ship of state; bailing not drawn",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-017-r1",
+ "title": "The Baby Kisser",
+ "caption": "\"Family values.\"",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "scene": "a smiling politician kissing a baby on the cheek while his other hand lifts the wallet from the mother's purse",
+ "created_at": "2026-09-25T17:49:47+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1160219923,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank+today-headline-tags",
+ "src": "/shadowman/shadowman-20260925T172534-017-r1.jpg",
+ "review_note": "PASS: beaming politician cuddling a baby with a fistful of cash",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-019-r1",
+ "title": "The Pretzel District",
+ "caption": "\"My constituents love me.\"",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "scene": "a politician tied up inside a giant twisted pretzel shaped like a map",
+ "created_at": "2026-09-25T17:50:13+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 813426368,
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "source": "theme-bank+today-headline-tags",
+ "src": "/shadowman/shadowman-20260925T172534-019-r1.jpg",
+ "review_note": "PASS: a politician stuck inside a giant twisted pretzel knot",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r2",
+ "title": "The Donor Oath",
+ "caption": "\"So help me, donors.\"",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "scene": "a politician swearing an oath with one hand raised while a fat rich man behind him slips cash into his back pocket",
+ "created_at": "2026-09-25T17:52:15+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 569407856,
+ "gen_seconds": 25.4,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-006-r2.jpg",
+ "review_note": "PASS: politician taking the oath with a hand full of cash, a donor at his side, money on the table",
+ "status": "pending",
+ "status_at": null
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r2",
+ "title": "The Empty Stage",
+ "caption": "\"The silence says it.\"",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "scene": "an empty wooden podium alone on a dark stage with a single burning candle on top and a folded flag beside it",
+ "created_at": "2026-09-25T17:52:46+00:00",
+ "signature": "Shadow Man",
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "seed": 1606107603,
+ "gen_seconds": 30.5,
+ "cost_usd": 0,
+ "source": "theme-bank",
+ "src": "/shadowman/shadowman-20260925T172534-007-r2.jpg",
+ "review_note": "PASS: a single memorial candle alone on an empty stage (podium/flag not drawn)",
+ "status": "pending",
+ "status_at": null
+ }
+ ]
+}
diff --git a/generator/compose-prompts.mjs b/generator/compose-prompts.mjs
index 1945451..2ee0206 100644
--- a/generator/compose-prompts.mjs
+++ b/generator/compose-prompts.mjs
@@ -18,6 +18,10 @@
// (date-seeded deterministic shuffle so a re-run for the same seed is idempotent).
//
// Usage: node generator/compose-prompts.mjs [--count=20] [--seed=YYYY-MM-DD] [--out=generator/out/batch.json]
+// node generator/compose-prompts.mjs --from-queue [--count=20] [--seed=...]
+// --from-queue: build the batch from generator/idea-queue.json (Model Arena ideas Steve approved in
+// Inkwell) instead of the scene library; marks each used entry batched_at so it is not re-queued.
+// Output defaults to generator/out/batch-queue-<seed>.json. Same BANNED guard.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -25,7 +29,7 @@ import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, '..');
const NEWS = path.join(process.env.HOME, 'Projects', 'crazy-news-channel', 'real-news-data.js');
-const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a, true]; }));
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]; }));
const COUNT = parseInt(args.count || '20', 10);
const SEED = args.seed || new Date().toISOString().slice(0, 10);
const OUT = path.resolve(ROOT, args.out || 'generator/out/batch.json');
@@ -160,7 +164,29 @@ function loadNewsThemes(themeNames) {
} catch { return null; }
}
+function fromQueue() {
+ const QUEUE = path.join(ROOT, 'generator', 'idea-queue.json');
+ const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { items: [] };
+ const todo = q.items.filter(b => !b.batched_at).slice(0, COUNT);
+ if (!todo.length) { console.log('idea-queue: nothing un-batched'); return; }
+ const out = path.resolve(ROOT, args.out || `generator/out/batch-queue-${SEED}.json`);
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15);
+ const items = todo.map((b, i) => {
+ const prompt = `${b.scene.replace(/\.$/, '')}. ${STYLE}`;
+ if (BANNED.test(prompt) || BANNED.test(b.caption)) throw new Error(`banned term in queued idea ${b.id}`);
+ const caption = /^["“]/.test(b.caption) ? b.caption : `"${b.caption}"`;
+ return { id: `shadowman-${stamp}-q${String(i + 1).padStart(3, "0")}`, theme: b.theme, era: b.era || "contemporary", source: b.source, idea_id: b.id, title: b.title, scene: b.scene, caption, prompt, seed: Math.floor(rand() * 2 ** 31) };
+ });
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ fs.writeFileSync(out, JSON.stringify({ generated_at: new Date().toISOString(), seed: SEED, from_queue: true, items }, null, 2));
+ const now = new Date().toISOString();
+ for (const b of todo) { b.batched_at = now; b.batch = path.relative(ROOT, out); }
+ fs.writeFileSync(QUEUE, JSON.stringify(q, null, 2) + '\n');
+ console.log(`Composed ${items.length} briefs from idea-queue -> ${path.relative(ROOT, out)}`);
+}
+
function main() {
+ if (args['from-queue']) return fromQueue();
const bank = JSON.parse(fs.readFileSync(path.join(ROOT, 'research', 'theme-bank.json'), 'utf8'));
const themeNames = Object.keys(bank.themes).filter(t => SCENES[t]);
const news = loadNewsThemes(themeNames);
diff --git a/generator/gen_shadowman.py b/generator/gen_shadowman.py
index 21f4849..9a319bf 100644
--- a/generator/gen_shadowman.py
+++ b/generator/gen_shadowman.py
@@ -83,16 +83,14 @@ def wrap(draw, text, font, width):
return lines
-def finish(img, caption):
- """Ink post-process + caption band + Shadow Man signature."""
- from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance
- g = ImageOps.grayscale(img)
- g = ImageEnhance.Contrast(g).enhance(1.6)
- g = g.point(lambda p: 255 if p > 200 else (0 if p < 45 else p)) # push toward ink/paper
- art = g.convert("RGB")
- W, H = art.size
+BOTTOM_CROP = 0.06
+
+
+def sign(art):
+ """Stamp the "Shadow Man" signature lower-right on the art, in place."""
+ from PIL import ImageDraw, ImageFont
d = ImageDraw.Draw(art)
- # signature: lower-right corner of the drawing, ink-style hand lettering on a paper patch
+ W, H = art.size
sf = ImageFont.truetype(FONT_SIG, max(30, W // 26))
sw = d.textlength(SIGNATURE, font=sf)
bbox = d.textbbox((0, 0), SIGNATURE, font=sf)
@@ -101,6 +99,23 @@ def finish(img, caption):
d.rectangle([x - 12, y - 6, x + sw + 12, y + sh + 16], fill=(255, 255, 255))
d.text((x, y - bbox[1]), SIGNATURE, font=sf, fill=(10, 10, 10))
d.line([x, y + sh + 8, x + sw, y + sh + 4], fill=(10, 10, 10), width=3) # brush underline
+
+
+def finish(img, caption):
+ """Ink post-process + caption band + Shadow Man signature."""
+ from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance
+ g = ImageOps.grayscale(img)
+ g = ImageEnhance.Contrast(g).enhance(1.6)
+ g = g.point(lambda p: 255 if p > 200 else (0 if p < 45 else p)) # push toward ink/paper
+ art = g.convert("RGB")
+ # bottom crop (TK-12241): SDXL often paints a fake scribbled "artist signature" along the very
+ # bottom edge of the drawing (bottom-left or bottom-right). Trim the bottom 6% of the frame so
+ # the only signature on the piece is "Shadow Man" (painting the corners white left visible
+ # blocks on dark scenes, so it is cropped instead).
+ art = art.crop((0, 0, art.size[0], int(art.size[1] * (1 - BOTTOM_CROP))))
+ W, H = art.size
+ d = ImageDraw.Draw(art)
+ sign(art)
# caption band under the panel
cf = ImageFont.truetype(FONT_CAPTION, max(26, W // 34))
lines = wrap(d, caption, cf, W - 80)
@@ -121,6 +136,7 @@ def main():
ap.add_argument("batch")
ap.add_argument("--limit", type=int, default=20)
ap.add_argument("--steps", type=int, default=30)
+ ap.add_argument("--force", action="store_true", help="re-render every brief in the batch, replacing its manifest row")
a = ap.parse_args()
items = json.load(open(a.batch))["items"][: a.limit]
@@ -130,7 +146,8 @@ def main():
os.makedirs(OUT, exist_ok=True)
man = load_manifest()
done = {m["id"] for m in man["items"]}
- todo = [it for it in items if it["id"] not in done and not os.path.exists(os.path.join(OUT, it["id"] + ".png"))]
+ todo = items if a.force else [it for it in items if it["id"] not in done and not os.path.exists(os.path.join(OUT, it["id"] + ".png"))]
+ os.makedirs(os.path.join(OUT, "raw"), exist_ok=True)
print(f"{len(items)} briefs, {len(todo)} to generate (limit {a.limit})", flush=True)
if not todo:
return 0
@@ -158,18 +175,21 @@ def main():
print(f"ABORT before {it['id']}: {det}", flush=True)
return 3
t0 = time.time()
- img = pipe(prompt=it["prompt"], negative_prompt=NEGATIVE, width=1152, height=896,
+ neg = NEGATIVE + (", " + it["negative_extra"] if it.get("negative_extra") else "")
+ img = pipe(prompt=it["prompt"], negative_prompt=neg, width=1152, height=896,
num_inference_steps=a.steps, guidance_scale=7.0,
generator=torch.Generator("cpu").manual_seed(int(it["seed"]))).images[0]
+ img.save(os.path.join(OUT, "raw", it["id"] + ".png")) # raw SDXL frame, so finish() can be re-run
final = finish(img, it["caption"])
final.save(os.path.join(OUT, it["id"] + ".png"), optimize=True)
secs = round(time.time() - t0, 1)
+ man["items"] = [m for m in man["items"] if m["id"] != it["id"]]
man["items"].append({
"id": it["id"], "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"theme": it["theme"], "era": it["era"], "caption": it["caption"], "prompt": it["prompt"],
- "negative_prompt": NEGATIVE, "seed": it["seed"], "model": MODEL, "steps": a.steps,
+ "negative_prompt": neg, "seed": it["seed"], "model": MODEL, "steps": a.steps,
"signature": SIGNATURE, "source": it.get("source"), "gen_seconds": secs, "cost_usd": 0,
- "file": it["id"] + ".png",
+ "file": it["id"] + ".png", "bottom_crop": BOTTOM_CROP, "retry_of": it.get("retry_of"),
})
save_manifest(man)
print(f"[{i}/{len(todo)}] {it['id']} {secs}s {det}", flush=True)
diff --git a/generator/out/batch-r1.json b/generator/out/batch-r1.json
new file mode 100644
index 0000000..9680ec6
--- /dev/null
+++ b/generator/out/batch-r1.json
@@ -0,0 +1,160 @@
+{
+ "generated_at": "2026-09-25T17:25:34.202Z",
+ "retry": "r1",
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-001-r1",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a pharmacy counter with a single pill on a velvet pillow priced like a diamond",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "one small white pill resting on a velvet cushion under a glass dome with a huge price tag, pharmacy counter. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1169055087,
+ "retry_of": "shadowman-20260925T172534-001"
+ },
+ {
+ "id": "shadowman-20260925T172534-002-r1",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head",
+ "caption": "\"Good boy.\"",
+ "prompt": "a frightened politician hiding under his office desk while a smiling lobbyist in a suit pats his head like a dog. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 163015837,
+ "retry_of": "shadowman-20260925T172534-002"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r1",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a giant eraser the size of a man rubbing out the headline of a huge newspaper. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1258537642,
+ "retry_of": "shadowman-20260925T172534-003"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r1",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a judge with a gavel shaped like a price tag",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a stern judge in black robes holding up a gavel shaped like a giant price tag. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1478486398,
+ "retry_of": "shadowman-20260925T172534-004"
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r1",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a politician taking an oath with one hand on a bible and the other hand behind his back in a donor's pocket",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician raising his right hand to take an oath while his left hand reaches into a rich man's coat pocket. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 614235297,
+ "retry_of": "shadowman-20260925T172534-006"
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r1",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "an empty podium with a single lit candle and a folded flag, in solemn quiet",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty speaker's podium on a bare stage with a single lit candle and a folded flag resting on it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 195767965,
+ "retry_of": "shadowman-20260925T172534-007"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r1",
+ "theme": "Education",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door",
+ "caption": "\"Go team.\"",
+ "prompt": "a small schoolhouse sinking into quicksand next to a giant shiny sports stadium. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 2069252085,
+ "retry_of": "shadowman-20260925T172534-010"
+ },
+ {
+ "id": "shadowman-20260925T172534-012-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table",
+ "caption": "\"May the best party win.\"",
+ "prompt": "an elephant and a donkey arm-wrestling on a table, a tiny ordinary man squashed flat under the table. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1688326547,
+ "retry_of": "shadowman-20260925T172534-012"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r1",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a statue of Justice peeking under her blindfold at the people in front of her",
+ "caption": "\"Just checking.\"",
+ "prompt": "a stone statue of Lady Justice holding scales, lifting the edge of her blindfold with one finger to peek. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 2034622770,
+ "retry_of": "shadowman-20260925T172534-014"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a ballot box drawn as a slot machine, a candidate pulling the lever",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a ballot box built like a slot machine with a big lever, a politician in a suit pulling the lever. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 50646513,
+ "retry_of": "shadowman-20260925T172534-015"
+ },
+ {
+ "id": "shadowman-20260925T172534-016-r1",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry",
+ "caption": "\"There is no leak. Next question.\"",
+ "prompt": "the White House drawn as a sinking ship in a stormy sea, men in suits bailing water out with teacups. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1415033303,
+ "retry_of": "shadowman-20260925T172534-016"
+ },
+ {
+ "id": "shadowman-20260925T172534-017-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a candidate kissing a baby while picking the baby's pocket",
+ "caption": "\"Family values.\"",
+ "prompt": "a smiling politician kissing a baby on the cheek while his other hand lifts the wallet from the mother's purse. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1160219923,
+ "retry_of": "shadowman-20260925T172534-017"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r1",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a gigantic rubber stamp crashing down on top of a small Capitol dome, cracking it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1914549874,
+ "retry_of": "shadowman-20260925T172534-018"
+ },
+ {
+ "id": "shadowman-20260925T172534-019-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a gerrymandered district map shaped like a pretzel with a politician tied up inside it",
+ "caption": "\"My constituents love me.\"",
+ "prompt": "a politician tied up inside a giant twisted pretzel shaped like a map. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 813426368,
+ "retry_of": "shadowman-20260925T172534-019"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/generator/out/batch-r2.json b/generator/out/batch-r2.json
new file mode 100644
index 0000000..a4f21e6
--- /dev/null
+++ b/generator/out/batch-r2.json
@@ -0,0 +1,106 @@
+{
+ "generated_at": "2026-09-25T17:25:34.202Z",
+ "retry": "r2",
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-001-r2",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a pharmacy counter with a single pill on a velvet pillow priced like a diamond",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "a single tiny pill displayed on a plush velvet pillow inside a jewelry store glass case, a jeweler peering at it through a loupe. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 374066636,
+ "retry_of": "shadowman-20260925T172534-001"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r2",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a smug official using a huge eraser to rub people out of a large group photograph pinned to a wall. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1345436888,
+ "retry_of": "shadowman-20260925T172534-003"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r2",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a judge with a gavel shaped like a price tag",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a judge in black robes at the courtroom bench acting as an auctioneer, rich men in the gallery holding up bags of money. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 717574595,
+ "retry_of": "shadowman-20260925T172534-004"
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r2",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a politician taking an oath with one hand on a bible and the other hand behind his back in a donor's pocket",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician swearing an oath with one hand raised while a fat rich man behind him slips cash into his back pocket. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 569407856,
+ "retry_of": "shadowman-20260925T172534-006"
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r2",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "an empty podium with a single lit candle and a folded flag, in solemn quiet",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty wooden podium alone on a dark stage with a single burning candle on top and a folded flag beside it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1606107603,
+ "retry_of": "shadowman-20260925T172534-007",
+ "negative_extra": "person, man, woman, speaker, crowd"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r2",
+ "theme": "Education",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door",
+ "caption": "\"Go team.\"",
+ "prompt": "a tiny crumbling one-room schoolhouse dwarfed by a colossal gleaming football stadium right next to it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1956924414,
+ "retry_of": "shadowman-20260925T172534-010"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r2",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a statue of Justice peeking under her blindfold at the people in front of her",
+ "caption": "\"Just checking.\"",
+ "prompt": "a statue of Lady Justice wearing a blindfold, pushing the blindfold up with one finger to peek at the viewer. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1000462233,
+ "retry_of": "shadowman-20260925T172534-014"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r2",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a ballot box drawn as a slot machine, a candidate pulling the lever",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a politician in a suit yanking the big lever on the side of a giant ballot box, coins spilling out of it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1112861726,
+ "retry_of": "shadowman-20260925T172534-015"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r2",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a gigantic wooden rubber stamp smashing down onto the dome of the Capitol building. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1859527368,
+ "retry_of": "shadowman-20260925T172534-018"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/generator/out/batch-refinish.json b/generator/out/batch-refinish.json
new file mode 100644
index 0000000..0d7d87a
--- /dev/null
+++ b/generator/out/batch-refinish.json
@@ -0,0 +1,120 @@
+{
+ "note": "re-render of reviewed PASS pieces with the bottom-crop finish (same prompt + seed) \u2014 TK-12241",
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-005",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a mayor cutting a ribbon on a pothole",
+ "caption": "\"Another infrastructure milestone.\"",
+ "prompt": "a mayor cutting a ribbon on a pothole, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 134093824
+ },
+ {
+ "id": "shadowman-20260925T172534-008",
+ "theme": "Presidential power & scandal",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a president shredding documents that pile up into a mountain spelling out the very thing he denies",
+ "caption": "\"Nothing to see here.\"",
+ "prompt": "a president shredding documents that pile up into a mountain spelling out the very thing he denies, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 856879872
+ },
+ {
+ "id": "shadowman-20260925T172534-009",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below",
+ "caption": "\"Traffic relief is on the way.\"",
+ "prompt": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1236883200
+ },
+ {
+ "id": "shadowman-20260925T172534-011",
+ "theme": "War & militarism",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a giant war machine made of dollar bills, rolling over a tiny olive branch",
+ "caption": "\"Peace is expensive. War is more profitable.\"",
+ "prompt": "a giant war machine made of dollar bills, rolling over a tiny olive branch, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 561833088
+ },
+ {
+ "id": "shadowman-20260925T172534-013",
+ "theme": "Corruption & money in politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs",
+ "caption": "\"Democracy in action.\"",
+ "prompt": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1081563136
+ },
+ {
+ "id": "shadowman-20260925T172534-020",
+ "theme": "Religion & politics",
+ "era": "1970s",
+ "source": "theme-bank",
+ "scene": "a collection plate being passed around a legislative chamber",
+ "caption": "\"Tithing, bipartisan.\"",
+ "prompt": "a collection plate being passed around a legislative chamber, 1970s props, wide lapels and gas lines. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 221318144
+ },
+ {
+ "id": "shadowman-20260925T172534-002-r1",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head",
+ "caption": "\"Good boy.\"",
+ "prompt": "a frightened politician hiding under his office desk while a smiling lobbyist in a suit pats his head like a dog. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 163015837,
+ "retry_of": "shadowman-20260925T172534-002"
+ },
+ {
+ "id": "shadowman-20260925T172534-012-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table",
+ "caption": "\"May the best party win.\"",
+ "prompt": "an elephant and a donkey arm-wrestling on a table, a tiny ordinary man squashed flat under the table. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1688326547,
+ "retry_of": "shadowman-20260925T172534-012"
+ },
+ {
+ "id": "shadowman-20260925T172534-016-r1",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry",
+ "caption": "\"There is no leak. Next question.\"",
+ "prompt": "the White House drawn as a sinking ship in a stormy sea, men in suits bailing water out with teacups. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1415033303,
+ "retry_of": "shadowman-20260925T172534-016"
+ },
+ {
+ "id": "shadowman-20260925T172534-017-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a candidate kissing a baby while picking the baby's pocket",
+ "caption": "\"Family values.\"",
+ "prompt": "a smiling politician kissing a baby on the cheek while his other hand lifts the wallet from the mother's purse. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1160219923,
+ "retry_of": "shadowman-20260925T172534-017"
+ },
+ {
+ "id": "shadowman-20260925T172534-019-r1",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a gerrymandered district map shaped like a pretzel with a politician tied up inside it",
+ "caption": "\"My constituents love me.\"",
+ "prompt": "a politician tied up inside a giant twisted pretzel shaped like a map. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 813426368,
+ "retry_of": "shadowman-20260925T172534-019"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/generator/out/batch.json b/generator/out/batch.json
new file mode 100644
index 0000000..de513d1
--- /dev/null
+++ b/generator/out/batch.json
@@ -0,0 +1,215 @@
+{
+ "generated_at": "2026-09-25T17:59:37.716Z",
+ "seed": "2026-09-25",
+ "news_blend": {
+ "stories": 42,
+ "theme_counts": {
+ "Immigration & borders": 1,
+ "Elections & party politics": 3,
+ "Corruption & money in politics": 3,
+ "Presidential power & scandal": 1
+ }
+ },
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-001",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a pharmacy counter with a single pill on a velvet pillow priced like a diamond",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "a pharmacy counter with a single pill on a velvet pillow priced like a diamond, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 488394240
+ },
+ {
+ "id": "shadowman-20260925T172534-002",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head",
+ "caption": "\"Good boy.\"",
+ "prompt": "a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1287429432
+ },
+ {
+ "id": "shadowman-20260925T172534-003",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 920001536
+ },
+ {
+ "id": "shadowman-20260925T172534-004",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a judge with a gavel shaped like a price tag",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a judge with a gavel shaped like a price tag, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1539447808
+ },
+ {
+ "id": "shadowman-20260925T172534-005",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a mayor cutting a ribbon on a pothole",
+ "caption": "\"Another infrastructure milestone.\"",
+ "prompt": "a mayor cutting a ribbon on a pothole, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 134093824
+ },
+ {
+ "id": "shadowman-20260925T172534-006",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a politician taking an oath with one hand on a bible and the other hand behind his back in a donor's pocket",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician taking an oath with one hand on a bible and the other hand behind his back in a donor's pocket, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1417327360
+ },
+ {
+ "id": "shadowman-20260925T172534-007",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "an empty podium with a single lit candle and a folded flag, in solemn quiet",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty podium with a single lit candle and a folded flag, in solemn quiet, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 762451712
+ },
+ {
+ "id": "shadowman-20260925T172534-008",
+ "theme": "Presidential power & scandal",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a president shredding documents that pile up into a mountain spelling out the very thing he denies",
+ "caption": "\"Nothing to see here.\"",
+ "prompt": "a president shredding documents that pile up into a mountain spelling out the very thing he denies, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 856879872
+ },
+ {
+ "id": "shadowman-20260925T172534-009",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below",
+ "caption": "\"Traffic relief is on the way.\"",
+ "prompt": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1236883200
+ },
+ {
+ "id": "shadowman-20260925T172534-010",
+ "theme": "Education",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door",
+ "caption": "\"Go team.\"",
+ "prompt": "a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1602169600
+ },
+ {
+ "id": "shadowman-20260925T172534-011",
+ "theme": "War & militarism",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a giant war machine made of dollar bills, rolling over a tiny olive branch",
+ "caption": "\"Peace is expensive. War is more profitable.\"",
+ "prompt": "a giant war machine made of dollar bills, rolling over a tiny olive branch, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 561833088
+ },
+ {
+ "id": "shadowman-20260925T172534-012",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table",
+ "caption": "\"May the best party win.\"",
+ "prompt": "an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1659576896
+ },
+ {
+ "id": "shadowman-20260925T172534-013",
+ "theme": "Corruption & money in politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs",
+ "caption": "\"Democracy in action.\"",
+ "prompt": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1081563136
+ },
+ {
+ "id": "shadowman-20260925T172534-014",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "source": "theme-bank",
+ "scene": "a statue of Justice peeking under her blindfold at the people in front of her",
+ "caption": "\"Just checking.\"",
+ "prompt": "a statue of Justice peeking under her blindfold at the people in front of her, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1931503360
+ },
+ {
+ "id": "shadowman-20260925T172534-015",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a ballot box drawn as a slot machine, a candidate pulling the lever",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a ballot box drawn as a slot machine, a candidate pulling the lever, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 2072267968
+ },
+ {
+ "id": "shadowman-20260925T172534-016",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry",
+ "caption": "\"There is no leak. Next question.\"",
+ "prompt": "a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1533267456
+ },
+ {
+ "id": "shadowman-20260925T172534-017",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a candidate kissing a baby while picking the baby's pocket",
+ "caption": "\"Family values.\"",
+ "prompt": "a candidate kissing a baby while picking the baby's pocket, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 426805504
+ },
+ {
+ "id": "shadowman-20260925T172534-018",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 1583933952
+ },
+ {
+ "id": "shadowman-20260925T172534-019",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "source": "theme-bank+today-headline-tags",
+ "scene": "a gerrymandered district map shaped like a pretzel with a politician tied up inside it",
+ "caption": "\"My constituents love me.\"",
+ "prompt": "a gerrymandered district map shaped like a pretzel with a politician tied up inside it, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 2108384512
+ },
+ {
+ "id": "shadowman-20260925T172534-020",
+ "theme": "Religion & politics",
+ "era": "1970s",
+ "source": "theme-bank",
+ "scene": "a collection plate being passed around a legislative chamber",
+ "caption": "\"Tithing, bipartisan.\"",
+ "prompt": "a collection plate being passed around a legislative chamber, 1970s props, wide lapels and gas lines. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "seed": 221318144
+ }
+ ]
+}
\ No newline at end of file
diff --git a/generator/out/manifest.json b/generator/out/manifest.json
new file mode 100644
index 0000000..630501b
--- /dev/null
+++ b/generator/out/manifest.json
@@ -0,0 +1,795 @@
+{
+ "signature": "Shadow Man",
+ "note": "AI-generated original editorial cartoons signed Shadow Man. $0 local SDXL.",
+ "items": [
+ {
+ "id": "shadowman-20260925T172534-001",
+ "created_at": "2026-09-25T17:26:28+00:00",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "a pharmacy counter with a single pill on a velvet pillow priced like a diamond, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 488394240,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 27.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-001.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-002",
+ "created_at": "2026-09-25T17:26:53+00:00",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "caption": "\"Good boy.\"",
+ "prompt": "a lawmaker hiding under his desk behind a stack of thoughts-and-prayers cards while a gun lobbyist pats his head, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1287429432,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-002.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-003",
+ "created_at": "2026-09-25T17:27:18+00:00",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a giant eraser marked OFFICIAL STORY rubbing out a newspaper headline, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 920001536,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-003.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-004",
+ "created_at": "2026-09-25T17:27:44+00:00",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a judge with a gavel shaped like a price tag, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1539447808,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-004.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-006",
+ "created_at": "2026-09-25T17:28:34+00:00",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician taking an oath with one hand on a bible and the other hand behind his back in a donor's pocket, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1417327360,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-006.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-007",
+ "created_at": "2026-09-25T17:28:59+00:00",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty podium with a single lit candle and a folded flag, in solemn quiet, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 762451712,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-007.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-010",
+ "created_at": "2026-09-25T17:30:15+00:00",
+ "theme": "Education",
+ "era": "1960s",
+ "caption": "\"Go team.\"",
+ "prompt": "a schoolhouse sinking in quicksand labeled BUDGET CUTS while a stadium rises next door, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1602169600,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-010.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-012",
+ "created_at": "2026-09-25T17:31:06+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"May the best party win.\"",
+ "prompt": "an elephant and a donkey arm-wrestling on top of a voter who is flattened beneath the table, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1659576896,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-012.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-014",
+ "created_at": "2026-09-25T17:31:56+00:00",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "caption": "\"Just checking.\"",
+ "prompt": "a statue of Justice peeking under her blindfold at the people in front of her, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1931503360,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-014.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-015",
+ "created_at": "2026-09-25T17:32:28+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a ballot box drawn as a slot machine, a candidate pulling the lever, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 2072267968,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 31.4,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-015.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-016",
+ "created_at": "2026-09-25T17:32:59+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "caption": "\"There is no leak. Next question.\"",
+ "prompt": "a White House drawn as a leaking ship, aides bailing water with teacups while the captain insists the sea is dry, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1533267456,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 30.3,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-016.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-017",
+ "created_at": "2026-09-25T17:33:30+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"Family values.\"",
+ "prompt": "a candidate kissing a baby while picking the baby's pocket, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 426805504,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 30.4,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-017.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-018",
+ "created_at": "2026-09-25T17:34:01+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a giant rubber stamp marked EXECUTIVE ORDER crushing a small Capitol dome, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1583933952,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 30.5,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-018.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-019",
+ "created_at": "2026-09-25T17:34:32+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"My constituents love me.\"",
+ "prompt": "a gerrymandered district map shaped like a pretzel with a politician tied up inside it, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 2108384512,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 30.4,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-019.png"
+ },
+ {
+ "id": "shadowman-20260925T172534-001-r1",
+ "created_at": "2026-09-25T17:37:43+00:00",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "one small white pill resting on a velvet cushion under a glass dome with a huge price tag, pharmacy counter. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1169055087,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.5,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-001-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-001"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r1",
+ "created_at": "2026-09-25T17:38:36+00:00",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a giant eraser the size of a man rubbing out the headline of a huge newspaper. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1258537642,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-003-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-003"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r1",
+ "created_at": "2026-09-25T17:39:02+00:00",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a stern judge in black robes holding up a gavel shaped like a giant price tag. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1478486398,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-004-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-004"
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r1",
+ "created_at": "2026-09-25T17:39:28+00:00",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician raising his right hand to take an oath while his left hand reaches into a rich man's coat pocket. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 614235297,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-006-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-006"
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r1",
+ "created_at": "2026-09-25T17:39:55+00:00",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty speaker's podium on a bare stage with a single lit candle and a folded flag resting on it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 195767965,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-007-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-007"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r1",
+ "created_at": "2026-09-25T17:40:21+00:00",
+ "theme": "Education",
+ "era": "1960s",
+ "caption": "\"Go team.\"",
+ "prompt": "a small schoolhouse sinking into quicksand next to a giant shiny sports stadium. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 2069252085,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 26.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-010-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-010"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r1",
+ "created_at": "2026-09-25T17:41:12+00:00",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "caption": "\"Just checking.\"",
+ "prompt": "a stone statue of Lady Justice holding scales, lifting the edge of her blindfold with one finger to peek. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 2034622770,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-014-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-014"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r1",
+ "created_at": "2026-09-25T17:41:37+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a ballot box built like a slot machine with a big lever, a politician in a suit pulling the lever. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 50646513,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-015-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-015"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r1",
+ "created_at": "2026-09-25T17:42:55+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a gigantic rubber stamp crashing down on top of a small Capitol dome, cracking it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1914549874,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 26.4,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-018-r1.png",
+ "corner_scrub": true,
+ "retry_of": "shadowman-20260925T172534-018"
+ },
+ {
+ "id": "shadowman-20260925T172534-005",
+ "created_at": "2026-09-25T17:45:59+00:00",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "caption": "\"Another infrastructure milestone.\"",
+ "prompt": "a mayor cutting a ribbon on a pothole, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 134093824,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.9,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-005.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-008",
+ "created_at": "2026-09-25T17:46:24+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "1960s",
+ "caption": "\"Nothing to see here.\"",
+ "prompt": "a president shredding documents that pile up into a mountain spelling out the very thing he denies, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 856879872,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-008.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-009",
+ "created_at": "2026-09-25T17:46:50+00:00",
+ "theme": "Local LA & California politics",
+ "era": "1960s",
+ "caption": "\"Traffic relief is on the way.\"",
+ "prompt": "a city hall floating away on a freeway overpass while commuters sit in endless gridlock below, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1236883200,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-009.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-011",
+ "created_at": "2026-09-25T17:47:15+00:00",
+ "theme": "War & militarism",
+ "era": "1960s",
+ "caption": "\"Peace is expensive. War is more profitable.\"",
+ "prompt": "a giant war machine made of dollar bills, rolling over a tiny olive branch, 1960s props, skinny ties and television antennas. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 561833088,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-011.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-013",
+ "created_at": "2026-09-25T17:47:41+00:00",
+ "theme": "Corruption & money in politics",
+ "era": "2020s",
+ "caption": "\"Democracy in action.\"",
+ "prompt": "a lobbyist puppeteer working a whole Congress of marionettes, the strings made of dollar signs, contemporary props, smartphones and livestream cameras. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1081563136,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.2,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-013.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-020",
+ "created_at": "2026-09-25T17:48:06+00:00",
+ "theme": "Religion & politics",
+ "era": "1970s",
+ "caption": "\"Tithing, bipartisan.\"",
+ "prompt": "a collection plate being passed around a legislative chamber, 1970s props, wide lapels and gas lines. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 221318144,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-020.png",
+ "bottom_crop": 0.06,
+ "retry_of": null
+ },
+ {
+ "id": "shadowman-20260925T172534-002-r1",
+ "created_at": "2026-09-25T17:48:31+00:00",
+ "theme": "Guns & violence",
+ "era": "1960s",
+ "caption": "\"Good boy.\"",
+ "prompt": "a frightened politician hiding under his office desk while a smiling lobbyist in a suit pats his head like a dog. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 163015837,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-002-r1.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-002"
+ },
+ {
+ "id": "shadowman-20260925T172534-012-r1",
+ "created_at": "2026-09-25T17:48:57+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"May the best party win.\"",
+ "prompt": "an elephant and a donkey arm-wrestling on a table, a tiny ordinary man squashed flat under the table. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1688326547,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-012-r1.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-012"
+ },
+ {
+ "id": "shadowman-20260925T172534-016-r1",
+ "created_at": "2026-09-25T17:49:22+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "caption": "\"There is no leak. Next question.\"",
+ "prompt": "the White House drawn as a sinking ship in a stormy sea, men in suits bailing water out with teacups. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1415033303,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-016-r1.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-016"
+ },
+ {
+ "id": "shadowman-20260925T172534-017-r1",
+ "created_at": "2026-09-25T17:49:47+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"Family values.\"",
+ "prompt": "a smiling politician kissing a baby on the cheek while his other hand lifts the wallet from the mother's purse. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1160219923,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-017-r1.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-017"
+ },
+ {
+ "id": "shadowman-20260925T172534-019-r1",
+ "created_at": "2026-09-25T17:50:13+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"My constituents love me.\"",
+ "prompt": "a politician tied up inside a giant twisted pretzel shaped like a map. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 813426368,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-019-r1.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-019"
+ },
+ {
+ "id": "shadowman-20260925T172534-001-r2",
+ "created_at": "2026-09-25T17:50:59+00:00",
+ "theme": "Health & medicine",
+ "era": "1960s",
+ "caption": "\"Generic, you say?\"",
+ "prompt": "a single tiny pill displayed on a plush velvet pillow inside a jewelry store glass case, a jeweler peering at it through a loupe. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 374066636,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.8,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-001-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-001"
+ },
+ {
+ "id": "shadowman-20260925T172534-003-r2",
+ "created_at": "2026-09-25T17:51:24+00:00",
+ "theme": "Press, speech & dissent",
+ "era": "1960s",
+ "caption": "\"Corrections department.\"",
+ "prompt": "a smug official using a huge eraser to rub people out of a large group photograph pinned to a wall. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1345436888,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-003-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-003"
+ },
+ {
+ "id": "shadowman-20260925T172534-004-r2",
+ "created_at": "2026-09-25T17:51:50+00:00",
+ "theme": "Courts, law & justice",
+ "era": "1960s",
+ "caption": "\"Sold to the highest bidder.\"",
+ "prompt": "a judge in black robes at the courtroom bench acting as an auctioneer, rich men in the gallery holding up bags of money. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 717574595,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-004-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-004"
+ },
+ {
+ "id": "shadowman-20260925T172534-006-r2",
+ "created_at": "2026-09-25T17:52:15+00:00",
+ "theme": "Corruption & money in politics",
+ "era": "1960s",
+ "caption": "\"So help me, donors.\"",
+ "prompt": "a politician swearing an oath with one hand raised while a fat rich man behind him slips cash into his back pocket. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 569407856,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 25.4,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-006-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-006"
+ },
+ {
+ "id": "shadowman-20260925T172534-007-r2",
+ "created_at": "2026-09-25T17:52:46+00:00",
+ "theme": "Memorials & tributes",
+ "era": "1960s",
+ "caption": "\"The silence says it.\"",
+ "prompt": "an empty wooden podium alone on a dark stage with a single burning candle on top and a folded flag beside it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip, person, man, woman, speaker, crowd",
+ "seed": 1606107603,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 30.5,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-007-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-007"
+ },
+ {
+ "id": "shadowman-20260925T172534-010-r2",
+ "created_at": "2026-09-25T17:53:15+00:00",
+ "theme": "Education",
+ "era": "1960s",
+ "caption": "\"Go team.\"",
+ "prompt": "a tiny crumbling one-room schoolhouse dwarfed by a colossal gleaming football stadium right next to it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1956924414,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 29.1,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-010-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-010"
+ },
+ {
+ "id": "shadowman-20260925T172534-014-r2",
+ "created_at": "2026-09-25T17:53:45+00:00",
+ "theme": "Civil rights & race",
+ "era": "1960s",
+ "caption": "\"Just checking.\"",
+ "prompt": "a statue of Lady Justice wearing a blindfold, pushing the blindfold up with one finger to peek at the viewer. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1000462233,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank",
+ "gen_seconds": 29.0,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-014-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-014"
+ },
+ {
+ "id": "shadowman-20260925T172534-015-r2",
+ "created_at": "2026-09-25T17:54:14+00:00",
+ "theme": "Elections & party politics",
+ "era": "2020s",
+ "caption": "\"Feeling lucky, America?\"",
+ "prompt": "a politician in a suit yanking the big lever on the side of a giant ballot box, coins spilling out of it. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1112861726,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 28.6,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-015-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-015"
+ },
+ {
+ "id": "shadowman-20260925T172534-018-r2",
+ "created_at": "2026-09-25T17:54:44+00:00",
+ "theme": "Presidential power & scandal",
+ "era": "2020s",
+ "caption": "\"We found a shortcut.\"",
+ "prompt": "a gigantic wooden rubber stamp smashing down onto the dome of the Capitol building. bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire",
+ "negative_prompt": "text, words, letters, caption, speech bubble text, watermark, logo, signature, artist name, typography, color, colorful, photograph, photorealistic, 3d render, blurry, lowres, deformed hands, extra fingers, multiple panels, comic strip",
+ "seed": 1859527368,
+ "model": "sd_xl_base_1.0 (local, diffusers StableDiffusionXLPipeline, MPS fp16)",
+ "steps": 30,
+ "signature": "Shadow Man",
+ "source": "theme-bank+today-headline-tags",
+ "gen_seconds": 30.2,
+ "cost_usd": 0,
+ "file": "shadowman-20260925T172534-018-r2.png",
+ "bottom_crop": 0.06,
+ "retry_of": "shadowman-20260925T172534-018"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/generator/retry_briefs.py b/generator/retry_briefs.py
new file mode 100644
index 0000000..44f3bea
--- /dev/null
+++ b/generator/retry_briefs.py
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+"""Write a retry batch for briefs whose render failed the gag-adherence review (TK-12241).
+
+ python3 generator/retry_briefs.py <tag> <nnn,nnn,...> -> generator/out/batch-<tag>.json
+
+Each retry keeps theme/era/caption but swaps in a SIMPLIFIED prompt that leads with the 1-2 key
+objects of the gag (no era props / background clutter) and a new deterministic seed per tag.
+"""
+import json, random
+b = json.load(open('generator/out/batch.json'))
+STYLE = 'bold black ink editorial cartoon, heavy brush line, crosshatching, stark black and white, single panel satire'
+SIMPLE_R1 = {
+ '001': 'one small white pill resting on a velvet cushion under a glass dome with a huge price tag, pharmacy counter',
+ '002': 'a frightened politician hiding under his office desk while a smiling lobbyist in a suit pats his head like a dog',
+ '003': 'a giant eraser the size of a man rubbing out the headline of a huge newspaper',
+ '004': 'a stern judge in black robes holding up a gavel shaped like a giant price tag',
+ '006': 'a politician raising his right hand to take an oath while his left hand reaches into a rich man\'s coat pocket',
+ '007': 'an empty speaker\'s podium on a bare stage with a single lit candle and a folded flag resting on it',
+ '010': 'a small schoolhouse sinking into quicksand next to a giant shiny sports stadium',
+ '012': 'an elephant and a donkey arm-wrestling on a table, a tiny ordinary man squashed flat under the table',
+ '014': 'a stone statue of Lady Justice holding scales, lifting the edge of her blindfold with one finger to peek',
+ '015': 'a ballot box built like a slot machine with a big lever, a politician in a suit pulling the lever',
+ '016': 'the White House drawn as a sinking ship in a stormy sea, men in suits bailing water out with teacups',
+ '017': 'a smiling politician kissing a baby on the cheek while his other hand lifts the wallet from the mother\'s purse',
+ '018': 'a gigantic rubber stamp crashing down on top of a small Capitol dome, cracking it',
+ '019': 'a politician tied up inside a giant twisted pretzel shaped like a map',
+}
+# r2: second retry for briefs whose r1 still failed. Avoids objects SDXL fills with fake lettering
+# (headlines, price tags, slot-machine reels) and leads with one unmistakable action.
+SIMPLE_R2 = {
+ '001': 'a single tiny pill displayed on a plush velvet pillow inside a jewelry store glass case, a jeweler peering at it through a loupe',
+ '003': 'a smug official using a huge eraser to rub people out of a large group photograph pinned to a wall',
+ '004': 'a judge in black robes at the courtroom bench acting as an auctioneer, rich men in the gallery holding up bags of money',
+ '006': 'a politician swearing an oath with one hand raised while a fat rich man behind him slips cash into his back pocket',
+ '007': 'an empty wooden podium alone on a dark stage with a single burning candle on top and a folded flag beside it',
+ '010': 'a tiny crumbling one-room schoolhouse dwarfed by a colossal gleaming football stadium right next to it',
+ '014': 'a statue of Lady Justice wearing a blindfold, pushing the blindfold up with one finger to peek at the viewer',
+ '015': 'a politician in a suit yanking the big lever on the side of a giant ballot box, coins spilling out of it',
+ '018': 'a gigantic wooden rubber stamp smashing down onto the dome of the Capitol building',
+}
+NEG_R2 = {'007': 'person, man, woman, speaker, crowd'}
+import sys
+tag = sys.argv[1]
+SIMPLE = {'r1': SIMPLE_R1, 'r2': SIMPLE_R2}[tag]; rng = random.Random(tag)
+todo = sys.argv[2].split(',')
+items = []
+for it in b['items']:
+ n = it['id'][-3:]
+ if n not in todo: continue
+ rec = {**it, 'id': f"{it['id']}-{tag}", 'retry_of': it['id'], 'prompt': f"{SIMPLE[n]}. {STYLE}", 'seed': rng.randrange(2**31)}
+ if tag == 'r2' and n in NEG_R2: rec['negative_extra'] = NEG_R2[n]
+ items.append(rec)
+json.dump({'generated_at': b['generated_at'], 'retry': tag, 'items': items}, open(f'generator/out/batch-{tag}.json', 'w'), indent=2)
+print(len(items), 'retry briefs ->', f'generator/out/batch-{tag}.json')
diff --git a/public/arena.js b/public/arena.js
new file mode 100644
index 0000000..bb626bf
--- /dev/null
+++ b/public/arena.js
@@ -0,0 +1,104 @@
+// Inkwell — Arena Ideas (TK-12241).
+// Cartoon ideas written by the local models in Model Arena battle 7b062a7d42e5 (extracted by
+// scripts/extract-arena-ideas.cjs into data/arena-ideas.json). Model + theme filters, sort, density
+// (persisted to localStorage) and curation via curate.js: Approve queues the idea as a Shadow Man
+// brief (generator/idea-queue.json); Delete hides it (kept in the file, restorable).
+// Every card carries a created date+time chip (admin rule) with the ISO value in title=.
+
+(function () {
+ const LS = { model: 'inkwell:ar:model', theme: 'inkwell:ar:theme', sort: 'inkwell:ar:sort', density: 'inkwell:ar:density', deleted: 'inkwell:ar:deleted' };
+ const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } };
+ const set = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } };
+ const esc = InkCurate.esc;
+ const fmtDate = (iso) => {
+ const d = new Date(iso);
+ return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ };
+
+ let ITEMS = [];
+ const state = { model: get(LS.model, 'all'), theme: get(LS.theme, 'all'), sort: get(LS.sort, 'drawability'), density: get(LS.density, 300), deleted: get(LS.deleted, false) };
+ let curate = null;
+
+ function sorted(list) {
+ const a = [...list];
+ const t = (x) => new Date(x.created_at).getTime() || 0;
+ switch (state.sort) {
+ case 'newest': return a.sort((x, y) => t(y) - t(x) || x.title.localeCompare(y.title));
+ case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
+ case 'model': return a.sort((x, y) => x.model.localeCompare(y.model) || x.title.localeCompare(y.title));
+ case 'theme': return a.sort((x, y) => x.theme.localeCompare(y.theme) || x.title.localeCompare(y.title));
+ case 'drawability':
+ default: return a.sort((x, y) => (y.drawability || 0) - (x.drawability || 0) || x.title.localeCompare(y.title));
+ }
+ }
+
+ function visible() {
+ return sorted(ITEMS.filter(i => (state.deleted || i.status !== 'deleted') && (state.model === 'all' || i.model === state.model) && (state.theme === 'all' || i.theme === state.theme)));
+ }
+
+ function card(it) {
+ const ctl = InkCurate.cardControls(it, curate && curate.selected.has(it.id));
+ const dots = it.drawability ? `<span class="badge ar-draw" title="drawability ${esc(it.drawability)} of 5">Drawability ${esc(it.drawability)}/5</span>` : '';
+ return `<article class="card ar-card status-${esc(it.status || 'pending')}" data-id="${esc(it.id)}">
+ <div class="curate-top">${ctl.top}</div>
+ <div class="body">
+ <div class="title">${esc(it.title)}</div>
+ <span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span>
+ <div class="caption ar-caption">“${esc(it.caption)}”</div>
+ <div class="ar-scene"><span class="ar-label">Scene</span> ${esc(it.scene)}</div>
+ ${it.why ? `<div class="ar-why"><span class="ar-label">Why it's funny</span> ${esc(it.why)}</div>` : ''}
+ <div class="badges"><span class="topic-tag">${esc(it.theme)}</span><span class="badge ar-model">${esc(it.model)}</span>${dots}</div>
+ </div>
+ ${ctl.actions}
+ </article>`;
+ }
+
+ function fillSelect(el, values, current, allLabel) {
+ el.innerHTML = `<option value="all">${esc(allLabel)}</option>` + values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
+ el.value = values.includes(current) ? current : 'all';
+ return el.value;
+ }
+
+ function render() {
+ const list = visible();
+ const approved = ITEMS.filter(i => i.status === 'approved').length;
+ const deleted = ITEMS.filter(i => i.status === 'deleted').length;
+ document.getElementById('ar-count').textContent = `${list.length} shown · ${approved} approved (queued) · ${deleted} deleted`;
+ document.getElementById('ar-grid').innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
+ if (curate) curate.syncBar();
+ }
+
+ function init() {
+ const modelEl = document.getElementById('ar-model');
+ const themeEl = document.getElementById('ar-theme');
+ const sortEl = document.getElementById('ar-sort');
+ const densEl = document.getElementById('ar-density');
+ const delEl = document.getElementById('ar-show-deleted');
+ state.model = fillSelect(modelEl, [...new Set(ITEMS.map(i => i.model))].sort(), state.model, 'All models');
+ state.theme = fillSelect(themeEl, [...new Set(ITEMS.map(i => i.theme))].sort(), state.theme, 'All themes');
+ sortEl.value = state.sort;
+ densEl.value = state.density;
+ delEl.checked = !!state.deleted;
+ document.documentElement.style.setProperty('--ar-min', state.density + 'px');
+ modelEl.addEventListener('change', () => { state.model = modelEl.value; set(LS.model, state.model); render(); });
+ themeEl.addEventListener('change', () => { state.theme = themeEl.value; set(LS.theme, state.theme); render(); });
+ sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
+ delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
+ densEl.addEventListener('input', () => {
+ state.density = Number(densEl.value);
+ document.documentElement.style.setProperty('--ar-min', state.density + 'px');
+ set(LS.density, state.density);
+ });
+ curate = InkCurate.attach({
+ api: '/api/arena-ideas', label: 'idea', deleteNote: 'It stays in the file, hidden.',
+ grid: document.getElementById('ar-grid'), bar: document.getElementById('ar-bulk'),
+ setItems: (items) => { ITEMS = items; }, render, visibleIds: () => visible().map(i => i.id),
+ onError: (msg) => { document.getElementById('ar-count').textContent = 'Action failed — ' + msg; },
+ });
+ }
+
+ fetch('/api/arena-ideas')
+ .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
+ .then(doc => { ITEMS = doc.items || []; init(); render(); })
+ .catch(err => { document.getElementById('ar-grid').innerHTML = `<div class="empty-note">Could not load arena ideas — ${esc(err.message)}</div>`; });
+})();
diff --git a/public/curate.js b/public/curate.js
new file mode 100644
index 0000000..5e7301f
--- /dev/null
+++ b/public/curate.js
@@ -0,0 +1,113 @@
+// Inkwell — shared curation controls (TK-12241).
+// Per-card: a select checkbox + two big full-width buttons (Approve / Delete; Restore when deleted).
+// Per-section: a sticky bulk bar that appears once >=1 card is selected (Approve N / Delete N /
+// Select all / Clear). Delete always goes through an in-page confirm modal (never window.confirm).
+// The server is the source of truth: every POST returns the collection's items, which replace the
+// local copy before re-rendering.
+
+(function () {
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const STATUS_LABEL = { pending: 'Pending', approved: 'Approved', deleted: 'Deleted' };
+
+ // In-page confirm modal. Resolves true on confirm, false on cancel / Esc / backdrop click.
+ function confirmModal(message, okLabel) {
+ const back = document.getElementById('confirm-backdrop');
+ const ok = document.getElementById('confirm-ok');
+ const cancel = document.getElementById('confirm-cancel');
+ document.getElementById('confirm-msg').textContent = message;
+ ok.textContent = okLabel;
+ back.classList.add('open');
+ const opener = document.activeElement;
+ cancel.focus();
+ return new Promise(resolve => {
+ const done = (v) => {
+ back.classList.remove('open');
+ ok.removeEventListener('click', onOk); cancel.removeEventListener('click', onCancel);
+ back.removeEventListener('click', onBack); document.removeEventListener('keydown', onKey, true);
+ if (opener && opener.focus) opener.focus();
+ resolve(v);
+ };
+ const onOk = () => done(true);
+ const onCancel = () => done(false);
+ const onBack = (e) => { if (e.target === back) done(false); };
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.stopPropagation(); done(false); }
+ if (e.key === 'Tab') { e.preventDefault(); (document.activeElement === ok ? cancel : ok).focus(); }
+ };
+ ok.addEventListener('click', onOk); cancel.addEventListener('click', onCancel);
+ back.addEventListener('click', onBack); document.addEventListener('keydown', onKey, true);
+ });
+ }
+
+ function cardControls(it, selected) {
+ const st = it.status || 'pending';
+ const actions = st === 'deleted'
+ ? `<button type="button" class="big-btn restore" data-act="restore" data-id="${esc(it.id)}">Restore</button>`
+ : `<button type="button" class="big-btn approve" data-act="${st === 'approved' ? 'restore' : 'approve'}" data-id="${esc(it.id)}" aria-pressed="${st === 'approved'}">${st === 'approved' ? 'Approved ✓ (undo)' : 'Approve'}</button>
+ <button type="button" class="big-btn delete" data-act="delete" data-id="${esc(it.id)}">Delete</button>`;
+ return {
+ top: `<label class="sel"><input type="checkbox" class="sel-box" data-id="${esc(it.id)}" ${selected ? 'checked' : ''} aria-label="Select ${esc(it.title)}"> Select</label>
+ <span class="status-chip status-${esc(st)}">${STATUS_LABEL[st] || esc(st)}</span>`,
+ actions: `<div class="card-actions">${actions}</div>`,
+ };
+ }
+
+ // opts: { api, grid, bar, label, getItems(), setItems(items), render(), visibleIds() }
+ function attach(opts) {
+ const selected = new Set();
+ const bar = opts.bar;
+
+ function syncBar() {
+ for (const id of [...selected]) if (!opts.visibleIds().includes(id)) selected.delete(id);
+ const n = selected.size;
+ bar.hidden = n === 0;
+ bar.querySelector('.bulk-count').textContent = `${n} selected`;
+ bar.querySelector('[data-bulk="approve"]').textContent = `Approve ${n}`;
+ bar.querySelector('[data-bulk="delete"]').textContent = `Delete ${n}`;
+ opts.grid.querySelectorAll('.sel-box').forEach(b => {
+ b.checked = selected.has(b.dataset.id);
+ const card = b.closest('.card');
+ if (card) card.classList.toggle('is-selected', b.checked);
+ });
+ }
+
+ async function post(action, ids) {
+ if (!ids.length) return;
+ if (action === 'delete') {
+ const ok = await confirmModal(`Delete ${ids.length} ${opts.label}${ids.length === 1 ? '' : 's'}? ${opts.deleteNote || ''} You can restore it later with "Show deleted".`, `Delete ${ids.length}`);
+ if (!ok) return;
+ }
+ const r = await fetch(`${opts.api}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) { opts.onError && opts.onError(body.error || ('HTTP ' + r.status)); return; }
+ opts.setItems(body.items || []);
+ ids.forEach(id => selected.delete(id));
+ opts.render();
+ }
+
+ opts.grid.addEventListener('click', (e) => {
+ const btn = e.target.closest('.big-btn');
+ if (btn) { e.stopPropagation(); post(btn.dataset.act, [btn.dataset.id]); return; }
+ if (e.target.closest('.sel')) e.stopPropagation();
+ });
+ opts.grid.addEventListener('keydown', (e) => { if (e.target.closest('.sel, .big-btn')) e.stopPropagation(); });
+ opts.grid.addEventListener('change', (e) => {
+ const box = e.target.closest('.sel-box');
+ if (!box) return;
+ if (box.checked) selected.add(box.dataset.id); else selected.delete(box.dataset.id);
+ syncBar();
+ });
+ bar.addEventListener('click', (e) => {
+ const b = e.target.closest('[data-bulk]');
+ if (!b) return;
+ const act = b.dataset.bulk;
+ if (act === 'all') { opts.visibleIds().forEach(id => selected.add(id)); syncBar(); return; }
+ if (act === 'clear') { selected.clear(); syncBar(); return; }
+ post(act, [...selected]);
+ });
+
+ return { selected, syncBar, post };
+ }
+
+ window.InkCurate = { attach, cardControls, confirmModal, esc };
+})();
diff --git a/public/index.html b/public/index.html
index 49b402f..084d049 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,8 @@
</div>
<div class="topnav-row2" id="topnav-row2">
<div class="section-links" id="section-links">
+ <a class="nav-anchor" href="#shadowman">Shadow Man</a>
+ <a class="nav-anchor" href="#arena">Arena Ideas</a>
<a class="nav-anchor" href="#p24">P24 Art</a>
<button type="button" data-kind="all" class="active">Public-domain cartoons</button>
<button type="button" data-kind="museum">Museums</button>
@@ -29,6 +31,90 @@
<div class="masthead-sub">Private archive · public-domain cartoons from museums, libraries & universities · local reference tool, not published</div>
+ <section class="shadowman" id="shadowman" aria-labelledby="sm-heading">
+ <div class="p24-head">
+ <h2 id="sm-heading">Shadow Man <span>original editorial cartoons written from archive themes & eras</span></h2>
+ </div>
+ <div class="controls p24-controls">
+ <div>
+ <label for="sm-theme">Theme</label>
+ <select id="sm-theme"><option value="all">All themes</option></select>
+ </div>
+ <div>
+ <label for="sm-era">Era</label>
+ <select id="sm-era"><option value="all">All eras</option></select>
+ </div>
+ <div>
+ <label for="sm-sort">Sort</label>
+ <select id="sm-sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ <option value="title-az">Title A→Z</option>
+ <option value="theme">Theme</option>
+ <option value="era">Era</option>
+ </select>
+ </div>
+ <div class="density-wrap">
+ <label for="sm-density">Density</label>
+ <input type="range" id="sm-density" min="160" max="480" step="10">
+ </div>
+ <label class="toggle"><input type="checkbox" id="sm-show-deleted"> Show deleted</label>
+ <div class="result-count" id="sm-count"></div>
+ </div>
+ <div class="bulk-bar" id="sm-bulk" hidden role="region" aria-label="Bulk actions">
+ <span class="bulk-count">0 selected</span>
+ <button type="button" class="big-btn approve" data-bulk="approve">Approve 0</button>
+ <button type="button" class="big-btn delete" data-bulk="delete">Delete 0</button>
+ <button type="button" class="big-btn plain" data-bulk="all">Select all</button>
+ <button type="button" class="big-btn plain" data-bulk="clear">Clear</button>
+ </div>
+ <div class="grid-wrap">
+ <div class="grid sm-grid" id="sm-grid"></div>
+ </div>
+ </section>
+
+ <section class="arena" id="arena" aria-labelledby="ar-heading">
+ <div class="p24-head">
+ <h2 id="ar-heading">Arena Ideas <span>cartoon ideas from the local-model arena — approve to queue for rendering</span></h2>
+ </div>
+ <div class="controls p24-controls">
+ <div>
+ <label for="ar-model">Model</label>
+ <select id="ar-model"><option value="all">All models</option></select>
+ </div>
+ <div>
+ <label for="ar-theme">Theme</label>
+ <select id="ar-theme"><option value="all">All themes</option></select>
+ </div>
+ <div>
+ <label for="ar-sort">Sort</label>
+ <select id="ar-sort">
+ <option value="drawability">Drawability ↓</option>
+ <option value="newest">Newest</option>
+ <option value="title-az">Title A→Z</option>
+ <option value="model">Model</option>
+ <option value="theme">Theme</option>
+ </select>
+ </div>
+ <div class="density-wrap">
+ <label for="ar-density">Density</label>
+ <input type="range" id="ar-density" min="220" max="520" step="10">
+ </div>
+ <label class="toggle"><input type="checkbox" id="ar-show-deleted"> Show deleted</label>
+ <div class="result-count" id="ar-count"></div>
+ </div>
+ <div class="bulk-bar" id="ar-bulk" hidden role="region" aria-label="Bulk actions">
+ <span class="bulk-count">0 selected</span>
+ <button type="button" class="big-btn approve" data-bulk="approve">Approve 0</button>
+ <button type="button" class="big-btn delete" data-bulk="delete">Delete 0</button>
+ <button type="button" class="big-btn plain" data-bulk="all">Select all</button>
+ <button type="button" class="big-btn plain" data-bulk="clear">Clear</button>
+ </div>
+ <div class="grid-wrap">
+ <div class="grid ar-grid" id="ar-grid"></div>
+ </div>
+ </section>
+
<section class="p24" id="p24" aria-labelledby="p24-heading">
<div class="p24-head">
<h2 id="p24-heading">P24 Art <span>original photos & cartoons</span></h2>
@@ -99,7 +185,21 @@
<div class="modal" id="modal-body"></div>
</div>
+ <div class="confirm-backdrop" id="confirm-backdrop">
+ <div class="confirm-box" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-msg">
+ <h2 id="confirm-title">Please confirm</h2>
+ <p id="confirm-msg"></p>
+ <div class="confirm-actions">
+ <button type="button" class="big-btn plain" id="confirm-cancel">Cancel</button>
+ <button type="button" class="big-btn delete" id="confirm-ok">Delete</button>
+ </div>
+ </div>
+ </div>
+
<script src="app.js"></script>
+ <script src="curate.js"></script>
<script src="p24.js"></script>
+ <script src="shadowman.js"></script>
+ <script src="arena.js"></script>
</body>
</html>
diff --git a/public/shadowman.js b/public/shadowman.js
new file mode 100644
index 0000000..3cc5bca
--- /dev/null
+++ b/public/shadowman.js
@@ -0,0 +1,126 @@
+// Inkwell — Shadow Man gallery (TK-12241).
+// Original single-panel editorial cartoons, written from archive THEMES and eras (never a redraw of
+// any specific archival cartoon), rendered locally ($0 SDXL) and signed "Shadow Man".
+// Loads /api/shadowman and renders every kept piece INLINE with theme + era filters, a sort select
+// and a density slider (all persisted to localStorage), plus curation via curate.js: select 1..many,
+// big Approve / Delete buttons per card, a sticky bulk bar, and a Show-deleted/Restore toggle.
+// Every card carries a created date+time chip (admin rule) with the ISO value in title=.
+
+(function () {
+ const LS = { theme: 'inkwell:sm:theme', era: 'inkwell:sm:era', sort: 'inkwell:sm:sort', density: 'inkwell:sm:density', deleted: 'inkwell:sm:deleted' };
+ const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } };
+ const set = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } };
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ const fmtDate = (iso) => {
+ const d = new Date(iso);
+ return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ };
+
+ let ITEMS = [];
+ const state = { theme: get(LS.theme, 'all'), era: get(LS.era, 'all'), sort: get(LS.sort, 'newest'), density: get(LS.density, 280), deleted: get(LS.deleted, false) };
+ let curate = null;
+
+ function sorted(list) {
+ const a = [...list];
+ const t = (x) => new Date(x.created_at).getTime() || 0;
+ switch (state.sort) {
+ case 'oldest': return a.sort((x, y) => t(x) - t(y) || x.id.localeCompare(y.id));
+ case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
+ case 'theme': return a.sort((x, y) => x.theme.localeCompare(y.theme) || x.title.localeCompare(y.title));
+ case 'era': return a.sort((x, y) => x.era.localeCompare(y.era) || x.title.localeCompare(y.title));
+ case 'newest':
+ default: return a.sort((x, y) => t(y) - t(x) || y.id.localeCompare(x.id));
+ }
+ }
+
+ function card(it) {
+ const ctl = InkCurate.cardControls(it, curate && curate.selected.has(it.id));
+ const img = it.status === 'deleted'
+ ? '<div class="no-img">Deleted — image moved to trash</div>'
+ : `<img src="${esc(it.src)}" alt="${esc(it.title)} — ${esc(it.caption)}" loading="lazy">`;
+ return `<article class="card sm-card status-${esc(it.status || 'pending')}" data-id="${esc(it.id)}" data-theme="${esc(it.theme)}" data-era="${esc(it.era)}" tabindex="0">
+ <div class="curate-top">${ctl.top}</div>
+ <div class="thumb">${img}</div>
+ <div class="body">
+ <div class="title">${esc(it.title)}</div>
+ <span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span>
+ <div class="caption">${esc(it.caption)}</div>
+ <div class="badges"><span class="topic-tag sm-theme">${esc(it.theme)}</span><span class="badge sm-era">${esc(it.era)}</span></div>
+ </div>
+ ${ctl.actions}
+ </article>`;
+ }
+
+ function fillSelect(el, values, current, allLabel) {
+ el.innerHTML = `<option value="all">${esc(allLabel)}</option>` + values.map(v => `<option value="${esc(v)}">${esc(v)}</option>`).join('');
+ el.value = values.includes(current) ? current : 'all';
+ return el.value;
+ }
+
+ function visible() {
+ return sorted(ITEMS.filter(i => (state.deleted || i.status !== 'deleted') && (state.theme === 'all' || i.theme === state.theme) && (state.era === 'all' || i.era === state.era)));
+ }
+
+ function render() {
+ const list = visible();
+ const approved = ITEMS.filter(i => i.status === 'approved').length;
+ const deleted = ITEMS.filter(i => i.status === 'deleted').length;
+ document.getElementById('sm-count').textContent = `${list.length} shown · ${approved} approved · ${deleted} deleted`;
+ const grid = document.getElementById('sm-grid');
+ grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
+ grid.querySelectorAll('.sm-card').forEach(c => {
+ c.addEventListener('click', (e) => { if (!e.target.closest('.curate-top, .card-actions')) open(c.dataset.id); });
+ c.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === c) open(c.dataset.id); });
+ });
+ if (curate) curate.syncBar();
+ }
+
+ function open(id) {
+ const it = ITEMS.find(x => x.id === id);
+ if (!it) return;
+ document.getElementById('modal-body').innerHTML = `
+ <button class="close-x" id="modal-close">×</button>
+ <h2>${esc(it.title)}</h2>
+ <div class="modal-meta"><span class="when" title="${esc(it.created_at)}">🕓 ${esc(fmtDate(it.created_at))}</span> · ${esc(it.theme)} · ${esc(it.era)}</div>
+ ${it.status === 'deleted' ? '<p class="modal-rep-note">Deleted — image is in the trash folder until restored.</p>' : `<img class="p24-full" src="${esc(it.src)}" alt="${esc(it.title)}">`}
+ <p>${esc(it.caption)}</p>
+ <dl><dt>Signed</dt><dd>${esc(it.signature)}</dd><dt>Scene</dt><dd>${esc(it.scene)}</dd><dt>Made with</dt><dd>${esc(it.model)} · ${esc(it.gen_seconds)}s · $${esc(it.cost_usd)}</dd></dl>
+ <p class="rights-note">Original AI-generated cartoon written from an archive theme and era; not a redraw of any published cartoon.</p>`;
+ document.getElementById('modal-backdrop').classList.add('open');
+ document.getElementById('modal-close').addEventListener('click', () => document.getElementById('modal-backdrop').classList.remove('open'));
+ }
+
+ function init() {
+ const themeEl = document.getElementById('sm-theme');
+ const eraEl = document.getElementById('sm-era');
+ const sortEl = document.getElementById('sm-sort');
+ const densEl = document.getElementById('sm-density');
+ const delEl = document.getElementById('sm-show-deleted');
+ delEl.checked = !!state.deleted;
+ delEl.addEventListener('change', () => { state.deleted = delEl.checked; set(LS.deleted, state.deleted); render(); });
+ curate = InkCurate.attach({
+ api: '/api/shadowman', label: 'cartoon', deleteNote: 'Its image moves to the trash folder.',
+ grid: document.getElementById('sm-grid'), bar: document.getElementById('sm-bulk'),
+ setItems: (items) => { ITEMS = items; }, render, visibleIds: () => visible().map(i => i.id),
+ onError: (msg) => { document.getElementById('sm-count').textContent = 'Action failed — ' + msg; },
+ });
+ state.theme = fillSelect(themeEl, [...new Set(ITEMS.map(i => i.theme))].sort(), state.theme, 'All themes');
+ state.era = fillSelect(eraEl, [...new Set(ITEMS.map(i => i.era))].sort(), state.era, 'All eras');
+ sortEl.value = state.sort;
+ densEl.value = state.density;
+ document.documentElement.style.setProperty('--sm-min', state.density + 'px');
+ themeEl.addEventListener('change', () => { state.theme = themeEl.value; set(LS.theme, state.theme); render(); });
+ eraEl.addEventListener('change', () => { state.era = eraEl.value; set(LS.era, state.era); render(); });
+ sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
+ densEl.addEventListener('input', () => {
+ state.density = Number(densEl.value);
+ document.documentElement.style.setProperty('--sm-min', state.density + 'px');
+ set(LS.density, state.density);
+ });
+ }
+
+ fetch('/api/shadowman')
+ .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
+ .then(doc => { ITEMS = doc.items || []; init(); render(); })
+ .catch(err => { document.getElementById('sm-grid').innerHTML = `<div class="empty-note">Could not load Shadow Man cartoons — ${esc(err.message)}</div>`; });
+})();
diff --git a/public/shadowman/shadowman-20260925T172534-002-r1.jpg b/public/shadowman/shadowman-20260925T172534-002-r1.jpg
new file mode 100644
index 0000000..5ba2f3c
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-002-r1.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-005.jpg b/public/shadowman/shadowman-20260925T172534-005.jpg
new file mode 100644
index 0000000..2f9c67b
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-005.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-006-r2.jpg b/public/shadowman/shadowman-20260925T172534-006-r2.jpg
new file mode 100644
index 0000000..f22e159
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-006-r2.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-007-r2.jpg b/public/shadowman/shadowman-20260925T172534-007-r2.jpg
new file mode 100644
index 0000000..da59459
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-007-r2.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-008.jpg b/public/shadowman/shadowman-20260925T172534-008.jpg
new file mode 100644
index 0000000..d6ea464
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-008.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-009.jpg b/public/shadowman/shadowman-20260925T172534-009.jpg
new file mode 100644
index 0000000..6cf07db
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-009.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-011.jpg b/public/shadowman/shadowman-20260925T172534-011.jpg
new file mode 100644
index 0000000..f4cc9f2
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-011.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-012-r1.jpg b/public/shadowman/shadowman-20260925T172534-012-r1.jpg
new file mode 100644
index 0000000..b8a3f60
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-012-r1.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-013.jpg b/public/shadowman/shadowman-20260925T172534-013.jpg
new file mode 100644
index 0000000..cf51d0d
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-013.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-016-r1.jpg b/public/shadowman/shadowman-20260925T172534-016-r1.jpg
new file mode 100644
index 0000000..2e93bae
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-016-r1.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-017-r1.jpg b/public/shadowman/shadowman-20260925T172534-017-r1.jpg
new file mode 100644
index 0000000..1ec6a04
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-017-r1.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-019-r1.jpg b/public/shadowman/shadowman-20260925T172534-019-r1.jpg
new file mode 100644
index 0000000..3916673
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-019-r1.jpg differ
diff --git a/public/shadowman/shadowman-20260925T172534-020.jpg b/public/shadowman/shadowman-20260925T172534-020.jpg
new file mode 100644
index 0000000..a8c7002
Binary files /dev/null and b/public/shadowman/shadowman-20260925T172534-020.jpg differ
diff --git a/public/style.css b/public/style.css
index a2c36aa..191cb2e 100644
--- a/public/style.css
+++ b/public/style.css
@@ -482,3 +482,63 @@ body {
/* ---- public-domain cartoons gallery ---- */
.badge.pd { background: var(--badge-doc); }
.pd .card .thumb img { background: #fff; }
+
+/* ---- Shadow Man gallery (TK-12241) — reuses the P24 card/controls look ---- */
+.shadowman { border-bottom: 3px solid var(--accent); }
+.sm-grid { grid-template-columns: repeat(auto-fill, minmax(var(--sm-min, 280px), 1fr)); }
+.sm-card .thumb { background: #fff; }
+.sm-card .thumb img { object-fit: contain; }
+.sm-card .caption { font-style: italic; font-size: 13px; color: var(--ink-soft); }
+.sm-card .badge.sm-era { background: var(--ink); color: var(--bg); }
+
+/* ---- Curation controls (TK-12241): select + big Approve/Delete, bulk bar, confirm modal ---- */
+.arena { border-bottom: 3px solid var(--accent); }
+.ar-grid { grid-template-columns: repeat(auto-fill, minmax(var(--ar-min, 300px), 1fr)); }
+.ar-card { cursor: default; }
+.ar-card .ar-caption { font-style: italic; font-size: 13.5px; color: var(--ink); }
+.ar-scene, .ar-why { font-size: 12.5px; color: var(--ink-soft); line-height: 1.4; }
+.ar-label { display: block; font-size: 10.5px; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); font-weight: 700; }
+.badge.ar-model { background: var(--ink); color: var(--bg); }
+.badge.ar-draw { background: var(--badge-doc); }
+.controls .toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-soft); margin: 0; }
+.controls .toggle input { width: 18px; height: 18px; }
+
+.curate-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); }
+.curate-top .sel { display: flex; align-items: center; gap: 8px; min-height: 44px; font-size: 13px; color: var(--ink); cursor: pointer; margin: 0; }
+.curate-top .sel input { width: 22px; height: 22px; accent-color: var(--accent); cursor: pointer; }
+.status-chip { font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink-soft); background: var(--bg); }
+.status-chip.status-approved { background: #1f7a3d; border-color: #1f7a3d; color: #fff; }
+.status-chip.status-deleted { background: var(--accent); border-color: var(--accent); color: #fff; }
+.card.status-approved { box-shadow: 0 0 0 2px #1f7a3d; }
+.card.status-deleted { opacity: .6; }
+.card.is-selected { box-shadow: 0 0 0 3px var(--accent); }
+
+.card-actions { display: flex; flex-direction: column; gap: 8px; padding: 10px; border-top: 1px solid var(--line); margin-top: auto; }
+.big-btn {
+ min-height: 48px; width: 100%; padding: 10px 14px; font: 700 15px/1.2 "Georgia", serif; letter-spacing: .02em;
+ border-radius: var(--radius); border: 2px solid var(--ink); background: var(--panel-bg); color: var(--ink); cursor: pointer;
+}
+.big-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+.big-btn.approve { background: #1f7a3d; border-color: #1f7a3d; color: #fff; }
+.big-btn.approve[aria-pressed="true"] { background: var(--panel-bg); color: #1f7a3d; }
+.big-btn.delete { background: var(--panel-bg); border-color: var(--accent); color: var(--accent); }
+.big-btn.delete:hover { background: var(--accent); color: #fff; }
+.big-btn.restore { background: var(--ink); color: var(--bg); }
+.big-btn.plain { background: var(--panel-bg); }
+
+.bulk-bar {
+ position: sticky; top: 96px; z-index: 15; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
+ margin: 0 20px 10px; padding: 10px 12px; background: var(--ink); color: var(--bg); border-radius: var(--radius); border: 2px solid var(--accent);
+}
+.bulk-bar[hidden] { display: none; }
+.bulk-bar .bulk-count { font-weight: 700; margin-right: auto; }
+.bulk-bar .big-btn { width: auto; flex: 0 0 auto; }
+
+.confirm-backdrop { display: none; position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, .6); align-items: center; justify-content: center; padding: 16px; }
+.confirm-backdrop.open { display: flex; }
+.confirm-box { background: var(--panel-bg); color: var(--ink); border: 2px solid var(--accent); border-radius: var(--radius); padding: 20px; max-width: 440px; width: 100%; }
+.confirm-box h2 { margin: 0 0 8px; font-size: 20px; }
+.confirm-box p { margin: 0 0 16px; line-height: 1.45; }
+.confirm-actions { display: flex; gap: 10px; }
+.confirm-actions .big-btn { flex: 1; }
+@media (max-width: 560px) { .bulk-bar { top: 120px; margin: 0 10px 10px; } .bulk-bar .big-btn { flex: 1 1 45%; } }
diff --git a/scripts/build-shadowman.mjs b/scripts/build-shadowman.mjs
new file mode 100644
index 0000000..b31a751
--- /dev/null
+++ b/scripts/build-shadowman.mjs
@@ -0,0 +1,63 @@
+#!/usr/bin/env node
+// build-shadowman.mjs — publish reviewed Shadow Man renders into Inkwell (TK-12241). $0, local.
+//
+// Inputs: generator/out/manifest.json (written by generator/gen_shadowman.py, one row per render)
+// scripts/shadowman-review.json (human/agent visual review: keep|drop + title + reason per id)
+// Outputs: public/shadowman/<id>.jpg (kept finals, JPEG q85 via macOS sips — the PNGs stay in generator/out/)
+// data/shadowman.json (served by /api/shadowman through sendClean())
+//
+// Every id in the manifest MUST have a review verdict — an unreviewed render is never published
+// (and never silently skipped: the build fails loudly). Captions/titles/scenes are checked against
+// the naming rule and the banned-style regex before anything is written.
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
+const OUT = path.join(ROOT, 'generator', 'out');
+const PUB = path.join(ROOT, 'public', 'shadowman');
+const BANNED = /conrad|in the style of|style of [A-Z]|pulitzer/i;
+
+const man = JSON.parse(fs.readFileSync(path.join(OUT, 'manifest.json'), 'utf8'));
+const review = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts', 'shadowman-review.json'), 'utf8'));
+const missing = man.items.filter(m => !review.items[m.id]);
+if (missing.length) { console.error(`FAIL: ${missing.length} renders have no review verdict: ${missing.map(m => m.id).join(', ')}`); process.exit(1); }
+
+fs.mkdirSync(PUB, { recursive: true });
+// Carry curation state (approve/delete from the Inkwell UI) across rebuilds.
+const DATA = path.join(ROOT, 'data', 'shadowman.json');
+const prev = fs.existsSync(DATA) ? JSON.parse(fs.readFileSync(DATA, 'utf8')) : { items: [] };
+const prevStatus = Object.fromEntries(prev.items.map(i => [i.id, { status: i.status, status_at: i.status_at }]));
+const items = [];
+const dropped = [];
+for (const m of man.items) {
+ const r = review.items[m.id];
+ if (r.verdict !== 'keep') { dropped.push({ id: m.id, reason: r.reason }); continue; }
+ const rec = {
+ id: m.id, title: r.title, caption: m.caption, theme: m.theme, era: m.era,
+ scene: m.prompt.replace(/\. bold black ink.*$/, ''),
+ created_at: m.created_at, signature: m.signature, model: m.model, steps: m.steps, seed: m.seed,
+ gen_seconds: m.gen_seconds, cost_usd: m.cost_usd, source: m.source,
+ src: `/shadowman/${m.id}.jpg`, review_note: r.reason || '',
+ status: (prevStatus[m.id] && prevStatus[m.id].status) || 'pending',
+ status_at: (prevStatus[m.id] && prevStatus[m.id].status_at) || null,
+ };
+ for (const k of ['title', 'caption', 'scene', 'theme', 'era', 'review_note']) {
+ if (BANNED.test(rec[k])) { console.error(`FAIL: banned term in ${m.id}.${k}`); process.exit(1); }
+ }
+ const dst = path.join(PUB, `${m.id}.jpg`);
+ if (rec.status === 'deleted') { items.push(rec); continue; } // image lives in generator/out/_trash/
+ execFileSync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '85', path.join(OUT, m.file), '--out', dst], { stdio: 'ignore' });
+ items.push(rec);
+}
+const doc = {
+ generated_at: new Date().toISOString(),
+ note: 'Original single-panel editorial cartoons written from archive themes and eras (never a redraw of a specific published cartoon), rendered locally with SDXL at $0 and signed "Shadow Man".',
+ counts: { briefs: new Set(man.items.map(m => m.retry_of || m.id)).size, rendered: man.items.length, kept: items.length, dropped: dropped.length },
+ dropped,
+ items,
+};
+fs.writeFileSync(DATA, JSON.stringify(doc, null, 2) + '\n');
+console.log(`kept ${items.length}/${man.items.length} -> data/shadowman.json + public/shadowman/*.jpg; dropped ${dropped.length}`);
+for (const d of dropped) console.log(` dropped ${d.id}: ${d.reason}`);
diff --git a/scripts/extract-arena-ideas.cjs b/scripts/extract-arena-ideas.cjs
new file mode 100644
index 0000000..5b8a7ca
--- /dev/null
+++ b/scripts/extract-arena-ideas.cjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// extract-arena-ideas.cjs — pull every cartoon idea out of a finished Model Arena battle (TK-12241). $0, local.
+// Usage: node scripts/extract-arena-ideas.cjs [battleDir] (default ~/Projects/model-arena/data/artifacts/7b062a7d42e5)
+// Each artifact is a model-written HTML page; some build their cards from an inline <script> array
+// (const ideas/cards = [...]), others hard-code .card DOM. Every page is rendered in headless Chromium
+// and read BOTH ways: the JS array when present (authoritative), else the rendered .card DOM.
+// Output: data/arena-ideas.json — deduped, with dropped ideas listed + reasons. Existing curation
+// status (approved/deleted) is carried over by id on re-extract.
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { chromium } = require(process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright'));
+
+const DIR = process.argv[2] || path.join(process.env.HOME, 'Projects/model-arena/data/artifacts/7b062a7d42e5');
+const BATTLE = path.basename(DIR);
+const OUT = path.join(__dirname, '..', 'data', 'arena-ideas.json');
+const NAME = new RegExp(['con', 'rad'].join(''), 'i');
+// Drop rules: real people, brands / franchises, named artists, style-mimicry, and caricature risk.
+const DROP = [
+ [/in the style of|style of [A-Z]/i, 'asks for an artist style'],
+ [NAME, 'names the research subject'],
+ [/\b(trump|biden|obama|clinton|bush|nixon|reagan|kennedy|carter|putin|musk|bezos|zuckerberg|pelosi|mcconnell|harris|desantis|newsom|schwarzenegger)\b/i, 'names a real person'],
+ [/\b(uber|lyft|instagram|twitter|tiktok|facebook|meta|google|amazon|apple|tesla|netflix|disney|starbucks|mcdonald'?s?|coca-?cola|pepsi|walmart|star wars|middle[- ]earth|tolkien|marvel|pixar|lego|iphone|youtube)\b/i, 'names a brand / franchise'],
+ [/\b(picasso|warhol|dali|van gogh|banksy|rockwell|seuss|schulz)\b/i, 'names an artist'],
+ [/\b(black|white|asian|latino|jewish|muslim) (man|woman|person|people)'?s? face\b|face on the pendulum/i, 'racial caricature risk'],
+];
+
+const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+const unquote = (s) => clean(s).replace(/^["“'‘]+|["”'’]+$/g, '');
+const modelOf = (f) => path.basename(f, '.html').replace(/^(mac2|mlx)__/, '');
+
+(async () => {
+ const files = fs.readdirSync(DIR).filter(f => f.endsWith('.html')).sort();
+ const browser = await chromium.launch();
+ const raw = [];
+ const perModel = {};
+ for (const f of files) {
+ const page = await browser.newPage();
+ await page.goto('file://' + path.join(DIR, f), { waitUntil: 'load' });
+ const got = await page.evaluate(() => {
+ // eslint-disable-next-line no-undef
+ const arr = (typeof ideas !== 'undefined' && Array.isArray(ideas)) ? ideas : (typeof cards !== 'undefined' && Array.isArray(cards) && cards.length && !(cards[0] instanceof Element)) ? cards : null;
+ if (arr) return { via: 'script-array', rows: arr.map(o => ({ theme: o.theme, title: o.title, caption: o.caption, scene: o.scene, why: o.why || o.whyFunny || o.reason, drawability: o.drawability })) };
+ const q = (el, sel) => { const n = el.querySelector(sel); return n ? n.textContent : ''; };
+ return { via: 'dom', rows: [...document.querySelectorAll('.card')].map(c => ({
+ theme: q(c, '.theme-chip, .theme'), title: q(c, '.card-title, h2, h3'), caption: q(c, '.caption'), scene: q(c, '.scene'),
+ why: q(c, '.funny-reason, .whys, .why'), drawability: c.getAttribute('data-drawability') || q(c, '.drawability, .score'),
+ })) };
+ });
+ const mtime = fs.statSync(path.join(DIR, f)).mtime.toISOString();
+ perModel[modelOf(f)] = { file: f, via: got.via, extracted: got.rows.length };
+ for (const r of got.rows) raw.push({ ...r, model: modelOf(f), created_at: mtime });
+ await page.close();
+ }
+ await browser.close();
+
+ const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : { items: [] };
+ const prevById = Object.fromEntries(prev.items.map(i => [i.id, i]));
+ const seen = new Map();
+ const items = [];
+ const dropped = [];
+ for (const r of raw) {
+ let caption = unquote(r.caption);
+ const notes = [];
+ if (/[ -鿿]/.test(caption + r.scene)) { caption = caption.replace(/授权/g, 'authorized ').replace(/[ -鿿]+/g, '').replace(/\s+/g, ' ').trim(); notes.push('garbled CJK token cleaned'); }
+ const it = {
+ model: r.model, theme: clean(r.theme).replace(/&/g, '&'), title: clean(r.title), caption,
+ scene: clean(r.scene).replace(/[ -鿿]+/g, ''), why: clean(r.why), drawability: Number(clean(r.drawability)) || null,
+ created_at: r.created_at,
+ };
+ const blob = [it.title, it.caption, it.scene, it.why].join(' ');
+ const hit = DROP.find(([re]) => re.test(blob));
+ if (!it.title || !it.scene) { dropped.push({ model: it.model, title: it.title, reason: 'incomplete (no title/scene)' }); continue; }
+ if (hit) { dropped.push({ model: it.model, title: it.title, reason: hit[1] }); continue; }
+ const key = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
+ const ckey = it.caption.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
+ if (seen.has(key) || seen.has(ckey)) { dropped.push({ model: it.model, title: it.title, reason: `duplicate of ${seen.get(key) || seen.get(ckey)}` }); continue; }
+ seen.set(key, `${it.model}:${it.title}`); seen.set(ckey, `${it.model}:${it.title}`);
+ it.id = 'arena-' + crypto.createHash('sha1').update(`${BATTLE}|${it.model}|${it.title}`).digest('hex').slice(0, 10);
+ const p = prevById[it.id];
+ it.status = (p && p.status) || 'pending';
+ it.status_at = (p && p.status_at) || null;
+ if (notes.length) it.note = notes.join('; ');
+ items.push(it);
+ }
+ for (const m of Object.keys(perModel)) {
+ perModel[m].kept = items.filter(i => i.model === m).length;
+ perModel[m].dropped = dropped.filter(d => d.model === m).length;
+ }
+ const doc = { generated_at: new Date().toISOString(), source: `model-arena battle ${BATTLE}`, per_model: perModel, counts: { extracted: raw.length, kept: items.length, dropped: dropped.length }, dropped, items };
+ const body = JSON.stringify(doc, null, 2);
+ if (NAME.test(body)) { console.error('FAIL: naming rule hit in arena output'); process.exit(1); }
+ fs.writeFileSync(OUT, body + '\n');
+ console.log(`extracted ${raw.length}, kept ${items.length}, dropped ${dropped.length} -> data/arena-ideas.json`);
+ for (const [m, v] of Object.entries(perModel)) console.log(` ${m}: ${v.extracted} extracted via ${v.via}, ${v.kept} kept, ${v.dropped} dropped`);
+ for (const d of dropped) console.log(` dropped [${d.model}] ${d.title}: ${d.reason}`);
+})().catch(e => { console.error(e); process.exit(2); });
diff --git a/scripts/shadowman-review.json b/scripts/shadowman-review.json
new file mode 100644
index 0000000..a23225e
--- /dev/null
+++ b/scripts/shadowman-review.json
@@ -0,0 +1,190 @@
+{
+ "note": "Visual review of every Shadow Man render (TK-12241): hard checks = \"Shadow Man\" signature lower-right, no fake/garbage lettering, no real-person likeness, not blank, AND the drawing clearly shows the brief's central gag. Retry cap = 2 (r1, r2) with simplified prompts.",
+ "items": {
+ "shadowman-20260925T172534-001": {
+ "verdict": "drop",
+ "reason": "gag missing: generic shop counter, no pill/pillow/price"
+ },
+ "shadowman-20260925T172534-002": {
+ "verdict": "drop",
+ "reason": "gag missing: man at desk, no hiding/lobbyist"
+ },
+ "shadowman-20260925T172534-003": {
+ "verdict": "drop",
+ "reason": "gag missing: surreal ruin, no eraser/newspaper"
+ },
+ "shadowman-20260925T172534-004": {
+ "verdict": "drop",
+ "reason": "gag missing: courtroom, no price-tag gavel; fake painted signature bottom-right"
+ },
+ "shadowman-20260925T172534-006": {
+ "verdict": "drop",
+ "reason": "gag missing: oath with bible but no hand in donor pocket"
+ },
+ "shadowman-20260925T172534-007": {
+ "verdict": "drop",
+ "reason": "gag missing: podium occupied, no candle/folded flag"
+ },
+ "shadowman-20260925T172534-010": {
+ "verdict": "drop",
+ "reason": "gag missing: decrepit schoolhouse only, no stadium/quicksand"
+ },
+ "shadowman-20260925T172534-012": {
+ "verdict": "drop",
+ "reason": "gag missing: elephant vs elephant-man, no donkey/voter"
+ },
+ "shadowman-20260925T172534-014": {
+ "verdict": "drop",
+ "reason": "gag missing: masked woman in crowd, does not read as Justice peeking"
+ },
+ "shadowman-20260925T172534-015": {
+ "verdict": "drop",
+ "reason": "garbage lettering on the slot machine"
+ },
+ "shadowman-20260925T172534-016": {
+ "verdict": "drop",
+ "reason": "gag missing: house-boat at sea, no leak/bailing"
+ },
+ "shadowman-20260925T172534-017": {
+ "verdict": "drop",
+ "reason": "gag missing: kissing a woman, no baby/pickpocket"
+ },
+ "shadowman-20260925T172534-018": {
+ "verdict": "drop",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ },
+ "shadowman-20260925T172534-019": {
+ "verdict": "drop",
+ "reason": "gag missing: rope knot over a maze, no politician"
+ },
+ "shadowman-20260925T172534-001-r1": {
+ "verdict": "drop",
+ "reason": "garbage lettering on the cushion object; no price/single pill"
+ },
+ "shadowman-20260925T172534-003-r1": {
+ "verdict": "drop",
+ "reason": "garbage fake headline lettering across the newspaper"
+ },
+ "shadowman-20260925T172534-004-r1": {
+ "verdict": "drop",
+ "reason": "garbage lettering on the price sign (\"PRICE PIAIG\")"
+ },
+ "shadowman-20260925T172534-006-r1": {
+ "verdict": "drop",
+ "reason": "gag missing: raised hand only, no donor pocket"
+ },
+ "shadowman-20260925T172534-007-r1": {
+ "verdict": "drop",
+ "reason": "gag missing: speaker at podium, no candle/flag"
+ },
+ "shadowman-20260925T172534-010-r1": {
+ "verdict": "drop",
+ "reason": "gag missing: beach house on sand, no stadium/quicksand"
+ },
+ "shadowman-20260925T172534-014-r1": {
+ "verdict": "drop",
+ "reason": "gag missing: Lady Justice without blindfold, not peeking"
+ },
+ "shadowman-20260925T172534-015-r1": {
+ "verdict": "drop",
+ "reason": "garbage lettering on the slot machine reels"
+ },
+ "shadowman-20260925T172534-018-r1": {
+ "verdict": "drop",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ },
+ "shadowman-20260925T172534-005": {
+ "verdict": "keep",
+ "title": "Ribbon-Cutting at the Pothole",
+ "reason": "PASS (weak): officials + press camera ceremonially gathered at a pothole; ribbon not drawn"
+ },
+ "shadowman-20260925T172534-008": {
+ "verdict": "keep",
+ "title": "Paper Mountain",
+ "reason": "PASS: official at his desk beside a mountain of shredded/stacked paper"
+ },
+ "shadowman-20260925T172534-009": {
+ "verdict": "keep",
+ "title": "Traffic Relief",
+ "reason": "PASS (weak): total gridlock under an overpass carries the caption irony; floating city hall not drawn"
+ },
+ "shadowman-20260925T172534-011": {
+ "verdict": "keep",
+ "title": "The Money Tank",
+ "reason": "PASS: a tank built of dollar bills rolling over a field of cash"
+ },
+ "shadowman-20260925T172534-013": {
+ "verdict": "keep",
+ "title": "The Marionette Chamber",
+ "reason": "PASS: a legislative chamber seated with marionettes on strings"
+ },
+ "shadowman-20260925T172534-020": {
+ "verdict": "keep",
+ "title": "Passing the Plate",
+ "reason": "PASS (weak): portly legislator passing a collection jar along the chamber benches"
+ },
+ "shadowman-20260925T172534-002-r1": {
+ "verdict": "keep",
+ "title": "Good Boy",
+ "reason": "PASS: grinning lobbyist patting a defeated lawmaker slumped at his desk"
+ },
+ "shadowman-20260925T172534-012-r1": {
+ "verdict": "keep",
+ "title": "Party Arm-Wrestle",
+ "reason": "PASS: elephant and donkey locked in an arm-wrestle across a table (voter underneath not drawn)"
+ },
+ "shadowman-20260925T172534-016-r1": {
+ "verdict": "keep",
+ "title": "Ship of State",
+ "reason": "PASS (weak): officials crowding a storm-tossed ship of state; bailing not drawn"
+ },
+ "shadowman-20260925T172534-017-r1": {
+ "verdict": "keep",
+ "title": "The Baby Kisser",
+ "reason": "PASS: beaming politician cuddling a baby with a fistful of cash"
+ },
+ "shadowman-20260925T172534-019-r1": {
+ "verdict": "keep",
+ "title": "The Pretzel District",
+ "reason": "PASS: a politician stuck inside a giant twisted pretzel knot"
+ },
+ "shadowman-20260925T172534-001-r2": {
+ "verdict": "drop",
+ "reason": "gag unclear: pills read as gemstones in a jewelry case"
+ },
+ "shadowman-20260925T172534-003-r2": {
+ "verdict": "drop",
+ "reason": "gag missing: crowd portrait, no eraser/photo"
+ },
+ "shadowman-20260925T172534-004-r2": {
+ "verdict": "drop",
+ "reason": "gag missing: robed speaker, no auction/bidders"
+ },
+ "shadowman-20260925T172534-006-r2": {
+ "verdict": "keep",
+ "title": "The Donor Oath",
+ "reason": "PASS: politician taking the oath with a hand full of cash, a donor at his side, money on the table"
+ },
+ "shadowman-20260925T172534-007-r2": {
+ "verdict": "keep",
+ "title": "The Empty Stage",
+ "reason": "PASS: a single memorial candle alone on an empty stage (podium/flag not drawn)"
+ },
+ "shadowman-20260925T172534-010-r2": {
+ "verdict": "drop",
+ "reason": "gag missing: stadium only, no schoolhouse"
+ },
+ "shadowman-20260925T172534-014-r2": {
+ "verdict": "drop",
+ "reason": "gag missing: Justice with scales, no blindfold peek"
+ },
+ "shadowman-20260925T172534-015-r2": {
+ "verdict": "drop",
+ "reason": "gag unclear: man with a box of coins, no slot-machine ballot box/lever"
+ },
+ "shadowman-20260925T172534-018-r2": {
+ "verdict": "drop",
+ "reason": "gag missing: Capitol only, no rubber stamp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/verify-inkwell.cjs b/scripts/verify-inkwell.cjs
index 213a2af..9a255f3 100644
--- a/scripts/verify-inkwell.cjs
+++ b/scripts/verify-inkwell.cjs
@@ -1,6 +1,9 @@
#!/usr/bin/env node
// verify-inkwell.cjs — E2E check of the Inkwell app (TK-12230). $0, local.
-// Usage: BASE=http://localhost:9947 SHOTS=/path/to/dir node scripts/verify-inkwell.cjs
+// Usage: BASE=http://localhost:9947 SHOTS=/path/to/dir [INKWELL_ROOT=/path/to/app/copy] node scripts/verify-inkwell.cjs
+// The curation checks (TK-12241) MUTATE the target's data (then undo it). Run them against a scratch
+// copy of the app (INKWELL_ROOT = that copy, enables the file-move / idea-queue assertions), never
+// against the live pm2 checkout.
// Exit 0 only if every assertion passes. Includes a negative self-test proving the name
// detector goes red on an injected fault (a check that cannot fail proves nothing).
const path = require('path');
@@ -8,6 +11,7 @@ const fs = require('fs');
const { chromium } = require(process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright'));
const BASE = process.env.BASE || 'http://localhost:9947';
+const ROOT = process.env.INKWELL_ROOT || null;
const SHOTS = process.env.SHOTS || path.join(__dirname, '..', 'shots');
const NAME = new RegExp(['con', 'rad'].join(''), 'i');
const results = [];
@@ -22,7 +26,7 @@ const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail
const expected = p24.items.length;
const expectedImgs = p24.items.reduce((n, i) => n + 1 + (i.panels || []).length, 0);
check('/api/p24 has items', expected > 0, `${p24.counts.photo} photos + ${p24.counts.cartoon} cartoons = ${expected}; ${expectedImgs} images incl. panels`);
- for (const ep of ['/api/p24', '/api/pd-cartoons', '/health', '/', '/app.js', '/p24.js', '/style.css']) {
+ for (const ep of ['/api/p24', '/api/pd-cartoons', '/api/shadowman', '/health', '/', '/app.js', '/p24.js', '/shadowman.js', '/style.css']) {
const r = await fetch(BASE + ep);
const body = await r.text();
check(`${ep} 200 and name-free`, r.status === 200 && !NAME.test(body), `status ${r.status}`);
@@ -33,6 +37,14 @@ const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail
const pd = await (await fetch(BASE + '/api/pd-cartoons')).json();
check('/api/pd-cartoons has items, each with rights + local image + http source', pd.items.length > 0 && pd.items.every(i => i.rights && /^\/pd\//.test(i.image) && /^https?:/.test(i.source_url)), `${pd.items.length} items`);
+ // Shadow Man (TK-12241): API shape + every kept piece has title/caption/theme/era/ISO date + local jpg.
+ const sm = await (await fetch(BASE + '/api/shadowman')).json();
+ check('/api/shadowman has items with title/caption/theme/era/created_at/local src', sm.items.length > 0 && sm.items.every(i => i.title && i.caption && i.theme && i.era && !isNaN(Date.parse(i.created_at)) && /^\/shadowman\/[\w-]+\.jpg$/.test(i.src) && i.signature === 'Shadow Man'), `${sm.items.length} items (rendered ${sm.counts.rendered}, kept ${sm.counts.kept}, dropped ${sm.counts.dropped})`);
+ check('/api/shadowman counts consistent', sm.counts.kept === sm.items.length && sm.counts.kept + sm.counts.dropped === sm.counts.rendered);
+ const smTheme = sm.items[0].theme;
+ const smFiltered = await (await fetch(BASE + '/api/shadowman?theme=' + encodeURIComponent(smTheme))).json();
+ check('/api/shadowman?theme= filters server-side', smFiltered.items.length === sm.items.filter(i => i.theme === smTheme).length && smFiltered.items.every(i => i.theme === smTheme), `${smTheme}: ${smFiltered.items.length}`);
+
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const page = await ctx.newPage();
@@ -101,6 +113,144 @@ const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail
await page.screenshot({ path: path.join(SHOTS, 'inkwell-modal.png') });
await page.keyboard.press('Escape');
+ // ---- Shadow Man gallery (TK-12241) ----
+ await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:sm:')).forEach(k => localStorage.removeItem(k)));
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('.sm-card');
+ await page.evaluate(() => document.querySelectorAll('#sm-grid img').forEach(i => { i.loading = 'eager'; }));
+ await page.locator('#shadowman').scrollIntoViewIfNeeded();
+ await page.waitForFunction(() => [...document.querySelectorAll('#sm-grid img')].every(i => i.complete), null, { timeout: 60000 });
+ const smImgs = await page.evaluate(() => [...document.querySelectorAll('#sm-grid img')].map(i => ({ src: i.getAttribute('src'), w: i.naturalWidth })));
+ check('Shadow Man: card count = /api/shadowman items', smImgs.length === sm.items.length, `${smImgs.length}/${sm.items.length}`);
+ check('Shadow Man: every image rendered (naturalWidth>0)', smImgs.length > 0 && smImgs.every(i => i.w > 0), `${smImgs.filter(i => i.w > 0).length}/${smImgs.length}`);
+ let smBad = 0;
+ for (const i of smImgs) { const r = await fetch(BASE + i.src); if (r.status !== 200 || !/image\/jpeg/.test(r.headers.get('content-type') || '')) smBad++; }
+ check('Shadow Man: every image HTTP 200 image/jpeg', smBad === 0, `${smImgs.length - smBad}/${smImgs.length}`);
+ const smChips = await page.evaluate(() => [...document.querySelectorAll('.sm-card .when')].map(w => ({ t: w.textContent, iso: w.title })));
+ check('Shadow Man: every card has date+time chip with ISO title', smChips.length === sm.items.length && smChips.every(c => /\d{4}/.test(c.t) && /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))), smChips[0] && smChips[0].t);
+ check('Shadow Man: every card shows theme + era chips', await page.locator('.sm-card .sm-theme').count() === sm.items.length && await page.locator('.sm-card .sm-era').count() === sm.items.length);
+ await page.locator('#shadowman').screenshot({ path: path.join(SHOTS, 'inkwell-shadowman.png') });
+ await page.selectOption('#sm-theme', smTheme);
+ check('Shadow Man: theme filter', await page.locator('.sm-card').count() === sm.items.filter(i => i.theme === smTheme).length, smTheme);
+ const smEra = sm.items[sm.items.length - 1].era;
+ await page.selectOption('#sm-theme', 'all');
+ await page.selectOption('#sm-era', smEra);
+ check('Shadow Man: era filter', await page.locator('.sm-card').count() === sm.items.filter(i => i.era === smEra).length, smEra);
+ await page.selectOption('#sm-sort', 'title-az');
+ const smTitles = await page.$$eval('.sm-card .title', els => els.map(e => e.textContent));
+ check('Shadow Man: sort Title A→Z', smTitles.join('|') === [...smTitles].sort((a, b) => a.localeCompare(b)).join('|'));
+ await page.$eval('#sm-density', el => { el.value = '180'; el.dispatchEvent(new Event('input', { bubbles: true })); });
+ const smCols180 = await page.$eval('#sm-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('.sm-card');
+ const smPersist = await page.evaluate(() => ({ era: document.getElementById('sm-era').value, sort: document.getElementById('sm-sort').value, density: document.getElementById('sm-density').value, cols: getComputedStyle(document.getElementById('sm-grid')).gridTemplateColumns.split(' ').length }));
+ check('Shadow Man: era/sort/density persist across reload', smPersist.era === smEra && smPersist.sort === 'title-az' && smPersist.density === '180' && smPersist.cols === smCols180, JSON.stringify(smPersist));
+ await page.$eval('#sm-density', el => { el.value = '460'; el.dispatchEvent(new Event('input', { bubbles: true })); });
+ const smCols460 = await page.$eval('#sm-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
+ check('Shadow Man: density slider changes column count', smCols180 > smCols460, `${smCols180} @180 -> ${smCols460} @460`);
+ await page.selectOption('#sm-era', 'all');
+ await page.locator('.sm-card').first().click();
+ await page.waitForSelector('.modal .p24-full');
+ const smModal = await page.$eval('#modal-body', el => el.innerText);
+ check('Shadow Man: modal shows signature + caption', /Shadow Man/.test(smModal) && sm.items.some(i => smModal.includes(i.caption)));
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: path.join(SHOTS, 'inkwell-shadowman-modal.png') });
+ await page.keyboard.press('Escape');
+
+ // ---- Curation (TK-12241): select 1..many, big Approve / Delete, bulk bar, confirm modal ----
+ let nativeDialogs = 0;
+ page.on('dialog', d => { nativeDialogs++; d.dismiss(); });
+ await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:')).forEach(k => localStorage.removeItem(k)));
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('#sm-grid .sm-card');
+ const btnH = await page.$$eval('#sm-grid .big-btn, #ar-grid .big-btn', els => els.map(e => e.getBoundingClientRect().height));
+ check('curation: every Approve/Delete button >= 44px tall', btnH.length > 0 && btnH.every(h => h >= 44), `${btnH.length} buttons, min ${Math.min(...btnH).toFixed(0)}px`);
+ const selH = await page.$$eval('#sm-grid .sel', els => els.map(e => e.getBoundingClientRect().height));
+ check('curation: select targets >= 44px tall', selH.every(h => h >= 44), `min ${Math.min(...selH).toFixed(0)}px`);
+ check('curation: bulk bar hidden with nothing selected', await page.locator('#sm-bulk').isHidden());
+ const smIds = await page.$$eval('#sm-grid .sm-card', els => els.map(e => e.dataset.id));
+ const [a1, a2, d1] = smIds;
+ // keyboard: Space on a focused checkbox selects it
+ await page.focus(`#sm-grid .sel-box[data-id="${a1}"]`);
+ await page.keyboard.press('Space');
+ await page.check(`#sm-grid .sel-box[data-id="${a2}"]`);
+ check('curation: keyboard + click select 2 -> bulk bar "Approve 2"', await page.locator('#sm-bulk').isVisible() && (await page.textContent('#sm-bulk [data-bulk="approve"]')).trim() === 'Approve 2');
+ await page.screenshot({ path: path.join(SHOTS, 'inkwell-curate-selected.png') });
+ await page.click('#sm-bulk [data-bulk="approve"]');
+ await page.waitForFunction(ids => ids.every(id => document.querySelector(`.sm-card[data-id="${id}"] .status-chip.status-approved`)), [a1, a2]);
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('#sm-grid .sm-card');
+ const approvedAfter = await page.$$eval('#sm-grid .sm-card.status-approved', els => els.map(e => e.dataset.id).sort());
+ const apiApproved = (await (await fetch(BASE + '/api/shadowman?status=approved')).json()).items.map(i => i.id).sort();
+ check('curation: Approve 2 persisted across reload (UI + API)', JSON.stringify(approvedAfter) === JSON.stringify([a1, a2].sort()) && JSON.stringify(apiApproved) === JSON.stringify([a1, a2].sort()), approvedAfter.join(','));
+ // delete 1 via its big button -> in-page confirm modal
+ const d1Src = (await (await fetch(BASE + '/api/shadowman')).json()).items.find(i => i.id === d1).src;
+ await page.click(`.sm-card[data-id="${d1}"] .big-btn.delete`);
+ check('curation: delete opens the in-page confirm modal', await page.locator('#confirm-backdrop.open').isVisible());
+ await page.screenshot({ path: path.join(SHOTS, 'inkwell-curate-confirm.png') });
+ await page.click('#confirm-ok');
+ await page.waitForFunction(id => !document.querySelector(`.sm-card[data-id="${id}"]`), d1);
+ const trashed = await fetch(BASE + d1Src);
+ let moved = true;
+ if (ROOT) moved = !fs.existsSync(path.join(ROOT, 'public', d1Src)) && fs.existsSync(path.join(ROOT, 'generator', 'out', '_trash', path.basename(d1Src)));
+ check('curation: Delete hides card, image moved to generator/out/_trash (HTTP 404 on old src)', trashed.status === 404 && moved, `status ${trashed.status}${ROOT ? ', trash file present' : ' (fs check skipped: no INKWELL_ROOT)'}`);
+ await page.check('#sm-show-deleted');
+ await page.waitForSelector(`.sm-card[data-id="${d1}"] .big-btn.restore`);
+ await page.click(`.sm-card[data-id="${d1}"] .big-btn.restore`);
+ await page.waitForSelector(`.sm-card[data-id="${d1}"] .status-chip.status-pending`);
+ const back = await fetch(BASE + d1Src);
+ check('curation: Restore brings the image back (HTTP 200) and status pending', back.status === 200, `status ${back.status}`);
+ await page.uncheck('#sm-show-deleted');
+ // bulk Select all / Clear
+ await page.check(`#sm-grid .sel-box[data-id="${a1}"]`);
+ await page.click('#sm-bulk [data-bulk="all"]');
+ const nAll = await page.locator('#sm-grid .sel-box:checked').count();
+ await page.click('#sm-bulk [data-bulk="clear"]');
+ check('curation: Select all / Clear', nAll === smIds.length && await page.locator('#sm-grid .sel-box:checked').count() === 0 && await page.locator('#sm-bulk').isHidden(), `${nAll}/${smIds.length}`);
+ // undo the approvals so the scratch data ends where it started
+ await fetch(BASE + '/api/shadowman/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [a1, a2] }) });
+
+ // Arena ideas
+ const ar = await (await fetch(BASE + '/api/arena-ideas')).json();
+ check('/api/arena-ideas has items with model/theme/title/caption/scene + ISO date, name-free', ar.items.length > 0 && ar.items.every(i => i.model && i.theme && i.title && i.caption && i.scene && !isNaN(Date.parse(i.created_at))) && !NAME.test(JSON.stringify(ar)), `${ar.items.length} ideas from ${Object.keys(ar.per_model).length} models`);
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('#ar-grid .ar-card');
+ check('arena: card count = /api/arena-ideas items', await page.locator('#ar-grid .ar-card').count() === ar.items.length);
+ const arChips = await page.$$eval('#ar-grid .ar-card .when', els => els.map(w => ({ t: w.textContent, iso: w.title })));
+ check('arena: every card has date+time chip with ISO title', arChips.length === ar.items.length && arChips.every(c => /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))));
+ const firstModel = ar.items[0].model;
+ await page.selectOption('#ar-model', firstModel);
+ check('arena: model filter', await page.locator('#ar-grid .ar-card').count() === ar.items.filter(i => i.model === firstModel).length, firstModel);
+ await page.selectOption('#ar-model', 'all');
+ const arIds = await page.$$eval('#ar-grid .ar-card', els => els.map(e => e.dataset.id));
+ const [q1, q2] = arIds;
+ await page.check(`#ar-grid .sel-box[data-id="${q1}"]`);
+ await page.check(`#ar-grid .sel-box[data-id="${q2}"]`);
+ await page.click('#ar-bulk [data-bulk="approve"]');
+ await page.waitForFunction(ids => ids.every(id => document.querySelector(`.ar-card[data-id="${id}"] .status-chip.status-approved`)), [q1, q2]);
+ await page.reload({ waitUntil: 'networkidle' });
+ await page.waitForSelector('#ar-grid .ar-card');
+ const arApproved = await page.$$eval('#ar-grid .ar-card.status-approved', els => els.map(e => e.dataset.id).sort());
+ let queued = null;
+ if (ROOT) queued = JSON.parse(fs.readFileSync(path.join(ROOT, 'generator', 'idea-queue.json'), 'utf8')).items.map(b => b.id).sort();
+ check('arena: Approve 2 persisted across reload + queued as briefs in generator/idea-queue.json', JSON.stringify(arApproved) === JSON.stringify([q1, q2].sort()) && (!ROOT || JSON.stringify(queued) === JSON.stringify([q1, q2].sort())), ROOT ? `queue=${queued.length}` : 'queue check skipped');
+ await page.click(`.ar-card[data-id="${q1}"] .big-btn.delete`);
+ await page.click('#confirm-ok');
+ await page.waitForFunction(id => !document.querySelector(`.ar-card[data-id="${id}"]`), q1);
+ const arDoc = await (await fetch(BASE + '/api/arena-ideas')).json();
+ if (ROOT) queued = JSON.parse(fs.readFileSync(path.join(ROOT, 'generator', 'idea-queue.json'), 'utf8')).items.map(b => b.id);
+ check('arena: Delete keeps the idea in the file (status deleted), hides it, and pulls it from the queue', arDoc.items.find(i => i.id === q1).status === 'deleted' && (!ROOT || !queued.includes(q1)));
+ await page.check('#ar-show-deleted');
+ await page.click(`.ar-card[data-id="${q1}"] .big-btn.restore`);
+ await page.waitForSelector(`.ar-card[data-id="${q1}"] .status-chip.status-pending`);
+ check('arena: Restore returns a deleted idea to pending', (await (await fetch(BASE + '/api/arena-ideas')).json()).items.find(i => i.id === q1).status === 'pending');
+ await page.uncheck('#ar-show-deleted');
+ await page.locator('#arena').screenshot({ path: path.join(SHOTS, 'inkwell-arena.png') });
+ await fetch(BASE + '/api/arena-ideas/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [q2] }) });
+ check('curation: no native window.confirm/alert dialogs used', nativeDialogs === 0, `${nativeDialogs}`);
+ const lan = await fetch(BASE + '/api/shadowman/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ check('curation: POST without ids rejected (400)', lan.status === 400, `status ${lan.status}`);
+
// Name-free page text (whole document)
const text = await page.evaluate(() => document.documentElement.innerText + ' ' + document.title);
check('page text contains no name', !NAME.test(text));
diff --git a/server.js b/server.js
index ab25a8a..1a9ee2f 100644
--- a/server.js
+++ b/server.js
@@ -58,6 +58,121 @@ app.get('/api/p24', (req, res) => {
}
});
+// ---- Curation (TK-12241): Shadow Man cartoons + Model Arena ideas ------------------------------
+// Both collections carry a per-item status: pending | approved | deleted. Mutations are POST-only,
+// LOOPBACK-ONLY (this is a no-auth local tool; a LAN client must not be able to curate), run the
+// response through sendClean(), and append one line per action to data/shadowman-actions.jsonl.
+// Deleting a Shadow Man piece MOVES its image to generator/out/_trash/ (never unlinks); restore
+// moves it back. Approving an arena idea appends a brief to generator/idea-queue.json (the queue
+// generator/compose-prompts.mjs --from-queue turns into the next render batch); restore removes it.
+const DATA_DIR = path.join(__dirname, 'data');
+const PUB_SM = path.join(__dirname, 'public', 'shadowman');
+const TRASH = path.join(__dirname, 'generator', 'out', '_trash');
+const QUEUE = path.join(__dirname, 'generator', 'idea-queue.json');
+const ACTIONS = path.join(DATA_DIR, 'shadowman-actions.jsonl');
+
+app.use(express.json({ limit: '64kb' }));
+
+function writeJsonAtomic(file, obj) {
+ const tmp = file + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
+ fs.renameSync(tmp, file);
+}
+
+function loopbackOnly(req, res, next) {
+ const ip = req.socket.remoteAddress || '';
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next();
+ res.status(403).json({ error: 'curation is loopback-only' });
+}
+
+function listFrom(doc, query) {
+ const { theme, era, model, status } = query;
+ return doc.items.filter(i => (!theme || i.theme === theme) && (!era || i.era === era) && (!model || i.model === model) && (!status || i.status === status));
+}
+
+function moveFile(from, to) {
+ fs.mkdirSync(path.dirname(to), { recursive: true });
+ fs.renameSync(from, to);
+}
+
+const COLLECTIONS = {
+ shadowman: {
+ file: path.join(DATA_DIR, 'shadowman.json'),
+ onDelete(item) {
+ const src = path.join(PUB_SM, item.id + '.jpg');
+ if (fs.existsSync(src)) moveFile(src, path.join(TRASH, item.id + '.jpg'));
+ },
+ onRestore(item) {
+ const t = path.join(TRASH, item.id + '.jpg');
+ if (item.status === 'deleted' && fs.existsSync(t)) moveFile(t, path.join(PUB_SM, item.id + '.jpg'));
+ },
+ },
+ 'arena-ideas': {
+ file: path.join(DATA_DIR, 'arena-ideas.json'),
+ onApprove(item) {
+ const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { note: 'Approved Model Arena ideas waiting to be rendered. node generator/compose-prompts.mjs --from-queue turns un-batched entries into generator/out/batch-queue-<date>.json.', items: [] };
+ if (!q.items.some(b => b.id === item.id)) {
+ q.items.push({ id: item.id, theme: item.theme, era: null, title: item.title, scene: item.scene, caption: item.caption, source: 'model-arena:' + item.model, queued_at: new Date().toISOString(), batched_at: null });
+ writeJsonAtomic(QUEUE, q);
+ }
+ },
+ onDelete(item) { dequeue(item); },
+ onRestore(item) { dequeue(item); },
+ },
+};
+
+// Un-approving (restore) or deleting an approved idea pulls its brief back out of the queue,
+// unless it was already handed to a render batch (then the batch is the record).
+function dequeue(item) {
+ if (item.status !== 'approved' || !fs.existsSync(QUEUE)) return;
+ const q = JSON.parse(fs.readFileSync(QUEUE, 'utf8'));
+ const before = q.items.length;
+ q.items = q.items.filter(b => b.id !== item.id || b.batched_at);
+ if (q.items.length !== before) writeJsonAtomic(QUEUE, q);
+}
+
+for (const [name, col] of Object.entries(COLLECTIONS)) {
+ app.get('/api/' + name, (req, res) => {
+ try {
+ const doc = readJson(path.relative(__dirname, col.file));
+ sendClean(res, { ...doc, items: listFrom(doc, req.query) });
+ } catch (err) {
+ res.status(500).json({ error: `could not read ${path.basename(col.file)}`, detail: neutralText(err.message) });
+ }
+ });
+ for (const action of ['approve', 'delete', 'restore']) {
+ app.post(`/api/${name}/${action}`, loopbackOnly, (req, res) => {
+ try {
+ const ids = Array.isArray(req.body && req.body.ids) ? [...new Set(req.body.ids.map(String))] : [];
+ if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
+ const doc = JSON.parse(fs.readFileSync(col.file, 'utf8'));
+ const byId = new Map(doc.items.map(i => [i.id, i]));
+ const unknown = ids.filter(id => !byId.has(id));
+ if (unknown.length) return res.status(404).json({ error: 'unknown ids', ids: unknown });
+ const now = new Date().toISOString();
+ const changed = [];
+ for (const id of ids) {
+ const it = byId.get(id);
+ const next = action === 'approve' ? 'approved' : action === 'delete' ? 'deleted' : 'pending';
+ if (it.status === next) continue;
+ if (action === 'approve' && it.status === 'deleted') continue; // restore first
+ if (action === 'approve' && col.onApprove) col.onApprove(it);
+ if (action === 'delete' && col.onDelete) col.onDelete(it);
+ if (action === 'restore' && col.onRestore) col.onRestore(it);
+ it.status = next;
+ it.status_at = now;
+ changed.push(id);
+ }
+ writeJsonAtomic(col.file, doc);
+ fs.appendFileSync(ACTIONS, JSON.stringify({ ts: now, collection: name, action, ids, changed }) + '\n');
+ sendClean(res, { ok: true, action, changed, items: doc.items });
+ } catch (err) {
+ res.status(500).json({ error: `${action} failed`, detail: neutralText(err.message) });
+ }
+ });
+ }
+}
+
app.get('/health', (req, res) => res.json({ ok: true, app: 'inkwell' }));
app.listen(PORT, () => {
← 5891a07 Replace text-only research-records section with public-domai
·
back to Paul Conrad Cartoons
·
TK-12241: export approved Shadow Man pieces to the P24 Carto 2ba5293 →