")
+
+ # ---------- landing
+ with gr.Column(visible=True) as pg_landing:
+ gr.HTML(f"
{WELCOME}
")
+ with gr.Row():
+ gr.Column(scale=1, min_width=10)
+ btn_taro = gr.Button("✦ Tarot Reading", elem_classes=["big-btn"], scale=2)
+ btn_viewnav = gr.Button("◈ View Deck", scale=2)
+ gr.Column(scale=1, min_width=10)
+
+ # ---------- tarot submenu
+ with gr.Column(visible=True) as pg_taro:
+ gr.Markdown("### Tarot Reading", elem_classes=["arc-h"])
+ with gr.Row():
+ btn_gen_new = gr.Button("✦ Generate New Deck", elem_classes=["big-btn"], scale=2)
+ btn_read_existing = gr.Button("◈ Read from Existing Deck", scale=2)
+ taro_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
+
+ # ---------- generate
+ with gr.Column(visible=True) as pg_generate:
+ gr.Markdown("### Conjure a Deck", elem_classes=["arc-h"])
+ theme = gr.Textbox(placeholder="thermodynamics · the French Revolution · breakfast foods…",
+ show_label=False, value=DEFAULT_THEME, max_lines=1)
+ with gr.Row(elem_id="chips"):
+ chips = [gr.Button(t, size="sm", elem_classes=["secondary"]) for t in EXAMPLE_THEMES]
+ with gr.Row():
+ style = gr.Dropdown(STYLE_CHOICES, value="rider-waite-smith", label="Visual style", scale=3)
+ custom = gr.Textbox(label="Custom style", placeholder=CUSTOM_PLACEHOLDER,
+ visible=False, scale=4, max_lines=1)
+ conjure = gr.Button("✦ Conjure the deck", elem_classes=["big-btn"])
+ gen_status = gr.Markdown("", elem_classes=["status"])
+ preview = gr.Gallery(value=[], columns=6, height=420, object_fit="contain",
+ show_label=False, elem_id="preview", preview=False)
+ with gr.Row(visible=False) as gen_fork:
+ btn_save_finish = gr.Button("✦ Save & Finish", scale=2)
+ btn_begin_reading = gr.Button("◈ Begin the Reading", elem_classes=["big-btn"], scale=2)
+ gen_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
+
+ # ---------- pick (read-from-existing OR view)
+ with gr.Column(visible=True) as pg_pick:
+ pick_title = gr.Markdown("### Choose a Deck", elem_classes=["arc-h"])
+ pick_dd = gr.Dropdown(choices=deck_choices(), label="Saved & bundled decks")
+ with gr.Row():
+ pick_open = gr.Button("✦ Open", elem_classes=["big-btn"], scale=2)
+ pick_refresh = gr.Button("⟳ Refresh", scale=1, elem_classes=["secondary"])
+ pick_back = gr.Button("↩ Back", scale=1, elem_classes=["secondary"])
+
+ # ---------- reading room
+ with gr.Column(visible=True) as pg_reading:
+ reading_head = gr.Markdown("### The Reading Room", elem_classes=["arc-h"])
+ with gr.Row():
+ question = gr.Textbox(label="Your question", scale=6, max_lines=2,
+ placeholder="Will my side-project ever ship?")
+ spread = gr.Dropdown(SPREAD_CHOICES, value="three", label="Spread", scale=2)
+ reversals = gr.Checkbox(value=True, label="Allow reversals", scale=1)
+ draw = gr.Button("✦ Draw the cards", elem_classes=["big-btn"])
+ spread_view = gr.HTML("
Pose your question, then draw.
")
+ reading = gr.Markdown("", elem_id="reading")
+ with gr.Row():
+ save_in_read = gr.Button("✦ Save this deck", scale=1, elem_classes=["secondary"])
+ read_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
+ save_note = gr.Markdown("", elem_classes=["status"])
+
+ # ---------- view deck
+ with gr.Column(visible=True) as pg_view:
+ view_head = gr.Markdown("### The Deck", elem_classes=["arc-h"])
+ view_gallery = gr.Gallery(value=[], columns=6, height=640, object_fit="contain",
+ show_label=False, elem_id="deck", preview=False,
+ allow_preview=True)
+ view_back = gr.Button("↩ Back to start", scale=1, elem_classes=["secondary"])
+
+ pages = [pg_landing, pg_taro, pg_generate, pg_pick, pg_reading, pg_view]
+
+ # ---------- wiring: navigation
+ btn_taro.click(lambda: show("taro"), None, pages)
+ taro_back.click(lambda: show("landing"), None, pages)
+ gen_back.click(lambda: show("taro"), None, pages)
+ pick_back.click(lambda: show("landing"), None, pages)
+ read_back.click(lambda: show("landing"), None, pages)
+ view_back.click(lambda: show("landing"), None, pages)
+
+ btn_gen_new.click(lambda: show("generate"), None, pages)
+ btn_read_existing.click(lambda: "read", None, pick_mode).then(
+ lambda: gr.update(choices=deck_choices()), None, pick_dd).then(
+ lambda: show("pick"), None, pages)
+ btn_viewnav.click(lambda: "view", None, pick_mode).then(
+ lambda: gr.update(choices=deck_choices()), None, pick_dd).then(
+ lambda: show("pick"), None, pages)
+
+ # style picker + examples
+ style.change(toggle_custom, style, custom)
+ for chip, t in zip(chips, EXAMPLE_THEMES):
+ chip.click(lambda t=t: t, None, theme)
+
+ # generate
+ conjure.click(do_conjure, [theme, style, custom],
+ [preview, deck_state, gen_status, gen_fork])
+ btn_save_finish.click(save_current, deck_state, gen_status).then(
+ lambda: show("landing"), None, pages)
+ btn_begin_reading.click(lambda: show("reading"), None, pages)
+
+ # pick → open
+ pick_refresh.click(lambda: gr.update(choices=deck_choices()), None, pick_dd)
+ pick_open.click(open_picked, [pick_dd, pick_mode],
+ [deck_state, view_gallery, *pages])
+
+ # reading
+ draw.click(do_reading, [deck_state, question, spread, reversals],
+ [spread_view, reading])
+ save_in_read.click(save_current, deck_state, save_note)
+
+ # pages are created visible=True so gallery components mount cleanly
+ # (a Gradio Gallery built inside a visible=False column never mounts);
+ # on load, collapse to just the landing page.
+ demo.load(lambda: show("landing"), None, pages)
+
+ return demo
+
+
+def build_app() -> FastAPI:
+ app = FastAPI()
+ os.makedirs(DECKS_DIR, exist_ok=True)
+ app.mount("/decks", StaticFiles(directory=DECKS_DIR), name="decks")
+ demo = build_demo()
+ demo.queue(default_concurrency_limit=4)
+ return gr.mount_gradio_app(app, demo, path="/", css=CSS,
+ theme=gr.themes.Soft(), ssr_mode=False)
+
+
+app = build_app()
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
diff --git a/arcana/__init__.py b/arcana/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/arcana/archetypes.py b/arcana/archetypes.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6595956cf3219e3e47b951eb900b2e787bd21e0
--- /dev/null
+++ b/arcana/archetypes.py
@@ -0,0 +1,60 @@
+"""The 22 Major Arcana — the fixed scaffold every deck maps onto.
+
+Agent 1 (the Deck Designer, SPEC §4) does NOT free-invent 22 cards. It assigns
+one in-theme concept to each of these fixed archetypes, justified by *shared
+meaning*. That guarantees exactly 22 cards, inherits a coherent meaning
+structure, and forces the "clever-but-right" fit that the whole toy lives on.
+
+`roman` is rendered onto the card frame (SPEC §8/§9); `meaning` is the canonical
+gloss that grounds each mapping and goes verbatim into the system prompt.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Arcana:
+ number: int # 0-21
+ roman: str # "0", "I", ... "XXI" — rendered on the frame
+ name: str # canonical archetype name
+ meaning: str # canonical upright gloss that grounds the mapping
+
+
+MAJOR_ARCANA: tuple[Arcana, ...] = (
+ Arcana(0, "0", "The Fool", "new beginnings, innocence, leap of faith"),
+ Arcana(1, "I", "The Magician", "manifestation, willpower, channeling potential"),
+ Arcana(2, "II", "The High Priestess", "intuition, hidden knowledge, the unseen"),
+ Arcana(3, "III", "The Empress", "abundance, fertility, creation, nurturing"),
+ Arcana(4, "IV", "The Emperor", "authority, structure, control, stability"),
+ Arcana(5, "V", "The Hierophant", "tradition, institutions, shared belief, convention"),
+ Arcana(6, "VI", "The Lovers", "union, choice, alignment of values"),
+ Arcana(7, "VII", "The Chariot", "directed force, willpower, triumph through control"),
+ Arcana(8, "VIII", "Strength", "inner strength, courage, gentleness over force"),
+ Arcana(9, "IX", "The Hermit", "introspection, solitude, the inward search"),
+ Arcana(10, "X", "Wheel of Fortune", "cycles, change, fate, turning points"),
+ Arcana(11, "XI", "Justice", "fairness, cause and effect, truth, accountability"),
+ Arcana(12, "XII", "The Hanged Man", "surrender, suspension, a new perspective"),
+ Arcana(13, "XIII", "Death", "endings, irreversible transformation, transition"),
+ Arcana(14, "XIV", "Temperance", "balance, synthesis, moderation"),
+ Arcana(15, "XV", "The Devil", "bondage, constraint, materialism, the shadow"),
+ Arcana(16, "XVI", "The Tower", "sudden upheaval, collapse of false structures"),
+ Arcana(17, "XVII", "The Star", "hope, renewal, inspiration"),
+ Arcana(18, "XVIII", "The Moon", "illusion, ambiguity, the unconscious, anxiety"),
+ Arcana(19, "XIX", "The Sun", "joy, vitality, clarity, success"),
+ Arcana(20, "XX", "Judgement", "reckoning, awakening, rebirth, a calling"),
+ Arcana(21, "XXI", "The World", "completion, integration, wholeness"),
+)
+
+assert len(MAJOR_ARCANA) == 22
+assert [a.number for a in MAJOR_ARCANA] == list(range(22))
+
+ROMAN_BY_NUMBER: dict[int, str] = {a.number: a.roman for a in MAJOR_ARCANA}
+NAME_BY_NUMBER: dict[int, str] = {a.number: a.name for a in MAJOR_ARCANA}
+
+
+def reference_block() -> str:
+ """The 22 archetypes + canonical meanings, for the system prompt (SPEC §4)."""
+ return "\n".join(
+ f"{a.roman:<5} {a.name:<18} — {a.meaning}" for a in MAJOR_ARCANA
+ )
diff --git a/arcana/build.py b/arcana/build.py
new file mode 100644
index 0000000000000000000000000000000000000000..8af9c03549969fc1351d7e335cf6ed35285d96c2
--- /dev/null
+++ b/arcana/build.py
@@ -0,0 +1,142 @@
+"""End-to-end deck build + persistence (SPEC §11, §12).
+
+theme + chosen visual style -> mappings (Agent 1) -> concept-native meanings
+(Loremaster) -> 22 central arts + ONE deck-back (open ≤32B image model) ->
+composited card PNGs. A fixed seed family makes the 22 cards cohere.
+
+Persistence model (§12): a freshly built deck lives in session memory; its image
+files are written to decks// so they can be composited and served, but
+the deck is only "saved" (and thus listed by View Deck / Read from Existing) once
+save_deck() writes its deck.json. Bundled demo decks ship with a deck.json.
+"""
+from __future__ import annotations
+
+import json
+import os
+import time
+from typing import Callable
+
+from .archetypes import ROMAN_BY_NUMBER
+from .compositor import compose_card
+from .designer import design_deck
+from .imagegen import get_imagegen
+from .llm import get_llm
+from .loremaster import refine_deck
+from .styles import resolve_style
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DECKS_DIR = os.path.join(ROOT, "decks")
+
+Progress = Callable[[str, float], None] # (message, fraction 0..1)
+
+
+def slugify(s: str) -> str:
+ return "".join(ch if ch.isalnum() else "_" for ch in s.lower()).strip("_")[:40]
+
+
+def deck_dir(deck_id: str) -> str:
+ return os.path.join(DECKS_DIR, deck_id)
+
+
+def _seed_base(theme: str, style_id: str) -> int:
+ # deterministic per (theme, style) so a deck's art is reproducible (§7)
+ return abs(hash((theme.lower().strip(), style_id))) % 1_000_000
+
+
+def _back_prompt(style_suffix: str) -> str:
+ return ("a tarot card back design, ornate symmetrical emblematic mandala, "
+ "abstract motif, no figures, no scene, " + style_suffix)
+
+
+# ------------------------------------------------------------------ build
+def build_deck(theme: str, visual_style: str = "rider-waite-smith",
+ custom_style: str | None = None,
+ progress: Progress | None = None) -> dict:
+ """Design + paint a full deck. Writes image files under decks// and
+ returns the in-memory deck dict (NOT yet saved — call save_deck to persist)."""
+ def emit(msg, frac):
+ if progress:
+ progress(msg, frac)
+
+ theme = (theme or "").strip()
+ style_id, style_suffix = resolve_style(visual_style, custom_style)
+ deck_id = f"{slugify(theme)}-{style_id}-{int(time.time())}"
+ out_dir = deck_dir(deck_id)
+ os.makedirs(out_dir, exist_ok=True)
+ seed_base = _seed_base(theme, style_id)
+
+ llm = get_llm()
+ emit("Consulting the deck designer…", 0.02)
+ deck = design_deck(theme, llm=llm)
+ emit("The loremaster reinterprets the cards…", 0.05)
+ try:
+ deck = refine_deck(deck, llm=llm)
+ except Exception:
+ pass # keep the designer's draft meanings if refinement hiccups
+
+ deck.update({
+ "deck_id": deck_id,
+ "theme": theme,
+ "visual_style": style_id,
+ "style_suffix": style_suffix,
+ "seed_base": seed_base,
+ })
+
+ ig = get_imagegen(deck_style=style_suffix)
+
+ emit("Pressing the deck-back…", 0.08)
+ back_path = os.path.join(out_dir, "back.png")
+ try:
+ back = ig.generate(_back_prompt(style_suffix), seed=seed_base - 1)
+ back.save(back_path)
+ deck["back_path"] = os.path.relpath(back_path, ROOT)
+ except Exception:
+ deck["back_path"] = None
+
+ n = len(deck["cards"])
+ for i, c in enumerate(deck["cards"]):
+ num = c["arcana_number"]
+ emit(f"Painting {c['concept']}…", 0.10 + 0.88 * i / n)
+ seed = seed_base + num
+ art = ig.generate(c["art_prompt"], seed=seed)
+ card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[num])
+ fname = f"{num:02d}_{slugify(c['concept'])}.png"
+ path = os.path.join(out_dir, fname)
+ card.save(path)
+ c["seed"] = seed
+ c["art_path"] = os.path.relpath(path, ROOT)
+
+ emit("The deck is ready.", 1.0)
+ return deck
+
+
+# ------------------------------------------------------------------ persistence
+def save_deck(deck: dict) -> str:
+ """Write deck.json into the deck's dir, registering it as a saved deck (§12)."""
+ out_dir = deck_dir(deck["deck_id"])
+ os.makedirs(out_dir, exist_ok=True)
+ path = os.path.join(out_dir, "deck.json")
+ with open(path, "w") as f:
+ json.dump(deck, f, indent=2, ensure_ascii=False)
+ return path
+
+
+def load_deck(deck_id: str) -> dict | None:
+ path = os.path.join(deck_dir(deck_id), "deck.json")
+ return json.load(open(path)) if os.path.exists(path) else None
+
+
+def list_decks() -> list[dict]:
+ """All saved/bundled decks (those with a deck.json), newest first."""
+ if not os.path.isdir(DECKS_DIR):
+ return []
+ decks = []
+ for name in os.listdir(DECKS_DIR):
+ path = os.path.join(DECKS_DIR, name, "deck.json")
+ if os.path.exists(path):
+ try:
+ decks.append(json.load(open(path)))
+ except Exception:
+ pass
+ decks.sort(key=lambda d: d.get("deck_id", ""), reverse=True)
+ return decks
diff --git a/arcana/compositor.py b/arcana/compositor.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3679fabcabfdcfddf7496a3405f8c36d1c692ff
--- /dev/null
+++ b/arcana/compositor.py
@@ -0,0 +1,81 @@
+"""Compositing pipeline (SPEC §9).
+
+Per card: central art → fill the card → overlay the reusable frame template →
+render the concept name + roman numeral as crisp text (never baked into the
+diffusion art). Deterministic, fast, Pillow-only.
+"""
+from __future__ import annotations
+
+from PIL import Image, ImageDraw
+
+from .frame import (CARD_H, CARD_W, CART_PLATE, GOLD, NUM_PLATE, PARCH,
+ font, frame_overlay)
+
+
+def _cover(art: Image.Image, w: int, h: int) -> Image.Image:
+ """Resize-to-cover then centre-crop so art fills WxH without distortion."""
+ aw, ah = art.size
+ scale = max(w / aw, h / ah)
+ art = art.resize((max(1, round(aw * scale)), max(1, round(ah * scale))), Image.LANCZOS)
+ aw, ah = art.size
+ left, top = (aw - w) // 2, (ah - h) // 2
+ return art.crop((left, top, left + w, top + h))
+
+
+def _fit_lines(draw, text, fnt_face, box, max_lines=2, start=40, min_size=20):
+ """Largest Cinzel size (and wrap) that fits `text` in `box`. Returns
+ (font, [lines])."""
+ bw = box[2] - box[0] - 24
+ bh = box[3] - box[1] - 16
+ words = text.upper().split()
+ for size in range(start, min_size - 1, -2):
+ fnt = font(size, weight=600, face=fnt_face)
+ # greedy wrap into <= max_lines
+ lines, cur = [], ""
+ for word in words:
+ trial = (cur + " " + word).strip()
+ if draw.textlength(trial, font=fnt) <= bw or not cur:
+ cur = trial
+ else:
+ lines.append(cur); cur = word
+ if cur:
+ lines.append(cur)
+ if len(lines) > max_lines:
+ continue
+ line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6
+ if all(draw.textlength(ln, font=fnt) <= bw for ln in lines) and \
+ line_h * len(lines) <= bh:
+ return fnt, lines
+ fnt = font(min_size, weight=600, face=fnt_face)
+ return fnt, [text.upper()[:40]]
+
+
+def _draw_centered(draw, lines, fnt, box, fill):
+ line_h = (fnt.getbbox("Ag")[3] - fnt.getbbox("Ag")[1]) + 6
+ total = line_h * len(lines)
+ cy = (box[1] + box[3]) / 2 - total / 2
+ for ln in lines:
+ tw = draw.textlength(ln, font=fnt)
+ draw.text(((box[0] + box[2]) / 2 - tw / 2, cy), ln, font=fnt, fill=fill)
+ cy += line_h
+
+
+def compose_card(art: Image.Image, concept: str, roman: str,
+ size=(CARD_W, CARD_H)) -> Image.Image:
+ """Build the finished upright card PNG. Reversal is a 180° rotation applied
+ at draw time (§9), not here."""
+ w, h = size
+ card = _cover(art.convert("RGB"), w, h)
+ card.paste(frame_overlay(w, h), (0, 0), frame_overlay(w, h))
+ draw = ImageDraw.Draw(card)
+
+ # roman numeral in the top plate
+ nf = font(40, weight=700)
+ nw = draw.textlength(roman, font=nf)
+ ny = (NUM_PLATE[1] + NUM_PLATE[3]) / 2 - (nf.getbbox("X")[3] - nf.getbbox("X")[1]) / 2 - nf.getbbox("X")[1]
+ draw.text(((NUM_PLATE[0] + NUM_PLATE[2]) / 2 - nw / 2, ny), roman, font=nf, fill=GOLD)
+
+ # concept name in the bottom cartouche (auto-fit, up to 2 lines)
+ cf, lines = _fit_lines(draw, concept, "Cinzel", CART_PLATE, max_lines=2, start=44)
+ _draw_centered(draw, lines, cf, CART_PLATE, PARCH)
+ return card
diff --git a/arcana/designer.py b/arcana/designer.py
new file mode 100644
index 0000000000000000000000000000000000000000..6ecf536c84324541b229764b63670c32bf6c64ff
--- /dev/null
+++ b/arcana/designer.py
@@ -0,0 +1,170 @@
+"""Agent 1 — Deck Designer (SPEC §4 + validation §10).
+
+theme string -> deck dict of exactly 22 archetype-mapped cards.
+
+Model output is treated as hostile: JSON is extracted defensively (fence-strip /
+balanced-block), validated against the 22-archetype contract, and re-prompted
+ONCE on a parse or structural failure. Build-time callers should fail loudly;
+runtime callers can catch DeckError and degrade.
+"""
+from __future__ import annotations
+
+import json
+import re
+
+from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
+from .llm import LLM, get_llm
+from .prompts import designer_system_prompt, designer_user_prompt
+
+_CARD_FIELDS = ("concept", "justification", "upright_meaning",
+ "reversed_meaning", "art_prompt")
+
+
+class DeckError(ValueError):
+ """The model's output could not be coerced into a valid 22-card deck."""
+
+
+# ------------------------------------------------------------------ json parse
+def extract_json(text: str) -> dict:
+ """Pull the first JSON object out of a model reply, tolerating fences and
+ stray prose. Strips a Qwen block if one leaks through."""
+ if not text or not text.strip():
+ raise DeckError("empty model reply")
+ text = re.sub(r".*?", "", text, flags=re.DOTALL).strip()
+ # strip ```json ... ``` fences if present
+ fence = re.search(r"```(?:json)?\s*(.*?)```", text, flags=re.DOTALL)
+ if fence:
+ text = fence.group(1).strip()
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ pass
+ # fall back to the first balanced {...} block
+ start = text.find("{")
+ if start == -1:
+ raise DeckError("no JSON object found in reply")
+ depth, in_str, esc = 0, False, False
+ for i in range(start, len(text)):
+ c = text[i]
+ if in_str:
+ if esc:
+ esc = False
+ elif c == "\\":
+ esc = True
+ elif c == '"':
+ in_str = False
+ elif c == '"':
+ in_str = True
+ elif c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ try:
+ return json.loads(text[start:i + 1])
+ except json.JSONDecodeError as e:
+ raise DeckError(f"malformed JSON block: {e}") from e
+ raise DeckError("unbalanced JSON object in reply")
+
+
+# ------------------------------------------------------------------ validation
+def validate_deck(data: dict, theme: str) -> dict:
+ """Enforce the §10 contract: exactly 22 cards covering arcana 0-21, no dupes,
+ non-empty meanings and art_prompt. Returns a normalized deck dict. Raises
+ DeckError on any structural problem (caller re-prompts once)."""
+ if not isinstance(data, dict):
+ raise DeckError("top-level JSON is not an object")
+ cards = data.get("cards")
+ if not isinstance(cards, list):
+ raise DeckError("'cards' is missing or not a list")
+ if len(cards) != 22:
+ raise DeckError(f"expected 22 cards, got {len(cards)}")
+
+ by_number: dict[int, dict] = {}
+ for idx, c in enumerate(cards):
+ if not isinstance(c, dict):
+ raise DeckError(f"card {idx} is not an object")
+ try:
+ n = int(c.get("arcana_number"))
+ except (TypeError, ValueError):
+ raise DeckError(f"card {idx} has a non-integer arcana_number")
+ if not 0 <= n <= 21:
+ raise DeckError(f"card {idx} arcana_number {n} out of range 0-21")
+ if n in by_number:
+ raise DeckError(f"duplicate arcana_number {n}")
+ for f in _CARD_FIELDS:
+ v = c.get(f)
+ if not isinstance(v, str) or not v.strip():
+ raise DeckError(f"card {n} ({NAME_BY_NUMBER[n]}) has empty '{f}'")
+ by_number[n] = c
+
+ missing = [a.number for a in MAJOR_ARCANA if a.number not in by_number]
+ if missing:
+ raise DeckError(f"missing arcana numbers: {missing}")
+
+ # normalize: canonical names/order, trimmed strings, keep concept dupes visible
+ norm_cards = []
+ seen_concepts: dict[str, int] = {}
+ for a in MAJOR_ARCANA:
+ c = by_number[a.number]
+ concept = c["concept"].strip()
+ key = concept.lower()
+ if key in seen_concepts:
+ raise DeckError(
+ f"concept {concept!r} reused for arcana {a.number} and "
+ f"{seen_concepts[key]}"
+ )
+ seen_concepts[key] = a.number
+ norm_cards.append({
+ "arcana_number": a.number,
+ "arcana_name": a.name,
+ "concept": concept,
+ "justification": c["justification"].strip(),
+ "upright_meaning": c["upright_meaning"].strip(),
+ "reversed_meaning": c["reversed_meaning"].strip(),
+ "art_prompt": c["art_prompt"].strip(),
+ "art_path": None,
+ })
+
+ style = data.get("style_suffix")
+ if not isinstance(style, str) or not style.strip():
+ style = "ornate tarot illustration, cohesive palette, symbolic, no text, no border"
+ return {
+ "theme": (data.get("theme") or theme).strip(),
+ "style_suffix": style.strip(),
+ "cards": norm_cards,
+ }
+
+
+# ------------------------------------------------------------------ generate
+def design_deck(theme: str, llm: LLM | None = None) -> dict:
+ """Run Agent 1 for a theme and return a validated deck. Re-prompts ONCE on a
+ parse/structural failure (SPEC §4, §10). Raises DeckError if both attempts
+ fail — build-time callers should let this surface."""
+ theme = (theme or "").strip()
+ if not theme:
+ raise DeckError("empty theme")
+ llm = llm or get_llm()
+ system = designer_system_prompt()
+ user = designer_user_prompt(theme)
+
+ first_err = None
+ raw = llm.complete(system, user, json_mode=True)
+ try:
+ return validate_deck(extract_json(raw), theme)
+ except DeckError as e:
+ first_err = e
+
+ # one repair attempt, told exactly what was wrong
+ repair = (
+ f"{user}\n\nYour previous reply was rejected: {first_err}. "
+ "Return ONLY the corrected strict JSON object with exactly 22 cards "
+ "covering arcana 0 through 21, each with non-empty concept, "
+ "justification, upright_meaning, reversed_meaning and art_prompt. "
+ "No prose, no code fences."
+ )
+ raw2 = llm.complete(system, repair, json_mode=True)
+ try:
+ return validate_deck(extract_json(raw2), theme)
+ except DeckError as e:
+ raise DeckError(f"deck invalid after repair (first: {first_err}) -> {e}") from e
diff --git a/arcana/frame.py b/arcana/frame.py
new file mode 100644
index 0000000000000000000000000000000000000000..1b4dc245cc57aaa1f7969af870dc1c2bb4e92848
--- /dev/null
+++ b/arcana/frame.py
@@ -0,0 +1,79 @@
+"""Decorative card frame — built ONCE and reused for all 22 cards (SPEC §8).
+
+Approach (A): an authored vector-ish frame overlay rendered with Pillow at a
+fixed card size — an RGBA layer with a transparent centre (the art shows
+through) plus an ornate gold border, a top plate for the roman numeral, and a
+bottom cartouche for the concept name. Per-card text is drawn at composite time
+(§9); it is NEVER baked into the generated art. The overlay is cached so the
+"build the frame one time" requirement holds.
+"""
+from __future__ import annotations
+
+import functools
+import os
+
+from PIL import Image, ImageDraw, ImageFont
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+FONT_DIR = os.path.join(ROOT, "assets", "fonts")
+
+CARD_W, CARD_H = 720, 1100
+
+# occult palette
+GOLD = (200, 162, 74)
+GOLD_SOFT = (150, 122, 60)
+INK = (18, 11, 30) # deep purple-black
+PARCH = (243, 230, 196) # parchment text
+
+BORDER = 26 # outer dark band thickness
+NUM_PLATE = (CARD_W // 2 - 70, 30, CARD_W // 2 + 70, 104) # roman numeral
+CART_PLATE = (54, CARD_H - 188, CARD_W - 54, CARD_H - 60) # concept title
+
+
+@functools.lru_cache(maxsize=8)
+def font(size: int, weight: int = 400, face: str = "Cinzel") -> ImageFont.FreeTypeFont:
+ f = ImageFont.truetype(os.path.join(FONT_DIR, f"{face}.ttf"), size)
+ try: # variable fonts: pick a weight on the wght axis
+ f.set_variation_by_axes([weight])
+ except Exception:
+ pass
+ return f
+
+
+def _rounded(draw: ImageDraw.ImageDraw, box, radius, fill=None, outline=None, width=2):
+ draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
+
+
+@functools.lru_cache(maxsize=4)
+def frame_overlay(w: int = CARD_W, h: int = CARD_H) -> Image.Image:
+ """The reusable frame as an RGBA overlay with a transparent art window."""
+ ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ d = ImageDraw.Draw(ov)
+
+ # dark vignette band hugging the edges (frames the full-bleed art)
+ d.rectangle([0, 0, w, h], outline=None)
+ band = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ bd = ImageDraw.Draw(band)
+ bd.rectangle([0, 0, w, h], fill=(*INK, 235))
+ bd.rectangle([BORDER, BORDER, w - BORDER, h - BORDER], fill=(0, 0, 0, 0))
+ ov.alpha_composite(band)
+
+ # double gold rule just inside the band
+ d.rectangle([BORDER - 6, BORDER - 6, w - BORDER + 6, h - BORDER + 6],
+ outline=(*GOLD, 255), width=3)
+ d.rectangle([BORDER + 4, BORDER + 4, w - BORDER - 4, h - BORDER - 4],
+ outline=(*GOLD_SOFT, 220), width=1)
+
+ # corner flourishes — small gold diamonds
+ for cx, cy in [(BORDER, BORDER), (w - BORDER, BORDER),
+ (BORDER, h - BORDER), (w - BORDER, h - BORDER)]:
+ d.polygon([(cx, cy - 10), (cx + 10, cy), (cx, cy + 10), (cx - 10, cy)],
+ fill=(*GOLD, 255))
+
+ # top numeral plate + bottom title cartouche (filled, gold-edged)
+ _rounded(d, NUM_PLATE, 10, fill=(*INK, 245), outline=(*GOLD, 255), width=2)
+ _rounded(d, CART_PLATE, 14, fill=(*INK, 245), outline=(*GOLD, 255), width=2)
+ # inner hairline on the cartouche
+ _rounded(d, (CART_PLATE[0] + 6, CART_PLATE[1] + 6, CART_PLATE[2] - 6, CART_PLATE[3] - 6),
+ 10, outline=(*GOLD_SOFT, 200), width=1)
+ return ov
diff --git a/arcana/imagegen.py b/arcana/imagegen.py
new file mode 100644
index 0000000000000000000000000000000000000000..7eb389d5f3ef0660f86ee528de00ad9e47c28c30
--- /dev/null
+++ b/arcana/imagegen.py
@@ -0,0 +1,106 @@
+"""Swappable image generator (SPEC §7).
+
+The app programs against the ``ImageGen`` protocol. Consistent with the sibling
+Globe project (which calls its ≤32B model through an inference endpoint and
+keeps the Space a lean CPU box), the default backend calls an OPEN ≤32B image
+model — FLUX.1-schnell (~12B) — through the same DeepInfra endpoint we use for
+the LLM. No closed API, no local GPU, no torch/diffusers in the deploy.
+
+Generates the CENTRAL ILLUSTRATION ONLY (no frame, no border, no text — those
+are added in the compositor, §8/§9). A fixed style suffix + a fixed seed family
+give the 22 cards one cohesive look.
+
+LocalImageGen is a stub for a GPU box running diffusers locally (earns the
+local-first bonus, §14); left unimplemented so v1 doesn't block on GPU access.
+"""
+from __future__ import annotations
+
+import base64
+import io
+import os
+from typing import Protocol, runtime_checkable
+
+from PIL import Image
+
+# Open ≤32B image models on DeepInfra's OpenAI-compatible images endpoint.
+# FLUX.1-schnell ~12B (best quality / few steps); sdxl-turbo ~3.5B (lighter).
+DEFAULT_IMAGE_MODEL = "black-forest-labs/FLUX-1-schnell"
+DEFAULT_IMAGE_BASE_URL = "https://api.deepinfra.com/v1/openai"
+
+# Appended to every art_prompt so all 22 cards share one look (style cohesion
+# comes from this + a fixed seed family, not from per-card prompts). The
+# per-deck style_suffix from Agent 1 is layered in front of this at call time.
+GLOBAL_STYLE = ("ornate symbolic tarot card illustration, centered subject, "
+ "cohesive color palette, painterly, no text, no words, no border, "
+ "no frame, full-bleed artwork")
+
+
+@runtime_checkable
+class ImageGen(Protocol):
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
+ """Return a central-illustration PIL image for the prompt."""
+ ...
+
+
+def _style(prompt: str, deck_style: str | None) -> str:
+ parts = [prompt.strip().rstrip(".")]
+ if deck_style:
+ parts.append(deck_style.strip().rstrip("."))
+ parts.append(GLOBAL_STYLE)
+ return ", ".join(p for p in parts if p)
+
+
+class EndpointImageGen:
+ """Open ≤32B image model behind an OpenAI-compatible images endpoint."""
+
+ def __init__(self, model: str | None = None, base_url: str | None = None,
+ api_key: str | None = None, deck_style: str | None = None,
+ size: str = "1024x1024"):
+ from openai import OpenAI # lazy
+
+ self.model = model or os.environ.get("IMAGE_MODEL", DEFAULT_IMAGE_MODEL)
+ self.base_url = base_url or os.environ.get("IMAGE_BASE_URL", DEFAULT_IMAGE_BASE_URL)
+ self.deck_style = deck_style
+ self.size = os.environ.get("IMAGE_SIZE", size)
+ key = (api_key or os.environ.get("IMAGE_API_KEY")
+ or os.environ.get("DEEPINFRA_TOKEN") or os.environ.get("DEEPINFRA_API_KEY"))
+ if not key:
+ raise RuntimeError("No image API key. Set IMAGE_API_KEY or DEEPINFRA_TOKEN.")
+ self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0)
+
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
+ kwargs = dict(model=self.model, prompt=_style(prompt, self.deck_style),
+ n=1, size=self.size)
+ if seed is not None: # DeepInfra accepts seed as an extra body field
+ kwargs["extra_body"] = {"seed": int(seed)}
+ resp = self.client.images.generate(**kwargs)
+ datum = resp.data[0]
+ b64 = getattr(datum, "b64_json", None)
+ if b64:
+ raw = base64.b64decode(b64)
+ else:
+ url = getattr(datum, "url", None)
+ if not url:
+ raise RuntimeError("image response had neither b64_json nor url")
+ if url.startswith("data:"): # data:image/png;base64,XXXX
+ raw = base64.b64decode(url.split(",", 1)[1])
+ else:
+ import requests
+ r = requests.get(url, timeout=120); r.raise_for_status(); raw = r.content
+ return Image.open(io.BytesIO(raw)).convert("RGB")
+
+
+class LocalImageGen:
+ """STRETCH stub — diffusers SDXL/FLUX on a local GPU (SPEC §7, §14)."""
+
+ def generate(self, prompt: str, seed: int | None = None) -> Image.Image:
+ raise NotImplementedError(
+ "local image backend needs a GPU + diffusers; use the endpoint backend."
+ )
+
+
+def get_imagegen(deck_style: str | None = None) -> ImageGen:
+ backend = os.environ.get("IMAGE_BACKEND", "endpoint").lower()
+ if backend == "local":
+ return LocalImageGen()
+ return EndpointImageGen(deck_style=deck_style)
diff --git a/arcana/llm.py b/arcana/llm.py
new file mode 100644
index 0000000000000000000000000000000000000000..de4371e82d048b398d39f8d32d25180ff3ce3b4e
--- /dev/null
+++ b/arcana/llm.py
@@ -0,0 +1,115 @@
+"""Swappable LLM adapter (SPEC §6).
+
+The app programs against the ``LLM`` protocol and never knows which backend
+serves it. Both agents (Designer §4, Reader §5) are just different prompts
+through ``complete()``.
+
+Backends, selected by ``LLM_BACKEND``:
+ endpoint (default here) — an OpenAI-compatible endpoint serving the *same
+ open ≤32B model*. We have no local GPU (CPU-only torch), so dev runs
+ Qwen3-32B through the HF Inference Providers router / DeepInfra.
+ This is still an OPEN ≤32B model — never a closed frontier model.
+ local — Qwen-32B via transformers/vLLM/llama.cpp on a GPU box. Same
+ interface; wire when GPU is available (earns local-first/llama.cpp
+ bonus, SPEC §14). Left as a thin stub so v1 doesn't block on it.
+
+The model string is configurable via ``QWEN_MODEL``. SPEC §2 names
+Qwen2.5-32B-Instruct with Qwen3-32B an acceptable swap; Qwen2.5-32B is not
+currently hosted on our providers, so the default is Qwen3-32B.
+"""
+from __future__ import annotations
+
+import os
+from typing import Protocol, runtime_checkable
+
+
+@runtime_checkable
+class LLM(Protocol):
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
+ """Return the model's text completion for a system+user prompt."""
+ ...
+
+
+# Qwen3-32B is a "thinking" model: by default it emits a trace that
+# overruns max_tokens before any JSON. /no_think gives a direct answer.
+DEFAULT_MODEL = "Qwen/Qwen3-32B"
+
+# provider presets: (base_url, env vars to try for the key, in order)
+_PROVIDERS = {
+ "hf": ("https://router.huggingface.co/v1", ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN")),
+ "deepinfra": ("https://api.deepinfra.com/v1/openai", ("DEEPINFRA_TOKEN", "DEEPINFRA_API_KEY")),
+}
+
+
+class EndpointLLM:
+ """Open ≤32B model behind an OpenAI-compatible endpoint."""
+
+ def __init__(self, model: str | None = None, provider: str | None = None,
+ base_url: str | None = None, api_key: str | None = None,
+ temperature: float = 0.7, max_tokens: int = 6000):
+ from openai import OpenAI # lazy: the local backend needs no openai
+
+ self.model = model or os.environ.get("QWEN_MODEL", DEFAULT_MODEL)
+ self.temperature = float(os.environ.get("QWEN_TEMPERATURE", temperature))
+ self.max_tokens = int(os.environ.get("QWEN_MAX_TOKENS", max_tokens))
+
+ provider = (provider or os.environ.get("LLM_PROVIDER", "hf")).lower()
+ preset_url, key_envs = _PROVIDERS.get(provider, _PROVIDERS["hf"])
+ self.base_url = base_url or os.environ.get("LLM_BASE_URL", preset_url)
+ key = api_key or os.environ.get("LLM_API_KEY")
+ for env in key_envs:
+ key = key or os.environ.get(env)
+ if not key:
+ raise RuntimeError(
+ f"No API key for provider {provider!r}. Set LLM_API_KEY or one of "
+ f"{key_envs} (Space secret for deploy)."
+ )
+ self.client = OpenAI(base_url=self.base_url, api_key=key, timeout=120.0)
+
+ def _is_thinking_qwen(self) -> bool:
+ m = self.model.lower()
+ return "qwen3" in m and "instruct" not in m
+
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
+ if self._is_thinking_qwen():
+ user = user.rstrip() + "\n/no_think"
+ kwargs = dict(
+ model=self.model,
+ messages=[{"role": "system", "content": system},
+ {"role": "user", "content": user}],
+ temperature=self.temperature,
+ max_tokens=self.max_tokens,
+ )
+ if json_mode:
+ kwargs["response_format"] = {"type": "json_object"}
+ try:
+ resp = self.client.chat.completions.create(**kwargs)
+ except Exception:
+ if json_mode: # some providers reject json_object — retry without it
+ kwargs.pop("response_format", None)
+ resp = self.client.chat.completions.create(**kwargs)
+ else:
+ raise
+ return resp.choices[0].message.content or ""
+
+
+class LocalLLM:
+ """STRETCH stub for a GPU-local Qwen-32B (transformers/vLLM/llama.cpp).
+
+ Point EndpointLLM at a local OpenAI-compatible server
+ (LLM_BASE_URL=http://127.0.0.1:8080/v1) and that path already works today; a
+ truly in-process backend would slot in here. Unimplemented so v1 doesn't
+ block on GPU access (SPEC §6, §12, §14).
+ """
+
+ def complete(self, system: str, user: str, json_mode: bool = False) -> str:
+ raise NotImplementedError(
+ "local backend needs a GPU; use LLM_BACKEND=endpoint (default) for dev."
+ )
+
+
+def get_llm() -> LLM:
+ backend = os.environ.get("LLM_BACKEND", "endpoint").lower()
+ if backend == "local":
+ return LocalLLM()
+ return EndpointLLM()
diff --git a/arcana/loremaster.py b/arcana/loremaster.py
new file mode 100644
index 0000000000000000000000000000000000000000..866ea009163d61a1486c8b2dafdf75bdcd1d2884
--- /dev/null
+++ b/arcana/loremaster.py
@@ -0,0 +1,111 @@
+"""Agent 1.5 — the Loremaster (deck reinterpretation pass).
+
+After the Designer maps the 22 archetypes onto in-theme concepts, this agent
+takes the WHOLE deck — each concept, the classical archetype it came from, that
+archetype's canonical meaning, and the Designer's first-draft meanings — and
+rewrites every card's meaning to be *native to the concept itself*, rather than a
+stiff reproduction of the original tarot card. It sees all 22 at once, so it can
+make the deck internally coherent and keep cards distinct.
+
+The point: a card like "The One Ring" should read about corruption, burden and
+the fate it drags behind it — not a generic "cycles and turning points" gloss
+inherited from Wheel of Fortune. The archetype is the seed; the concept is the
+plant. This pass also adds a one-line `essence` the Reader can draw on.
+"""
+from __future__ import annotations
+
+from .archetypes import MAJOR_ARCANA, NAME_BY_NUMBER
+from .designer import DeckError, extract_json
+from .llm import LLM, get_llm
+
+_FIELDS = ("essence", "upright_meaning", "reversed_meaning")
+
+
+def _system() -> str:
+ return """\
+You are the loremaster of a custom tarot deck. Each card began life as a classical
+Major Arcana archetype, but has been re-cast as a concept from a single theme. Your
+job is to make every card's meaning feel TRUE TO ITS OWN CONCEPT — drawn from what
+that concept actually is, does, and evokes — instead of a stiff hand-me-down of the
+original tarot card's text.
+
+You are given, for each card: the concept, the archetype it was seeded from, that
+archetype's canonical meaning, and a first-draft upright/reversed meaning. Keep the
+archetype's underlying SHAPE (a Death-card still concerns endings; a Tower-card still
+concerns sudden collapse) but re-express it through the concept's own specifics,
+imagery and stakes. Lean into what makes THIS concept particular. Make the 22 cards
+distinct from one another — no two should read interchangeably.
+
+For each card produce:
+- essence: one vivid line naming what this card uniquely means in this deck.
+- upright_meaning: 2-3 sentences, concept-native, concrete and evocative — never
+ boilerplate that could be pasted onto any card.
+- reversed_meaning: 1-2 sentences — the shadow / blockage / inversion, also
+ concept-native.
+
+Speak in confident, grounded tarot voice. No hedging, no meta-commentary.
+
+OUTPUT: strict JSON only, no prose, no fences:
+{ "cards": [ { "arcana_number": <0-21>, "essence": "...", "upright_meaning": "...", "reversed_meaning": "..." }, ... 22 ] }"""
+
+
+def _user(deck: dict) -> str:
+ canon = {a.number: a.meaning for a in MAJOR_ARCANA}
+ blocks = []
+ for c in deck["cards"]:
+ n = c["arcana_number"]
+ blocks.append(
+ f"--- card {n} ---\n"
+ f"concept: {c['concept']}\n"
+ f"seeded from archetype: {NAME_BY_NUMBER[n]} (classical meaning: {canon[n]})\n"
+ f"draft upright: {c['upright_meaning']}\n"
+ f"draft reversed: {c['reversed_meaning']}"
+ )
+ return (
+ f"Theme: {deck.get('theme')}\n\n"
+ "Reinterpret all 22 cards so each meaning is native to its own concept. "
+ "Return the strict JSON object only.\n\n" + "\n\n".join(blocks)
+ )
+
+
+def _apply(deck: dict, data: dict) -> dict:
+ if not isinstance(data, dict) or not isinstance(data.get("cards"), list):
+ raise DeckError("loremaster output missing 'cards' list")
+ refined = {}
+ for c in data["cards"]:
+ try:
+ n = int(c.get("arcana_number"))
+ except (TypeError, ValueError):
+ raise DeckError("loremaster card has non-integer arcana_number")
+ for f in _FIELDS:
+ v = c.get(f)
+ if not isinstance(v, str) or not v.strip():
+ raise DeckError(f"loremaster card {n} has empty '{f}'")
+ refined[n] = c
+ missing = [a.number for a in MAJOR_ARCANA if a.number not in refined]
+ if missing:
+ raise DeckError(f"loremaster missing arcana: {missing}")
+
+ for card in deck["cards"]:
+ r = refined[card["arcana_number"]]
+ card["essence"] = r["essence"].strip()
+ card["upright_meaning"] = r["upright_meaning"].strip()
+ card["reversed_meaning"] = r["reversed_meaning"].strip()
+ return deck
+
+
+def refine_deck(deck: dict, llm: LLM | None = None) -> dict:
+ """Rewrite every card's meaning to be concept-native (one batched call, with
+ one repair retry). Mutates and returns the deck dict."""
+ llm = llm or get_llm()
+ system, user = _system(), _user(deck)
+
+ raw = llm.complete(system, user, json_mode=True)
+ try:
+ return _apply(deck, extract_json(raw))
+ except DeckError as first:
+ repair = (f"{user}\n\nYour previous reply was rejected: {first}. Return ONLY "
+ "the corrected strict JSON with all 22 cards, each having non-empty "
+ "essence, upright_meaning and reversed_meaning.")
+ raw2 = llm.complete(system, repair, json_mode=True)
+ return _apply(deck, extract_json(raw2))
diff --git a/arcana/prompts.py b/arcana/prompts.py
new file mode 100644
index 0000000000000000000000000000000000000000..e85619079bc6dd30276de4ff06a9f4df930a8075
--- /dev/null
+++ b/arcana/prompts.py
@@ -0,0 +1,137 @@
+"""Prompts for Agent 1 — the Deck Designer (SPEC §4).
+
+Mapping quality is the single most important thing in this build, and the
+few-shot examples below are what teach the model the "clever-but-right"
+standard. They show the full 22-archetype mapping for one serious theme
+(thermodynamics) and one silly theme (breakfast foods), plus one fully detailed
+card so the model learns the output format and the meaning-rewriting voice.
+
+The mappings honour the SPEC's own worked examples: Entropy→Death,
+Absolute Zero→The Hermit, Critical Point→The Tower.
+"""
+from __future__ import annotations
+
+from .archetypes import reference_block
+
+# Compact teaching tables: archetype → concept — why it's earned (not arbitrary).
+_FEWSHOT_THERMO = """\
+THEME: thermodynamics
+0 The Fool → Activation Energy — the leap you must pay up front before anything can begin
+I The Magician → Work — directed energy that channels raw potential into real change
+II The High Priestess → Internal Energy — the hidden total you can never measure directly, only its changes
+III The Empress → Heat Reservoir — an abundant, inexhaustible source that nurtures warmth into everything
+IV The Emperor → The First Law — the unbreakable governing rule: energy is conserved, no exceptions
+V The Hierophant → Standard State (STP) — the agreed convention every measurement bows to
+VI The Lovers → Thermal Contact — two bodies coupled until their temperatures align as one
+VII The Chariot → The Heat Engine — chaotic heat harnessed and steered into directed motion
+VIII Strength → Heat Capacity — quiet endurance, absorbing energy without ever flaring up
+IX The Hermit → Absolute Zero — utter stillness and solitude, all motion finally ceased
+X Wheel of Fortune → The Carnot Cycle — the ideal loop that endlessly turns and returns to its start
+XI Justice → The Second Law's Toll — every process pays an irreversible tax; you cannot break even
+XII The Hanged Man → Metastability — suspended in a false minimum, surrendered but not yet resolved
+XIII Death → Entropy — irreversible transformation, the one-way march toward the end-state
+XIV Temperance → Thermal Equilibrium — hot and cold blended down to one moderate, settled mean
+XV The Devil → Bound Energy — energy chained inside the system, forever unavailable to do work
+XVI The Tower → The Critical Point — where distinctions suddenly collapse and the old phase fails
+XVII The Star → Free Energy — the hopeful, still-usable energy that can yet be spent on work
+XVIII The Moon → Thermal Fluctuations — random microscopic jitter beneath the calm macroscopic surface
+XIX The Sun → Blackbody Radiation — the radiant glow of a hot body; literal light and vitality
+XX Judgement → The Third Law — as you near absolute zero, entropy resolves to perfect order, a final reckoning
+XXI The World → The Universe — system plus surroundings, the whole and total accounting"""
+
+_FEWSHOT_BREAKFAST = """\
+THEME: breakfast foods
+0 The Fool → The Banana — fresh beginnings, and the original literal pratfall waiting to happen
+I The Magician → The Whisk — sheer willed motion that conjures raw ingredients into something new
+II The High Priestess → The Covered Dish — hidden contents; you must lift the lid to reveal the unseen
+III The Empress → The Stack of Pancakes — warm, syrup-rich abundance, endlessly nurturing
+IV The Emperor → Black Coffee — the bitter, stabilizing authority that rules the whole morning
+V The Hierophant → The Continental Breakfast — the same institutional spread laid out by tradition everywhere
+VI The Lovers → Bacon and Eggs — the classic union, two partners always chosen together
+VII The Chariot → The Toaster — directed force, bread launched upward through controlled heat
+VIII Strength → Oatmeal — humble, gentle power that quietly sustains you all morning long
+IX The Hermit → The Lone Boiled Egg — solitary and self-contained, sitting alone in its little cup
+X Wheel of Fortune → The Cereal-Box Prize — pure chance; what you get is fate, sight unseen
+XI Justice → The Diner Check — fair accounting; you pay for exactly what you ordered
+XII The Hanged Man → French Toast Soaking — suspended in the custard, surrendered, transformed by the wait
+XIII Death → The Burnt Toast — irreversible; you cannot un-burn it, only scrape it and begin again
+XIV Temperance → The Smoothie — disparate fruits blended into one balanced, moderated whole
+XV The Devil → The Sugary Cereal — sweet craving and indulgence, the bondage you can't put down
+XVI The Tower → The Dropped Tray — sudden upheaval; the whole breakfast collapses to the floor at once
+XVII The Star → Fresh-Squeezed Orange Juice — bright hope and vitamin-C renewal, sunny optimism
+XVIII The Moon → The Mystery Omelette — ambiguity and unease; you are never quite sure what is inside
+XIX The Sun → The Sunny-Side-Up Egg — literally the sun, a bright yolk of joy and clarity
+XX Judgement → "Order's Up!" — the breakfast bell, the calling that summons you to the table
+XXI The World → The Full English — completion; every element integrated together on one plate"""
+
+# One fully detailed card so the model learns the output shape and the voice:
+# meanings are the archetype RE-EXPRESSED through the concept, never boilerplate.
+_FEWSHOT_CARD = """\
+Example of one fully detailed card object (theme: thermodynamics):
+{
+ "arcana_number": 13,
+ "arcana_name": "Death",
+ "concept": "Entropy",
+ "justification": "Both name an irreversible one-way transformation toward an end-state that cannot be undone.",
+ "upright_meaning": "An ending you cannot reverse. The old order is dispersing into the new and there is no path back — but this is the natural direction of things, not a punishment. Let what is spent be spent.",
+ "reversed_meaning": "Clinging to a structure the universe has already moved past; pouring energy into un-mixing what is mixed. A refusal to accept the arrow of time, and the exhaustion that refusal brings.",
+ "art_prompt": "a central illustration of a crumbling sandcastle dissolving grain by grain into a still dark sea, warm embers cooling to grey ash drifting outward, a single thread unravelling into the dusk"
+}"""
+
+
+def designer_system_prompt() -> str:
+ return f"""\
+You are a master tarot deck designer with a scholar's grasp of symbolism. Your \
+craft is taking ANY theme a person names and discovering, within that theme, the \
+22 Major Arcana hiding inside it.
+
+You do not invent 22 cards from scratch. You are handed the 22 fixed Major Arcana \
+archetypes below, and for EACH one you assign a single concept drawn from the \
+theme — chosen because it shares the archetype's deep meaning. The fit must feel \
+EARNED and clever-but-right, never arbitrary. A great mapping makes the reader \
+think "of course — I never saw it before, but of course." A lazy or generic \
+mapping ruins the whole deck.
+
+THE 22 MAJOR ARCANA (archetype — canonical meaning):
+{reference_block()}
+
+RULES FOR A GREAT MAPPING:
+- One in-theme concept per archetype. Exactly 22, covering arcana 0-21, no \
+concept repeated.
+- Choose the concept whose real meaning rhymes with the archetype's meaning — \
+not merely a famous item from the theme stuffed into a slot. Earn it.
+- `justification`: one crisp line naming the shared meaning that makes the fit click.
+- `upright_meaning` / `reversed_meaning`: the archetype's meaning RE-EXPRESSED \
+through the concept, in evocative tarot voice. Concrete and specific to this \
+concept — never generic boilerplate that would fit any card.
+- `art_prompt`: a vivid CENTRAL ILLUSTRATION ONLY — no border, no frame, no card \
+layout, and NO text or numerals in the image (those are added later). Describe \
+symbolic imagery, composition, and mood.
+- `style_suffix`: choose ONE short visual style line that suits the whole theme \
+(e.g. art-deco blueprint, warm storybook gouache, baroque oil painting). It will \
+be appended to every card's art_prompt so the 22 cards share one cohesive look.
+
+Study how these two reference decks earn every mapping:
+
+{_FEWSHOT_THERMO}
+
+{_FEWSHOT_BREAKFAST}
+
+{_FEWSHOT_CARD}
+
+OUTPUT CONTRACT — reply with STRICT JSON ONLY. No prose, no markdown fences. \
+Shape:
+{{
+ "theme": "",
+ "style_suffix": "",
+ "cards": [ {{ 22 card objects as shown, in arcana order 0..21 }} ]
+}}"""
+
+
+def designer_user_prompt(theme: str) -> str:
+ return (
+ f"Design the Major Arcana deck for this theme: {theme}\n\n"
+ "Discover the 22 concepts hiding inside it and map one to each archetype, "
+ "in order from 0 (The Fool) to 21 (The World). Make every mapping earned. "
+ "Reply with the strict JSON object only."
+ )
diff --git a/arcana/reader.py b/arcana/reader.py
new file mode 100644
index 0000000000000000000000000000000000000000..41e996cfb75353c1206821c4293a7f5867fea72c
--- /dev/null
+++ b/arcana/reader.py
@@ -0,0 +1,143 @@
+"""Agent 2 — the Reader / Oracle, PROGRESSIVE (SPEC §5).
+
+Real readings are sequential: cards turn one at a time, each interpreted in light
+of what came before, with the full synthesis held for the end. This module mirrors
+that — the app reveals a partial as each card lands (card_partial), then the held
+finale (final_synthesis). The reader speaks the concepts' own language and never
+names the classical archetype in its prose (the mapping is hidden, per the deck's
+rendering), but it IS told where each card sits on the 0→21 Fool's Journey so it
+can add that depth.
+
+v1 uses one default voice — an earnest, warm mystic — lightly tinted by the deck's
+visual style (§5). No custom free-text reading voice in v1.
+"""
+from __future__ import annotations
+
+import random
+import re
+
+from .llm import LLM, get_llm
+from .styles import reading_tint
+
+SPREADS: dict[str, list[str]] = {
+ "single": ["The Heart of It"],
+ "three": ["Past", "Present", "Future"],
+}
+
+BASE_VOICE = "an earnest, warm mystic — reverent and a little uncanny, but never silly"
+
+
+def _strip_think(text: str) -> str:
+ return re.sub(r".*?", "", text, flags=re.DOTALL).strip()
+
+
+def draw_spread(deck: dict, spread: str = "three", reversals: bool = True,
+ seed: int | None = None) -> list[dict]:
+ """Draw cards (no repeats); orientations random only if reversals enabled."""
+ positions = SPREADS.get(spread, SPREADS["three"])
+ rng = random.Random(seed)
+ chosen = rng.sample(deck["cards"], k=len(positions))
+ drawn = []
+ for pos, card in zip(positions, chosen):
+ reversed_ = reversals and rng.random() < 0.5
+ drawn.append({
+ "position": pos,
+ "arcana_name": card["arcana_name"],
+ "arcana_number": card["arcana_number"],
+ "concept": card["concept"],
+ "orientation": "reversed" if reversed_ else "upright",
+ "meaning": card["reversed_meaning"] if reversed_ else card["upright_meaning"],
+ "essence": card.get("essence", ""),
+ "art_path": card.get("art_path"),
+ })
+ return drawn
+
+
+def journey_note(n: int) -> str:
+ """Where arcana n sits on the 0→21 Fool's Journey — as a phrase, never the
+ classical card name (the mapping stays hidden in the prose)."""
+ if n == 0: return "the very first step of the journey, setting out"
+ if n <= 2: return "the journey's first awakening"
+ if n <= 5: return "the early road of the journey"
+ if n <= 7: return "the journey's first real choices"
+ if n == 8 or n == 9: return "the turn inward at mid-journey"
+ if n == 10: return "the great turning of the journey's wheel"
+ if n <= 12: return "the reckoning in the middle of the journey"
+ if n <= 14: return "the journey's death-and-rebirth passage"
+ if n == 15: return "the journey's confrontation with the shadow"
+ if n == 16: return "the crisis point of the journey"
+ if n <= 19: return "the journey's return toward the light"
+ if n == 20: return "the journey's reckoning and awakening"
+ return "the completion of the journey"
+
+
+def _voice(deck: dict) -> str:
+ tint = reading_tint(deck.get("visual_style", ""))
+ return BASE_VOICE + (f"; {tint}" if tint else "")
+
+
+def _prior_block(drawn: list[dict], upto: int) -> str:
+ if upto == 0:
+ return "(none yet — this is the first card)"
+ return "\n".join(f"- [{d['position']}] {d['concept']} ({d['orientation']})"
+ for d in drawn[:upto])
+
+
+# ------------------------------------------------------------------ stages
+def card_partial(deck: dict, question: str, drawn: list[dict], idx: int,
+ llm: LLM | None = None) -> str:
+ """Interpret ONLY drawn[idx], coloured by the cards already turned. Builds the
+ thread; does not resolve (the synthesis does)."""
+ llm = llm or get_llm()
+ theme = deck.get("theme", "this world")
+ d = drawn[idx]
+ q = question.strip() or "an open reading — whatever the cards wish to say"
+ system = f"""\
+You are giving a live tarot reading, turning cards one at a time, from a deck whose
+cards are concepts from {theme}. Your voice is {_voice(deck)}.
+
+Interpret ONLY the card just turned — its meaning in its position, coloured by the
+cards already shown. Call it by its concept name and treat it as that very thing,
+with its own texture and stakes. Do NOT name classical tarot cards or archetypes.
+Build the thread and let suspense gather — do NOT resolve the reading yet; the final
+synthesis comes after the last card. 2 to 4 sentences, plain prose, no preamble."""
+ user = (
+ f"Question: {q}\n\n"
+ f"Cards already turned:\n{_prior_block(drawn, idx)}\n\n"
+ f"The card just turned — [{d['position']}] {d['concept']} ({d['orientation']})\n"
+ f" essence: {d.get('essence','')}\n"
+ f" meaning now: {d['meaning']}\n"
+ f" journey: this concept sits at {journey_note(d['arcana_number'])}.\n\n"
+ "Give the interpretation of this one card now."
+ )
+ return _strip_think(llm.complete(system, user, json_mode=False).strip())
+
+
+def final_synthesis(deck: dict, question: str, drawn: list[dict],
+ partials: list[str] | None = None, llm: LLM | None = None) -> str:
+ """Weave all turned cards into one narrative + a closing verdict (§5)."""
+ llm = llm or get_llm()
+ theme = deck.get("theme", "this world")
+ q = question.strip() or "an open reading — whatever the cards wish to say"
+ spread_lines = []
+ for i, d in enumerate(drawn):
+ ess = f" — {d['essence']}" if d.get("essence") else ""
+ spread_lines.append(
+ f"- [{d['position']}] {d['concept']} ({d['orientation']}){ess}\n"
+ f" meaning now: {d['meaning']}")
+ prior = ("\n\nWhat you have said so far, card by card:\n" +
+ "\n".join(f" • {p}" for p in partials)) if partials else ""
+ system = f"""\
+You are closing a tarot reading from a deck of concepts from {theme}. Your voice is
+{_voice(deck)}.
+
+Now weave ALL the cards into ONE coherent reading and a closing verdict that truly
+answers the question. The craft is in collision — show how the cards modify one
+another across their positions (not separate paragraphs), how the story they tell
+together turns. Call each card by its concept name; never name classical tarot cards
+or archetypes. Honour reversals. End with a clear, earned takeaway the querent can
+hold. A few short paragraphs of flowing prose — no headings, no bullets."""
+ user = (f"Question: {q}\n\nThe full spread, in order:\n" +
+ "\n".join(spread_lines) + prior +
+ "\n\nNow give the final synthesis and verdict.")
+ return _strip_think(llm.complete(system, user, json_mode=False).strip())
diff --git a/arcana/styles.py b/arcana/styles.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c5b166e9189baad05c3aa45e49dce26409b400e
--- /dev/null
+++ b/arcana/styles.py
@@ -0,0 +1,66 @@
+"""User-selectable visual styles (SPEC §8) — visual ONLY.
+
+The user picks the deck's look at generation time. This drives the image-model
+style suffix and nothing else; it does NOT add a custom reading voice (§5,
+reading voice stays a single default). Each preset is just a stored suffix
+string; "custom" passes the user's free text through. The compositor's frame
+stays style-neutral in v1 (§9).
+
+Guardrails ("no text, no border, cohesive palette…") are appended downstream in
+imagegen.GLOBAL_STYLE, so suffixes here describe only the artistic look.
+"""
+from __future__ import annotations
+
+DEFAULT_STYLE = "rider-waite-smith"
+
+STYLE_SUFFIXES: dict[str, str] = {
+ "rider-waite-smith":
+ "Rider-Waite-Smith tarot art, rich symbolic illustration, warm storybook "
+ "palette, gold linework, medieval-renaissance figures",
+ "thoth":
+ "Thoth tarot art, dark esoteric and ornate, Art-Deco geometry, jewel "
+ "tones, dense occult symbolism, luminous",
+ "marseille":
+ "Tarot de Marseille art, flat woodcut print, austere bold black outlines, "
+ "primary red blue and yellow, medieval block-print",
+ "playful":
+ "bright modern playful pop illustration, bold flat colours, clean vector "
+ "shapes, whimsical and irreverent, high-contrast",
+}
+
+# label shown in the UI -> preset id (Custom handled separately)
+STYLE_CHOICES: list[tuple[str, str]] = [
+ ("Rider-Waite-Smith", "rider-waite-smith"),
+ ("Thoth (dark, esoteric)", "thoth"),
+ ("Marseille (woodcut)", "marseille"),
+ ("Playful / Pop", "playful"),
+ ("Custom…", "custom"),
+]
+
+# a tempting pre-fill so people discover the custom field (§8)
+CUSTOM_PLACEHOLDER = "1920s scientific engraving"
+
+# light esoteric tint for the reading voice, keyed by visual style (§5)
+STYLE_READING_TINT: dict[str, str] = {
+ "thoth": "lean a little more esoteric and occult in your phrasing",
+ "marseille": "keep the phrasing plain, old and woodcut-stark",
+ "playful": "keep it lighter and more mischievous",
+}
+
+
+def resolve_style(visual_style: str, custom_text: str | None = None) -> tuple[str, str]:
+ """Return (style_id, style_suffix) for a chosen preset id or 'custom'.
+
+ 'custom' uses the user's free text (falling back to the placeholder); any
+ unknown id falls back to the default preset.
+ """
+ if visual_style == "custom":
+ text = (custom_text or "").strip() or CUSTOM_PLACEHOLDER
+ return "custom", text
+ if visual_style in STYLE_SUFFIXES:
+ return visual_style, STYLE_SUFFIXES[visual_style]
+ return DEFAULT_STYLE, STYLE_SUFFIXES[DEFAULT_STYLE]
+
+
+def reading_tint(style_id: str) -> str:
+ return STYLE_READING_TINT.get(style_id, "")
diff --git a/assets/fonts/Cinzel.ttf b/assets/fonts/Cinzel.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..89a863d7005b66dffef6dffba7ac93ed1a740d01
--- /dev/null
+++ b/assets/fonts/Cinzel.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f4d83d34d1f6c741193e4acf4b3dff9531e5a67b6aa65228d00a7db72a4e0f34
+size 125468
diff --git a/assets/fonts/EBGaramond.ttf b/assets/fonts/EBGaramond.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..c0076f92b2056fa09f10a9b700e1c0e498026c40
--- /dev/null
+++ b/assets/fonts/EBGaramond.ttf
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:684fcc0db2239b43497341d596d68422ea60ef7522cf2e857071ad84e4ade2a4
+size 851176
diff --git a/decks/breakfast_foods-playful-1780954994/00_the_banana.png b/decks/breakfast_foods-playful-1780954994/00_the_banana.png
new file mode 100644
index 0000000000000000000000000000000000000000..1e9fabbb20b10425eabcbf150c8de5177b26978a
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/00_the_banana.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:218bae8b4594ddd3e643a4188803404fb00e84a284f7737d7a581ac930718713
+size 498176
diff --git a/decks/breakfast_foods-playful-1780954994/01_the_whisk.png b/decks/breakfast_foods-playful-1780954994/01_the_whisk.png
new file mode 100644
index 0000000000000000000000000000000000000000..444d52b35ea3960cbc8c43cd1c2fe1bea0b5de6b
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/01_the_whisk.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1d4cb619baf6bbf9c985639a1860c28d0c4eb25b150d76eda98dd57ba8b386d2
+size 560663
diff --git a/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png b/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png
new file mode 100644
index 0000000000000000000000000000000000000000..f5b464a866100fdbe75c5f3a754e0631a3964722
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:409d446096263ca00238467e39676d35d4184737e4662d19b0e416fa1e0473cd
+size 710745
diff --git a/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png b/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png
new file mode 100644
index 0000000000000000000000000000000000000000..bd392820207c87b08b3e33f7c56537a568e4d564
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2acd29766df2f39c9e7cd0ff99d09605e39a30566831c671099f48226a03dc79
+size 455785
diff --git a/decks/breakfast_foods-playful-1780954994/04_black_coffee.png b/decks/breakfast_foods-playful-1780954994/04_black_coffee.png
new file mode 100644
index 0000000000000000000000000000000000000000..8ea299e98dff7c6da37ed04cc22e76238a97e2c4
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/04_black_coffee.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b1ded0b77f39da35bb45cbd07f72db2c60c33e70f53b45797d7f5a86cee78538
+size 671576
diff --git a/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png b/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png
new file mode 100644
index 0000000000000000000000000000000000000000..720b3339d314e69321de174fac0f0ca84e96c9c7
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d175921eb121aee30ca0765c4532634bb346141432b8e50437b691a9c7ce2d58
+size 507869
diff --git a/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png b/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png
new file mode 100644
index 0000000000000000000000000000000000000000..0ff8c4eddb51c4a096c6df413d50f8e6585be672
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e327c99b5235546d302ffc573c4d2ca9000e15113d9255f7f59af96d11a4320c
+size 626836
diff --git a/decks/breakfast_foods-playful-1780954994/07_the_toaster.png b/decks/breakfast_foods-playful-1780954994/07_the_toaster.png
new file mode 100644
index 0000000000000000000000000000000000000000..1e496766feb42f1eadc829c9f4a4193476cfe166
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/07_the_toaster.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e6b520705f39aa3e522328773c9b66ff918162c456f26829ba3810ef944fd8f4
+size 458173
diff --git a/decks/breakfast_foods-playful-1780954994/08_oatmeal.png b/decks/breakfast_foods-playful-1780954994/08_oatmeal.png
new file mode 100644
index 0000000000000000000000000000000000000000..cf7ca7201bd848bc91ac90c5713d6a24c93f68d5
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/08_oatmeal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d3d18696b508894925985635fe05afe5c7b79d13afde39cbc114758921ad2b78
+size 771405
diff --git a/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png b/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png
new file mode 100644
index 0000000000000000000000000000000000000000..88e8ac96c07f316267a6534477d661e41945df8e
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a723e0e40a3ab0b24eadce9a0ccc6ad50c17124fb84106beed29d5302888121c
+size 329520
diff --git a/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png b/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png
new file mode 100644
index 0000000000000000000000000000000000000000..a2e70ac984cfcf4fb66824325e14a9258589f098
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ebfd1c1ffcd2a355280d2e83939cf9de01f6c1fdfba987aa8a85f6c64c9d8f51
+size 610545
diff --git a/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png b/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png
new file mode 100644
index 0000000000000000000000000000000000000000..caca693f2bd628b6d46a62f4c4565d6dfea73be7
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/11_the_diner_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:21e763a4877aedc8be3db5589cc2fb983b10707d84bdcd537eb4e1e89892d83e
+size 888701
diff --git a/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png b/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png
new file mode 100644
index 0000000000000000000000000000000000000000..ce687661b5ff3e4fb22e19851d4eef1e0b7515c3
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7cd448fd4a598ff8d26a3d74a42348744842b74e6cd6ec6193fa2f8763383f05
+size 417917
diff --git a/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png b/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png
new file mode 100644
index 0000000000000000000000000000000000000000..3e4020df9fa2cb1171a08a08ea63303fb49ebadb
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7bc51d524d7ce1626a170642e4665b4288a62535b0a62e5f5a6ae1cccf197142
+size 695227
diff --git a/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png b/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png
new file mode 100644
index 0000000000000000000000000000000000000000..a4bb6aa138de9e255976ccaca071e594783f7079
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/14_the_smoothie.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5957afbc5f99c42461b98d7662606eee0d5770e755eda1bf093727d0d72888af
+size 321760
diff --git a/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png b/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png
new file mode 100644
index 0000000000000000000000000000000000000000..35e1fd9fdc3b2eb241e6d0842b233c5fad0c21ce
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:81ae96c873e47f8c9766a8cc24290b999cf4c8cac704622a4e90f03a81ed4c4f
+size 575894
diff --git a/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png b/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png
new file mode 100644
index 0000000000000000000000000000000000000000..29e8577da9c99a2af3637d17659c154cde02cec3
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:51d29a06cbe51c55cf8f5bfa3d4a1ce4775d7f4daa0b1564e25b471fcb7b300d
+size 524719
diff --git a/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png b/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png
new file mode 100644
index 0000000000000000000000000000000000000000..f610f8da37d552ce3bbe530def96403fad18b680
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bca6c7832eee106a4c7894d0132c45f8cd53df880f7220bb4260d2da1069628c
+size 451978
diff --git a/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png b/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee6027bfc67c946b1af0f6845ac3384825df2f90
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fc810ffbfe51970ea42f0cf90b05528f29db7d4c4a600a869c64449f8dd5ab61
+size 652150
diff --git a/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png b/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png
new file mode 100644
index 0000000000000000000000000000000000000000..98a95d7fc09ec17cb0368bbdc9ebce01f02ad175
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f93b77b85823b5ec8b086d07eb35ec0b43123e34f685e50bd1fb50f1e0eb6796
+size 647240
diff --git a/decks/breakfast_foods-playful-1780954994/20_order_s_up.png b/decks/breakfast_foods-playful-1780954994/20_order_s_up.png
new file mode 100644
index 0000000000000000000000000000000000000000..d6075bd9d1ef268d4a5dcb9789daf911a77c8d85
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/20_order_s_up.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bbfdd34c5710f30a011574c5ad68ed4e5abb836ff04ab399b4f01fb473b9297b
+size 464062
diff --git a/decks/breakfast_foods-playful-1780954994/21_the_full_english.png b/decks/breakfast_foods-playful-1780954994/21_the_full_english.png
new file mode 100644
index 0000000000000000000000000000000000000000..d5ac40babe2fdb7eb8933d6c3123e5e65b7e9405
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/21_the_full_english.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5bc7ca410eba06b80bdc5a6e0311ee789a9de8e126f3c6c0d36a9f73fcc52909
+size 699091
diff --git a/decks/breakfast_foods-playful-1780954994/back.png b/decks/breakfast_foods-playful-1780954994/back.png
new file mode 100644
index 0000000000000000000000000000000000000000..8db225053d0fc8da73c9f8d51d3235a9f02467dd
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f36697cfc2640d0a130b8f43e0de11822bfebb455ed046b506d85a975aab976d
+size 1376660
diff --git a/decks/breakfast_foods-playful-1780954994/deck.json b/decks/breakfast_foods-playful-1780954994/deck.json
new file mode 100644
index 0000000000000000000000000000000000000000..58a6c4887e1086bb9fe2e4d69fa074c0b9568efc
--- /dev/null
+++ b/decks/breakfast_foods-playful-1780954994/deck.json
@@ -0,0 +1,274 @@
+{
+ "theme": "breakfast foods",
+ "style_suffix": "bright modern playful pop illustration, bold flat colours, clean vector shapes, whimsical and irreverent, high-contrast",
+ "cards": [
+ {
+ "arcana_number": 0,
+ "arcana_name": "The Fool",
+ "concept": "The Banana",
+ "justification": "A fresh, ripe banana is a simple and innocent beginning, often accompanied by the risk of misstep or falling — a literal pratfall.",
+ "upright_meaning": "The Banana marks the start of something sweet and ripe — a moment of innocence and optimism, ripe with possibility and a touch of whimsy.",
+ "reversed_meaning": "A false start or a miscalculation — slipping on your own assumptions or rushing in too soon.",
+ "art_prompt": "a bright yellow banana hanging from a green stem, sunlight filtering through, with a small figure standing at the edge of a tree branch, one foot in the air",
+ "art_path": "decks/breakfast_foods-playful-1780954994/00_the_banana.png",
+ "essence": "Playful new beginnings with a hint of fragility.",
+ "seed": 871341
+ },
+ {
+ "arcana_number": 1,
+ "arcana_name": "The Magician",
+ "concept": "The Whisk",
+ "justification": "The whisk is a tool that transforms raw ingredients into something new through focused motion and energy.",
+ "upright_meaning": "The Whisk turns raw ingredients into something new — your will is stirring, blending, and bringing ideas to life with focused energy.",
+ "reversed_meaning": "A lack of momentum — spinning without progress, or mixing without purpose.",
+ "art_prompt": "a hand holding a whisk moving through a cloud of egg whites, which swirl and rise into a foamy tower",
+ "art_path": "decks/breakfast_foods-playful-1780954994/01_the_whisk.png",
+ "essence": "Manifesting through motion and blending.",
+ "seed": 871342
+ },
+ {
+ "arcana_number": 2,
+ "arcana_name": "The High Priestess",
+ "concept": "The Covered Dish",
+ "justification": "The covered dish holds a mystery — something hidden and waiting to be revealed, much like intuition.",
+ "upright_meaning": "The Covered Dish holds something hidden beneath its lid — trust in the secret forces at work, even when you cannot yet see them.",
+ "reversed_meaning": "Ignoring what lies beneath — closing the lid too tightly or refusing to look inside.",
+ "art_prompt": "a steaming covered casserole on a wooden table, the lid slightly ajar, revealing a golden, bubbly interior",
+ "art_path": "decks/breakfast_foods-playful-1780954994/02_the_covered_dish.png",
+ "essence": "Mystery beneath the surface.",
+ "seed": 871343
+ },
+ {
+ "arcana_number": 3,
+ "arcana_name": "The Empress",
+ "concept": "The Stack of Pancakes",
+ "justification": "Pancakes are nurturing and abundant, layered with care and richness, much like the Empress's nurturing abundance.",
+ "upright_meaning": "The Stack of Pancakes is a bountiful creation, rising from care and generosity — a gift of warmth and sustenance shared with others.",
+ "reversed_meaning": "Overload or neglect — too many layers without support, or a neglected pile that never rises.",
+ "art_prompt": "a warm stack of fluffy pancakes, syrup glistening on each layer, with a red cherry on top",
+ "art_path": "decks/breakfast_foods-playful-1780954994/03_the_stack_of_pancakes.png",
+ "essence": "Nurturing abundance, layer by layer.",
+ "seed": 871344
+ },
+ {
+ "arcana_number": 4,
+ "arcana_name": "The Emperor",
+ "concept": "Black Coffee",
+ "justification": "Black coffee is bitter and bold, symbolizing control and the structure of morning routine.",
+ "upright_meaning": "Black Coffee offers a bold clarity — a moment of strong authority, a decision made with focus and confidence.",
+ "reversed_meaning": "Rigid control leading to bitterness — or a stale routine that no longer fuels you.",
+ "art_prompt": "a steaming black coffee in a porcelain mug, sitting on a wooden tray with a clock and newspaper",
+ "art_path": "decks/breakfast_foods-playful-1780954994/04_black_coffee.png",
+ "essence": "Clarity through strength and structure.",
+ "seed": 871345
+ },
+ {
+ "arcana_number": 5,
+ "arcana_name": "The Hierophant",
+ "concept": "The Continental Breakfast",
+ "justification": "The continental breakfast is a standard, traditional offering served at hotels and cafes worldwide.",
+ "upright_meaning": "The Continental Breakfast invites you into a ritual of comfort and connection — finding your place within a shared experience.",
+ "reversed_meaning": "Feeling trapped by routine — eating out of habit rather than heart.",
+ "art_prompt": "a breakfast tray with bread, butter, jam, and a small glass of juice on a white cloth",
+ "art_path": "decks/breakfast_foods-playful-1780954994/05_the_continental_breakfast.png",
+ "essence": "Tradition served with a sense of place.",
+ "seed": 871346
+ },
+ {
+ "arcana_number": 6,
+ "arcana_name": "The Lovers",
+ "concept": "Bacon and Eggs",
+ "justification": "Bacon and eggs are a classic pair, often chosen together as a harmonious combination.",
+ "upright_meaning": "Bacon and Eggs show a deep harmony of parts — when two forces align to create something greater than themselves.",
+ "reversed_meaning": "A failed pairing — when components clash instead of complementing, or when one dominates the other.",
+ "art_prompt": "a sizzling pan with eggs and bacon side by side, their juices mingling in a golden pool",
+ "art_path": "decks/breakfast_foods-playful-1780954994/06_bacon_and_eggs.png",
+ "essence": "Complementary union.",
+ "seed": 871347
+ },
+ {
+ "arcana_number": 7,
+ "arcana_name": "The Chariot",
+ "concept": "The Toaster",
+ "justification": "The toaster channels heat to control the bread’s transformation into a desired, triumphant form — a chariot of morning energy.",
+ "upright_meaning": "The Toaster moves with precision — mastering time and temperature to achieve your goal with focus and fire.",
+ "reversed_meaning": "A failure to execute — undercooked intentions or a fire that burns too hot, too fast.",
+ "art_prompt": "a vintage toaster glowing red-hot, with golden slices of bread emerging on each side",
+ "art_path": "decks/breakfast_foods-playful-1780954994/07_the_toaster.png",
+ "essence": "Control through heat and momentum.",
+ "seed": 871348
+ },
+ {
+ "arcana_number": 8,
+ "arcana_name": "Strength",
+ "concept": "Oatmeal",
+ "justification": "Oatmeal provides quiet, inner strength and sustains through the morning with gentle power.",
+ "upright_meaning": "Oatmeal is the strength of steady warmth — the kind that nourishes without noise, and supports without strain.",
+ "reversed_meaning": "Drained energy or lack of foundation — feeling empty or brittle despite having rested.",
+ "art_prompt": "a bowl of steaming oatmeal with cinnamon sprinkled on top, held in a woman's hand",
+ "art_path": "decks/breakfast_foods-playful-1780954994/08_oatmeal.png",
+ "essence": "Strength in quiet endurance.",
+ "seed": 871349
+ },
+ {
+ "arcana_number": 9,
+ "arcana_name": "The Hermit",
+ "concept": "The Lone Boiled Egg",
+ "justification": "The boiled egg is self-contained and solitary, sitting in quiet reflection.",
+ "upright_meaning": "The Lone Boiled Egg offers a moment of stillness — a time to look inward, to listen to your own quiet voice.",
+ "reversed_meaning": "Loneliness without purpose — being alone but not at peace with yourself.",
+ "art_prompt": "a single boiled egg in a small porcelain cup, sitting on a windowsill with soft morning light",
+ "art_path": "decks/breakfast_foods-playful-1780954994/09_the_lone_boiled_egg.png",
+ "essence": "Introspection in a shell.",
+ "seed": 871350
+ },
+ {
+ "arcana_number": 10,
+ "arcana_name": "Wheel of Fortune",
+ "concept": "The Cereal-Box Prize",
+ "justification": "The cereal-box prize is a small but precious surprise — a moment of fate in a box.",
+ "upright_meaning": "The Cereal-Box Prize is a twist of fate — a small treasure from a larger game, a chance to be surprised by fortune.",
+ "reversed_meaning": "Missed opportunity — the prize is gone, or the box is empty just when you needed it.",
+ "art_prompt": "a child holding a cereal box with a toy peeking out from the top, eyes wide with wonder",
+ "art_path": "decks/breakfast_foods-playful-1780954994/10_the_cereal_box_prize.png",
+ "essence": "Luck in the box.",
+ "seed": 871351
+ },
+ {
+ "arcana_number": 11,
+ "arcana_name": "Justice",
+ "concept": "The Diner Check",
+ "justification": "The check at the diner represents the fair accounting of what was ordered and paid for.",
+ "upright_meaning": "The Diner Check reveals what you owe and what is owed to you — a moment of truth in exchange and expectation.",
+ "reversed_meaning": "Unbalanced transactions — being shortchanged, or failing to give what is fair.",
+ "art_prompt": "a waitress sliding a check across the diner counter, a pen in hand, waiting for signature",
+ "art_path": "decks/breakfast_foods-playful-1780954994/11_the_diner_check.png",
+ "essence": "Fair accounting at the table.",
+ "seed": 871352
+ },
+ {
+ "arcana_number": 12,
+ "arcana_name": "The Hanged Man",
+ "concept": "French Toast Soaking",
+ "justification": "French toast is suspended in custard, waiting for transformation — a state of suspension and acceptance.",
+ "upright_meaning": "French Toast Soaking invites you to let the old be absorbed — the slow transformation of self through patience and surrender.",
+ "reversed_meaning": "Stuck in waiting — refusing to let go, or waiting without purpose.",
+ "art_prompt": "a slice of bread soaking in a golden custard bath, slowly absorbing the liquid",
+ "art_path": "decks/breakfast_foods-playful-1780954994/12_french_toast_soaking.png",
+ "essence": "Soaking in transformation.",
+ "seed": 871353
+ },
+ {
+ "arcana_number": 13,
+ "arcana_name": "Death",
+ "concept": "The Burnt Toast",
+ "justification": "Burnt toast is an irreversible change — a transformation into something unusable.",
+ "upright_meaning": "The Burnt Toast signals a necessary end — something must be sacrificed to clear the path for what is to come.",
+ "reversed_meaning": "Clinging to the past — trying to revive what is already lost.",
+ "art_prompt": "a piece of burnt toast, curled at the edges, sitting in a cold plate of crumbs",
+ "art_path": "decks/breakfast_foods-playful-1780954994/13_the_burnt_toast.png",
+ "essence": "Ashes of a flame.",
+ "seed": 871354
+ },
+ {
+ "arcana_number": 14,
+ "arcana_name": "Temperance",
+ "concept": "The Smoothie",
+ "justification": "Smoothies blend contrasting flavors into a balanced and harmonious whole.",
+ "upright_meaning": "The Smoothie shows harmony in blend — when ingredients that seem incompatible unite into something both nourishing and refreshing.",
+ "reversed_meaning": "An imbalance of taste — too much of one flavor, or a drink that refuses to settle.",
+ "art_prompt": "a vibrant green smoothie in a glass, with a straw and a banana half hanging over the edge",
+ "art_path": "decks/breakfast_foods-playful-1780954994/14_the_smoothie.png",
+ "essence": "Balancing the flavors.",
+ "seed": 871355
+ },
+ {
+ "arcana_number": 15,
+ "arcana_name": "The Devil",
+ "concept": "The Sugary Cereal",
+ "justification": "Sugary cereal is addictive and hard to resist — a bondage to craving and indulgence.",
+ "upright_meaning": "The Sugary Cereal is a trap of sweetness — you are tied to what you crave, unable to break free from its grip.",
+ "reversed_meaning": "Breaking the spell — shaking free from unhealthy patterns and finding clarity.",
+ "art_prompt": "a bowl of colorful sugary cereal, the milk bubbling with cartoonish delight",
+ "art_path": "decks/breakfast_foods-playful-1780954994/15_the_sugary_cereal.png",
+ "essence": "Bound by craving.",
+ "seed": 871356
+ },
+ {
+ "arcana_number": 16,
+ "arcana_name": "The Tower",
+ "concept": "The Dropped Tray",
+ "justification": "A dropped tray is a sudden collapse — everything falls apart in a moment.",
+ "upright_meaning": "The Dropped Tray is chaos unleashed — the sudden undoing of what you thought was carefully arranged.",
+ "reversed_meaning": "Refusing to let go — holding on to broken pieces and pretending they still serve a purpose.",
+ "art_prompt": "a tray of food mid-fall, glasses and food spilling in slow motion across the floor",
+ "art_path": "decks/breakfast_foods-playful-1780954994/16_the_dropped_tray.png",
+ "essence": "Order overturned.",
+ "seed": 871357
+ },
+ {
+ "arcana_number": 17,
+ "arcana_name": "The Star",
+ "concept": "Fresh-Squeezed Orange Juice",
+ "justification": "Fresh orange juice brings hope and renewal — a burst of vitality in the morning.",
+ "upright_meaning": "Fresh-Squeezed Orange Juice brings a burst of hope — the kind that lifts your spirit and restores your energy.",
+ "reversed_meaning": "Bitter disappointment — the promise of light turned sour.",
+ "art_prompt": "a glass of fresh-squeezed orange juice, sunlight streaming in, with drops of juice dripping from the orange",
+ "art_path": "decks/breakfast_foods-playful-1780954994/17_fresh_squeezed_orange_juice.png",
+ "essence": "Light in the glass.",
+ "seed": 871358
+ },
+ {
+ "arcana_number": 18,
+ "arcana_name": "The Moon",
+ "concept": "The Mystery Omelette",
+ "justification": "The omelette is made with unknown ingredients — a symbol of uncertainty and fear.",
+ "upright_meaning": "The Mystery Omelette offers uncertainty — you are cooking with unknowns, trusting the process even if the result is unclear.",
+ "reversed_meaning": "Truth revealed — the veil is lifted, and the contents are known, even if they are unsettling.",
+ "art_prompt": "a waiter holding a silver dome over a plate, the omelette hidden beneath, eyes squinting in curiosity",
+ "art_path": "decks/breakfast_foods-playful-1780954994/18_the_mystery_omelette.png",
+ "essence": "Mystery in the pan.",
+ "seed": 871359
+ },
+ {
+ "arcana_number": 19,
+ "arcana_name": "The Sun",
+ "concept": "The Sunny-Side-Up Egg",
+ "justification": "The Sunny-Side-Up Egg is a literal sun — bright, joyous, and full of vitality.",
+ "upright_meaning": "The Sunny-Side-Up Egg is a symbol of clear success — your efforts have risen, and the future looks golden and unbroken.",
+ "reversed_meaning": "A cracked promise — the yolk breaks, and the optimism fades.",
+ "art_prompt": "a sunny-side-up egg on a white plate, surrounded by herbs and sunlight",
+ "art_path": "decks/breakfast_foods-playful-1780954994/19_the_sunny_side_up_egg.png",
+ "essence": "Sun in the bowl.",
+ "seed": 871360
+ },
+ {
+ "arcana_number": 20,
+ "arcana_name": "Judgement",
+ "concept": "\"Order's Up!\"",
+ "justification": "The breakfast bell is the call to judgment — you are summoned to receive the fruit of your labor.",
+ "upright_meaning": "Order's Up is a summons to action — the time has come to respond, to wake up or take responsibility.",
+ "reversed_meaning": "Ignoring the call — pretending you don’t hear it or that it isn’t for you.",
+ "art_prompt": "a diner bell hanging from a wooden counter, with a chef holding a tray, calling out to the customer",
+ "art_path": "decks/breakfast_foods-playful-1780954994/20_order_s_up.png",
+ "essence": "The call from the kitchen.",
+ "seed": 871361
+ },
+ {
+ "arcana_number": 21,
+ "arcana_name": "The World",
+ "concept": "The Full English",
+ "justification": "The full English is a complete and integrated breakfast — everything on the plate working together in harmony.",
+ "upright_meaning": "The Full English is the gathering of all parts — a satisfying, whole meal that shows integration and fulfillment.",
+ "reversed_meaning": "Incompletion or imbalance — missing ingredients, or a meal that fails to come together.",
+ "art_prompt": "a plate containing bacon, eggs, sausages, beans, toast, and mushrooms, all perfectly arranged",
+ "art_path": "decks/breakfast_foods-playful-1780954994/21_the_full_english.png",
+ "essence": "Completion on the plate.",
+ "seed": 871362
+ }
+ ],
+ "deck_id": "breakfast_foods-playful-1780954994",
+ "visual_style": "playful",
+ "seed_base": 871341,
+ "back_path": "decks/breakfast_foods-playful-1780954994/back.png"
+}
\ No newline at end of file
diff --git a/decks/lord_of_the_rings-bundled/00_frodo_baggins.png b/decks/lord_of_the_rings-bundled/00_frodo_baggins.png
new file mode 100644
index 0000000000000000000000000000000000000000..54f9ea6afc8224732692114b1471c46800cf8efa
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/00_frodo_baggins.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fb9d9975ea23c4fbe1a60db3c9e7600ebd553e3a203c3e688cc8323b34874ab4
+size 1433153
diff --git a/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png b/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png
new file mode 100644
index 0000000000000000000000000000000000000000..424f849efa761c3eb0736ffa691c73547fd0504c
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:83cae7fc315f3c6ff492aa051edf5e4416f1ba09a25a495c05323b16d4f98c61
+size 1539770
diff --git a/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png b/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png
new file mode 100644
index 0000000000000000000000000000000000000000..d9a5fc195222e0268ed61f12de8bcc418327653f
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:53403ebd5ad2073c0f65dab76943ae8635e6b10a4c3ad6c92c07d9d9f565499b
+size 1675832
diff --git "a/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png" "b/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png"
new file mode 100644
index 0000000000000000000000000000000000000000..9222a5ebb34a1e806a91214ce531f86fec986d21
--- /dev/null
+++ "b/decks/lord_of_the_rings-bundled/03_lothl\303\263rien.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a639ef360c852e4a4161166fb97e1dae8bb15620171322a69783f1637cd479ef
+size 1371664
diff --git "a/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png" "b/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png"
new file mode 100644
index 0000000000000000000000000000000000000000..bd1ff8edc64afa4fe49ad103eb9dd31068f573e2
--- /dev/null
+++ "b/decks/lord_of_the_rings-bundled/04_th\303\251oden_king.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d1e79a40eeed65361b2c11c09b2ae3d1401c90c6a1dee7aebc64b2f0fa5bc7c8
+size 670312
diff --git a/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png b/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png
new file mode 100644
index 0000000000000000000000000000000000000000..76ca802a3f41f384683d344751161ad16343f7d1
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e94e3facf1dff221db8b4fd9cd46f9d7d640b2f59d4bf5853719d8b109a2904
+size 1498142
diff --git a/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png b/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png
new file mode 100644
index 0000000000000000000000000000000000000000..b22c0de0b18944bd922c7899f68c165068e5050a
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:28b6200e8c3d3966f205da2f57ec8e34746c81d4d31a0ff1fb83cacfe8bb4f44
+size 1067749
diff --git a/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png b/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png
new file mode 100644
index 0000000000000000000000000000000000000000..aa112b6f0e13bcfa752aff8f1b2f1c72cb633121
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:75c16677ec4f3e142ec34d1261964ba94118506d5b99f2de4f53d13651bf0355
+size 1627306
diff --git a/decks/lord_of_the_rings-bundled/08_gollum.png b/decks/lord_of_the_rings-bundled/08_gollum.png
new file mode 100644
index 0000000000000000000000000000000000000000..f6f94f85b405b84b5531576e92c18c79a9b9e3ea
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/08_gollum.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a07de21cb0c64d12d64de1b17dcfc99b4c50d8dc6df6164ccb7988157f8be2dd
+size 1254376
diff --git a/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png b/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png
new file mode 100644
index 0000000000000000000000000000000000000000..6da962ef1801237515fa29f3f3616cc1aed7e99b
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bc6846cb4d0269d6288985553b967f0dfd7cae6c941e713cffc96bf3536b9880
+size 956324
diff --git a/decks/lord_of_the_rings-bundled/10_the_one_ring.png b/decks/lord_of_the_rings-bundled/10_the_one_ring.png
new file mode 100644
index 0000000000000000000000000000000000000000..3925caf716e6b4b59513206847ab88b808c1bb93
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/10_the_one_ring.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fd6415f4aff3304e3235b68014a6831cb8d8249defc711051981dcd4467f7120
+size 1333872
diff --git a/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png b/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png
new file mode 100644
index 0000000000000000000000000000000000000000..882c943d92857631f4bbf24878bbb4dd61591fd2
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a60b647cde5655dc30cdd5341051616bcc3eea62f6ead68c9072addb4f254fb0
+size 1522325
diff --git a/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png b/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png
new file mode 100644
index 0000000000000000000000000000000000000000..9a967d02dd83284e265235cd62e1eb34ac60aba3
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:79701dd1f39dd5c54c5a54aa1159cd8a8919f6415cc804bb21667004026cfca1
+size 1686714
diff --git a/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png b/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png
new file mode 100644
index 0000000000000000000000000000000000000000..5b21a0e3cc55147be69b6aaa1b72161f98ae0ed9
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4204d5456c50f24707418a3b958a52bc702cd135f5383d4b811c80c7e0daab05
+size 1672208
diff --git a/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png b/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png
new file mode 100644
index 0000000000000000000000000000000000000000..e069c49195751c5352ab5cf2480e8a44090b8219
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8393ed1451efe1f24107649f614cc699949175a455bd4e29f8b88f3437f13f63
+size 970668
diff --git a/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png b/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png
new file mode 100644
index 0000000000000000000000000000000000000000..0b6e7bf1cab9f8bd2b300798077dc3534532b309
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3f9472665e275001bbc0a0c0bc891b8e44555990e5d8295645b276b48c6c40b4
+size 1231578
diff --git "a/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png" "b/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png"
new file mode 100644
index 0000000000000000000000000000000000000000..e8611d99cc6779f863613e10ef7a48a05f63f0a3
--- /dev/null
+++ "b/decks/lord_of_the_rings-bundled/16_the_fall_of_barad_d\303\273r.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c8e6f1cf4d85a2b55f4da2daefc9199526ede49de0c18c512b3c8e6f6762d6b8
+size 1630161
diff --git "a/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png" "b/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png"
new file mode 100644
index 0000000000000000000000000000000000000000..11dd2fba6109dd0cccab3210a256da4efc6ce298
--- /dev/null
+++ "b/decks/lord_of_the_rings-bundled/17_the_light_of_e\303\244rendil.png"
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:70baa7f3a0a76f39c641b1d053d48366bb95a7650887ba1a6eb09f1e73810ab0
+size 689300
diff --git a/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png b/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png
new file mode 100644
index 0000000000000000000000000000000000000000..39c6e505dbd46ddecc3e7aaa74cc5144d5dc7b1a
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/18_the_misty_mountains.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5bb7b6abb0530236ff10232324e5f431cc484ff0430c12d0a6ce78d570264cd1
+size 1426666
diff --git a/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png b/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png
new file mode 100644
index 0000000000000000000000000000000000000000..77d58f89334041b705b3f6824b773f68724503f2
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e34ecd7704aa8e69c118296f4b36ff35ad86c33f2ff58293b09f98e3d8abfa5f
+size 1271157
diff --git a/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png b/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png
new file mode 100644
index 0000000000000000000000000000000000000000..de4796a9ffca6adecd9ed2f3e2c20f2fcaa1f639
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f138869b7af28b5bb57800884fb3b1e3e70459f16bd7e1e380a1647fe70e8fe5
+size 1669141
diff --git a/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png b/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png
new file mode 100644
index 0000000000000000000000000000000000000000..96f368aec93a9b088ffdc699e63636d6819eae06
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eac01a0d834de59bc6dd479d397a17a6dcd9726ff9613391af8d32a7bfd9ffa4
+size 805329
diff --git a/decks/lord_of_the_rings-bundled/back.png b/decks/lord_of_the_rings-bundled/back.png
new file mode 100644
index 0000000000000000000000000000000000000000..0dc3569e91bdd01e6f76b4da7a2051e5f6153e6f
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:84a3ae66b8c50d9d4acac4dad875b4902fbaaf5202716dee692c83902a10da85
+size 2126507
diff --git a/decks/lord_of_the_rings-bundled/deck.json b/decks/lord_of_the_rings-bundled/deck.json
new file mode 100644
index 0000000000000000000000000000000000000000..3e2c5665f1e4e9a8c6a62c57513de50b6a62024d
--- /dev/null
+++ b/decks/lord_of_the_rings-bundled/deck.json
@@ -0,0 +1,274 @@
+{
+ "deck_id": "lord_of_the_rings-bundled",
+ "theme": "Lord of the Rings",
+ "visual_style": "custom",
+ "style_suffix": "medieval illuminated manuscript style with golden ink and intricate linework",
+ "seed_base": 770077,
+ "back_path": "decks/lord_of_the_rings-bundled/back.png",
+ "cards": [
+ {
+ "arcana_number": 0,
+ "arcana_name": "The Fool",
+ "concept": "Frodo Baggins",
+ "justification": "Embodies innocence and the leap of faith at the start of a perilous journey.",
+ "upright_meaning": "A small but determined soul embarks on a great path, guided by innocence, purpose, and hope.",
+ "reversed_meaning": "A loss of idealism, or a failure to see the burden one carries.",
+ "art_prompt": "a hobbit standing at the threshold of a green door, a small ring in hand, with a path winding into distant mountains",
+ "art_path": "decks/lord_of_the_rings-bundled/00_frodo_baggins.png",
+ "essence": "A hobbit’s journey into darkness with a heart full of light.",
+ "seed": 770077
+ },
+ {
+ "arcana_number": 1,
+ "arcana_name": "The Magician",
+ "concept": "Gandalf the Grey",
+ "justification": "Gandalf channels great power into purposeful action and transformation.",
+ "upright_meaning": "A master of elements and knowledge, guiding others with wisdom and inner fire.",
+ "reversed_meaning": "Power used without wisdom or clarity; guidance turned to confusion.",
+ "art_prompt": "a tall grey-clad wizard holding a staff, the flame of his pipe glowing as he stands on a cliff overlooking a storm",
+ "art_path": "decks/lord_of_the_rings-bundled/01_gandalf_the_grey.png",
+ "essence": "A wizard who wields fire and wisdom to shape the world.",
+ "seed": 770078
+ },
+ {
+ "arcana_number": 2,
+ "arcana_name": "The High Priestess",
+ "concept": "The Mirror of Galadriel",
+ "justification": "The mirror reflects hidden truths and unseen futures.",
+ "upright_meaning": "A vision of the unseen, offering clarity through reflection and foresight.",
+ "reversed_meaning": "Misreading what is seen; confusion between truth and illusion.",
+ "art_prompt": "a polished silver mirror reflecting a starry sky and a shadowy figure standing at the edge of a forest",
+ "art_path": "decks/lord_of_the_rings-bundled/02_the_mirror_of_galadriel.png",
+ "essence": "A mirror that reveals the past and future in one glance.",
+ "seed": 770079
+ },
+ {
+ "arcana_number": 3,
+ "arcana_name": "The Empress",
+ "concept": "Lothlórien",
+ "justification": "Lothlórien is a place of abundance, beauty, and nurturing life.",
+ "upright_meaning": "A place of beauty, healing, and ancient grace, offering rest and renewal.",
+ "reversed_meaning": "Beauty corrupted or a paradise lost to carelessness.",
+ "art_prompt": "a golden tree glowing in a forest of silver-barked trees, a river of light flowing nearby",
+ "art_path": "decks/lord_of_the_rings-bundled/03_lothlórien.png",
+ "essence": "A land where time slows and peace is woven into the trees.",
+ "seed": 770080
+ },
+ {
+ "arcana_number": 4,
+ "arcana_name": "The Emperor",
+ "concept": "Théoden King",
+ "justification": "Théoden embodies authority, leadership, and the restoration of order.",
+ "upright_meaning": "A leader who restores order, strength, and dignity to a broken land.",
+ "reversed_meaning": "A leader who wavers or fails to claim their rightful place.",
+ "art_prompt": "a tall, kingly figure in white and silver standing on a hill, sword raised, leading riders into the horizon",
+ "art_path": "decks/lord_of_the_rings-bundled/04_théoden_king.png",
+ "essence": "A king who returns to reclaim his throne and his people.",
+ "seed": 770081
+ },
+ {
+ "arcana_number": 5,
+ "arcana_name": "The Hierophant",
+ "concept": "The Council of Elrond",
+ "justification": "A gathering of wisdom and tradition to decide the course of action.",
+ "upright_meaning": "A sacred assembly where choices are made with care and purpose.",
+ "reversed_meaning": "Disarray in leadership or a failure to reach consensus.",
+ "art_prompt": "a long, round table with robed figures seated, a ring at the center casting a faint glow",
+ "art_path": "decks/lord_of_the_rings-bundled/05_the_council_of_elrond.png",
+ "essence": "A gathering where fate and wisdom are weighed in council.",
+ "seed": 770082
+ },
+ {
+ "arcana_number": 6,
+ "arcana_name": "The Lovers",
+ "concept": "Aragorn and Arwen",
+ "justification": "A union of choice and destiny, bound by love and sacrifice.",
+ "upright_meaning": "A union of heart and purpose, shaped by fate and sacrifice.",
+ "reversed_meaning": "Love unfulfilled or a choice not made in time.",
+ "art_prompt": "two figures standing at the edge of a river, one in royal armor, the other in flowing robes, gazing into each other’s eyes",
+ "art_path": "decks/lord_of_the_rings-bundled/06_aragorn_and_arwen.png",
+ "essence": "Two souls bound by love and duty.",
+ "seed": 770083
+ },
+ {
+ "arcana_number": 7,
+ "arcana_name": "The Chariot",
+ "concept": "The Battle of Pelennor Fields",
+ "justification": "A coordinated battle for victory, led by will and courage.",
+ "upright_meaning": "A decisive moment where unity and courage turn the tide.",
+ "reversed_meaning": "Leadership failing in chaos; victory slipping away.",
+ "art_prompt": "a galloping white horse, riderless, galloping toward a burning city, arrows flying in the sky",
+ "art_path": "decks/lord_of_the_rings-bundled/07_the_battle_of_pelennor_f.png",
+ "essence": "An army moving as one toward the heart of battle.",
+ "seed": 770084
+ },
+ {
+ "arcana_number": 8,
+ "arcana_name": "Strength",
+ "concept": "Gollum",
+ "justification": "Despite his brokenness, Gollum’s devotion to the Ring shows inner strength and strange loyalty.",
+ "upright_meaning": "Strength found in vulnerability; a twisted path leading to redemption.",
+ "reversed_meaning": "A mind torn in two, unable to choose or heal.",
+ "art_prompt": "a thin, twisted creature clutching a ring with both hands, standing at the edge of a crumbling bridge",
+ "art_path": "decks/lord_of_the_rings-bundled/08_gollum.png",
+ "essence": "A soul split between light and shadow, love and obsession.",
+ "seed": 770085
+ },
+ {
+ "arcana_number": 9,
+ "arcana_name": "The Hermit",
+ "concept": "Faramir in Ithilien",
+ "justification": "Faramir, in solitude, watches and learns from the land, waiting for the right time to act.",
+ "upright_meaning": "A guardian of the borderlands, seeking truth in quiet solitude.",
+ "reversed_meaning": "Isolation leading to hesitation or missed opportunity.",
+ "art_prompt": "a lone ranger cloaked in green, standing on a hill at dusk, watching a distant road",
+ "art_path": "decks/lord_of_the_rings-bundled/09_faramir_in_ithilien.png",
+ "essence": "A ranger who walks alone, seeking wisdom in the wild.",
+ "seed": 770086
+ },
+ {
+ "arcana_number": 10,
+ "arcana_name": "Wheel of Fortune",
+ "concept": "The One Ring",
+ "justification": "The Ring is a force of fate, turning the course of history with unseen hands.",
+ "upright_meaning": "A force beyond control, turning the course of destiny in unseen ways.",
+ "reversed_meaning": "Fate unraveled; what was built is undone by a single lost object.",
+ "art_prompt": "a golden ring floating in space, casting dark shadows over mountains, rivers, and lands below",
+ "art_path": "decks/lord_of_the_rings-bundled/10_the_one_ring.png",
+ "essence": "A ring that turns fortune, binds fate, and whispers temptation.",
+ "seed": 770087
+ },
+ {
+ "arcana_number": 11,
+ "arcana_name": "Justice",
+ "concept": "The Judgment of Isildur",
+ "justification": "Isildur’s failure to destroy the Ring led to a long chain of consequences.",
+ "upright_meaning": "Consequences return when choices are left unhealed; truth revealed in time.",
+ "reversed_meaning": "Justice delayed or denied, letting old wounds fester.",
+ "art_prompt": "a shattered sword in a river, the golden ring floating beside it, a shadowy figure watching from the shore",
+ "art_path": "decks/lord_of_the_rings-bundled/11_the_judgment_of_isildur.png",
+ "essence": "A king’s downfall and a nation’s reckoning.",
+ "seed": 770088
+ },
+ {
+ "arcana_number": 12,
+ "arcana_name": "The Hanged Man",
+ "concept": "Gandalf falls in Moria",
+ "justification": "Gandalf is suspended in death and transformation, from grey to white.",
+ "upright_meaning": "A moment of surrender that leads to transformation and rebirth.",
+ "reversed_meaning": "A missed chance to let go and move forward.",
+ "art_prompt": "a tall figure falling from a bridge, a flame in hand, darkness rising behind him",
+ "art_path": "decks/lord_of_the_rings-bundled/12_gandalf_falls_in_moria.png",
+ "essence": "A death that becomes a new birth.",
+ "seed": 770089
+ },
+ {
+ "arcana_number": 13,
+ "arcana_name": "Death",
+ "concept": "Sauron’s Defeat",
+ "justification": "The end of Sauron, a transformation of the world, and the end of an age.",
+ "upright_meaning": "The end of tyranny, the breaking of an evil force, making way for a new age.",
+ "reversed_meaning": "Clinging to the past, refusing to let go of dying power.",
+ "art_prompt": "a flaming eye falling apart into ash, a golden ring rising into the sky",
+ "art_path": "decks/lord_of_the_rings-bundled/13_sauron_s_defeat.png",
+ "essence": "The fall of a power that ruled in shadow.",
+ "seed": 770090
+ },
+ {
+ "arcana_number": 14,
+ "arcana_name": "Temperance",
+ "concept": "The Shire after the War",
+ "justification": "The Shire represents balance restored after the upheaval of war.",
+ "upright_meaning": "A return to peace through careful blending of old and new, tradition and change.",
+ "reversed_meaning": "Peace disrupted, or the struggle to rebuild what was broken.",
+ "art_prompt": "a green and peaceful landscape with smoke rising from a distant fire; a hobbit smiling in a field",
+ "art_path": "decks/lord_of_the_rings-bundled/14_the_shire_after_the_war.png",
+ "essence": "A land restored after the storm.",
+ "seed": 770091
+ },
+ {
+ "arcana_number": 15,
+ "arcana_name": "The Devil",
+ "concept": "The Eye of Sauron",
+ "justification": "Sauron represents temptation, control, and the shadow of corruption.",
+ "upright_meaning": "A force of dominance and fear, drawing all into its gaze.",
+ "reversed_meaning": "Breaking free from a shadow’s hold, though it still lingers.",
+ "art_prompt": "a great eye burning red in a stormy sky, casting long, dark shadows over the land below",
+ "art_path": "decks/lord_of_the_rings-bundled/15_the_eye_of_sauron.png",
+ "essence": "An eye that sees, controls, and binds.",
+ "seed": 770092
+ },
+ {
+ "arcana_number": 16,
+ "arcana_name": "The Tower",
+ "concept": "The Fall of Barad-dûr",
+ "justification": "A sudden and dramatic collapse of a powerful and corrupt structure.",
+ "upright_meaning": "The collapse of a corrupt system; truth and freedom breaking through.",
+ "reversed_meaning": "Destruction that does not bring renewal; lingering ruin.",
+ "art_prompt": "a great black tower crumbling in a storm, lightning striking the mountain as fire rains from above",
+ "art_path": "decks/lord_of_the_rings-bundled/16_the_fall_of_barad_dûr.png",
+ "essence": "The fall of a dark tower and the end of its lies.",
+ "seed": 770093
+ },
+ {
+ "arcana_number": 17,
+ "arcana_name": "The Star",
+ "concept": "The Light of Eärendil",
+ "justification": "A symbol of hope and renewal, visible even in the darkest times.",
+ "upright_meaning": "Hope rising in the darkest hour, a beacon for the lost.",
+ "reversed_meaning": "Doubt creeping in, threatening to smother the flame.",
+ "art_prompt": "a glowing blue gem in the night sky, casting a pale light over a shadowed land",
+ "art_path": "decks/lord_of_the_rings-bundled/17_the_light_of_eärendil.png",
+ "essence": "A light in the darkness that never dies.",
+ "seed": 770094
+ },
+ {
+ "arcana_number": 18,
+ "arcana_name": "The Moon",
+ "concept": "The Misty Mountains",
+ "justification": "The mountains are a place of mystery and hidden paths, where fear and illusion thrive.",
+ "upright_meaning": "A journey through uncertainty, guided by courage and clarity of purpose.",
+ "reversed_meaning": "False paths, illusions, or the overcoming of fear through truth.",
+ "art_prompt": "a tall mountain shrouded in fog at night, a pale moon rising over a winding path",
+ "art_path": "decks/lord_of_the_rings-bundled/18_the_misty_mountains.png",
+ "essence": "A mountain path where shadows whisper and fear walks ahead.",
+ "seed": 770095
+ },
+ {
+ "arcana_number": 19,
+ "arcana_name": "The Sun",
+ "concept": "The Reckoning at the End of the Third Age",
+ "justification": "The dawn of a new age, marked by victory and renewal.",
+ "upright_meaning": "A time of joy and clarity after long struggle; victory’s light shining bright.",
+ "reversed_meaning": "A victory without full light; peace still waiting to bloom.",
+ "art_prompt": "a sunrise over the sea, a ship with white sails approaching the horizon",
+ "art_path": "decks/lord_of_the_rings-bundled/19_the_reckoning_at_the_end.png",
+ "essence": "The end of the Third Age and the dawn of a new era.",
+ "seed": 770096
+ },
+ {
+ "arcana_number": 20,
+ "arcana_name": "Judgement",
+ "concept": "The Scouring of the Shire",
+ "justification": "A reckoning with past neglect and a call to account for the land and its people.",
+ "upright_meaning": "A soul called to face its past and make amends for what it has done.",
+ "reversed_meaning": "Denial of responsibility, or a refusal to return home.",
+ "art_prompt": "a hobbit standing in a ruined field, looking back at smoke and ruin, with the sky breaking into dawn",
+ "art_path": "decks/lord_of_the_rings-bundled/20_the_scouring_of_the_shir.png",
+ "essence": "A land and a heart awoken to their reckoning.",
+ "seed": 770097
+ },
+ {
+ "arcana_number": 21,
+ "arcana_name": "The World",
+ "concept": "The Return of the King",
+ "justification": "A complete journey, with all threads drawn together in the homecoming.",
+ "upright_meaning": "A journey complete; the return to one’s origin with wisdom and peace.",
+ "reversed_meaning": "A journey unfinished; some pieces still missing, some paths not taken.",
+ "art_prompt": "a crowned king walking through a city of white towers, the golden sun rising behind him",
+ "art_path": "decks/lord_of_the_rings-bundled/21_the_return_of_the_king.png",
+ "essence": "A king who returns to his people, changed and whole.",
+ "seed": 770098
+ }
+ ]
+}
\ No newline at end of file
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png
new file mode 100644
index 0000000000000000000000000000000000000000..6165884997d6ccd747e39a10126d3c27bde39cfd
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d1e66aba0de61a7bcffbcb1e947837d135c3039de17de258fca144c9bc2633c5
+size 1329589
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png b/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png
new file mode 100644
index 0000000000000000000000000000000000000000..2672f659c79a68d3361a1bd118e00c27c53ceb3f
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/01_work.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a50f7af7d763b52cb655098e7e62a83031233922e1de2b5b54ff4a6812efb4a8
+size 876997
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png
new file mode 100644
index 0000000000000000000000000000000000000000..22a693a76105dd16127edeb6a7fcd7572f4e4b98
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:994683f8240943d2b1ffb49646a03b43c178e6fa0848c8705c2d83b96a703137
+size 1507113
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png b/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png
new file mode 100644
index 0000000000000000000000000000000000000000..1386f41bee4794693442f3d01c9002f28dbda5f0
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f626f546805e1f60aefa12869cd407e1a4ea0818dd4682ab9649783529c808bb
+size 930626
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png b/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png
new file mode 100644
index 0000000000000000000000000000000000000000..44abc0bbe28043ebd464ea0d130625f78dab6191
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:92cb5707358617f054dc693f56b26a9bd87ee02a48753d8bf93a2b517cb8baa6
+size 1544856
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png b/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png
new file mode 100644
index 0000000000000000000000000000000000000000..9d086a92c7829da209e0275e379717d00b7d67ca
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4a68d6314e566d8f1bfc6fffc6d9488c2d637576e4ccd2e968c9d7f1909b5ced
+size 862391
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png b/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png
new file mode 100644
index 0000000000000000000000000000000000000000..5f38aba66b11207dcbe2420dd6a43c212faa3b13
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ad1046b39361f94e489386b046b3faddb41708e07f17b50e679a6fa4e44f2c2e
+size 1540757
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png b/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png
new file mode 100644
index 0000000000000000000000000000000000000000..79f2714c87ffc7ae3875ecd9133bc476d8d30a90
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d96489ad1599a48994864ac1a8c0ef072c7abfeaa8c7bba51a661830dcb85654
+size 939791
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png b/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png
new file mode 100644
index 0000000000000000000000000000000000000000..fd883c0853fb0cebc8a3a96ad890e5fd353bccc9
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e86e6797d95e2a4eda4ad873cc2e65008958782263d8d02f72d741822446aeb4
+size 1296975
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png b/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png
new file mode 100644
index 0000000000000000000000000000000000000000..d790f6247728598c645344b4704974f744a18ab1
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:45e037442400d747c7a7a2d550f18fa36e8a31df85f7bca98a91b81c16f2ac0e
+size 690757
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png b/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png
new file mode 100644
index 0000000000000000000000000000000000000000..53f5da47aa0f5bbed8a20f0994120dc6bd306c5c
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ae1b52ff8f8587f661d8777f66cf165a14e732be3db53c8b30bb1ee2dee396c3
+size 1695250
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png b/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png
new file mode 100644
index 0000000000000000000000000000000000000000..f3883953692449361fb76e7761d75c8da09c62bf
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c34700063fd657af8409af36df29eaec9f2d34048237396f99909a85b8a5c5d3
+size 1531138
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png b/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png
new file mode 100644
index 0000000000000000000000000000000000000000..0300537a63055978d8ebd9c69b2d9d2b7eaa0df7
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d557ec44484a1432b275e94b42750f962ff1a70c276ce18cef9e309a5bd2db9d
+size 1397223
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png b/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png
new file mode 100644
index 0000000000000000000000000000000000000000..aa0f2ee1fbe45fb79e84b12e3f5677d3ad076e61
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b1f5fc5b893e20416347411c5c4642f3b3b6070fd3a70831fe54192c36481988
+size 1268680
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png b/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png
new file mode 100644
index 0000000000000000000000000000000000000000..597f0902071fa5fe8d899767103c7df5a4b75cb2
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6f95aa25788e89981f5196ab1baa7e46626bfed1a3d2cc8f735766b0c7b1973c
+size 1233392
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png
new file mode 100644
index 0000000000000000000000000000000000000000..b62b9c5722aef8683688790ab058a00ef5b8f6db
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:723925a4f560a4fbab4d8ede304117af3a1f90f482362da53b3a6a3a35bd1998
+size 1048547
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png b/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png
new file mode 100644
index 0000000000000000000000000000000000000000..26d3e19f9eff6e4bc026d9f963634c1e6ddc0367
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:282913ca14ebc84155792b6947094296201b980f8e0701b23d8433a1100d4579
+size 729937
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png b/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png
new file mode 100644
index 0000000000000000000000000000000000000000..4498daff13d13bcd529075c075a07f6e9d3bbe6c
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9afd8cab25444cf3569eda9c6df947cc7414be7d7ff7f3ed4e22f1010352711c
+size 887111
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png b/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png
new file mode 100644
index 0000000000000000000000000000000000000000..d2e838e306053bb14d55274951ab8e3857557938
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6382be44b78c6c36469294ffe3a6a3fb538632c45b17ea4103328dd8e7bf6a0e
+size 775432
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png b/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png
new file mode 100644
index 0000000000000000000000000000000000000000..1970402a9d25e406e426a81d875aecf1b8a8b021
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6f04bdb31d04800d33f5fc68e5b4f6750077f95176894b659a8639e0e9234c34
+size 1239648
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png b/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png
new file mode 100644
index 0000000000000000000000000000000000000000..9cdc93e863455ddadcd1515244e1e871607d2459
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b7814449eea1f0e5370b282ea700a517626b82d7361ea1f71e2d087352a8fd0
+size 738677
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png b/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png
new file mode 100644
index 0000000000000000000000000000000000000000..61ed5d7e0cf1e438ea9015b7d3aed140e1d55fd1
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dfd12982465b262814da79ec13834e01c3189090644ae67dab26263f7da95a3a
+size 1637695
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/back.png b/decks/thermodynamics-rider-waite-smith-1780952458/back.png
new file mode 100644
index 0000000000000000000000000000000000000000..653a070896d6a9ce1893e668a30d8e2369e61125
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dd91c3b3dbee6f9f4f37c227a01243928be85f9b7e25e4b96bea597604aaaccb
+size 1989487
diff --git a/decks/thermodynamics-rider-waite-smith-1780952458/deck.json b/decks/thermodynamics-rider-waite-smith-1780952458/deck.json
new file mode 100644
index 0000000000000000000000000000000000000000..d5e176c77311c9aacab83e7657a50a1d7b0d0444
--- /dev/null
+++ b/decks/thermodynamics-rider-waite-smith-1780952458/deck.json
@@ -0,0 +1,274 @@
+{
+ "theme": "thermodynamics",
+ "style_suffix": "Rider-Waite-Smith tarot art, rich symbolic illustration, warm storybook palette, gold linework, medieval-renaissance figures",
+ "cards": [
+ {
+ "arcana_number": 0,
+ "arcana_name": "The Fool",
+ "concept": "Activation Energy",
+ "justification": "The threshold of potential energy that must be overcome for a process to begin — mirroring the leap of faith before a new path unfolds.",
+ "upright_meaning": "A new process is waiting to begin, but it needs an initial push. You must supply the energy to cross the threshold and begin the transformation.",
+ "reversed_meaning": "You're trapped in hesitation, unable to ignite the necessary energy to start. You fear the unknown or underestimate the power of that first spark.",
+ "art_prompt": "a central illustration of a ball perched at the edge of a hill, poised between stillness and motion, with an invisible line of potential energy beneath it",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/00_activation_energy.png",
+ "essence": "The leap requires a spark before it can become flame.",
+ "seed": 679027
+ },
+ {
+ "arcana_number": 1,
+ "arcana_name": "The Magician",
+ "concept": "Work",
+ "justification": "The directed transformation of energy into action — a channeling of raw potential into purposeful change.",
+ "upright_meaning": "You are channeling energy into productive action. You have the tools, the focus, and the will to bring your vision to life.",
+ "reversed_meaning": "Your energy is wasted or misdirected. You lack the clarity or discipline to make your efforts meaningful.",
+ "art_prompt": "a central illustration of a person bending a gear, converting heat from a flame into movement through a pulley",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/01_work.png",
+ "essence": "Action is the conduit through which energy becomes purpose.",
+ "seed": 679028
+ },
+ {
+ "arcana_number": 2,
+ "arcana_name": "The High Priestess",
+ "concept": "Internal Energy",
+ "justification": "Energy that cannot be directly observed, only measured by its changes — hidden truths revealed through careful study.",
+ "upright_meaning": "There is hidden energy at play in your life. Trust your instinct to read the signs and interpret the silent forces that guide you.",
+ "reversed_meaning": "You are blind to the internal currents shaping you. Misunderstanding the hidden forces leads to confusion and poor choices.",
+ "art_prompt": "a central illustration of a closed box with internal glowing particles visible only through the cracks, hinting at energy within",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/02_internal_energy.png",
+ "essence": "Energy flows invisibly, shaping the unseen currents of your world.",
+ "seed": 679029
+ },
+ {
+ "arcana_number": 3,
+ "arcana_name": "The Empress",
+ "concept": "Heat Reservoir",
+ "justification": "An abundant and nurturing source of warmth and energy, continuously giving without diminishing.",
+ "upright_meaning": "You are drawing from a rich and nurturing energy source. This abundance supports your growth and creative output.",
+ "reversed_meaning": "You depend too heavily on external sources. You neglect your own internal reservoirs and risk imbalance.",
+ "art_prompt": "a central illustration of a glowing, open-ended cylindrical reservoir, radiating waves of heat outward into the surrounding darkness",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/03_heat_reservoir.png",
+ "essence": "A wellspring of warmth feeds creation and sustenance.",
+ "seed": 679030
+ },
+ {
+ "arcana_number": 4,
+ "arcana_name": "The Emperor",
+ "concept": "The First Law",
+ "justification": "The unyielding rule of energy conservation — a governing truth that must be obeyed.",
+ "upright_meaning": "You are maintaining equilibrium through discipline and structure. You uphold the balance by respecting the laws that govern your system.",
+ "reversed_meaning": "The system is breaking down. You ignore the rules that keep things stable, and entropy begins to take hold.",
+ "art_prompt": "a central illustration of an ancient stone tablet inscribed with the words: 'Energy is Neither Created Nor Destroyed'",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/04_the_first_law.png",
+ "essence": "Energy is conserved in structure and law.",
+ "seed": 679031
+ },
+ {
+ "arcana_number": 5,
+ "arcana_name": "The Hierophant",
+ "concept": "Standard State (STP)",
+ "justification": "The agreed-upon reference point for all measurements — a universal convention and tradition.",
+ "upright_meaning": "You align with the accepted conditions. You find comfort and stability in the established, the known, and the familiar.",
+ "reversed_meaning": "You reject the standard. You challenge the norm or struggle to conform, creating friction with the status quo.",
+ "art_prompt": "a central illustration of a scale balanced at 25°C and 1 atm, with precise instruments measuring everything on either side",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/05_standard_state__stp.png",
+ "essence": "The default state is one of shared pressure and uniformity.",
+ "seed": 679032
+ },
+ {
+ "arcana_number": 6,
+ "arcana_name": "The Lovers",
+ "concept": "Thermal Contact",
+ "justification": "The union of two systems until their temperatures align — a shared journey to equilibrium.",
+ "upright_meaning": "You and another are in perfect thermal and emotional resonance. Your shared energy creates a stable, harmonious whole.",
+ "reversed_meaning": "You are out of sync. There is a mismatch in energy or intention, leaving you isolated or unaligned.",
+ "art_prompt": "a central illustration of two blocks of metal in contact, with energy flowing between them until their temperatures match",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/06_thermal_contact.png",
+ "essence": "Energy merges when systems connect, creating a new thermal unity.",
+ "seed": 679033
+ },
+ {
+ "arcana_number": 7,
+ "arcana_name": "The Chariot",
+ "concept": "The Heat Engine",
+ "justification": "Transforming heat into motion through control and direction — the triumph of directed force.",
+ "upright_meaning": "You are converting your inner heat into forward momentum. You are focused, purposeful, and efficient in your pursuit.",
+ "reversed_meaning": "Your energy is wasted or misused. You are moving, but not forward — direction is lost or inefficient.",
+ "art_prompt": "a central illustration of a steam-powered chariot, its wheels turning from the heat of a fire at the front",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/07_the_heat_engine.png",
+ "essence": "Efficiency turns heat into motion.",
+ "seed": 679034
+ },
+ {
+ "arcana_number": 8,
+ "arcana_name": "Strength",
+ "concept": "Heat Capacity",
+ "justification": "The ability to absorb energy without significant change — inner fortitude and resilience.",
+ "upright_meaning": "You endure the heat of change without flaring or fracturing. Your capacity to store energy keeps you calm and steady.",
+ "reversed_meaning": "You are overwhelmed by input. You absorb too much heat and risk breakdown or burnout.",
+ "art_prompt": "a central illustration of a large, calm lake absorbing the heat of the sun without boiling over",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/08_heat_capacity.png",
+ "essence": "Resilience lies in absorbing change without breaking.",
+ "seed": 679035
+ },
+ {
+ "arcana_number": 9,
+ "arcana_name": "The Hermit",
+ "concept": "Absolute Zero",
+ "justification": "The state of complete stillness and solitude — the ultimate inward journey.",
+ "upright_meaning": "You have reached a state of perfect stillness and solitude. Here, you confront your purest essence, free from external influence.",
+ "reversed_meaning": "You are caught in a cycle of noise and distraction. True silence and clarity remain elusive.",
+ "art_prompt": "a central illustration of a frozen, perfect crystal, motionless in the void, surrounded by a dark, endless space",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/09_absolute_zero.png",
+ "essence": "Zero is the quietest point, where energy has vanished.",
+ "seed": 679036
+ },
+ {
+ "arcana_number": 10,
+ "arcana_name": "Wheel of Fortune",
+ "concept": "The Carnot Cycle",
+ "justification": "An idealized loop of energy conversion that returns to its starting point — the eternal turning of fate.",
+ "upright_meaning": "You are caught in a familiar cycle of energy transfer. Change is inevitable, bringing you back to where you started — but transformed.",
+ "reversed_meaning": "The cycle is broken or stalled. You are stuck in a loop without forward motion or resolution.",
+ "art_prompt": "a central illustration of a reversible Carnot engine, cycling endlessly between hot and cold reservoirs",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/10_the_carnot_cycle.png",
+ "essence": "Cycles repeat, and heat moves in loops to bring you back.",
+ "seed": 679037
+ },
+ {
+ "arcana_number": 11,
+ "arcana_name": "Justice",
+ "concept": "The Second Law's Toll",
+ "justification": "The law that no system is perfect — every process pays an irreversible cost.",
+ "upright_meaning": "You are facing the cost of progress. You understand that no action is without consequence, and you accept the losses as part of growth.",
+ "reversed_meaning": "You ignore the cost of progress. You are trying to reverse the flow or deny entropy’s inevitable role.",
+ "art_prompt": "a central illustration of a scale tipping toward entropy, with energy being lost to disorder and heat",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/11_the_second_law_s_toll.png",
+ "essence": "Every gain has a cost — entropy always takes its toll.",
+ "seed": 679038
+ },
+ {
+ "arcana_number": 12,
+ "arcana_name": "The Hanged Man",
+ "concept": "Metastability",
+ "justification": "A state of false equilibrium — suspended and waiting for change.",
+ "upright_meaning": "You are in a temporary equilibrium, stable but not permanent. Change is possible, but it requires surrender to move beyond.",
+ "reversed_meaning": "You fear collapse and cling to the false stability. You are trapped in a metastable state, afraid to let go.",
+ "art_prompt": "a central illustration of a perfectly balanced, but unstable crystal, poised on the edge of transformation",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/12_metastability.png",
+ "essence": "You are suspended in a false state — waiting for the push.",
+ "seed": 679039
+ },
+ {
+ "arcana_number": 13,
+ "arcana_name": "Death",
+ "concept": "Entropy",
+ "justification": "The one-way journey toward disorder — an irreversible transformation.",
+ "upright_meaning": "An end is coming — complete and irreversible. Let go of the old structure and allow it to dissolve into the natural order.",
+ "reversed_meaning": "You fight the inevitable. You are trying to reverse entropy, to restore what is already lost.",
+ "art_prompt": "a central illustration of a once-ordered structure crumbling into dust and fire, with no path back",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/13_entropy.png",
+ "essence": "Disorder is the natural state — and all things must return to it.",
+ "seed": 679040
+ },
+ {
+ "arcana_number": 14,
+ "arcana_name": "Temperance",
+ "concept": "Thermal Equilibrium",
+ "justification": "The blending of extremes into a balanced whole — harmony through moderation.",
+ "upright_meaning": "You are merging opposing forces into a balanced whole. Harmony is found through integration, not dominance.",
+ "reversed_meaning": "You are in conflict or imbalance. You fail to synthesize differences and maintain disharmony.",
+ "art_prompt": "a central illustration of two streams joining into a single river, one hot and one cold, merging into warm, flowing water",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/14_thermal_equilibrium.png",
+ "essence": "Balance is achieved when energies blend in harmony.",
+ "seed": 679041
+ },
+ {
+ "arcana_number": 15,
+ "arcana_name": "The Devil",
+ "concept": "Bound Energy",
+ "justification": "Energy locked and inaccessible — the chains of constraint and materialism.",
+ "upright_meaning": "You are trapped by internal constraints. Energy is present, but it cannot be accessed freely — you're bound by your own structure.",
+ "reversed_meaning": "You are breaking free. The system that held you is unraveling, and energy is now available for transformation.",
+ "art_prompt": "a central illustration of a glowing sphere locked in a steel cage, its energy pulsing but unable to escape",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/15_bound_energy.png",
+ "essence": "You are bound by your own system — energy is trapped and undelivered.",
+ "seed": 679042
+ },
+ {
+ "arcana_number": 16,
+ "arcana_name": "The Tower",
+ "concept": "The Critical Point",
+ "justification": "The sudden collapse of identity — a phase or structure that is no longer viable.",
+ "upright_meaning": "A critical point is reached. The old system is unsustainable and must be torn down for something new to emerge.",
+ "reversed_meaning": "You delay the necessary collapse. The pressure builds until it is forced upon you with greater force.",
+ "art_prompt": "a central illustration of a building made of ice melting into a puddle, the structure dissolving into formless water",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/16_the_critical_point.png",
+ "essence": "The system can no longer hold — it will collapse or be reset.",
+ "seed": 679043
+ },
+ {
+ "arcana_number": 17,
+ "arcana_name": "The Star",
+ "concept": "Free Energy",
+ "justification": "The usable energy available for work — a beacon of hope and future possibility.",
+ "upright_meaning": "Hope is alive. You have usable energy left — the path is still open, and the future remains within your grasp.",
+ "reversed_meaning": "Hope is dimming. You feel drained, with no energy left to act or believe in change.",
+ "art_prompt": "a central illustration of a glowing star rising from the void, casting light on a dark horizon",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/17_free_energy.png",
+ "essence": "Free energy is the spark that rekindles hope.",
+ "seed": 679044
+ },
+ {
+ "arcana_number": 18,
+ "arcana_name": "The Moon",
+ "concept": "Thermal Fluctuations",
+ "justification": "Tiny, random changes beneath the surface — the unseen disturbances of the unconscious.",
+ "upright_meaning": "You sense hidden energy shifts. There are subtle changes and fluctuations in the unseen, shaping your world in real time.",
+ "reversed_meaning": "You are lost in uncertainty. You cannot tell what is real and what is merely fluctuation or illusion.",
+ "art_prompt": "a central illustration of a calm ocean with tiny, imperceptible waves rising and falling in slow motion",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/18_thermal_fluctuations.png",
+ "essence": "Energy fluctuates beneath the surface — reality is not what it seems.",
+ "seed": 679045
+ },
+ {
+ "arcana_number": 19,
+ "arcana_name": "The Sun",
+ "concept": "Blackbody Radiation",
+ "justification": "The radiant energy of a hot body — vitality and clarity in their purest form.",
+ "upright_meaning": "You are radiating with clarity and purpose. Your energy is fully expressed, unfiltered and unbroken, bringing light to your path.",
+ "reversed_meaning": "Your radiance is dimmed. You feel shadowed or unable to express your true energy and purpose.",
+ "art_prompt": "a central illustration of a glowing, hot sphere radiating light and warmth outward into space",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/19_blackbody_radiation.png",
+ "essence": "You radiate with clarity — your energy is fully expressed.",
+ "seed": 679046
+ },
+ {
+ "arcana_number": 20,
+ "arcana_name": "Judgement",
+ "concept": "The Third Law",
+ "justification": "A final reckoning at absolute zero — the purification of entropy and the calling of all things to order.",
+ "upright_meaning": "A reckoning and awakening occur. From this moment, a new order is born from the dissolution of the old.",
+ "reversed_meaning": "You remain in disorder. You are unable to reach a final resolution or perfect state — the cycle continues.",
+ "art_prompt": "a central illustration of a perfect crystal forming in a void, its shape clean and final, as if summoned by a voice from above",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/20_the_third_law.png",
+ "essence": "Order is restored in time — but never perfectly.",
+ "seed": 679047
+ },
+ {
+ "arcana_number": 21,
+ "arcana_name": "The World",
+ "concept": "The Universe",
+ "justification": "The total system — everything included in one grand whole and final reckoning.",
+ "upright_meaning": "You see the whole system — you are integrated, complete, and part of a greater whole. Your journey is fulfilled.",
+ "reversed_meaning": "You are incomplete. You fail to see the full picture or remain separated from the whole.",
+ "art_prompt": "a central illustration of a vast diagram of the universe, with each system connected in perfect, endless cycles of energy exchange",
+ "art_path": "decks/thermodynamics-rider-waite-smith-1780952458/21_the_universe.png",
+ "essence": "The whole is greater than the sum of its parts — or is it?",
+ "seed": 679048
+ }
+ ],
+ "deck_id": "thermodynamics-rider-waite-smith-1780952458",
+ "visual_style": "rider-waite-smith",
+ "seed_base": 679027,
+ "back_path": "decks/thermodynamics-rider-waite-smith-1780952458/back.png"
+}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e77e5c3a08ad7448e82a6b3a044e94451e1dd0f7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+gradio==6.14.0
+openai>=1.40,<3
+fastapi>=0.110
+uvicorn>=0.30
+pillow>=10
+requests>=2.31
diff --git a/scripts/phase0_mapping.py b/scripts/phase0_mapping.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a6dda24243cac43499386f58bc9602f527af380
--- /dev/null
+++ b/scripts/phase0_mapping.py
@@ -0,0 +1,77 @@
+"""Phase 0 (SPEC §12) — prove mapping quality before any art or UI.
+
+Runs Agent 1 (the Deck Designer) against the open ≤32B model for several varied
+themes and prints each deck as a readable archetype→concept table plus the full
+JSON. Inspect by hand: do the mappings read as clever-but-right across all
+themes? If not, fix the prompt/few-shots before building anything else.
+
+Usage:
+ set -a; source ~/tokens; set +a
+ python -m scripts.phase0_mapping # default 5 themes
+ python -m scripts.phase0_mapping "free jazz" "tax law"
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+
+from arcana.archetypes import MAJOR_ARCANA
+from arcana.designer import DeckError, design_deck
+from arcana.llm import get_llm
+
+DEFAULT_THEMES = [
+ "thermodynamics", # serious / scientific
+ "Lord of the Rings", # fandom
+ "the French Revolution", # history
+ "breakfast foods", # silly
+ "professional wrestling", # pop-culture wildcard
+]
+
+OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+ "phase0_out")
+
+
+def print_table(deck: dict) -> None:
+ print(f"\n style: {deck['style_suffix']}")
+ for a in MAJOR_ARCANA:
+ c = deck["cards"][a.number]
+ print(f" {a.roman:<5} {a.name:<18} → {c['concept']}")
+ print(f" ↳ {c['justification']}")
+
+
+def main(themes: list[str]) -> int:
+ os.makedirs(OUT_DIR, exist_ok=True)
+ llm = get_llm()
+ print(f"Model: {getattr(llm, 'model', '?')} via {getattr(llm, 'base_url', '?')}")
+
+ failures = 0
+ for theme in themes:
+ print("\n" + "=" * 72)
+ print(f"THEME: {theme}")
+ print("=" * 72)
+ t0 = time.time()
+ try:
+ deck = design_deck(theme, llm=llm)
+ except DeckError as e:
+ failures += 1
+ print(f" ✗ FAILED: {e} ({time.time() - t0:.1f}s)")
+ continue
+ dt = time.time() - t0
+ print_table(deck)
+ slug = "".join(ch if ch.isalnum() else "_" for ch in theme.lower())[:40]
+ path = os.path.join(OUT_DIR, f"{slug}.json")
+ with open(path, "w") as f:
+ json.dump(deck, f, indent=2, ensure_ascii=False)
+ print(f"\n ✓ 22 cards in {dt:.1f}s → {path}")
+
+ print("\n" + "=" * 72)
+ print(f"DONE — {len(themes) - failures}/{len(themes)} decks generated.")
+ print("Inspect the tables above: do the mappings feel clever-but-right?")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ args = sys.argv[1:] or DEFAULT_THEMES
+ raise SystemExit(main(args))
diff --git a/scripts/phase1_deck.py b/scripts/phase1_deck.py
new file mode 100644
index 0000000000000000000000000000000000000000..33e70d5bff2b5ee6b440053e6e0af8737e1093b4
--- /dev/null
+++ b/scripts/phase1_deck.py
@@ -0,0 +1,88 @@
+"""Phase 1 (SPEC §12) — local end-to-end deck: art + frame + composite.
+
+Loads a deck JSON (from Phase 0), generates the 22 central arts via the open
+≤32B image endpoint, composites each with the reusable frame + correct
+name/numeral, writes 22 finished card PNGs, and a contact sheet to eyeball.
+
+Usage:
+ set -a; source ~/tokens; set +a
+ python -m scripts.phase1_deck phase0_out/lord_of_the_rings.json
+ python -m scripts.phase1_deck phase0_out/lord_of_the_rings.json --limit 6
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import time
+
+from PIL import Image
+
+from arcana.archetypes import ROMAN_BY_NUMBER
+from arcana.compositor import CARD_H, CARD_W, compose_card
+from arcana.imagegen import get_imagegen
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BASE_SEED = 770077 # fixed seed family → deck cohesion (§7)
+
+
+def contact_sheet(cards: list[Image.Image], cols: int = 6) -> Image.Image:
+ if not cards:
+ return Image.new("RGB", (CARD_W, CARD_H), (10, 8, 16))
+ tw, th = CARD_W // 3, CARD_H // 3
+ rows = (len(cards) + cols - 1) // cols
+ sheet = Image.new("RGB", (cols * (tw + 10) + 10, rows * (th + 10) + 10), (10, 8, 16))
+ for i, c in enumerate(cards):
+ thumb = c.resize((tw, th), Image.LANCZOS)
+ x = 10 + (i % cols) * (tw + 10)
+ y = 10 + (i // cols) * (th + 10)
+ sheet.paste(thumb, (x, y))
+ return sheet
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("deck_json")
+ ap.add_argument("--limit", type=int, default=0, help="only first N cards (quick test)")
+ args = ap.parse_args()
+
+ deck = json.load(open(args.deck_json))
+ theme = deck["theme"]
+ style = deck.get("style_suffix")
+ cards = deck["cards"]
+ if args.limit:
+ cards = cards[:args.limit]
+
+ slug = "".join(ch if ch.isalnum() else "_" for ch in theme.lower())[:40]
+ out_dir = os.path.join(ROOT, "cards", slug)
+ os.makedirs(out_dir, exist_ok=True)
+ print(f"THEME: {theme}\n style: {style}\n out: {out_dir}")
+
+ ig = get_imagegen(deck_style=style)
+ composed = []
+ for c in cards:
+ n = c["arcana_number"]
+ t0 = time.time()
+ try:
+ art = ig.generate(c["art_prompt"], seed=BASE_SEED + n)
+ except Exception as e:
+ print(f" ✗ {n:>2} {c['concept']}: art failed {type(e).__name__}: {str(e)[:90]}")
+ continue
+ card = compose_card(art, c["concept"], ROMAN_BY_NUMBER[n])
+ path = os.path.join(out_dir, f"{n:02d}_{''.join(ch if ch.isalnum() else '_' for ch in c['concept'].lower())[:24]}.png")
+ card.save(path)
+ c["art_path"] = os.path.relpath(path, ROOT)
+ composed.append(card)
+ print(f" ✓ {n:>2} {c['arcana_name']:<18} → {c['concept']:<28} {time.time()-t0:.1f}s")
+
+ sheet = contact_sheet(composed)
+ sheet_path = os.path.join(ROOT, "cards", f"{slug}_contact.png")
+ sheet.save(sheet_path)
+ with open(os.path.join(out_dir, "deck.json"), "w") as f:
+ json.dump(deck, f, indent=2, ensure_ascii=False)
+ print(f"\n contact sheet → {sheet_path} ({len(composed)} cards)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())