First pass
Browse files- app.py +250 -41
- static/app.js +0 -232
- static/index.html +0 -102
- static/storage.js +0 -82
- static/style.css +0 -171
- theme.css +169 -0
app.py
CHANGED
|
@@ -1,62 +1,271 @@
|
|
| 1 |
-
"""FrogQuest
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
"No @spaces.GPU function detected during startup" and the container stops).
|
| 11 |
-
|
| 12 |
-
Frontend calls endpoints via the Gradio JS Client:
|
| 13 |
-
client.predict("/generate_quests", { todos, theme })
|
| 14 |
-
client.predict("/generate_image", { photo_b64, art_style, scene_prompt, seed })
|
| 15 |
"""
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
-
import
|
|
|
|
|
|
|
| 19 |
import os
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
from
|
| 24 |
|
| 25 |
from schema import validate_and_clamp
|
| 26 |
-
# Top-level imports so ZeroGPU registers the @spaces.GPU functions at startup.
|
| 27 |
-
from llm import generate_quests_raw
|
| 28 |
-
from images import b64_to_pil, initial_image, pil_to_data_url
|
| 29 |
|
| 30 |
-
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
raw = generate_quests_raw(todos, theme)
|
| 46 |
adventure = validate_and_clamp(raw, theme)
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
img = initial_image(photo, art_style, scene_prompt, int(seed))
|
| 55 |
-
return pil_to_data_url(img)
|
| 56 |
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
# as __main__, so a `if __name__ == "__main__"` guard would never fire and HF's launcher would
|
| 61 |
-
# loop trying to find a launchable. There is intentionally NO `demo` variable.
|
| 62 |
-
app.launch(show_error=True)
|
|
|
|
| 1 |
+
"""FrogQuest — plain Gradio (gr.Blocks) app.
|
| 2 |
|
| 3 |
+
Standard HF Spaces pattern: a module-level `demo` Blocks + `demo.launch()`. The LLM/image
|
| 4 |
+
modules are imported DEFENSIVELY so a bleeding-edge import error (e.g. a diffusers build
|
| 5 |
+
missing Flux2KleinPipeline) surfaces in the UI instead of hanging the Space on a loading
|
| 6 |
+
screen. A module-level @spaces.GPU `_warmup` guarantees ZeroGPU's startup detection even if
|
| 7 |
+
those imports fail.
|
| 8 |
|
| 9 |
+
State lives in gr.BrowserState (per-browser localStorage): the resized photo (base64), the
|
| 10 |
+
chosen theme, and the validated adventure/quests JSON. The photo goes to the GPU only
|
| 11 |
+
transiently during generation and is never persisted server-side.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import base64
|
| 16 |
+
import html
|
| 17 |
+
import io
|
| 18 |
import os
|
| 19 |
+
import traceback
|
| 20 |
|
| 21 |
+
import gradio as gr
|
| 22 |
+
import spaces
|
| 23 |
+
from PIL import Image
|
| 24 |
|
| 25 |
from schema import validate_and_clamp
|
|
|
|
|
|
|
|
|
|
| 26 |
|
|
|
|
| 27 |
|
| 28 |
+
# ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless
|
| 29 |
+
# of whether the heavy model modules import cleanly.
|
| 30 |
+
@spaces.GPU(duration=15)
|
| 31 |
+
def _warmup():
|
| 32 |
+
return "ok"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# Defensive import: never let a model-import error hang the whole app.
|
| 36 |
+
MODEL_IMPORT_ERROR = None
|
| 37 |
+
try:
|
| 38 |
+
from llm import generate_quests_raw
|
| 39 |
+
from images import initial_image
|
| 40 |
+
except Exception:
|
| 41 |
+
MODEL_IMPORT_ERROR = traceback.format_exc()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ----------------------------- photo helpers (PIL only) -----------------------------
|
| 45 |
+
|
| 46 |
+
PHOTO_MAX_SIDE = 512
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _resize_dataurl(pil: Image.Image, max_side: int = PHOTO_MAX_SIDE) -> str:
|
| 50 |
+
img = pil.convert("RGB")
|
| 51 |
+
w, h = img.size
|
| 52 |
+
scale = min(1.0, max_side / max(w, h))
|
| 53 |
+
if scale < 1.0:
|
| 54 |
+
img = img.resize((round(w * scale), round(h * scale)))
|
| 55 |
+
buf = io.BytesIO()
|
| 56 |
+
img.save(buf, format="JPEG", quality=80)
|
| 57 |
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _dataurl_to_pil(data_url: str) -> Image.Image:
|
| 61 |
+
s = data_url.split(",", 1)[1] if "," in data_url else data_url
|
| 62 |
+
return Image.open(io.BytesIO(base64.b64decode(s))).convert("RGB")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _import_error_message() -> str:
|
| 66 |
+
last = (MODEL_IMPORT_ERROR or "").strip().splitlines()
|
| 67 |
+
return "Model failed to load: " + (last[-1] if last else "unknown error")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ----------------------------- quest-card HTML -----------------------------
|
| 71 |
+
|
| 72 |
+
def _card_body(q: dict) -> str:
|
| 73 |
+
badges = []
|
| 74 |
+
if q.get("is_frog"):
|
| 75 |
+
badges.append('<span class="badge frog-badge">🐸 THE FROG</span>')
|
| 76 |
+
if q.get("type") == "bonus":
|
| 77 |
+
badges.append('<span class="badge bonus-badge">✦ BONUS · OPTIONAL</span>')
|
| 78 |
+
if q.get("goal_group"):
|
| 79 |
+
badges.append(f'<span class="badge group-badge">⛓ {html.escape(str(q["goal_group"]))}</span>')
|
| 80 |
+
return (
|
| 81 |
+
'<div class="quest-body">'
|
| 82 |
+
f'<div class="quest-badges">{"".join(badges)}</div>'
|
| 83 |
+
f'<h3 class="quest-title">{html.escape(q.get("quest_title", ""))}</h3>'
|
| 84 |
+
f'<p class="quest-narrative">{html.escape(q.get("narrative", ""))}</p>'
|
| 85 |
+
f'<p class="quest-task">{html.escape(q.get("task", ""))}</p>'
|
| 86 |
+
f'<div class="quest-foot"><span class="xp">{int(q.get("xp", 0))} XP</span></div>'
|
| 87 |
+
'</div>'
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _title_html(adventure: dict | None) -> str:
|
| 92 |
+
if not adventure:
|
| 93 |
+
return ""
|
| 94 |
+
return f'<div class="adventure-header"><h2>{html.escape(adventure.get("title", ""))}</h2></div>'
|
| 95 |
+
|
| 96 |
|
| 97 |
+
# ----------------------------- event handlers -----------------------------
|
| 98 |
|
| 99 |
+
def boot(state):
|
| 100 |
+
state = state or {}
|
| 101 |
+
profile = state.get("profile") or {}
|
| 102 |
+
has_profile = bool(profile.get("photo") and profile.get("theme"))
|
| 103 |
+
return (
|
| 104 |
+
gr.update(visible=not has_profile), # onboard_col
|
| 105 |
+
gr.update(visible=has_profile), # app_col
|
| 106 |
+
profile.get("photo"), # photo_state
|
| 107 |
+
profile.get("theme") or "", # theme_box
|
| 108 |
+
state.get("quests") or [], # quests_state
|
| 109 |
+
state.get("adventure"), # adventure_state
|
| 110 |
+
_title_html(state.get("adventure")), # adventure_title
|
| 111 |
+
)
|
| 112 |
|
| 113 |
|
| 114 |
+
def begin(photo_pil, theme, state):
|
| 115 |
+
if photo_pil is None:
|
| 116 |
+
raise gr.Error("Upload a photo first.")
|
| 117 |
+
if not theme:
|
| 118 |
+
raise gr.Error("Pick a theme first.")
|
| 119 |
+
photo_b64 = _resize_dataurl(photo_pil)
|
| 120 |
+
state = dict(state or {})
|
| 121 |
+
state["profile"] = {"photo": photo_b64, "theme": theme}
|
| 122 |
+
return state, photo_b64, gr.update(visible=False), gr.update(visible=True)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def go_settings(photo_b64):
|
| 126 |
+
preview = _dataurl_to_pil(photo_b64) if photo_b64 else None
|
| 127 |
+
return gr.update(visible=True), gr.update(visible=False), preview
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def forge(todos, theme, photo_b64, state):
|
| 131 |
+
if MODEL_IMPORT_ERROR:
|
| 132 |
+
raise gr.Error(_import_error_message())
|
| 133 |
+
if not (todos or "").strip():
|
| 134 |
+
raise gr.Error("Tell the oracle your plans first.")
|
| 135 |
+
theme = theme or (state or {}).get("profile", {}).get("theme") or "cyberpunk"
|
| 136 |
raw = generate_quests_raw(todos, theme)
|
| 137 |
adventure = validate_and_clamp(raw, theme)
|
| 138 |
+
state = dict(state or {})
|
| 139 |
+
state["adventure"] = adventure["adventure"]
|
| 140 |
+
state["quests"] = adventure["quests"]
|
| 141 |
+
return adventure["quests"], adventure["adventure"], state, _title_html(adventure["adventure"])
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _make_scene_handler(quest_id: str, scene_prompt: str):
|
| 145 |
+
def _gen(photo_b64, adventure):
|
| 146 |
+
if MODEL_IMPORT_ERROR:
|
| 147 |
+
raise gr.Error(_import_error_message())
|
| 148 |
+
if not photo_b64:
|
| 149 |
+
raise gr.Error("Add a photo in Settings first.")
|
| 150 |
+
if not adventure:
|
| 151 |
+
raise gr.Error("Forge a quest log first.")
|
| 152 |
+
pil = _dataurl_to_pil(photo_b64)
|
| 153 |
+
return initial_image(pil, adventure["art_style"], scene_prompt, int(adventure["seed"]))
|
| 154 |
+
|
| 155 |
+
return _gen
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ----------------------------- UI -----------------------------
|
| 159 |
+
|
| 160 |
+
_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 161 |
+
with open(os.path.join(_DIR, "theme.css"), "r", encoding="utf-8") as _f:
|
| 162 |
+
THEME_CSS = _f.read()
|
| 163 |
+
|
| 164 |
+
HEAD = """
|
| 165 |
+
<script>
|
| 166 |
+
function fqSetTheme(t){
|
| 167 |
+
if(!t){ return; }
|
| 168 |
+
document.documentElement.setAttribute('data-theme', t);
|
| 169 |
+
['cyberpunk','fantasy','space'].forEach(function(n){
|
| 170 |
+
var el = document.getElementById('theme-' + n);
|
| 171 |
+
if(el){ el.classList.toggle('selected', n === t); }
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
</script>
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
with gr.Blocks(
|
| 178 |
+
css=THEME_CSS,
|
| 179 |
+
head=HEAD,
|
| 180 |
+
title="FrogQuest",
|
| 181 |
+
theme=gr.themes.Base(font=[gr.themes.GoogleFont("Press Start 2P"), "monospace"]),
|
| 182 |
+
) as demo:
|
| 183 |
+
browser = gr.BrowserState(
|
| 184 |
+
{"profile": {"photo": None, "theme": None}, "adventure": None, "quests": []},
|
| 185 |
+
storage_key="frogquest",
|
| 186 |
+
)
|
| 187 |
+
photo_state = gr.State(None) # resized photo as base64 data URL
|
| 188 |
+
quests_state = gr.State([]) # drives the quest-log render
|
| 189 |
+
adventure_state = gr.State(None) # title / art_style / seed
|
| 190 |
+
theme_box = gr.Textbox(visible=False) # canonical theme; .change applies the palette
|
| 191 |
+
|
| 192 |
+
gr.HTML('<div class="fq-topbar"><h1 class="fq-logo">FROG<b>QUEST</b></h1></div>')
|
| 193 |
+
|
| 194 |
+
# ---------- onboarding ----------
|
| 195 |
+
with gr.Column(visible=True) as onboard_col:
|
| 196 |
+
with gr.Group(elem_classes=["panel"]):
|
| 197 |
+
gr.HTML(
|
| 198 |
+
'<h2 class="panel-title">NEW HERO</h2>'
|
| 199 |
+
'<p class="hint">Upload a photo so you become the hero of every quest. '
|
| 200 |
+
'It stays in your browser — sent to the GPU only to draw your scenes, never stored '
|
| 201 |
+
'on a server.</p>'
|
| 202 |
+
)
|
| 203 |
+
photo_image = gr.Image(
|
| 204 |
+
type="pil", sources=["upload"], show_label=False, height=240,
|
| 205 |
+
elem_classes=["uploader"],
|
| 206 |
+
)
|
| 207 |
+
gr.HTML('<h3 class="panel-subtitle">CHOOSE YOUR WORLD</h3>')
|
| 208 |
+
with gr.Row():
|
| 209 |
+
cyber_btn = gr.Button("🌃\nCYBERPUNK", elem_id="theme-cyberpunk", elem_classes=["theme-card"])
|
| 210 |
+
fantasy_btn = gr.Button("🏰\nFANTASY", elem_id="theme-fantasy", elem_classes=["theme-card"])
|
| 211 |
+
space_btn = gr.Button("🚀\nSPACE", elem_id="theme-space", elem_classes=["theme-card"])
|
| 212 |
+
begin_btn = gr.Button("BEGIN ADVENTURE ▶", elem_classes=["pix-btn", "primary"])
|
| 213 |
+
|
| 214 |
+
# ---------- main app ----------
|
| 215 |
+
with gr.Column(visible=False) as app_col:
|
| 216 |
+
settings_btn = gr.Button("⚙ SETTINGS", elem_classes=["pix-btn", "small"])
|
| 217 |
+
with gr.Group(elem_classes=["panel"]):
|
| 218 |
+
gr.HTML('<h2 class="panel-title">TELL THE ORACLE YOUR PLANS</h2>')
|
| 219 |
+
todos_box = gr.Textbox(
|
| 220 |
+
lines=5, show_label=False, elem_classes=["compose"],
|
| 221 |
+
placeholder=("e.g. Finish the quarterly report (due Friday), reply to 3 emails, "
|
| 222 |
+
"book dentist, start gym routine, learn one chord on guitar..."),
|
| 223 |
+
)
|
| 224 |
+
forge_btn = gr.Button("⚔ FORGE QUEST LOG", elem_classes=["pix-btn", "primary"])
|
| 225 |
+
adventure_title = gr.HTML("")
|
| 226 |
+
|
| 227 |
+
@gr.render(inputs=[quests_state])
|
| 228 |
+
def render_quests(quests):
|
| 229 |
+
for q in quests or []:
|
| 230 |
+
classes = ["quest-card"]
|
| 231 |
+
if q.get("is_frog"):
|
| 232 |
+
classes.append("is-frog")
|
| 233 |
+
if q.get("type") == "bonus":
|
| 234 |
+
classes.append("is-bonus")
|
| 235 |
+
with gr.Group(elem_classes=classes):
|
| 236 |
+
with gr.Row():
|
| 237 |
+
with gr.Column(scale=2, min_width=160):
|
| 238 |
+
scene_img = gr.Image(
|
| 239 |
+
show_label=False, interactive=False, height=200,
|
| 240 |
+
container=False, elem_classes=["quest-art"],
|
| 241 |
+
)
|
| 242 |
+
gen_btn = gr.Button("GENERATE SCENE", elem_classes=["pix-btn", "small"])
|
| 243 |
+
with gr.Column(scale=3):
|
| 244 |
+
gr.HTML(_card_body(q))
|
| 245 |
+
gen_btn.click(
|
| 246 |
+
_make_scene_handler(q.get("id", ""), q.get("initial_image_prompt", "")),
|
| 247 |
+
inputs=[photo_state, adventure_state],
|
| 248 |
+
outputs=[scene_img],
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# ---------- wiring ----------
|
| 252 |
+
theme_box.change(None, theme_box, None, js="(t) => { fqSetTheme(t); }")
|
| 253 |
+
cyber_btn.click(lambda: "cyberpunk", None, theme_box)
|
| 254 |
+
fantasy_btn.click(lambda: "fantasy", None, theme_box)
|
| 255 |
+
space_btn.click(lambda: "space", None, theme_box)
|
| 256 |
|
| 257 |
+
begin_btn.click(begin, [photo_image, theme_box, browser], [browser, photo_state, onboard_col, app_col])
|
| 258 |
+
settings_btn.click(go_settings, [photo_state], [onboard_col, app_col, photo_image])
|
| 259 |
+
forge_btn.click(
|
| 260 |
+
forge, [todos_box, theme_box, photo_state, browser],
|
| 261 |
+
[quests_state, adventure_state, browser, adventure_title],
|
| 262 |
+
)
|
| 263 |
|
| 264 |
+
demo.load(
|
| 265 |
+
boot, [browser],
|
| 266 |
+
[onboard_col, app_col, photo_state, theme_box, quests_state, adventure_state, adventure_title],
|
| 267 |
+
)
|
|
|
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
+
if __name__ == "__main__":
|
| 271 |
+
demo.launch()
|
|
|
|
|
|
|
|
|
static/app.js
DELETED
|
@@ -1,232 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,171 +0,0 @@
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
theme.css
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* FrogQuest — 8-bit / NES-RPG pixel-art theme for plain Gradio (gr.Blocks).
|
| 2 |
+
Reuses the original static/style.css rules and adds overrides that strip Gradio's
|
| 3 |
+
default chrome so the app looks identical to the gr.Server build. */
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--bg: #0d0b1a;
|
| 7 |
+
--bg2: #161228;
|
| 8 |
+
--panel: #1d1733;
|
| 9 |
+
--ink: #e8e6ff;
|
| 10 |
+
--dim: #9b95c9;
|
| 11 |
+
--accent: #00e5ff;
|
| 12 |
+
--accent2: #ff2e88;
|
| 13 |
+
--gold: #ffd23f;
|
| 14 |
+
--frog: #4ade5b;
|
| 15 |
+
--shadow: #000;
|
| 16 |
+
--border: 4px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* ---- per-theme palettes (set on <html data-theme> by fqSetTheme) ---- */
|
| 20 |
+
[data-theme="cyberpunk"] { --bg:#0d0b1a; --bg2:#161228; --panel:#1d1733; --accent:#00e5ff; --accent2:#ff2e88; --gold:#ffd23f; }
|
| 21 |
+
[data-theme="fantasy"] { --bg:#10180f; --bg2:#172414; --panel:#1d2e1a; --accent:#7bdc5a; --accent2:#ffae34; --gold:#ffe066; --ink:#eafbe2; --dim:#9ec48c; }
|
| 22 |
+
[data-theme="space"] { --bg:#070914; --bg2:#0d1124; --panel:#141a36; --accent:#7c5cff; --accent2:#36c5ff; --gold:#ffd23f; --ink:#e6ecff; --dim:#8d97c9; }
|
| 23 |
+
|
| 24 |
+
/* ================= Gradio chrome neutralization ================= */
|
| 25 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 26 |
+
|
| 27 |
+
body, gradio-app, .gradio-container {
|
| 28 |
+
background: var(--bg) !important;
|
| 29 |
+
color: var(--ink) !important;
|
| 30 |
+
font-family: "Press Start 2P", monospace !important;
|
| 31 |
+
image-rendering: pixelated;
|
| 32 |
+
}
|
| 33 |
+
.gradio-container {
|
| 34 |
+
max-width: 900px !important;
|
| 35 |
+
margin: 0 auto !important;
|
| 36 |
+
padding: 0 16px 64px !important;
|
| 37 |
+
font-size: 11px !important;
|
| 38 |
+
line-height: 1.7 !important;
|
| 39 |
+
}
|
| 40 |
+
/* faint background grid */
|
| 41 |
+
.gradio-container {
|
| 42 |
+
background-image:
|
| 43 |
+
repeating-linear-gradient(0deg, transparent 0 38px, rgba(255,255,255,0.015) 38px 39px),
|
| 44 |
+
repeating-linear-gradient(90deg, transparent 0 38px, rgba(255,255,255,0.015) 38px 39px) !important;
|
| 45 |
+
}
|
| 46 |
+
/* CRT scanlines overlay */
|
| 47 |
+
body::after {
|
| 48 |
+
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 9000;
|
| 49 |
+
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.16) 0 2px, transparent 2px 4px);
|
| 50 |
+
mix-blend-mode: multiply;
|
| 51 |
+
}
|
| 52 |
+
/* hide Gradio's footer & progress chrome */
|
| 53 |
+
footer, .gradio-container > .main > .wrap > .contain > div > .built-with,
|
| 54 |
+
.show-api, .built-with { display: none !important; }
|
| 55 |
+
|
| 56 |
+
/* strip default block styling so our panels/cards control the look */
|
| 57 |
+
.block, .form, .gr-box, .gr-block, .panel-fq .block, .styler,
|
| 58 |
+
.gradio-container .prose { background: transparent !important; border: none !important;
|
| 59 |
+
box-shadow: none !important; border-radius: 0 !important; }
|
| 60 |
+
.gr-padded, .block.padded { padding: 0 !important; }
|
| 61 |
+
.gap, .gradio-container .gap { gap: 0 !important; }
|
| 62 |
+
|
| 63 |
+
/* ================= original pixel-art rules ================= */
|
| 64 |
+
.fq-topbar {
|
| 65 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 66 |
+
padding: 16px 4px; border-bottom: var(--border) solid var(--accent);
|
| 67 |
+
background: var(--bg2); margin-bottom: 20px;
|
| 68 |
+
}
|
| 69 |
+
.fq-logo { margin: 0; font-size: 20px; letter-spacing: 2px; color: var(--accent);
|
| 70 |
+
text-shadow: 3px 3px 0 var(--shadow); }
|
| 71 |
+
.fq-logo b { color: var(--accent2); font-weight: normal; }
|
| 72 |
+
|
| 73 |
+
.panel {
|
| 74 |
+
background: var(--panel) !important;
|
| 75 |
+
border: var(--border) solid var(--ink) !important;
|
| 76 |
+
box-shadow: 8px 8px 0 var(--shadow) !important;
|
| 77 |
+
padding: 22px !important; margin-bottom: 22px !important;
|
| 78 |
+
}
|
| 79 |
+
.panel-title { margin: 0 0 14px; font-size: 13px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); }
|
| 80 |
+
.panel-subtitle { margin: 18px 0 12px; font-size: 11px; color: var(--accent); }
|
| 81 |
+
.hint { color: var(--dim); font-size: 10px; line-height: 1.9; }
|
| 82 |
+
|
| 83 |
+
/* ---------- buttons (Gradio <button> with elem_classes=["pix-btn"]) ---------- */
|
| 84 |
+
button.pix-btn, .pix-btn button, .pix-btn {
|
| 85 |
+
font-family: "Press Start 2P", monospace !important; font-size: 11px !important;
|
| 86 |
+
color: var(--ink) !important; cursor: pointer;
|
| 87 |
+
background: var(--bg2) !important; border: var(--border) solid var(--ink) !important;
|
| 88 |
+
box-shadow: 4px 4px 0 var(--shadow) !important; padding: 12px 16px !important;
|
| 89 |
+
text-transform: uppercase; border-radius: 0 !important;
|
| 90 |
+
transition: transform .05s, box-shadow .05s; min-width: 0 !important;
|
| 91 |
+
}
|
| 92 |
+
button.pix-btn:hover, .pix-btn button:hover { background: var(--accent) !important; color: var(--bg) !important; }
|
| 93 |
+
button.pix-btn:active, .pix-btn button:active { transform: translate(4px, 4px); box-shadow: 0 0 0 var(--shadow) !important; }
|
| 94 |
+
.pix-btn.primary, .pix-btn.primary button {
|
| 95 |
+
background: var(--accent2) !important; color: #fff !important; border-color: #fff !important;
|
| 96 |
+
width: 100% !important; font-size: 12px !important; padding: 16px !important;
|
| 97 |
+
}
|
| 98 |
+
.pix-btn.primary:hover, .pix-btn.primary button:hover { background: var(--gold) !important; color: var(--bg) !important; }
|
| 99 |
+
.pix-btn.small, .pix-btn.small button { font-size: 9px !important; padding: 9px 10px !important; box-shadow: 3px 3px 0 var(--shadow) !important; }
|
| 100 |
+
|
| 101 |
+
/* ---------- photo uploader (gr.Image) ---------- */
|
| 102 |
+
.uploader { border: none !important; }
|
| 103 |
+
.uploader .image-container, .uploader .upload-container, .uploader [data-testid="image"] {
|
| 104 |
+
min-height: 220px !important; background: var(--bg2) !important;
|
| 105 |
+
border: var(--border) dashed var(--accent) !important; border-radius: 0 !important;
|
| 106 |
+
}
|
| 107 |
+
.uploader .image-container img { image-rendering: pixelated; object-fit: cover; }
|
| 108 |
+
.uploader .wrap, .uploader .icon-wrap, .uploader .upload-text { color: var(--dim) !important;
|
| 109 |
+
font-family: "Press Start 2P", monospace !important; font-size: 9px !important; }
|
| 110 |
+
|
| 111 |
+
/* ---------- theme picker (3 buttons styled as cards) ---------- */
|
| 112 |
+
.theme-card, .theme-card button {
|
| 113 |
+
font-family: "Press Start 2P", monospace !important; font-size: 9px !important;
|
| 114 |
+
color: var(--ink) !important; cursor: pointer; height: 100%;
|
| 115 |
+
display: flex !important; flex-direction: column; align-items: center; gap: 10px;
|
| 116 |
+
padding: 18px 8px !important; background: var(--bg2) !important;
|
| 117 |
+
border: var(--border) solid var(--dim) !important; box-shadow: 4px 4px 0 var(--shadow) !important;
|
| 118 |
+
border-radius: 0 !important; white-space: pre-line; line-height: 1.6;
|
| 119 |
+
}
|
| 120 |
+
.theme-card:hover, .theme-card button:hover { border-color: var(--accent) !important; background: var(--bg2) !important; }
|
| 121 |
+
.theme-card.selected, .theme-card.selected button {
|
| 122 |
+
border-color: var(--gold) !important; background: var(--panel) !important;
|
| 123 |
+
box-shadow: 4px 4px 0 var(--accent2) !important;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* ---------- compose textarea (gr.Textbox) ---------- */
|
| 127 |
+
.compose textarea {
|
| 128 |
+
width: 100%; resize: vertical; font-family: "Press Start 2P", monospace !important;
|
| 129 |
+
font-size: 10px !important; line-height: 1.8 !important; color: var(--ink) !important;
|
| 130 |
+
background: var(--bg2) !important; border: var(--border) solid var(--accent) !important;
|
| 131 |
+
padding: 14px !important; box-shadow: inset 3px 3px 0 var(--shadow); border-radius: 0 !important;
|
| 132 |
+
}
|
| 133 |
+
.compose textarea:focus { outline: none; border-color: var(--gold) !important; box-shadow: inset 3px 3px 0 var(--shadow) !important; }
|
| 134 |
+
.compose label, .compose span[data-testid] { display: none !important; }
|
| 135 |
+
|
| 136 |
+
/* ---------- adventure header ---------- */
|
| 137 |
+
.adventure-header h2 { font-size: 14px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); margin: 6px 0; }
|
| 138 |
+
|
| 139 |
+
/* ---------- quest cards ---------- */
|
| 140 |
+
.quest-card {
|
| 141 |
+
background: var(--panel) !important; border: var(--border) solid var(--ink) !important;
|
| 142 |
+
box-shadow: 8px 8px 0 var(--shadow) !important; overflow: hidden; margin-bottom: 20px !important;
|
| 143 |
+
border-radius: 0 !important; padding: 0 !important;
|
| 144 |
+
}
|
| 145 |
+
.quest-card.is-frog { border-color: var(--frog) !important; box-shadow: 8px 8px 0 var(--frog) !important; }
|
| 146 |
+
.quest-card.is-bonus { border-style: dashed !important; }
|
| 147 |
+
|
| 148 |
+
.quest-art .image-container, .quest-art [data-testid="image"] {
|
| 149 |
+
min-height: 200px !important; background: var(--bg2) !important; border-radius: 0 !important;
|
| 150 |
+
border: none !important; border-right: var(--border) solid var(--ink) !important;
|
| 151 |
+
}
|
| 152 |
+
.quest-art img { image-rendering: pixelated; object-fit: cover; width: 100%; }
|
| 153 |
+
|
| 154 |
+
.quest-body { padding: 16px 18px; }
|
| 155 |
+
.quest-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
| 156 |
+
.badge { font-size: 8px; padding: 5px 7px; border: 2px solid currentColor; display: inline-block; }
|
| 157 |
+
.frog-badge { color: var(--frog); }
|
| 158 |
+
.bonus-badge { color: var(--accent); }
|
| 159 |
+
.group-badge { color: var(--gold); }
|
| 160 |
+
.quest-title { margin: 4px 0 10px; font-size: 12px; color: var(--ink); line-height: 1.6; }
|
| 161 |
+
.quest-narrative { margin: 0 0 12px; font-size: 9px; color: var(--dim); line-height: 1.9; }
|
| 162 |
+
.quest-task { margin: 0 0 14px; font-size: 9px; color: var(--accent); line-height: 1.8; }
|
| 163 |
+
.quest-task::before { content: "▸ TASK: "; color: var(--dim); }
|
| 164 |
+
.xp { font-size: 9px; color: var(--gold); }
|
| 165 |
+
.xp::before { content: "★ "; }
|
| 166 |
+
|
| 167 |
+
@media (max-width: 620px) {
|
| 168 |
+
.fq-logo { font-size: 16px; }
|
| 169 |
+
}
|