VirusDumb commited on
Commit
2d2eed9
·
1 Parent(s): 8ed502b

Second pass

Browse files
Files changed (6) hide show
  1. README.md +3 -3
  2. app.py +437 -175
  3. images.py +15 -3
  4. llm.py +41 -1
  5. schema.py +100 -35
  6. theme.css +156 -88
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: FrogQuest
3
- emoji:
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
 
1
  ---
2
  title: FrogQuest
3
+ emoji: 🐸
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.17.3
8
  python_version: '3.12'
app.py CHANGED
@@ -1,14 +1,19 @@
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
 
@@ -26,7 +31,7 @@ import gradio as gr
26
  import spaces
27
  from PIL import Image
28
 
29
- from schema import validate_and_clamp
30
 
31
 
32
  # ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless
@@ -39,15 +44,16 @@ def _warmup():
39
  # Defensive import: never let a model-import error hang the whole app.
40
  MODEL_IMPORT_ERROR = None
41
  try:
42
- from llm import generate_quests_raw
43
- from images import initial_image
44
  except Exception:
45
  MODEL_IMPORT_ERROR = traceback.format_exc()
46
 
47
 
48
- # ----------------------------- photo helpers (PIL only) -----------------------------
49
 
50
  PHOTO_MAX_SIDE = 512
 
51
 
52
 
53
  def _resize_dataurl(pil: Image.Image, max_side: int = PHOTO_MAX_SIDE) -> str:
@@ -71,92 +77,327 @@ def _import_error_message() -> str:
71
  return "Model failed to load: " + (last[-1] if last else "unknown error")
72
 
73
 
74
- # ----------------------------- quest-card HTML -----------------------------
 
 
 
 
 
75
 
76
- def _card_body(q: dict) -> str:
77
- badges = []
78
- if q.get("is_frog"):
79
- badges.append('<span class="badge frog-badge">🐸 THE FROG</span>')
80
- if q.get("type") == "bonus":
81
- badges.append('<span class="badge bonus-badge">✦ BONUS · OPTIONAL</span>')
82
- if q.get("goal_group"):
83
- badges.append(f'<span class="badge group-badge">⛓ {html.escape(str(q["goal_group"]))}</span>')
84
- return (
85
- '<div class="quest-body">'
86
- f'<div class="quest-badges">{"".join(badges)}</div>'
87
- f'<h3 class="quest-title">{html.escape(q.get("quest_title", ""))}</h3>'
88
- f'<p class="quest-narrative">{html.escape(q.get("narrative", ""))}</p>'
89
- f'<p class="quest-task">{html.escape(q.get("task", ""))}</p>'
90
- f'<div class="quest-foot"><span class="xp">{int(q.get("xp", 0))} XP</span></div>'
91
- '</div>'
92
- )
93
 
94
 
95
- def _title_html(adventure: dict | None) -> str:
96
- if not adventure:
97
- return ""
98
- return f'<div class="adventure-header"><h2>{html.escape(adventure.get("title", ""))}</h2></div>'
99
 
 
 
 
100
 
101
- # ----------------------------- event handlers -----------------------------
102
 
103
- def boot(state):
104
- state = state or {}
105
- profile = state.get("profile") or {}
106
- has_profile = bool(profile.get("photo") and profile.get("theme"))
107
- return (
108
- gr.update(visible=not has_profile), # onboard_col
109
- gr.update(visible=has_profile), # app_col
110
- profile.get("photo"), # photo_state
111
- profile.get("theme") or "", # theme_box
112
- state.get("quests") or [], # quests_state
113
- state.get("adventure"), # adventure_state
114
- _title_html(state.get("adventure")), # adventure_title
115
- )
116
 
117
 
118
- def begin(photo_pil, theme, state):
119
- if photo_pil is None:
120
- raise gr.Error("Upload a photo first.")
121
- if not theme:
122
- raise gr.Error("Pick a theme first.")
123
- photo_b64 = _resize_dataurl(photo_pil)
124
- state = dict(state or {})
125
- state["profile"] = {"photo": photo_b64, "theme": theme}
126
- return state, photo_b64, gr.update(visible=False), gr.update(visible=True)
127
 
128
 
129
- def go_settings(photo_b64):
130
- preview = _dataurl_to_pil(photo_b64) if photo_b64 else None
131
- return gr.update(visible=True), gr.update(visible=False), preview
132
 
133
 
134
- def forge(todos, theme, photo_b64, state):
135
- if MODEL_IMPORT_ERROR:
136
- raise gr.Error(_import_error_message())
137
- if not (todos or "").strip():
138
- raise gr.Error("Tell the oracle your plans first.")
139
- theme = theme or (state or {}).get("profile", {}).get("theme") or "cyberpunk"
140
- raw = generate_quests_raw(todos, theme)
141
- adventure = validate_and_clamp(raw, theme)
142
- state = dict(state or {})
143
- state["adventure"] = adventure["adventure"]
144
- state["quests"] = adventure["quests"]
145
- return adventure["quests"], adventure["adventure"], state, _title_html(adventure["adventure"])
146
-
147
-
148
- def _make_scene_handler(quest_id: str, scene_prompt: str):
149
- def _gen(photo_b64, adventure):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  if MODEL_IMPORT_ERROR:
151
  raise gr.Error(_import_error_message())
152
- if not photo_b64:
153
- raise gr.Error("Add a photo in Settings first.")
154
  if not adventure:
155
- raise gr.Error("Forge a quest log first.")
156
- pil = _dataurl_to_pil(photo_b64)
157
- return initial_image(pil, adventure["art_style"], scene_prompt, int(adventure["seed"]))
158
-
159
- return _gen
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
 
162
  # ----------------------------- UI -----------------------------
@@ -165,111 +406,132 @@ _DIR = os.path.dirname(os.path.abspath(__file__))
165
  with open(os.path.join(_DIR, "theme.css"), "r", encoding="utf-8") as _f:
166
  THEME_CSS = _f.read()
167
 
168
- HEAD = """
169
- <script>
170
- function fqSetTheme(t){
171
- if(!t){ return; }
172
- document.documentElement.setAttribute('data-theme', t);
173
- ['cyberpunk','fantasy','space'].forEach(function(n){
174
- var el = document.getElementById('theme-' + n);
175
- if(el){ el.classList.toggle('selected', n === t); }
176
- });
177
- }
178
- </script>
179
- """
180
-
181
- with gr.Blocks(
182
- css=THEME_CSS,
183
- head=HEAD,
184
- title="FrogQuest",
185
- theme=gr.themes.Base(font=[gr.themes.GoogleFont("Press Start 2P"), "monospace"]),
186
- ) as demo:
187
- browser = gr.BrowserState(
188
- {"profile": {"photo": None, "theme": None}, "adventure": None, "quests": []},
189
- storage_key="frogquest",
190
- )
191
- photo_state = gr.State(None) # resized photo as base64 data URL
192
- quests_state = gr.State([]) # drives the quest-log render
193
- adventure_state = gr.State(None) # title / art_style / seed
194
- theme_box = gr.Textbox(visible=False) # canonical theme; .change applies the palette
195
 
196
  gr.HTML('<div class="fq-topbar"><h1 class="fq-logo">FROG<b>QUEST</b></h1></div>')
197
 
198
- # ---------- onboarding ----------
199
- with gr.Column(visible=True) as onboard_col:
200
- with gr.Group(elem_classes=["panel"]):
201
- gr.HTML(
202
- '<h2 class="panel-title">NEW HERO</h2>'
203
- '<p class="hint">Upload a photo so you become the hero of every quest. '
204
- 'It stays in your browser — sent to the GPU only to draw your scenes, never stored '
205
- 'on a server.</p>'
206
- )
207
  photo_image = gr.Image(
208
- type="pil", sources=["upload"], show_label=False, height=240,
209
- elem_classes=["uploader"],
 
 
 
 
 
 
 
 
210
  )
211
- gr.HTML('<h3 class="panel-subtitle">CHOOSE YOUR WORLD</h3>')
212
- with gr.Row():
213
- cyber_btn = gr.Button("🌃\nCYBERPUNK", elem_id="theme-cyberpunk", elem_classes=["theme-card"])
214
- fantasy_btn = gr.Button("🏰\nFANTASY", elem_id="theme-fantasy", elem_classes=["theme-card"])
215
- space_btn = gr.Button("🚀\nSPACE", elem_id="theme-space", elem_classes=["theme-card"])
216
- begin_btn = gr.Button("BEGIN ADVENTURE ▶", elem_classes=["pix-btn", "primary"])
217
-
218
- # ---------- main app ----------
219
- with gr.Column(visible=False) as app_col:
220
- settings_btn = gr.Button("⚙ SETTINGS", elem_classes=["pix-btn", "small"])
221
- with gr.Group(elem_classes=["panel"]):
222
- gr.HTML('<h2 class="panel-title">TELL THE ORACLE YOUR PLANS</h2>')
223
- todos_box = gr.Textbox(
224
- lines=5, show_label=False, elem_classes=["compose"],
225
- placeholder=("e.g. Finish the quarterly report (due Friday), reply to 3 emails, "
226
- "book dentist, start gym routine, learn one chord on guitar..."),
227
  )
228
- forge_btn = gr.Button("⚔ FORGE QUEST LOG", elem_classes=["pix-btn", "primary"])
229
- adventure_title = gr.HTML("")
230
-
231
- @gr.render(inputs=[quests_state])
232
- def render_quests(quests):
233
- for q in quests or []:
234
- classes = ["quest-card"]
235
- if q.get("is_frog"):
236
- classes.append("is-frog")
237
- if q.get("type") == "bonus":
238
- classes.append("is-bonus")
239
- with gr.Group(elem_classes=classes):
240
- with gr.Row():
241
- with gr.Column(scale=2, min_width=160):
242
- scene_img = gr.Image(
243
- show_label=False, interactive=False, height=200,
244
- container=False, elem_classes=["quest-art"],
245
- )
246
- gen_btn = gr.Button("GENERATE SCENE", elem_classes=["pix-btn", "small"])
247
- with gr.Column(scale=3):
248
- gr.HTML(_card_body(q))
249
- gen_btn.click(
250
- _make_scene_handler(q.get("id", ""), q.get("initial_image_prompt", "")),
251
- inputs=[photo_state, adventure_state],
252
- outputs=[scene_img],
253
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
 
255
  # ---------- wiring ----------
256
- theme_box.change(None, theme_box, None, js="(t) => { fqSetTheme(t); }")
257
- cyber_btn.click(lambda: "cyberpunk", None, theme_box)
258
- fantasy_btn.click(lambda: "fantasy", None, theme_box)
259
- space_btn.click(lambda: "space", None, theme_box)
260
-
261
- begin_btn.click(begin, [photo_image, theme_box, browser], [browser, photo_state, onboard_col, app_col])
262
- settings_btn.click(go_settings, [photo_state], [onboard_col, app_col, photo_image])
263
- forge_btn.click(
264
- forge, [todos_box, theme_box, photo_state, browser],
265
- [quests_state, adventure_state, browser, adventure_title],
 
 
 
 
 
 
 
266
  )
267
 
 
 
 
 
 
 
 
268
  demo.load(
269
  boot, [browser],
270
- [onboard_col, app_col, photo_state, theme_box, quests_state, adventure_state, adventure_title],
 
271
  )
272
 
273
 
274
  if __name__ == "__main__":
275
- demo.launch()
 
 
 
 
1
+ """FrogQuest — plain Gradio (gr.Blocks) app, single-page master-detail layout.
2
 
3
+ Layout (one page, no onboarding gate):
4
+ LEFT — hero photo (click to upload/change), hero stats, world picker + reset
5
+ CENTER the selected quest's scene image, its description, and Done / Couldn't actions
6
+ RIGHT — the quest log: a selectable list of quests (selected one highlighted orange)
7
+ BOTTOM the "Frog Master" chat: one box that forges quests, adds tasks, and marks quests
8
+ done / couldn't (the LLM classifies each message into an intent — see llm.route_intent)
9
 
10
  State lives in gr.BrowserState (per-browser localStorage): the resized photo (base64), the
11
+ chosen world/theme, the validated adventure/quests JSON, the selected quest id, and a cache of
12
+ generated scene images (JPEG data-urls). The photo goes to the GPU only transiently during
13
+ generation and is never persisted server-side.
14
+
15
+ Images generate LAZILY: selecting a quest with no cached scene generates it on the spot; Done /
16
+ Couldn't EDIT the cached initial scene into a success / failure state (never regenerate).
17
  """
18
  from __future__ import annotations
19
 
 
31
  import spaces
32
  from PIL import Image
33
 
34
+ from schema import THEMES, merge_quests, validate_and_clamp
35
 
36
 
37
  # ZeroGPU scans for a @spaces.GPU function at startup; this guarantees detection regardless
 
44
  # Defensive import: never let a model-import error hang the whole app.
45
  MODEL_IMPORT_ERROR = None
46
  try:
47
+ from llm import generate_quests_raw, route_intent
48
+ from images import edit_image, initial_image, pil_to_data_url
49
  except Exception:
50
  MODEL_IMPORT_ERROR = traceback.format_exc()
51
 
52
 
53
+ # ----------------------------- photo / image helpers (PIL only) -----------------------------
54
 
55
  PHOTO_MAX_SIDE = 512
56
+ IMAGES_CAP_BYTES = 3_500_000 # soft cap on the cached-scene blob to stay under localStorage ~5MB
57
 
58
 
59
  def _resize_dataurl(pil: Image.Image, max_side: int = PHOTO_MAX_SIDE) -> str:
 
77
  return "Model failed to load: " + (last[-1] if last else "unknown error")
78
 
79
 
80
+ def _trim_images(images: dict, keep_id) -> dict:
81
+ """Bound the cached-scene blob so a BrowserState write stays under localStorage's ~5MB cap.
82
+ Drops oldest non-selected `initial` variants first (keeping success/failure = completed
83
+ progress), then whole entries if still over. Mutates and returns `images`."""
84
+ def total() -> int:
85
+ return sum(len(v) for c in images.values() for v in c.values())
86
 
87
+ if total() <= IMAGES_CAP_BYTES:
88
+ return images
89
+ for qid in list(images.keys()):
90
+ if total() <= IMAGES_CAP_BYTES:
91
+ break
92
+ if qid == keep_id:
93
+ continue
94
+ c = images[qid]
95
+ if "initial" in c and ("success" in c or "failure" in c):
96
+ del c["initial"]
97
+ for qid in list(images.keys()):
98
+ if total() <= IMAGES_CAP_BYTES:
99
+ break
100
+ if qid != keep_id:
101
+ del images[qid]
102
+ return images
 
103
 
104
 
105
+ # ----------------------------- state plumbing -----------------------------
 
 
 
106
 
107
+ def _default_state() -> dict:
108
+ return {"photo": None, "theme": "fantasy", "adventure": None,
109
+ "quests": [], "selected_id": None, "images": {}}
110
 
 
111
 
112
+ def _merge(browser: dict | None, **changes) -> dict:
113
+ """Return the browser dict with `changes` applied (other keys preserved for persistence)."""
114
+ b = dict(browser or _default_state())
115
+ b.update(changes)
116
+ return b
 
 
 
 
 
 
 
 
117
 
118
 
119
+ def _find(quests: list, qid) -> dict | None:
120
+ return next((q for q in (quests or []) if q.get("id") == qid), None)
 
 
 
 
 
 
 
121
 
122
 
123
+ def _art_style(theme: str) -> str:
124
+ return f"8-bit / 16-bit retro pixel art, {theme} palette, NES RPG style"
 
125
 
126
 
127
+ # ----------------------------- HTML builders -----------------------------
128
+
129
+ def _esc(s) -> str:
130
+ return html.escape(str(s or ""))
131
+
132
+
133
+ def _badges_html(q: dict) -> str:
134
+ badges = []
135
+ if q.get("is_frog"):
136
+ badges.append('<span class="badge frog-badge">🐸 THE FROG</span>')
137
+ if q.get("type") == "bonus":
138
+ badges.append('<span class="badge bonus-badge">✦ BONUS · OPTIONAL</span>')
139
+ if q.get("goal_group"):
140
+ badges.append(f'<span class="badge group-badge">⛓ {_esc(q["goal_group"])}</span>')
141
+ return f'<div class="quest-badges">{"".join(badges)}</div>' if badges else ""
142
+
143
+
144
+ def _desc_html(quest: dict | None, adventure: dict | None, hint: str | None = None) -> str:
145
+ adv = ""
146
+ if adventure and adventure.get("title"):
147
+ adv = f'<div class="adventure-header"><h2>{_esc(adventure["title"])}</h2></div>'
148
+ if not quest:
149
+ body = ('<div class="fq-empty">No quest selected yet.<br>'
150
+ 'Tell the Frog Master your plans below to forge your quest log.</div>')
151
+ return f'<div class="fq-desc">{adv}{body}</div>'
152
+
153
+ status = quest.get("status", "active")
154
+ state_cls = {"success": " state-success", "failure": " state-failure"}.get(status, "")
155
+ parts = [
156
+ adv,
157
+ _badges_html(quest),
158
+ f'<h3 class="quest-title">{_esc(quest.get("quest_title"))}</h3>',
159
+ f'<p class="quest-narrative">{_esc(quest.get("narrative"))}</p>',
160
+ f'<p class="quest-task">{_esc(quest.get("task"))}</p>',
161
+ f'<div class="quest-foot"><span class="xp">{int(quest.get("xp", 0))} XP</span></div>',
162
+ ]
163
+ if status in ("success", "failure") and quest.get("result_msg"):
164
+ parts.append(f'<p class="result-msg">{_esc(quest["result_msg"])}</p>')
165
+ elif hint:
166
+ parts.append(f'<p class="result-msg">{_esc(hint)}</p>')
167
+ return f'<div class="fq-desc{state_cls}">{"".join(parts)}</div>'
168
+
169
+
170
+ def _stats_html(quests: list) -> str:
171
+ quests = quests or []
172
+ total = len(quests)
173
+ done = sum(1 for q in quests if q.get("status") == "success")
174
+ failed = sum(1 for q in quests if q.get("status") == "failure")
175
+ xp = sum(int(q.get("xp", 0)) for q in quests if q.get("status") == "success")
176
+ return (
177
+ '<div class="fq-stats"><h4>HERO STATS</h4>'
178
+ f'<div class="stat-row"><span>QUESTS</span><b>{done}/{total}</b></div>'
179
+ f'<div class="stat-row"><span>XP EARNED</span><b>{xp}</b></div>'
180
+ f'<div class="stat-row"><span>RETREATS</span><b>{failed}</b></div>'
181
+ '</div>'
182
+ )
183
+
184
+
185
+ # ----------------------------- core actions (reused by clicks + chat) -----------------------------
186
+
187
+ def select_quest(qid, photo, adventure, images, quests, browser):
188
+ """Select a quest: show its cached scene, or lazily generate the initial scene on first view.
189
+ Returns (selected_id, scene_pil_or_None, desc_html, images, browser)."""
190
+ images = dict(images or {})
191
+ quest = _find(quests, qid)
192
+ if quest is None:
193
+ return qid, None, _desc_html(None, adventure), images, _merge(browser, selected_id=qid)
194
+
195
+ cache = dict(images.get(qid) or {})
196
+ want = quest.get("image_state", "initial")
197
+ data = cache.get(want) or cache.get("initial")
198
+ scene = None
199
+ hint = None
200
+ if data is not None:
201
+ scene = _dataurl_to_pil(data)
202
+ else:
203
  if MODEL_IMPORT_ERROR:
204
  raise gr.Error(_import_error_message())
 
 
205
  if not adventure:
206
+ raise gr.Error("Forge a quest log first — tell the Frog Master your plans.")
207
+ if not photo:
208
+ hint = "Upload your photo (left) to draw this scene."
209
+ else:
210
+ pil = initial_image(_dataurl_to_pil(photo), adventure["art_style"],
211
+ quest.get("initial_image_prompt", ""), int(adventure["seed"]))
212
+ cache["initial"] = pil_to_data_url(pil)
213
+ images[qid] = cache
214
+ images = _trim_images(images, qid)
215
+ scene = pil
216
+ return qid, scene, _desc_html(quest, adventure, hint), images, _merge(browser, selected_id=qid, images=images)
217
+
218
+
219
+ def _apply_result(qid, quests, adventure, photo, images, browser, kind, reason):
220
+ """Mark a quest success/failure: EDIT its initial scene into the after-state and update text.
221
+ Returns (quests, images, scene_pil, desc_html, stats_html, browser)."""
222
+ if MODEL_IMPORT_ERROR:
223
+ raise gr.Error(_import_error_message())
224
+ quests = [dict(q) for q in (quests or [])]
225
+ quest = _find(quests, qid)
226
+ if quest is None:
227
+ raise gr.Error("Select a quest first.")
228
+ if not adventure:
229
+ raise gr.Error("Forge a quest log first.")
230
+
231
+ images = dict(images or {})
232
+ cache = dict(images.get(qid) or {})
233
+ if "initial" in cache:
234
+ init_pil = _dataurl_to_pil(cache["initial"])
235
+ else:
236
+ if not photo:
237
+ raise gr.Error("Upload your photo (left) and view the scene first.")
238
+ init_pil = initial_image(_dataurl_to_pil(photo), adventure["art_style"],
239
+ quest.get("initial_image_prompt", ""), int(adventure["seed"]))
240
+ cache["initial"] = pil_to_data_url(init_pil)
241
+
242
+ instruction = reason or (quest["success_edit"] if kind == "success" else quest["failure_edit"])
243
+ edited = edit_image(init_pil, instruction, adventure["art_style"], int(adventure["seed"]))
244
+ cache[kind] = pil_to_data_url(edited)
245
+ images[qid] = cache
246
+ images = _trim_images(images, qid)
247
+
248
+ quest["status"] = kind
249
+ quest["image_state"] = kind
250
+ if kind == "success":
251
+ quest["result_msg"] = f"⚔ VICTORY! +{quest['xp']} XP — {quest['quest_title']} conquered."
252
+ else:
253
+ msg = "🌙 Retreat for now — you'll face it another day. No shame in resting."
254
+ if reason:
255
+ msg = f"{reason.strip()} · {msg}"
256
+ quest["result_msg"] = msg
257
+
258
+ desc = _desc_html(quest, adventure)
259
+ return quests, images, edited, desc, _stats_html(quests), _merge(browser, quests=quests, images=images)
260
+
261
+
262
+ def apply_done(qid, quests, adventure, photo, images, browser):
263
+ if qid is None:
264
+ raise gr.Error("Select a quest first.")
265
+ return _apply_result(qid, quests, adventure, photo, images, browser, "success", None)
266
+
267
+
268
+ def apply_couldnt(qid, quests, adventure, photo, images, browser):
269
+ if qid is None:
270
+ raise gr.Error("Select a quest first.")
271
+ return _apply_result(qid, quests, adventure, photo, images, browser, "failure", None)
272
+
273
+
274
+ # ----------------------------- chat (Frog Master, full intent routing) -----------------------------
275
+
276
+ def _chat_context(quests: list, selected_id) -> str:
277
+ if not quests:
278
+ return "No quest log exists yet."
279
+ lines = []
280
+ for i, q in enumerate(quests):
281
+ sel = " | SELECTED" if q.get("id") == selected_id else ""
282
+ lines.append(f"{i + 1}. id={q.get('id')} | title={q.get('quest_title')} | "
283
+ f"task={q.get('task')} | status={q.get('status')}{sel}")
284
+ return "A quest log already exists:\n" + "\n".join(lines)
285
+
286
+
287
+ def _resolve_target(quests: list, target: str, selected_id):
288
+ t = (target or "").strip().lower()
289
+ if t:
290
+ for q in quests:
291
+ if t in (str(q.get("id", "")).lower(), q.get("quest_title", "").lower(),
292
+ q.get("task", "").lower()):
293
+ return q["id"]
294
+ for q in quests: # substring fallback
295
+ if t in q.get("quest_title", "").lower() or t in q.get("task", "").lower():
296
+ return q["id"]
297
+ return selected_id
298
+
299
+
300
+ def _do_forge(message, theme, photo, browser):
301
+ raw = generate_quests_raw(message, theme)
302
+ adv = validate_and_clamp(raw, theme)
303
+ quests, adventure = adv["quests"], adv["adventure"]
304
+ frog_id = quests[0]["id"]
305
+ _sel, scene, desc, images, _b = select_quest(frog_id, photo, adventure, {}, quests, browser)
306
+ browser2 = _merge(browser, theme=theme, adventure=adventure, quests=quests,
307
+ selected_id=frog_id, images=images)
308
+ return quests, adventure, frog_id, images, scene, desc, _stats_html(quests), browser2
309
+
310
+
311
+ # chat_send outputs (order): chat_input, quests_state, adventure_state, selected_id_state,
312
+ # images_state, scene_image, desc_html, stats_html, browser
313
+ def chat_send(message, quests, adventure, theme, photo, selected_id, images, browser):
314
+ message = (message or "").strip()
315
+ nochange = (gr.update(),) * 8 # everything except the cleared input
316
+ if not message:
317
+ return ("",) + nochange
318
+ if MODEL_IMPORT_ERROR:
319
+ raise gr.Error(_import_error_message())
320
+ theme = theme if theme in THEMES else "fantasy"
321
+
322
+ intent = route_intent(message, _chat_context(quests, selected_id))
323
+ kind = intent.get("intent", "unknown")
324
+
325
+ if kind == "forge" or (kind == "add_tasks" and not quests):
326
+ q, a, fid, im, sc, de, st, b2 = _do_forge(message, theme, photo, browser)
327
+ return ("", q, a, fid, im, sc, de, st, b2)
328
+
329
+ if kind == "add_tasks":
330
+ raw = generate_quests_raw(message, theme)
331
+ quests2 = merge_quests(quests, raw, theme)
332
+ gr.Info(f"Added {len(quests2) - len(quests)} quest(s) to your log.")
333
+ return ("", quests2, gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
334
+ _stats_html(quests2), _merge(browser, quests=quests2))
335
+
336
+ if kind in ("mark_done", "mark_couldnt"):
337
+ qid = _resolve_target(quests, intent.get("target_task", ""), selected_id)
338
+ if qid is None:
339
+ gr.Info("Which quest? Select it on the right, or name it.")
340
+ return ("",) + nochange
341
+ result_kind = "success" if kind == "mark_done" else "failure"
342
+ reason = intent.get("reason") or (message if kind == "mark_couldnt" else None)
343
+ q2, im2, sc, de, st, b2 = _apply_result(qid, quests, adventure, photo, images, browser,
344
+ result_kind, reason)
345
+ return ("", q2, gr.update(), qid, im2, sc, de, st, b2)
346
+
347
+ gr.Info("Frog Master: tell me your plans to forge quests, what you finished, or what you couldn't do.")
348
+ return ("",) + nochange
349
+
350
+
351
+ # ----------------------------- other handlers -----------------------------
352
+
353
+ def upload_photo(pil, browser):
354
+ if pil is None:
355
+ return gr.update(), gr.update()
356
+ b64 = _resize_dataurl(pil)
357
+ return b64, _merge(browser, photo=b64)
358
+
359
+
360
+ def change_theme(theme, adventure, images, browser):
361
+ """The world picker drives image generation. Changing it with an adventure present re-themes
362
+ art_style and clears the cached scenes so they regenerate in the new world on next select."""
363
+ theme = theme if theme in THEMES else "fantasy"
364
+ if adventure and adventure.get("theme") != theme:
365
+ adventure = dict(adventure)
366
+ adventure["theme"] = theme
367
+ adventure["art_style"] = _art_style(theme)
368
+ desc = (f'<div class="fq-desc"><p class="fq-empty">World changed to {theme.upper()}. '
369
+ 'Reselect a quest to redraw its scene in the new style.</p></div>')
370
+ return adventure, {}, None, desc, _merge(browser, theme=theme, adventure=adventure, images={})
371
+ return gr.update(), gr.update(), gr.update(), gr.update(), _merge(browser, theme=theme)
372
+
373
+
374
+ def reset_all():
375
+ d = _default_state()
376
+ return (d, None, None, [], None, None, {}, None,
377
+ _desc_html(None, None), _stats_html([]), "fantasy")
378
+
379
+
380
+ def boot(browser):
381
+ """Restore everything from BrowserState on page load. Shows cached scenes only (never
382
+ generates) so reloads are instant; uncached scenes regenerate when the quest is clicked."""
383
+ b = browser or _default_state()
384
+ photo = b.get("photo")
385
+ quests = b.get("quests") or []
386
+ adventure = b.get("adventure")
387
+ images = b.get("images") or {}
388
+ theme = b.get("theme") or "fantasy"
389
+
390
+ quest = _find(quests, b.get("selected_id")) or (quests[0] if quests else None)
391
+ selected = quest["id"] if quest else None
392
+ scene = None
393
+ if quest:
394
+ cache = images.get(quest["id"]) or {}
395
+ data = cache.get(quest.get("image_state", "initial")) or cache.get("initial")
396
+ if data:
397
+ scene = _dataurl_to_pil(data)
398
+ photo_pil = _dataurl_to_pil(photo) if photo else None
399
+ return (photo_pil, photo, quests, adventure, selected, images,
400
+ scene, _desc_html(quest, adventure), _stats_html(quests), theme)
401
 
402
 
403
  # ----------------------------- UI -----------------------------
 
406
  with open(os.path.join(_DIR, "theme.css"), "r", encoding="utf-8") as _f:
407
  THEME_CSS = _f.read()
408
 
409
+ with gr.Blocks(title="FrogQuest") as demo:
410
+ browser = gr.BrowserState(_default_state(), storage_key="frogquest")
411
+ photo_state = gr.State(None) # resized photo as base64 data URL
412
+ quests_state = gr.State([]) # drives the quest-log render
413
+ adventure_state = gr.State(None) # title / art_style / seed / theme
414
+ selected_id_state = gr.State(None) # currently selected quest id
415
+ images_state = gr.State({}) # {quest_id: {initial/success/failure: jpeg data-url}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
  gr.HTML('<div class="fq-topbar"><h1 class="fq-logo">FROG<b>QUEST</b></h1></div>')
418
 
419
+ with gr.Row(elem_classes=["fq-app-grid"]):
420
+ # ---------- LEFT: hero ----------
421
+ with gr.Column(elem_classes=["fq-left"]):
422
+ gr.HTML('<h2 class="panel-title">HERO</h2>')
 
 
 
 
 
423
  photo_image = gr.Image(
424
+ type="pil", sources=["upload"], show_label=False, height=190,
425
+ elem_classes=["fq-photo"],
426
+ )
427
+ gr.HTML('<p class="hint">Click to upload / change your photo. It stays in your '
428
+ 'browser — sent to the GPU only to draw your scenes, never stored.</p>')
429
+ stats_html = gr.HTML(_stats_html([]))
430
+ gr.HTML('<h3 class="panel-subtitle">WORLD</h3>')
431
+ theme_radio = gr.Radio(
432
+ choices=list(THEMES), value="fantasy", show_label=False,
433
+ elem_classes=["fq-theme"],
434
  )
435
+ reset_btn = gr.Button("⟲ RESET", elem_classes=["pix-btn", "small"])
436
+
437
+ # ---------- CENTER: selected scene + actions ----------
438
+ with gr.Column(elem_classes=["fq-center"]):
439
+ scene_image = gr.Image(
440
+ show_label=False, interactive=False, container=False, height=380,
441
+ elem_classes=["fq-scene"],
 
 
 
 
 
 
 
 
 
442
  )
443
+ with gr.Row(elem_classes=["fq-detail"]):
444
+ with gr.Column(scale=3):
445
+ desc_html = gr.HTML(_desc_html(None, None))
446
+ with gr.Column(scale=1, min_width=136, elem_classes=["fq-actions"]):
447
+ done_btn = gr.Button("✓ DONE", elem_classes=["pix-btn", "btn-done"])
448
+ couldnt_btn = gr.Button("✗ COULDN'T", elem_classes=["pix-btn", "btn-couldnt"])
449
+
450
+ # ---------- RIGHT: quest log ----------
451
+ with gr.Column(elem_classes=["fq-right"]):
452
+ gr.HTML('<h2 class="panel-title">QUEST LOG</h2>')
453
+
454
+ @gr.render(inputs=[quests_state, selected_id_state])
455
+ def render_tasklist(quests, selected):
456
+ if not quests:
457
+ gr.HTML('<p class="hint">No quests yet.<br>Forge them with the Frog '
458
+ 'Master chat below.</p>')
459
+ return
460
+ with gr.Column(elem_classes=["fq-tasklist"]):
461
+ for q in quests:
462
+ classes = ["fq-task-item"]
463
+ if q.get("id") == selected:
464
+ classes.append("selected")
465
+ if q.get("is_frog"):
466
+ classes.append("is-frog")
467
+ if q.get("status") == "success":
468
+ classes.append("done")
469
+ elif q.get("status") == "failure":
470
+ classes.append("failed")
471
+ if q.get("status") == "success":
472
+ mark = "✓"
473
+ elif q.get("status") == "failure":
474
+ mark = "✗"
475
+ elif q.get("is_frog"):
476
+ mark = "🐸"
477
+ elif q.get("type") == "bonus":
478
+ mark = "✦"
479
+ else:
480
+ mark = "•"
481
+ btn = gr.Button(f"{mark} {q.get('quest_title', '')}", elem_classes=classes)
482
+ btn.click(
483
+ (lambda qid: (lambda photo, adv, imgs, qs, br:
484
+ select_quest(qid, photo, adv, imgs, qs, br)))(q["id"]),
485
+ inputs=[photo_state, adventure_state, images_state, quests_state, browser],
486
+ outputs=[selected_id_state, scene_image, desc_html, images_state, browser],
487
+ )
488
+
489
+ # ---------- BOTTOM: Frog Master chat ----------
490
+ with gr.Row(elem_classes=["fq-chatbar"]):
491
+ chat_input = gr.Textbox(
492
+ show_label=False, lines=2, elem_classes=["compose", "fq-chat-input"],
493
+ placeholder="Tell the Frog Master your plans, what you finished, or what you couldn't do...",
494
+ )
495
+ send_btn = gr.Button("SEND ▶", elem_classes=["pix-btn", "primary", "fq-chat-send"])
496
+ gr.HTML('<p class="fq-chat-hint">e.g. "Finish the report, reply to emails, book dentist" · '
497
+ '"I finished the report" · "Couldn\'t do the gym — too tired"</p>')
498
 
499
  # ---------- wiring ----------
500
+ photo_image.upload(upload_photo, [photo_image, browser], [photo_state, browser])
501
+ theme_radio.change(
502
+ change_theme, [theme_radio, adventure_state, images_state, browser],
503
+ [adventure_state, images_state, scene_image, desc_html, browser],
504
+ )
505
+ reset_btn.click(
506
+ reset_all, None,
507
+ [browser, photo_image, photo_state, quests_state, adventure_state, selected_id_state,
508
+ images_state, scene_image, desc_html, stats_html, theme_radio],
509
+ )
510
+ done_btn.click(
511
+ apply_done, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
512
+ [quests_state, images_state, scene_image, desc_html, stats_html, browser],
513
+ )
514
+ couldnt_btn.click(
515
+ apply_couldnt, [selected_id_state, quests_state, adventure_state, photo_state, images_state, browser],
516
+ [quests_state, images_state, scene_image, desc_html, stats_html, browser],
517
  )
518
 
519
+ _chat_inputs = [chat_input, quests_state, adventure_state, theme_radio, photo_state,
520
+ selected_id_state, images_state, browser]
521
+ _chat_outputs = [chat_input, quests_state, adventure_state, selected_id_state, images_state,
522
+ scene_image, desc_html, stats_html, browser]
523
+ send_btn.click(chat_send, _chat_inputs, _chat_outputs)
524
+ chat_input.submit(chat_send, _chat_inputs, _chat_outputs)
525
+
526
  demo.load(
527
  boot, [browser],
528
+ [photo_image, photo_state, quests_state, adventure_state, selected_id_state, images_state,
529
+ scene_image, desc_html, stats_html, theme_radio],
530
  )
531
 
532
 
533
  if __name__ == "__main__":
534
+ demo.launch(
535
+ css=THEME_CSS,
536
+ theme=gr.themes.Base(font=[gr.themes.GoogleFont("Press Start 2P"), "monospace"]),
537
+ )
images.py CHANGED
@@ -79,8 +79,20 @@ def b64_to_pil(data_url_or_b64: str) -> Image.Image:
79
  return Image.open(io.BytesIO(raw)).convert("RGB")
80
 
81
 
82
- def pil_to_data_url(img: Image.Image) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
83
  buf = io.BytesIO()
84
- img.save(buf, format="PNG")
85
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
86
- return f"data:image/png;base64,{b64}"
 
79
  return Image.open(io.BytesIO(raw)).convert("RGB")
80
 
81
 
82
+ def pil_to_data_url(img: Image.Image, fmt: str = "JPEG", quality: int = 82) -> str:
83
+ """Encode a PIL image as a data URL. Defaults to JPEG (~80-200KB for a 768px scene) so the
84
+ quest-image cache fits in localStorage; pass fmt="PNG" for lossless when size doesn't matter.
85
+ """
86
+ fmt = (fmt or "JPEG").upper()
87
+ if fmt in ("JPG", "JPEG"):
88
+ fmt = "JPEG"
89
+ img = img.convert("RGB") # JPEG has no alpha channel
90
+ save_kwargs = {"quality": quality}
91
+ mime = "jpeg"
92
+ else:
93
+ save_kwargs = {}
94
+ mime = fmt.lower()
95
  buf = io.BytesIO()
96
+ img.save(buf, format=fmt, **save_kwargs)
97
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
98
+ return f"data:image/{mime};base64,{b64}"
llm.py CHANGED
@@ -16,7 +16,7 @@ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1") # MUST precede huggingf
16
 
17
  import spaces # noqa: E402
18
 
19
- from schema import RESPONSE_SCHEMA # noqa: E402
20
 
21
  # Verified on HF (June 2026). unsloth's GGUF of NVIDIA Nemotron-3 Nano 4B (the repo actually
22
  # exists; the older Llama-3.1-Nemotron-Nano GGUF id 404s). Q8_0 (~4.3GB, near-fp16) over Q4_K_M:
@@ -43,6 +43,19 @@ For EVERY quest write vivid {theme}-themed, 8-bit pixel-art image instructions w
43
  Set adventure.art_style to one shared "8-bit / 16-bit pixel-art, {theme} palette" string applied to every image, and adventure.seed to a single integer for the whole adventure. xp 10-100 by effort. All status:"active", image_state:"initial". Echo the user's real wording in each quest.task.
44
  /no_think"""
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  _llm = None
47
 
48
 
@@ -124,6 +137,33 @@ def generate_quests_raw(todos: str, theme: str) -> dict:
124
  return _extract_json(content)
125
 
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def _extract_json(text: str) -> dict:
128
  """Parse JSON from the model output, tolerating stray prose or code fences."""
129
  text = (text or "").strip()
 
16
 
17
  import spaces # noqa: E402
18
 
19
+ from schema import INTENT_SCHEMA, RESPONSE_SCHEMA # noqa: E402
20
 
21
  # Verified on HF (June 2026). unsloth's GGUF of NVIDIA Nemotron-3 Nano 4B (the repo actually
22
  # exists; the older Llama-3.1-Nemotron-Nano GGUF id 404s). Q8_0 (~4.3GB, near-fp16) over Q4_K_M:
 
43
  Set adventure.art_style to one shared "8-bit / 16-bit pixel-art, {theme} palette" string applied to every image, and adventure.seed to a single integer for the whole adventure. xp 10-100 by effort. All status:"active", image_state:"initial". Echo the user's real wording in each quest.task.
44
  /no_think"""
45
 
46
+ # Frog Master chat router. Classifies one user message into a single intent and OUTPUTS JSON ONLY.
47
+ INTENT_SYSTEM_PROMPT = """You are FrogQuest's "Frog Master" router. Read ONE user message plus a short context describing the current quest log, and classify it into EXACTLY ONE intent. OUTPUT JSON ONLY - no prose.
48
+
49
+ intent must be one of:
50
+ - "forge": the user is describing their to-do list / plans / goals for the first time (or wants a brand-new quest log). Use this when no quest log exists yet, or they clearly want to start over.
51
+ - "add_tasks": the user wants to ADD one or more new tasks/goals to the EXISTING quest log.
52
+ - "mark_done": the user says they FINISHED/completed a task. Put the task they mean in target_task (match it to one of the listed quest titles or tasks; leave empty to mean the currently selected quest).
53
+ - "mark_couldnt": the user could NOT do a task, or wants to skip/postpone it. Put the task in target_task (empty = currently selected quest) and put their explanation in reason.
54
+ - "unknown": small talk, a question, or anything that doesn't fit the above.
55
+
56
+ Only "forge" and "add_tasks" describe NEW work; if a log already exists and the user is describing more things to do, prefer "add_tasks". target_task should copy the matching quest's title or task wording when you can identify it.
57
+ /no_think"""
58
+
59
  _llm = None
60
 
61
 
 
137
  return _extract_json(content)
138
 
139
 
140
+ @spaces.GPU(duration=60)
141
+ def route_intent(message: str, context: str) -> dict:
142
+ """Classify one Frog Master chat message into {intent, target_task?, reason?}.
143
+
144
+ `context` is a SHORT text summary of the current log (does a log exist + quest titles/ids/
145
+ status) - never images (CLAUDE.md rule). Returns the raw classification; the caller decides
146
+ what to do. Falls back to {"intent": "unknown"} on unparseable output.
147
+ """
148
+ llm = _get_llm()
149
+ user = f"Context:\n{context.strip()}\n\nUser message:\n{message.strip()}"
150
+ out = llm.create_chat_completion(
151
+ messages=[
152
+ {"role": "system", "content": INTENT_SYSTEM_PROMPT},
153
+ {"role": "user", "content": user},
154
+ ],
155
+ response_format={"type": "json_object", "schema": INTENT_SCHEMA},
156
+ temperature=0.0,
157
+ max_tokens=256,
158
+ )
159
+ parsed = _extract_json(out["choices"][0]["message"]["content"])
160
+ if not isinstance(parsed, dict) or parsed.get("intent") not in (
161
+ "forge", "add_tasks", "mark_done", "mark_couldnt", "unknown",
162
+ ):
163
+ return {"intent": "unknown"}
164
+ return parsed
165
+
166
+
167
  def _extract_json(text: str) -> dict:
168
  """Parse JSON from the model output, tolerating stray prose or code fences."""
169
  text = (text or "").strip()
schema.py CHANGED
@@ -63,6 +63,23 @@ RESPONSE_SCHEMA: dict[str, Any] = {
63
  }
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def _s(value: Any, default: str = "") -> str:
67
  """Coerce to a stripped string."""
68
  if value is None:
@@ -85,6 +102,53 @@ def _slugify(text: str, fallback: str) -> str:
85
  return slug or fallback
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
89
  """Turn whatever the LLM returned into a safe, render-ready adventure object.
90
 
@@ -128,41 +192,8 @@ def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
128
  break
129
  if not isinstance(q, dict):
130
  continue
131
-
132
- qtype = q.get("type")
133
- qtype = qtype if qtype in QUEST_TYPES else "main"
134
- if qtype == "bonus":
135
- if bonus_count >= MAX_BONUS:
136
- qtype = "main" # demote excess bonus quests to main
137
- else:
138
- bonus_count += 1
139
-
140
- title = _s(q.get("quest_title")) or _s(q.get("task")) or f"Quest {idx + 1}"
141
- qid = _s(q.get("id"))
142
- if not qid or qid in seen_ids:
143
- qid = _slugify(title, f"quest-{idx + 1}")
144
- base, n = qid, 2
145
- while qid in seen_ids:
146
- qid, n = f"{base}-{n}", n + 1
147
- seen_ids.add(qid)
148
-
149
- cleaned.append({
150
- "id": qid,
151
- "task": _s(q.get("task")) or title,
152
- "quest_title": title,
153
- "narrative": _s(q.get("narrative")),
154
- "type": qtype,
155
- "goal_group": _s(q.get("goal_group")) or None,
156
- "is_frog": bool(q.get("is_frog")),
157
- "initial_image_prompt": _s(q.get("initial_image_prompt")),
158
- "success_edit": _s(q.get("success_edit")) or "Show the hero victorious.",
159
- "failure_edit": _s(q.get("failure_edit"))
160
- or "The hero retreats safely to rest and try again another day.",
161
- "xp": _clamp_int(q.get("xp"), 0, 100, 25),
162
- # fresh-generation runtime state (never trusted from the model)
163
- "status": "active",
164
- "image_state": "initial",
165
- })
166
 
167
  if not cleaned:
168
  # Degenerate output: synthesise a single placeholder so the UI still renders.
@@ -187,3 +218,37 @@ def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
187
  cleaned.insert(0, frog)
188
 
189
  return {"adventure": adventure, "quests": cleaned}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
 
66
+ # JSON Schema for the Frog Master chat router. The LLM classifies each user message into one
67
+ # intent and (optionally) names a target task and/or a free-text reason. Kept tiny: the chat
68
+ # never sees images — only a short text context (see llm.route_intent).
69
+ INTENT_SCHEMA: dict[str, Any] = {
70
+ "type": "object",
71
+ "properties": {
72
+ "intent": {
73
+ "type": "string",
74
+ "enum": ["forge", "add_tasks", "mark_done", "mark_couldnt", "unknown"],
75
+ },
76
+ "target_task": {"type": "string"},
77
+ "reason": {"type": "string"},
78
+ },
79
+ "required": ["intent"],
80
+ }
81
+
82
+
83
  def _s(value: Any, default: str = "") -> str:
84
  """Coerce to a stripped string."""
85
  if value is None:
 
102
  return slug or fallback
103
 
104
 
105
+ def _clean_quest(q: Any, idx: int, seen_ids: set[str], qtype: str) -> dict[str, Any]:
106
+ """Coerce one raw quest dict into a render-ready quest with a unique non-empty id.
107
+
108
+ `seen_ids` is mutated in place; `qtype` is the already-resolved type (so the caller owns
109
+ the bonus-cap accounting). Runtime state (status/image_state) is always set fresh here and
110
+ never trusted from the model.
111
+ """
112
+ title = _s(q.get("quest_title")) or _s(q.get("task")) or f"Quest {idx + 1}"
113
+ qid = _s(q.get("id"))
114
+ if not qid or qid in seen_ids:
115
+ qid = _slugify(title, f"quest-{idx + 1}")
116
+ base, n = qid, 2
117
+ while qid in seen_ids:
118
+ qid, n = f"{base}-{n}", n + 1
119
+ seen_ids.add(qid)
120
+
121
+ return {
122
+ "id": qid,
123
+ "task": _s(q.get("task")) or title,
124
+ "quest_title": title,
125
+ "narrative": _s(q.get("narrative")),
126
+ "type": qtype,
127
+ "goal_group": _s(q.get("goal_group")) or None,
128
+ "is_frog": bool(q.get("is_frog")),
129
+ "initial_image_prompt": _s(q.get("initial_image_prompt")),
130
+ "success_edit": _s(q.get("success_edit")) or "Show the hero victorious.",
131
+ "failure_edit": _s(q.get("failure_edit"))
132
+ or "The hero retreats safely to rest and try again another day.",
133
+ "xp": _clamp_int(q.get("xp"), 0, 100, 25),
134
+ "status": "active",
135
+ "image_state": "initial",
136
+ }
137
+
138
+
139
+ def _resolve_type(q: Any, bonus_count: int) -> tuple[str, int]:
140
+ """Resolve a quest's type, demoting bonus quests to main once MAX_BONUS is reached.
141
+ Returns (type, new_bonus_count)."""
142
+ qtype = q.get("type") if isinstance(q, dict) else None
143
+ qtype = qtype if qtype in QUEST_TYPES else "main"
144
+ if qtype == "bonus":
145
+ if bonus_count >= MAX_BONUS:
146
+ qtype = "main" # demote excess bonus quests to main
147
+ else:
148
+ bonus_count += 1
149
+ return qtype, bonus_count
150
+
151
+
152
  def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
153
  """Turn whatever the LLM returned into a safe, render-ready adventure object.
154
 
 
192
  break
193
  if not isinstance(q, dict):
194
  continue
195
+ qtype, bonus_count = _resolve_type(q, bonus_count)
196
+ cleaned.append(_clean_quest(q, idx, seen_ids, qtype))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  if not cleaned:
199
  # Degenerate output: synthesise a single placeholder so the UI still renders.
 
218
  cleaned.insert(0, frog)
219
 
220
  return {"adventure": adventure, "quests": cleaned}
221
+
222
+
223
+ def merge_quests(existing: list[dict[str, Any]], raw_new: Any, theme: str) -> list[dict[str, Any]]:
224
+ """Append the LLM's freshly generated quests to an existing log (chat "add_tasks").
225
+
226
+ New quests are cleaned through the same id-uniqueness / bonus-cap / MAX_QUESTS rules as
227
+ validate_and_clamp, but the existing log's structure is preserved: the original frog stays
228
+ first (new quests are forced is_frog=False) and existing quests are untouched. Returns the
229
+ combined list (capped at MAX_QUESTS).
230
+ """
231
+ existing = list(existing or [])
232
+ if len(existing) >= MAX_QUESTS:
233
+ return existing
234
+
235
+ raw = raw_new if isinstance(raw_new, dict) else {}
236
+ quests_in = raw.get("quests")
237
+ quests_in = quests_in if isinstance(quests_in, list) else []
238
+
239
+ seen_ids = {q.get("id") for q in existing if q.get("id")}
240
+ bonus_count = sum(1 for q in existing if q.get("type") == "bonus")
241
+ base_idx = len(existing)
242
+
243
+ added: list[dict[str, Any]] = []
244
+ for i, q in enumerate(quests_in):
245
+ if len(existing) + len(added) >= MAX_QUESTS:
246
+ break
247
+ if not isinstance(q, dict):
248
+ continue
249
+ qtype, bonus_count = _resolve_type(q, bonus_count)
250
+ cleaned = _clean_quest(q, base_idx + i, seen_ids, qtype)
251
+ cleaned["is_frog"] = False # the original frog keeps its place at the front
252
+ added.append(cleaned)
253
+
254
+ return existing + added
theme.css CHANGED
@@ -1,26 +1,26 @@
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
 
@@ -31,17 +31,17 @@ body, gradio-app, .gradio-container {
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 {
@@ -54,117 +54,185 @@ 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
- /* NOTE: do NOT `display:none` the .compose label — Gradio nests the <textarea> inside the
135
- <label>, so hiding the label hides the whole input. show_label=False already hides the text. */
136
-
137
- /* ---------- adventure header ---------- */
138
- .adventure-header h2 { font-size: 14px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); margin: 6px 0; }
139
-
140
- /* ---------- quest cards ---------- */
141
- .quest-card {
142
- background: var(--panel) !important; border: var(--border) solid var(--ink) !important;
143
- box-shadow: 8px 8px 0 var(--shadow) !important; overflow: hidden; margin-bottom: 20px !important;
144
- border-radius: 0 !important; padding: 0 !important;
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
- .quest-card.is-frog { border-color: var(--frog) !important; box-shadow: 8px 8px 0 var(--frog) !important; }
147
- .quest-card.is-bonus { border-style: dashed !important; }
148
-
149
- .quest-art .image-container, .quest-art [data-testid="image"] {
150
- min-height: 200px !important; background: var(--bg2) !important; border-radius: 0 !important;
151
- border: none !important; border-right: var(--border) solid var(--ink) !important;
152
  }
153
- .quest-art img { image-rendering: pixelated; object-fit: cover; width: 100%; }
154
 
155
- .quest-body { padding: 16px 18px; }
156
- .quest-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
157
- .badge { font-size: 8px; padding: 5px 7px; border: 2px solid currentColor; display: inline-block; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  .frog-badge { color: var(--frog); }
159
  .bonus-badge { color: var(--accent); }
160
  .group-badge { color: var(--gold); }
161
- .quest-title { margin: 4px 0 10px; font-size: 12px; color: var(--ink); line-height: 1.6; }
162
- .quest-narrative { margin: 0 0 12px; font-size: 9px; color: var(--dim); line-height: 1.9; }
163
- .quest-task { margin: 0 0 14px; font-size: 9px; color: var(--accent); line-height: 1.8; }
164
- .quest-task::before { content: "▸ TASK: "; color: var(--dim); }
165
- .xp { font-size: 9px; color: var(--gold); }
166
- .xp::before { content: "★ "; }
167
-
168
- @media (max-width: 620px) {
 
 
 
 
 
 
 
 
 
 
169
  .fq-logo { font-size: 16px; }
 
 
170
  }
 
1
  /* FrogQuest — 8-bit / NES-RPG pixel-art theme for plain Gradio (gr.Blocks).
2
+ Single-page master-detail layout (left = hero/stats/settings, center = selected scene +
3
+ actions, right = task list, bottom = Frog Master chat). Palette is the fixed "Eat That Frog"
4
+ green / black / red scheme; the cyberpunk/fantasy/space theme only flavors the generated
5
+ images, never the UI. */
6
 
7
  :root {
8
+ --bg: #0a0f0a; /* near-black green-tinted background */
9
+ --bg2: #10180f; /* raised surfaces / inputs */
10
+ --panel: #16241a; /* panels & cards */
11
+ --ink: #eafbe2; /* primary text */
12
+ --dim: #8fae84; /* muted text */
13
+ --accent: #4ade5b; /* FROG GREEN — primary actions, borders, logo */
14
+ --accent2: #e5484d; /* RED — danger / couldn't */
15
+ --select: #ff8a1f; /* ORANGE — currently-selected task highlight */
16
+ --success: #4ade5b; /* green — done */
17
+ --danger: #e5484d; /* red — couldn't */
18
+ --gold: #ffd23f; /* XP / accents */
19
+ --frog: #4ade5b; /* frog-quest border */
20
  --shadow: #000;
21
  --border: 4px;
22
  }
23
 
 
 
 
 
 
24
  /* ================= Gradio chrome neutralization ================= */
25
  *, *::before, *::after { box-sizing: border-box; }
26
 
 
31
  image-rendering: pixelated;
32
  }
33
  .gradio-container {
34
+ max-width: 1240px !important;
35
  margin: 0 auto !important;
36
+ padding: 0 16px 32px !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(120,255,120,0.02) 38px 39px),
44
+ repeating-linear-gradient(90deg, transparent 0 38px, rgba(120,255,120,0.02) 38px 39px) !important;
45
  }
46
  /* CRT scanlines overlay */
47
  body::after {
 
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, .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
+ /* ================= top bar ================= */
64
  .fq-topbar {
65
  display: flex; align-items: center; justify-content: space-between;
66
+ padding: 14px 4px; border-bottom: var(--border) solid var(--accent);
67
+ background: var(--bg2); margin-bottom: 16px;
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
+ /* ================= master-detail grid ================= */
74
+ /* the wrapping gr.Row becomes a 3-column grid; its child gr.Columns are the cells */
75
+ .fq-app-grid { display: grid !important; grid-template-columns: 248px 1fr 248px !important;
76
+ gap: 16px !important; align-items: start !important; flex-wrap: nowrap !important; }
77
+ .fq-app-grid > .fq-left, .fq-app-grid > .fq-center, .fq-app-grid > .fq-right { min-width: 0 !important; }
78
+
79
+ .panel, .fq-left, .fq-center, .fq-right {
80
  background: var(--panel) !important;
81
  border: var(--border) solid var(--ink) !important;
82
+ box-shadow: 6px 6px 0 var(--shadow) !important;
83
+ padding: 16px !important;
84
  }
85
+ .panel-title { margin: 0 0 12px; font-size: 12px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); }
86
+ .panel-subtitle { margin: 16px 0 10px; font-size: 10px; color: var(--accent); }
87
+ .hint { color: var(--dim); font-size: 9px; line-height: 1.9; }
88
 
89
+ /* ================= buttons ================= */
90
  button.pix-btn, .pix-btn button, .pix-btn {
91
+ font-family: "Press Start 2P", monospace !important; font-size: 10px !important;
92
  color: var(--ink) !important; cursor: pointer;
93
  background: var(--bg2) !important; border: var(--border) solid var(--ink) !important;
94
+ box-shadow: 4px 4px 0 var(--shadow) !important; padding: 12px 14px !important;
95
  text-transform: uppercase; border-radius: 0 !important;
96
  transition: transform .05s, box-shadow .05s; min-width: 0 !important;
97
  }
98
  button.pix-btn:hover, .pix-btn button:hover { background: var(--accent) !important; color: var(--bg) !important; }
99
  button.pix-btn:active, .pix-btn button:active { transform: translate(4px, 4px); box-shadow: 0 0 0 var(--shadow) !important; }
100
  .pix-btn.primary, .pix-btn.primary button {
101
+ background: var(--accent) !important; color: var(--bg) !important; border-color: #fff !important;
102
+ width: 100% !important; font-size: 11px !important; padding: 14px !important;
103
  }
104
  .pix-btn.primary:hover, .pix-btn.primary button:hover { background: var(--gold) !important; color: var(--bg) !important; }
105
+ .pix-btn.small, .pix-btn.small button { font-size: 8px !important; padding: 9px 10px !important; box-shadow: 3px 3px 0 var(--shadow) !important; }
106
 
107
+ /* ================= LEFT: hero photo / stats / settings ================= */
108
+ .fq-photo .image-container, .fq-photo .upload-container, .fq-photo [data-testid="image"] {
109
+ min-height: 180px !important; background: var(--bg2) !important;
 
110
  border: var(--border) dashed var(--accent) !important; border-radius: 0 !important;
111
  }
112
+ .fq-photo .image-container img { image-rendering: pixelated; object-fit: cover; }
113
+ .fq-photo .wrap, .fq-photo .icon-wrap, .fq-photo .upload-text { color: var(--dim) !important;
114
+ font-family: "Press Start 2P", monospace !important; font-size: 8px !important; }
115
+
116
+ .fq-stats { margin: 14px 0; padding: 12px; background: var(--bg2);
117
+ border: 2px solid var(--dim); }
118
+ .fq-stats h4 { margin: 0 0 8px; font-size: 9px; color: var(--accent); }
119
+ .fq-stats .stat-row { display: flex; justify-content: space-between; font-size: 9px;
120
+ color: var(--ink); margin: 6px 0; }
121
+ .fq-stats .stat-row b { color: var(--gold); }
122
+
123
+ .fq-settings { margin-top: 12px; }
124
+
125
+ /* world picker (gr.Radio styled to fit the pixel theme) */
126
+ .fq-theme, .fq-theme * { color: var(--ink) !important; }
127
+ .fq-theme .wrap { gap: 8px !important; }
128
+ .fq-theme label, .fq-theme .wrap label {
129
+ background: var(--bg2) !important; border: 3px solid var(--dim) !important;
130
+ box-shadow: 3px 3px 0 var(--shadow) !important; border-radius: 0 !important;
131
+ font-family: "Press Start 2P", monospace !important; font-size: 8px !important;
132
+ padding: 8px 10px !important; cursor: pointer; text-transform: uppercase;
133
+ }
134
+ .fq-theme label:hover { border-color: var(--accent) !important; }
135
+ .fq-theme label.selected, .fq-theme input:checked ~ * {
136
+ border-color: var(--select) !important; color: var(--select) !important;
137
+ box-shadow: 3px 3px 0 var(--select) !important;
138
+ }
139
+ .fq-theme input[type="radio"] { accent-color: var(--accent); }
140
 
141
+ /* theme picker (3 buttons styled as small cards) */
142
  .theme-card, .theme-card button {
143
+ font-family: "Press Start 2P", monospace !important; font-size: 8px !important;
144
  color: var(--ink) !important; cursor: pointer; height: 100%;
145
+ display: flex !important; flex-direction: column; align-items: center; gap: 6px;
146
+ padding: 10px 4px !important; background: var(--bg2) !important;
147
+ border: 3px solid var(--dim) !important; box-shadow: 3px 3px 0 var(--shadow) !important;
148
+ border-radius: 0 !important; white-space: pre-line; line-height: 1.5;
149
  }
150
+ .theme-card:hover, .theme-card button:hover { border-color: var(--accent) !important; }
151
  .theme-card.selected, .theme-card.selected button {
152
+ border-color: var(--select) !important; background: var(--panel) !important;
153
+ box-shadow: 3px 3px 0 var(--select) !important;
154
  }
155
 
156
+ /* ================= CENTER: scene / description / actions ================= */
157
+ .fq-scene .image-container, .fq-scene [data-testid="image"] {
158
+ min-height: 360px !important; background: var(--bg2) !important; border-radius: 0 !important;
159
+ border: var(--border) solid var(--ink) !important;
 
 
160
  }
161
+ .fq-scene img { image-rendering: pixelated; object-fit: contain; width: 100%; }
162
+
163
+ .fq-detail { display: flex; gap: 14px; align-items: stretch; margin-top: 14px; }
164
+ .fq-desc { flex: 1; background: var(--bg2); border: var(--border) solid var(--ink);
165
+ box-shadow: 4px 4px 0 var(--shadow); padding: 14px 16px; min-height: 96px; }
166
+ .fq-desc .adventure-header h2 { font-size: 13px; color: var(--gold); text-shadow: 2px 2px 0 var(--shadow); margin: 0 0 8px; }
167
+ .fq-desc .quest-title { margin: 0 0 8px; font-size: 12px; color: var(--ink); line-height: 1.6; }
168
+ .fq-desc .quest-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
169
+ .fq-desc .quest-narrative { margin: 0 0 10px; font-size: 9px; color: var(--dim); line-height: 1.9; }
170
+ .fq-desc .quest-task { margin: 0 0 8px; font-size: 9px; color: var(--accent); line-height: 1.8; }
171
+ .fq-desc .quest-task::before { content: "▸ TASK: "; color: var(--dim); }
172
+ .fq-desc .xp { font-size: 9px; color: var(--gold); }
173
+ .fq-desc .xp::before { content: "★ "; }
174
+ .fq-desc.state-success { border-color: var(--success); box-shadow: 4px 4px 0 var(--success); }
175
+ .fq-desc.state-failure { border-color: var(--danger); box-shadow: 4px 4px 0 var(--danger); }
176
+ .fq-desc .result-msg { font-size: 10px; line-height: 1.9; }
177
+ .fq-desc.state-success .result-msg { color: var(--success); }
178
+ .fq-desc.state-failure .result-msg { color: var(--danger); }
179
+ .fq-empty { color: var(--dim); font-size: 10px; line-height: 1.9; }
180
+
181
+ .fq-actions { display: flex; flex-direction: column; gap: 12px; justify-content: center; min-width: 132px; }
182
+ .btn-done, .btn-done button {
183
+ background: var(--success) !important; color: var(--bg) !important; border-color: #fff !important;
184
+ font-size: 11px !important;
185
  }
186
+ .btn-done:hover, .btn-done button:hover { background: var(--gold) !important; }
187
+ .btn-couldnt, .btn-couldnt button {
188
+ background: var(--danger) !important; color: #fff !important; border-color: #fff !important;
189
+ font-size: 9px !important;
 
 
190
  }
191
+ .btn-couldnt:hover, .btn-couldnt button:hover { background: #ff7a7e !important; color: var(--bg) !important; }
192
 
193
+ /* ================= RIGHT: task list ================= */
194
+ .fq-right .panel-title { margin-bottom: 12px; }
195
+ .fq-tasklist { display: flex; flex-direction: column; gap: 10px; }
196
+ .fq-task-item, .fq-task-item button {
197
+ font-family: "Press Start 2P", monospace !important; font-size: 9px !important;
198
+ text-align: left !important; color: var(--ink) !important; cursor: pointer;
199
+ background: var(--bg2) !important; border: 3px solid var(--ink) !important;
200
+ box-shadow: 3px 3px 0 var(--shadow) !important; padding: 11px 12px !important;
201
+ border-radius: 0 !important; width: 100% !important; line-height: 1.6; white-space: normal;
202
+ }
203
+ .fq-task-item:hover, .fq-task-item button:hover { border-color: var(--accent) !important; }
204
+ .fq-task-item.selected, .fq-task-item.selected button {
205
+ border-color: var(--select) !important; box-shadow: 4px 4px 0 var(--select) !important;
206
+ color: var(--select) !important; background: var(--panel) !important;
207
+ }
208
+ .fq-task-item.is-frog, .fq-task-item.is-frog button { border-color: var(--frog) !important; }
209
+ .fq-task-item.done, .fq-task-item.done button { opacity: 0.7; border-color: var(--success) !important; }
210
+ .fq-task-item.failed, .fq-task-item.failed button { opacity: 0.6; border-style: dashed !important; }
211
+
212
+ /* badges (reused in the description pane and task items) */
213
+ .badge { font-size: 8px; padding: 4px 6px; border: 2px solid currentColor; display: inline-block; }
214
  .frog-badge { color: var(--frog); }
215
  .bonus-badge { color: var(--accent); }
216
  .group-badge { color: var(--gold); }
217
+
218
+ /* ================= BOTTOM: Frog Master chat bar ================= */
219
+ .fq-chatbar { display: flex !important; gap: 12px !important; align-items: stretch !important;
220
+ margin-top: 16px !important; flex-wrap: nowrap !important; }
221
+ .fq-chatbar .fq-chat-input { flex: 1 !important; min-width: 0 !important; }
222
+ .fq-chatbar .fq-chat-send { width: 150px !important; flex: 0 0 auto !important; }
223
+ .compose textarea {
224
+ width: 100%; resize: none; font-family: "Press Start 2P", monospace !important;
225
+ font-size: 10px !important; line-height: 1.8 !important; color: var(--ink) !important;
226
+ background: var(--bg2) !important; border: var(--border) solid var(--accent) !important;
227
+ padding: 14px !important; box-shadow: inset 3px 3px 0 var(--shadow); border-radius: 0 !important;
228
+ }
229
+ .compose textarea:focus { outline: none; border-color: var(--gold) !important; box-shadow: inset 3px 3px 0 var(--shadow) !important; }
230
+ .fq-chat-hint { color: var(--dim); font-size: 8px; margin: 8px 2px 0; line-height: 1.8; }
231
+
232
+ /* ================= responsive: stack on narrow screens ================= */
233
+ @media (max-width: 900px) {
234
+ .fq-app-grid { grid-template-columns: 1fr !important; }
235
  .fq-logo { font-size: 16px; }
236
+ .fq-detail { flex-direction: column; }
237
+ .fq-actions { flex-direction: row; min-width: 0; }
238
  }