diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..fc563232c903b867d74bcf57181bb9e4163133e9 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy to .env.local and fill in. Without these, the app runs in mock mode. + +# Modal-hosted OpenAI-compatible endpoint (vLLM serving Qwen2.5-32B or similar). +# Leave empty to force mock mode for the LLM calls. +MODAL_URL= + +# Modal proxy auth (optional, only if the endpoint requires headers). +MODAL_KEY= +MODAL_SECRET= + +# Force mock mode for the LLM even if MODAL_URL is set. +ORACLES_FORCE_MOCK=0 + +# Klein-4B image generation endpoint (separate from the LLM URL). +# Leave empty to disable live image gen; mock SVG cards are used. +KLEIN_URL= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..24a8e87939aa53cdd833f6be7610cb4972e063ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..afebaf6277db259b6b18044d794418220efeeb02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# --------------------------------------------------------------------------- +# Local secrets — NEVER push to HF Space. +# --------------------------------------------------------------------------- +.env +.env.local +.env.production + +# --------------------------------------------------------------------------- +# Python build artifacts +# --------------------------------------------------------------------------- +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +.pytest_cache/ + +# --------------------------------------------------------------------------- +# OS junk +# --------------------------------------------------------------------------- +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# --------------------------------------------------------------------------- +# Dev-only directories — they live on the local machine but never need to +# ship to the HF Space. The Space only runs app.py + the oracles/ package +# + prompts/ + assets/sprites. +# --------------------------------------------------------------------------- + +# Klein sprite generator scripts — only used at build time +modal_backend/ +scripts/ + +# Training + distillation — produced the LoRA weights, not needed at runtime +training/ + +# Smoke / unit tests — not part of the served app +tests/ + +# Local LoRA adapter weights — published to HF Hub separately +lora-out/ +lora-out-v2/ +lora-out-tiny/ + +# Pre-chroma-key raw PNGs — *.png is what the app uses +assets/sprites/*_raw.png +assets/sprites/v1_backup/ + +# GGUF artifacts for the local llama-cpp backend +gguf-out/ +*.gguf + +# Captured LLM traces — submit separately to HF Datasets for the +# Sharing-is-Caring badge, not for the Space repo +traces/ +.llama_cpp_cache/ +/tmp/oracles_local_llama.log + +# --------------------------------------------------------------------------- +# Project pivot logs + planning docs — useful to the developer, noise to +# the Space reader. Keep on disk, don't ship. +# --------------------------------------------------------------------------- +PIVOT_LOG.md +IMPLEMENTATION_PLAN.md +CONTRACTS.md + +# Mocked / debug output dirs +mock_out/ +debug_out/ + +# Runtime-generated mock SVG scene images — produced by the +# image-fallback code in oracles/images.py when Klein output is +# unavailable. These accumulate over self-test runs and should +# never ship to the Space. +assets/generated/ diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..78e8f1808f80b35883d58945deee4dbb19d7da63 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +--- +title: The Apprentice +emoji: 🌲 +colorFrom: indigo +colorTo: yellow +sdk: gradio +sdk_version: 4.44.1 +python_version: "3.10" +suggested_hardware: cpu-basic +app_file: app.py +pinned: false +license: mit +short_description: A pixel-art branching-narrative game powered by a custom 14B LoRA — the mentor inscribes the wisdom, the apprentice has to make it save his life. +tags: + - thousand-token-wood + - off-brand + - well-tuned + - field-notes + - tiny-titan + - branching-narrative + - game + - pixel-art + - gradio + - vllm + - lora + - qwen + - bilingual +models: + - Qwen/Qwen2.5-14B-Instruct + - Qwen/Qwen2.5-1.5B-Instruct + - AndrewRqy/oracles-wizard-14b-lora + - AndrewRqy/oracles-wizard-1.5b-lora +--- + +# The Apprentice — Build Small Hackathon + +> A pixel-art branching fairy-tale game. You inscribe five short oracles before the journey begins; an apprentice has to make every one of them save his life across five trials in a branching graph that converges on one of five distinct endings. + +**Track**: Thousand Token Wood +**Stack**: Gradio + Modal-hosted vLLM + Qwen2.5-14B + custom humor LoRA +**Languages**: English + Simplified Chinese +**Demo video**: _(to be added by submitter)_ +**Social post**: _(to be added by submitter)_ + +--- + +## What's in the box + +| Component | Detail | +|---|---| +| **Frontend** | Single-file Gradio app (~5000 lines) with a hand-authored pixel-art aesthetic — Press Start 2P + VT323 fonts, NES-style sharp corners, layered animated scene composition, custom theme that suppresses every visible piece of Gradio chrome. | +| **Backend** | Qwen2.5-14B-Instruct + custom humor LoRA (`AndrewRqy/oracles-wizard-14b-lora`) served via vLLM on Modal. OpenAI-compatible API; the frontend talks to it through the OpenAI SDK. | +| **Tiny Titan path** | Qwen2.5-1.5B-Instruct + LoRA, also trained on the same 23k humor corpus (`AndrewRqy/oracles-wizard-1.5b-lora`). Eligible for the ≤4B prize. | +| **Branching narrative** | Hand-authored 15-node story graph with 5 distinct endings. Each fork at trials 2–4 is picked by an LLM call seeded with the player's inscribed oracle, so the path the apprentice walks is shaped by what wisdom was inscribed. | +| **6 themes × 2 languages** | Fantasy, Space-Cowboy, Galactic-Light, Black-Land, Mistgate, Quiet-Years — each with its own sprite set, finale boss, themed obstacle scenes (finale / arena / passage / trap), localized banner + oracle artifact + apprentice role title. All authored for both English and Chinese. | +| **Klein-4B sprites** | All in-game art (~105 pixel-art sprites + 4 environment backdrops per theme) was generated locally via Klein-4B on a Modal H100, then chroma-keyed offline. No FLUX, no commercial generators. | + +## How to play + +1. **Inscribe** — pick a language, then a theme, then write five short oracles (any words, gibberish, emoji — whatever you want). +2. **Send-off** — the mentor seals the parchments and the apprentice leaves. +3. **Five trials** — at each obstacle, the apprentice draws ONE oracle. The model takes the obstacle + the oracle and writes a 200-word resolution in one of three humor modes (wild imagination / accidental trip / last-minute revelation). +4. **Boss** — trial 5 is the world's finale (the dragon for fantasy; the warlord-king for space-cowboy; etc.). Different paths through the tree lead to different bosses. +5. **Ending** — one of 5 distinct endings plays, each with its own preset framing (Why the boss behaved as it did + What the apprentice carried home) and an LLM-expanded 3-paragraph epilogue. +6. **Summary** — clicking through reveals the story tree with the path you took lit gold and the four endings you didn't reach blurred behind "???" for replay motivation. + +## Badge claims + +| Badge | Why we claim it | +|---|---| +| **🎯 Well-Tuned** | Qwen2.5-14B + a hand-distilled 23k-example humor LoRA (rank 16, ~6.5 h on H100). The fine-tune visibly steers all three humor modes; the field-notes write-up below details the distillation pipeline. | +| **🎨 Off-Brand** | ~2000 lines of bespoke CSS, Press Start 2P + VT323 fonts, hand-painted pixel-art sprites, sharp-corner theme that overrides every visible Gradio token, custom story-tree visualization, custom ending banner with five-pip "endings discovered" strip. No stock Gradio chrome reaches the rendered page. | +| **📓 Field Notes** | See `docs/FIELD_NOTES_distillation.md` — a ~950-word blog post on the schema-locked Claude-Sonnet-4.5 → Qwen distillation pipeline, the three humor modes, the JSON enforcement layers, and what 98.4% reject-sampling acceptance got us. | +| **⚡ Tiny Titan (prize)** | Same 23k corpus also trained into a Qwen2.5-1.5B LoRA on H100 (~$5.50, ~1.5 h). Eligible for the ≤4B prize. | + +## Running it (locally or on Space) + +Three environment variables need to be set as **secrets** (HF Space Settings → Variables and secrets): + +``` +MODAL_URL = https://---serve.modal.run +MODAL_KEY = wk-… (Modal proxy auth key) +MODAL_SECRET = ws-… (Modal proxy auth secret) +``` + +Once those are set, the Space spins up the Gradio app and the player can immediately walk a trial. No GPU is needed on the Space side — the LLM call streams in from Modal over HTTPS. + +If `MODAL_URL` is unset OR `ORACLES_FORCE_MOCK=1`, the app runs in **mock mode** — every LLM call returns a hand-written placeholder, the visuals still play, but the humor is dead. Useful for offline demos. + +## Repository layout + +``` +oracles_app/ +├── app.py main Gradio file (~5000 lines) +├── README.md this file (HF Space metadata + write-up) +├── requirements.txt gradio + openai + Pillow +├── .gitignore excludes dev artifacts from the Space upload +├── oracles/ pure-Python package (state, LLM client, story graph, themes, i18n) +├── prompts/ LLM prompt templates (resolution / epilogue / interludes / obstacles) +└── assets/sprites/ ~105 chroma-keyed pixel-art PNGs +``` + +The dev-only directories (`modal_backend/`, `scripts/`, `training/`, `tests/`, `lora-out/`, `*_raw.png`) live in this folder on local disk but are excluded from the HF Space upload by `.gitignore`. + +## Credits + +- Base model — Qwen2.5-14B-Instruct + Qwen2.5-1.5B-Instruct (Alibaba) +- Distillation teacher — Claude Sonnet 4.5 (Anthropic) via OpenRouter +- Sprite generator — Klein-4B (Anthropic) on Modal H100 +- Pixel-art fonts — Press Start 2P + VT323 (Google Fonts) + +Built for the **Build Small Hackathon — Thousand Token Wood track** (2026-06-15). diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..32361c7ee7268f7136ba1810e5e7d2c09f4b3686 --- /dev/null +++ b/app.py @@ -0,0 +1,4987 @@ +"""The Wizard's Oracles — Gradio Blocks UI. + +Single-file demo. Player writes 5 sealed oracles, hands them to a young hero, +then watches as the LLM is forced to interpret whatever was written as the +means of the hero's salvation against 5 lethal trials. + +State machine: + inscribe → send_off → trial (1..5) → epilogue → done + +Run: + python app.py # launches Gradio + python app.py --self-test # scripted walkthrough; exits 0 on pass +""" + +from __future__ import annotations + +import base64 +import os +import sys +from pathlib import Path +from typing import Optional + +# Ensure ``oracles`` package is importable when run directly. +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +import gradio as gr # noqa: E402 + +from oracles.images import generate_scene_image # noqa: E402 +from oracles.llm_client import LLMClient # noqa: E402 +from oracles.obstacles import generate_obstacles # noqa: E402 +from oracles.resolution import ( # noqa: E402 + generate_dragon_interlude, + generate_epilogue, + generate_interlude, + kick_background_epilogue, + kick_background_interlude, + precompute_all_resolutions, + resolve_trial, +) +from oracles.state import ( # noqa: E402 + LANG_LABELS, + LANG_PROMPT_NAME, + NUM_ORACLES, + NUM_TRIALS, + GameState, + fresh_state, +) +from oracles.ui_strings import t as _t, tlang as _tlang # noqa: E402 +from oracles.wizard_text import ( # noqa: E402 + sigil_svg as _sigil_svg, + text_for as _wizard_text_for, +) + + +# --------------------------------------------------------------------------- +# Module-level client + asset dir +# --------------------------------------------------------------------------- + + +CLIENT = LLMClient() +OUT_DIR = str(_HERE / "assets" / "generated") +os.makedirs(OUT_DIR, exist_ok=True) + +# Parallax PNG bundled into the banner. Resized to 1024x192 (~200KB) from the +# parent Forest Focus cozy-forest near layer — same visual register, but small +# enough to inline as a data URI without bloating the page config. +_PARALLAX_PNG = _HERE / "assets" / "parallax" / "banner_near.png" + +# Optional Klein-generated grimoire page texture. If present, embedded into +# the CSS as a background-image on .grimoire-page so the parchment looks +# like real aged paper instead of CSS-only gradients. Skipped silently if +# the user hasn't generated it. +_GRIMOIRE_TEXTURE_PNG = _HERE / "assets" / "sprites" / "grimoire_page_texture.png" + + +# --------------------------------------------------------------------------- +# Pixel-art book frame: leather binding (via CSS border-image 9-slice), a +# vertical spine with raised bands and stitching, and a red ribbon bookmark. +# All inline SVGs so the book "is" pixel art without depending on an external +# asset run. +# --------------------------------------------------------------------------- + + +def _book_frame_svg() -> str: + """60x60 SVG used as the border-image source for the leather binding. + 9-sliced at 20 units → corners stay crisp, edge slices stretch. + Leather is warmer/browner than before so it doesn't merge with the + dark purple page palette.""" + return ( + "" + # leather on the 8 border slices (center 20x20 left transparent). + # Warmer brown so it reads as leather against the dark purple pages. + "" + "" + "" + "" + # leather grain (darker/lighter horizontal lines for texture) + "" + "" + "" + "" + # leather vertical grain on the side edges + "" + "" + "" + "" + # outer leather edge - slightly lighter strip on the outside + "" + "" + "" + "" + # brass corner — TOP-LEFT + "" + "" + "" + "" + "" + "" + "" + "" + # brass corner — TOP-RIGHT + "" + "" + "" + "" + "" + "" + "" + "" + # brass corner — BOTTOM-LEFT + "" + "" + "" + "" + "" + "" + "" + "" + # brass corner — BOTTOM-RIGHT + "" + "" + "" + "" + "" + "" + "" + "" + # amber inner trim — visible just inside the leather frame + "" + "" + "" + "" + # darker amber rule just inside the inner trim + "" + "" + "" + "" + "" + ) + + +def _book_spine_svg() -> str: + """40×200 tall stretchable spine with five raised bands and amber + stitching dots. Brighter, more contrasted than the surrounding leather + so the binding clearly reads as a separate strip down the centre. + ``preserveAspectRatio='none'`` lets it stretch vertically with the + grimoire.""" + bands = [] + # five raised horizontal bands, each with a top highlight + bottom shadow + for y in (28, 63, 96, 129, 164): + bands.append(f"") + bands.append(f"") + bands.append(f"") + # amber stitching dots in the gaps between bands + stitch = [] + for y in (12, 48, 83, 116, 149, 184): + stitch.append(f"") + return ( + "" + # base spine — medium-warm brown, brighter than surrounding leather + "" + # darker edges where the spine curves into the cover + "" + "" + # subtle highlights just inside the dark edges (the spine's curve) + "" + "" + # central lighter highlight running down the middle + "" + + "".join(bands) + + "".join(stitch) + + "" + ) + + +def _book_ribbon_svg() -> str: + """Small red ribbon bookmark, drawn at 28×44 with chunky pixel V-cut.""" + return ( + "" + # ribbon body + "" + # highlight (left edge) + "" + # shadow (right edge) + "" + # subtle fold line down the centre + "" + # chunky V-cut bottom + "" + "" + "" + "" + "" + "" + # highlight on V-cut + "" + "" + "" + "" + "" + # shadow on V-cut + "" + "" + "" + "" + "" + ) + + +def _grimoire_book_css() -> str: + """Compose the dynamic CSS for the pixel-art book frame. + + Always returns a non-empty string (the SVGs are inline) — distinct from + the optional ``_grimoire_texture_css`` which only fires when the Klein + parchment PNG is on disk. + """ + frame_b64 = base64.b64encode(_book_frame_svg().encode("utf-8")).decode("ascii") + spine_b64 = base64.b64encode(_book_spine_svg().encode("utf-8")).decode("ascii") + ribbon_b64 = base64.b64encode(_book_ribbon_svg().encode("utf-8")).decode("ascii") + frame_uri = f"data:image/svg+xml;base64,{frame_b64}" + spine_uri = f"data:image/svg+xml;base64,{spine_b64}" + ribbon_uri = f"data:image/svg+xml;base64,{ribbon_b64}" + return f""" +/* ---------- Pixel-art book frame ---------- */ +.oracle-grimoire {{ + /* Replace the plain amber border with a 9-sliced leather frame. + The 20-unit slice in the 60x60 source means corner art (brass + protectors + amber trim) stays crisp; only the edge middles + stretch. */ + border: 20px solid transparent !important; + border-image: url('{frame_uri}') 20 / 20px / 0 stretch !important; + /* Stretchable centre spine sits as background-image, centred. */ + background-image: url('{spine_uri}') !important; + background-position: center center !important; + background-repeat: no-repeat !important; + background-size: 32px 100% !important; + image-rendering: pixelated !important; + overflow: visible !important; +}} +/* Override the previous ::before (dark spine line) with the ribbon + bookmark hanging from the top of the binding. */ +.oracle-grimoire::before {{ + content: "" !important; + position: absolute !important; + top: -6px !important; + left: 50% !important; + transform: translateX(-50%) !important; + width: 28px !important; + height: 44px !important; + background: url('{ribbon_uri}') no-repeat center !important; + background-size: 100% 100% !important; + z-index: 10 !important; + pointer-events: none !important; + image-rendering: pixelated !important; + box-shadow: none !important; +}} +/* The stitching dots and inner-amber-border live in the SVG now — hide + the legacy ::after pseudo-element decorations. */ +.oracle-grimoire::after {{ + display: none !important; +}} +/* The leather frame already provides the chunky border + drop shadow, + so soften the existing inset shadow that used to imply page curl — + the SVG amber trim does that visual now. */ +""" + + +def _grimoire_texture_css() -> str: + """Return a small CSS snippet that adds the Klein-generated parchment + texture as a background-image on .grimoire-page, if the PNG exists. + Returns empty string otherwise (so the CSS-only gradients still apply). + """ + if not _GRIMOIRE_TEXTURE_PNG.exists(): + return "" + try: + png_b64 = base64.b64encode(_GRIMOIRE_TEXTURE_PNG.read_bytes()).decode("ascii") + except OSError: + return "" + return ( + "\n/* Klein-generated parchment texture (loaded only when the PNG exists). " + "Stacked above the gradients so the warm pixel-art grain shows through. " + "A dark linear-gradient is layered ON TOP at high opacity so the cream " + "wizard-monologue text remains readable — the parchment grain is still " + "visible underneath but the text contrast wins. */\n" + ".grimoire-page {\n" + " background-image:\n" + " linear-gradient(180deg,\n" + " rgba(24, 20, 31, 0.82) 0%,\n" + " rgba(37, 29, 46, 0.86) 100%),\n" + " radial-gradient(ellipse 70% 40% at 50% 0%,\n" + " rgba(240, 192, 96, 0.18) 0%, transparent 70%),\n" + f" url('data:image/png;base64,{png_b64}') !important;\n" + " background-size: 100% 100%, 100% 100%, cover !important;\n" + " background-repeat: no-repeat, no-repeat, no-repeat !important;\n" + " image-rendering: pixelated !important;\n" + "}\n" + ) + + +# Sub-state within a trial: A = waiting to open, B = oracle drawn & resolved +TRIAL_STATE_AWAIT = "await" +TRIAL_STATE_REVEAL = "reveal" + + +_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV", 5: "V"} + + +def _sprite_data_uri_app(name: str) -> str: + """Read a sprite PNG from assets/sprites/ and base64-encode as a data URI. + Cached on first call. Returns empty string if the file is missing.""" + cache = _sprite_data_uri_app.__dict__.setdefault("_cache", {}) + if name in cache: + return cache[name] + path = _HERE / "assets" / "sprites" / f"{name}.png" + if not path.exists(): + cache[name] = "" + return "" + try: + png_b64 = base64.b64encode(path.read_bytes()).decode("ascii") + except OSError: + cache[name] = "" + return "" + uri = f"data:image/png;base64,{png_b64}" + cache[name] = uri + return uri + + +# --------------------------------------------------------------------------- +# Grimoire — book-spread renderer for the inscribe phase. +# The left page is the wizard's voice (markdown-like rendering of wizard_text). +# The right page is whatever input the current step asks for. +# --------------------------------------------------------------------------- + + +_GRIMOIRE_NUM_STEPS = 9 # 0..8 +_GRIMOIRE_ORACLE_STEPS = (3, 4, 5, 6, 7) # which steps capture an oracle + + +def _grimoire_render_markdown(raw: str) -> str: + """Render the lightweight markdown used in wizard_text into HTML. + + Supports: + - ``**bold**`` → ```` + - ``*italic*`` → ```` + - lines starting with ``> `` → wrapped in a single ``
`` + - paragraphs split on blank lines + """ + import re + + def _inline(line: str) -> str: + out = _md_safe(line) + out = re.sub(r"\*\*([^*]+)\*\*", r"\1", out) + out = re.sub(r"(?\1", out) + return out + + blocks: list[str] = [] + for raw_block in raw.split("\n\n"): + block = raw_block.strip() + if not block: + continue + if block.startswith(">"): + quote = "\n".join( + ln[1:].lstrip() if ln.startswith(">") else ln + for ln in block.splitlines() + ) + blocks.append(f"
{_inline(quote)}
") + else: + blocks.append(f"

{_inline(' '.join(block.split()))}

") + return "".join(blocks) + + +def _grimoire_left_html(state: GameState) -> str: + """Render the left page: wizard's text for the current inscribe step, + with hero/village placeholders substituted, the step marker in the + corner, and the step-themed pixel sigil tucked into the bottom-inner + corner.""" + raw = _wizard_text_for(state.lang or "en", state.inscribe_step) + raw = (raw + .replace("{hero_name}", state.hero_name or "Tobin") + .replace("{village_name}", state.village_name or "the Hollow")) + body = _grimoire_render_markdown(raw) + step = state.inscribe_step + 1 + lang = _tlang(state) + marker_text = _t("grimoire_step_marker", lang).format( + step=step, GRIMOIRE_NUM_STEPS=_GRIMOIRE_NUM_STEPS + ) + marker = f"
{marker_text}
" + sigil = ( + "
" + + _sigil_svg(state.inscribe_step) + + "
" + ) + # Wrap in a lang-tagged div so the CSS drop-cap rule (which mangles + # Chinese characters into chunky pixel-block initials) can be + # disabled selectively for `lang="zh"`. The page-counter marker goes + # AFTER the body so it flows below the text instead of absolute- + # positioned over it. + return f"
{body}
{marker}{sigil}" + + +def _grimoire_right_sigil_html(state: GameState) -> str: + """The same sigil, rendered for the right page (mirrored placement).""" + return ( + "
" + + _sigil_svg(state.inscribe_step) + + "
" + ) + + +def _grimoire_progress_html(state: GameState) -> str: + """Five-dot progress indicator for the oracles, rendered at the bottom of + the right page.""" + n_sealed = sum(1 for o in state.oracles if (o.text or "").strip()) + dots = [] + for i in range(NUM_ORACLES): + cls = "dot done" if i < n_sealed else "dot" + dots.append(f"") + return "
" + "".join(dots) + "
" + + +def _grimoire_summary_html(state: GameState) -> str: + """Step-8 summary panel: shows all five sealed oracles, with the open- + spell-book pixel art crowning the spread to mark the grimoire complete.""" + lang = _tlang(state) + blank_label = _t("blank_oracle", lang) + items = [] + for o in state.oracles: + text = (o.text or "").strip() or blank_label + items.append(f"
  • {_md_safe(text)}
  • ") + book_uri = _sprite_data_uri_app("open_book_spread") + alt_text = _t("alt_sealed_grimoire", lang) + book_img = ( + f"" + if book_uri else "" + ) + header = _md_safe(_t("summary_five_sealed", lang)) + return ( + "
    " + + book_img + + f"{header}" + "
      " + "".join(items) + "
    " + "
    " + ) + + +def _grimoire_error_html(state: GameState) -> str: + err = (state.grimoire_error or "").strip() + if not err: + return "" + return f"
    {_md_safe(err)}
    " + + +# _PROLOGUE_CACHE, _load_prologue, _prologue_html — REMOVED. +# These rendered a separate "prologue" card from prompts/prologue_{en,zh}.txt +# but nothing on the page ever called _prologue_html, so the prologue +# never reached the player. The grimoire's wizard_text monologue does +# the world-introduction job now. + + +def _pipeline_demo_html(state: Optional[GameState] = None) -> str: + """Three side-by-side 'thought-bubble' panels showing the wizard daydreaming + about three example pipelines — one per humor mode (A wild-imagination, + B accidental-trip, C last-minute-revelation). Each panel now embeds two + pixel-art sprites (the obstacle + the hero's resolved pose) over a soft + night-sky backdrop, so the gag reads visually before the caption is read. + + Shown only on the very first grimoire spread (step 0). Hidden via + `_pipeline_demo_visibility` after the player advances. + """ + lang = _tlang(state) + # Pixel-art sprites + backdrop pre-loaded as data URIs. The + # `_sprite_data_uri_app` helper caches per name, so reading the same + # sprite twice in this function only hits disk once. + bg = _sprite_data_uri_app("demo_card_backdrop") + bg_style = f"background-image:url('{bg}');" if bg else "" + tag_inscribes = _t("demo_tag_inscribes", lang) + tag_meets = _t("demo_tag_meets", lang) + tag_somehow = _t("demo_tag_somehow", lang) + + def _panel(mode: str, mode_label: str, oracle: str, + encounter_text: str, resolve_text: str, + encounter_sprite: str, resolve_sprite: str) -> str: + encounter_uri = _sprite_data_uri_app(encounter_sprite) + resolve_uri = _sprite_data_uri_app(resolve_sprite) + encounter_img = ( + f"{encounter_sprite}" + if encounter_uri else "" + ) + resolve_img = ( + f"{resolve_sprite}" + if resolve_uri else "" + ) + mode_badge_text = _t("demo_mode", lang).format(mode=mode) + return ( + f"
    " + "
    " + + encounter_img + + resolve_img + + "
    " + f"
    " + f"{mode_badge_text}" + f"{mode_label}" + "
    " + "
    " + f"{tag_inscribes}" + f"{oracle}" + "
    " + "
    " + f"{tag_meets}" + f"{encounter_text}" + "
    " + "
    " + f"{tag_somehow}" + f"{resolve_text}" + "
    " + "
    " + ) + + header = _t("demo_header", lang) + footer = _t("demo_footer", lang) + label_a = _t("demo_mode_a_label", lang) + label_b = _t("demo_mode_b_label", lang) + label_c = _t("demo_mode_c_label", lang) + return ( + f"
    " + "
    " + "💭" + f"{header}" + "
    " + "
    " + + # Panel A — Mode A WILD IMAGINATION (phone → hornets) + + _panel( + mode="A", + mode_label=label_a, + oracle="phone", + encounter_text="a swarm of glass-winged hornets, daggers humming", + resolve_text=( + "a brass horn drops into his hand. He calls the swarm's " + "mother. She is most disappointed. The hornets file home " + "single-file, none of them making eye contact." + ), + encounter_sprite="demo_hornet_dagger", + resolve_sprite="demo_hero_smug_phone", + ) + + # Panel B — Mode B ACCIDENTAL TRIP (Neil → archers → trip) + + _panel( + mode="B", + mode_label=label_b, + oracle="Neil", + encounter_text=( + "an ambush — hidden archers at the crossroads, bows drawn" + ), + resolve_text=( + "baffled by 'Neil', he turns calling the name, trips on a " + "wagon-axle, and four arrows hiss through the air his " + "throat had been in. He thanks Neil sincerely." + ), + encounter_sprite="demo_bandit_archer", + resolve_sprite="demo_hero_tripped", + ) + + # Panel C — Mode C LAST-MINUTE REVELATION (blue → wraiths → jays) + + _panel( + mode="C", + mode_label=label_c, + oracle="blue", + encounter_text=( + "fen-wraith herons closing in with toothed beaks" + ), + resolve_text=( + "he waves a blue scarf — fails. A jay scolds " + "jay-JAY-jay. He gets it: blue jay. " + "He whistles. A furious cobalt mob scatters the wraiths." + ), + encounter_sprite="demo_wraith_heron", + resolve_sprite="demo_bluejay_angry", + ) + + + "
    " + + "" + + "
    " + ) + + +def _inscribe_decoration_html(state: Optional[GameState] = None) -> str: + """A small framing illustration at the top of the inscribe panel: the + wizard (the player) on the left, the hero on the right, with the five + sealed oracles passing between them. Sets the whole game's framing — + 'YOU are the wizard.' + + If the wizard_desk_scene Klein texture exists, it's used as a backdrop + behind the wizard/hero figures (warm candle + parchment + quill imagery + that doubles down on the inscribe-phase atmosphere). + + Falls back to a plain text caption if the sprites haven't been + generated yet (e.g. on a fresh checkout before Klein has run).""" + # Theme-aware sprite lookup. Prefer theme-specific files + # (e.g. wizard_space_cowboy.png), fall back to the canonical fantasy + # sprites if the theme-specific file isn't on disk yet. + from oracles.themes import get_theme as _get_theme + theme_key_for_sprites = ( + getattr(state, "theme", "fantasy") if state else "fantasy" + ) or "fantasy" + wizard_uri = ( + _sprite_data_uri_app(f"wizard_{theme_key_for_sprites}") + or _sprite_data_uri_app("wizard") + ) + hero_uri = ( + _sprite_data_uri_app(f"hero_{theme_key_for_sprites}") + or _sprite_data_uri_app("hero") + ) + desk_uri = _sprite_data_uri_app("wizard_desk_scene") + if not wizard_uri or not hero_uri: + return ( + "
    " + "(sprites not yet generated — run " + "modal run oracles_app/modal_backend/modal_klein_oracles.py)" + "
    " + ) + bg_style = "" + if desk_uri: + # Layer the wizard-desk texture behind the figures at low opacity + # so the wizard and hero pop, but the desk's candle + parchment + # mood comes through. + bg_style = ( + f"background:" + f" linear-gradient(rgba(24,20,31,0.66), rgba(24,20,31,0.82))," + f" url('{desk_uri}') center/cover no-repeat;" + "image-rendering: pixelated;" + ) + # Theme-aware captions so each world reskins the labels. + from oracles.themes import get_theme as _get_theme + theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" + th = _get_theme(theme_key) + lang = _tlang(state) + mentor_caption_raw = th.localized_field("mentor_caption", lang) \ + or f"the {th.mentor_archetype}" + finale_caption_raw = th.localized_field("finale_caption", lang) \ + or f"off to {th.goal_verb} {th.finale_descriptor}" + artifact_raw = th.localized_field("oracle_artifact", lang) \ + or th.oracle_artifact + if lang == "zh": + arrow_caption_raw = f"五{artifact_raw}" + else: + arrow_caption_raw = f"five {artifact_raw}" + mentor_caption = _md_safe(mentor_caption_raw) + finale_caption = _md_safe(finale_caption_raw) + arrow_caption = _md_safe(arrow_caption_raw) + you_label = _md_safe(_t("figure_you", lang)) + # Theme-aware role title — render it as-is without a "Your" prefix + # (the labels already start with "the …", so "YOUR THE CHOSEN BOY" + # would read wrong). Falls back to the bilingual "figure_apprentice" + # key when a theme doesn't set apprentice_role. + _theme_obj = _get_theme(theme_key_for_sprites) + role_raw = _theme_obj.localized_field("apprentice_role", lang).strip() + if role_raw: + if lang == "zh": + champion_label = _md_safe(role_raw) + else: + champion_label = _md_safe(role_raw).upper() + else: + champion_label = _md_safe(_t("figure_apprentice", lang)) + return ( + f"
    " + f"
    " + f"mentor" + f"
    {you_label}
    {mentor_caption}
    " + "
    " + "
    " + "
    I   II   III   IV   V
    " + "
    ━━━━━━>
    " + f"
    {arrow_caption}
    " + "
    " + f"
    " + f"champion" + f"
    {champion_label}
    {finale_caption}
    " + "
    " + "
    " + ) + + +def _banner_html(state: Optional[GameState] = None) -> str: + """Top-of-page parallax banner with the game title overlaid. + + Reads the cozy-forest near.png and embeds it as a data URI so the HTML + is fully self-contained (no static-file routing dependency). + + The title and subtitle come from the active theme so each world reskins + the banner. Falls back to fantasy defaults when no state is provided + (initial render before any theme has been picked). + + Once the player leaves the inscribe phase (mode == 'trial' or + 'epilogue'), the banner collapses to a compact strip via the + `oracle-banner-compact` class so the trial visual gets that vertical + real estate. The banner re-renders on every UI bundle so the swap is + automatic.""" + # Pull theme-driven title + subtitle. + from oracles.themes import get_theme as _get_theme + theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" + theme = _get_theme(theme_key) + lang = _tlang(state) + # Game-wide title — every theme is a chapter under the same banner. + # The theme's display_name becomes a subtitle (smaller, italic). + game_title = "弟子" if lang == "zh" else "THE APPRENTICE" + theme_label = theme.localized_field("display_name", lang) or "The Apprentice" + if lang != "zh": + theme_label = theme_label.upper() + title = _md_safe(game_title).replace("'", "’").replace("'", "’") + subtitle_raw = ( + theme.localized_field("banner_subtitle", lang) + or _t("banner_default_subtitle", lang) + ) + # Prepend the theme name to the subtitle so the player still sees which + # chapter / world they're in even though the title is now universal. + subtitle = _md_safe(f"{theme_label} — {subtitle_raw}") + + # Decide tall vs compact based on phase. Inscribe = tall (intro vibe). + # Trial/epilogue = compact (give screen real estate to the visuals). + mode = getattr(state, "mode", "inscribe") if state else "inscribe" + is_compact = mode in ("trial", "epilogue", "send_off") + extra_cls = " oracle-banner-compact" if is_compact else "" + + if not _PARALLAX_PNG.exists(): + return ( + f"
    " + "
    " + f"
    {title}
    " + f"
    {subtitle}
    " + "
    " + ) + try: + png_b64 = base64.b64encode(_PARALLAX_PNG.read_bytes()).decode("ascii") + except OSError: + png_b64 = "" + src = f"data:image/png;base64,{png_b64}" if png_b64 else "" + return ( + f"
    " + f"banner" + "
    " + f"
    {title}
    " + f"
    {subtitle}
    " + "
    " + ) + + +def _processing_overlay_html(state: Optional[GameState] = None) -> str: + """Floating modal overlay shown while a generator handler is mid-LLM-call. + A walking-hero sprite paces across a small horizon strip with a caption + explaining what he's doing. Empty string when no work is in flight.""" + msg = getattr(state, "processing_msg", "") if state else "" + if not msg: + return "" + # Theme-aware hero sprite — fall back to canonical "hero" when no + # theme-suffixed variant is on disk. + theme_key = getattr(state, "theme", "fantasy") or "fantasy" + hero_uri = ( + _sprite_data_uri_app(f"hero_{theme_key}") + or _sprite_data_uri_app("hero") + ) + hero_img = ( + f"hero" + if hero_uri else "" + ) + + # Four landscape sprites stacked as crossfade layers. Each one fades + # in for ~3.5s then fades out for 0.5s while the hero walks through + # one 4-second exit cycle — so a new landscape appears every time the + # hero exits the right edge. Layers missing on disk are simply omitted. + # The first layer is theme-specific when available; the journey ones + # stay fantasy (generic horizon scenes). + theme_scene = f"scene_{theme_key}_landscape" + layer_names = [ + theme_scene if _sprite_data_uri_app(theme_scene) else "processing_horizon", + "journey_dawn_meadow", + "journey_dusk_forest", + "journey_misty_valley", + ] + layers = [] + for i, name in enumerate(layer_names): + uri = _sprite_data_uri_app(name) + if not uri: + continue + # The theme-scene backdrops (scene__landscape) are square + # Klein vignettes with chroma-keyed transparent margins; `cover` + # scales them up so the centered content gets cropped + the + # transparent margins reveal a void. Mark this first layer so + # CSS can size it with `contain` + a solid dark fill instead. + extra_cls = " oracle-processing-bg-theme" if name.startswith("scene_") else "" + layers.append( + f"
    " + ) + bg_html = "".join(layers) + + return ( + "
    " + "
    " + + bg_html + + f"
    {_md_safe(msg)}
    " + f"{hero_img}" + "
    " + "
    " + ) + + +def _global_sprite_vars_html() -> str: + """One-time ``" + ) + + +def _phase_backdrop_styles_html() -> str: + """One-time ``" + + +def _theme_style_overrides_html(state: Optional[GameState] = None) -> str: + """Emit a tiny ``" + + +# --------------------------------------------------------------------------- +# CSS +# --------------------------------------------------------------------------- + + +CSS = """ +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap'); + +:root { + --w-night: #18141f; /* page background */ + --w-panel: #2a2438; /* cards */ + --w-amber: #f0c060; /* accent */ + --w-moss: #7ea687; /* secondary accent */ + --w-cream: #ede4d3; /* primary text */ + --w-mute: #b4a890; /* dimmer text */ + --w-shadow: #0c0a13; /* chunky drop shadow */ + --w-dragon: #c84a3a; /* dragon accent */ + --w-dragon-deep: #3a0e0e; +} + +/* ---------- Off-brand sweep — hide every Gradio default chrome bit ---------- */ +/* The "Built with Gradio" footer + API badge in the corner are dead + giveaways that this is a Gradio app. The Off-Brand badge wants the + judge to not be able to tell at a glance. */ +footer, +.footer, +.gradio-container > .footer, +.gradio-container footer, +a[href*="gradio.app"], +a[href*="huggingface.co/spaces"][title*="Built with"], +.svelte-built-with-gradio, +.show-api, +.api-docs, +.use-via-api, +.built-with { + display: none !important; + visibility: hidden !important; + height: 0 !important; +} +/* The "Use via API" pill in the bottom-right of HF Spaces. */ +.api-docs-btn, +button.api-docs-btn, +.gradio-container .footer * { + display: none !important; +} +/* Just suppress the Gradio focus-blue ring + their default fonts. + Do NOT zero out block backgrounds — they're providing the + dark-on-light text contrast on the inscribe pages. */ +.gradio-container .info, +.gradio-container .info-text { + font-family: 'VT323', monospace !important; + color: var(--w-cream, #ede4d3) !important; + font-size: 17px !important; + letter-spacing: 0.3px !important; + line-height: 1.5 !important; + opacity: 0.95 !important; +} +.gradio-container label, +.gradio-container .label-wrap, +.gradio-container .label { + font-family: 'Press Start 2P', monospace !important; + font-size: 12px !important; + color: var(--w-amber, #f0c060) !important; + letter-spacing: 1px !important; +} +/* Stop Gradio's default focus glow — we use our own border tinge. */ +.gradio-container *:focus-visible { + outline: 2px solid var(--w-amber, #f0c060) !important; + outline-offset: 1px !important; + box-shadow: none !important; +} + +/* Belt-and-suspenders: kill any Gradio default loading / progress / spinner + indicators that try to flash on a yielded handler. We already pass + ``show_progress="hidden"`` on every click + change, but Gradio's CSS + sometimes still inserts a momentary status overlay before the first + yield lands — that brief flash is what the user perceived as flicker + when transitioning between pages. */ +.progress-bar-wrap, +.progress-bar, +.progress-text, +.gradio-container .progress, +.gradio-container .progress-level, +.gradio-container .progress-level-inner, +.gradio-container .progress-bar, +.gradio-container .progress-bar-wrap, +.gradio-container .pending, +.gradio-container .generating, +.gradio-container [class*="progress-level"], +.gradio-container [class*="status"]:not(.oracle-processing-modal):not(.oracle-processing-overlay), +.gradio-container .wrap.default, +.gradio-container .loading, +.gradio-container .spin, +.gradio-container .spinner { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; +} +/* The block-wrapper that Gradio adds a "pending" class to during a + click — suppress its opacity transition so the page doesn't dim. */ +.gradio-container .block.pending, +.gradio-container .block.generating { + opacity: 1 !important; + pointer-events: auto !important; +} + +/* ---------- Force dark theme on the whole gradio container ---------- */ +.gradio-container, body, .dark, .gradio-container .form, .gradio-container .block { + background: var(--w-night) !important; + color: var(--w-cream) !important; + font-family: 'VT323', 'Courier New', monospace !important; +} +.gradio-container * { image-rendering: pixelated; } + +/* Make gradio's own labels readable on dark */ +.gradio-container label, .gradio-container .label, .gradio-container .label-wrap, +.gradio-container span, .gradio-container p, .gradio-container li { + color: var(--w-cream) !important; +} +.gradio-container input, .gradio-container textarea { + background: var(--w-panel) !important; + color: var(--w-cream) !important; + border: 2px solid var(--w-amber) !important; + box-shadow: 3px 3px 0 var(--w-shadow) !important; + font-family: 'VT323', monospace !important; + font-size: 18px !important; +} +.gradio-container input:focus, .gradio-container textarea:focus { + outline: none !important; + border-color: var(--w-moss) !important; +} +.gradio-container button { + background: var(--w-amber) !important; + color: var(--w-shadow) !important; + font-family: 'Press Start 2P', monospace !important; + font-size: 11px !important; + letter-spacing: 1px !important; + border: none !important; + box-shadow: 4px 4px 0 var(--w-shadow) !important; + padding: 12px 18px !important; + border-radius: 0 !important; + transition: transform 0.05s, box-shadow 0.05s; +} +.gradio-container button:hover { + background: var(--w-moss) !important; +} +.gradio-container button:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--w-shadow) !important; +} + +/* ---------- Parallax banner ---------- */ +.oracle-banner { + width: 100%; + height: 200px; + overflow: hidden; + position: relative; + border-bottom: 4px solid var(--w-shadow); + margin-bottom: 0; +} +.oracle-banner img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center 60%; + image-rendering: pixelated; + filter: brightness(0.65) saturate(1.1); + display: block; +} +.oracle-banner-overlay { + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(24, 20, 31, 0.05) 0%, + rgba(24, 20, 31, 0.45) 60%, + rgba(24, 20, 31, 0.95) 100% + ); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: 0 20px; +} +.oracle-banner-overlay .oracle-banner-title { + font-family: 'Press Start 2P', monospace !important; + font-size: 28px; + color: var(--w-amber); + text-shadow: + 4px 4px 0 var(--w-shadow), + 0 0 24px rgba(240, 192, 96, 0.5); + letter-spacing: 4px; + margin: 0; +} +.oracle-banner-overlay .oracle-banner-sub { + font-family: 'VT323', monospace !important; + color: var(--w-cream); + font-size: 20px; + margin-top: 12px; + opacity: 0.95; + text-shadow: 2px 2px 0 var(--w-shadow); +} + +/* ---------- Title / badge ---------- */ +.oracle-title { + font-family: 'Press Start 2P', monospace !important; + font-size: 14px !important; + color: var(--w-amber) !important; + text-shadow: 3px 3px 0 var(--w-shadow); + letter-spacing: 2px; + margin: 0 0 8px 0; +} +.oracle-subtitle { + font-style: italic; + color: var(--w-mute) !important; + font-size: 16px; + margin-bottom: 8px; +} +.oracle-badge { + display: inline-block; + padding: 6px 14px; + background: var(--w-amber); + color: var(--w-shadow) !important; + font-family: 'Press Start 2P', monospace !important; + font-weight: 400; + font-size: 10px; + letter-spacing: 1px; + margin-left: 8px; + border: 2px solid var(--w-shadow); + box-shadow: 3px 3px 0 var(--w-shadow); + vertical-align: middle; +} +.oracle-mock-banner { + font-style: italic; + color: var(--w-amber) !important; + font-size: 14px; + margin: 4px 0 10px 0; + border: 1px dashed var(--w-amber); + padding: 4px 10px; + display: inline-block; +} + +/* ---------- Card (the main panel for trial / epilogue) ---------- */ +.oracle-card { + background: var(--w-panel); + border: 4px solid var(--w-amber); + box-shadow: 6px 6px 0 var(--w-shadow); + padding: 22px 26px; + margin: 14px 0 18px 0; + color: var(--w-cream) !important; +} +.oracle-card[data-dragon="true"] { + background: linear-gradient(160deg, var(--w-dragon-deep) 0%, #1a0606 100%); + border-color: var(--w-dragon); + box-shadow: 6px 6px 0 #260606; +} +.oracle-card[data-dragon="true"] .oracle-trial-heading { + color: var(--w-dragon); + text-shadow: 3px 3px 0 #1a0606; +} +.oracle-trial-heading { + font-family: 'Press Start 2P', monospace !important; + font-size: 18px; + color: var(--w-amber); + text-shadow: 3px 3px 0 var(--w-shadow); + margin: 0 0 14px 0; + letter-spacing: 2px; + /* Buffering panel — same treatment as narration/tactic so the + heading stays readable on any scene image behind it. */ + background: rgba(20, 14, 30, 0.78); + border: 1px solid rgba(240, 192, 96, 0.35); + padding: 10px 16px; + display: inline-block; +} +.oracle-obstacle { + font-size: 19px; + line-height: 1.5; + color: var(--w-cream) !important; + margin-bottom: 14px; + /* Buffering for the obstacle setup text. */ + background: rgba(20, 14, 30, 0.72); + border: 1px solid rgba(240, 192, 96, 0.20); + padding: 12px 16px; +} + +/* ---------- Oracle quote blockquote ---------- */ +blockquote.oracle-quote { + border-left: 6px solid var(--w-amber); + margin: 12px 0 16px 0; + padding: 10px 16px; + font-style: italic; + background: rgba(24, 20, 31, 0.55); + color: var(--w-cream) !important; + border-radius: 0; + white-space: pre-wrap; + font-size: 18px; +} +.oracle-card[data-dragon="true"] blockquote.oracle-quote { + border-left-color: var(--w-dragon); + color: #ffd9c3 !important; + background: rgba(0,0,0,0.4); +} + +.oracle-narration { + font-size: 18px; + line-height: 1.6; + color: var(--w-cream) !important; + /* Inline dark backdrop — every LLM-generated narration sits on a + scene image with unpredictable brightness. Without a buffer the + cream text disappears whenever a light patch of the scene sits + under it. This semi-transparent panel rides ON TOP of the scene + image and gives the text its own consistent contrast layer. */ + background: rgba(20, 14, 30, 0.72); + border: 1px solid rgba(240, 192, 96, 0.25); + padding: 14px 18px; + box-shadow: 0 0 22px rgba(0, 0, 0, 0.35) inset; + border-radius: 0; +} +.oracle-tactic { + font-size: 16px; + color: var(--w-amber) !important; + margin-top: 14px; + font-style: italic; + border-top: 1px dashed var(--w-amber); + padding-top: 8px; + /* Same buffering treatment for the tactic one-liner. */ + background: rgba(20, 14, 30, 0.70); + border: 1px solid rgba(240, 192, 96, 0.25); + padding: 10px 16px; + margin-top: 12px; +} +.oracle-card[data-dragon="true"] .oracle-tactic { + color: var(--w-dragon) !important; + border-top-color: var(--w-dragon); +} + +/* ---------- Inscribe phase ---------- */ +.oracle-inscribe-help { + background: var(--w-panel); + border-left: 4px solid var(--w-amber); + padding: 12px 16px; + margin-bottom: 14px; + font-size: 17px; + color: var(--w-cream) !important; + box-shadow: 4px 4px 0 var(--w-shadow); +} +.oracle-error { + color: #ff8a78 !important; + font-weight: 700; + font-size: 16px; + margin-top: 8px; + min-height: 1.2em; +} +.oracle-sealed-list { + color: var(--w-cream) !important; + line-height: 1.7; + font-size: 18px; +} +.oracle-sealed-list strong { + color: var(--w-amber) !important; + font-family: 'Press Start 2P', monospace !important; + font-size: 11px; + letter-spacing: 1px; +} +.oracle-sealed-list ol { + padding-left: 28px; +} +.oracle-sealed-list li { + color: var(--w-cream) !important; + margin-bottom: 6px; +} +.oracle-sealed-list li::marker { + color: var(--w-amber); + font-weight: bold; +} + +/* ---------- Image wrap ---------- */ +.oracle-image-wrap { + text-align: center; + margin: 14px 0; +} +.oracle-image-wrap img { + max-width: 100%; + height: auto; + border: 4px solid var(--w-amber); + box-shadow: 6px 6px 0 var(--w-shadow); + image-rendering: pixelated; +} +.oracle-card[data-dragon="true"] .oracle-image-wrap img { + border-color: var(--w-dragon); +} + +/* ---------- Pixel decoration: small inline SVG icons ---------- */ +.oracle-icon { + display: inline-block; + vertical-align: middle; + image-rendering: pixelated; + margin-right: 6px; +} + +/* ---------- The grimoire (inscribe page, book spread) ---------- */ +/* The .oracle-grimoire is on a gr.Row. Gradio's row is already flex with + horizontal direction, so we just style the band and let the two child + columns sit side by side. Gradio uses gap by default; we override. */ +.oracle-grimoire { + background: var(--w-shadow) !important; + /* Chunky pixel-art amber border with darker inner stitch */ + border: 6px solid var(--w-amber) !important; + box-shadow: + 10px 10px 0 var(--w-shadow), + inset 0 0 0 2px #6a4818 !important; + margin: 14px 0 18px 0 !important; + padding: 0 !important; + gap: 0 !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + position: relative; +} +.oracle-grimoire > * { + margin: 0 !important; + border-radius: 0 !important; +} +/* Visible book spine running down the centre where the two pages meet */ +.oracle-grimoire::before { + content: ""; + position: absolute; + top: 0; bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 10px; + background: linear-gradient(180deg, + transparent 0%, + #150f1c 8%, + #0a0710 50%, + #150f1c 92%, + transparent 100%); + box-shadow: + -7px 0 14px rgba(0,0,0,0.75), + 7px 0 14px rgba(0,0,0,0.75); + z-index: 5; + pointer-events: none; +} +/* Five amber "stitching" dots vertically along the spine */ +.oracle-grimoire::after { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%); + top: 50%; + width: 4px; + height: 4px; + background: var(--w-amber); + box-shadow: + 0 -76px 0 var(--w-amber), + 0 -38px 0 var(--w-amber), + 0 38px 0 var(--w-amber), + 0 76px 0 var(--w-amber); + z-index: 6; + pointer-events: none; +} +/* Each .grimoire-page is on a gr.Column inside the row. */ +.grimoire-page { + background: + /* candlelight halo at the top of the page */ + radial-gradient(ellipse 70% 40% at 50% 0%, + rgba(240, 192, 96, 0.12) 0%, transparent 70%), + /* faint horizontal ruled-paper lines (writing guides) */ + repeating-linear-gradient(0deg, + transparent 0px, transparent 32px, + rgba(240, 192, 96, 0.045) 32px, + rgba(240, 192, 96, 0.045) 33px), + /* warm aged-page tint over the base */ + linear-gradient(180deg, #2f2638 0%, #251d2e 100%) !important; + padding: 38px 36px 34px 36px !important; + color: var(--w-cream) !important; + font-size: 22px !important; + line-height: 1.7 !important; + min-height: 460px; + position: relative; + overflow: hidden; + flex: 1 1 50% !important; + min-width: 0 !important; +} +/* Gradio wraps gr.HTML output in its own div which resets font-size on the + inner content — these selectors win because they target the inner div + directly with !important. */ +.grimoire-page .grimoire-text-body, +.grimoire-page .grimoire-text-body p, +.grimoire-page .grimoire-text-body li, +.grimoire-page .grimoire-text-body strong, +.grimoire-page .grimoire-text-body em, +.grimoire-page .grimoire-text-body blockquote { + font-size: 22px !important; + line-height: 1.7 !important; +} +/* Hand-drawn amber rule near the top of each page */ +.grimoire-page::before { + content: ""; + position: absolute; + top: 18px; + left: 30px; + right: 30px; + height: 2px; + background: linear-gradient(90deg, + transparent 0%, + rgba(240, 192, 96, 0.45) 18%, + rgba(240, 192, 96, 0.70) 50%, + rgba(240, 192, 96, 0.45) 82%, + transparent 100%); + pointer-events: none; + z-index: 1; +} +/* Per-step pixel-art sigil rendered inline (via _grimoire_left_html and + _grimoire_right_sigil_html). Positioned at the inner-bottom corner of + each page so the two sigils mirror across the spine. */ +.grimoire-sigil { + position: absolute; + bottom: 14px; + width: 36px; + height: 36px; + opacity: 0.9; + pointer-events: none; + z-index: 2; + filter: drop-shadow(2px 2px 0 var(--w-shadow)); +} +.grimoire-sigil-left { right: 28px; } +.grimoire-sigil-right { left: 28px; } +/* Illuminated drop-cap: first letter of the wizard's first paragraph + on the left page renders as a chunky pixel-block initial. + Chinese characters look forced as drop-caps — the rule is suppressed + when the inner div has lang="zh" (set by the renderer). */ +.grimoire-text-body[lang="zh"] p:first-of-type::first-letter, +.grimoire-text-body[lang="zh"] p:first-of-type strong:first-of-type::first-letter { + font-size: inherit !important; + color: inherit !important; + text-shadow: none !important; + float: none !important; + margin: 0 !important; + padding: 0 !important; + font-family: inherit !important; +} +.grimoire-page.grimoire-left p:first-of-type::first-letter { + font-family: 'Press Start 2P', 'Courier New', monospace !important; + font-size: 40px !important; + line-height: 1 !important; + color: var(--w-amber) !important; + text-shadow: 3px 3px 0 var(--w-shadow); + float: left; + margin: 4px 8px 0 0; + padding: 2px 4px 0 0; + font-style: normal !important; +} +/* Parchment curl: deeper shadow at the spine edge of each page */ +.grimoire-page.grimoire-left { + box-shadow: + inset -18px 0 24px rgba(0,0,0,0.55), + inset 0 0 28px rgba(0,0,0,0.25); + font-family: 'VT323', 'Courier New', monospace !important; + font-style: italic; + color: var(--w-cream) !important; +} +.grimoire-page.grimoire-right { + box-shadow: + inset 18px 0 24px rgba(0,0,0,0.55), + inset 0 0 28px rgba(0,0,0,0.25); +} +.grimoire-page.grimoire-left p { + margin: 8px 0; + color: var(--w-cream) !important; +} +.grimoire-page.grimoire-left strong { + color: var(--w-amber) !important; + font-style: normal; + font-weight: 600; +} +.grimoire-page.grimoire-left em { + color: var(--w-amber) !important; + font-style: italic; +} +.grimoire-page.grimoire-left blockquote { + border-left: 4px solid var(--w-amber); + margin: 12px 0; + padding: 6px 14px; + background: rgba(0, 0, 0, 0.3); + font-style: italic; + color: var(--w-cream) !important; +} +.grimoire-page.grimoire-left .grimoire-step-marker { + /* In normal flow now (was absolute), rendered AFTER the text body + so it sits BELOW the wizard's speech rather than overlapping. */ + display: inline-block; + margin: 20px 0 0 0; + font-family: 'Press Start 2P', monospace !important; + font-size: 10px; + color: var(--w-amber); + letter-spacing: 1px; + opacity: 0.85; + background: rgba(24, 20, 31, 0.78); + padding: 4px 8px; + border: 1px solid rgba(240, 192, 96, 0.45); + pointer-events: none; +} +/* Reserve a right-side rectangle in the upper corner so the first + paragraph's first 1-2 lines wrap before the marker instead of + running underneath it. Done with a floating phantom block so the + text envelope flows naturally around both the drop-cap (left) AND + the marker zone (right). */ +.grimoire-page.grimoire-left p:first-of-type::before { + content: ""; + float: right; + width: 96px; + height: 36px; + margin: 0 0 6px 8px; + shape-outside: inset(0 0 0 0); +} +.grimoire-page.grimoire-right { + display: flex; + flex-direction: column; + justify-content: center; +} +.grimoire-page.grimoire-right .grimoire-right-helper { + font-family: 'VT323', monospace !important; + color: var(--w-mute); + font-size: 16px; + margin-bottom: 12px; + font-style: italic; +} +.grimoire-page.grimoire-right textarea, +.grimoire-page.grimoire-right input { + background: rgba(0,0,0,0.35) !important; + border: 2px solid var(--w-amber) !important; + color: var(--w-cream) !important; + font-family: 'VT323', monospace !important; + font-size: 19px !important; + padding: 12px !important; + box-shadow: 3px 3px 0 var(--w-shadow) !important; +} +.grimoire-summary { + color: var(--w-cream) !important; + font-family: 'VT323', monospace !important; + font-size: 17px; + line-height: 1.5; + background: rgba(0,0,0,0.3); + border-left: 4px solid var(--w-amber); + padding: 10px 14px; + margin: 8px 0; +} +.grimoire-summary strong { + color: var(--w-amber) !important; +} +.grimoire-summary ol { + margin: 6px 0 6px 18px; + padding: 0; +} +.grimoire-summary ol li { + color: var(--w-cream) !important; + margin: 4px 0; +} +.grimoire-progress { + text-align: center; + margin: 6px 0 0 0; + letter-spacing: 6px; + font-family: 'Press Start 2P', monospace !important; + font-size: 12px; + color: var(--w-amber); +} +.grimoire-progress .dot { + color: var(--w-mute); +} +.grimoire-progress .dot.done { + color: var(--w-amber); +} + +@media (max-width: 720px) { + .oracle-grimoire { + flex-direction: column !important; + } + .grimoire-page { + flex: 1 1 100% !important; + min-height: 220px !important; + } + .grimoire-page.grimoire-left { + border-right: none !important; + border-bottom: 2px solid var(--w-amber); + } +} + +/* ---------- Prologue (legacy, kept for any references) ---------- */ +.oracle-prologue { + background: var(--w-panel); + border: 4px solid var(--w-amber); + box-shadow: 6px 6px 0 var(--w-shadow); + padding: 22px 26px; + margin: 0 0 18px 0; + color: var(--w-cream) !important; + font-size: 18px; + line-height: 1.6; +} +.oracle-prologue-title { + font-family: 'Press Start 2P', monospace !important; + font-size: 12px; + color: var(--w-amber); + letter-spacing: 2px; + text-shadow: 2px 2px 0 var(--w-shadow); + margin-bottom: 12px; + text-align: center; +} +.oracle-prologue p { + margin: 8px 0; + color: var(--w-cream) !important; +} +.oracle-prologue strong { + color: var(--w-amber) !important; + font-weight: 600; +} +.oracle-prologue em { + color: var(--w-mute); + font-style: italic; +} +.oracle-prologue-quote { + border-left: 4px solid var(--w-amber); + margin: 12px 0; + padding: 6px 14px; + background: rgba(0, 0, 0, 0.3); + font-style: italic; + color: var(--w-cream) !important; +} + +/* ---------- Interlude (above the next trial's setup) ---------- */ +.oracle-interlude { + background: rgba(42, 36, 56, 0.6); + border-left: 4px solid var(--w-moss); + padding: 14px 18px; + margin: 0 0 16px 0; + font-style: italic; + color: var(--w-cream) !important; + font-size: 17px; + line-height: 1.55; +} +.oracle-interlude p { + margin: 6px 0; + color: var(--w-cream) !important; +} +.oracle-interlude.oracle-interlude-dragon { + border-left-color: var(--w-dragon); + background: rgba(58, 14, 14, 0.45); + color: #ffd9c3 !important; +} +.oracle-interlude.oracle-interlude-dragon p { + color: #ffd9c3 !important; +} + +/* ---------- Inscribe-phase figure row (wizard → 5 oracles → hero) ---------- */ +.oracle-inscribe-figs { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + padding: 24px 18px; + margin: 12px 0 20px 0; + background: var(--w-panel); + border: 4px solid var(--w-amber); + box-shadow: 6px 6px 0 var(--w-shadow); +} +.oracle-inscribe-figs .oracle-fig { + display: flex; + flex-direction: column; + align-items: center; + flex: 0 0 auto; +} +.oracle-inscribe-figs .oracle-fig img { + width: 128px; + height: 128px; + image-rendering: pixelated; + filter: drop-shadow(4px 4px 0 var(--w-shadow)); +} +.oracle-inscribe-figs .oracle-fig-label { + margin-top: 12px; + font-family: 'Press Start 2P', monospace !important; + font-size: 12px; /* was 10 */ + color: var(--w-amber); + text-align: center; + letter-spacing: 1px; + line-height: 1.6; +} +.oracle-inscribe-figs .oracle-fig-label span { + display: block; + font-family: 'VT323', monospace !important; + font-size: 20px; /* was 16 */ + color: var(--w-mute); + font-style: italic; + letter-spacing: 0; + margin-top: 6px; +} +.oracle-inscribe-figs .oracle-fig-arrow { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--w-cream); + padding: 0 16px; + min-width: 0; +} +.oracle-arrow-scrolls { + font-family: 'Press Start 2P', monospace !important; + font-size: 14px; + color: var(--w-amber); + letter-spacing: 1px; + margin-bottom: 6px; + text-shadow: 2px 2px 0 var(--w-shadow); +} +.oracle-arrow-line { + font-family: 'VT323', monospace !important; + font-size: 32px; + color: var(--w-amber); + line-height: 1; + letter-spacing: 0; +} +.oracle-arrow-caption { + font-family: 'VT323', monospace !important; + font-size: 22px; /* was 18 */ + color: var(--w-mute); + font-style: italic; + margin-top: 6px; +} + +/* ---------- Chronicle accordion text ---------- */ +.gradio-container .label-wrap, .gradio-container details summary { + color: var(--w-amber) !important; + font-family: 'Press Start 2P', monospace !important; + font-size: 11px !important; + letter-spacing: 1px !important; +} +.gradio-container details h3, .gradio-container details strong { + color: var(--w-amber) !important; + font-family: 'Press Start 2P', monospace !important; + font-size: 12px !important; +} +.gradio-container details em { + color: var(--w-mute) !important; +} + +/* ---------- Story-tree visualization (epilogue/chronicle) ---------- */ +.story-tree { + background: linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.55) 100%); + border: 2px solid var(--w-amber); + box-shadow: 5px 5px 0 var(--w-shadow); + padding: 18px 16px 22px 16px; + margin: 0 0 18px 0; + font-family: 'VT323', monospace; + color: var(--w-cream); +} +.story-tree-header { + text-align: center; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px dashed rgba(240, 192, 96, 0.4); +} +.story-tree-title { + font-family: 'Press Start 2P', monospace; + color: var(--w-amber); + font-size: 14px; + letter-spacing: 2px; + margin-bottom: 4px; +} +.story-tree-hint { + color: var(--w-mute); + font-size: 14px; + font-style: italic; +} +.story-tree-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 8px; + align-items: start; +} +.story-tree-col { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.story-tree-col-label { + font-family: 'Press Start 2P', monospace; + color: var(--w-amber); + font-size: 10px; + text-align: center; + letter-spacing: 1px; + padding: 4px 0; + border-bottom: 1px solid rgba(240, 192, 96, 0.3); + margin-bottom: 4px; +} +.story-node { + border: 2px solid var(--w-shadow); + background: rgba(20, 14, 30, 0.7); + padding: 6px 8px; + min-height: 64px; + border-radius: 0; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} +.story-node.visited { + border-color: var(--w-amber); + background: linear-gradient(180deg, rgba(60, 42, 16, 0.95) 0%, rgba(36, 28, 18, 0.95) 100%); + box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); + /* Subtle breath so the walked path looks alive vs. the static + masked nodes. Same keyframe-anchor pattern (0% = 100% = resting). */ + animation: story-node-breath 5.5s ease-in-out infinite; +} +@keyframes story-node-breath { + 0% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); } + 50% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 18px rgba(240, 192, 96, 0.42); } + 100% { box-shadow: 3px 3px 0 var(--w-shadow), 0 0 12px rgba(240, 192, 96, 0.25); } +} +.story-node.visited:hover { + transform: translate(-1px, -1px); + box-shadow: 4px 4px 0 var(--w-shadow), 0 0 22px rgba(240, 192, 96, 0.55); +} +.story-node.masked { + border-color: rgba(120, 110, 100, 0.35); + background: rgba(20, 14, 30, 0.45); + color: var(--w-mute); + filter: blur(0.5px); + opacity: 0.55; +} +.story-node.masked .story-node-title { + color: rgba(180, 168, 144, 0.6); + font-style: italic; +} +.story-node.ending.visited { + border-color: #ffd966; + background: linear-gradient(180deg, rgba(120, 80, 20, 0.95) 0%, rgba(60, 40, 16, 0.95) 100%); + box-shadow: 3px 3px 0 var(--w-shadow), 0 0 20px rgba(255, 217, 102, 0.5); +} +.story-node-title { + font-family: 'Press Start 2P', monospace; + color: var(--w-amber); + font-size: 9px; + letter-spacing: 0.5px; + margin-bottom: 4px; + line-height: 1.3; + word-break: break-word; +} +.story-node.masked .story-node-title { + font-family: 'VT323', monospace; + font-size: 16px; + color: rgba(180, 168, 144, 0.6); +} +.story-node-tag { + font-family: 'VT323', monospace; + color: var(--w-mute); + font-size: 12px; + margin-bottom: 4px; +} +.story-node-setup { + font-family: 'VT323', monospace; + color: var(--w-cream); + font-size: 13px; + line-height: 1.35; + opacity: 0.92; +} +@media (max-width: 900px) { + .story-tree-grid { + grid-template-columns: repeat(3, 1fr); + gap: 6px; + } + .story-tree-col-label { font-size: 9px; } + .story-node-title { font-size: 8px; } +} + +/* ---------- Ending banner on the epilogue screen ---------- */ +.ending-banner { + background: linear-gradient(180deg, rgba(120, 80, 20, 0.92) 0%, rgba(36, 24, 14, 0.92) 100%); + border: 2px solid #ffd966; + box-shadow: 4px 4px 0 var(--w-shadow), 0 0 28px rgba(255, 217, 102, 0.35); + padding: 16px 18px 18px 18px; + margin: 12px 0 18px 0; + text-align: center; + color: var(--w-cream); + font-family: 'VT323', monospace; + position: relative; +} +.ending-eyebrow { + font-family: 'Press Start 2P', monospace; + color: #ffd966; + font-size: 10px; + letter-spacing: 2px; + margin-bottom: 8px; + text-shadow: 1px 1px 0 var(--w-shadow); +} +.ending-title { + font-family: 'Press Start 2P', monospace; + color: #ffd966; + font-size: 16px; + letter-spacing: 1.5px; + line-height: 1.5; + margin-bottom: 14px; + text-shadow: 2px 2px 0 var(--w-shadow); +} +.ending-pips { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-top: 10px; +} +.ending-pip { + border: 2px solid var(--w-shadow); + padding: 6px 4px; + font-family: 'VT323', monospace; + font-size: 13px; + line-height: 1.25; + text-align: center; + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + word-break: break-word; +} +.ending-pip.lit { + background: linear-gradient(180deg, rgba(255, 217, 102, 0.95) 0%, rgba(180, 130, 30, 0.95) 100%); + border-color: #ffd966; + color: #1a1018; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + letter-spacing: 0.5px; + text-shadow: 1px 1px 0 rgba(255, 240, 200, 0.7); + box-shadow: 0 0 12px rgba(255, 217, 102, 0.5); + /* Subtle pulse so the player's eye lands on the ending they reached. + Keyframes anchored at the resting glow (0% == 100%) so the + Gradio-yield restart never snaps to a darker state — the same + fix pattern used for every other animation in the app. */ + animation: ending-pip-pulse 3.4s ease-in-out infinite; +} +@keyframes ending-pip-pulse { + 0% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50); + transform: scale(1.00); } + 50% { box-shadow: 0 0 22px rgba(255, 217, 102, 0.85); + transform: scale(1.025); } + 100% { box-shadow: 0 0 12px rgba(255, 217, 102, 0.50); + transform: scale(1.00); } +} +.ending-pip.dim { + background: rgba(20, 14, 30, 0.7); + color: rgba(180, 168, 144, 0.55); + border-color: rgba(120, 110, 100, 0.35); + filter: blur(0.5px); + font-style: italic; +} +.ending-pip-tag { + font-size: 11px; + opacity: 0.7; + margin-left: 2px; +} +@media (max-width: 700px) { + .ending-pips { grid-template-columns: repeat(2, 1fr); } + .ending-title { font-size: 13px; } +} + +/* ---------- Motion layer (overnight overhaul) ---------- */ +/* Subtle decorative animations to make the static page feel alive. + All animations respect prefers-reduced-motion (see bottom of block). */ + +/* (1) Candle flicker on the grimoire pages. + We add a NEW pseudo-element to .grimoire-page (::after) that overlays a + candlelight halo at the top, then pulse its opacity irregularly. + We don't touch .grimoire-page::before (the hand-drawn rule) to avoid + conflicting with its existing transform/positioning. */ +.grimoire-page::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40%; + background: radial-gradient(ellipse 70% 100% at 50% 0%, + rgba(240, 192, 96, 0.18) 0%, + rgba(240, 192, 96, 0.08) 40%, + transparent 75%); + pointer-events: none; + z-index: 0; + opacity: 0.95; + animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate; +} +@keyframes grimoire-candle-flicker { + 0% { opacity: 0.88; } + 23% { opacity: 1.00; } + 47% { opacity: 0.92; } + 71% { opacity: 0.97; } + 88% { opacity: 0.85; } + 100% { opacity: 0.96; } +} +/* Also expose an explicit class hook in case it's added on the element. */ +.grimoire-candle-flicker { + animation: grimoire-candle-flicker 2.5s ease-in-out infinite alternate; +} + +/* (2) Ribbon sway on the grimoire bookmark. + We need to preserve the existing translateX(-50%) positioning of + .oracle-grimoire::before, so we override transform-origin and add + the sway via animation that drives the rotation while keeping the + horizontal centering. */ +.oracle-grimoire::before { + transform-origin: top center; + animation: grimoire-ribbon-sway 4s ease-in-out infinite alternate; +} +@keyframes grimoire-ribbon-sway { + 0% { transform: translateX(-50%) rotate(-2deg); } + 50% { transform: translateX(-50%) rotate(0deg); } + 100% { transform: translateX(-50%) rotate(2deg); } +} + +/* (3) Wax-seal pulse on grimoire progress dots. + Plays once each time a dot's class becomes .done (Gradio re-renders the + inner HTML so the animation fires fresh on each new sealed oracle). */ +.grimoire-progress .dot.done { + display: inline-block; + animation: grimoire-seal-stamp 0.6s ease-out; + transform-origin: center; +} +@keyframes grimoire-seal-stamp { + 0% { transform: scale(1.6) rotate(-8deg); opacity: 0.4; } + 55% { transform: scale(0.9) rotate(3deg); opacity: 1; } + 100% { transform: scale(1) rotate(0deg); opacity: 1; } +} + +/* (4) Sprite hover glow on the trial scene cards. */ +.oracle-image-wrap img { + transition: transform 0.3s ease, filter 0.3s ease, box-shadow 0.3s ease; +} +.oracle-image-wrap img:hover { + transform: scale(1.04); + filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55)) + drop-shadow(0 0 18px rgba(240, 192, 96, 0.25)); +} +.oracle-card[data-dragon="true"] .oracle-image-wrap img:hover { + filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55)) + drop-shadow(0 0 18px rgba(200, 74, 58, 0.25)); +} + +/* (5) Spread entrance fade — DISABLED. + This animation re-fired on every generator yield because Gradio swaps + inner HTML each tick, restarting the keyframes at opacity:0 + + translateX(-12px). The result was a visible 0.5s shift-and-fade on + every button click — what the user described as "all blocks + displaced for a second". The grimoire pages now render statically. */ +@keyframes grimoire-spread-enter { + 0% { opacity: 1; transform: translateX(0); } + 100% { opacity: 1; transform: translateX(0); } +} + +/* (6) Candlelight breathing on the trial scene cards. + Can't style internals of an data-URI SVG, so we breathe the whole + card's brightness/filter instead. Skipped the per-rect twinkle for that + reason — documented in PIVOT_LOG. */ +.oracle-image-wrap img { + /* Atmosphere preserved — keyframes now pin frame 0% and 100% to + the resting visual state and put the variation at 50%. Gradio's + inner-HTML rebuilds on each yield still restart the animation + at 0%, but 0% is now indistinguishable from steady-state, so + there's no visible snap. */ + animation: oracle-card-breathe 5s ease-in-out infinite; +} +@keyframes oracle-card-breathe { + 0% { filter: brightness(1.00) saturate(1.04); } + 50% { filter: brightness(1.06) saturate(1.08); } + 100% { filter: brightness(1.00) saturate(1.04); } +} +/* Don't override the hover filter glow — re-state the hover rule AFTER the + breathing keyframe so :hover wins precedence. */ +.oracle-image-wrap img:hover { + animation: none; + transform: scale(1.04); + filter: drop-shadow(0 0 10px rgba(240, 192, 96, 0.55)) + drop-shadow(0 0 18px rgba(240, 192, 96, 0.25)); +} +.oracle-card[data-dragon="true"] .oracle-image-wrap img:hover { + filter: drop-shadow(0 0 10px rgba(200, 74, 58, 0.55)) + drop-shadow(0 0 18px rgba(200, 74, 58, 0.25)); +} + +/* ---------- Wizard's Daydream — pipeline demo card on step 0 ---------- */ +.oracle-pipeline-demo { + background: linear-gradient(180deg, #2f2638 0%, #251d2e 100%); + border: 4px solid var(--w-amber); + box-shadow: 6px 6px 0 var(--w-shadow); + padding: 18px 22px 14px 22px; + margin: 14px 0 18px 0; + color: var(--w-cream) !important; + position: relative; +} +.oracle-pipeline-demo::before { + /* Thought-bubble tail pointing up from the card's top-left corner, + suggesting these are the wizard's musings drifting up. */ + content: ""; + position: absolute; + top: -10px; left: 26px; + width: 14px; height: 14px; + background: var(--w-panel); + border: 4px solid var(--w-amber); + border-bottom: none; + border-right: none; + transform: rotate(45deg); + z-index: 1; +} +.pipeline-demo-header { + font-family: 'Press Start 2P', monospace !important; + font-size: 11px; + color: var(--w-amber) !important; + text-shadow: 2px 2px 0 var(--w-shadow); + letter-spacing: 1.5px; + margin-bottom: 14px; + text-align: center; +} +.pipeline-demo-header span.float { + /* a small wobbling thought-cloud emoji for charm */ + display: inline-block; + margin-right: 8px; + animation: pipeline-cloud-bob 3.5s ease-in-out infinite alternate; +} +@keyframes pipeline-cloud-bob { + 0% { transform: translateY(0); } + 100% { transform: translateY(-3px); } +} +.pipeline-demo-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} +@media (max-width: 820px) { + .pipeline-demo-grid { grid-template-columns: 1fr; } +} +.pipeline-demo-panel { + /* Bumped opacity 0.30 → 0.72 so the example-usage text on the + front page reads clearly regardless of what scene/texture sits + behind it. Same buffering treatment as the trial narration. */ + background: rgba(20, 14, 30, 0.72); + border: 1px solid rgba(240, 192, 96, 0.25); + border-left: 3px solid var(--w-moss); + padding: 14px 16px; + font-size: 15px; + line-height: 1.55; + box-shadow: 2px 2px 0 rgba(0,0,0,0.4); +} +.pipeline-demo-panel:nth-child(1) { border-left-color: #f0c060; } +.pipeline-demo-panel:nth-child(2) { border-left-color: #7ea687; } +.pipeline-demo-panel:nth-child(3) { border-left-color: #c84a3a; } + +/* ---- Sprite scene + mode row inside each demo panel ---- */ +.pipeline-demo-scene { + position: relative; + width: 100%; + height: 130px; + margin-bottom: 8px; + background: linear-gradient(180deg, + rgba(40, 24, 56, 0.55) 0%, + rgba(40, 24, 56, 0.55) 62%, + /* horizon line */ + rgba(120, 84, 48, 0.85) 64%, + rgba(78, 52, 26, 0.95) 80%, + rgba(40, 24, 8, 1) 100%); + border: 2px solid var(--w-amber); + box-shadow: 2px 2px 0 var(--w-shadow); + overflow: hidden; + image-rendering: pixelated; +} +/* Painted pixel-art ground tile (grass-on-dirt) anchored at the bottom + of the scene. Tiles horizontally so the panel can be any width. The + data URI is injected from a build-time helper so we don't bake an + 800KB image into the static CSS string. */ +.pipeline-demo-scene::after { + content: ""; + position: absolute; + bottom: 0; left: 0; right: 0; + height: 36px; + background-image: var(--ground-tile-uri, none); + background-repeat: repeat-x; + background-size: auto 36px; + background-position: bottom left; + image-rendering: pixelated; + pointer-events: none; + z-index: 0; +} +.pipeline-demo-sprite { + position: absolute; + bottom: 4px; + width: 96px; + height: 96px; + image-rendering: pixelated; + filter: drop-shadow(2px 2px 0 var(--w-shadow)); +} +.pipeline-demo-sprite-foe { left: 6px; transform: scaleX(1); } +.pipeline-demo-sprite-hero { right: 6px; } +.pipeline-demo-mode-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} +.pipeline-demo-mode-label { + font-family: 'VT323', monospace !important; + font-size: 16px; + color: var(--w-amber); + font-style: italic; +} +/* For demo card itself: layer the backdrop image (inline-styled by + _pipeline_demo_html) under the existing gradient so the painted backdrop + reads as ambience but text stays legible. */ +.oracle-pipeline-demo { + background-blend-mode: normal !important; + background-size: cover !important; + background-position: center 30% !important; + background-repeat: no-repeat !important; +} +.oracle-pipeline-demo::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, + rgba(24,20,31,0.25) 0%, + rgba(24,20,31,0.55) 100%); + pointer-events: none; + z-index: 0; +} +.oracle-pipeline-demo > * { position: relative; z-index: 1; } + +.pipeline-demo-step { + margin: 5px 0; + color: var(--w-cream); +} +.pipeline-demo-tag { + font-family: 'Press Start 2P', monospace !important; + font-size: 9px; + color: var(--w-amber) !important; + letter-spacing: 1px; + margin-right: 6px; + display: inline-block; +} +.pipeline-demo-step code { + background: rgba(240,192,96,0.18); + color: var(--w-amber); + padding: 1px 7px; + font-family: 'VT323', monospace; + font-size: 18px; + border-radius: 2px; + border: 1px solid rgba(240,192,96,0.35); +} +.pipeline-demo-resolution { + border-top: 1px dashed var(--w-mute); + padding-top: 8px; + margin-top: 8px; + font-style: italic; + color: var(--w-cream); +} +.pipeline-demo-mode-badge { + display: inline-block; + font-family: 'Press Start 2P', monospace !important; + font-size: 8px; + color: var(--w-shadow); + background: var(--w-amber); + padding: 2px 6px; + margin-left: 6px; + letter-spacing: 1px; + vertical-align: middle; +} +.pipeline-demo-footer { + text-align: center; + font-size: 16px; + color: var(--w-cream); + border-top: 1px solid var(--w-amber); + padding-top: 12px; + margin-top: 6px; + line-height: 1.45; +} +.pipeline-demo-footer strong { + color: var(--w-amber) !important; + font-family: 'Press Start 2P', monospace !important; + font-size: 10px; + letter-spacing: 1.5px; + margin: 0 2px; +} + +/* ---------- Walking-hero processing overlay ---------- + Replaces Gradio's generic spinner with a pixel-art hero crossing a small + horizon strip — gives the LLM-wait a narrative feel ("the hero is + walking to the next obstacle") instead of a tech progress bar. + The data URI for the hero sprite is injected at app startup via a +