VirusDumb commited on
Commit
e02efab
·
1 Parent(s): a94c8cf

First pass

Browse files
Files changed (6) hide show
  1. app.py +250 -41
  2. static/app.js +0 -232
  3. static/index.html +0 -102
  4. static/storage.js +0 -82
  5. static/style.css +0 -171
  6. theme.css +169 -0
app.py CHANGED
@@ -1,62 +1,271 @@
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
- NOTE: the @spaces.GPU functions live in llm.py / images.py and are imported AT MODULE LEVEL
8
- below. ZeroGPU scans for @spaces.GPU functions during startup by importing this file, so the
9
- import must NOT be deferred into the endpoints (doing so triggers
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 json
 
 
19
  import os
 
20
 
21
- from fastapi.responses import HTMLResponse
22
- from fastapi.staticfiles import StaticFiles
23
- from gradio import Server
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
- app = Server()
33
- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
 
35
 
36
- @app.get("/", response_class=HTMLResponse)
37
- async def homepage():
38
- with open(os.path.join(STATIC_DIR, "index.html"), "r", encoding="utf-8") as f:
39
- return f.read()
 
 
 
 
 
 
 
 
 
40
 
41
 
42
- @app.api(name="generate_quests")
43
- def generate_quests(todos: str, theme: str) -> str:
44
- """Nemotron -> validated quest JSON (returned as a JSON string)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  raw = generate_quests_raw(todos, theme)
46
  adventure = validate_and_clamp(raw, theme)
47
- return json.dumps(adventure)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
 
 
 
 
 
 
49
 
50
- @app.api(name="generate_image")
51
- def generate_image(photo_b64: str, art_style: str, scene_prompt: str, seed: int) -> str:
52
- """FLUX.2 klein initial image with the user as hero -> PNG data URL."""
53
- photo = b64_to_pil(photo_b64)
54
- img = initial_image(photo, art_style, scene_prompt, int(seed))
55
- return pil_to_data_url(img)
56
 
57
 
58
- # Launch at MODULE LEVEL (no __main__ guard) — this matches the working gr.Server+ZeroGPU
59
- # reference Space (ysharma/text-behind-image). HF Spaces IMPORTS app.py rather than running it
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
+ }