"""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 (uses ./run.sh for full setup) """ from __future__ import annotations import base64 import os import sys from urllib.parse import quote as _url_quote 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" def _full_visual_mode(state: Optional["GameState"] = None) -> bool: """Visual-mode toggle. Resolution order: 1. ``state.visual_mode`` if set to "full" or "lean" (per-session override). 2. ``$ORACLES_VISUAL_MODE`` env var (process-wide default). 3. ``lean``. - ``lean``: skip heavy decorative PNGs — parallax banner, grimoire parchment, the four phase backdrops, the scene landscapes, the wizard-desk backdrop, the open-book illustration, and the pipeline-demo card backdrop. ~2-3 MB saved per cold load on HF Space's slow egress bandwidth. - ``full``: enable everything. Use locally where bandwidth is fast, or flip the dropdown on inscribe step 0. """ if state is not None: vm = (getattr(state, "visual_mode", "") or "").strip().lower() if vm in ("full", "lean"): return vm == "full" return os.environ.get("ORACLES_VISUAL_MODE", "lean").strip().lower() == "full" # --------------------------------------------------------------------------- # 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). Skipped entirely in lean mode (default) — the texture is ~70 KB base64-inlined into the initial CSS, which on HF Space's slow egress adds noticeable cold-load time. Set ORACLES_VISUAL_MODE=full to enable. """ if not _full_visual_mode(): return "" 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"} # Gradio file-route URL prefix. Differs between major versions: # - Gradio 4.x → "/file=" # - Gradio 5.x and 6.x → "/gradio_api/file=" # Computed once at module load; cheap to read. try: _GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0]) except Exception: _GRADIO_MAJOR = 5 _FILE_URL_PREFIX = "/file=" if _GRADIO_MAJOR < 5 else "/gradio_api/file=" def _sprite_data_uri_app(name: str) -> str: """Return a URL to the sprite. Used everywhere a CSS ``url(...)`` or ```` needs the sprite. Originally returned a base64 data URI, which baked ~30 sprites worth of binary data straight into the initial HTML response (~1-3 MB) and made the first paint take 3+ minutes on HF Spaces CPU-basic. Now returns the Gradio file route (``/gradio_api/file=``) so the browser fetches each sprite lazily in parallel — initial HTML drops to ~50 KB. Gradio's launch is set up with ``allowed_paths=[assets]`` so the path is servable. Cached so we don't stat the disk on every 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 "" # Gradio's file route prefix depends on the running major version. # The /gradio_api/file= path works on 5+; legacy /file= works on 4.x. # _FILE_URL_PREFIX is computed once at module load based on # gr.__version__. # # URL-encode the path so any spaces or special chars (e.g. the # "CHAI Project" path on the dev machine) survive the trip through # an HTML attribute. safe="/" preserves the path # separators which browsers + Gradio's route both need verbatim. uri = f"{_FILE_URL_PREFIX}{_url_quote(path.as_posix(), safe='/')}" cache[name] = uri return uri def _sprite_data_uri_app_base64(name: str) -> str: """Fallback that base64-encodes a sprite — only used if the file-route approach doesn't work in some environment (e.g. the iframe can't reach /gradio_api/file=). Cached separately so we don't accidentally bake base64 into a path-style call site.""" cache = _sprite_data_uri_app_base64.__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. Cached by (lang, inscribe_step, hero_name, village_name). All four determine the output exactly; nothing else does. On grimoire spread transitions, only ``inscribe_step`` changes, so the cache stays warm. """ lang_code = state.lang or "en" cache_key = ( lang_code, state.inscribe_step, state.hero_name or "Tobin", state.village_name or "the Hollow", ) if cache_key in _GRIMOIRE_LEFT_CACHE: return _GRIMOIRE_LEFT_CACHE[cache_key] raw = _wizard_text_for(lang_code, 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) + "
" ) result = f"
{body}
{marker}{sigil}" _GRIMOIRE_LEFT_CACHE[cache_key] = result return result 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)}
  • ") # Lean mode (default for HF Space): skip the open-book illustration — # the PNG was ~200-300 KB. The heading + numbered list carry the # meaning. Full mode restores the illustration above the list. if _full_visual_mode(state): book_uri = _sprite_data_uri_app("open_book_spread") alt_text = _t("alt_sealed_grimoire", lang) book_img = ( f"" if book_uri else "" ) else: book_img = "" 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. # Lean mode (default for HF Space): skip the demo card painted # backdrop (~200-400 KB). Panels still have CSS borders + shading. # Full mode restores the painted background. if _full_visual_mode(state): bg = _sprite_data_uri_app("demo_card_backdrop") bg_style = f"background-image:url('{bg}');" if bg else "" else: bg_style = "" 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: # Lean mode: drop the in-panel encounter/resolve sprites too. They # were the only image content left in the demo panels after the # painted backdrop was stripped earlier — and a row of small sprites # still costs a half-dozen HTTP requests per page-load. if _full_visual_mode(state): encounter_uri = _sprite_data_uri_app(encounter_sprite) resolve_uri = _sprite_data_uri_app(resolve_sprite) else: encounter_uri = "" resolve_uri = "" 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.' Cached by (theme, lang) — those are the only fields the rendered HTML depends on. Called on every click; caching saves a few hundred μs of string building per transition. """ theme_key_for_sprites = ( getattr(state, "theme", "fantasy") if state else "fantasy" ) or "fantasy" lang_for_cache = _tlang(state) cache_key = (theme_key_for_sprites, lang_for_cache, _full_visual_mode(state)) if cache_key in _INSCRIBE_DECORATION_CACHE: return _INSCRIBE_DECORATION_CACHE[cache_key] from oracles.themes import get_theme as _get_theme 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") ) # Lean mode (default for HF Space): skip the wizard-desk backdrop — # the PNG is ~200-400 KB. Full mode restores it as a fade-behind # layer for the mentor/apprentice figures. desk_uri = _sprite_data_uri_app("wizard_desk_scene") if _full_visual_mode(state) else "" if not wizard_uri or not hero_uri: fallback = ( "
    " "(sprites not yet generated — run " "modal run oracles_app/modal_backend/modal_klein_oracles.py)" "
    " ) _INSCRIBE_DECORATION_CACHE[cache_key] = fallback return fallback 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)) result = ( 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}
    " "
    " "
    " ) _INSCRIBE_DECORATION_CACHE[cache_key] = result return result # --------------------------------------------------------------------------- # Memoization caches for HTML helpers that are recomputed on every click. # Each helper is a pure function of a small slice of state (theme + lang + # mode at most). Caching by the relevant tuple turns repeated identical # calls into a dict lookup. On a 6-theme × 2-lang × 5-mode app, the caches # saturate at ~60 entries — bounded, no eviction needed. # --------------------------------------------------------------------------- _BANNER_HTML_CACHE: dict = {} _THEME_STYLE_CACHE: dict = {} _INSCRIBE_DECORATION_CACHE: dict = {} _LOCALIZED_CHROME_CACHE: dict = {} _GRIMOIRE_LEFT_CACHE: dict = {} _BADGE_CACHE: dict = {} def _banner_html(state: Optional[GameState] = None) -> str: """Top-of-page parallax banner with the game title overlaid. Cached by (theme, lang, mode) — these are the only fields the rendered HTML depends on. The bundle re-runs this helper on every click; without caching, each call rebuilds the same string. Cache hit is a dict lookup; cache miss recomputes. """ theme_key = getattr(state, "theme", "fantasy") if state else "fantasy" lang = _tlang(state) mode = getattr(state, "mode", "inscribe") if state else "inscribe" vm_full = _full_visual_mode(state) cache_key = (theme_key, lang, mode, vm_full) if cache_key in _BANNER_HTML_CACHE: return _BANNER_HTML_CACHE[cache_key] from oracles.themes import get_theme as _get_theme theme = _get_theme(theme_key) # Game-wide title — every theme is a chapter under the same banner. 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) ) 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). is_compact = mode in ("trial", "epilogue", "send_off") extra_cls = " oracle-banner-compact" if is_compact else "" # Lean mode (default for HF Space): use a CSS gradient instead of the # ~455 KB parallax PNG. Full mode loads the PNG via Gradio's file # route so the browser caches it after the first request. if _full_visual_mode(state) and _PARALLAX_PNG.exists(): src = f"{_FILE_URL_PREFIX}{_url_quote(_PARALLAX_PNG.as_posix(), safe='/')}" result = ( f"
    " f"banner" "
    " f"
    {title}
    " f"
    {subtitle}
    " "
    " ) else: result = ( f"
    " "
    " f"
    {title}
    " f"
    {subtitle}
    " "
    " ) _BANNER_HTML_CACHE[cache_key] = result return result 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 ``" _THEME_STYLE_CACHE[theme_key] = result return result # --------------------------------------------------------------------------- # CSS # --------------------------------------------------------------------------- CSS = """ /* Fonts are loaded via the theme's GoogleFont() declarations (Press Start 2P + VT323), not via @import. Gradio injects user CSS as a constructed stylesheet which doesn't allow @import — leaving the @import in here spams the browser console with "@import rules are not allowed here". */ :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; } /* The trial-await "He has N oracles left" status line is rendered as a bare .oracle-narration (no scene image, no .oracle-bg). Gradio's host CSS has at times beaten the base rule above on cascade, leaving the text floating on the raw scene. Lock the backdrop with !important so the status line always reads against a dark panel. Scoped via :not() so the .oracle-bg case (which provides its own backdrop via ::before) is untouched. */ .oracle-narration:not(.oracle-bg) { background-color: rgba(20, 14, 30, 0.85) !important; border: 1px solid rgba(240, 192, 96, 0.28) !important; padding: 14px 18px !important; box-shadow: 0 0 22px rgba(0, 0, 0, 0.40) inset !important; } .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