First pass
Browse files- .gitignore +71 -0
- README.md +19 -0
- app.py +55 -0
- images.py +87 -0
- llm.py +97 -0
- requirements.txt +15 -0
- schema.py +189 -0
- static/app.js +232 -0
- static/index.html +102 -0
- static/storage.js +82 -0
- 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 |
+
}
|