curieous commited on
Commit
4b565e0
·
verified ·
1 Parent(s): 40554d6

Deploy Epic Errands UI refresh

Browse files
Files changed (32) hide show
  1. .gitattributes +22 -0
  2. README.md +2 -2
  3. epic_errands_space/quest_generation.py +231 -59
  4. epic_errands_space/render.py +37 -22
  5. frontend/assets/demo-placeholders/placeholder-boy-720.png +3 -0
  6. frontend/assets/demo-placeholders/placeholder-female-parent-720.png +3 -0
  7. frontend/assets/demo-placeholders/placeholder-girl-720.png +3 -0
  8. frontend/assets/demo-placeholders/placeholder-male-parent-720.png +3 -0
  9. frontend/assets/generated-hero-manifest.json +0 -0
  10. frontend/assets/generated-v2/clean-room-classroom-c5cb506427.png +3 -0
  11. frontend/assets/generated-v2/clean-room-classroom-cf6f43d6ce.wav +3 -0
  12. frontend/assets/generated-v2/clean-room-comic-88b770a8dc.wav +3 -0
  13. frontend/assets/generated-v2/clean-room-comic-9bce2b2b21.png +3 -0
  14. frontend/assets/generated-v2/clean-room-questbook-01493f395c.wav +3 -0
  15. frontend/assets/generated-v2/clean-room-questbook-d35e6ee10c.png +3 -0
  16. frontend/assets/generated-v2/project-outline-classroom-3783f10bae.png +3 -0
  17. frontend/assets/generated-v2/project-outline-classroom-70a886be5a.wav +3 -0
  18. frontend/assets/generated-v2/project-outline-comic-4f9eaa443a.png +3 -0
  19. frontend/assets/generated-v2/project-outline-comic-ef3f180846.wav +3 -0
  20. frontend/assets/generated-v2/project-outline-questbook-bddf03af1a.wav +3 -0
  21. frontend/assets/generated-v2/project-outline-questbook-e1d5ced2e8.png +3 -0
  22. frontend/assets/generated-v2/read-20-classroom-7c13f43782.png +3 -0
  23. frontend/assets/generated-v2/read-20-classroom-cca67b8288.wav +3 -0
  24. frontend/assets/generated-v2/read-20-comic-7ec8c91bf8.wav +3 -0
  25. frontend/assets/generated-v2/read-20-comic-b5ae2d561a.png +3 -0
  26. frontend/assets/generated-v2/read-20-questbook-39aac8ba25.png +3 -0
  27. frontend/assets/generated-v2/read-20-questbook-5805518930.wav +3 -0
  28. frontend/index.html +27 -4
  29. frontend/scripts/app.js +315 -387
  30. frontend/styles/app.css +189 -801
  31. frontend/styles/components.css +350 -174
  32. frontend/styles/tokens.css +323 -52
.gitattributes CHANGED
@@ -47,3 +47,25 @@ frontend/assets/generated-audio-3-read-for-20-minutes-5e944b16.wav filter=lfs di
47
  frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png filter=lfs diff=lfs merge=lfs -text
48
  frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png filter=lfs diff=lfs merge=lfs -text
49
  frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  frontend/assets/generated-hero-1-clean-up-my-room-before-dinner-07b300bc.png filter=lfs diff=lfs merge=lfs -text
48
  frontend/assets/generated-hero-2-finish-my-class-project-outline-4a8a06bc.png filter=lfs diff=lfs merge=lfs -text
49
  frontend/assets/generated-hero-3-read-for-20-minutes-3781199a.png filter=lfs diff=lfs merge=lfs -text
50
+ frontend/assets/demo-placeholders/placeholder-boy-720.png filter=lfs diff=lfs merge=lfs -text
51
+ frontend/assets/demo-placeholders/placeholder-female-parent-720.png filter=lfs diff=lfs merge=lfs -text
52
+ frontend/assets/demo-placeholders/placeholder-girl-720.png filter=lfs diff=lfs merge=lfs -text
53
+ frontend/assets/demo-placeholders/placeholder-male-parent-720.png filter=lfs diff=lfs merge=lfs -text
54
+ frontend/assets/generated-v2/clean-room-classroom-c5cb506427.png filter=lfs diff=lfs merge=lfs -text
55
+ frontend/assets/generated-v2/clean-room-classroom-cf6f43d6ce.wav filter=lfs diff=lfs merge=lfs -text
56
+ frontend/assets/generated-v2/clean-room-comic-88b770a8dc.wav filter=lfs diff=lfs merge=lfs -text
57
+ frontend/assets/generated-v2/clean-room-comic-9bce2b2b21.png filter=lfs diff=lfs merge=lfs -text
58
+ frontend/assets/generated-v2/clean-room-questbook-01493f395c.wav filter=lfs diff=lfs merge=lfs -text
59
+ frontend/assets/generated-v2/clean-room-questbook-d35e6ee10c.png filter=lfs diff=lfs merge=lfs -text
60
+ frontend/assets/generated-v2/project-outline-classroom-3783f10bae.png filter=lfs diff=lfs merge=lfs -text
61
+ frontend/assets/generated-v2/project-outline-classroom-70a886be5a.wav filter=lfs diff=lfs merge=lfs -text
62
+ frontend/assets/generated-v2/project-outline-comic-4f9eaa443a.png filter=lfs diff=lfs merge=lfs -text
63
+ frontend/assets/generated-v2/project-outline-comic-ef3f180846.wav filter=lfs diff=lfs merge=lfs -text
64
+ frontend/assets/generated-v2/project-outline-questbook-bddf03af1a.wav filter=lfs diff=lfs merge=lfs -text
65
+ frontend/assets/generated-v2/project-outline-questbook-e1d5ced2e8.png filter=lfs diff=lfs merge=lfs -text
66
+ frontend/assets/generated-v2/read-20-classroom-7c13f43782.png filter=lfs diff=lfs merge=lfs -text
67
+ frontend/assets/generated-v2/read-20-classroom-cca67b8288.wav filter=lfs diff=lfs merge=lfs -text
68
+ frontend/assets/generated-v2/read-20-comic-7ec8c91bf8.wav filter=lfs diff=lfs merge=lfs -text
69
+ frontend/assets/generated-v2/read-20-comic-b5ae2d561a.png filter=lfs diff=lfs merge=lfs -text
70
+ frontend/assets/generated-v2/read-20-questbook-39aac8ba25.png filter=lfs diff=lfs merge=lfs -text
71
+ frontend/assets/generated-v2/read-20-questbook-5805518930.wav filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -21,8 +21,8 @@ The prototype includes:
21
 
22
  - Home, Parent Goals, Kid Goals, and disabled DIY Lab navigation.
23
  - Quest, Classroom, and Comic app theme switching.
24
- - Quest is the default visible theme; the internal style id `magical` is kept
25
- only for V1 compatibility.
26
  - Theme-based parent/kid titles such as Quest Keeper and Young Adventurer.
27
  - Kid completion state separated from parent approval/reward state.
28
  - Parent approve-only reward flow.
 
21
 
22
  - Home, Parent Goals, Kid Goals, and disabled DIY Lab navigation.
23
  - Quest, Classroom, and Comic app theme switching.
24
+ - Questbook is the default visible theme; the canonical internal style id is
25
+ `questbook`, with legacy `magical` accepted only as a compatibility alias.
26
  - Theme-based parent/kid titles such as Quest Keeper and Young Adventurer.
27
  - Kid completion state separated from parent approval/reward state.
28
  - Parent approve-only reward flow.
epic_errands_space/quest_generation.py CHANGED
@@ -27,8 +27,9 @@ VOXCPM2_MODEL_ID = "openbmb/VoxCPM2"
27
  NEMOTRON_SPEECH_MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b"
28
 
29
  THEME_ALIASES = {
30
- "quest": "magical",
31
- "magical": "magical",
 
32
  "class": "classroom",
33
  "classroom": "classroom",
34
  "comic": "comic",
@@ -66,7 +67,7 @@ def default_goal_payload() -> list[dict[str, str]]:
66
  return [{"id": goal.id, "text": goal.text} for goal in DEFAULT_GOALS]
67
 
68
 
69
- def _theme(value: Any, fallback: str = "magical") -> str:
70
  normalized = str(value or fallback).strip().lower()
71
  return THEME_ALIASES.get(normalized, fallback)
72
 
@@ -83,12 +84,12 @@ def normalize_goals(raw_goals: Any) -> list[dict[str, str]]:
83
  goals.append({
84
  "id": str(item.get("id") or f"goal-{index + 1}"),
85
  "text": text,
86
- "theme": _theme(item.get("theme") or item.get("style_id"), "magical"),
87
  })
88
  if len(goals) == 3:
89
  break
90
 
91
- fallback_themes = ["magical", "classroom", "comic"]
92
  for fallback_index, fallback in enumerate(default_goal_payload()):
93
  if len(goals) == 3:
94
  break
@@ -144,7 +145,7 @@ def _step_key(goal_text: str) -> str:
144
  return "default"
145
 
146
 
147
- def _quest_title(goal_text: str, tone: str, theme: str = "magical") -> str:
148
  lowered = goal_text.lower()
149
  if theme == "classroom":
150
  noun = "Quest"
@@ -155,7 +156,7 @@ def _quest_title(goal_text: str, tone: str, theme: str = "magical") -> str:
155
  tone_word = {
156
  "cozy": "Cozy",
157
  "brave": "Brave",
158
- "magical": "Quest",
159
  "silly": "Silly",
160
  "epic": "Epic",
161
  }.get(tone, "Cozy")
@@ -178,9 +179,9 @@ def _why_it_helps(goal_text: str) -> str:
178
 
179
 
180
  def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
181
- theme = _theme(goal.get("theme"), "magical")
182
  visual_direction = {
183
- "magical": (
184
  "premium storybook quest card, warm paper, gentle fantasy props, "
185
  "earned crest, no monsters or threats"
186
  ),
@@ -202,6 +203,117 @@ def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
202
  )
203
 
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dict[str, Any]:
206
  return {
207
  "goal_id": goal["id"],
@@ -210,6 +322,7 @@ def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dic
210
  "hero_src": _asset_to_data_uri(FALLBACK_HERO),
211
  "origin": "deterministic_fallback",
212
  "prompt_summary": f"Fallback classroom adventure image for goal: {goal['text']}",
 
213
  "runtime_metadata": _runtime_metadata(
214
  fallback_used=True,
215
  fallback_reason=reason,
@@ -218,21 +331,19 @@ def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dic
218
 
219
 
220
  def _fallback_card_text(goal: dict[str, str], tone: str, reason: str) -> dict[str, Any]:
221
- steps = [label for label, _icon in STEP_LIBRARY[_step_key(goal["text"])]][:3]
222
  return {
223
  "goal_id": goal["id"],
224
  "ordinary_goal": goal["text"],
225
- "theme": _theme(goal.get("theme"), "magical"),
226
  "model_key": "deterministic_fallback",
227
  "model_id": "none",
228
  "schema_valid": True,
229
  "raw_text": "",
 
230
  "card_text": {
231
- "title": _quest_title(goal["text"], tone, _theme(goal.get("theme"), "magical")),
232
  "narration": f"Your goal card is ready: {goal['text']}. Take it one small step at a time.",
233
- "steps": steps,
234
  "reward_label": "Quest Hero",
235
- "completion_check": "Done when the ordinary goal is truly finished.",
236
  },
237
  "runtime_metadata": _runtime_metadata(fallback_used=True, fallback_reason=reason),
238
  }
@@ -242,10 +353,11 @@ def _fallback_audio_result(goal: dict[str, str], index: int, reason: str) -> dic
242
  return {
243
  "goal_id": goal["id"],
244
  "ordinary_goal": goal["text"],
245
- "theme": _theme(goal.get("theme"), "magical"),
246
  "audio_asset_name": "",
247
  "audio_src": "",
248
  "spoken_text": "",
 
249
  "runtime_metadata": _runtime_metadata(
250
  model_backend="modal_http",
251
  model_id=VOXCPM2_MODEL_ID,
@@ -310,7 +422,11 @@ def _cached_images_for(goals: list[dict[str, str]]) -> list[dict[str, Any]] | No
310
  return results
311
 
312
 
313
- def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
 
 
 
 
314
  base_url = os.environ.get("EPIC_MODAL_IMAGE_URL", "").strip() or os.environ.get("APP_MODAL_BASE_URL", "").strip()
315
  token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
316
  timeout = float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "180"))
@@ -322,8 +438,9 @@ def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str,
322
  {
323
  "goal_id": goal["id"],
324
  "ordinary_goal": goal["text"],
325
- "theme": _theme(goal.get("theme"), "magical"),
326
- "prompt": _prompt_for_goal(goal, tone),
 
327
  "seed": 26000 + index,
328
  }
329
  for index, goal in enumerate(goals)
@@ -380,6 +497,7 @@ def _call_modal(goals: list[dict[str, str]], tone: str) -> tuple[list[dict[str,
380
  "hero_src": f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}",
381
  "origin": "modal_flux_generated",
382
  "prompt_summary": output.get("prompt_summary", _prompt_for_goal(goal, tone)),
 
383
  "runtime_metadata": metadata,
384
  "seed": output.get("seed", 26000 + index),
385
  })
@@ -418,7 +536,8 @@ def _call_text_model(goals: list[dict[str, str]], model_key: str) -> tuple[list[
418
  {
419
  "goal_id": goal["id"],
420
  "ordinary_goal": goal["text"],
421
- "theme": _theme(goal.get("theme"), "magical"),
 
422
  }
423
  for goal in goals
424
  ],
@@ -458,8 +577,10 @@ def _call_voxcpm2(goals: list[dict[str, str]], card_texts: dict[str, dict[str, A
458
  {
459
  "goal_id": goal["id"],
460
  "ordinary_goal": goal["text"],
461
- "theme": _theme(goal.get("theme"), "magical"),
462
- "narration": str(card_texts.get(goal["id"], {}).get("narration") or ""),
 
 
463
  }
464
  for goal in goals
465
  ],
@@ -497,11 +618,12 @@ def _call_voxcpm2(goals: list[dict[str, str]], card_texts: dict[str, dict[str, A
497
  saved.append({
498
  "goal_id": goal["id"],
499
  "ordinary_goal": goal["text"],
500
- "theme": _theme(goal.get("theme"), "magical"),
501
  "audio_asset_name": asset_name,
502
  "audio_src": f"data:audio/wav;base64,{base64.b64encode(audio_bytes).decode('ascii')}",
503
  "audio_wav_base64": audio_b64,
504
  "spoken_text": output.get("spoken_text", ""),
 
505
  "sample_rate": output.get("sample_rate"),
506
  "runtime_metadata": metadata,
507
  })
@@ -557,19 +679,11 @@ def _quest_for_goal(
557
  audio_results: dict[str, dict[str, Any]] | None = None,
558
  asr_results: dict[str, dict[str, Any]] | None = None,
559
  ) -> dict[str, Any]:
560
- steps = [
561
- {"id": f"{goal['id']}-step-{step_index + 1}", "label": label, "icon": icon}
562
- for step_index, (label, icon) in enumerate(STEP_LIBRARY[_step_key(goal["text"])])
563
- ]
564
- theme = _theme(goal.get("theme"), "magical")
565
  primary_text = (text_results or {}).get(goal["id"], {})
566
  secondary_text = (secondary_text_results or {}).get(goal["id"], {})
567
  card_text = primary_text.get("card_text") or _fallback_card_text(goal, tone, "Primary text model did not return card_text.")["card_text"]
568
- if isinstance(card_text.get("steps"), list) and card_text["steps"]:
569
- steps = [
570
- {"id": f"{goal['id']}-step-{step_index + 1}", "label": str(label), "icon": "flag"}
571
- for step_index, label in enumerate(card_text["steps"][:4])
572
- ]
573
  audio = (audio_results or {}).get(goal["id"], {})
574
  asr = (asr_results or {}).get(goal["id"], {})
575
  return {
@@ -584,13 +698,22 @@ def _quest_for_goal(
584
  "hero_alt": f"Storybook classroom adventure for {goal['text']}",
585
  "reward": {
586
  "label": str(card_text.get("reward_label") or (["Idea Builder", "Lantern Reader", "Tidy Hero"][index] if index < 3 else "Quest Hero")),
587
- "description": str(card_text.get("completion_check") or "Reward earned after all steps are complete."),
588
  },
589
  "steps": steps,
590
  "narration": str(card_text.get("narration") or ""),
591
  "why_it_helps": _why_it_helps(goal["text"]),
592
  "image_origin": image_result.get("origin", "unknown"),
593
  "prompt_summary": image_result.get("prompt_summary", ""),
 
 
 
 
 
 
 
 
 
594
  "runtime_metadata": image_result.get("runtime_metadata", _runtime_metadata()),
595
  "text_models": {
596
  "primary": primary_text,
@@ -605,32 +728,57 @@ def _quest_for_goal(
605
  }
606
 
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool = True) -> dict[str, Any]:
609
  payload = payload or {}
610
  tone = str(payload.get("tone") or "cozy").strip().lower() or "cozy"
611
  goals = normalize_goals(payload.get("goals"))
612
  force_refresh = bool(payload.get("force_refresh"))
613
 
614
- images: list[dict[str, Any]] | None = None
615
- metadata = _runtime_metadata()
616
- blocker = ""
617
- if use_cache and not force_refresh:
618
- images = _cached_images_for(goals)
619
- if images:
620
- metadata = dict(images[0].get("runtime_metadata", metadata))
621
- metadata["fallback_used"] = any(
622
- bool(item.get("runtime_metadata", {}).get("fallback_used", True))
623
- for item in images
624
- )
625
-
626
- if images is None:
627
- try:
628
- images, metadata = _call_modal(goals, tone)
629
- except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
630
- blocker = f"{type(exc).__name__}: {exc}"
631
- images = [_fallback_image_result(goal, index, blocker) for index, goal in enumerate(goals)]
632
- metadata = _runtime_metadata(fallback_used=True, fallback_reason=blocker)
633
-
634
  text_blocker = ""
635
  minicpm_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
636
  nemotron3_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
@@ -650,16 +798,36 @@ def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool =
650
 
651
  minicpm_by_id = {item["goal_id"]: item for item in minicpm_results}
652
  nemotron3_by_id = {item["goal_id"]: item for item in nemotron3_results}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
 
654
  audio_blocker = ""
655
  audio_results = [_fallback_audio_result(goal, index, "Live speech generation is disabled.") for index, goal in enumerate(goals)]
656
  speech_metadata: dict[str, Any] = {}
657
  if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1":
658
- primary_card_texts = {
659
- goal_id: result.get("card_text") or {}
660
- for goal_id, result in minicpm_by_id.items()
661
- if isinstance(result, dict)
662
- }
663
  try:
664
  audio_results, speech_metadata["voxcpm2"] = _call_voxcpm2(goals, primary_card_texts)
665
  except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
@@ -719,9 +887,13 @@ def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool =
719
  and not asr_results
720
  )
721
 
 
 
 
722
  return {
723
  "goals": goals,
724
  "quests": quests,
 
725
  "runtime_metadata": metadata,
726
  "model_runs": {
727
  "image": metadata,
@@ -735,7 +907,7 @@ def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool =
735
  or any_asr_fallback
736
  ),
737
  "blocker": "; ".join(part for part in [blocker, text_blocker, audio_blocker, asr_blocker] if part),
738
- "generated_at": datetime.now(timezone.utc).isoformat(),
739
  }
740
 
741
 
 
27
  NEMOTRON_SPEECH_MODEL_ID = "nvidia/nemotron-speech-streaming-en-0.6b"
28
 
29
  THEME_ALIASES = {
30
+ "quest": "questbook",
31
+ "questbook": "questbook",
32
+ "magical": "questbook",
33
  "class": "classroom",
34
  "classroom": "classroom",
35
  "comic": "comic",
 
67
  return [{"id": goal.id, "text": goal.text} for goal in DEFAULT_GOALS]
68
 
69
 
70
+ def _theme(value: Any, fallback: str = "questbook") -> str:
71
  normalized = str(value or fallback).strip().lower()
72
  return THEME_ALIASES.get(normalized, fallback)
73
 
 
84
  goals.append({
85
  "id": str(item.get("id") or f"goal-{index + 1}"),
86
  "text": text,
87
+ "theme": _theme(item.get("theme") or item.get("style_id"), "questbook"),
88
  })
89
  if len(goals) == 3:
90
  break
91
 
92
+ fallback_themes = ["questbook", "classroom", "comic"]
93
  for fallback_index, fallback in enumerate(default_goal_payload()):
94
  if len(goals) == 3:
95
  break
 
145
  return "default"
146
 
147
 
148
+ def _quest_title(goal_text: str, tone: str, theme: str = "questbook") -> str:
149
  lowered = goal_text.lower()
150
  if theme == "classroom":
151
  noun = "Quest"
 
156
  tone_word = {
157
  "cozy": "Cozy",
158
  "brave": "Brave",
159
+ "questbook": "Quest",
160
  "silly": "Silly",
161
  "epic": "Epic",
162
  }.get(tone, "Cozy")
 
179
 
180
 
181
  def _prompt_for_goal(goal: dict[str, str], tone: str) -> str:
182
+ theme = _theme(goal.get("theme"), "questbook")
183
  visual_direction = {
184
+ "questbook": (
185
  "premium storybook quest card, warm paper, gentle fantasy props, "
186
  "earned crest, no monsters or threats"
187
  ),
 
203
  )
204
 
205
 
206
+ def _theme_label(theme: str) -> str:
207
+ return {
208
+ "questbook": "Questbook",
209
+ "classroom": "Classroom",
210
+ "comic": "Comic",
211
+ }.get(_theme(theme), theme.title())
212
+
213
+
214
+ def _text_prompt_for_goal(goal: dict[str, str]) -> str:
215
+ theme = _theme_label(_theme(goal.get("theme"), "questbook"))
216
+ return (
217
+ "Create a kid-safe Epic Errands goal card for an 8-year-old. "
218
+ f"Theme: {theme}. Plain goal: \"{goal['text']}\". "
219
+ "Return compact JSON only with keys title, narration, and reward_label. "
220
+ "Do not include task lists, verification fields, snake_case, UI copy, or "
221
+ "image text. Keep the ordinary goal visible, positive, parent-readable, and "
222
+ "achievable. No shame, threats, monsters, weapons, private details, readable "
223
+ "image text, or user photo references."
224
+ )
225
+
226
+
227
+ def _card_prompt_block(card_text: dict[str, Any] | None) -> str:
228
+ if not isinstance(card_text, dict) or not card_text:
229
+ return "Generated Epic Errand card text is unavailable; use the plain goal only."
230
+ return (
231
+ f"{str(card_text.get('title') or '').strip()}. "
232
+ f"{str(card_text.get('narration') or '').strip()}"
233
+ )
234
+
235
+
236
+ def _scene_hint_for_goal(goal_text: str) -> str:
237
+ lowered = goal_text.lower()
238
+ if "room" in lowered or "clean" in lowered:
239
+ return (
240
+ "Show a child tidying a bright bedroom, placing toys in bins and books "
241
+ "on shelves, with a cozy dinner glow through the doorway. All papers, "
242
+ "walls, signs, books, covers, and surfaces are completely blank."
243
+ )
244
+ if "project" in lowered or "class" in lowered:
245
+ return (
246
+ "Show a child organizing blank papers, colored shapes, pencils, and a "
247
+ "calm desk, with an encouraging adult nearby. Every worksheet, poster, "
248
+ "paper, notebook, and board is completely blank."
249
+ )
250
+ if "read" in lowered:
251
+ return (
252
+ "Show a child enjoying a quiet reading moment in a cozy seat with warm "
253
+ "light and a single plain book. Use a simple background with no bookshelf, "
254
+ "no book spines, no posters, and no decorative labels. The book cover, "
255
+ "book pages, and all background surfaces are completely blank."
256
+ )
257
+ return "Show a child-safe everyday accomplishment scene tied to the plain goal."
258
+
259
+
260
+ def _image_prompt_for_card(goal: dict[str, str], tone: str, card_text: dict[str, Any] | None) -> str:
261
+ theme = _theme(goal.get("theme"), "questbook")
262
+ visual_direction = {
263
+ "questbook": (
264
+ "Warm fantasy story illustration, soft lantern light, gentle adventure "
265
+ "props, cozy handcrafted polish, full-bleed scene, no parchment border, "
266
+ "no title plaque, no banner."
267
+ ),
268
+ "classroom": (
269
+ "Sunny learning illustration, tidy supplies, pencils, blank notebooks, "
270
+ "calm school-day polish, simple blank walls, no posters, no signs."
271
+ ),
272
+ "comic": (
273
+ "Bright cartoon action illustration, bold shapes, clean motion lines, "
274
+ "cheerful everyday hero energy, full-bleed scene, no frame, no panel, "
275
+ "no banner, no speech bubble."
276
+ ),
277
+ }[theme]
278
+ return (
279
+ "Generate an image-only hero illustration for a child goal app. "
280
+ f"Tone: {tone}. "
281
+ f"{visual_direction} {_scene_hint_for_goal(goal['text'])} "
282
+ "This is not a poster, title card, label, worksheet, book cover, or UI. Do not render any "
283
+ "words, alphabet letters, numerals, captions, labels, signs, handwriting, "
284
+ "book titles, speech bubbles, sound effects, logos, or UI text. Use image "
285
+ "composition only; all paper and book surfaces stay blank; no user photos, "
286
+ "no monsters or threats."
287
+ )
288
+
289
+
290
+ def _audio_prompt_for_card(goal: dict[str, str], card_text: dict[str, Any] | None) -> str:
291
+ theme = _theme(goal.get("theme"), "questbook")
292
+ voice_direction = {
293
+ "questbook": "gentle Questbook narrator, warm, clear, encouraging, paced for a child",
294
+ "classroom": "warm teacher, clear, encouraging, calm classroom cadence",
295
+ "comic": "upbeat comic narrator, energetic but not loud or frantic, clear and encouraging",
296
+ }[theme]
297
+ return (
298
+ "Generate a friendly spoken version of the Epic Errand produced by the text model. "
299
+ f"Plain goal: \"{goal['text']}\". Theme: {_theme_label(theme)}. "
300
+ f"Voice direction: {voice_direction}. Speak only the title and short narration "
301
+ f"from this generated card text: {_card_prompt_block(card_text)} "
302
+ "Do not add internal field names, warnings, or unrelated commentary."
303
+ )
304
+
305
+
306
+ def _spoken_text_for_card(goal: dict[str, str], card_text: dict[str, Any] | None) -> str:
307
+ if not isinstance(card_text, dict) or not card_text:
308
+ return f"Your Epic Errands card is ready. Today's goal is: {goal['text']}."
309
+ parts = [
310
+ str(card_text.get("title") or "").strip(),
311
+ str(card_text.get("narration") or "").strip(),
312
+ ]
313
+ spoken = " ".join(part for part in parts if part)
314
+ return spoken or f"Your Epic Errands card is ready. Today's goal is: {goal['text']}."
315
+
316
+
317
  def _fallback_image_result(goal: dict[str, str], index: int, reason: str) -> dict[str, Any]:
318
  return {
319
  "goal_id": goal["id"],
 
322
  "hero_src": _asset_to_data_uri(FALLBACK_HERO),
323
  "origin": "deterministic_fallback",
324
  "prompt_summary": f"Fallback classroom adventure image for goal: {goal['text']}",
325
+ "resolved_image_prompt": _image_prompt_for_card(goal, "cozy", None),
326
  "runtime_metadata": _runtime_metadata(
327
  fallback_used=True,
328
  fallback_reason=reason,
 
331
 
332
 
333
  def _fallback_card_text(goal: dict[str, str], tone: str, reason: str) -> dict[str, Any]:
 
334
  return {
335
  "goal_id": goal["id"],
336
  "ordinary_goal": goal["text"],
337
+ "theme": _theme(goal.get("theme"), "questbook"),
338
  "model_key": "deterministic_fallback",
339
  "model_id": "none",
340
  "schema_valid": True,
341
  "raw_text": "",
342
+ "text_prompt": _text_prompt_for_goal(goal),
343
  "card_text": {
344
+ "title": _quest_title(goal["text"], tone, _theme(goal.get("theme"), "questbook")),
345
  "narration": f"Your goal card is ready: {goal['text']}. Take it one small step at a time.",
 
346
  "reward_label": "Quest Hero",
 
347
  },
348
  "runtime_metadata": _runtime_metadata(fallback_used=True, fallback_reason=reason),
349
  }
 
353
  return {
354
  "goal_id": goal["id"],
355
  "ordinary_goal": goal["text"],
356
+ "theme": _theme(goal.get("theme"), "questbook"),
357
  "audio_asset_name": "",
358
  "audio_src": "",
359
  "spoken_text": "",
360
+ "resolved_audio_prompt": _audio_prompt_for_card(goal, None),
361
  "runtime_metadata": _runtime_metadata(
362
  model_backend="modal_http",
363
  model_id=VOXCPM2_MODEL_ID,
 
422
  return results
423
 
424
 
425
+ def _call_modal(
426
+ goals: list[dict[str, str]],
427
+ tone: str,
428
+ card_texts: dict[str, dict[str, Any]] | None = None,
429
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
430
  base_url = os.environ.get("EPIC_MODAL_IMAGE_URL", "").strip() or os.environ.get("APP_MODAL_BASE_URL", "").strip()
431
  token = os.environ.get("APP_MODAL_AUTH_TOKEN", "").strip()
432
  timeout = float(os.environ.get("APP_MODAL_TIMEOUT_SECONDS", "180"))
 
438
  {
439
  "goal_id": goal["id"],
440
  "ordinary_goal": goal["text"],
441
+ "theme": _theme(goal.get("theme"), "questbook"),
442
+ "card_text": (card_texts or {}).get(goal["id"], {}),
443
+ "prompt": _image_prompt_for_card(goal, tone, (card_texts or {}).get(goal["id"])),
444
  "seed": 26000 + index,
445
  }
446
  for index, goal in enumerate(goals)
 
497
  "hero_src": f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}",
498
  "origin": "modal_flux_generated",
499
  "prompt_summary": output.get("prompt_summary", _prompt_for_goal(goal, tone)),
500
+ "resolved_image_prompt": output.get("resolved_image_prompt", output.get("prompt_summary", "")),
501
  "runtime_metadata": metadata,
502
  "seed": output.get("seed", 26000 + index),
503
  })
 
536
  {
537
  "goal_id": goal["id"],
538
  "ordinary_goal": goal["text"],
539
+ "theme": _theme(goal.get("theme"), "questbook"),
540
+ "text_prompt": _text_prompt_for_goal(goal),
541
  }
542
  for goal in goals
543
  ],
 
577
  {
578
  "goal_id": goal["id"],
579
  "ordinary_goal": goal["text"],
580
+ "theme": _theme(goal.get("theme"), "questbook"),
581
+ "card_text": card_texts.get(goal["id"], {}),
582
+ "audio_prompt": _audio_prompt_for_card(goal, card_texts.get(goal["id"])),
583
+ "narration": _spoken_text_for_card(goal, card_texts.get(goal["id"])),
584
  }
585
  for goal in goals
586
  ],
 
618
  saved.append({
619
  "goal_id": goal["id"],
620
  "ordinary_goal": goal["text"],
621
+ "theme": _theme(goal.get("theme"), "questbook"),
622
  "audio_asset_name": asset_name,
623
  "audio_src": f"data:audio/wav;base64,{base64.b64encode(audio_bytes).decode('ascii')}",
624
  "audio_wav_base64": audio_b64,
625
  "spoken_text": output.get("spoken_text", ""),
626
+ "resolved_audio_prompt": output.get("resolved_audio_prompt", output.get("audio_prompt", "")),
627
  "sample_rate": output.get("sample_rate"),
628
  "runtime_metadata": metadata,
629
  })
 
679
  audio_results: dict[str, dict[str, Any]] | None = None,
680
  asr_results: dict[str, dict[str, Any]] | None = None,
681
  ) -> dict[str, Any]:
682
+ steps: list[dict[str, str]] = []
683
+ theme = _theme(goal.get("theme"), "questbook")
 
 
 
684
  primary_text = (text_results or {}).get(goal["id"], {})
685
  secondary_text = (secondary_text_results or {}).get(goal["id"], {})
686
  card_text = primary_text.get("card_text") or _fallback_card_text(goal, tone, "Primary text model did not return card_text.")["card_text"]
 
 
 
 
 
687
  audio = (audio_results or {}).get(goal["id"], {})
688
  asr = (asr_results or {}).get(goal["id"], {})
689
  return {
 
698
  "hero_alt": f"Storybook classroom adventure for {goal['text']}",
699
  "reward": {
700
  "label": str(card_text.get("reward_label") or (["Idea Builder", "Lantern Reader", "Tidy Hero"][index] if index < 3 else "Quest Hero")),
701
+ "description": "Reward earned when the ordinary goal is finished.",
702
  },
703
  "steps": steps,
704
  "narration": str(card_text.get("narration") or ""),
705
  "why_it_helps": _why_it_helps(goal["text"]),
706
  "image_origin": image_result.get("origin", "unknown"),
707
  "prompt_summary": image_result.get("prompt_summary", ""),
708
+ "generation_trace": {
709
+ "text_prompt": primary_text.get("text_prompt", _text_prompt_for_goal(goal)),
710
+ "card_text": card_text,
711
+ "image_prompt_template": "Generated Epic Errand meaning + theme visual direction + strict no-text negatives.",
712
+ "resolved_image_prompt": image_result.get("resolved_image_prompt", image_result.get("prompt_summary", "")),
713
+ "audio_prompt_template": "Generated Epic Errand title/narration + theme voice direction + no-field-name rule.",
714
+ "resolved_audio_prompt": audio.get("resolved_audio_prompt", _audio_prompt_for_card(goal, card_text)),
715
+ "spoken_text": audio.get("spoken_text", _spoken_text_for_card(goal, card_text)),
716
+ },
717
  "runtime_metadata": image_result.get("runtime_metadata", _runtime_metadata()),
718
  "text_models": {
719
  "primary": primary_text,
 
728
  }
729
 
730
 
731
+ def _agent_trace_row(quest: dict[str, Any], run_generated_at: str) -> dict[str, Any]:
732
+ trace = quest.get("generation_trace", {})
733
+ primary_text = quest.get("text_models", {}).get("primary", {})
734
+ secondary_text = quest.get("text_models", {}).get("secondary", {})
735
+ image_runtime = quest.get("runtime_metadata", _runtime_metadata())
736
+ speech_runtime = quest.get("speech_runtime_metadata", _runtime_metadata())
737
+ asr_runtime = quest.get("asr_runtime_metadata", _runtime_metadata())
738
+ plain_goal = str(quest.get("ordinary_goal") or "")
739
+ theme = str(quest.get("theme") or "")
740
+ trace_hash = hashlib.sha1(f"{plain_goal}:{theme}:{run_generated_at}".encode("utf-8")).hexdigest()[:10]
741
+ return {
742
+ "trace_id": f"epic-errands-v1-{trace_hash}",
743
+ "generated_at": run_generated_at,
744
+ "goal_id": quest.get("source_goal_id", ""),
745
+ "plain_goal": plain_goal,
746
+ "theme": theme,
747
+ "text_model_primary": primary_text.get("model_id", image_runtime.get("model_id", "none")),
748
+ "text_model_secondary": secondary_text.get("model_id", ""),
749
+ "text_prompt": trace.get("text_prompt", ""),
750
+ "card_text": trace.get("card_text", {}),
751
+ "image_model_id": image_runtime.get("model_id", MODEL_ID),
752
+ "image_prompt_template": trace.get("image_prompt_template", ""),
753
+ "resolved_image_prompt": trace.get("resolved_image_prompt", ""),
754
+ "image_asset_path": quest.get("hero_asset_name", ""),
755
+ "audio_model_id": speech_runtime.get("model_id", VOXCPM2_MODEL_ID),
756
+ "audio_prompt_template": trace.get("audio_prompt_template", ""),
757
+ "resolved_audio_prompt": trace.get("resolved_audio_prompt", ""),
758
+ "spoken_text": trace.get("spoken_text", quest.get("spoken_text", "")),
759
+ "audio_asset_path": quest.get("audio_asset_name", ""),
760
+ "asr_model_id": asr_runtime.get("model_id", NEMOTRON_SPEECH_MODEL_ID),
761
+ "asr_transcript": quest.get("asr_transcript", ""),
762
+ "runtime_axes": {
763
+ "image": image_runtime,
764
+ "audio": speech_runtime,
765
+ "asr": asr_runtime,
766
+ },
767
+ "fallback_used": (
768
+ bool(image_runtime.get("fallback_used", True))
769
+ or bool(speech_runtime.get("fallback_used", True))
770
+ or bool(asr_runtime.get("fallback_used", True))
771
+ or bool(primary_text.get("runtime_metadata", {}).get("fallback_used", False))
772
+ ),
773
+ }
774
+
775
+
776
  def build_quest_pack(payload: dict[str, Any] | None = None, *, use_cache: bool = True) -> dict[str, Any]:
777
  payload = payload or {}
778
  tone = str(payload.get("tone") or "cozy").strip().lower() or "cozy"
779
  goals = normalize_goals(payload.get("goals"))
780
  force_refresh = bool(payload.get("force_refresh"))
781
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  text_blocker = ""
783
  minicpm_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
784
  nemotron3_results = [_fallback_card_text(goal, tone, "Live text generation is disabled.") for goal in goals]
 
798
 
799
  minicpm_by_id = {item["goal_id"]: item for item in minicpm_results}
800
  nemotron3_by_id = {item["goal_id"]: item for item in nemotron3_results}
801
+ primary_card_texts = {
802
+ goal_id: result.get("card_text") or {}
803
+ for goal_id, result in minicpm_by_id.items()
804
+ if isinstance(result, dict)
805
+ }
806
+
807
+ images: list[dict[str, Any]] | None = None
808
+ metadata = _runtime_metadata()
809
+ blocker = ""
810
+ if use_cache and not force_refresh:
811
+ images = _cached_images_for(goals)
812
+ if images:
813
+ metadata = dict(images[0].get("runtime_metadata", metadata))
814
+ metadata["fallback_used"] = any(
815
+ bool(item.get("runtime_metadata", {}).get("fallback_used", True))
816
+ for item in images
817
+ )
818
+
819
+ if images is None:
820
+ try:
821
+ images, metadata = _call_modal(goals, tone, primary_card_texts)
822
+ except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
823
+ blocker = f"{type(exc).__name__}: {exc}"
824
+ images = [_fallback_image_result(goal, index, blocker) for index, goal in enumerate(goals)]
825
+ metadata = _runtime_metadata(fallback_used=True, fallback_reason=blocker)
826
 
827
  audio_blocker = ""
828
  audio_results = [_fallback_audio_result(goal, index, "Live speech generation is disabled.") for index, goal in enumerate(goals)]
829
  speech_metadata: dict[str, Any] = {}
830
  if os.environ.get("EPIC_ENABLE_LIVE_GENERATION") == "1":
 
 
 
 
 
831
  try:
832
  audio_results, speech_metadata["voxcpm2"] = _call_voxcpm2(goals, primary_card_texts)
833
  except (OSError, RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc:
 
887
  and not asr_results
888
  )
889
 
890
+ generated_at = datetime.now(timezone.utc).isoformat()
891
+ agent_traces = [_agent_trace_row(quest, generated_at) for quest in quests]
892
+
893
  return {
894
  "goals": goals,
895
  "quests": quests,
896
+ "agent_traces": agent_traces,
897
  "runtime_metadata": metadata,
898
  "model_runs": {
899
  "image": metadata,
 
907
  or any_asr_fallback
908
  ),
909
  "blocker": "; ".join(part for part in [blocker, text_blocker, audio_blocker, asr_blocker] if part),
910
+ "generated_at": generated_at,
911
  }
912
 
913
 
epic_errands_space/render.py CHANGED
@@ -23,16 +23,16 @@ STYLE_MODES: dict[str, dict[str, str]] = {
23
  "copy": "Warm school adventure with stickers, notebooks, and clear wins.",
24
  "image": "generated-hero-1-finish-class-project-8f64eeb3.png",
25
  },
26
- "magical": {
27
- "label": "Magical",
28
- "mark": "MG",
29
- "goal_noun": "Spell Quest",
30
- "parent_title": "Chief Wizard",
31
- "kid_title": "Junior Wizard",
32
- "reward_noun": "charm",
33
- "reward": "Glow charm",
34
- "waiting": "Waiting for Chief Wizard approval.",
35
- "copy": "Gentle spellbook fantasy with cozy magic and glowing rewards.",
36
  "image": "generated-hero-2-read-for-20-minutes-895e7642.png",
37
  },
38
  "comic": {
@@ -53,8 +53,8 @@ INITIAL_GOALS: list[dict[str, str]] = [
53
  {
54
  "id": "goal-1",
55
  "ordinary_goal": "Finish class project",
56
- "title": "Spell Quest: Bright Idea Bridge",
57
- "style_id": "magical",
58
  "kid_completion_state": "completed",
59
  "parent_reward_state": "waiting_for_approval",
60
  },
@@ -93,7 +93,7 @@ def _initial_state() -> dict[str, Any]:
93
  }
94
  return {
95
  "activeTab": "home",
96
- "defaultStyleId": "magical",
97
  "selectedGoalId": "goal-1",
98
  "parentName": "Maya",
99
  "kidName": "Ari",
@@ -175,7 +175,7 @@ def build_app_css() -> str:
175
  --reward: #11784f;
176
  }
177
 
178
- .ee-shell[data-style="magical"] {
179
  --theme: #7652c7;
180
  --theme-2: #0f877e;
181
  --theme-3: #efb84a;
@@ -656,9 +656,17 @@ if (root && root.dataset.ready !== "true") {
656
  root.dataset.ready = "true";
657
 
658
  const state = __STATE_JSON__;
 
 
 
 
 
 
 
 
659
  const stepsByStyle = {
660
  classroom: ["Find your reading spot", "Read the next pages", "Tell the best part"],
661
- magical: ["Gather your project ideas", "Build the first bright piece", "Check your spellbook notes"],
662
  comic: ["Sort the pile", "Fold the hero gear", "Launch it to the drawer"],
663
  };
664
 
@@ -671,13 +679,19 @@ function escapeHtml(value) {
671
  .replace(/'/g, "&#039;");
672
  }
673
 
 
 
 
 
 
674
  function styleFor(goalOrStyleId) {
675
- const styleId = typeof goalOrStyleId === "string" ? goalOrStyleId : goalOrStyleId.style_id;
 
676
  return state.styles[styleId] || state.styles.classroom;
677
  }
678
 
679
  function shellStyle() {
680
- return state.styles[state.defaultStyleId] || state.styles.magical;
681
  }
682
 
683
  function awaitingApprovalCount() {
@@ -824,7 +838,7 @@ function renderKid() {
824
  ${state.goals.map((goal) => {
825
  const style = styleFor(goal);
826
  return `<button class="ee-kid-tile" data-action="select-goal" data-goal-id="${goal.id}" aria-pressed="${state.selectedGoalId === goal.id}">
827
- <img src="${state.images[goal.style_id] || ""}" alt="${escapeHtml(style.label)} generated image for ${escapeHtml(goal.ordinary_goal)}">
828
  <span class="ee-tile-body">
829
  <span class="ee-tile-title">${escapeHtml(goal.title)}</span>
830
  ${stateChip(goal)}
@@ -838,11 +852,12 @@ function renderKid() {
838
 
839
  function renderGoalCard(goal) {
840
  const style = styleFor(goal);
841
- const steps = stepsByStyle[goal.style_id] || stepsByStyle.classroom;
 
842
  const waiting = goal.parent_reward_state === "waiting_for_approval";
843
  const rewarded = goal.parent_reward_state === "approved_reward_given";
844
  return `<article class="ee-goal-card" data-design-id="kid-goal-card">
845
- <img class="ee-card-image" src="${state.images[goal.style_id] || ""}" alt="${escapeHtml(style.label)} generated goal card image for ${escapeHtml(goal.ordinary_goal)}">
846
  <div class="ee-card-body">
847
  <div>
848
  <p class="ee-kicker">${escapeHtml(style.kid_title)} ${escapeHtml(style.goal_noun)}</p>
@@ -865,7 +880,7 @@ function renderGoalCard(goal) {
865
  function render() {
866
  const shell = root.querySelector(".ee-shell");
867
  const screen = state.activeTab === "parent" ? renderParent() : state.activeTab === "kid" ? renderKid() : renderHome();
868
- shell.dataset.style = state.defaultStyleId;
869
  shell.innerHTML = `${renderHeader()}<div class="ee-body">${screen}</div>`;
870
  }
871
 
@@ -881,7 +896,7 @@ root.addEventListener("click", (event) => {
881
  }
882
 
883
  if (target.dataset.action === "style") {
884
- state.defaultStyleId = target.dataset.style || state.defaultStyleId;
885
  render();
886
  return;
887
  }
 
23
  "copy": "Warm school adventure with stickers, notebooks, and clear wins.",
24
  "image": "generated-hero-1-finish-class-project-8f64eeb3.png",
25
  },
26
+ "questbook": {
27
+ "label": "Questbook",
28
+ "mark": "QB",
29
+ "goal_noun": "Quest",
30
+ "parent_title": "Quest Keeper",
31
+ "kid_title": "Young Adventurer",
32
+ "reward_noun": "crest",
33
+ "reward": "Orderkeeper crest",
34
+ "waiting": "Waiting for Quest Keeper approval.",
35
+ "copy": "Premium questbook fantasy with warm paper, ink lines, and earned crests.",
36
  "image": "generated-hero-2-read-for-20-minutes-895e7642.png",
37
  },
38
  "comic": {
 
53
  {
54
  "id": "goal-1",
55
  "ordinary_goal": "Finish class project",
56
+ "title": "Quest: Bright Idea Bridge",
57
+ "style_id": "questbook",
58
  "kid_completion_state": "completed",
59
  "parent_reward_state": "waiting_for_approval",
60
  },
 
93
  }
94
  return {
95
  "activeTab": "home",
96
+ "defaultStyleId": "questbook",
97
  "selectedGoalId": "goal-1",
98
  "parentName": "Maya",
99
  "kidName": "Ari",
 
175
  --reward: #11784f;
176
  }
177
 
178
+ .ee-shell[data-style="questbook"] {
179
  --theme: #7652c7;
180
  --theme-2: #0f877e;
181
  --theme-3: #efb84a;
 
656
  root.dataset.ready = "true";
657
 
658
  const state = __STATE_JSON__;
659
+ const styleAliases = {
660
+ quest: "questbook",
661
+ questbook: "questbook",
662
+ magical: "questbook",
663
+ class: "classroom",
664
+ classroom: "classroom",
665
+ comic: "comic",
666
+ };
667
  const stepsByStyle = {
668
  classroom: ["Find your reading spot", "Read the next pages", "Tell the best part"],
669
+ questbook: ["Gather your project ideas", "Build the first bright piece", "Check your questbook notes"],
670
  comic: ["Sort the pile", "Fold the hero gear", "Launch it to the drawer"],
671
  };
672
 
 
679
  .replace(/'/g, "&#039;");
680
  }
681
 
682
+ function normalizeStyleId(value, fallback = "questbook") {
683
+ const normalized = String(value || fallback).trim().toLowerCase();
684
+ return styleAliases[normalized] || fallback;
685
+ }
686
+
687
  function styleFor(goalOrStyleId) {
688
+ const rawStyleId = typeof goalOrStyleId === "string" ? goalOrStyleId : goalOrStyleId.style_id;
689
+ const styleId = normalizeStyleId(rawStyleId);
690
  return state.styles[styleId] || state.styles.classroom;
691
  }
692
 
693
  function shellStyle() {
694
+ return state.styles[normalizeStyleId(state.defaultStyleId)] || state.styles.questbook;
695
  }
696
 
697
  function awaitingApprovalCount() {
 
838
  ${state.goals.map((goal) => {
839
  const style = styleFor(goal);
840
  return `<button class="ee-kid-tile" data-action="select-goal" data-goal-id="${goal.id}" aria-pressed="${state.selectedGoalId === goal.id}">
841
+ <img src="${state.images[normalizeStyleId(goal.style_id)] || ""}" alt="${escapeHtml(style.label)} generated image for ${escapeHtml(goal.ordinary_goal)}">
842
  <span class="ee-tile-body">
843
  <span class="ee-tile-title">${escapeHtml(goal.title)}</span>
844
  ${stateChip(goal)}
 
852
 
853
  function renderGoalCard(goal) {
854
  const style = styleFor(goal);
855
+ const styleId = normalizeStyleId(goal.style_id);
856
+ const steps = stepsByStyle[styleId] || stepsByStyle.classroom;
857
  const waiting = goal.parent_reward_state === "waiting_for_approval";
858
  const rewarded = goal.parent_reward_state === "approved_reward_given";
859
  return `<article class="ee-goal-card" data-design-id="kid-goal-card">
860
+ <img class="ee-card-image" src="${state.images[styleId] || ""}" alt="${escapeHtml(style.label)} generated goal card image for ${escapeHtml(goal.ordinary_goal)}">
861
  <div class="ee-card-body">
862
  <div>
863
  <p class="ee-kicker">${escapeHtml(style.kid_title)} ${escapeHtml(style.goal_noun)}</p>
 
880
  function render() {
881
  const shell = root.querySelector(".ee-shell");
882
  const screen = state.activeTab === "parent" ? renderParent() : state.activeTab === "kid" ? renderKid() : renderHome();
883
+ shell.dataset.style = normalizeStyleId(state.defaultStyleId);
884
  shell.innerHTML = `${renderHeader()}<div class="ee-body">${screen}</div>`;
885
  }
886
 
 
896
  }
897
 
898
  if (target.dataset.action === "style") {
899
+ state.defaultStyleId = normalizeStyleId(target.dataset.style || state.defaultStyleId);
900
  render();
901
  return;
902
  }
frontend/assets/demo-placeholders/placeholder-boy-720.png ADDED

Git LFS Details

  • SHA256: cee157d4b1c52862901d14954cfa73ac7f2c25f6c50c6b9d385ef1b676f15096
  • Pointer size: 131 Bytes
  • Size of remote file: 689 kB
frontend/assets/demo-placeholders/placeholder-female-parent-720.png ADDED

Git LFS Details

  • SHA256: db069904a215a67105ab26924a20c8feba753eaa7c16a28e331f689faa52d71f
  • Pointer size: 131 Bytes
  • Size of remote file: 837 kB
frontend/assets/demo-placeholders/placeholder-girl-720.png ADDED

Git LFS Details

  • SHA256: c3c6b189d1fb161938e174320ad888954ee096ff1b42b1b7afea9c9215fc13e0
  • Pointer size: 131 Bytes
  • Size of remote file: 766 kB
frontend/assets/demo-placeholders/placeholder-male-parent-720.png ADDED

Git LFS Details

  • SHA256: ba2b250f01fedc514b0e66f08b2dc4f97426b76eea8170852943bec2164dd316
  • Pointer size: 131 Bytes
  • Size of remote file: 737 kB
frontend/assets/generated-hero-manifest.json CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/assets/generated-v2/clean-room-classroom-c5cb506427.png ADDED

Git LFS Details

  • SHA256: 63f0faa067c7d7693074d6f7bbfa462a74adc824e54c78bd7d4a43291222b963
  • Pointer size: 131 Bytes
  • Size of remote file: 752 kB
frontend/assets/generated-v2/clean-room-classroom-cf6f43d6ce.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c28f904ffbeddd61d2c1c42c6feeb6a7f4ad6411df294e1798886e969ac49c8d
3
+ size 476204
frontend/assets/generated-v2/clean-room-comic-88b770a8dc.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d480728fb53c35e3736bc5962bed033d43d1820e9ee2d84dfc49f5819c55a92
3
+ size 506924
frontend/assets/generated-v2/clean-room-comic-9bce2b2b21.png ADDED

Git LFS Details

  • SHA256: e982e07ffba895aa00638e04503e399b3a7e95966d831e415ceaf3aa9aa83b91
  • Pointer size: 131 Bytes
  • Size of remote file: 720 kB
frontend/assets/generated-v2/clean-room-questbook-01493f395c.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1fd97b2e4646cc0a9b71a85e40a1934c49ec3d4dc56fef054205892a6d3380ed
3
+ size 645164
frontend/assets/generated-v2/clean-room-questbook-d35e6ee10c.png ADDED

Git LFS Details

  • SHA256: 9d5bda864b7880b22edf6c0b9a130c28d81c04b85ca98fd818a71206413be71a
  • Pointer size: 131 Bytes
  • Size of remote file: 928 kB
frontend/assets/generated-v2/project-outline-classroom-3783f10bae.png ADDED

Git LFS Details

  • SHA256: ef66c60de3007dfca3e4f0b56850da021f340734b9a9ef65d03e335d808bcbd4
  • Pointer size: 131 Bytes
  • Size of remote file: 638 kB
frontend/assets/generated-v2/project-outline-classroom-70a886be5a.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32205a5ba298d905addc223c1c440f6e28f12e8ad6360e73cd272acf930ad189
3
+ size 476204
frontend/assets/generated-v2/project-outline-comic-4f9eaa443a.png ADDED

Git LFS Details

  • SHA256: a9c85ef1ad3454f4b0ecbc8bf509fa71ca606e7007dd4b0244f9c39c5a49a727
  • Pointer size: 131 Bytes
  • Size of remote file: 759 kB
frontend/assets/generated-v2/project-outline-comic-ef3f180846.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:72f4d436bae6059ccba06f0b396d6232773b312dceefd17abc56dd1567efa91c
3
+ size 491564
frontend/assets/generated-v2/project-outline-questbook-bddf03af1a.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f920054ef4f1dc56fb179afc39954362fca77d788b9053241d5a524a00aa2a15
3
+ size 629804
frontend/assets/generated-v2/project-outline-questbook-e1d5ced2e8.png ADDED

Git LFS Details

  • SHA256: 54426ab4ea8308aa059a61ce8e6a7e093aaf99aca6148fc8457f85b414eb6a8d
  • Pointer size: 131 Bytes
  • Size of remote file: 918 kB
frontend/assets/generated-v2/read-20-classroom-7c13f43782.png ADDED

Git LFS Details

  • SHA256: a5077504792edc0fa963b2f317e662399443c5d844e8b85317d388d0a7108ea2
  • Pointer size: 131 Bytes
  • Size of remote file: 719 kB
frontend/assets/generated-v2/read-20-classroom-cca67b8288.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43d6cae31493484882409f4221bba1e73a17120df1c0f67bc32bb42c3a737d82
3
+ size 553004
frontend/assets/generated-v2/read-20-comic-7ec8c91bf8.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:def73d2835049162aa30cc8ebb44596cf90ccd55e2c0519fdb831432397a213c
3
+ size 337964
frontend/assets/generated-v2/read-20-comic-b5ae2d561a.png ADDED

Git LFS Details

  • SHA256: 8f1d210d307af51e16e08a8adcf6ee06e77133bfad9f62747e1d2657ab08dba5
  • Pointer size: 131 Bytes
  • Size of remote file: 513 kB
frontend/assets/generated-v2/read-20-questbook-39aac8ba25.png ADDED

Git LFS Details

  • SHA256: f7169ebed2a6ad09a9135c6a3a47edd2f39676d6cb3ba492d2613534d00948ec
  • Pointer size: 131 Bytes
  • Size of remote file: 837 kB
frontend/assets/generated-v2/read-20-questbook-5805518930.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16131b8c8a6711c2e09ea00732c74be78333f9486ebb25f2eacccc98cced828d
3
+ size 798764
frontend/index.html CHANGED
@@ -3,15 +3,38 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Epic Errands V1 - Mobile Review Prototype</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=Baloo+2:wght@600;700;800&family=Lora:wght@600;700&family=Nunito:wght@500;600;700;800&display=swap" rel="stylesheet">
10
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0" rel="stylesheet">
 
11
  <link rel="stylesheet" href="/frontend/styles/app.css">
12
  </head>
13
  <body>
14
- <main id="epic-errands-app" class="epic-app" aria-label="Epic Errands V1 mobile review prototype"></main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  <script src="/frontend/scripts/app.js"></script>
16
  </body>
17
  </html>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Epic Errands V1 - Generated Media Prototype</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=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Bangers&family=Hanken+Grotesk:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,500&family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/frontend/styles/tokens.css">
11
+ <link rel="stylesheet" href="/frontend/styles/components.css">
12
  <link rel="stylesheet" href="/frontend/styles/app.css">
13
  </head>
14
  <body>
15
+ <nav class="theme-rail" aria-label="Theme">
16
+ <span class="theme-rail__label">Theme</span>
17
+ <div class="theme-seg" role="group" aria-label="Choose Epic Errands theme">
18
+ <button class="theme-btn" type="button" data-app-theme="classroom" aria-pressed="false">
19
+ <span class="swatch" style="background:#e9382d"></span>
20
+ <span class="label">Classroom</span>
21
+ </button>
22
+ <button class="theme-btn" type="button" data-app-theme="comic" aria-pressed="false">
23
+ <span class="swatch" style="background:#f6a81d"></span>
24
+ <span class="label">Comic</span>
25
+ </button>
26
+ <button class="theme-btn" type="button" data-app-theme="questbook" aria-pressed="false">
27
+ <span class="swatch" style="background:#c8a25c"></span>
28
+ <span class="label">Questbook</span>
29
+ </button>
30
+ </div>
31
+ </nav>
32
+ <main class="stage" aria-label="Epic Errands V1 generated media prototype">
33
+ <div id="app"></div>
34
+ </main>
35
+ <script>
36
+ window.EPIC_ASSET_BASE = "/frontend/assets/";
37
+ </script>
38
  <script src="/frontend/scripts/app.js"></script>
39
  </body>
40
  </html>
frontend/scripts/app.js CHANGED
@@ -1,88 +1,165 @@
1
  (function () {
2
  const ASSET_BASE = window.EPIC_ASSET_BASE || "assets/";
3
  const EMBEDDED_ASSETS = window.EPIC_ASSETS || {};
4
- const root = document.getElementById("epic-errands-app");
5
 
6
- const styles = {
 
 
 
 
 
 
7
  classroom: {
8
  label: "Classroom",
9
- icon: "school",
10
- goalNoun: "Quest",
11
- parentTitle: "Classroom Captain",
12
- kidTitle: "Star Student",
13
- rewardNoun: "badge",
14
  waitingCopy: "Waiting for Classroom Captain approval.",
15
- copy: "Bright school adventure with stickers, notebooks, and sunny goals.",
16
- },
17
- magical: {
18
- label: "Quest",
19
- icon: "auto_stories",
20
- goalNoun: "Quest",
21
- parentTitle: "Quest Keeper",
22
- kidTitle: "Young Adventurer",
23
- rewardNoun: "crest",
24
- waitingCopy: "Waiting for Quest Keeper approval.",
25
- copy: "Premium questbook fantasy with warm paper, ink lines, and earned crests.",
26
  },
27
  comic: {
28
  label: "Comic",
29
- icon: "comic_bubble",
30
- goalNoun: "Mission",
31
- parentTitle: "Comic Editor",
32
- kidTitle: "Panel Hero",
33
- rewardNoun: "starburst",
34
  waitingCopy: "Waiting for Comic Editor approval.",
35
- copy: "Punchy action panels with bold copy and kid-safe energy.",
 
 
 
 
 
 
 
36
  },
37
  };
38
 
39
- const imageByStyle = {
40
- classroom: "generated-hero-1-finish-class-project-8f64eeb3.png",
41
- magical: "generated-hero-2-read-for-20-minutes-895e7642.png",
42
- comic: "generated-hero-3-put-away-laundry-083482db.png",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  };
44
 
45
  const state = {
46
- activeTab: "home",
47
- selectedGoalId: null,
48
- nextGoalNumber: 4,
49
- defaultStyleId: "magical",
50
- parentAvatarReady: true,
51
- kidAvatarReady: true,
52
  goals: [
53
  {
54
  id: "goal-1",
55
- ordinaryGoal: "Finish class project",
56
- styleId: "magical",
57
  kidCompletionState: "completed",
58
  parentRewardState: "waiting_for_approval",
59
  },
60
  {
61
  id: "goal-2",
62
- ordinaryGoal: "Read for 20 minutes",
63
- styleId: "classroom",
64
  kidCompletionState: "in_progress",
65
  parentRewardState: "not_reviewed",
66
  },
67
  {
68
  id: "goal-3",
69
- ordinaryGoal: "Put away laundry",
70
- styleId: "comic",
71
  kidCompletionState: "not_started",
72
  parentRewardState: "not_reviewed",
73
  },
74
  ],
75
  };
76
 
 
 
 
 
 
 
 
 
 
77
  function asset(name) {
78
  if (EMBEDDED_ASSETS[name]) return EMBEDDED_ASSETS[name];
79
  return `${ASSET_BASE}${name}`;
80
  }
81
 
82
- function icon(name) {
83
- return `<span class="material-symbols-rounded" aria-hidden="true">${name}</span>`;
84
- }
85
-
86
  function escapeHtml(value) {
87
  return String(value)
88
  .replace(/&/g, "&amp;")
@@ -92,410 +169,261 @@
92
  .replace(/'/g, "&#039;");
93
  }
94
 
95
- function titleCase(value) {
96
- return String(value)
97
- .trim()
98
- .split(/\s+/)
99
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
100
- .join(" ");
101
  }
102
 
103
- function currentStyle() {
104
- return styles[state.defaultStyleId];
105
  }
106
 
107
- function goalStyle(goal) {
108
- return styles[goal.styleId] || styles.classroom;
 
 
 
 
109
  }
110
 
111
- function activeGoals() {
112
- return state.goals.length;
 
 
 
113
  }
114
 
115
- function awaitingApproval() {
116
- return state.goals.filter((goal) => goal.parentRewardState === "waiting_for_approval").length;
 
 
 
 
117
  }
118
 
119
- function goalTitle(goal) {
120
- const style = goalStyle(goal);
121
- const lower = goal.ordinaryGoal.toLowerCase();
122
- if (goal.styleId === "magical" && lower.includes("class project")) return `${style.goalNoun}: Bridge of Bright Ideas`;
123
- if (goal.styleId === "comic" && lower.includes("laundry")) return `${style.goalNoun}: Laundry Launch`;
124
- if (lower.includes("read")) return `${style.goalNoun}: Reading Lantern`;
125
- return `${style.goalNoun}: ${titleCase(goal.ordinaryGoal)}`;
126
  }
127
 
128
- function rewardLabel(goal) {
129
- const style = goalStyle(goal);
130
- if (goal.styleId === "magical") return `Orderkeeper ${style.rewardNoun}`;
131
- if (goal.styleId === "comic") return `Pow ${style.rewardNoun}`;
132
- return `Helper ${style.rewardNoun}`;
133
  }
134
 
135
- function derivedState(goal) {
136
- if (goal.parentRewardState === "approved_reward_given") return "reward_given";
137
- if (goal.kidCompletionState === "completed") return "waiting_for_parent";
138
- if (goal.kidCompletionState === "in_progress") return "working_on_it";
139
- return "ready_to_start";
140
  }
141
 
142
- function stateChip(goal) {
143
- const derived = derivedState(goal);
144
- if (derived === "reward_given") return `<span class="chip success">Reward given</span>`;
145
- if (derived === "waiting_for_parent") return `<span class="chip waiting">Awaiting parent</span>`;
146
- if (derived === "working_on_it") return `<span class="chip">In progress</span>`;
147
- return `<span class="chip">Ready</span>`;
148
  }
149
 
150
- function stepsFor(goal) {
151
- const style = goalStyle(goal);
152
- if (goal.styleId === "magical") {
153
- return [
154
- "Gather the scattered clues",
155
- "Build the first brave piece",
156
- "Check the questbook notes",
157
- ];
158
- }
159
- if (goal.styleId === "comic") {
160
- return ["Sort the pile", "Fold the hero gear", "Launch it to the drawer"];
161
- }
162
- return ["Find your reading spot", "Read the next pages", "Tell the best part"];
163
  }
164
 
165
- function renderHome() {
166
- const style = currentStyle();
167
  return `
168
- <section class="screen" data-design-id="home-screen" aria-labelledby="home-title">
169
- <div class="screen-head">
170
- <h1 class="screen-title" id="home-title">Set the app theme</h1>
171
- <p class="screen-copy">Quest is the default visual style. Switch to Classroom or Comic before creating a new goal.</p>
172
- </div>
173
-
174
- <section class="panel" data-design-id="style-mode-picker" aria-labelledby="style-label">
175
- <h2 class="section-label" id="style-label">App theme</h2>
176
- <div class="style-grid">
177
- ${Object.entries(styles).map(([id, item]) => `
178
- <button class="style-option" data-action="select-style" data-style="${id}" aria-pressed="${state.defaultStyleId === id}">
179
- <span class="style-mark">${icon(item.icon)}</span>
180
- <span>
181
- <span class="style-name">${item.label}</span>
182
- <span class="style-meta">${item.parentTitle} / ${item.rewardNoun}. ${item.copy}</span>
183
- </span>
184
- <span class="selected-dot" aria-hidden="true"></span>
185
- </button>
186
- `).join("")}
187
- </div>
188
- </section>
189
-
190
- <section class="panel" data-design-id="avatar-creator" aria-labelledby="avatar-label">
191
- <h2 class="section-label" id="avatar-label">Avatars</h2>
192
- <div class="avatar-grid">
193
- <div class="avatar-slot">
194
- <div>
195
- <div class="avatar-face">${icon("shield_person")}</div>
196
- <p class="avatar-name">Maya</p>
197
- <p class="avatar-title">${escapeHtml(style.parentTitle)}</p>
198
- </div>
199
- </div>
200
- <div class="avatar-slot">
201
- <div>
202
- <div class="avatar-face">${icon("face_3")}</div>
203
- <p class="avatar-name">Ari</p>
204
- <p class="avatar-title">${escapeHtml(style.kidTitle)}</p>
205
- </div>
206
- </div>
207
- </div>
208
- <p class="privacy-note">${icon("lock")} Photos create session-only avatars in this prototype. Nothing is persisted.</p>
209
- </section>
210
-
211
- <section class="counter-grid" aria-label="Goal counts">
212
- <div class="counter">
213
- <strong>${activeGoals()}</strong>
214
- <span>active goals</span>
215
- </div>
216
- <div class="counter">
217
- <strong>${awaitingApproval()}</strong>
218
- <span>awaiting approval</span>
219
- </div>
220
- </section>
221
- </section>
222
  `;
223
  }
224
 
225
- function renderParentGoals() {
226
- const style = currentStyle();
 
227
  return `
228
- <section class="screen" data-design-id="parent-goals-screen" aria-labelledby="parent-title">
229
- <div class="screen-head">
230
- <h1 class="screen-title" id="parent-title">Parent Goals</h1>
231
- <p class="screen-copy">Create new goals, approve completed ones, and grant the reward.</p>
232
  </div>
233
- <form class="goal-create panel" data-design-id="parent-goal-create" aria-labelledby="create-goal-title">
234
- <div>
235
- <h2 class="section-label" id="create-goal-title">Create goal</h2>
236
- <p class="goal-create-copy">New goals snapshot the current ${escapeHtml(style.label)} app theme.</p>
237
- </div>
238
- <label class="goal-input-label" for="new-goal-input">Ordinary goal</label>
239
- <div class="goal-create-row">
240
- <input id="new-goal-input" name="ordinaryGoal" type="text" autocomplete="off" placeholder="Practice spelling words">
241
- <button class="create-button" type="submit">${icon("add_task")} Add</button>
242
- </div>
243
- </form>
244
- <div class="goal-list" data-design-id="parent-goal-list">
245
- ${state.goals.map((goal) => {
246
- const style = goalStyle(goal);
247
- const canApprove = goal.parentRewardState === "waiting_for_approval";
248
- const approved = goal.parentRewardState === "approved_reward_given";
249
- return `
250
- <article class="goal-row" data-goal-id="${goal.id}">
251
- <div class="goal-main">
252
- <div class="goal-title-line">
253
- <span class="goal-name">${escapeHtml(goalTitle(goal))}</span>
254
- <span class="chip">${escapeHtml(style.label)}</span>
255
- ${stateChip(goal)}
256
- </div>
257
- <p class="goal-plain">Ordinary goal: ${escapeHtml(goal.ordinaryGoal)}</p>
258
- <p class="goal-plain">Reward: ${escapeHtml(rewardLabel(goal))}</p>
259
- </div>
260
- <button class="approve-button" data-design-id="parent-approve-action" data-action="approve-goal" data-goal-id="${goal.id}" ${canApprove ? "" : "disabled"}>
261
- ${approved ? "Approved" : "Approve"}
262
- </button>
263
- </article>
264
- `;
265
- }).join("")}
266
  </div>
267
- </section>
268
  `;
269
  }
270
 
271
- function renderKidGoals() {
272
  return `
273
- <section class="screen" data-design-id="kid-goals-screen" aria-labelledby="kid-title">
274
- <div class="screen-head">
275
- <h1 class="screen-title" id="kid-title">Kid Goals</h1>
276
- <p class="screen-copy">Tap a thumbnail to open the goal card, finish it, then wait for your grown-up's approval.</p>
 
 
 
277
  </div>
278
- <div class="kid-grid" data-design-id="kid-goal-thumbnail-grid">
279
- ${state.goals.map((goal) => {
280
- const style = goalStyle(goal);
281
- return `
282
- <button class="kid-tile" data-action="open-goal-card" data-goal-id="${goal.id}" aria-label="Open ${escapeHtml(goalTitle(goal))}">
283
- <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} illustration for ${escapeHtml(goal.ordinaryGoal)}">
284
- <span class="tile-status" aria-hidden="true">${stateChip(goal)}</span>
285
- </button>
286
- `;
287
- }).join("")}
 
 
 
 
 
 
 
 
288
  </div>
289
- </section>
290
  `;
291
  }
292
 
293
- function renderGoalCard(goal, options = {}) {
294
- const style = goalStyle(goal);
295
- const isWaiting = goal.parentRewardState === "waiting_for_approval";
296
- const rewarded = goal.parentRewardState === "approved_reward_given";
297
- const steps = stepsFor(goal);
298
- const modalChrome = options.modal ? `
299
- <button class="modal-close" data-action="close-goal-card" aria-label="Close goal card">
300
- ${icon("close")}
301
- </button>
302
- ` : "";
303
  return `
304
- <article class="goal-card" data-design-id="kid-goal-card" ${options.modal ? 'role="dialog" aria-modal="true" aria-labelledby="goal-card-title"' : ""}>
305
- ${modalChrome}
306
- <div class="goal-card-hero">
307
- <img src="${asset(imageByStyle[goal.styleId])}" alt="${escapeHtml(style.label)} goal card image for ${escapeHtml(goal.ordinaryGoal)}">
308
- <span class="hero-badge">${escapeHtml(style.kidTitle)} ${escapeHtml(style.goalNoun)}</span>
309
  </div>
310
- <div class="goal-card-body">
311
- <h2 id="goal-card-title">${escapeHtml(goalTitle(goal))}</h2>
312
- <p class="screen-copy">${escapeHtml(style.copy)}</p>
313
- <ol class="step-list">
314
- ${steps.map((step, index) => `
315
- <li>
316
- <span class="step-index">${index + 1}</span>
317
- <span>${escapeHtml(step)}</span>
318
- </li>
319
- `).join("")}
320
- </ol>
321
- <div class="card-actions">
322
- <button class="audio-button" data-design-id="audio-play-hook" disabled>
323
- ${icon("volume_up")} Audio in V1.1
324
- </button>
325
- <button class="complete-button" data-action="complete-goal" data-goal-id="${goal.id}" ${goal.kidCompletionState === "completed" ? "disabled" : ""}>
326
- ${goal.kidCompletionState === "completed" ? "Completed" : `Mark ${style.goalNoun} complete`}
327
- </button>
328
  </div>
329
- ${isWaiting ? `
330
- <div class="waiting-panel" data-design-id="waiting-for-parent-approval">
331
- <strong>${escapeHtml(style.waitingCopy)}</strong>
332
- <span>Your ${escapeHtml(style.rewardNoun)} unlocks when ${escapeHtml(style.parentTitle)} approves.</span>
333
- </div>
334
- ` : ""}
335
- ${rewarded ? `
336
- <div class="reward-panel">
337
- <strong>${escapeHtml(rewardLabel(goal))} earned!</strong>
338
- <span>${escapeHtml(style.parentTitle)} approved this goal.</span>
339
- </div>
340
- ` : ""}
341
  </div>
342
- </article>
 
 
 
 
 
 
 
343
  `;
344
  }
345
 
346
- function renderGoalModal() {
347
- const selected = state.goals.find((goal) => goal.id === state.selectedGoalId);
348
- if (!selected || state.activeTab !== "kid") return "";
349
  return `
350
- <div class="goal-modal-backdrop" data-design-id="kid-goal-card-modal">
351
- <button class="modal-scrim" data-action="close-goal-card" aria-label="Close goal card"></button>
352
- <div class="goal-modal-shell">
353
- ${renderGoalCard(selected, { modal: true })}
354
- </div>
355
  </div>
 
 
356
  `;
357
  }
358
 
359
- function renderNav() {
360
- const items = [
361
- ["home", "Home", "home"],
362
- ["parent", "Parent", "fact_check"],
363
- ["kid", "Kid", "view_carousel"],
364
- ["diy", "DIY Lab", "experiment"],
365
- ];
366
  return `
367
- <nav class="nav" data-design-id="mobile-tab-nav" aria-label="Epic Errands tabs">
368
- ${items.map(([id, label, iconName]) => {
369
- const isDisabled = id === "diy";
370
- const active = state.activeTab === id;
371
- return `
372
- <button class="nav-button ${active ? "is-active" : ""}" data-action="nav" data-tab="${id}" ${isDisabled ? 'aria-disabled="true" data-design-id="diy-lab-nav" title="DIY Lab is planned for later."' : ""}>
373
- ${icon(iconName)}
374
- <span>${label}</span>
375
- </button>
376
- `;
377
- }).join("")}
378
- </nav>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  `;
380
  }
381
 
382
  function render() {
383
- const style = currentStyle();
384
- const screen = state.activeTab === "parent"
385
- ? renderParentGoals()
386
- : state.activeTab === "kid"
387
- ? renderKidGoals()
388
- : renderHome();
389
-
390
  root.innerHTML = `
391
- <div class="phone" data-style="${state.defaultStyleId}" data-design-id="mobile-app-shell">
392
- <header class="app-top">
393
- <div class="brand-row">
394
- <div class="brand-lockup">
395
- <p class="eyebrow">V1 visual review</p>
396
- <h1 class="brand-title">Epic Errands</h1>
397
- </div>
398
- <span class="status-pill">${icon(style.icon)} ${escapeHtml(style.label)}</span>
399
- </div>
400
- </header>
401
- <div class="screen-region">${screen}</div>
402
- ${renderGoalModal()}
403
- ${renderNav()}
404
- </div>
405
  `;
406
  }
407
 
408
- function updateGoal(goalId, updater) {
409
- const goal = state.goals.find((item) => item.id === goalId);
410
- if (goal) updater(goal);
411
- }
412
-
413
- root.addEventListener("click", (event) => {
414
- const target = event.target.closest("[data-action]");
415
- if (!target) return;
416
-
417
- const action = target.dataset.action;
418
- if (action === "select-style") {
419
- state.defaultStyleId = target.dataset.style || state.defaultStyleId;
420
- render();
421
- return;
422
- }
423
-
424
- if (action === "nav") {
425
- if (target.getAttribute("aria-disabled") === "true") return;
426
- state.activeTab = target.dataset.tab || "home";
427
- if (state.activeTab !== "kid") state.selectedGoalId = null;
428
- render();
429
- return;
430
- }
431
-
432
- if (action === "open-goal-card") {
433
- state.selectedGoalId = target.dataset.goalId || state.selectedGoalId;
434
- render();
435
- return;
436
- }
437
-
438
- if (action === "close-goal-card") {
439
- state.selectedGoalId = null;
440
- render();
441
  return;
442
  }
443
 
444
- if (action === "complete-goal") {
445
- const goalId = target.dataset.goalId;
446
- updateGoal(goalId, (goal) => {
447
- goal.kidCompletionState = "completed";
448
- if (goal.parentRewardState !== "approved_reward_given") {
449
- goal.parentRewardState = "waiting_for_approval";
450
- }
451
- });
452
- state.selectedGoalId = goalId || state.selectedGoalId;
453
- render();
454
- return;
455
- }
456
 
457
- if (action === "approve-goal") {
458
- const goalId = target.dataset.goalId;
459
- updateGoal(goalId, (goal) => {
460
- if (goal.kidCompletionState === "completed") {
461
- goal.parentRewardState = "approved_reward_given";
462
- }
463
- });
464
- render();
465
- }
466
- });
467
-
468
- root.addEventListener("submit", (event) => {
469
- const form = event.target.closest("[data-design-id='parent-goal-create']");
470
- if (!form) return;
471
- event.preventDefault();
472
-
473
- const input = form.querySelector("input[name='ordinaryGoal']");
474
- const ordinaryGoal = input ? input.value.trim() : "";
475
- if (!ordinaryGoal) {
476
- if (input) input.focus();
477
- return;
478
- }
479
-
480
- const goal = {
481
- id: `goal-${state.nextGoalNumber}`,
482
- ordinaryGoal,
483
- styleId: state.defaultStyleId,
484
- kidCompletionState: "not_started",
485
- parentRewardState: "not_reviewed",
486
- };
487
- state.nextGoalNumber += 1;
488
- state.goals.unshift(goal);
489
- state.selectedGoalId = null;
490
- render();
491
- });
492
-
493
- window.addEventListener("keydown", (event) => {
494
- if (event.key === "Escape" && state.selectedGoalId) {
495
- state.selectedGoalId = null;
496
- render();
497
- }
498
  });
499
 
500
- render();
501
  })();
 
1
  (function () {
2
  const ASSET_BASE = window.EPIC_ASSET_BASE || "assets/";
3
  const EMBEDDED_ASSETS = window.EPIC_ASSETS || {};
4
+ const root = document.getElementById("app");
5
 
6
+ const themeToToken = {
7
+ classroom: "storybook",
8
+ comic: "comic",
9
+ questbook: "atelier",
10
+ };
11
+
12
+ const themes = {
13
  classroom: {
14
  label: "Classroom",
15
+ kidRole: "Star Student",
16
+ parentRole: "Classroom Captain",
17
+ cardLabel: "Classroom Card",
18
+ proofLabel: "Sticker earned",
 
19
  waitingCopy: "Waiting for Classroom Captain approval.",
 
 
 
 
 
 
 
 
 
 
 
20
  },
21
  comic: {
22
  label: "Comic",
23
+ kidRole: "Panel Lead",
24
+ parentRole: "Comic Editor",
25
+ cardLabel: "Comic Card",
26
+ proofLabel: "Badge earned",
 
27
  waitingCopy: "Waiting for Comic Editor approval.",
28
+ },
29
+ questbook: {
30
+ label: "Questbook",
31
+ kidRole: "Young Adventurer",
32
+ parentRole: "Quest Keeper",
33
+ cardLabel: "Questbook Card",
34
+ proofLabel: "Crest earned",
35
+ waitingCopy: "Waiting for Quest Keeper approval.",
36
  },
37
  };
38
 
39
+ const generatedVariants = {
40
+ "clean-room": {
41
+ questbook: {
42
+ title: "Questbook: Room Cleanup",
43
+ narration: "You find a magical book that says, 'Before dinner, tidy your room and earn a shiny star!'",
44
+ rewardLabel: "Star",
45
+ image: "generated-v2/clean-room-questbook-d35e6ee10c.png",
46
+ audio: "generated-v2/clean-room-questbook-01493f395c.wav",
47
+ traceId: "epic-errands-v1-live-clean-room-questbook",
48
+ },
49
+ classroom: {
50
+ title: "Room Cleanup",
51
+ narration: "You tidy your room before dinner, making it ready for a fun day.",
52
+ rewardLabel: "Clean Room",
53
+ image: "generated-v2/clean-room-classroom-c5cb506427.png",
54
+ audio: "generated-v2/clean-room-classroom-cf6f43d6ce.wav",
55
+ traceId: "epic-errands-v1-live-clean-room-classroom",
56
+ },
57
+ comic: {
58
+ title: "Comic Cleanup",
59
+ narration: "You find a superhero cape in your room. It's time to put it back where it belongs before dinner.",
60
+ rewardLabel: "Superhero Cleanup",
61
+ image: "generated-v2/clean-room-comic-9bce2b2b21.png",
62
+ audio: "generated-v2/clean-room-comic-88b770a8dc.wav",
63
+ traceId: "epic-errands-v1-live-clean-room-comic",
64
+ },
65
+ },
66
+ "project-outline": {
67
+ questbook: {
68
+ title: "Questbook Quest",
69
+ narration: "You have a class project outline to finish. Gather your supplies and write it out step by step.",
70
+ rewardLabel: "Great job!",
71
+ image: "generated-v2/project-outline-questbook-e1d5ced2e8.png",
72
+ audio: "generated-v2/project-outline-questbook-bddf03af1a.wav",
73
+ traceId: "epic-errands-v1-live-project-outline-questbook",
74
+ },
75
+ classroom: {
76
+ title: "Classroom Project",
77
+ narration: "You finish your class project outline and get a high-five from your teacher.",
78
+ rewardLabel: "Great job!",
79
+ image: "generated-v2/project-outline-classroom-3783f10bae.png",
80
+ audio: "generated-v2/project-outline-classroom-70a886be5a.wav",
81
+ traceId: "epic-errands-v1-live-project-outline-classroom",
82
+ },
83
+ comic: {
84
+ title: "Comic Comic",
85
+ narration: "You finish your class project outline and get a shiny comic badge!",
86
+ rewardLabel: "Comic Badge",
87
+ image: "generated-v2/project-outline-comic-4f9eaa443a.png",
88
+ audio: "generated-v2/project-outline-comic-ef3f180846.wav",
89
+ traceId: "epic-errands-v1-live-project-outline-comic",
90
+ },
91
+ },
92
+ "read-20": {
93
+ questbook: {
94
+ title: "Questbook: Read 20 Minutes",
95
+ narration: "You've found a magical book that whispers stories. Spend 20 minutes reading to hear its magic.",
96
+ rewardLabel: "Storytime Star",
97
+ image: "generated-v2/read-20-questbook-39aac8ba25.png",
98
+ audio: "generated-v2/read-20-questbook-5805518930.wav",
99
+ traceId: "epic-errands-v1-live-read-20-questbook",
100
+ },
101
+ classroom: {
102
+ title: "Read for 20 Minutes",
103
+ narration: "Read for 20 minutes and earn a sticker.",
104
+ rewardLabel: "Sticker",
105
+ image: "generated-v2/read-20-classroom-7c13f43782.png",
106
+ audio: "generated-v2/read-20-classroom-cca67b8288.wav",
107
+ traceId: "epic-errands-v1-live-read-20-classroom",
108
+ },
109
+ comic: {
110
+ title: "Comic Quest",
111
+ narration: "Read for 20 minutes and earn a comic badge!",
112
+ rewardLabel: "Comic Badge",
113
+ image: "generated-v2/read-20-comic-b5ae2d561a.png",
114
+ audio: "generated-v2/read-20-comic-7ec8c91bf8.wav",
115
+ traceId: "epic-errands-v1-live-read-20-comic",
116
+ },
117
+ },
118
  };
119
 
120
  const state = {
121
+ activeTheme: "questbook",
122
+ view: "kid",
123
+ selectedGoalId: "goal-1",
 
 
 
124
  goals: [
125
  {
126
  id: "goal-1",
127
+ assetKey: "clean-room",
128
+ plainGoal: "Clean up my room before dinner",
129
  kidCompletionState: "completed",
130
  parentRewardState: "waiting_for_approval",
131
  },
132
  {
133
  id: "goal-2",
134
+ assetKey: "project-outline",
135
+ plainGoal: "Finish my class project outline",
136
  kidCompletionState: "in_progress",
137
  parentRewardState: "not_reviewed",
138
  },
139
  {
140
  id: "goal-3",
141
+ assetKey: "read-20",
142
+ plainGoal: "Read for 20 minutes",
143
  kidCompletionState: "not_started",
144
  parentRewardState: "not_reviewed",
145
  },
146
  ],
147
  };
148
 
149
+ const icons = {
150
+ check: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m5 12.5 4.2 4L19 7" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
151
+ image: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 7.5A2.5 2.5 0 0 1 6.5 5h11A2.5 2.5 0 0 1 20 7.5v9A2.5 2.5 0 0 1 17.5 19h-11A2.5 2.5 0 0 1 4 16.5v-9Z" stroke="currentColor" stroke-width="2"/><path d="m7 16 3.2-3.3 2.2 2.1 2.3-2.8L18 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.5 9h.01" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
152
+ sound: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4h4l5 4V6l-5 4H4Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M16 9.5c.8.7 1.2 1.5 1.2 2.5s-.4 1.8-1.2 2.5M18.6 7c1.5 1.3 2.3 3 2.3 5s-.8 3.7-2.3 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
153
+ sparkle: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3.5 13.8 9l5.7 1.7-5.7 1.8L12 18l-1.8-5.5-5.7-1.8L10.2 9 12 3.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
154
+ parent: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM16.5 12a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM3.5 20c.5-3.8 2.1-5.7 4.5-5.7s4 1.9 4.5 5.7M13 19.5c.4-2.7 1.6-4 3.5-4 1.8 0 3 1.3 3.5 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
155
+ kid: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 12a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM5.5 20c.7-3.8 2.9-5.8 6.5-5.8s5.8 2 6.5 5.8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
156
+ };
157
+
158
  function asset(name) {
159
  if (EMBEDDED_ASSETS[name]) return EMBEDDED_ASSETS[name];
160
  return `${ASSET_BASE}${name}`;
161
  }
162
 
 
 
 
 
163
  function escapeHtml(value) {
164
  return String(value)
165
  .replace(/&/g, "&amp;")
 
169
  .replace(/'/g, "&#039;");
170
  }
171
 
172
+ function goalById(id) {
173
+ return state.goals.find((goal) => goal.id === id) || state.goals[0];
 
 
 
 
174
  }
175
 
176
+ function activeCard(goal, themeId = state.activeTheme) {
177
+ return generatedVariants[goal.assetKey]?.[themeId] || generatedVariants[goal.assetKey]?.questbook;
178
  }
179
 
180
+ function statusLabel(goal) {
181
+ if (goal.parentRewardState === "approved") return "Approved";
182
+ if (goal.parentRewardState === "waiting_for_approval") return "Review";
183
+ if (goal.kidCompletionState === "completed") return "Done";
184
+ if (goal.kidCompletionState === "in_progress") return "Active";
185
+ return "Ready";
186
  }
187
 
188
+ function parentStatusCopy(goal) {
189
+ if (goal.parentRewardState === "approved") return "Reward approved";
190
+ if (goal.parentRewardState === "waiting_for_approval") return "Ready for parent review";
191
+ if (goal.kidCompletionState === "completed") return "Completed by kid";
192
+ return "Not submitted yet";
193
  }
194
 
195
+ function setTheme(themeId) {
196
+ if (!themes[themeId]) return;
197
+ state.activeTheme = themeId;
198
+ document.documentElement.dataset.theme = themeToToken[themeId];
199
+ updateThemeRail();
200
+ render();
201
  }
202
 
203
+ function updateThemeRail() {
204
+ document.querySelectorAll(".theme-btn").forEach((button) => {
205
+ button.setAttribute("aria-pressed", String(button.dataset.appTheme === state.activeTheme));
206
+ });
 
 
 
207
  }
208
 
209
+ function setView(view) {
210
+ state.view = view;
211
+ render();
 
 
212
  }
213
 
214
+ function selectGoal(goalId) {
215
+ state.selectedGoalId = goalId;
216
+ render();
 
 
217
  }
218
 
219
+ function completeSelectedGoal() {
220
+ const goal = goalById(state.selectedGoalId);
221
+ goal.kidCompletionState = "completed";
222
+ if (goal.parentRewardState !== "approved") goal.parentRewardState = "waiting_for_approval";
223
+ render();
 
224
  }
225
 
226
+ function approveGoal(goalId) {
227
+ const goal = goalById(goalId);
228
+ goal.kidCompletionState = "completed";
229
+ goal.parentRewardState = "approved";
230
+ render();
 
 
 
 
 
 
 
 
231
  }
232
 
233
+ function flourishCorners() {
234
+ const flourish = '<svg viewBox="0 0 60 60" fill="none"><path d="M8 52c16-3 19-10 20-22 1 12 5 19 24 22M8 8c16 3 19 10 20 22 1-12 5-19 24-22" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
235
  return `
236
+ <span class="corner-flourish tl">${flourish}</span>
237
+ <span class="corner-flourish tr">${flourish}</span>
238
+ <span class="corner-flourish bl">${flourish}</span>
239
+ <span class="corner-flourish br">${flourish}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  `;
241
  }
242
 
243
+ function headerHtml() {
244
+ const kidPressed = state.view === "kid";
245
+ const parentPressed = state.view === "parent";
246
  return `
247
+ <div class="app-head">
248
+ <div class="wordmark" aria-label="Epic Errands">
249
+ <span class="display">Epic Errands</span>
250
+ <span class="sub">${escapeHtml(themes[state.activeTheme].kidRole)} mode</span>
251
  </div>
252
+ <div class="viewtoggle" role="group" aria-label="Choose view">
253
+ <button class="vt" type="button" data-action="view" data-view="kid" aria-pressed="${kidPressed}">
254
+ ${icons.kid}<span>Kid</span>
255
+ </button>
256
+ <button class="vt" type="button" data-action="view" data-view="parent" aria-pressed="${parentPressed}">
257
+ ${icons.parent}<span>Parent</span>
258
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  </div>
260
+ </div>
261
  `;
262
  }
263
 
264
+ function goalStripHtml() {
265
  return `
266
+ <div class="section">
267
+ <div class="section__head">
268
+ <div class="section__num">3</div>
269
+ <div class="section__titles">
270
+ <div class="section__title">Loaded goals <span class="note">${escapeHtml(themes[state.activeTheme].label)}</span></div>
271
+ <div class="section__sub">Pick a plain goal to preview its generated card.</div>
272
+ </div>
273
  </div>
274
+ <div class="goal-strip">
275
+ ${state.goals
276
+ .map((goal, index) => {
277
+ const card = activeCard(goal);
278
+ const selected = goal.id === state.selectedGoalId;
279
+ const complete = goal.kidCompletionState === "completed";
280
+ return `
281
+ <button class="goal-button ${complete ? "is-complete" : ""}" type="button" data-action="select-goal" data-goal-id="${goal.id}" aria-pressed="${selected}">
282
+ <span class="goal-button__mark">${complete ? icons.check : index + 1}</span>
283
+ <span class="goal-button__copy">
284
+ <span class="goal-button__title">${escapeHtml(card.title)}</span>
285
+ <span class="goal-button__meta">${escapeHtml(goal.plainGoal)}</span>
286
+ </span>
287
+ <span class="goal-button__status">${escapeHtml(statusLabel(goal))}</span>
288
+ </button>
289
+ `;
290
+ })
291
+ .join("")}
292
  </div>
293
+ </div>
294
  `;
295
  }
296
 
297
+ function selectedCardHtml() {
298
+ const goal = goalById(state.selectedGoalId);
299
+ const card = activeCard(goal);
300
+ const theme = themes[state.activeTheme];
301
+ const complete = goal.kidCompletionState === "completed";
302
+ const approved = goal.parentRewardState === "approved";
303
+ const waiting = goal.parentRewardState === "waiting_for_approval";
304
+ const buttonText = complete ? "Send Again" : "I Did It";
305
+
 
306
  return `
307
+ <section class="card-preview" aria-label="Generated goal card">
308
+ <div class="hero">
309
+ <img src="${asset(card.image)}" alt="${escapeHtml(card.title)} generated ${escapeHtml(theme.label)} artwork">
310
+ <span class="hero__rank">${escapeHtml(theme.cardLabel)}</span>
311
+ <span class="hero__points">${icons.sparkle}${escapeHtml(card.rewardLabel)}</span>
312
  </div>
313
+ <div class="quest-headline">
314
+ <div class="eyebrow">${escapeHtml(goal.plainGoal)}</div>
315
+ <h1 class="quest-title">${escapeHtml(card.title)}</h1>
316
+ <p class="narration">${escapeHtml(card.narration)}</p>
317
+ </div>
318
+ <div class="media-panel">
319
+ <div class="audio-shell">
320
+ <audio controls preload="metadata" src="${asset(card.audio)}"></audio>
321
+ </div>
322
+ <div class="asset-pills" aria-label="Generated assets">
323
+ <span class="asset-pill">${icons.image} Image</span>
324
+ <span class="asset-pill">${icons.sound} Audio</span>
325
+ <span class="asset-pill">${icons.sparkle} ${escapeHtml(theme.proofLabel)}</span>
 
 
 
 
 
326
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
327
  </div>
328
+ <div class="action-row">
329
+ <button class="btn-primary" type="button" data-action="complete-selected" ${approved ? "disabled" : ""}>
330
+ ${icons.check}<span>${approved ? "Reward Approved" : buttonText}</span>
331
+ </button>
332
+ ${waiting ? `<p class="state-note"><strong>${escapeHtml(theme.parentRole)}:</strong> ${escapeHtml(theme.waitingCopy)}</p>` : ""}
333
+ ${approved ? `<p class="state-note"><strong>Reward approved.</strong> The kid card can now show the earned reward in the UI.</p>` : ""}
334
+ </div>
335
+ </section>
336
  `;
337
  }
338
 
339
+ function kidViewHtml() {
 
 
340
  return `
341
+ ${headerHtml()}
342
+ <div class="lead">
343
+ <h1>Today's <span class="u">epic errands</span></h1>
344
+ <p>Generated text, artwork, and voice are loaded into the same card system.</p>
 
345
  </div>
346
+ ${goalStripHtml()}
347
+ ${selectedCardHtml()}
348
  `;
349
  }
350
 
351
+ function parentViewHtml() {
352
+ const completeCount = state.goals.filter((goal) => goal.kidCompletionState === "completed").length;
353
+ const waitingCount = state.goals.filter((goal) => goal.parentRewardState === "waiting_for_approval").length;
354
+ const approvedCount = state.goals.filter((goal) => goal.parentRewardState === "approved").length;
355
+
 
 
356
  return `
357
+ ${headerHtml()}
358
+ <div class="lead">
359
+ <h1>${escapeHtml(themes[state.activeTheme].parentRole)}</h1>
360
+ <p>Review generated cards and approve rewards after completion.</p>
361
+ </div>
362
+ <div class="summary-grid" aria-label="Parent review summary">
363
+ <div class="summary-tile"><b>${completeCount}</b><span>Completed</span></div>
364
+ <div class="summary-tile"><b>${waitingCount}</b><span>Review</span></div>
365
+ <div class="summary-tile"><b>${approvedCount}</b><span>Approved</span></div>
366
+ </div>
367
+ <section class="parent">
368
+ <div class="parent__head">
369
+ ${icons.parent}
370
+ <h3>Reward Queue</h3>
371
+ </div>
372
+ <div class="parent-list">
373
+ ${state.goals
374
+ .map((goal) => {
375
+ const card = activeCard(goal);
376
+ const canApprove = goal.parentRewardState === "waiting_for_approval";
377
+ return `
378
+ <article class="parent-row">
379
+ <div>
380
+ <div class="parent-row__title">${escapeHtml(card.title)}</div>
381
+ <div class="parent-row__meta">${escapeHtml(goal.plainGoal)} · ${escapeHtml(card.rewardLabel)}</div>
382
+ </div>
383
+ <div class="parent-actions">
384
+ <span class="parent-row__status">${escapeHtml(parentStatusCopy(goal))}</span>
385
+ <button class="btn-ghost" type="button" data-action="approve-goal" data-goal-id="${goal.id}" ${canApprove ? "" : "disabled"}>
386
+ ${icons.check}<span>${goal.parentRewardState === "approved" ? "Approved" : "Approve"}</span>
387
+ </button>
388
+ </div>
389
+ </article>
390
+ `;
391
+ })
392
+ .join("")}
393
+ </div>
394
+ </section>
395
  `;
396
  }
397
 
398
  function render() {
399
+ if (!root) return;
400
+ const body = state.view === "parent" ? parentViewHtml() : kidViewHtml();
 
 
 
 
 
401
  root.innerHTML = `
402
+ <article class="card screen-in ornate">
403
+ ${flourishCorners()}
404
+ <div class="card-inner app-shell">
405
+ ${body}
406
+ </div>
407
+ </article>
 
 
 
 
 
 
 
 
408
  `;
409
  }
410
 
411
+ document.addEventListener("click", (event) => {
412
+ const themeButton = event.target.closest(".theme-btn");
413
+ if (themeButton) {
414
+ setTheme(themeButton.dataset.appTheme);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  return;
416
  }
417
 
418
+ const actionButton = event.target.closest("[data-action]");
419
+ if (!actionButton || actionButton.disabled) return;
 
 
 
 
 
 
 
 
 
 
420
 
421
+ const action = actionButton.dataset.action;
422
+ if (action === "view") setView(actionButton.dataset.view);
423
+ if (action === "select-goal") selectGoal(actionButton.dataset.goalId);
424
+ if (action === "complete-selected") completeSelectedGoal();
425
+ if (action === "approve-goal") approveGoal(actionButton.dataset.goalId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  });
427
 
428
+ setTheme(state.activeTheme);
429
  })();
frontend/styles/app.css CHANGED
@@ -1,913 +1,301 @@
1
- :root {
2
- --font-display: "Baloo 2", system-ui, sans-serif;
3
- --font-quest: "Lora", Georgia, serif;
4
- --font-body: "Nunito", system-ui, sans-serif;
5
- --font-heading: var(--font-display);
6
- --ink: #241d19;
7
- --muted: #655f59;
8
- --line: rgba(62, 47, 33, 0.16);
9
- --paper: #fff8e8;
10
- --surface: #ffffff;
11
- --soft: #f5efe3;
12
- --primary: #e3403a;
13
- --primary-ink: #ffffff;
14
- --success: #128a55;
15
- --warning: #b36b00;
16
- --shadow: 0 18px 42px rgba(30, 42, 64, 0.14);
17
- --shadow-tight: 0 8px 22px rgba(30, 42, 64, 0.12);
18
- --radius-sm: 10px;
19
- --radius-md: 16px;
20
- --radius-lg: 24px;
21
- --nav-h: 76px;
22
- color-scheme: light;
23
- }
24
-
25
- * {
26
- box-sizing: border-box;
27
- }
28
-
29
- html,
30
- body {
31
- min-height: 100%;
32
- height: 100%;
33
- }
34
-
35
- body {
36
- margin: 0;
37
- background: #e9dcc5;
38
- color: var(--ink);
39
- font-family: var(--font-body);
40
- overflow: hidden;
41
- }
42
-
43
- button,
44
- input {
45
- font: inherit;
46
- }
47
-
48
- button {
49
- cursor: pointer;
50
- }
51
-
52
- button:focus-visible,
53
- input:focus-visible {
54
- outline: 3px solid rgba(31, 105, 255, 0.45);
55
- outline-offset: 3px;
56
- }
57
-
58
- .material-symbols-rounded {
59
- width: 1em;
60
- max-width: 1em;
61
- display: inline-block;
62
- overflow: hidden;
63
- font-family: "Material Symbols Rounded";
64
- font-size: 22px;
65
- font-style: normal;
66
- font-weight: 500;
67
- font-feature-settings: "liga";
68
- line-height: 1;
69
- text-transform: none;
70
- vertical-align: middle;
71
- white-space: nowrap;
72
- }
73
-
74
- .epic-app {
75
- min-height: 100dvh;
76
- height: 100dvh;
77
- display: grid;
78
- place-items: center;
79
- padding: 18px;
80
- overflow: hidden;
81
- background:
82
- radial-gradient(circle at 16% 12%, rgba(255, 252, 240, 0.95), transparent 28%),
83
- radial-gradient(circle at 86% 16%, rgba(109, 84, 53, 0.18), transparent 22%),
84
- linear-gradient(135deg, #d7c29e 0%, #fff1d5 48%, #dfe7d5 100%);
85
- }
86
-
87
- .phone {
88
- position: relative;
89
- width: min(100%, 430px);
90
- height: min(920px, calc(100dvh - 36px));
91
- min-height: min(760px, calc(100dvh - 36px));
92
- max-height: calc(100dvh - 36px);
93
- overflow: hidden;
94
- display: grid;
95
- grid-template-rows: auto minmax(0, 1fr) auto;
96
- border: 1px solid rgba(31, 42, 55, 0.16);
97
- border-radius: 26px;
98
- background: var(--paper);
99
- box-shadow: var(--shadow);
100
- }
101
-
102
- .phone[data-style="classroom"] {
103
- --font-heading: var(--font-display);
104
- --theme: #e3403a;
105
- --theme-2: #1467b3;
106
- --theme-3: #f7c948;
107
- --theme-soft: #fff3d7;
108
- --theme-panel: #f8fbff;
109
- --theme-border: #9bc4ef;
110
- --reward: #128a55;
111
- }
112
-
113
- .phone[data-style="magical"] {
114
- --font-heading: var(--font-quest);
115
- --theme: #31583e;
116
- --theme-2: #5a3f78;
117
- --theme-3: #c79335;
118
- --theme-soft: #f2e6cc;
119
- --theme-panel: #fff6e3;
120
- --theme-border: #aa8752;
121
- --reward: #2f6b45;
122
- --line: rgba(95, 66, 33, 0.24);
123
- --surface: #fffaf0;
124
- }
125
-
126
- .phone[data-style="comic"] {
127
- --font-heading: var(--font-display);
128
- --theme: #136bd9;
129
- --theme-2: #d62957;
130
- --theme-3: #ffd33d;
131
- --theme-soft: #fff2a8;
132
- --theme-panel: #f8fbff;
133
- --theme-border: #1f2a37;
134
- --reward: #d64b20;
135
- }
136
-
137
- .app-top {
138
- padding: 22px 20px 12px;
139
- background:
140
- linear-gradient(90deg, rgba(255, 252, 244, 0.96), rgba(255, 252, 244, 0.55)),
141
- var(--theme-panel);
142
- border-bottom: 1px solid var(--line);
143
- }
144
-
145
- .brand-row {
146
- display: flex;
147
- align-items: center;
148
- justify-content: space-between;
149
- gap: 12px;
150
- }
151
-
152
- .brand-lockup {
153
- min-width: 0;
154
- }
155
-
156
- .eyebrow {
157
- margin: 0 0 2px;
158
- color: var(--theme-2);
159
- font-size: 12px;
160
- font-weight: 900;
161
- text-transform: uppercase;
162
- }
163
-
164
- .brand-title {
165
- margin: 0;
166
- color: var(--ink);
167
- font-family: var(--font-heading);
168
- font-size: 29px;
169
- line-height: 1;
170
  }
171
 
172
- .status-pill {
173
- display: inline-flex;
174
  align-items: center;
175
- gap: 6px;
176
- min-height: 34px;
177
- padding: 0 10px;
178
- border: 1px solid var(--line);
179
- border-radius: 999px;
180
- background: rgba(255, 255, 255, 0.84);
181
- color: var(--muted);
182
- font-size: 12px;
183
- font-weight: 900;
184
- white-space: nowrap;
185
- }
186
-
187
- .screen-region {
188
- min-height: 0;
189
- overflow: auto;
190
- overscroll-behavior: contain;
191
- -webkit-overflow-scrolling: touch;
192
- padding: 18px 18px calc(var(--nav-h) + 18px);
193
- background:
194
- linear-gradient(180deg, rgba(255, 253, 247, 0.72), rgba(255, 255, 255, 0)),
195
- var(--theme-panel);
196
- }
197
-
198
- .screen {
199
- display: grid;
200
- gap: 14px;
201
- }
202
-
203
- .screen-head {
204
- display: grid;
205
- gap: 4px;
206
- }
207
-
208
- .screen-title {
209
- margin: 0;
210
- font-family: var(--font-heading);
211
- font-size: 28px;
212
- line-height: 1.05;
213
- }
214
-
215
- .screen-copy {
216
- margin: 0;
217
- color: var(--muted);
218
- font-size: 15px;
219
- font-weight: 800;
220
- line-height: 1.35;
221
  }
222
 
223
- .panel,
224
- .goal-row,
225
- .kid-tile,
226
- .goal-card,
227
- .style-option,
228
- .avatar-slot,
229
- .goal-create {
230
- border: 1px solid var(--line);
231
- border-radius: var(--radius-sm);
232
- background: rgba(255, 253, 247, 0.92);
233
- box-shadow: var(--shadow-tight);
234
- }
235
-
236
- .panel {
237
- padding: 15px;
238
- }
239
-
240
- .section-label {
241
- margin: 0 0 10px;
242
- color: var(--ink);
243
- font-size: 13px;
244
- font-weight: 1000;
245
  text-transform: uppercase;
246
  }
247
 
248
- .style-grid {
249
  display: grid;
250
- gap: 9px;
251
  }
252
 
253
- .style-option {
254
  width: 100%;
255
- min-height: 78px;
 
 
 
 
 
256
  display: grid;
257
- grid-template-columns: 46px 1fr auto;
 
258
  align-items: center;
259
- gap: 12px;
260
- padding: 12px;
261
- color: var(--ink);
262
  text-align: left;
 
263
  }
264
 
265
- .style-option[aria-pressed="true"] {
266
- border-color: var(--theme);
267
- background: linear-gradient(135deg, var(--theme-soft), #fff);
 
268
  }
269
 
270
- .style-mark {
271
- display: grid;
272
- width: 46px;
273
- height: 46px;
274
- place-items: center;
275
- border-radius: 12px;
276
- background: var(--theme-soft);
277
- color: var(--theme);
278
- }
279
-
280
- .style-name {
281
- display: block;
282
- font-family: var(--font-heading);
283
- font-size: 18px;
284
- font-weight: 1000;
285
- }
286
-
287
- .style-meta {
288
- display: block;
289
- color: var(--muted);
290
- font-size: 13px;
291
- font-weight: 800;
292
- line-height: 1.25;
293
  }
294
 
295
- .selected-dot {
296
- width: 18px;
297
- height: 18px;
298
- border: 2px solid var(--theme);
299
  border-radius: 50%;
300
- }
301
-
302
- .style-option[aria-pressed="true"] .selected-dot {
303
- background: radial-gradient(circle, var(--theme) 45%, transparent 49%);
304
- }
305
-
306
- .avatar-grid {
307
- display: grid;
308
- grid-template-columns: 1fr 1fr;
309
- gap: 10px;
310
- }
311
-
312
- .avatar-slot {
313
- min-height: 134px;
314
- padding: 12px;
315
- display: grid;
316
- align-content: space-between;
317
- background:
318
- linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.92)),
319
- var(--theme-soft);
320
- }
321
-
322
- .avatar-face {
323
- width: 62px;
324
- height: 62px;
325
  display: grid;
326
  place-items: center;
327
- border: 3px solid #fff;
328
- border-radius: 22px;
329
- background: linear-gradient(135deg, var(--theme-3), var(--theme-soft));
330
- color: var(--ink);
331
- box-shadow: var(--shadow-tight);
 
332
  }
333
 
334
- .avatar-name {
335
- margin: 10px 0 0;
336
- font-size: 16px;
337
- font-weight: 1000;
338
  }
339
 
340
- .avatar-title {
341
- margin: 2px 0 0;
342
- color: var(--muted);
343
- font-size: 12px;
344
- font-weight: 900;
345
  }
346
 
347
- .privacy-note {
348
- display: flex;
349
- gap: 8px;
350
- align-items: flex-start;
351
- margin: 0;
352
- color: #4d6174;
353
- font-size: 13px;
354
  font-weight: 800;
355
- line-height: 1.3;
356
- }
357
-
358
- .counter-grid {
359
- display: grid;
360
- grid-template-columns: 1fr 1fr;
361
- gap: 10px;
362
  }
363
 
364
- .counter {
365
- min-height: 96px;
366
- display: grid;
367
- align-content: center;
368
- gap: 2px;
369
- padding: 12px;
370
- border-radius: var(--radius-md);
371
- background: var(--surface);
372
- border: 1px solid var(--line);
373
  }
374
 
375
- .counter strong {
376
- color: var(--theme);
377
- font-family: var(--font-heading);
378
- font-size: 38px;
379
- line-height: 0.9;
380
- }
381
-
382
- .counter span {
383
- color: var(--muted);
384
- font-size: 13px;
385
  font-weight: 900;
 
 
386
  }
387
 
388
- .goal-list {
389
- display: grid;
390
- gap: 10px;
391
  }
392
 
393
- .goal-create {
394
- display: grid;
395
- gap: 10px;
396
- padding: 13px;
 
 
397
  }
398
 
399
- .goal-create-copy {
400
- margin: 0;
401
- color: var(--muted);
402
- font-size: 13px;
403
- font-weight: 800;
404
- line-height: 1.3;
405
  }
406
 
407
- .goal-input-label {
408
- color: var(--ink);
409
- font-size: 12px;
410
- font-weight: 1000;
411
- text-transform: uppercase;
 
 
 
 
 
412
  }
413
 
414
- .goal-create-row {
 
415
  display: grid;
416
- grid-template-columns: minmax(0, 1fr) auto;
417
- gap: 8px;
418
- }
419
-
420
- .goal-create-row input {
421
- width: 100%;
422
- min-width: 0;
423
- min-height: 46px;
424
- border: 1px solid var(--line);
425
- border-radius: 999px;
426
- padding: 0 14px;
427
- background: #fff;
428
- color: var(--ink);
429
- font-weight: 900;
430
- }
431
-
432
- .goal-create-row input::placeholder {
433
- color: #8a98aa;
434
  }
435
 
436
- .goal-row {
437
- display: grid;
438
- grid-template-columns: 1fr auto;
439
- gap: 10px;
440
- padding: 13px;
441
  }
442
 
443
- .goal-main {
444
- min-width: 0;
 
 
445
  }
446
 
447
- .goal-title-line {
448
  display: flex;
449
- align-items: center;
450
  flex-wrap: wrap;
451
- gap: 6px;
452
- margin-bottom: 5px;
453
- }
454
-
455
- .goal-name {
456
- font-family: var(--font-heading);
457
- font-size: 17px;
458
- font-weight: 1000;
459
- }
460
-
461
- .goal-plain {
462
- margin: 0;
463
- color: var(--muted);
464
- font-size: 13px;
465
- font-weight: 800;
466
- line-height: 1.3;
467
- }
468
-
469
- .chip {
470
- display: inline-flex;
471
- align-items: center;
472
- min-height: 24px;
473
- padding: 0 8px;
474
- border-radius: 999px;
475
- background: var(--theme-soft);
476
- color: var(--theme);
477
- font-size: 11px;
478
- font-weight: 1000;
479
- white-space: nowrap;
480
- }
481
-
482
- .chip.success {
483
- background: #e6f7ee;
484
- color: var(--success);
485
- }
486
-
487
- .chip.waiting {
488
- background: #fff4d8;
489
- color: var(--warning);
490
- }
491
-
492
- .approve-button,
493
- .complete-button,
494
- .audio-button,
495
- .create-button {
496
- min-height: 44px;
497
- border: 0;
498
- border-radius: 12px;
499
- padding: 0 14px;
500
- background: var(--theme);
501
- color: var(--primary-ink);
502
- font-weight: 1000;
503
- box-shadow: var(--shadow-tight);
504
  }
505
 
506
- .create-button {
507
  display: inline-flex;
508
  align-items: center;
509
  gap: 6px;
510
- white-space: nowrap;
511
- }
512
-
513
- .approve-button:disabled,
514
- .complete-button:disabled,
515
- .audio-button:disabled {
516
- cursor: not-allowed;
517
- border: 1px solid var(--line);
518
- background: #eef2f7;
519
- color: #6b7888;
520
- box-shadow: none;
521
- }
522
-
523
- .kid-grid {
524
- display: grid;
525
- grid-template-columns: 1fr 1fr;
526
- gap: 10px;
527
- }
528
-
529
- .kid-tile {
530
- position: relative;
531
- aspect-ratio: 1 / 1;
532
- min-height: 132px;
533
- overflow: hidden;
534
- text-align: left;
535
- color: var(--ink);
536
- background: var(--theme-soft);
537
- }
538
-
539
- .kid-tile img {
540
- width: 100%;
541
- height: 100%;
542
- display: block;
543
- object-fit: cover;
544
- background: var(--theme-soft);
545
- }
546
-
547
- .tile-status {
548
- position: absolute;
549
- left: 8px;
550
- bottom: 8px;
551
- max-width: calc(100% - 16px);
552
- pointer-events: none;
553
  }
554
 
555
- .goal-card {
556
- position: relative;
557
- overflow: hidden;
 
558
  }
559
 
560
- .goal-modal-backdrop {
561
- position: absolute;
562
- z-index: 20;
563
- inset: 0;
564
  display: grid;
565
- place-items: end center;
566
- padding: 18px 14px calc(var(--nav-h) + 14px);
567
  }
568
 
569
- .modal-scrim {
570
- position: absolute;
571
- inset: 0;
572
- border: 0;
573
- background: rgba(31, 42, 55, 0.42);
 
 
 
 
574
  }
575
 
576
- .goal-modal-shell {
577
- position: relative;
578
- z-index: 1;
579
- width: min(100%, 390px);
580
- max-height: min(690px, calc(100dvh - var(--nav-h) - 44px));
581
- overflow: auto;
582
- border-radius: var(--radius-lg);
583
- box-shadow: 0 24px 54px rgba(13, 24, 40, 0.28);
584
  }
585
 
586
- .modal-close {
587
- position: absolute;
588
- z-index: 2;
589
- top: 10px;
590
- right: 10px;
591
  display: grid;
592
- width: 42px;
593
- height: 42px;
594
- place-items: center;
595
- border: 1px solid rgba(255, 255, 255, 0.72);
596
- border-radius: 50%;
597
- background: rgba(255, 255, 255, 0.94);
598
- color: var(--ink);
599
- box-shadow: var(--shadow-tight);
600
- }
601
-
602
- .goal-card-hero {
603
- position: relative;
604
- min-height: 188px;
605
- background: var(--theme-soft);
606
- }
607
-
608
- .goal-card-hero img {
609
- width: 100%;
610
- height: 206px;
611
- display: block;
612
- object-fit: cover;
613
- }
614
-
615
- .hero-badge {
616
- position: absolute;
617
- left: 12px;
618
- bottom: 12px;
619
- max-width: calc(100% - 24px);
620
- padding: 8px 10px;
621
- border: 1px solid rgba(255, 255, 255, 0.72);
622
- border-radius: 12px;
623
- background: rgba(255, 255, 255, 0.9);
624
- color: var(--theme);
625
- font-size: 12px;
626
- font-weight: 1000;
627
  }
628
 
629
- .goal-card-body {
630
  display: grid;
 
631
  gap: 12px;
632
- padding: 14px;
633
- background:
634
- linear-gradient(180deg, rgba(255, 255, 255, 0.44), rgba(255, 255, 255, 0)),
635
- var(--surface);
 
 
636
  }
637
 
638
- .goal-card h2 {
639
- margin: 0;
640
- font-family: var(--font-heading);
641
- font-size: 28px;
642
- line-height: 1.05;
643
  }
644
 
645
- .step-list {
646
- display: grid;
647
- gap: 8px;
648
- margin: 0;
649
- padding: 0;
650
- list-style: none;
651
  }
652
 
653
- .step-list li {
654
- display: grid;
655
- grid-template-columns: 32px 1fr;
656
  align-items: center;
657
- gap: 9px;
658
- min-height: 42px;
659
- padding: 7px 10px;
660
- border-radius: 13px;
661
- background: var(--soft);
 
662
  font-weight: 900;
 
 
663
  }
664
 
665
- .step-index {
666
- display: grid;
667
- width: 32px;
668
- height: 32px;
669
- place-items: center;
670
- border-radius: 50%;
671
- background: var(--theme-soft);
672
- color: var(--theme);
673
- font-weight: 1000;
674
- }
675
-
676
- .card-actions {
677
- display: grid;
678
- grid-template-columns: 1fr;
679
- gap: 10px;
680
- }
681
-
682
- .audio-button {
683
- background: #eef2f7;
684
- color: #536273;
685
  }
686
 
687
- .complete-button {
688
- min-height: 54px;
689
- background: var(--reward);
690
- font-size: 16px;
691
  }
692
 
693
- .waiting-panel,
694
- .reward-panel {
695
  display: grid;
696
- gap: 5px;
697
- padding: 12px;
698
- border-radius: var(--radius-md);
699
- border: 1px solid var(--line);
700
- }
701
-
702
- .waiting-panel {
703
- background: #fff5dd;
704
- color: #744800;
705
- }
706
-
707
- .reward-panel {
708
- background: #eaf9f0;
709
- color: #105c3a;
710
- }
711
-
712
- .waiting-panel strong,
713
- .reward-panel strong {
714
- font-size: 15px;
715
  }
716
 
717
- .waiting-panel span,
718
- .reward-panel span {
719
- font-size: 13px;
720
- font-weight: 800;
721
- line-height: 1.3;
 
722
  }
723
 
724
- .nav {
725
- display: grid;
726
- grid-template-columns: repeat(4, 1fr);
727
- min-height: var(--nav-h);
728
- border-top: 1px solid var(--line);
729
- background: rgba(255, 255, 255, 0.96);
730
  }
731
 
732
- .nav-button {
733
- display: grid;
734
- place-items: center;
735
- align-content: center;
736
- gap: 4px;
737
- min-width: 0;
738
- border: 0;
739
- background: transparent;
740
- color: #68778a;
741
  font-size: 11px;
742
- font-weight: 1000;
743
- }
744
-
745
- .nav-button.is-active {
746
- color: var(--theme);
747
- }
748
-
749
- .nav-button[aria-disabled="true"] {
750
- cursor: not-allowed;
751
- opacity: 0.46;
752
  }
753
 
754
- .empty-state {
755
- display: grid;
756
- place-items: center;
757
- min-height: 260px;
758
- padding: 24px;
759
- border: 1px dashed var(--line);
760
- border-radius: var(--radius-lg);
761
- background: rgba(255, 255, 255, 0.58);
762
- color: var(--muted);
763
- text-align: center;
764
- font-weight: 900;
765
  }
766
 
767
  @media (max-width: 520px) {
768
- .epic-app {
769
- padding: 0;
770
- min-height: 100dvh;
771
- height: 100dvh;
772
- }
773
-
774
- .phone {
775
- width: 100%;
776
- height: 100dvh;
777
- min-height: 0;
778
- max-height: 100dvh;
779
- display: grid;
780
- grid-template-rows: auto minmax(0, 1fr) auto;
781
- overflow: hidden;
782
- border: 0;
783
- border-radius: 0;
784
- }
785
-
786
- .app-top {
787
- padding: 14px 16px 10px;
788
- }
789
-
790
- .brand-title {
791
- font-size: 25px;
792
- }
793
-
794
- .status-pill {
795
- min-height: 30px;
796
- font-size: 11px;
797
- }
798
-
799
- .screen-region {
800
- min-height: 0;
801
- overflow-y: auto;
802
- padding: 14px 14px 16px;
803
- }
804
-
805
- .screen {
806
- min-height: auto;
807
- gap: 10px;
808
- }
809
-
810
- .screen-head {
811
- gap: 2px;
812
- }
813
-
814
- .screen-title {
815
- font-size: 26px;
816
- }
817
-
818
- .screen-copy {
819
- font-size: 13px;
820
- }
821
-
822
- .panel {
823
- padding: 11px;
824
- }
825
-
826
- .goal-create {
827
- padding: 11px;
828
- }
829
-
830
- .goal-create-row {
831
  grid-template-columns: 1fr;
832
  }
833
 
834
- .create-button {
835
- justify-content: center;
836
- }
837
-
838
- .section-label {
839
- margin-bottom: 7px;
840
- font-size: 12px;
841
- }
842
-
843
- .style-grid {
844
- gap: 7px;
845
- }
846
-
847
- .style-option {
848
- min-height: 62px;
849
- grid-template-columns: 38px 1fr auto;
850
- gap: 9px;
851
- padding: 9px;
852
- }
853
-
854
- .style-mark {
855
- width: 38px;
856
- height: 38px;
857
- border-radius: 12px;
858
- }
859
-
860
- .style-name {
861
- font-size: 16px;
862
- }
863
-
864
- .style-meta {
865
- font-size: 12px;
866
  }
867
 
868
- .avatar-slot {
869
- min-height: 108px;
870
- }
871
-
872
- .avatar-face {
873
- width: 50px;
874
- height: 50px;
875
- border-radius: 18px;
876
- }
877
-
878
- .counter {
879
- min-height: 76px;
880
- }
881
-
882
- .counter strong {
883
- font-size: 30px;
884
- }
885
-
886
- .nav {
887
- min-height: 68px;
888
- }
889
-
890
- .goal-modal-backdrop {
891
- padding: 12px 12px 78px;
892
- }
893
-
894
- .goal-modal-shell {
895
- max-height: calc(100dvh - 96px);
896
- }
897
- }
898
-
899
- @media (max-width: 374px) {
900
- .screen-region {
901
- padding-inline: 12px;
902
- }
903
-
904
- .kid-grid,
905
- .counter-grid {
906
- gap: 8px;
907
  }
908
 
909
- .screen-title,
910
- .goal-card h2 {
911
- font-size: 25px;
912
  }
913
  }
 
1
+ .app-shell {
2
+ width: 100%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
4
 
5
+ .app-head {
 
6
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
 
9
+ .wordmark .sub {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  text-transform: uppercase;
11
  }
12
 
13
+ .goal-strip {
14
  display: grid;
15
+ gap: clamp(8px, 2.4cqw, 12px);
16
  }
17
 
18
+ .goal-button {
19
  width: 100%;
20
+ border: 1.5px solid var(--field-border-color);
21
+ border-radius: var(--control-radius);
22
+ background: var(--field-bg);
23
+ box-shadow: var(--control-shadow);
24
+ color: var(--field-fg);
25
+ cursor: pointer;
26
  display: grid;
27
+ grid-template-columns: auto 1fr auto;
28
+ gap: clamp(10px, 3cqw, 14px);
29
  align-items: center;
30
+ padding: clamp(11px, 3.2cqw, 15px) clamp(13px, 3.6cqw, 17px);
 
 
31
  text-align: left;
32
+ transition: border-color .15s, background .15s, transform .1s;
33
  }
34
 
35
+ .goal-button:hover,
36
+ .goal-button[aria-pressed="true"] {
37
+ border-color: var(--accent);
38
+ background: var(--accent-soft);
39
  }
40
 
41
+ .goal-button:active {
42
+ transform: translateY(1px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  }
44
 
45
+ .goal-button__mark {
46
+ width: 30px;
47
+ height: 30px;
 
48
  border-radius: 50%;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  display: grid;
50
  place-items: center;
51
+ background: var(--card-bg);
52
+ color: var(--accent);
53
+ border: 1.5px solid var(--field-border-color);
54
+ font-family: var(--font-display);
55
+ font-weight: var(--display-weight);
56
+ font-size: 14px;
57
  }
58
 
59
+ .goal-button.is-complete .goal-button__mark {
60
+ background: var(--success);
61
+ border-color: var(--success);
62
+ color: var(--on-primary);
63
  }
64
 
65
+ .goal-button__copy {
66
+ min-width: 0;
 
 
 
67
  }
68
 
69
+ .goal-button__title {
70
+ color: var(--field-fg);
 
 
 
 
 
71
  font-weight: 800;
72
+ font-size: clamp(15px, 4.2cqw, 18px);
73
+ line-height: 1.2;
 
 
 
 
 
74
  }
75
 
76
+ .goal-button__meta {
77
+ margin-top: 3px;
78
+ color: var(--field-fg-muted);
79
+ font-size: clamp(12px, 3.2cqw, 14px);
80
+ font-weight: 700;
 
 
 
 
81
  }
82
 
83
+ .goal-button__status {
84
+ border-radius: var(--pill-radius);
85
+ padding: 5px 9px;
86
+ color: var(--accent-ink);
87
+ background: var(--accent-soft);
88
+ font-size: 11px;
 
 
 
 
89
  font-weight: 900;
90
+ text-transform: uppercase;
91
+ white-space: nowrap;
92
  }
93
 
94
+ .card-preview {
95
+ margin-top: clamp(20px, 6cqw, 28px);
 
96
  }
97
 
98
+ .hero img {
99
+ display: block;
100
+ width: 100%;
101
+ aspect-ratio: 16 / 9;
102
+ object-fit: cover;
103
+ background: var(--is-bg);
104
  }
105
 
106
+ .hero__rank {
107
+ max-width: calc(100% - 92px);
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ white-space: nowrap;
 
111
  }
112
 
113
+ .quest-headline .narration {
114
+ max-width: 36rem;
115
+ margin: .65em auto 0;
116
+ color: var(--body-color);
117
+ font-family: var(--font-body);
118
+ font-size: clamp(14px, 3.6cqw, 17px);
119
+ font-weight: 650;
120
+ line-height: 1.45;
121
+ text-transform: none;
122
+ letter-spacing: 0;
123
  }
124
 
125
+ .media-panel {
126
+ margin-top: clamp(16px, 4.4cqw, 22px);
127
  display: grid;
128
+ gap: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  }
130
 
131
+ .audio-shell {
132
+ background: var(--panel-bg);
133
+ border: 1px solid var(--panel-border-color);
134
+ border-radius: var(--control-radius);
135
+ padding: 12px;
136
  }
137
 
138
+ .audio-shell audio {
139
+ display: block;
140
+ width: 100%;
141
+ min-height: 38px;
142
  }
143
 
144
+ .asset-pills {
145
  display: flex;
 
146
  flex-wrap: wrap;
147
+ gap: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
 
150
+ .asset-pill {
151
  display: inline-flex;
152
  align-items: center;
153
  gap: 6px;
154
+ min-height: 30px;
155
+ border-radius: var(--pill-radius);
156
+ background: var(--panel-bg);
157
+ border: 1px solid var(--panel-border-color);
158
+ color: var(--muted-color);
159
+ font-size: 12px;
160
+ font-weight: 850;
161
+ padding: 6px 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  }
163
 
164
+ .asset-pill svg {
165
+ width: 14px;
166
+ height: 14px;
167
+ color: var(--accent);
168
  }
169
 
170
+ .action-row {
171
+ margin-top: clamp(18px, 5cqw, 24px);
 
 
172
  display: grid;
173
+ gap: 12px;
 
174
  }
175
 
176
+ .state-note {
177
+ border-radius: var(--control-radius);
178
+ border: 1px solid var(--panel-border-color);
179
+ background: var(--panel-bg);
180
+ padding: 14px 16px;
181
+ color: var(--body-color);
182
+ font-size: clamp(13px, 3.6cqw, 15px);
183
+ font-weight: 700;
184
+ line-height: 1.45;
185
  }
186
 
187
+ .state-note strong {
188
+ color: var(--accent);
 
 
 
 
 
 
189
  }
190
 
191
+ .parent-list {
 
 
 
 
192
  display: grid;
193
+ gap: 12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
+ .parent-row {
197
  display: grid;
198
+ grid-template-columns: 1fr auto;
199
  gap: 12px;
200
+ align-items: center;
201
+ background: var(--field-bg);
202
+ border: 1.5px solid var(--field-border-color);
203
+ border-radius: var(--control-radius);
204
+ box-shadow: var(--control-shadow);
205
+ padding: clamp(12px, 3.4cqw, 16px);
206
  }
207
 
208
+ .parent-row__title {
209
+ color: var(--field-fg);
210
+ font-size: clamp(15px, 4cqw, 18px);
211
+ font-weight: 850;
212
+ line-height: 1.2;
213
  }
214
 
215
+ .parent-row__meta {
216
+ margin-top: 5px;
217
+ color: var(--field-fg-muted);
218
+ font-size: clamp(12px, 3.2cqw, 14px);
219
+ font-weight: 700;
220
+ line-height: 1.35;
221
  }
222
 
223
+ .parent-row__status {
224
+ display: inline-flex;
 
225
  align-items: center;
226
+ min-height: 30px;
227
+ border-radius: var(--pill-radius);
228
+ color: var(--accent-ink);
229
+ background: var(--accent-soft);
230
+ padding: 5px 10px;
231
+ font-size: 11px;
232
  font-weight: 900;
233
+ text-transform: uppercase;
234
+ white-space: nowrap;
235
  }
236
 
237
+ .parent-actions {
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 8px;
241
+ align-items: flex-end;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
 
244
+ .parent-actions .btn-ghost {
245
+ justify-content: center;
 
 
246
  }
247
 
248
+ .summary-grid {
 
249
  display: grid;
250
+ grid-template-columns: repeat(3, minmax(0, 1fr));
251
+ gap: 10px;
252
+ margin-top: clamp(16px, 4.4cqw, 22px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
+ .summary-tile {
256
+ background: var(--panel-bg);
257
+ border: 1px solid var(--panel-border-color);
258
+ border-radius: var(--control-radius);
259
+ padding: 12px;
260
+ text-align: center;
261
  }
262
 
263
+ .summary-tile b {
264
+ display: block;
265
+ color: var(--heading-color);
266
+ font-family: var(--font-display);
267
+ font-size: clamp(20px, 5cqw, 26px);
268
+ line-height: 1;
269
  }
270
 
271
+ .summary-tile span {
272
+ display: block;
273
+ margin-top: 5px;
274
+ color: var(--muted-color);
 
 
 
 
 
275
  font-size: 11px;
276
+ font-weight: 850;
277
+ text-transform: uppercase;
 
 
 
 
 
 
 
 
278
  }
279
 
280
+ .corner-flourish svg,
281
+ .corner-flourish {
282
+ pointer-events: none;
 
 
 
 
 
 
 
 
283
  }
284
 
285
  @media (max-width: 520px) {
286
+ .parent-row {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  grid-template-columns: 1fr;
288
  }
289
 
290
+ .parent-actions {
291
+ align-items: stretch;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  }
293
 
294
+ .summary-grid {
295
+ grid-template-columns: 1fr;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  }
297
 
298
+ .vt {
299
+ padding-inline: 10px;
 
300
  }
301
  }
frontend/styles/components.css CHANGED
@@ -1,224 +1,400 @@
1
- * {
2
- box-sizing: border-box;
3
- }
 
 
 
4
 
5
- html {
6
- min-height: 100%;
7
- background:
8
- radial-gradient(circle at 8% 0%, rgba(115, 202, 241, 0.28), transparent 34%),
9
- radial-gradient(circle at 90% 18%, rgba(255, 242, 181, 0.45), transparent 30%),
10
- linear-gradient(180deg, #f9fdff 0%, #fffaf3 100%);
11
- }
12
 
13
  body {
14
- min-height: 100%;
15
- margin: 0;
16
- color: var(--color-ink);
17
  font-family: var(--font-body);
18
- font-size: 16px;
19
- line-height: 1.45;
 
 
 
 
20
  }
21
 
22
- button,
23
- input {
24
- font: inherit;
25
- }
26
 
27
- button {
28
- cursor: pointer;
29
- }
30
 
31
- button:focus-visible,
32
- input:focus-visible,
33
- summary:focus-visible {
34
- outline: 3px solid rgba(27, 103, 213, 0.35);
35
- outline-offset: 3px;
 
 
 
36
  }
 
37
 
38
- .material-symbols-rounded {
39
- display: inline-flex;
 
 
 
 
40
  align-items: center;
41
  justify-content: center;
42
- font-family: "Material Symbols Rounded";
43
- font-size: 24px;
44
- font-style: normal;
45
- font-weight: normal;
46
- line-height: 1;
47
- text-transform: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  white-space: nowrap;
49
- direction: ltr;
50
- -webkit-font-feature-settings: "liga";
51
- -webkit-font-smoothing: antialiased;
 
 
 
 
 
 
 
 
 
 
 
 
52
  }
53
 
54
- .app-frame {
 
 
 
55
  position: relative;
56
- width: min(100%, var(--frame-width));
57
- min-height: 100vh;
58
- margin: 0 auto;
 
 
 
59
  overflow: hidden;
60
- background:
61
- linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 252, 246, 0.98)),
62
- repeating-linear-gradient(180deg, transparent 0 62px, rgba(38, 97, 165, 0.08) 63px 64px);
63
- border: 1px solid rgba(204, 214, 225, 0.8);
64
- border-radius: 10px;
65
- box-shadow: var(--shadow-card);
66
- }
67
-
68
- .app-frame::before {
69
- position: absolute;
70
- inset: -10px -10px auto -10px;
71
- height: 160px;
72
  content: "";
 
 
73
  pointer-events: none;
74
- background:
75
- radial-gradient(ellipse at 12% 12%, rgba(95, 185, 231, 0.16), transparent 44%),
76
- radial-gradient(ellipse at 82% 0%, rgba(165, 221, 247, 0.42), transparent 36%);
77
  }
 
78
 
79
- .screen {
80
- position: relative;
81
- z-index: 1;
82
- min-height: 100vh;
83
- }
 
 
 
 
 
 
84
 
85
- .brand-wordmark {
86
- display: inline-grid;
87
- gap: 0;
 
 
88
  font-family: var(--font-display);
89
- font-size: clamp(34px, 8vw, 50px);
90
- font-weight: 800;
91
- line-height: 0.82;
 
 
 
92
  }
93
 
94
- .brand-wordmark span:first-child {
95
- color: var(--color-primary);
96
- text-shadow: 0 2px 0 #ffe17d;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  }
 
 
 
98
 
99
- .brand-wordmark span:last-child {
100
- color: #122346;
101
- transform: rotate(-5deg);
 
 
 
 
 
 
 
 
 
 
 
 
102
  }
 
 
 
103
 
104
- .icon-button,
105
- .pill-button,
106
- .segmented-button,
107
- .tone-card,
108
- .goal-row,
109
- .photo-card,
110
- .photo-uploader {
111
- border: 1px solid var(--color-line);
112
- background: var(--color-paper);
113
- color: var(--color-ink);
114
- box-shadow: var(--shadow-soft);
115
  }
 
 
 
116
 
117
- .icon-button {
118
- display: inline-flex;
119
- width: 64px;
120
- height: 64px;
121
- align-items: center;
122
- justify-content: center;
123
- border: 0;
124
- border-radius: 50%;
125
- background: rgba(255, 255, 255, 0.95);
126
- color: #10264a;
127
- }
128
 
129
- .pill-button {
130
- display: inline-flex;
131
- min-height: 46px;
132
- align-items: center;
133
- justify-content: center;
134
- gap: var(--space-2);
135
- padding: 0 var(--space-5);
136
- border-radius: var(--radius-pill);
137
- font-weight: 800;
138
- }
139
 
140
- .status-line,
141
- .runtime-chip {
142
- display: inline-flex;
143
- align-items: center;
144
- justify-content: center;
145
- gap: var(--space-2);
146
- color: var(--color-ink-muted);
147
- font-size: 13px;
148
- font-weight: 700;
149
  }
 
 
 
 
150
 
151
- .status-dot {
152
- width: 8px;
153
- height: 8px;
154
- border-radius: 50%;
155
- background: #5acb85;
 
 
 
 
 
 
 
 
156
  }
157
-
158
- .primary-action,
159
- .done-action {
160
- display: inline-flex;
161
- width: min(100%, 392px);
162
- min-height: 58px;
163
- align-items: center;
164
- justify-content: center;
165
- gap: var(--space-3);
166
- border: 0;
167
- border-radius: var(--radius-pill);
168
- color: white;
169
- font-family: var(--font-display);
170
- font-size: 24px;
171
- font-weight: 800;
172
  }
173
-
174
- .primary-action {
175
- background: linear-gradient(180deg, #ff3f38, var(--color-primary));
176
- box-shadow: var(--shadow-button);
 
 
 
 
177
  }
 
 
 
 
 
178
 
179
- .done-action {
180
- background: linear-gradient(180deg, #18ba75, var(--color-success));
181
- box-shadow: 0 6px 0 rgba(8, 115, 71, 0.16), 0 14px 28px rgba(17, 169, 104, 0.22);
 
 
 
 
 
 
 
 
182
  }
 
 
183
 
184
- .primary-action:active,
185
- .done-action:active {
186
- transform: translateY(2px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
 
 
188
 
189
- .screen-title {
190
- margin: 0;
191
- color: var(--color-ink);
192
- font-family: var(--font-display);
193
- font-size: clamp(42px, 10vw, 55px);
194
- font-weight: 800;
195
- line-height: 0.95;
196
- text-align: center;
 
 
 
 
 
 
197
  }
 
 
 
 
198
 
199
- .field-label {
200
- display: flex;
201
- align-items: baseline;
202
- gap: var(--space-1);
203
- margin: 0 0 var(--space-3);
204
- color: var(--color-ink);
205
- font-size: 18px;
206
- font-weight: 900;
 
 
207
  }
 
 
 
 
208
 
209
- .field-label span {
210
- color: var(--color-ink-soft);
211
- font-size: 15px;
212
- font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
- .sr-only {
216
- position: absolute;
217
- width: 1px;
218
- height: 1px;
219
- padding: 0;
220
- overflow: hidden;
221
- clip: rect(0, 0, 0, 0);
222
- white-space: nowrap;
223
- border: 0;
 
 
 
 
 
 
 
 
 
 
224
  }
 
1
+ /* ============================================================================
2
+ EPIC ERRANDS — STRUCTURAL COMPONENT CSS
3
+ Layout + structure only. Every color / font / radius / shadow comes from a
4
+ SEMANTIC token (see tokens.css). No literal theme values live here, so the
5
+ same stylesheet paints all three skins.
6
+ ========================================================================== */
7
 
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html, body { min-height: 100%; }
 
 
 
 
 
10
 
11
  body {
 
 
 
12
  font-family: var(--font-body);
13
+ color: var(--body-color);
14
+ background: var(--page-bg);
15
+ background-attachment: fixed;
16
+ -webkit-font-smoothing: antialiased;
17
+ text-rendering: optimizeLegibility;
18
+ min-height: 100dvh;
19
  }
20
 
21
+ button { font-family: inherit; }
22
+ [contenteditable] { outline: none; }
23
+ [contenteditable]:focus { box-shadow: 0 0 0 2px var(--accent); border-radius: 4px; }
 
24
 
25
+ .display { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); line-height: var(--display-line); }
 
 
26
 
27
+ /* ============================================================================
28
+ APP SHELL (theme rail + stage)
29
+ ========================================================================== */
30
+ .stage {
31
+ min-height: 100dvh;
32
+ display: grid;
33
+ place-items: start center;
34
+ padding: clamp(64px, 8vh, 96px) clamp(12px, 4vw, 40px) clamp(32px, 6vh, 64px);
35
  }
36
+ #app { width: 100%; max-width: var(--card-max); display: flex; justify-content: center; }
37
 
38
+ /* ---- Theme switcher rail (fixed top) ---- */
39
+ .theme-rail {
40
+ position: fixed;
41
+ top: 0; left: 0; right: 0;
42
+ z-index: 50;
43
+ display: flex;
44
  align-items: center;
45
  justify-content: center;
46
+ gap: 10px;
47
+ padding: 10px 16px;
48
+ background: rgba(20,22,28,.72);
49
+ backdrop-filter: blur(14px) saturate(1.2);
50
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
51
+ border-bottom: 1px solid rgba(255,255,255,.08);
52
+ }
53
+ .theme-rail__label {
54
+ font-family: 'Hanken Grotesk', system-ui, sans-serif;
55
+ font-size: 11px;
56
+ font-weight: 700;
57
+ letter-spacing: .14em;
58
+ text-transform: uppercase;
59
+ color: rgba(255,255,255,.5);
60
+ margin-right: 4px;
61
+ }
62
+ .theme-seg { display: flex; gap: 4px; background: rgba(255,255,255,.07); border-radius: 999px; padding: 4px; }
63
+ .theme-btn {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ border: none;
68
+ cursor: pointer;
69
+ background: transparent;
70
+ color: rgba(255,255,255,.7);
71
+ font-family: 'Hanken Grotesk', system-ui, sans-serif;
72
+ font-size: 13px;
73
+ font-weight: 700;
74
+ padding: 8px 16px;
75
+ border-radius: 999px;
76
+ transition: background .18s, color .18s;
77
  white-space: nowrap;
78
+ }
79
+ .theme-btn:hover { color: #fff; }
80
+ .theme-btn .swatch {
81
+ width: 13px; height: 13px; border-radius: 50%;
82
+ flex: none;
83
+ box-shadow: 0 0 0 2px rgba(255,255,255,.25);
84
+ }
85
+ .theme-btn[aria-pressed="true"] { background: #fff; color: #15171d; }
86
+ .theme-btn[aria-pressed="true"] .swatch { box-shadow: 0 0 0 2px rgba(0,0,0,.12); }
87
+
88
+ @media (max-width: 560px) {
89
+ .theme-rail__label { display: none; }
90
+ .theme-btn { padding: 8px 12px; }
91
+ .theme-btn .label { display: none; }
92
+ .theme-btn .swatch { width: 16px; height: 16px; }
93
  }
94
 
95
+ /* ============================================================================
96
+ CARD (the device-sized surface, shared by both screens)
97
+ ========================================================================== */
98
+ .card {
99
  position: relative;
100
+ width: 100%;
101
+ max-width: var(--card-max);
102
+ background: var(--card-bg);
103
+ border-radius: var(--card-radius);
104
+ border: var(--card-border-width) solid var(--card-border-color);
105
+ box-shadow: var(--card-shadow);
106
  overflow: hidden;
107
+ isolation: isolate;
108
+ container-type: inline-size;
109
+ }
110
+ .card::before {
 
 
 
 
 
 
 
 
111
  content: "";
112
+ position: absolute; inset: 0;
113
+ background: var(--card-texture);
114
  pointer-events: none;
115
+ z-index: 0;
 
 
116
  }
117
+ .card-inner { position: relative; z-index: 1; padding: clamp(22px, 6cqw, 34px); }
118
 
119
+ /* ornate corner flourishes (atelier only — hidden elsewhere by token CSS) */
120
+ .corner-flourish { position: absolute; width: 50px; height: 50px; color: var(--accent); z-index: 2; pointer-events: none; }
121
+ .corner-flourish.tl { top: 10px; left: 10px; }
122
+ .corner-flourish.tr { top: 10px; right: 10px; transform: scaleX(-1); }
123
+ .corner-flourish.bl { bottom: 10px; left: 10px; transform: scaleY(-1); }
124
+ .corner-flourish.br { bottom: 10px; right: 10px; transform: scale(-1,-1); }
125
+
126
+ /* ============================================================================
127
+ SHARED PRIMITIVES
128
+ ========================================================================== */
129
+ .divider { width: 100%; margin: clamp(18px, 5cqw, 26px) 0; border: none; border-top: 1px solid var(--field-border-color); }
130
 
131
+ /* Banner pill (top of quest screen) */
132
+ .banner {
133
+ display: inline-flex; align-items: center; justify-content: center;
134
+ background: var(--banner-bg);
135
+ color: var(--banner-text);
136
  font-family: var(--font-display);
137
+ font-weight: var(--display-weight);
138
+ text-transform: var(--display-transform);
139
+ letter-spacing: var(--display-tracking);
140
+ font-size: clamp(16px, 3.6cqw, 22px);
141
+ padding: 11px 22px;
142
+ box-shadow: var(--control-shadow);
143
  }
144
 
145
+ /* Primary CTA */
146
+ .btn-primary {
147
+ position: relative;
148
+ display: inline-flex; align-items: center; justify-content: center;
149
+ gap: .55em;
150
+ width: 100%;
151
+ border: none; cursor: pointer;
152
+ padding: clamp(15px, 4.2cqw, 19px);
153
+ border-radius: var(--primary-radius);
154
+ background: var(--primary);
155
+ color: var(--primary-text);
156
+ font-family: var(--font-display);
157
+ font-weight: var(--display-weight);
158
+ text-transform: var(--display-transform);
159
+ letter-spacing: var(--display-tracking);
160
+ font-size: clamp(19px, 5cqw, 25px);
161
+ box-shadow: var(--primary-shadow);
162
+ transition: transform .08s, box-shadow .08s, filter .15s;
163
  }
164
+ .btn-primary:hover:not(:disabled) { filter: brightness(1.04); }
165
+ .btn-primary:disabled { opacity: .45; cursor: not-allowed; }
166
+ .btn-primary svg { width: 1.2em; height: 1.2em; }
167
 
168
+ /* Secondary / ghost button */
169
+ .btn-ghost {
170
+ display: inline-flex; align-items: center; gap: .5em;
171
+ border: 1.5px solid var(--field-border-color);
172
+ background: var(--field-bg);
173
+ color: var(--field-fg);
174
+ cursor: pointer;
175
+ font-family: var(--font-body);
176
+ font-weight: 700;
177
+ font-size: clamp(13px, 3.4cqw, 15px);
178
+ padding: 10px 16px;
179
+ border-radius: var(--control-radius);
180
+ box-shadow: var(--control-shadow);
181
+ white-space: nowrap;
182
+ transition: background .15s, border-color .15s, transform .1s;
183
  }
184
+ .btn-ghost:hover { border-color: var(--accent); }
185
+ .btn-ghost:active { transform: translateY(1px); }
186
+ .btn-ghost svg { width: 1.1em; height: 1.1em; flex: none; }
187
 
188
+ .icon-btn {
189
+ flex: none; width: 44px; height: 44px; border-radius: var(--control-radius);
190
+ display: grid; place-items: center; cursor: pointer;
191
+ border: 1.5px solid var(--field-border-color);
192
+ background: var(--field-bg);
193
+ color: var(--field-fg);
194
+ box-shadow: var(--control-shadow);
195
+ transition: background .15s, transform .1s;
 
 
 
196
  }
197
+ .icon-btn:hover { border-color: var(--accent); }
198
+ .icon-btn:active { transform: translateY(1px); }
199
+ .icon-btn svg { width: 22px; height: 22px; }
200
 
201
+ /* ============================================================================
202
+ HEADER (shared)
203
+ ========================================================================== */
204
+ .app-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
205
+ .wordmark { display: flex; flex-direction: column; line-height: 1; }
206
+ .wordmark .display { font-size: clamp(26px, 7cqw, 36px); color: var(--heading-color); }
207
+ .wordmark .sub { font-family: var(--font-body); font-weight: 700; font-size: clamp(11px, 3cqw, 13px); color: var(--muted-color); letter-spacing: .04em; margin-top: 4px; }
 
 
 
 
208
 
209
+ /* ============================================================================
210
+ TITLE / SECTION
211
+ ========================================================================== */
212
+ .lead { text-align: center; margin-top: clamp(10px, 3cqw, 18px); }
213
+ .lead h1 { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); line-height: var(--display-line); color: var(--heading-color); font-size: clamp(32px, 10cqw, 48px); }
214
+ .lead p { margin-top: .55em; color: var(--muted-color); font-weight: 600; font-size: clamp(14px, 4cqw, 18px); }
215
+ .lead .u { background-image: linear-gradient(var(--success), var(--success)); background-size: 100% 3px; background-position: 0 100%; background-repeat: no-repeat; padding-bottom: 1px; }
 
 
 
216
 
217
+ .section { margin-top: clamp(20px, 6cqw, 28px); }
218
+ .section__head { display: flex; align-items: center; gap: 12px; margin-bottom: clamp(12px, 3.4cqw, 16px); }
219
+ .section__num {
220
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
221
+ display: grid; place-items: center;
222
+ background: var(--accent-soft); color: var(--accent-ink);
223
+ font-family: var(--font-display); font-weight: var(--display-weight); font-size: 16px;
 
 
224
  }
225
+ .section__titles { min-width: 0; }
226
+ .section__title { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); color: var(--heading-color); font-size: clamp(18px, 5cqw, 24px); display: flex; align-items: baseline; gap: .4em; flex-wrap: wrap; }
227
+ .section__title .note { font-family: var(--font-body); font-weight: 600; text-transform: none; letter-spacing: 0; font-size: .62em; color: var(--faint-color); }
228
+ .section__sub { color: var(--muted-color); font-size: clamp(13px, 3.6cqw, 15px); margin-top: 2px; }
229
 
230
+ /* ============================================================================
231
+ GOAL ROWS
232
+ ========================================================================== */
233
+ .goals { display: flex; flex-direction: column; gap: clamp(8px, 2.4cqw, 12px); }
234
+ .goal {
235
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center;
236
+ gap: clamp(10px, 3cqw, 14px);
237
+ padding: clamp(11px, 3.2cqw, 15px) clamp(13px, 3.6cqw, 17px);
238
+ border: 1.5px solid var(--field-border-color);
239
+ border-radius: var(--control-radius);
240
+ background: var(--field-bg);
241
+ box-shadow: var(--control-shadow);
242
+ transition: border-color .15s;
243
  }
244
+ .goal:focus-within { border-color: var(--accent); }
245
+ .goal__check {
246
+ flex: none; width: 24px; height: 24px; border-radius: 6px;
247
+ border: 2px solid var(--field-border-color);
248
+ background: var(--card-bg); cursor: pointer;
249
+ display: grid; place-items: center;
250
+ transition: background .12s, border-color .12s;
 
 
 
 
 
 
 
 
251
  }
252
+ .goal__check svg { width: 70%; height: 70%; opacity: 0; transition: opacity .12s; color: #fff; }
253
+ .goal.done .goal__check { background: var(--success); border-color: var(--success); }
254
+ .goal.done .goal__check svg { opacity: 1; }
255
+ .goal__input {
256
+ border: none; outline: none; background: transparent;
257
+ font-family: var(--font-body); font-weight: 700;
258
+ font-size: clamp(15px, 4.2cqw, 18px); color: var(--field-fg);
259
+ min-width: 0;
260
  }
261
+ .goal__input::placeholder { color: var(--field-fg-muted); font-weight: 600; }
262
+ .goal.done .goal__input { text-decoration: line-through; color: var(--field-fg-muted); }
263
+ .goal__clear { flex: none; width: 30px; height: 30px; border-radius: 8px; border: none; background: transparent; color: var(--field-fg-muted); cursor: pointer; display: grid; place-items: center; transition: background .15s, color .15s; }
264
+ .goal__clear:hover { background: rgba(0,0,0,.06); color: var(--field-fg); }
265
+ .goal__clear svg { width: 18px; height: 18px; }
266
 
267
+ .goal-add {
268
+ display: flex; align-items: center; justify-content: center; gap: .5em;
269
+ width: 100%;
270
+ padding: clamp(11px, 3.2cqw, 15px);
271
+ border: 1.5px dashed var(--control-border-color);
272
+ border-radius: var(--control-radius);
273
+ background: none; cursor: pointer;
274
+ font-family: var(--font-body); font-weight: 700;
275
+ color: var(--muted-color);
276
+ font-size: clamp(14px, 4cqw, 17px);
277
+ transition: border-color .15s, color .15s;
278
  }
279
+ .goal-add:hover { border-color: var(--accent); color: var(--accent); }
280
+ .goal-add svg { width: 20px; height: 20px; }
281
 
282
+ /* ============================================================================
283
+ PHOTOS
284
+ ========================================================================== */
285
+ .photos { display: flex; flex-wrap: wrap; gap: clamp(14px, 4cqw, 20px); }
286
+ .photo { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 88px; }
287
+ .photo__slot { position: relative; width: 88px; height: 88px; }
288
+ .photo__slot image-slot { display: block; width: 88px; height: 88px; border: 3px solid var(--card-bg); border-radius: 50%; box-shadow: 0 0 0 1.5px var(--control-border-color), var(--control-shadow); }
289
+ .photo__rm { position: absolute; top: -2px; right: -2px; width: 26px; height: 26px; border-radius: 50%; border: 1.5px solid var(--control-border-color); background: var(--card-bg); color: var(--body-color); cursor: pointer; display: grid; place-items: center; box-shadow: var(--control-shadow); }
290
+ .photo__rm svg { width: 13px; height: 13px; }
291
+ .photo__name { width: 100%; text-align: center; border: none; outline: none; background: transparent; font-family: var(--font-body); font-weight: 600; font-size: 13px; color: var(--muted-color); }
292
+ .photo-add {
293
+ width: 88px; height: 88px; border-radius: 50%; cursor: pointer;
294
+ border: 2px dashed var(--control-border-color); background: transparent;
295
+ color: var(--muted-color);
296
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
297
+ font-family: var(--font-body); font-weight: 700; font-size: 12px;
298
+ transition: border-color .15s, color .15s, background .15s;
299
  }
300
+ .photo-add:hover { border-color: var(--accent); color: var(--accent); }
301
+ .photo-add svg { width: 24px; height: 24px; }
302
 
303
+ /* ============================================================================
304
+ TONE CHIPS
305
+ ========================================================================== */
306
+ .tones { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(8px, 2.4cqw, 12px); }
307
+ .tone-chip {
308
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .5em;
309
+ padding: clamp(12px, 3.4cqw, 17px) 4px;
310
+ border: 1.5px solid var(--field-border-color);
311
+ border-radius: var(--chip-radius);
312
+ background: var(--field-bg); cursor: pointer;
313
+ font-family: var(--font-body); font-weight: 700;
314
+ font-size: clamp(11px, 3cqw, 14px); color: var(--field-fg);
315
+ box-shadow: var(--control-shadow);
316
+ transition: border-color .15s, background .15s, box-shadow .15s;
317
  }
318
+ .tone-chip svg { width: clamp(26px, 7cqw, 32px); height: clamp(26px, 7cqw, 32px); color: var(--field-fg-muted); transition: color .15s; }
319
+ .tone-chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
320
+ .tone-chip[aria-pressed="true"] svg { color: var(--accent-ink); }
321
+ .tone-chip[aria-pressed="true"] span { color: var(--accent-ink); }
322
 
323
+ /* ============================================================================
324
+ STATUS FOOTER (builder)
325
+ ========================================================================== */
326
+ .statusbar {
327
+ display: flex; align-items: center; gap: 14px;
328
+ margin-top: clamp(20px, 6cqw, 28px);
329
+ padding: 14px 18px;
330
+ background: var(--panel-bg);
331
+ border: 1px solid var(--panel-border-color);
332
+ border-radius: var(--control-radius);
333
  }
334
+ .statusbar__dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--success); box-shadow: 0 0 8px var(--success); }
335
+ .statusbar__info { flex: 1; min-width: 0; font-size: clamp(12px, 3.4cqw, 14px); line-height: 1.5; }
336
+ .statusbar__info b { color: var(--accent); font-weight: 800; }
337
+ .statusbar__info .k { color: var(--muted-color); font-weight: 700; }
338
 
339
+ /* ============================================================================
340
+ QUEST SCREEN
341
+ ========================================================================== */
342
+ .quest-top { display: flex; align-items: center; gap: 12px; margin-bottom: clamp(14px, 4cqw, 20px); }
343
+ .quest-top .banner { flex: 1 1 auto; }
344
+ .viewtoggle { display: inline-flex; gap: 4px; background: var(--panel-bg); border: 1px solid var(--panel-border-color); padding: 4px; border-radius: var(--pill-radius); flex: none; }
345
+ .vt { display: inline-flex; align-items: center; gap: 6px; border: none; cursor: pointer; background: transparent; color: var(--muted-color); font-family: var(--font-body); font-weight: 700; font-size: clamp(11px, 3cqw, 13px); padding: 8px 12px; border-radius: var(--pill-radius); transition: background .15s, color .15s; white-space: nowrap; }
346
+ .vt svg { width: 15px; height: 15px; }
347
+ .vt[aria-pressed="true"] { background: var(--accent); color: var(--on-primary); }
348
+
349
+ .hero { position: relative; border-radius: var(--control-radius); overflow: hidden; box-shadow: var(--control-shadow); }
350
+ .hero image-slot { display: block; width: 100%; aspect-ratio: 16 / 9; }
351
+ .hero__points { position: absolute; top: 14px; right: 14px; display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px 5px 8px; border-radius: var(--pill-radius); background: rgba(20,16,10,.5); backdrop-filter: blur(4px); color: #fff; font-family: var(--font-display); font-weight: var(--display-weight); font-size: 16px; }
352
+ .hero__points svg { width: 17px; height: 17px; color: var(--reward); }
353
+ .hero__rank { position: absolute; top: 14px; left: 14px; padding: 5px 12px; border-radius: 6px; background: var(--accent); color: var(--on-primary); font-family: var(--font-display); font-weight: var(--display-weight); text-transform: uppercase; letter-spacing: .06em; font-size: 12px; box-shadow: 0 2px 6px rgba(0,0,0,.3); }
354
+
355
+ .quest-headline { position: relative; margin-top: clamp(-30px, -6cqw, -18px); text-align: center; z-index: 2; }
356
+ .quest-headline .eyebrow { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); color: var(--banner-text); font-size: clamp(15px, 3.6cqw, 20px); }
357
+ .quest-headline .quest-title { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); line-height: var(--display-line); color: var(--quest-title-color); font-size: clamp(30px, 8cqw, 46px); margin: .12em 0; }
358
+
359
+ /* parent panel */
360
+ .parent {
361
+ margin-top: clamp(18px, 5cqw, 24px);
362
+ background: var(--panel-bg);
363
+ border: 1px solid var(--panel-border-color);
364
+ border-radius: var(--control-radius);
365
+ padding: clamp(16px, 4.4cqw, 22px);
366
  }
367
+ .parent__head { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
368
+ .parent__head svg { width: 18px; height: 18px; color: var(--accent); }
369
+ .parent__head h3 { font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); color: var(--heading-color); font-size: clamp(16px, 4cqw, 20px); }
370
+ .parent__grid { display: grid; grid-template-columns: 1.5fr 1fr 1fr; gap: 16px; margin-bottom: 18px; }
371
+ .field .lab { font-family: var(--font-body); font-size: 12px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--muted-color); margin-bottom: 7px; }
372
+ .field__input { width: 100%; background: var(--field-bg); color: var(--field-fg); border: 1.5px solid var(--field-border-color); border-radius: var(--control-radius); padding: 11px 13px; font-family: var(--font-body); font-size: 15px; font-weight: 600; outline: none; }
373
+ .field__input:focus { border-color: var(--accent); }
374
+ .field .big { font-family: var(--font-display); font-weight: var(--display-weight); font-size: 20px; color: var(--heading-color); }
375
+ .field .pts { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-display); font-weight: var(--display-weight); font-size: 20px; color: var(--heading-color); }
376
+ .field .pts svg { width: 18px; height: 18px; color: var(--reward); }
377
+ .parent__btns { display: flex; gap: 12px; }
378
+ .parent__btns .btn-ghost { flex: 1; justify-content: center; }
379
+ .parent__btns .danger { flex: none; color: var(--accent); }
380
 
381
+ .kidnote { margin-top: clamp(18px, 5cqw, 24px); border: 2px dashed var(--control-border-color); border-radius: var(--control-radius); padding: 22px; text-align: center; color: var(--muted-color); font-weight: 600; font-size: clamp(13px, 3.6cqw, 15px); }
382
+ .kidnote b { display: block; margin-bottom: 6px; font-family: var(--font-display); font-weight: var(--display-weight); text-transform: var(--display-transform); letter-spacing: var(--display-tracking); color: var(--accent); font-size: 18px; }
383
+
384
+ /* footer line on builder */
385
+ .foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px 18px; margin-top: clamp(16px, 4cqw, 20px); color: var(--faint-color); font-weight: 700; font-size: clamp(11px, 3.2cqw, 14px); }
386
+ .foot__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); }
387
+ .foot span { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
388
+
389
+ /* screen transition */
390
+ @keyframes screenIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
391
+ @media (prefers-reduced-motion: no-preference) { .screen-in { animation: screenIn .34s cubic-bezier(.2,.7,.3,1); } }
392
+
393
+ /* loading spinner inside CTA */
394
+ @keyframes spin { to { transform: rotate(360deg); } }
395
+ .spinner { width: 1.2em; height: 1.2em; border-radius: 50%; border: 3px solid rgba(255,255,255,.4); border-top-color: currentColor; animation: spin .7s linear infinite; }
396
+
397
+ @media (max-width: 520px) {
398
+ .tones { grid-template-columns: repeat(2, 1fr); }
399
+ .parent__grid { grid-template-columns: 1fr 1fr; }
400
  }
frontend/styles/tokens.css CHANGED
@@ -1,54 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  :root {
2
- color-scheme: light;
3
-
4
- --font-display: "Baloo 2", "Trebuchet MS", system-ui, sans-serif;
5
- --font-body: "Nunito", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
6
-
7
- --color-ink: #13213c;
8
- --color-ink-soft: #536076;
9
- --color-ink-muted: #7c8596;
10
- --color-paper: #fffdfa;
11
- --color-paper-warm: #fff7e7;
12
- --color-line: #e7e1d8;
13
- --color-line-strong: #d8cdbd;
14
- --color-sky: #dff5ff;
15
- --color-sky-strong: #43b7ee;
16
- --color-primary: #ef332d;
17
- --color-primary-deep: #cf241f;
18
- --color-success: #11a968;
19
- --color-success-deep: #078a54;
20
- --color-school-blue: #1b67d5;
21
- --color-pencil: #f6b21a;
22
- --color-mint: #e9fff4;
23
- --color-parchment: #fff0c9;
24
- --color-parchment-edge: #e0b76b;
25
- --color-warning: #ff8b20;
26
- --color-lavender: #a64ce8;
27
- --color-shadow: rgba(33, 43, 68, 0.16);
28
-
29
- --space-1: 4px;
30
- --space-2: 8px;
31
- --space-3: 12px;
32
- --space-4: 16px;
33
- --space-5: 20px;
34
- --space-6: 24px;
35
- --space-7: 32px;
36
- --space-8: 40px;
37
- --space-9: 56px;
38
-
39
- --radius-sm: 10px;
40
- --radius-md: 14px;
41
- --radius-lg: 18px;
42
- --radius-xl: 24px;
43
- --radius-pill: 999px;
44
-
45
- --shadow-card: 0 12px 32px var(--color-shadow);
46
- --shadow-soft: 0 8px 18px rgba(34, 42, 62, 0.11);
47
- --shadow-sticker: 0 7px 14px rgba(47, 39, 28, 0.22);
48
- --shadow-button: 0 6px 0 rgba(145, 28, 24, 0.16), 0 14px 28px rgba(239, 51, 45, 0.22);
49
-
50
- --frame-width: 732px;
51
- --duration-fast: 160ms;
52
- --duration-med: 260ms;
53
- --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================
2
+ EPIC ERRANDS — THEMEABLE TOKEN SYSTEM
3
+ ----------------------------------------------------------------------------
4
+ One app, three skins. The whole UI is painted from a single SEMANTIC token
5
+ contract (declared in :root below as documentation defaults). Each theme is
6
+ a complete re-binding of those tokens, scoped under [data-theme="…"], plus a
7
+ small block of theme-scoped "flourish" rules for decoration that a flat
8
+ token cannot express (halftone, speech bubbles, ornate frames, lined paper).
9
+
10
+ The DOM never changes between themes — only the values behind these names.
11
+
12
+ THEMES
13
+ storybook — crayon / kids' picture-book (Baloo 2 · Nunito)
14
+ comic — comic-book command center (Bangers · Hanken Grotesk)
15
+ atelier — parchment questbook (Playfair Display · Lora)
16
+ ========================================================================== */
17
+
18
+ /* ============================================================================
19
+ SEMANTIC TOKEN CONTRACT (names every component is allowed to reference)
20
+ The values here are neutral fallbacks; a theme always overrides them.
21
+ ========================================================================== */
22
  :root {
23
+ /* — Type — */
24
+ --font-display: system-ui, sans-serif;
25
+ --font-body: system-ui, sans-serif;
26
+ --display-weight: 800;
27
+ --display-transform: none; /* none | uppercase */
28
+ --display-tracking: 0;
29
+ --display-line: 1.05;
30
+ --display-stroke: 0; /* px of -webkit-text-stroke on display text */
31
+ --display-stroke-color: transparent;
32
+
33
+ /* — Page & surfaces — */
34
+ --page-bg: #f4f5f7;
35
+ --page-fg-pattern: none; /* optional page texture overlay */
36
+ --card-bg: #ffffff;
37
+ --card-radius: 24px;
38
+ --card-border-width: 0px;
39
+ --card-border-color: transparent;
40
+ --card-shadow: 0 24px 60px -22px rgba(30,40,60,.4);
41
+ --card-texture: none; /* background-image overlay inside the card */
42
+ --card-max: 540px;
43
+
44
+ --panel-bg: #f3f4f6; /* raised inner panels: status bar, parent box */
45
+ --panel-border-color: #e5e7eb;
46
+ --field-bg: #fcfcfd; /* text inputs / goal rows */
47
+ --field-border-color: #e2e5e9;
48
+ --field-fg: #353a42; /* text ON a field surface (may differ from body) */
49
+ --field-fg-muted: #8b9097; /* placeholder / secondary text on a field */
50
+
51
+ /* — Text roles — */
52
+ --heading-color: #1f2430;
53
+ --body-color: #353a42;
54
+ --muted-color: #8b9097;
55
+ --faint-color: #b3b8bf;
56
+ --on-primary: #ffffff;
57
+
58
+ /* — Accent + primary action — */
59
+ --accent: #2f7fd6; /* selection ring, links, secondary highlight */
60
+ --accent-soft: #e7f0fb; /* selected chip fill */
61
+ --accent-ink: #1d4f88; /* text/icon on accent-soft */
62
+ --primary: #2f7fd6; /* CTA fill (may be a gradient) */
63
+ --primary-edge: #1d5fa8; /* 3D bottom edge under CTA */
64
+ --primary-text: #ffffff;
65
+
66
+ /* Semantic status colors — */
67
+ --success: #2f9e4e; /* checks and completed states */
68
+ --reward: #f6b81e; /* points, stars, ribbons */
69
+ --quest-title-color: #1f2430; /* the big generated quest title */
70
+ --banner-bg: #2f7fd6; /* top banner on the quest screen */
71
+ --banner-text: #ffffff;
72
+
73
+ /* — Controls — */
74
+ --control-radius: 14px;
75
+ --chip-radius: 14px;
76
+ --pill-radius: 999px;
77
+ --control-border-color: #e2e5e9;
78
+ --control-shadow: 0 2px 5px -2px rgba(30,40,60,.12);
79
+
80
+ /* — Button geometry per theme — */
81
+ --primary-radius: 999px;
82
+ --primary-shadow: 0 10px 22px -8px rgba(47,127,214,.55);
83
+
84
+ /* image-slot theming hook */
85
+ --is-bg: #eef1f5;
86
+ }
87
+
88
+ /* ============================================================================
89
+ THEME 1 — STORYBOOK (crayon, picture-book, soft & sunny)
90
+ ========================================================================== */
91
+ [data-theme="storybook"] {
92
+ --font-display: 'Baloo 2', cursive;
93
+ --font-body: 'Nunito', system-ui, sans-serif;
94
+ --display-weight: 800;
95
+ --display-transform: none;
96
+ --display-tracking: -.01em;
97
+ --display-line: 1.02;
98
+ --display-stroke: 0;
99
+
100
+ --page-bg: radial-gradient(125% 90% at 50% 0%, #eef6fd 0%, #e4eff9 50%, #d9e7f4 100%);
101
+ --card-bg: #ffffff;
102
+ --card-radius: 30px;
103
+ --card-border-width: 0px;
104
+ --card-border-color: transparent;
105
+ --card-shadow: 0 24px 60px -22px rgba(40,55,80,.42);
106
+ --card-texture:
107
+ radial-gradient(60px 34px at 16% 5%, rgba(120,180,230,.10), transparent 70%),
108
+ radial-gradient(80px 40px at 88% 8%, rgba(120,180,230,.09), transparent 70%),
109
+ repeating-linear-gradient(180deg, transparent 0 33px, rgba(120,150,180,.045) 33px 34px);
110
+ --card-max: 500px;
111
+
112
+ --panel-bg: #f3f7fb;
113
+ --panel-border-color: #e4e9ef;
114
+ --field-bg: #fcfcfd;
115
+ --field-border-color: #e4e6e9;
116
+ --field-fg: #34373a;
117
+ --field-fg-muted: #a9aeb4;
118
+
119
+ --heading-color: #163154;
120
+ --body-color: #34373a;
121
+ --muted-color: #8b9097;
122
+ --faint-color: #a9aeb4;
123
+ --on-primary: #ffffff;
124
+
125
+ --accent: #e9382d;
126
+ --accent-soft: #eaf5ee;
127
+ --accent-ink: #2f7f47;
128
+ --primary: linear-gradient(180deg, #e9382d 0%, #c52b22 100%);
129
+ --primary-edge: #a82018;
130
+ --primary-text: #ffffff;
131
+ --primary-radius: 999px;
132
+ --primary-shadow: 0 7px 0 var(--primary-edge), 0 10px 22px -8px rgba(233,56,45,.55);
133
+
134
+ --success: #46a05d;
135
+ --reward: #f6b81e;
136
+ --quest-title-color: #3a2a15;
137
+ --banner-bg: #f4e8c8;
138
+ --banner-text: #3a2a15;
139
+
140
+ --control-radius: 13px;
141
+ --chip-radius: 13px;
142
+ --control-border-color: #e4e6e9;
143
+ --control-shadow: 0 2px 5px -2px rgba(40,55,80,.12);
144
+
145
+ --is-bg: #eaf2fa;
146
+ }
147
+
148
+ /* ============================================================================
149
+ THEME 2 — COMIC (dark command center, halftone, bursts, bold ink)
150
+ ========================================================================== */
151
+ [data-theme="comic"] {
152
+ --font-display: 'Bangers', system-ui, sans-serif;
153
+ --font-body: 'Hanken Grotesk', system-ui, sans-serif;
154
+ --display-weight: 400;
155
+ --display-transform: uppercase;
156
+ --display-tracking: .04em;
157
+ --display-line: .92;
158
+ --display-stroke: 0;
159
+
160
+ --page-bg: radial-gradient(120% 80% at 50% -10%, #1a1a1f 0%, #0a0a0b 55%);
161
+ --card-bg: #121214;
162
+ --card-radius: 26px;
163
+ --card-border-width: 6px;
164
+ --card-border-color: #f6a81d;
165
+ --card-shadow: 0 30px 70px rgba(0,0,0,.55), 0 0 0 1px rgba(0,0,0,.6);
166
+ --card-texture: radial-gradient(rgba(255,255,255,.035) 1px, transparent 1.4px);
167
+ --card-max: 620px;
168
+
169
+ --panel-bg: #1b1b1f;
170
+ --panel-border-color: rgba(255,255,255,.12);
171
+ --field-bg: #f1ecdd; /* cream input plates */
172
+ --field-border-color: rgba(0,0,0,.15);
173
+ --field-fg: #1a1a1a; /* dark ink on cream plates */
174
+ --field-fg-muted: #8a8268;
175
+
176
+ --heading-color: #f6a81d; /* gold display headings */
177
+ --body-color: #ededf0;
178
+ --muted-color: #a6a6ad;
179
+ --faint-color: #6f6f77;
180
+ --on-primary: #1a1208;
181
+
182
+ --accent: #2cb4e8;
183
+ --accent-soft: rgba(44,180,232,.12);
184
+ --accent-ink: #2cb4e8;
185
+ --primary: #f6a81d;
186
+ --primary-edge: #b9790a;
187
+ --primary-text: #1a1208;
188
+ --primary-radius: 16px;
189
+ --primary-shadow: 0 8px 0 var(--primary-edge), 0 16px 30px -8px rgba(246,168,29,.5);
190
+
191
+ --success: #74b62e;
192
+ --reward: #f6a81d;
193
+ --quest-title-color: #e23a2c;
194
+ --banner-bg: #f1ecdd;
195
+ --banner-text: #1a1a1a;
196
+
197
+ --control-radius: 12px;
198
+ --chip-radius: 12px;
199
+ --control-border-color: rgba(255,255,255,.16);
200
+ --control-shadow: 0 6px 18px rgba(0,0,0,.35);
201
+
202
+ --is-bg: #1b1b1f;
203
+ }
204
+
205
+ /* ============================================================================
206
+ THEME 3 — ATELIER (parchment questbook, serif, ornate gold, calm)
207
+ ========================================================================== */
208
+ [data-theme="atelier"] {
209
+ --font-display: 'Playfair Display', Georgia, serif;
210
+ --font-body: 'Lora', Georgia, serif;
211
+ --display-weight: 800;
212
+ --display-transform: none;
213
+ --display-tracking: 0;
214
+ --display-line: 1.05;
215
+ --display-stroke: 0;
216
+
217
+ --page-bg:
218
+ radial-gradient(120% 80% at 50% -10%, rgba(255,250,238,.9), transparent 60%),
219
+ radial-gradient(100% 60% at 100% 110%, rgba(214,196,156,.35), transparent 55%),
220
+ radial-gradient(80% 50% at 0% 100%, rgba(214,196,156,.25), transparent 55%),
221
+ #f3ead6;
222
+ --card-bg: #f8f1e1;
223
+ --card-radius: 22px;
224
+ --card-border-width: 1px;
225
+ --card-border-color: #e3d6b8;
226
+ --card-shadow: 0 2px 4px rgba(70,50,20,.12), 0 18px 40px -16px rgba(70,50,20,.40);
227
+ --card-texture:
228
+ radial-gradient(140% 90% at 50% 0%, rgba(255,251,242,.7), transparent 55%),
229
+ radial-gradient(100% 70% at 100% 100%, rgba(220,202,162,.25), transparent 60%);
230
+ --card-max: 600px;
231
+
232
+ --panel-bg: #f1e7cf;
233
+ --panel-border-color: #e3d6b8;
234
+ --field-bg: #fcf8ee;
235
+ --field-border-color: #d4c39c;
236
+ --field-fg: #574b39;
237
+ --field-fg-muted: #b0a286;
238
+
239
+ --heading-color: #2d2417;
240
+ --body-color: #574b39;
241
+ --muted-color: #8c7e66;
242
+ --faint-color: #b0a286;
243
+ --on-primary: #f4efd9;
244
+
245
+ --accent: #c8a25c;
246
+ --accent-soft: #f1e4c5;
247
+ --accent-ink: #8a6c3c;
248
+ --primary: linear-gradient(180deg, #4e6038 0%, #43542f 100%);
249
+ --primary-edge: #283318;
250
+ --primary-text: #f4efd9;
251
+ --primary-radius: 12px;
252
+ --primary-shadow: 0 2px 0 var(--primary-edge), 0 8px 18px -4px rgba(40,55,25,.5);
253
+
254
+ --success: #2e9e6a;
255
+ --reward: #e5b23c;
256
+ --quest-title-color: #573b7e;
257
+ --banner-bg: transparent;
258
+ --banner-text: #2d2417;
259
+
260
+ --control-radius: 12px;
261
+ --chip-radius: 12px;
262
+ --control-border-color: #d4c39c;
263
+ --control-shadow: 0 1px 2px rgba(80,60,30,.05), 0 8px 24px -12px rgba(80,60,30,.18);
264
+
265
+ --is-bg: #f1e7cf;
266
+ }
267
+
268
+ /* ============================================================================
269
+ FLOURISHES — theme-scoped decoration on the SHARED DOM.
270
+ These do not change structure; they layer ornament that flat tokens cannot.
271
+ ========================================================================== */
272
+
273
+ /* ---- STORYBOOK: chunky candy buttons, dashed dividers, sunny chips ---- */
274
+ [data-theme="storybook"] .divider { border: none; border-top: 2px dashed var(--field-border-color); }
275
+ [data-theme="storybook"] .btn-primary:active { transform: translateY(5px); box-shadow: 0 2px 0 var(--primary-edge), 0 6px 12px -8px rgba(233,56,45,.5); }
276
+ [data-theme="storybook"] .quest-headline { background: linear-gradient(180deg,#fbf3dd,#f4e8c8 55%,#e8d6a8); border-radius: 14px; padding: 22px 26px; box-shadow: inset 0 2px 0 rgba(255,255,255,.6), inset 0 -10px 22px -10px rgba(150,120,60,.35), 0 14px 26px -14px rgba(80,60,20,.5); }
277
+ [data-theme="storybook"] .tone-chip[aria-pressed="true"] { border-color: var(--success); background: var(--accent-soft); }
278
+ [data-theme="storybook"] .banner { border-radius: 999px; }
279
+
280
+ /* ---- COMIC: halftone, black outlines, speech bubble, burst CTA ---- */
281
+ [data-theme="comic"] .halftone { background-image: radial-gradient(rgba(0,0,0,.20) 1.3px, transparent 1.6px); background-size: 8px 8px; }
282
+ [data-theme="comic"] .card-inner > * { color: inherit; }
283
+ [data-theme="comic"] .btn-primary {
284
+ -webkit-text-stroke: 1px rgba(0,0,0,.25); paint-order: stroke fill;
285
  }
286
+ [data-theme="comic"] .btn-primary:active { transform: translateY(4px); box-shadow: 0 4px 0 var(--primary-edge), 0 8px 14px -8px rgba(0,0,0,.4); }
287
+ [data-theme="comic"] .tone-chip { border-width: 2px; }
288
+ [data-theme="comic"] .tone-chip[aria-pressed="true"] { border-color: var(--reward); background: var(--accent-soft); box-shadow: 0 0 0 2px var(--reward) inset; }
289
+ /* cream banner with torn comic edge */
290
+ [data-theme="comic"] .banner {
291
+ transform: rotate(-1.2deg);
292
+ background-image: radial-gradient(rgba(0,0,0,.10) 1.1px, transparent 1.4px);
293
+ background-size: 8px 8px;
294
+ clip-path: polygon(0 14%, 6% 0, 22% 10%, 40% 2%, 62% 11%, 82% 1%, 96% 12%, 100% 4%, 99% 84%, 92% 100%, 74% 90%, 52% 99%, 30% 89%, 12% 100%, 3% 88%, 0 96%);
295
+ }
296
+ /* quest headline becomes a speech bubble */
297
+ [data-theme="comic"] .quest-headline {
298
+ background: var(--banner-bg);
299
+ color: var(--banner-text);
300
+ padding: 26px 30px 30px;
301
+ filter: drop-shadow(0 8px 16px rgba(0,0,0,.4));
302
+ clip-path: polygon(3% 18%, 12% 6%, 22% 16%, 33% 2%, 45% 14%, 57% 0%, 68% 13%, 80% 3%, 90% 15%, 99% 8%, 96% 30%, 100% 44%, 95% 58%, 100% 72%, 90% 80%, 94% 96%, 78% 90%, 70% 100%, 60% 90%, 50% 99%, 40% 89%, 30% 99%, 21% 88%, 10% 96%, 6% 80%, 0% 66%, 4% 50%, 0% 34%);
303
+ }
304
+ [data-theme="comic"] .quest-headline .quest-title { -webkit-text-stroke: 2px #1a1a1a; paint-order: stroke fill; }
305
+ [data-theme="comic"] .divider { border: none; border-top: 1px solid var(--panel-border-color); }
306
+ /* ---- ATELIER: ornate gold inset frame, diamond dividers, serif calm ---- */
307
+ [data-theme="atelier"] .card.ornate {
308
+ box-shadow: var(--card-shadow), inset 0 0 0 2px var(--accent), inset 0 0 0 3px var(--card-bg), inset 0 0 0 4px rgba(200,162,92,.4);
309
+ }
310
+ [data-theme="atelier"] .card.ornate .corner-flourish { display: block; }
311
+ [data-theme="atelier"] .divider {
312
+ border: none; height: 1px; background: var(--field-border-color);
313
+ position: relative; overflow: visible;
314
+ }
315
+ [data-theme="atelier"] .divider::after {
316
+ content: ""; position: absolute; left: 50%; top: 50%;
317
+ width: 9px; height: 9px; transform: translate(-50%,-50%) rotate(45deg);
318
+ background: var(--accent); box-shadow: 0 0 0 3px var(--card-bg);
319
+ }
320
+ [data-theme="atelier"] .quest-headline { background: linear-gradient(180deg, #fcf7ea, #f6eedb); border-radius: 14px; padding: clamp(20px,3.5vw,30px); box-shadow: 0 -2px 0 rgba(255,255,255,.6), 0 10px 30px -10px rgba(70,50,20,.35), inset 0 0 0 1px rgba(180,150,100,.18); }
321
+ [data-theme="atelier"] .tone-chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
322
+ [data-theme="atelier"] .banner { border-radius: 12px; }
323
+
324
+ /* corner flourishes are hidden unless the atelier ornate card shows them */
325
+ .corner-flourish { display: none; }