VirusDumb commited on
Commit
a1afbca
·
1 Parent(s): 409ea2e

First pass

Browse files
Files changed (11) hide show
  1. .gitignore +71 -0
  2. README.md +19 -0
  3. app.py +55 -0
  4. images.py +87 -0
  5. llm.py +97 -0
  6. requirements.txt +15 -0
  7. schema.py +189 -0
  8. static/app.js +232 -0
  9. static/index.html +102 -0
  10. static/storage.js +82 -0
  11. static/style.css +171 -0
.gitignore ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- Internal planning / memory (do NOT push to the HF remote) ----
2
+ secretplans/
3
+ AGENTS.md
4
+ CLAUDE.md
5
+
6
+ # ---- Secrets / env ----
7
+ .env
8
+ .env.*
9
+ *.env
10
+ !.env.example # keep the template tracked
11
+
12
+ # ---- Local databases: session history (SQLite) + game-patterns vector store (LanceDB) ----
13
+ db/
14
+ *.sqlite
15
+ *.sqlite3
16
+
17
+ # ---- Python ----
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+ *.so
22
+ .Python
23
+ build/
24
+ dist/
25
+ develop-eggs/
26
+ downloads/
27
+ eggs/
28
+ .eggs/
29
+ *.egg-info/
30
+ *.egg
31
+ .installed.cfg
32
+ wheels/
33
+ pip-wheel-metadata/
34
+
35
+ # Virtual environments
36
+ .venv/
37
+ venv/
38
+ env/
39
+ ENV/
40
+ .python-version
41
+
42
+ # Test / type / lint caches
43
+ .pytest_cache/
44
+ .mypy_cache/
45
+ .ruff_cache/
46
+ .coverage
47
+ htmlcov/
48
+ .tox/
49
+
50
+ # Notebooks
51
+ .ipynb_checkpoints/
52
+
53
+ # ---- Gradio ----
54
+ .gradio/
55
+ flagged/
56
+ gradio_cached_examples/
57
+
58
+ # ---- App runtime artifacts ----
59
+ # Generated adventures / sites, model caches, local DBs
60
+ generated_sites/
61
+ adventures/
62
+ *.gguf
63
+ *.sqlite
64
+ *.db
65
+
66
+ # ---- OS / editor ----
67
+ .DS_Store
68
+ Thumbs.db
69
+ .idea/
70
+ .vscode/
71
+ *.swp
README.md CHANGED
@@ -13,3 +13,22 @@ short_description: A text based RPG built to help you fight procrastination
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
+
17
+ ## FrogQuest
18
+
19
+ Turn your real to-do list into an 8-bit pixel-art text-adventure quest log where **you** are the
20
+ hero. Named for the "Eat That Frog" method: your hardest/most important task is the **frog** — the
21
+ boss quest you face first.
22
+
23
+ - **Nemotron Nano 4B** (text only, via llama.cpp on ZeroGPU) writes the quest JSON — Eat That Frog
24
+ prioritization, linked sub-task chains, and optional self-care bonus quests.
25
+ - A fixed pixel-art frontend (`gr.Server` custom UI) renders that JSON as the quest log.
26
+ - **FLUX.2 [klein] 4B** (diffusers) generates each quest's image using your uploaded photo as a
27
+ reference, then edits that same image into success / "retreat to fight another day" states.
28
+
29
+ Your photo and quest state live **only in your browser** (localStorage); the photo is sent to the
30
+ GPU transiently for generation and is never stored server-side.
31
+
32
+ **Set the Space hardware to ZeroGPU.** First quest generation cold-downloads the model GGUF
33
+ (~60-90s), then runs fast.
34
+
app.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FrogQuest server: gr.Server custom frontend + queued API endpoints (Off-Brand badge).
2
+
3
+ The browser owns all state (localStorage, see static/storage.js). This server is stateless:
4
+ it serves the pixel-art frontend and exposes two GPU-backed endpoints. The user's photo is
5
+ sent transiently to generate_image and is never written to disk.
6
+
7
+ Frontend calls endpoints via the Gradio JS Client:
8
+ client.predict("/generate_quests", { todos, theme })
9
+ client.predict("/generate_image", { photo_b64, art_style, scene_prompt, seed })
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+
16
+ from fastapi.responses import HTMLResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from gradio import Server
19
+
20
+ from schema import validate_and_clamp
21
+
22
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
23
+
24
+ app = Server()
25
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
26
+
27
+
28
+ @app.get("/", response_class=HTMLResponse)
29
+ async def homepage():
30
+ with open(os.path.join(STATIC_DIR, "index.html"), "r", encoding="utf-8") as f:
31
+ return f.read()
32
+
33
+
34
+ @app.api(name="generate_quests")
35
+ def generate_quests(todos: str, theme: str) -> str:
36
+ """Nemotron -> validated quest JSON (returned as a JSON string)."""
37
+ from llm import generate_quests_raw
38
+
39
+ raw = generate_quests_raw(todos, theme)
40
+ adventure = validate_and_clamp(raw, theme)
41
+ return json.dumps(adventure)
42
+
43
+
44
+ @app.api(name="generate_image")
45
+ def generate_image(photo_b64: str, art_style: str, scene_prompt: str, seed: int) -> str:
46
+ """FLUX.2 klein initial image with the user as hero -> PNG data URL."""
47
+ from images import b64_to_pil, initial_image, pil_to_data_url
48
+
49
+ photo = b64_to_pil(photo_b64)
50
+ img = initial_image(photo, art_style, scene_prompt, int(seed))
51
+ return pil_to_data_url(img)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ app.launch(show_error=True)
images.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FLUX.2 [klein] 4B image pipeline (diffusers, ZeroGPU).
2
+
3
+ One model, one `image=` parameter does both jobs:
4
+ - initial generation: image=[user_photo] (a LIST -> multi-reference; puts the user in the scene)
5
+ - edit (next pass): image=base_image (a single image -> instruction-guided edit)
6
+
7
+ Shared art_style + a fixed per-adventure seed give every quest a cohesive look. Always EDIT
8
+ the initial image for success/failure states (consistency) - never text-to-image from scratch.
9
+
10
+ Pipeline loaded at module level; CUDA emulation makes that safe outside @spaces.GPU. The
11
+ actual .to("cuda") + inference happen inside the GPU-decorated functions.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import io
17
+
18
+ import spaces
19
+ import torch
20
+ from diffusers import Flux2KleinPipeline
21
+ from PIL import Image
22
+
23
+ MODEL_ID = "black-forest-labs/FLUX.2-klein-4B"
24
+ # TODO(verify at build): klein's recommended fast step count. Default schema is 50; klein is
25
+ # distilled for low latency, so this is intentionally lower. Tune against the model card.
26
+ STEPS = 8
27
+ GUIDANCE = 4.0
28
+ MAX_SIDE = 768 # cap generated resolution for ZeroGPU speed
29
+
30
+ _pipe = None
31
+
32
+
33
+ def _get_pipe():
34
+ global _pipe
35
+ if _pipe is None:
36
+ _pipe = Flux2KleinPipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
37
+ return _pipe
38
+
39
+
40
+ def _gen(prompt: str, image, seed: int) -> Image.Image:
41
+ pipe = _get_pipe()
42
+ pipe.to("cuda")
43
+ generator = torch.Generator("cuda").manual_seed(int(seed))
44
+ result = pipe(
45
+ prompt=prompt,
46
+ image=image,
47
+ generator=generator,
48
+ num_inference_steps=STEPS,
49
+ guidance_scale=GUIDANCE,
50
+ height=MAX_SIDE,
51
+ width=MAX_SIDE,
52
+ )
53
+ return result.images[0]
54
+
55
+
56
+ @spaces.GPU(duration=120)
57
+ def initial_image(user_photo: Image.Image, art_style: str, scene_prompt: str, seed: int) -> Image.Image:
58
+ """Generate the quest's initial scene with the user as the hero (photo as reference)."""
59
+ prompt = (
60
+ f"{art_style}. {scene_prompt}. "
61
+ "The hero is the person shown in the reference image, in this style and scene."
62
+ )
63
+ return _gen(prompt, image=[user_photo], seed=seed)
64
+
65
+
66
+ @spaces.GPU(duration=120)
67
+ def edit_image(base_image: Image.Image, edit_instruction: str, art_style: str, seed: int) -> Image.Image:
68
+ """NEXT PASS (priority 5): edit the existing image into a success/failure state."""
69
+ prompt = f"{art_style}. {edit_instruction}"
70
+ return _gen(prompt, image=base_image, seed=seed)
71
+
72
+
73
+ # --- base64 <-> PIL helpers (photo arrives transiently as a data URL; nothing is persisted) ---
74
+
75
+ def b64_to_pil(data_url_or_b64: str) -> Image.Image:
76
+ s = data_url_or_b64
77
+ if "," in s and s.strip().startswith("data:"):
78
+ s = s.split(",", 1)[1]
79
+ raw = base64.b64decode(s)
80
+ return Image.open(io.BytesIO(raw)).convert("RGB")
81
+
82
+
83
+ def pil_to_data_url(img: Image.Image) -> str:
84
+ buf = io.BytesIO()
85
+ img.save(buf, format="PNG")
86
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
87
+ return f"data:image/png;base64,{b64}"
llm.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron Nano 4B (text-only) -> raw quest JSON, run via llama.cpp on ZeroGPU.
2
+
3
+ Recipe (CLAUDE.md + ZeroGPU/llama.cpp reference): set HF_HUB_ENABLE_HF_TRANSFER before
4
+ importing huggingface_hub, download the GGUF at module level, but construct the Llama
5
+ object INSIDE the @spaces.GPU function (first call ~60-90s, then disk-cached & fast).
6
+
7
+ The LLM's job is ONLY to write JSON to the contract in schema.py. Output is constrained
8
+ with a JSON-schema response_format and then validated/clamped by the caller.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+
15
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") # MUST precede huggingface_hub import
16
+
17
+ import spaces # noqa: E402
18
+ from huggingface_hub import hf_hub_download # noqa: E402
19
+
20
+ from schema import RESPONSE_SCHEMA # noqa: E402
21
+
22
+ # TODO(verify at build): confirm exact GGUF repo/filename on HF. Llama-3.1-Nemotron is
23
+ # Llama-architecture -> keeps Llama Champion eligibility. Q4_K_M is the recommended quant.
24
+ GGUF_REPO = "unsloth/Llama-3.1-Nemotron-Nano-4B-v1.1-GGUF"
25
+ GGUF_FILE = "Llama-3.1-Nemotron-Nano-4B-v1.1-Q4_K_M.gguf"
26
+
27
+ N_CTX = 8192
28
+
29
+ SYSTEM_PROMPT = """You are FrogQuest's quest designer. Convert the user's real to-do list into a themed text-adventure quest log and OUTPUT JSON ONLY - no prose, no markdown, no code.
30
+
31
+ Apply the "Eat That Frog" method:
32
+ - The FROG = the single most important/hardest task. Mark exactly ONE quest is_frog:true and order it FIRST.
33
+ - Break each big/multi-step goal into an ordered chain of smaller quests sharing one goal_group label; keep simple to-dos as standalone quests.
34
+ - Add 1-3 bonus self-care quests (type:"bonus") such as meditate 5 min, exercise 20 min, digital detox 1 hr. They are OPTIONAL and ENCOURAGING - never guilt-inducing.
35
+
36
+ For EVERY quest write vivid {theme}-themed, 8-bit pixel-art image instructions where the USER is the hero:
37
+ - initial_image_prompt: the hero facing the challenge (scene only - the renderer adds the user's face from a photo; do NOT describe their face).
38
+ - success_edit: a SHORT edit instruction turning the initial scene victorious (same hero, same scene).
39
+ - failure_edit: a SHORT, FORGIVING edit instruction - the hero retreats to fight another day. Never shaming.
40
+
41
+ Set adventure.art_style to one shared "8-bit / 16-bit pixel-art, {theme} palette" string applied to every image, and adventure.seed to a single integer for the whole adventure. xp 10-100 by effort. All status:"active", image_state:"initial". Echo the user's real wording in each quest.task.
42
+ /no_think"""
43
+
44
+ _llm = None
45
+
46
+
47
+ def _get_llm():
48
+ """Lazily construct the Llama model on the GPU (must run inside @spaces.GPU)."""
49
+ global _llm
50
+ if _llm is None:
51
+ from llama_cpp import Llama
52
+
53
+ model_path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE)
54
+ _llm = Llama(
55
+ model_path=model_path,
56
+ n_gpu_layers=-1, # offload all layers to GPU
57
+ n_ctx=N_CTX,
58
+ verbose=False,
59
+ )
60
+ return _llm
61
+
62
+
63
+ @spaces.GPU(duration=120)
64
+ def generate_quests_raw(todos: str, theme: str) -> dict:
65
+ """Return the model's raw JSON object (UNVALIDATED - caller must validate_and_clamp)."""
66
+ llm = _get_llm()
67
+ system = SYSTEM_PROMPT.replace("{theme}", theme)
68
+ user = f"Theme: {theme}\nMy to-do list / goals:\n{todos.strip()}"
69
+
70
+ out = llm.create_chat_completion(
71
+ messages=[
72
+ {"role": "system", "content": system},
73
+ {"role": "user", "content": user},
74
+ ],
75
+ response_format={"type": "json_object", "schema": RESPONSE_SCHEMA},
76
+ temperature=0.0,
77
+ max_tokens=4096,
78
+ )
79
+ content = out["choices"][0]["message"]["content"]
80
+ return _extract_json(content)
81
+
82
+
83
+ def _extract_json(text: str) -> dict:
84
+ """Parse JSON from the model output, tolerating stray prose or code fences."""
85
+ text = (text or "").strip()
86
+ try:
87
+ return json.loads(text)
88
+ except json.JSONDecodeError:
89
+ pass
90
+ # Fallback: grab the outermost { ... } span.
91
+ start, end = text.find("{"), text.rfind("}")
92
+ if start != -1 and end != -1 and end > start:
93
+ try:
94
+ return json.loads(text[start : end + 1])
95
+ except json.JSONDecodeError:
96
+ pass
97
+ return {}
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FrogQuest — HF Space (ZeroGPU) dependencies
2
+ # NOTE: Flux2KleinPipeline lands in diffusers >= 0.38; install from git until the release wheel
3
+ # exposing it is confirmed on the index.
4
+ git+https://github.com/huggingface/diffusers.git
5
+ gradio>=5.0
6
+ spaces
7
+ transformers>=4.44.2
8
+ accelerate
9
+ torch
10
+ pillow
11
+ hf-transfer
12
+ huggingface-hub
13
+ # llama-cpp-python with prebuilt CUDA wheels. cu128 = RTX Pro 6000 Blackwell (ZeroGPU).
14
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu128
15
+ llama-cpp-python
schema.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FrogQuest data contract: the JSON schema the LLM emits + server-side validate/clamp.
2
+
3
+ The LLM only ever writes DATA to this contract. Nothing it returns is trusted: every
4
+ field is validated and clamped here before the frontend renders it, so a malformed or
5
+ missing field can never break a quest card. See CLAUDE.md "Data contract".
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import random
10
+ import re
11
+ from typing import Any
12
+
13
+ THEMES = ("cyberpunk", "fantasy", "space")
14
+ QUEST_TYPES = ("main", "bonus")
15
+ STATUSES = ("active", "success", "failure")
16
+ IMAGE_STATES = ("initial", "success", "failure")
17
+
18
+ MAX_QUESTS = 12
19
+ MAX_BONUS = 3
20
+
21
+ # JSON Schema passed to llama-cpp-python via response_format to constrain generation.
22
+ # Kept deliberately permissive (the strict guarantees are enforced by validate_and_clamp,
23
+ # not the grammar) because GBNF-from-schema chokes on overly nested constraints.
24
+ RESPONSE_SCHEMA: dict[str, Any] = {
25
+ "type": "object",
26
+ "properties": {
27
+ "adventure": {
28
+ "type": "object",
29
+ "properties": {
30
+ "title": {"type": "string"},
31
+ "theme": {"type": "string", "enum": list(THEMES)},
32
+ "art_style": {"type": "string"},
33
+ "seed": {"type": "integer"},
34
+ },
35
+ "required": ["title", "theme", "art_style", "seed"],
36
+ },
37
+ "quests": {
38
+ "type": "array",
39
+ "items": {
40
+ "type": "object",
41
+ "properties": {
42
+ "id": {"type": "string"},
43
+ "task": {"type": "string"},
44
+ "quest_title": {"type": "string"},
45
+ "narrative": {"type": "string"},
46
+ "type": {"type": "string", "enum": list(QUEST_TYPES)},
47
+ "goal_group": {"type": "string"},
48
+ "is_frog": {"type": "boolean"},
49
+ "initial_image_prompt": {"type": "string"},
50
+ "success_edit": {"type": "string"},
51
+ "failure_edit": {"type": "string"},
52
+ "xp": {"type": "integer"},
53
+ },
54
+ "required": [
55
+ "task", "quest_title", "narrative", "type",
56
+ "is_frog", "initial_image_prompt", "success_edit",
57
+ "failure_edit", "xp",
58
+ ],
59
+ },
60
+ },
61
+ },
62
+ "required": ["adventure", "quests"],
63
+ }
64
+
65
+
66
+ def _s(value: Any, default: str = "") -> str:
67
+ """Coerce to a stripped string."""
68
+ if value is None:
69
+ return default
70
+ if isinstance(value, str):
71
+ return value.strip()
72
+ return str(value).strip()
73
+
74
+
75
+ def _clamp_int(value: Any, lo: int, hi: int, default: int) -> int:
76
+ try:
77
+ n = int(value)
78
+ except (TypeError, ValueError):
79
+ return default
80
+ return max(lo, min(hi, n))
81
+
82
+
83
+ def _slugify(text: str, fallback: str) -> str:
84
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
85
+ return slug or fallback
86
+
87
+
88
+ def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
89
+ """Turn whatever the LLM returned into a safe, render-ready adventure object.
90
+
91
+ Guarantees on return:
92
+ - adventure has title/theme/art_style/seed (int)
93
+ - 1..MAX_QUESTS quests, each with all schema fields and unique non-empty id
94
+ - exactly one is_frog quest, and it is ordered FIRST
95
+ - <= MAX_BONUS bonus quests
96
+ - type/status/image_state are valid enum values; xp in 0..100
97
+ """
98
+ theme = theme if theme in THEMES else THEMES[0]
99
+ raw = raw if isinstance(raw, dict) else {}
100
+
101
+ adv_in = raw.get("adventure") if isinstance(raw.get("adventure"), dict) else {}
102
+ seed = adv_in.get("seed")
103
+ try:
104
+ seed = int(seed)
105
+ except (TypeError, ValueError):
106
+ seed = random.randint(0, 2**31 - 1)
107
+
108
+ adv_theme = adv_in.get("theme")
109
+ adv_theme = adv_theme if adv_theme in THEMES else theme
110
+
111
+ adventure = {
112
+ "title": _s(adv_in.get("title")) or "Your Quest Log",
113
+ "theme": adv_theme,
114
+ "art_style": _s(adv_in.get("art_style"))
115
+ or f"8-bit / 16-bit retro pixel art, {adv_theme} palette, NES RPG style",
116
+ "seed": seed,
117
+ }
118
+
119
+ quests_in = raw.get("quests")
120
+ quests_in = quests_in if isinstance(quests_in, list) else []
121
+
122
+ cleaned: list[dict[str, Any]] = []
123
+ seen_ids: set[str] = set()
124
+ bonus_count = 0
125
+
126
+ for idx, q in enumerate(quests_in):
127
+ if len(cleaned) >= MAX_QUESTS:
128
+ break
129
+ if not isinstance(q, dict):
130
+ continue
131
+
132
+ qtype = q.get("type")
133
+ qtype = qtype if qtype in QUEST_TYPES else "main"
134
+ if qtype == "bonus":
135
+ if bonus_count >= MAX_BONUS:
136
+ qtype = "main" # demote excess bonus quests to main
137
+ else:
138
+ bonus_count += 1
139
+
140
+ title = _s(q.get("quest_title")) or _s(q.get("task")) or f"Quest {idx + 1}"
141
+ qid = _s(q.get("id"))
142
+ if not qid or qid in seen_ids:
143
+ qid = _slugify(title, f"quest-{idx + 1}")
144
+ base, n = qid, 2
145
+ while qid in seen_ids:
146
+ qid, n = f"{base}-{n}", n + 1
147
+ seen_ids.add(qid)
148
+
149
+ cleaned.append({
150
+ "id": qid,
151
+ "task": _s(q.get("task")) or title,
152
+ "quest_title": title,
153
+ "narrative": _s(q.get("narrative")),
154
+ "type": qtype,
155
+ "goal_group": _s(q.get("goal_group")) or None,
156
+ "is_frog": bool(q.get("is_frog")),
157
+ "initial_image_prompt": _s(q.get("initial_image_prompt")),
158
+ "success_edit": _s(q.get("success_edit")) or "Show the hero victorious.",
159
+ "failure_edit": _s(q.get("failure_edit"))
160
+ or "The hero retreats safely to rest and try again another day.",
161
+ "xp": _clamp_int(q.get("xp"), 0, 100, 25),
162
+ # fresh-generation runtime state (never trusted from the model)
163
+ "status": "active",
164
+ "image_state": "initial",
165
+ })
166
+
167
+ if not cleaned:
168
+ # Degenerate output: synthesise a single placeholder so the UI still renders.
169
+ cleaned.append({
170
+ "id": "quest-1", "task": "Add your first task", "quest_title": "The Journey Begins",
171
+ "narrative": "Tell the oracle your goals to fill this log.", "type": "main",
172
+ "goal_group": None, "is_frog": True, "initial_image_prompt": "",
173
+ "success_edit": "Show the hero victorious.",
174
+ "failure_edit": "The hero retreats to try again.", "xp": 10,
175
+ "status": "active", "image_state": "initial",
176
+ })
177
+
178
+ # Enforce exactly one frog, ordered first. Prefer a main quest flagged by the model.
179
+ frog_idx = next((i for i, q in enumerate(cleaned) if q["is_frog"] and q["type"] == "main"), None)
180
+ if frog_idx is None:
181
+ frog_idx = next((i for i, q in enumerate(cleaned) if q["is_frog"]), None)
182
+ if frog_idx is None:
183
+ frog_idx = next((i for i, q in enumerate(cleaned) if q["type"] == "main"), 0)
184
+ for i, q in enumerate(cleaned):
185
+ q["is_frog"] = (i == frog_idx)
186
+ frog = cleaned.pop(frog_idx)
187
+ cleaned.insert(0, frog)
188
+
189
+ return {"adventure": adventure, "quests": cleaned}
static/app.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FrogQuest frontend flow. The browser owns all state; the server is two stateless endpoints.
2
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/+esm";
3
+ import * as store from "/static/storage.js";
4
+
5
+ const $ = (sel) => document.querySelector(sel);
6
+ const $$ = (sel) => document.querySelectorAll(sel);
7
+
8
+ let state = store.load();
9
+ let pendingPhoto = state.profile.photo; // staged during onboarding
10
+ let pendingTheme = state.profile.theme;
11
+ let _client = null;
12
+
13
+ async function client() {
14
+ if (!_client) _client = await Client.connect(window.location.origin);
15
+ return _client;
16
+ }
17
+
18
+ function setTheme(theme) {
19
+ document.documentElement.dataset.theme = theme || "cyberpunk";
20
+ }
21
+
22
+ function show(id) {
23
+ ["screen-onboard", "screen-app"].forEach((s) => ($("#" + s).hidden = s !== id));
24
+ $("#settings-btn").hidden = id !== "screen-app";
25
+ }
26
+
27
+ function overlay(msg) {
28
+ $("#overlay-msg").textContent = msg;
29
+ $("#overlay").hidden = false;
30
+ }
31
+ function hideOverlay() {
32
+ $("#overlay").hidden = true;
33
+ }
34
+
35
+ // ---------------- onboarding ----------------
36
+
37
+ function refreshBeginEnabled() {
38
+ $("#begin-btn").disabled = !(pendingPhoto && pendingTheme);
39
+ }
40
+
41
+ function initOnboarding() {
42
+ setTheme(pendingTheme);
43
+
44
+ if (pendingPhoto) {
45
+ $("#photo-preview").src = pendingPhoto;
46
+ $("#photo-preview").hidden = false;
47
+ $("#photo-placeholder").hidden = true;
48
+ }
49
+ $$("#theme-grid .theme-card").forEach((c) =>
50
+ c.classList.toggle("selected", c.dataset.theme === pendingTheme)
51
+ );
52
+
53
+ $("#photo-input").addEventListener("change", async (e) => {
54
+ const file = e.target.files[0];
55
+ if (!file) return;
56
+ try {
57
+ pendingPhoto = await store.resizePhoto(file);
58
+ $("#photo-preview").src = pendingPhoto;
59
+ $("#photo-preview").hidden = false;
60
+ $("#photo-placeholder").hidden = true;
61
+ refreshBeginEnabled();
62
+ } catch {
63
+ alert("Could not read that image — try another file.");
64
+ }
65
+ });
66
+
67
+ $$("#theme-grid .theme-card").forEach((card) =>
68
+ card.addEventListener("click", () => {
69
+ pendingTheme = card.dataset.theme;
70
+ setTheme(pendingTheme);
71
+ $$("#theme-grid .theme-card").forEach((c) =>
72
+ c.classList.toggle("selected", c === card)
73
+ );
74
+ refreshBeginEnabled();
75
+ })
76
+ );
77
+
78
+ $("#begin-btn").addEventListener("click", () => {
79
+ state = store.update({ profile: { photo: pendingPhoto, theme: pendingTheme } });
80
+ enterApp();
81
+ });
82
+
83
+ refreshBeginEnabled();
84
+ show("screen-onboard");
85
+ }
86
+
87
+ // ---------------- main app ----------------
88
+
89
+ function enterApp() {
90
+ setTheme(state.profile.theme);
91
+ show("screen-app");
92
+ if (state.adventure && state.quests.length) {
93
+ renderAdventure();
94
+ }
95
+ }
96
+
97
+ async function onGenerate() {
98
+ const todos = $("#todos-input").value.trim();
99
+ if (!todos) {
100
+ $("#todos-input").focus();
101
+ return;
102
+ }
103
+ overlay("THE ORACLE IS WRITING YOUR QUEST LOG…");
104
+ try {
105
+ const c = await client();
106
+ const res = await c.predict("/generate_quests", {
107
+ todos,
108
+ theme: state.profile.theme,
109
+ });
110
+ const adventure = JSON.parse(res.data[0]);
111
+ state = store.update({
112
+ adventure: adventure.adventure,
113
+ quests: adventure.quests,
114
+ images: {}, // new adventure -> drop old image cache
115
+ });
116
+ renderAdventure();
117
+ } catch (err) {
118
+ console.error(err);
119
+ alert("The oracle stumbled. Please try again.\n\n" + (err?.message || err));
120
+ } finally {
121
+ hideOverlay();
122
+ }
123
+ }
124
+
125
+ function renderAdventure() {
126
+ const { adventure, quests, images } = state;
127
+ $("#adventure-header").hidden = false;
128
+ $("#adventure-title").textContent = adventure.title || "Your Quest Log";
129
+
130
+ const log = $("#quest-log");
131
+ log.innerHTML = "";
132
+ const tpl = $("#quest-card-tpl");
133
+
134
+ quests.forEach((q) => {
135
+ const node = tpl.content.firstElementChild.cloneNode(true);
136
+ node.dataset.questId = q.id;
137
+ if (q.is_frog) node.classList.add("is-frog");
138
+ if (q.type === "bonus") node.classList.add("is-bonus");
139
+
140
+ node.querySelector(".frog-badge").hidden = !q.is_frog;
141
+ node.querySelector(".bonus-badge").hidden = q.type !== "bonus";
142
+ const group = node.querySelector(".group-badge");
143
+ if (q.goal_group) {
144
+ group.hidden = false;
145
+ group.textContent = "⛓ " + q.goal_group;
146
+ }
147
+
148
+ node.querySelector(".quest-title").textContent = q.quest_title;
149
+ node.querySelector(".quest-narrative").textContent = q.narrative;
150
+ node.querySelector(".quest-task").textContent = q.task;
151
+ node.querySelector(".xp").textContent = q.xp + " XP";
152
+
153
+ const img = node.querySelector(".quest-img");
154
+ const empty = node.querySelector(".quest-art-empty");
155
+ const cached = images[q.id]?.initial;
156
+ if (cached) {
157
+ img.src = cached;
158
+ img.hidden = false;
159
+ empty.hidden = true;
160
+ } else {
161
+ node
162
+ .querySelector(".gen-img-btn")
163
+ .addEventListener("click", () => generateScene(q, node));
164
+ }
165
+
166
+ log.appendChild(node);
167
+ });
168
+ }
169
+
170
+ async function generateScene(quest, node) {
171
+ if (!state.profile.photo) {
172
+ alert("Add a photo in Settings first so you can star in the scene.");
173
+ return;
174
+ }
175
+ const btn = node.querySelector(".gen-img-btn");
176
+ btn.disabled = true;
177
+ btn.textContent = "DRAWING…";
178
+ try {
179
+ const c = await client();
180
+ const res = await c.predict("/generate_image", {
181
+ photo_b64: state.profile.photo,
182
+ art_style: state.adventure.art_style,
183
+ scene_prompt: quest.initial_image_prompt,
184
+ seed: state.adventure.seed,
185
+ });
186
+ const dataUrl = res.data[0];
187
+ state = store.cacheImage(quest.id, "initial", dataUrl);
188
+ const img = node.querySelector(".quest-img");
189
+ img.src = dataUrl;
190
+ img.hidden = false;
191
+ node.querySelector(".quest-art-empty").hidden = true;
192
+ } catch (err) {
193
+ console.error(err);
194
+ btn.disabled = false;
195
+ btn.textContent = "RETRY SCENE";
196
+ alert("The artist's pixels jammed. Try again.\n\n" + (err?.message || err));
197
+ }
198
+ }
199
+
200
+ // ---------------- settings ----------------
201
+
202
+ function initSettings() {
203
+ $("#settings-btn").addEventListener("click", () => {
204
+ pendingPhoto = state.profile.photo;
205
+ pendingTheme = state.profile.theme;
206
+ // re-seed onboarding UI with current values
207
+ $("#photo-preview").src = pendingPhoto || "";
208
+ $("#photo-preview").hidden = !pendingPhoto;
209
+ $("#photo-placeholder").hidden = !!pendingPhoto;
210
+ $$("#theme-grid .theme-card").forEach((c) =>
211
+ c.classList.toggle("selected", c.dataset.theme === pendingTheme)
212
+ );
213
+ refreshBeginEnabled();
214
+ show("screen-onboard");
215
+ });
216
+ }
217
+
218
+ // ---------------- boot ----------------
219
+
220
+ function boot() {
221
+ $("#generate-btn").addEventListener("click", onGenerate);
222
+ initSettings();
223
+ initOnboarding();
224
+
225
+ if (state.profile.photo && state.profile.theme) {
226
+ enterApp();
227
+ } else {
228
+ show("screen-onboard");
229
+ }
230
+ }
231
+
232
+ boot();
static/index.html ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="cyberpunk">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>FrogQuest</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
10
+ <link rel="stylesheet" href="/static/style.css" />
11
+ </head>
12
+ <body>
13
+ <div class="scanlines" aria-hidden="true"></div>
14
+
15
+ <header class="topbar">
16
+ <h1 class="logo">FROG<span>QUEST</span></h1>
17
+ <button id="settings-btn" class="pix-btn small" hidden>SETTINGS</button>
18
+ </header>
19
+
20
+ <!-- FIRST RUN: photo + theme -->
21
+ <main id="screen-onboard" class="screen" hidden>
22
+ <div class="panel">
23
+ <h2 class="panel-title">NEW HERO</h2>
24
+ <p class="hint">Upload a photo so you become the hero of every quest. It stays in your
25
+ browser — sent to the GPU only to draw your scenes, never stored on a server.</p>
26
+
27
+ <label class="uploader" id="photo-drop">
28
+ <input type="file" id="photo-input" accept="image/*" hidden />
29
+ <img id="photo-preview" alt="" hidden />
30
+ <span id="photo-placeholder">▣ CLICK TO UPLOAD PHOTO</span>
31
+ </label>
32
+
33
+ <h3 class="panel-subtitle">CHOOSE YOUR WORLD</h3>
34
+ <div class="theme-grid" id="theme-grid">
35
+ <button class="theme-card" data-theme="cyberpunk">
36
+ <span class="theme-emoji">🌃</span><span>CYBERPUNK</span>
37
+ </button>
38
+ <button class="theme-card" data-theme="fantasy">
39
+ <span class="theme-emoji">🏰</span><span>FANTASY</span>
40
+ </button>
41
+ <button class="theme-card" data-theme="space">
42
+ <span class="theme-emoji">🚀</span><span>SPACE</span>
43
+ </button>
44
+ </div>
45
+
46
+ <button id="begin-btn" class="pix-btn primary" disabled>BEGIN ADVENTURE ▶</button>
47
+ </div>
48
+ </main>
49
+
50
+ <!-- MAIN: goals -> quest log -->
51
+ <main id="screen-app" class="screen" hidden>
52
+ <section class="panel compose">
53
+ <h2 class="panel-title">TELL THE ORACLE YOUR PLANS</h2>
54
+ <textarea id="todos-input" rows="5"
55
+ placeholder="e.g. Finish the quarterly report (due Friday), reply to 3 emails, book dentist, start gym routine, learn one chord on guitar..."></textarea>
56
+ <button id="generate-btn" class="pix-btn primary">⚔ FORGE QUEST LOG</button>
57
+ </section>
58
+
59
+ <section id="adventure-header" class="adventure-header" hidden>
60
+ <h2 id="adventure-title"></h2>
61
+ </section>
62
+
63
+ <section id="quest-log" class="quest-log" aria-live="polite"></section>
64
+ </main>
65
+
66
+ <!-- status / loading overlay -->
67
+ <div id="overlay" class="overlay" hidden>
68
+ <div class="overlay-box">
69
+ <div class="loader"></div>
70
+ <p id="overlay-msg">LOADING…</p>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- card template -->
75
+ <template id="quest-card-tpl">
76
+ <article class="quest-card">
77
+ <div class="quest-art">
78
+ <img class="quest-img" alt="" hidden />
79
+ <div class="quest-art-empty">
80
+ <span class="art-glyph">🗺</span>
81
+ <button class="pix-btn small gen-img-btn">GENERATE SCENE</button>
82
+ </div>
83
+ </div>
84
+ <div class="quest-body">
85
+ <div class="quest-badges">
86
+ <span class="badge frog-badge" hidden>🐸 THE FROG</span>
87
+ <span class="badge bonus-badge" hidden>✦ BONUS · OPTIONAL</span>
88
+ <span class="badge group-badge" hidden></span>
89
+ </div>
90
+ <h3 class="quest-title"></h3>
91
+ <p class="quest-narrative"></p>
92
+ <p class="quest-task"></p>
93
+ <div class="quest-foot">
94
+ <span class="xp"></span>
95
+ </div>
96
+ </div>
97
+ </article>
98
+ </template>
99
+
100
+ <script type="module" src="/static/app.js"></script>
101
+ </body>
102
+ </html>
static/storage.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FrogQuest browser-only persistence. Everything lives in localStorage under one key.
2
+ // The photo is resized client-side BEFORE storing so it fits the ~5MB localStorage cap.
3
+
4
+ const KEY = "frogquest:v1";
5
+ const PHOTO_MAX_SIDE = 512;
6
+ const PHOTO_QUALITY = 0.8;
7
+
8
+ const EMPTY = {
9
+ version: 1,
10
+ profile: { photo: null, theme: null },
11
+ adventure: null,
12
+ quests: [],
13
+ images: {}, // questId -> { initial, success, failure } (data URLs; best-effort cache)
14
+ };
15
+
16
+ export function load() {
17
+ try {
18
+ const raw = localStorage.getItem(KEY);
19
+ if (!raw) return structuredClone(EMPTY);
20
+ const parsed = JSON.parse(raw);
21
+ return { ...structuredClone(EMPTY), ...parsed };
22
+ } catch {
23
+ return structuredClone(EMPTY);
24
+ }
25
+ }
26
+
27
+ export function save(state) {
28
+ // Try to persist everything; if the image cache blows the quota, drop it and keep the
29
+ // reproducible core (seed + prompts regenerate the images on demand).
30
+ try {
31
+ localStorage.setItem(KEY, JSON.stringify(state));
32
+ } catch (e) {
33
+ if (e && e.name === "QuotaExceededError") {
34
+ const lean = { ...state, images: {} };
35
+ try {
36
+ localStorage.setItem(KEY, JSON.stringify(lean));
37
+ } catch {
38
+ /* give up on persistence; in-memory state still works this session */
39
+ }
40
+ }
41
+ }
42
+ return state;
43
+ }
44
+
45
+ export function update(patch) {
46
+ return save({ ...load(), ...patch });
47
+ }
48
+
49
+ export function cacheImage(questId, slot, dataUrl) {
50
+ const state = load();
51
+ state.images[questId] = { ...(state.images[questId] || {}), [slot]: dataUrl };
52
+ return save(state);
53
+ }
54
+
55
+ export function clear() {
56
+ localStorage.removeItem(KEY);
57
+ }
58
+
59
+ // Read an uploaded File, downscale so the longest side <= PHOTO_MAX_SIDE, return a JPEG data URL.
60
+ export function resizePhoto(file) {
61
+ return new Promise((resolve, reject) => {
62
+ const reader = new FileReader();
63
+ reader.onerror = () => reject(new Error("Could not read file"));
64
+ reader.onload = () => {
65
+ const img = new Image();
66
+ img.onerror = () => reject(new Error("Could not decode image"));
67
+ img.onload = () => {
68
+ let { width, height } = img;
69
+ const scale = Math.min(1, PHOTO_MAX_SIDE / Math.max(width, height));
70
+ width = Math.round(width * scale);
71
+ height = Math.round(height * scale);
72
+ const canvas = document.createElement("canvas");
73
+ canvas.width = width;
74
+ canvas.height = height;
75
+ canvas.getContext("2d").drawImage(img, 0, 0, width, height);
76
+ resolve(canvas.toDataURL("image/jpeg", PHOTO_QUALITY));
77
+ };
78
+ img.src = reader.result;
79
+ };
80
+ reader.readAsDataURL(file);
81
+ });
82
+ }
static/style.css ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* FrogQuest — 8-bit / NES-RPG pixel-art theme. */
2
+
3
+ :root {
4
+ --bg: #0d0b1a;
5
+ --bg2: #161228;
6
+ --panel: #1d1733;
7
+ --ink: #e8e6ff;
8
+ --dim: #9b95c9;
9
+ --accent: #00e5ff;
10
+ --accent2: #ff2e88;
11
+ --gold: #ffd23f;
12
+ --frog: #4ade5b;
13
+ --shadow: #000;
14
+ --border: 4px;
15
+ font-family: "Press Start 2P", monospace;
16
+ }
17
+
18
+ /* ---- per-theme palettes (set on <html data-theme>) ---- */
19
+ [data-theme="cyberpunk"] { --bg:#0d0b1a; --bg2:#161228; --panel:#1d1733; --accent:#00e5ff; --accent2:#ff2e88; --gold:#ffd23f; }
20
+ [data-theme="fantasy"] { --bg:#10180f; --bg2:#172414; --panel:#1d2e1a; --accent:#7bdc5a; --accent2:#ffae34; --gold:#ffe066; --ink:#eafbe2; --dim:#9ec48c; }
21
+ [data-theme="space"] { --bg:#070914; --bg2:#0d1124; --panel:#141a36; --accent:#7c5cff; --accent2:#36c5ff; --gold:#ffd23f; --ink:#e6ecff; --dim:#8d97c9; }
22
+
23
+ * { box-sizing: border-box; }
24
+
25
+ html, body {
26
+ margin: 0;
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ image-rendering: pixelated;
30
+ }
31
+
32
+ body {
33
+ min-height: 100vh;
34
+ padding-bottom: 48px;
35
+ font-size: 11px;
36
+ line-height: 1.7;
37
+ background-image:
38
+ repeating-linear-gradient(0deg, transparent 0 38px, rgba(255,255,255,0.015) 38px 39px),
39
+ repeating-linear-gradient(90deg, transparent 0 38px, rgba(255,255,255,0.015) 38px 39px);
40
+ }
41
+
42
+ /* faint CRT scanlines */
43
+ .scanlines {
44
+ position: fixed; inset: 0; pointer-events: none; z-index: 9000;
45
+ background: repeating-linear-gradient(0deg, rgba(0,0,0,0.16) 0 2px, transparent 2px 4px);
46
+ mix-blend-mode: multiply;
47
+ }
48
+
49
+ /* ---------- top bar ---------- */
50
+ .topbar {
51
+ display: flex; align-items: center; justify-content: space-between;
52
+ padding: 16px 20px; border-bottom: var(--border) solid var(--accent);
53
+ background: var(--bg2);
54
+ }
55
+ .logo { margin: 0; font-size: 20px; letter-spacing: 2px; color: var(--accent); text-shadow: 3px 3px 0 var(--shadow); }
56
+ .logo span { color: var(--accent2); }
57
+
58
+ /* ---------- screens / panels ---------- */
59
+ .screen { max-width: 860px; margin: 0 auto; padding: 24px 16px; }
60
+ .panel {
61
+ background: var(--panel);
62
+ border: var(--border) solid var(--ink);
63
+ box-shadow: 8px 8px 0 var(--shadow);
64
+ padding: 22px; margin-bottom: 22px;
65
+ }
66
+ .panel-title { margin: 0 0 14px; font-size: 13px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); }
67
+ .panel-subtitle { margin: 22px 0 12px; font-size: 11px; color: var(--accent); }
68
+ .hint { color: var(--dim); font-size: 10px; line-height: 1.9; }
69
+
70
+ /* ---------- buttons ---------- */
71
+ .pix-btn {
72
+ font-family: inherit; font-size: 11px; color: var(--ink); cursor: pointer;
73
+ background: var(--bg2); border: var(--border) solid var(--ink);
74
+ box-shadow: 4px 4px 0 var(--shadow); padding: 12px 16px;
75
+ text-transform: uppercase; transition: transform .05s, box-shadow .05s;
76
+ }
77
+ .pix-btn:hover { background: var(--accent); color: var(--bg); }
78
+ .pix-btn:active { transform: translate(4px, 4px); box-shadow: 0 0 0 var(--shadow); }
79
+ .pix-btn:disabled { opacity: .4; cursor: not-allowed; }
80
+ .pix-btn.primary { background: var(--accent2); color: #fff; border-color: #fff; width: 100%; margin-top: 18px; font-size: 12px; padding: 16px; }
81
+ .pix-btn.primary:hover { background: var(--gold); color: var(--bg); }
82
+ .pix-btn.small { font-size: 9px; padding: 9px 10px; box-shadow: 3px 3px 0 var(--shadow); }
83
+
84
+ /* ---------- uploader ---------- */
85
+ .uploader {
86
+ display: flex; align-items: center; justify-content: center;
87
+ width: 100%; min-height: 200px; cursor: pointer;
88
+ border: var(--border) dashed var(--accent); background: var(--bg2);
89
+ color: var(--dim); font-size: 10px; text-align: center; overflow: hidden;
90
+ }
91
+ .uploader:hover { border-color: var(--gold); color: var(--ink); }
92
+ #photo-preview { width: 100%; height: 240px; object-fit: cover; display: block; }
93
+
94
+ /* ---------- theme picker ---------- */
95
+ .theme-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
96
+ .theme-card {
97
+ font-family: inherit; font-size: 9px; color: var(--ink); cursor: pointer;
98
+ display: flex; flex-direction: column; align-items: center; gap: 10px;
99
+ padding: 18px 8px; background: var(--bg2);
100
+ border: var(--border) solid var(--dim); box-shadow: 4px 4px 0 var(--shadow);
101
+ }
102
+ .theme-card:hover { border-color: var(--accent); }
103
+ .theme-card.selected { border-color: var(--gold); background: var(--panel); box-shadow: 4px 4px 0 var(--accent2); }
104
+ .theme-emoji { font-size: 26px; line-height: 1; }
105
+
106
+ /* ---------- compose ---------- */
107
+ .compose textarea {
108
+ width: 100%; resize: vertical; font-family: inherit; font-size: 10px; line-height: 1.8;
109
+ color: var(--ink); background: var(--bg2); border: var(--border) solid var(--accent);
110
+ padding: 14px; box-shadow: inset 3px 3px 0 var(--shadow);
111
+ }
112
+ .compose textarea:focus { outline: none; border-color: var(--gold); }
113
+
114
+ /* ---------- adventure header ---------- */
115
+ .adventure-header { max-width: 860px; margin: 0 auto 6px; padding: 0 16px; }
116
+ .adventure-header h2 { font-size: 14px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); }
117
+
118
+ /* ---------- quest log ---------- */
119
+ .quest-log { display: flex; flex-direction: column; gap: 20px; }
120
+
121
+ .quest-card {
122
+ display: grid; grid-template-columns: 200px 1fr;
123
+ background: var(--panel); border: var(--border) solid var(--ink);
124
+ box-shadow: 8px 8px 0 var(--shadow); overflow: hidden;
125
+ }
126
+ .quest-card.is-frog { border-color: var(--frog); box-shadow: 8px 8px 0 var(--frog); }
127
+ .quest-card.is-bonus { border-style: dashed; }
128
+ .quest-card.done { opacity: .85; }
129
+
130
+ .quest-art { position: relative; background: var(--bg2); border-right: var(--border) solid var(--ink); min-height: 200px; }
131
+ .quest-img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
132
+ .quest-art-empty {
133
+ position: absolute; inset: 0; display: flex; flex-direction: column;
134
+ align-items: center; justify-content: center; gap: 14px; color: var(--dim);
135
+ }
136
+ .art-glyph { font-size: 40px; opacity: .6; }
137
+
138
+ .quest-body { padding: 16px 18px; }
139
+ .quest-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
140
+ .badge { font-size: 8px; padding: 5px 7px; border: 2px solid currentColor; }
141
+ .frog-badge { color: var(--frog); }
142
+ .bonus-badge { color: var(--accent); }
143
+ .group-badge { color: var(--gold); }
144
+ .quest-title { margin: 4px 0 10px; font-size: 12px; color: var(--ink); line-height: 1.6; }
145
+ .quest-narrative { margin: 0 0 12px; font-size: 9px; color: var(--dim); line-height: 1.9; }
146
+ .quest-task { margin: 0 0 14px; font-size: 9px; color: var(--accent); line-height: 1.8; }
147
+ .quest-task::before { content: "▸ TASK: "; color: var(--dim); }
148
+ .quest-foot { display: flex; align-items: center; justify-content: space-between; }
149
+ .xp { font-size: 9px; color: var(--gold); }
150
+ .xp::before { content: "★ "; }
151
+
152
+ /* ---------- overlay ---------- */
153
+ .overlay {
154
+ position: fixed; inset: 0; z-index: 9500; display: flex; align-items: center; justify-content: center;
155
+ background: rgba(5,4,12,0.86);
156
+ }
157
+ .overlay-box { text-align: center; color: var(--ink); }
158
+ .overlay-box p { font-size: 11px; margin-top: 18px; color: var(--accent); }
159
+ .loader {
160
+ width: 36px; height: 36px; margin: 0 auto; background: var(--accent2);
161
+ animation: blink 0.6s steps(2, end) infinite;
162
+ }
163
+ @keyframes blink { 0% { opacity: 1; transform: scale(1); } 50% { opacity: .3; transform: scale(.7); } 100% { opacity: 1; transform: scale(1); } }
164
+
165
+ /* ---------- responsive ---------- */
166
+ @media (max-width: 620px) {
167
+ .quest-card { grid-template-columns: 1fr; }
168
+ .quest-art { border-right: none; border-bottom: var(--border) solid var(--ink); }
169
+ .theme-grid { grid-template-columns: 1fr; }
170
+ .logo { font-size: 16px; }
171
+ }